Merge pull request #162 from carlobortolan/main
Add fullscreen image view with viewerjs controls integration
This commit is contained in:
commit
69b256c35c
@ -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
|
||||
|
||||
|
||||
@ -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
8
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -77,6 +77,12 @@ export default async function PhotoPage({
|
||||
if (!photo) { redirect(PATH_ROOT); }
|
||||
|
||||
return (
|
||||
<PhotoDetailPage {...{ photo, photos, photosGrid }} />
|
||||
<PhotoDetailPage
|
||||
{...{
|
||||
photo,
|
||||
photos,
|
||||
photosGrid,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
69
src/components/FullscreenButton.tsx
Normal file
69
src/components/FullscreenButton.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
68
src/components/image/ImageActions.tsx
Normal file
68
src/components/image/ImageActions.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -15,4 +15,4 @@ export default function ImageLarge(props: ImageProps) {
|
||||
height: Math.round(IMAGE_WIDTH_LARGE / aspectRatio),
|
||||
}} />
|
||||
);
|
||||
};
|
||||
};
|
||||
@ -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}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>({});
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user