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,
+ ]);
+}