Merge pull request #162 from carlobortolan/main

Add fullscreen image view with viewerjs controls integration
This commit is contained in:
Sam Becker 2025-01-25 19:52:03 -06:00 committed by GitHub
commit 69b256c35c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 198 additions and 13 deletions

View File

@ -127,6 +127,7 @@ Application behavior can be changed by configuring the following environment var
- `NEXT_PUBLIC_GRID_ASPECT_RATIO = 1.5` sets aspect ratio for grid tiles (defaults to `1`—setting to `0` removes the constraint)
- `NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS = 1` ensures large thumbnails on photo grid views
- `NEXT_PUBLIC_OG_TEXT_ALIGNMENT = BOTTOM` keeps OG image text bottom aligned (default is top)
- `NEXT_PUBLIC_IMAGE_ACTIONS = 1` enables fullscreen and zoom actions when clicking on an image
## Alternate storage providers

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

@ -77,6 +77,12 @@ export default async function PhotoPage({
if (!photo) { redirect(PATH_ROOT); }
return (
<PhotoDetailPage {...{ photo, photos, photosGrid }} />
<PhotoDetailPage
{...{
photo,
photos,
photosGrid,
}}
/>
);
}

View File

@ -0,0 +1,69 @@
'use client';
import { useEffect, useCallback, RefObject } from 'react';
import { MdFullscreen, MdFullscreenExit } from 'react-icons/md';
import { clsx } from 'clsx/lite';
import { useAppState } from '@/state/AppState';
import LoaderButton from './primitives/LoaderButton';
export default function FullscreenButton({
className,
imageRef,
}: {
className?: string;
imageRef: RefObject<HTMLDivElement | null>;
}) {
const { isFullscreen, setIsFullscreen, isCommandKOpen } = useAppState();
// Toggle fullscreen mode
const toggleFullscreen = useCallback(async () => {
if (!document.fullscreenElement) {
if (isCommandKOpen) return;
await imageRef.current?.requestFullscreen();
setIsFullscreen?.(true);
} else {
await document.exitFullscreen();
setIsFullscreen?.(false);
}
}, [imageRef, setIsFullscreen, isCommandKOpen]);
// Toggle fullscreen on 'f' key press
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (event.key === 'f' || event.key === 'F') {
toggleFullscreen();
}
}, [toggleFullscreen]);
// Handle fullscreen change (e.g, switching tabs in fullscreen mode)
const handleFullscreenChange = useCallback(() => {
if (!document.fullscreenElement) {
setIsFullscreen?.(false);
}
}, [setIsFullscreen]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('fullscreenchange', handleFullscreenChange);
};
}, [handleKeyDown, handleFullscreenChange]);
return (
<LoaderButton
title="Toggle Fullscreen"
className={clsx(
className,
'text-medium absolute bottom-2 right-2 bg-white p-2 rounded',
'hidden md:block',
)}
icon={isFullscreen ? <MdFullscreenExit size={18} />
: <MdFullscreen size={18} />}
spinnerColor="light-gray"
styleAs="link"
onClick={toggleFullscreen}
/>
);
}

View File

@ -0,0 +1,68 @@
import { useEffect, useRef } from 'react';
import Viewer from 'viewerjs';
import 'viewerjs/dist/viewer.css';
import { clsx } from 'clsx/lite';
import FullscreenButton from '../FullscreenButton';
export default function ImageActions({
children,
enableImageActions = false,
className,
}: {
children: React.ReactNode;
enableImageActions?: boolean;
className?: string;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<Viewer | null>(null);
useEffect(() => {
if (containerRef.current && enableImageActions) {
viewerRef.current = new Viewer(containerRef.current, {
inline: false,
button: true,
navbar: false,
title: false,
toolbar: {
zoomIn: 1,
zoomOut: 1,
reset: 1,
tooltip: 1,
},
});
return () => {
viewerRef.current?.destroy();
};
}
}, [enableImageActions]);
return (
<>
<style jsx global>{`
.viewer-canvas {
background-color: black !important;
}
.viewer-reset::before {
content: '1:1';
font-size: 13px;
font-weight: 600;
color: #fff;
display: inline-block;
position: relative;
bottom: -9px;
letter-spacing: -2px;
background-image: none;
}
`}</style>
<div
className={clsx(className, enableImageActions && 'cursor-zoom-in')}
ref={containerRef}
>
{children}
{enableImageActions && (
<FullscreenButton imageRef={containerRef} />
)}
</div>
</>
);
}

View File

@ -15,4 +15,4 @@ export default function ImageLarge(props: ImageProps) {
height: Math.round(IMAGE_WIDTH_LARGE / aspectRatio),
}} />
);
};
};

View File

