Merge pull request #170 from sambecker/fullscreen-image-viewer

Fullscreen zoom controls
This commit is contained in:
Sam Becker 2025-01-26 16:53:22 -06:00 committed by GitHub
commit 11bd05481f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 317 additions and 47 deletions

View File

@ -111,6 +111,7 @@ Application behavior can be changed by configuring the following environment var
#### Display
- `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography)
- `NEXT_PUBLIC_HIDE_ZOOM_CONTROLS = 1` hides fullscreen photo zoom controls
- `NEXT_PUBLIC_HIDE_TAKEN_AT_TIME = 1` hides taken at time from photo meta
- `NEXT_PUBLIC_HIDE_SOCIAL = 1` removes X button from share modal
- `NEXT_PUBLIC_HIDE_FILM_SIMULATIONS = 1` prevents Fujifilm simulations showing up in `/grid` sidebar and CMD-K search results

View File

@ -40,7 +40,8 @@
"sonner": "^1.7.2",
"swr": "^2.3.0",
"ts-exif-parser": "^0.2.2",
"use-debounce": "^10.0.4"
"use-debounce": "^10.0.4",
"viewerjs": "^1.11.7"
},
"devDependencies": {
"@next/bundle-analyzer": "15.1.6",

8
pnpm-lock.yaml generated
View File

@ -104,6 +104,9 @@ importers:
use-debounce:
specifier: ^10.0.4
version: 10.0.4(react@19.0.0)
viewerjs:
specifier: ^1.11.7
version: 1.11.7
devDependencies:
'@next/bundle-analyzer':
specifier: 15.1.6
@ -4234,6 +4237,9 @@ packages:
resolution: {integrity: sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==}
engines: {node: '>=10.12.0'}
viewerjs@1.11.7:
resolution: {integrity: sha512-0JuVqOmL5v1jmEAlG5EBDR3XquxY8DWFQbFMprOXgaBB0F7Q/X9xWdEaQc59D8xzwkdUgXEMSSknTpriq95igg==}
vue@3.4.27:
resolution: {integrity: sha512-8s/56uK6r01r1icG/aEOHqyMVxd1bkYcSe9j8HcKtr/xTOFWvnzIVTehNW+5Yt89f+DLBe4A569pnZLS5HzAMA==}
peerDependencies:
@ -9391,6 +9397,8 @@ snapshots:
'@types/istanbul-lib-coverage': 2.0.6
convert-source-map: 2.0.0
viewerjs@1.11.7: {}
vue@3.4.27(typescript@5.7.3):
dependencies:
'@vue/compiler-dom': 3.4.27

View File

@ -23,6 +23,7 @@ import ShareModals from '@/share/ShareModals';
import '../site/globals.css';
import '../site/sonner.css';
import '../site/viewerjs.css';
const ibmPlexMono = IBM_Plex_Mono({
subsets: ['latin'],

View File

@ -9,6 +9,7 @@ import AnimateItems from './AnimateItems';
import { PATH_ROOT } from '@/site/paths';
import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
import useMetaThemeColor from '@/site/useMetaThemeColor';
import useEscapeHandler from '@/photo/useEscapeHandler';
export default function Modal({
onClosePath,
@ -55,6 +56,8 @@ export default function Modal({
},
});
useEscapeHandler(onClose, true);
return (
<motion.div
className={clsx(

View File

@ -92,6 +92,7 @@ export default function CommandKClient({
selectedPhotoIds,
setSelectedPhotoIds,
isGridHighDensity,
areZoomControlsShown,
arePhotosMatted,
shouldShowBaselineGrid,
shouldDebugImageFallbacks,
@ -99,6 +100,7 @@ export default function CommandKClient({
setShouldRespondToKeyboardCommands,
setShouldShowBaselineGrid,
setIsGridHighDensity,
setAreZoomControlsShown,
setArePhotosMatted,
setShouldDebugImageFallbacks,
} = useAppState();
@ -250,6 +252,10 @@ export default function CommandKClient({
heading: 'Debug Tools',
accessory: <RiToolsFill size={16} className="translate-x-[-1px]" />,
items: [{
label: 'Toggle Zoom Controls',
action: () => setAreZoomControlsShown?.(prev => !prev),
annotation: areZoomControlsShown ? <FaCheck size={12} /> : undefined,
}, {
label: 'Toggle Photo Matting',
action: () => setArePhotosMatted?.(prev => !prev),
annotation: arePhotosMatted ? <FaCheck size={12} /> : undefined,

View File

@ -0,0 +1,81 @@
import { useAppState } from '@/state/AppState';
import useKeydownHandler from '@/utility/useKeydownHandler';
import { RefObject, useCallback, useEffect, useRef } from 'react';
import Viewer from 'viewerjs';
const EVENT_SHOWN = 'shown';
const EVENT_HIDDEN = 'hidden';
export default function useImageZoomControls(
imageRef: RefObject<HTMLDivElement | null>,
isEnabled?: boolean,
shouldExpandOnFKeydown?: boolean,
) {
const viewerRef = useRef<Viewer | null>(null);
const { setShouldRespondToKeyboardCommands } = useAppState();
useEffect(() => {
if (imageRef.current && isEnabled) {
viewerRef.current = new Viewer(imageRef.current, {
inline: false,
button: true,
navbar: false,
title: false,
toolbar: {
zoomIn: 1,
reset: 2,
zoomOut: 3,
},
});
return () => {
viewerRef.current?.destroy();
};
}
}, [imageRef, isEnabled]);
const open = useCallback(() => {
viewerRef.current?.show();
}, [viewerRef]);
const close = useCallback(() => {
viewerRef.current?.hide();
}, [viewerRef]);
// On shown, disable keyboard commands
const onShown = useCallback(() =>
setShouldRespondToKeyboardCommands?.(false),
[setShouldRespondToKeyboardCommands]);
useEffect(() => {
const imageRefCurrent = imageRef.current;
imageRefCurrent?.addEventListener(EVENT_SHOWN, onShown);
return () => {
imageRefCurrent?.removeEventListener(EVENT_SHOWN, onShown);
};
}, [imageRef, onShown]);
// On hidden, reenable keyboard commands
const onHide = useCallback(() =>
setShouldRespondToKeyboardCommands?.(true),
[setShouldRespondToKeyboardCommands]);
useEffect(() => {
const imageRefCurrent = imageRef.current;
imageRefCurrent?.addEventListener(EVENT_HIDDEN, onHide);
return () => {
imageRefCurrent?.removeEventListener(EVENT_HIDDEN, onHide);
};
}, [imageRef, onHide]);
// On 'F' keydown, toggle fullscreen
const handleKeyDown = useCallback(() => {
if (shouldExpandOnFKeydown) {
viewerRef.current?.show();
}
}, [shouldExpandOnFKeydown]);
useKeydownHandler(handleKeyDown, ['F']);
return {
open,
close,
};
}

View File

@ -110,7 +110,6 @@ export default function PhotoDetailPage({
shouldShareTag={tag !== undefined}
shouldShareCamera={camera !== undefined}
shouldShareSimulation={simulation !== undefined}
shouldScrollOnShare={false}
includeFavoriteInAdminMenu={includeFavoriteInAdminMenu}
/>,
]}

View File

@ -1,32 +1,22 @@
'use client';
import { getEscapePath } from '@/site/paths';
import { useAppState } from '@/state/AppState';
import { useRouter, usePathname } from 'next/navigation';
import { useEffect } from 'react';
const LISTENER_KEYUP = 'keyup';
import { useCallback } from 'react';
import useEscapeHandler from './useEscapeHandler';
export default function PhotoEscapeHandler() {
const router = useRouter();
const pathname = usePathname();
const { shouldRespondToKeyboardCommands } = useAppState();
const escapePath = getEscapePath(pathname);
useEffect(() => {
if (shouldRespondToKeyboardCommands) {
const onKeyUp = (e: KeyboardEvent) => {
if (e.key?.toUpperCase() === 'ESCAPE' && escapePath) {
router.push(escapePath, { scroll: false });
};
};
window.addEventListener(LISTENER_KEYUP, onKeyUp);
return () => window.removeEventListener(LISTENER_KEYUP, onKeyUp);
}
}, [shouldRespondToKeyboardCommands, router, escapePath]);
const escapeHandler = useCallback(() => {
if (escapePath) { router.push(escapePath, { scroll: false }); }
}, [escapePath, router]);
useEscapeHandler(escapeHandler);
return null;
}

View File

@ -36,6 +36,9 @@ import { useRef } from 'react';
import useOnVisible from '@/utility/useOnVisible';
import PhotoDate from './PhotoDate';
import { useAppState } from '@/state/AppState';
import useImageZoomControls from '@/components/image/useImageZoomControls';
import { LuZoomIn } from 'react-icons/lu';
import LoaderButton from '@/components/primitives/LoaderButton';
export default function PhotoLarge({
photo,
@ -49,6 +52,8 @@ export default function PhotoLarge({
showTitleAsH1,
showCamera = true,
showSimulation = true,
showZoomControls: showZoomControlsProp = true,
shouldZoomOnFKeydown = true,
shouldShare = true,
shouldShareTag,
shouldShareCamera,
@ -68,16 +73,26 @@ export default function PhotoLarge({
showTitleAsH1?: boolean
showCamera?: boolean
showSimulation?: boolean
showZoomControls?: boolean
shouldZoomOnFKeydown?: boolean
shouldShare?: boolean
shouldShareTag?: boolean
shouldShareCamera?: boolean
shouldShareSimulation?: boolean
shouldShareFocalLength?: boolean
shouldScrollOnShare?: boolean
includeFavoriteInAdminMenu?: boolean
onVisible?: () => void
}) {
const ref = useRef<HTMLDivElement>(null);
const refZoomControlsContainer = useRef<HTMLDivElement>(null);
const {
areZoomControlsShown,
arePhotosMatted,
isUserSignedIn,
} = useAppState();
const showZoomControls = showZoomControlsProp && areZoomControlsShown;
const tags = sortTags(photo.tags, primaryTag);
@ -89,7 +104,11 @@ export default function PhotoLarge({
useOnVisible(ref, onVisible);
const { arePhotosMatted, isUserSignedIn } = useAppState();
const { open } = useImageZoomControls(
refZoomControlsContainer,
showZoomControls,
shouldZoomOnFKeydown,
);
const hasTitle =
showTitle &&
@ -125,36 +144,49 @@ export default function PhotoLarge({
}
};
const largePhotoContent =
<div className={clsx(
arePhotosMatted && 'flex items-center justify-center',
// Always specify height to ensure fallback doesn't collapse
arePhotosMatted && 'h-[90%]',
arePhotosMatted && matteContentWidthForAspectRatio(),
)}>
<div
ref={refZoomControlsContainer}
className={clsx('h-full', showZoomControls && 'cursor-zoom-in')}
>
<ImageLarge
className={clsx(arePhotosMatted && 'h-full')}
imgClassName={clsx(arePhotosMatted &&
'object-contain w-full h-full')}
alt={altTextForPhoto(photo)}
src={photo.url}
aspectRatio={photo.aspectRatio}
blurDataURL={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
priority={priority}
/>
</div>
</div>;
const largePhotoContainerClassName = clsx(arePhotosMatted &&
'flex items-center justify-center aspect-[3/2] bg-gray-100',
);
return (
<SiteGrid
containerRef={ref}
className={className}
contentMain={
<Link
contentMain={showZoomControls
? <div className={largePhotoContainerClassName}>
{largePhotoContent}
</div>
: <Link
href={pathForPhoto({ photo })}
className={clsx(arePhotosMatted &&
'flex items-center justify-center aspect-[3/2] bg-gray-100',
)}
className={largePhotoContainerClassName}
prefetch={prefetch}
>
<div className={clsx(
arePhotosMatted && 'flex items-center justify-center',
// Always specify height to ensure fallback doesn't collapse
arePhotosMatted && 'h-[90%]',
arePhotosMatted && matteContentWidthForAspectRatio(),
)}>
<ImageLarge
className={clsx(arePhotosMatted && 'h-full')}
imgClassName={clsx(arePhotosMatted &&
'object-contain w-full h-full')}
alt={altTextForPhoto(photo)}
src={photo.url}
aspectRatio={photo.aspectRatio}
blurDataURL={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
priority={priority}
/>
</div>
{largePhotoContent}
</Link>}
contentSide={
<DivDebugBaselineGrid className={clsx(
@ -271,6 +303,7 @@ export default function PhotoLarge({
)}>
{shouldShare &&
<ShareButton
title="Share Photo"
photo={photo}
tag={shouldShareTag ? primaryTag : undefined}
camera={shouldShareCamera ? camera : undefined}
@ -280,6 +313,14 @@ export default function PhotoLarge({
focal={shouldShareFocalLength ? photo.focalLength : undefined}
prefetch={prefetchRelatedLinks}
/>}
{showZoomControls &&
<LoaderButton
title="Open Image Viewer"
icon={<LuZoomIn size={17} />}
onClick={open}
styleAs="link"
className="text-medium"
/>}
{ALLOW_PUBLIC_DOWNLOADS &&
<DownloadButton
className={clsx(

View File

@ -31,6 +31,7 @@ export default function PhotosLarge({
priority={index <= 1}
prefetchRelatedLinks={prefetchFirstPhotoLinks && index === 0}
revalidatePhoto={revalidatePhoto}
shouldZoomOnFKeydown={false}
onVisible={index === photos.length - 1
? onLastPhotoVisible
: undefined}

View File

@ -0,0 +1,12 @@
import useKeydownHandler from '@/utility/useKeydownHandler';
export default function useEscapeHandler(
onEscape?: () => void,
ignoreShouldRespondToKeyboardCommands?: boolean,
) {
useKeydownHandler(
onEscape,
['ESCAPE'],
ignoreShouldRespondToKeyboardCommands,
);
}

View File

@ -11,11 +11,13 @@ import { useRouter } from 'next/navigation';
let prefetchedImage: HTMLImageElement | null = null;
export default function ShareButton({
title,
dim,
prefetch,
className,
...rest
}: {
title?: string
dim?: boolean
prefetch?: boolean
className?: string
@ -35,6 +37,7 @@ export default function ShareButton({
return (
<LoaderButton
title={title}
onClick={() => setShareModalProps?.({ ...rest })}
className={clsx(
className,

View File

@ -4,7 +4,7 @@ import Modal from '@/components/Modal';
import { TbPhotoShare } from 'react-icons/tb';
import { clsx } from 'clsx/lite';
import { BiCopy } from 'react-icons/bi';
import { JSX, ReactNode } from 'react';
import { JSX, ReactNode, useEffect } from 'react';
import { shortenUrl } from '@/utility/url';
import { toastSuccess } from '@/toast';
import { PiXLogo } from 'react-icons/pi';
@ -24,7 +24,15 @@ export default function ShareModal({
socialText: string
children: ReactNode
}) {
const { setShareModalProps } = useAppState();
const {
setShareModalProps,
setShouldRespondToKeyboardCommands,
} = useAppState();
useEffect(() => {
setShouldRespondToKeyboardCommands?.(false);
return () => setShouldRespondToKeyboardCommands?.(true);
}, [setShouldRespondToKeyboardCommands]);
const renderIcon = (
icon: JSX.Element,

View File

@ -60,6 +60,7 @@ export default function SiteChecklistClient({
isBlurEnabled,
// Display
showExifInfo,
showZoomControls,
showTakenAtTimeHidden,
showSocial,
showFilmSimulations,
@ -473,6 +474,15 @@ export default function SiteChecklistClient({
Set environment variable to {'"1"'} to hide EXIF data:
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
</ChecklistRow>
<ChecklistRow
title="Zoom controls"
status={showZoomControls}
optional
>
Set environment variable to {'"1"'} to hide
fullscreen photo zoom controls:
{renderEnvVars(['NEXT_PUBLIC_HIDE_ZOOM_CONTROLS'])}
</ChecklistRow>
<ChecklistRow
title="Show taken at time"
status={showTakenAtTimeHidden}

View File

@ -165,6 +165,8 @@ export const BLUR_ENABLED =
export const SHOW_EXIF_DATA =
process.env.NEXT_PUBLIC_HIDE_EXIF_DATA !== '1';
export const SHOW_ZOOM_CONTROLS =
process.env.NEXT_PUBLIC_HIDE_ZOOM_CONTROLS !== '1';
export const SHOW_TAKEN_AT_TIME =
process.env.NEXT_PUBLIC_HIDE_TAKEN_AT_TIME !== '1';
export const SHOW_SOCIAL =
@ -262,6 +264,7 @@ export const CONFIG_CHECKLIST_STATUS = {
isBlurEnabled: BLUR_ENABLED,
// DISPLAY
showExifInfo: SHOW_EXIF_DATA,
showZoomControls: SHOW_ZOOM_CONTROLS,
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
showSocial: SHOW_SOCIAL,
showFilmSimulations: SHOW_FILM_SIMULATIONS,

60
src/site/viewerjs.css Normal file
View File

@ -0,0 +1,60 @@
@import 'viewerjs/dist/viewer.css';
.viewer-canvas {
background-color: black;
}
.viewer-reset::before {
content: '1:1';
font-size: 13px;
font-weight: 500;
color: #fff;
display: inline-block;
position: relative;
bottom: -9px;
letter-spacing: -2px;
background-image: none;
}
.viewer-close {
display: flex;
align-items: center;
justify-content: center;
top: 20px;
right: 20px;
}
.viewer-close::before {
transform: scale(1.1);
}
.viewer-button {
width: 40px;
height: 40px;
}
.viewer-button::before {
bottom: auto;
left: auto;
}
.viewer-button:focus {
box-shadow: none;
}
.viewer-button:active {
opacity: 0.5;
}
.viewer-toolbar > ul {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0rem 0.25rem 2rem 0.25rem;
}
.viewer-toolbar > ul > li {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
}
.viewer-toolbar > ul > li:focus {
box-shadow: none;
}
.viewer-reset::before {
left:-1px;
}

View File

@ -33,6 +33,8 @@ export interface AppStateContext {
// DEBUG
isGridHighDensity?: boolean
setIsGridHighDensity?: Dispatch<SetStateAction<boolean>>
areZoomControlsShown?: boolean
setAreZoomControlsShown?: Dispatch<SetStateAction<boolean>>
arePhotosMatted?: boolean
setArePhotosMatted?: Dispatch<SetStateAction<boolean>>
shouldDebugImageFallbacks?: boolean

View File

@ -6,7 +6,11 @@ import { AnimationConfig } from '@/components/AnimateItems';
import usePathnames from '@/utility/usePathnames';
import { getAuthAction } from '@/auth/actions';
import useSWR from 'swr';
import { HIGH_DENSITY_GRID, MATTE_PHOTOS } from '@/site/config';
import {
HIGH_DENSITY_GRID,
MATTE_PHOTOS,
SHOW_ZOOM_CONTROLS,
} from '@/site/config';
import { getPhotosHiddenMetaCachedAction } from '@/photo/actions';
import { ShareModalProps } from '@/share';
import { storeTimezoneCookie } from '@/utility/timezone';
@ -46,6 +50,8 @@ export default function AppStateProvider({
// DEBUG
const [isGridHighDensity, setIsGridHighDensity] =
useState(HIGH_DENSITY_GRID);
const [areZoomControlsShown, setAreZoomControlsShown] =
useState(SHOW_ZOOM_CONTROLS);
const [arePhotosMatted, setArePhotosMatted] =
useState(MATTE_PHOTOS);
const [shouldDebugImageFallbacks, setShouldDebugImageFallbacks] =
@ -116,6 +122,8 @@ export default function AppStateProvider({
// DEBUG
isGridHighDensity,
setIsGridHighDensity,
areZoomControlsShown,
setAreZoomControlsShown,
arePhotosMatted,
setArePhotosMatted,
shouldDebugImageFallbacks,

View File

@ -0,0 +1,32 @@
import { useAppState } from '@/state/AppState';
import { useCallback, useEffect } from 'react';
const LISTENER_KEYDOWN = 'keydown';
export default function useKeydownHandler(
onKeydown?: (e: KeyboardEvent) => void,
keys: string[] = [],
ignoreShouldRespondToKeyboardCommands?: boolean,
) {
const { shouldRespondToKeyboardCommands } = useAppState();
const onKeyUp = useCallback((e: KeyboardEvent) => {
if (keys.some(key => key.toUpperCase() === e.key?.toUpperCase())) {
onKeydown?.(e);
}
}, [onKeydown, keys]);
useEffect(() => {
if (
shouldRespondToKeyboardCommands ||
ignoreShouldRespondToKeyboardCommands
) {
window.addEventListener(LISTENER_KEYDOWN, onKeyUp);
return () => window.removeEventListener(LISTENER_KEYDOWN, onKeyUp);
}
}, [
shouldRespondToKeyboardCommands,
ignoreShouldRespondToKeyboardCommands,
onKeyUp,
]);
}