diff --git a/README.md b/README.md index 6c920595..8bf08af1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index 93fb2455..4f3cf89a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7347f4c..890b8eb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/layout.tsx b/src/app/layout.tsx index feeadc9a..fddaab5f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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'], diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index ac0f7526..20dd1482 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -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 ( , items: [{ + label: 'Toggle Zoom Controls', + action: () => setAreZoomControlsShown?.(prev => !prev), + annotation: areZoomControlsShown ? : undefined, + }, { label: 'Toggle Photo Matting', action: () => setArePhotosMatted?.(prev => !prev), annotation: arePhotosMatted ? : undefined, diff --git a/src/components/image/useImageZoomControls.ts b/src/components/image/useImageZoomControls.ts new file mode 100644 index 00000000..80e7a7c5 --- /dev/null +++ b/src/components/image/useImageZoomControls.ts @@ -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, + isEnabled?: boolean, + shouldExpandOnFKeydown?: boolean, +) { + const viewerRef = useRef(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, + }; +} diff --git a/src/photo/PhotoDetailPage.tsx b/src/photo/PhotoDetailPage.tsx index beb67cf4..2a0ea013 100644 --- a/src/photo/PhotoDetailPage.tsx +++ b/src/photo/PhotoDetailPage.tsx @@ -110,7 +110,6 @@ export default function PhotoDetailPage({ shouldShareTag={tag !== undefined} shouldShareCamera={camera !== undefined} shouldShareSimulation={simulation !== undefined} - shouldScrollOnShare={false} includeFavoriteInAdminMenu={includeFavoriteInAdminMenu} />, ]} diff --git a/src/photo/PhotoEscapeHandler.tsx b/src/photo/PhotoEscapeHandler.tsx index b8da15c9..afaf1019 100644 --- a/src/photo/PhotoEscapeHandler.tsx +++ b/src/photo/PhotoEscapeHandler.tsx @@ -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; } diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 59fc577a..237ba6e4 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -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(null); + const refZoomControlsContainer = useRef(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 = +
+
+ +
+
; + + const largePhotoContainerClassName = clsx(arePhotosMatted && + 'flex items-center justify-center aspect-[3/2] bg-gray-100', + ); + return ( + {largePhotoContent} + + : -
- -
+ {largePhotoContent} } contentSide={ {shouldShare && } + {showZoomControls && + } + onClick={open} + styleAs="link" + className="text-medium" + />} {ALLOW_PUBLIC_DOWNLOADS && void, + ignoreShouldRespondToKeyboardCommands?: boolean, +) { + useKeydownHandler( + onEscape, + ['ESCAPE'], + ignoreShouldRespondToKeyboardCommands, + ); +} diff --git a/src/share/ShareButton.tsx b/src/share/ShareButton.tsx index f3a4d604..bc95e4af 100644 --- a/src/share/ShareButton.tsx +++ b/src/share/ShareButton.tsx @@ -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 ( setShareModalProps?.({ ...rest })} className={clsx( className, diff --git a/src/share/ShareModal.tsx b/src/share/ShareModal.tsx index 97d42c92..1b4b0379 100644 --- a/src/share/ShareModal.tsx +++ b/src/share/ShareModal.tsx @@ -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, diff --git a/src/site/SiteChecklistClient.tsx b/src/site/SiteChecklistClient.tsx index 3b2ee2c9..38861a81 100644 --- a/src/site/SiteChecklistClient.tsx +++ b/src/site/SiteChecklistClient.tsx @@ -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'])} + + Set environment variable to {'"1"'} to hide + fullscreen photo zoom controls: + {renderEnvVars(['NEXT_PUBLIC_HIDE_ZOOM_CONTROLS'])} + 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; +} diff --git a/src/state/AppState.ts b/src/state/AppState.ts index 69975e9e..26843036 100644 --- a/src/state/AppState.ts +++ b/src/state/AppState.ts @@ -33,6 +33,8 @@ export interface AppStateContext { // DEBUG isGridHighDensity?: boolean setIsGridHighDensity?: Dispatch> + areZoomControlsShown?: boolean + setAreZoomControlsShown?: Dispatch> arePhotosMatted?: boolean setArePhotosMatted?: Dispatch> shouldDebugImageFallbacks?: boolean diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index e8619fec..39e7382e 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -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, diff --git a/src/utility/useKeydownHandler.ts b/src/utility/useKeydownHandler.ts new file mode 100644 index 00000000..b6f2af40 --- /dev/null +++ b/src/utility/useKeydownHandler.ts @@ -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, + ]); +}