@ -11,6 +11,7 @@ import HiddenHeader from '@/tag/HiddenHeader';
import FocalLengthHeader from '@/focal/FocalLengthHeader';
import PhotoHeader from './PhotoHeader';
import { JSX } from 'react';
import { IMAGE_ACTIONS_ENABLED } from '@/site/config';
export default function PhotoDetailPage({
photo,
@ -112,6 +113,7 @@ export default function PhotoDetailPage({
shouldShareSimulation={simulation !== undefined}
shouldScrollOnShare={false}
includeFavoriteInAdminMenu={includeFavoriteInAdminMenu}
enableImageActions={IMAGE_ACTIONS_ENABLED}
/>,
]}
/>

View File

@ -36,6 +36,7 @@ import { useRef } from 'react';
import useOnVisible from '@/utility/useOnVisible';
import PhotoDate from './PhotoDate';
import { useAppState } from '@/state/AppState';
import ImageActions from '@/components/image/ImageActions';
export default function PhotoLarge({
photo,
@ -56,6 +57,7 @@ export default function PhotoLarge({
shouldShareFocalLength,
includeFavoriteInAdminMenu,
onVisible,
enableImageActions = false,
}: {
photo: Photo
className?: string
@ -76,6 +78,7 @@ export default function PhotoLarge({
shouldScrollOnShare?: boolean
includeFavoriteInAdminMenu?: boolean
onVisible?: () => void
enableImageActions?: boolean
}) {
const ref = useRef<HTMLDivElement>(null);
@ -143,17 +146,22 @@ export default function PhotoLarge({
arePhotosMatted && 'h-[90%]',
arePhotosMatted && matteContentWidthForAspectRatio(),
)}>
<ImageLarge
className={clsx(arePhotosMatted && 'h-full')}
imgClassName={clsx(arePhotosMatted &&
<ImageActions
enableImageActions={enableImageActions}
className="flex relative items-center justify-center h-full"
>
<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}
/>
alt={altTextForPhoto(photo)}
src={photo.url}
aspectRatio={photo.aspectRatio}
blurDataURL={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
priority={priority}
/>
</ImageActions>
</div>
</Link>}
contentSide={

View File

@ -78,6 +78,7 @@ export default function SiteChecklistClient({
isPublicApiEnabled,
isPriorityOrderEnabled,
isOgTextBottomAligned,
isImageActionsEnabled,
// Misc
baseUrl,
commitSha,
@ -612,6 +613,15 @@ export default function SiteChecklistClient({
keep OG image text bottom aligned (default is {'"top"'}):
{renderEnvVars(['NEXT_PUBLIC_OG_TEXT_ALIGNMENT'])}
</ChecklistRow>
<ChecklistRow
title="Enable image actions"
status={isImageActionsEnabled}
optional
>
Set environment variable to {'"1"'} to enable fullscreen and zoom
actions when clicking on an image:
{renderEnvVars(['NEXT_PUBLIC_IMAGE_ACTIONS'])}
</ChecklistRow>
</Checklist>
</>}
</div>

View File

@ -205,6 +205,8 @@ export const PRIORITY_ORDER_ENABLED =
process.env.NEXT_PUBLIC_IGNORE_PRIORITY_ORDER !== '1';
export const OG_TEXT_BOTTOM_ALIGNMENT =
(process.env.NEXT_PUBLIC_OG_TEXT_ALIGNMENT ?? '').toUpperCase() === 'BOTTOM';
export const IMAGE_ACTIONS_ENABLED =
process.env.NEXT_PUBLIC_IMAGE_ACTIONS === '1';
// INTERNAL
@ -281,6 +283,7 @@ export const CONFIG_CHECKLIST_STATUS = {
isPublicApiEnabled: PUBLIC_API_ENABLED,
isPriorityOrderEnabled: PRIORITY_ORDER_ENABLED,
isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT,
isImageActionsEnabled: IMAGE_ACTIONS_ENABLED,
// MISC
baseUrl: BASE_URL,
commitSha: VERCEL_GIT_COMMIT_SHA_SHORT,

View File

@ -39,6 +39,9 @@ export interface AppStateContext {
setShouldDebugImageFallbacks?: Dispatch<SetStateAction<boolean>>
shouldShowBaselineGrid?: boolean
setShouldShowBaselineGrid?: Dispatch<SetStateAction<boolean>>
// FULLSCREEN
isFullscreen?: boolean
setIsFullscreen?: Dispatch<SetStateAction<boolean>>
}
export const AppStateContext = createContext<AppStateContext>({});

View File

@ -52,6 +52,9 @@ export default function AppStateProvider({
useState(false);
const [shouldShowBaselineGrid, setShouldShowBaselineGrid] =
useState(false);
// FULLSCREEN
const [isFullscreen, setIsFullscreen] =
useState(false);
const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []);
@ -122,6 +125,9 @@ export default function AppStateProvider({
setShouldDebugImageFallbacks,
shouldShowBaselineGrid,
setShouldShowBaselineGrid,
// FULLSCREEN
isFullscreen,
setIsFullscreen,
}}
>
{children}