Merge pull request #170 from sambecker/fullscreen-image-viewer
Fullscreen zoom controls
This commit is contained in:
commit
11bd05481f
@ -111,6 +111,7 @@ Application behavior can be changed by configuring the following environment var
|
|||||||
|
|
||||||
#### Display
|
#### 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_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_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_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
|
- `NEXT_PUBLIC_HIDE_FILM_SIMULATIONS = 1` prevents Fujifilm simulations showing up in `/grid` sidebar and CMD-K search results
|
||||||
|
|||||||
@ -40,7 +40,8 @@
|
|||||||
"sonner": "^1.7.2",
|
"sonner": "^1.7.2",
|
||||||
"swr": "^2.3.0",
|
"swr": "^2.3.0",
|
||||||
"ts-exif-parser": "^0.2.2",
|
"ts-exif-parser": "^0.2.2",
|
||||||
"use-debounce": "^10.0.4"
|
"use-debounce": "^10.0.4",
|
||||||
|
"viewerjs": "^1.11.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@next/bundle-analyzer": "15.1.6",
|
"@next/bundle-analyzer": "15.1.6",
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -104,6 +104,9 @@ importers:
|
|||||||
use-debounce:
|
use-debounce:
|
||||||
specifier: ^10.0.4
|
specifier: ^10.0.4
|
||||||
version: 10.0.4(react@19.0.0)
|
version: 10.0.4(react@19.0.0)
|
||||||
|
viewerjs:
|
||||||
|
specifier: ^1.11.7
|
||||||
|
version: 1.11.7
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@next/bundle-analyzer':
|
'@next/bundle-analyzer':
|
||||||
specifier: 15.1.6
|
specifier: 15.1.6
|
||||||
@ -4234,6 +4237,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==}
|
resolution: {integrity: sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==}
|
||||||
engines: {node: '>=10.12.0'}
|
engines: {node: '>=10.12.0'}
|
||||||
|
|
||||||
|
viewerjs@1.11.7:
|
||||||
|
resolution: {integrity: sha512-0JuVqOmL5v1jmEAlG5EBDR3XquxY8DWFQbFMprOXgaBB0F7Q/X9xWdEaQc59D8xzwkdUgXEMSSknTpriq95igg==}
|
||||||
|
|
||||||
vue@3.4.27:
|
vue@3.4.27:
|
||||||
resolution: {integrity: sha512-8s/56uK6r01r1icG/aEOHqyMVxd1bkYcSe9j8HcKtr/xTOFWvnzIVTehNW+5Yt89f+DLBe4A569pnZLS5HzAMA==}
|
resolution: {integrity: sha512-8s/56uK6r01r1icG/aEOHqyMVxd1bkYcSe9j8HcKtr/xTOFWvnzIVTehNW+5Yt89f+DLBe4A569pnZLS5HzAMA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -9391,6 +9397,8 @@ snapshots:
|
|||||||
'@types/istanbul-lib-coverage': 2.0.6
|
'@types/istanbul-lib-coverage': 2.0.6
|
||||||
convert-source-map: 2.0.0
|
convert-source-map: 2.0.0
|
||||||
|
|
||||||
|
viewerjs@1.11.7: {}
|
||||||
|
|
||||||
vue@3.4.27(typescript@5.7.3):
|
vue@3.4.27(typescript@5.7.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/compiler-dom': 3.4.27
|
'@vue/compiler-dom': 3.4.27
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import ShareModals from '@/share/ShareModals';
|
|||||||
|
|
||||||
import '../site/globals.css';
|
import '../site/globals.css';
|
||||||
import '../site/sonner.css';
|
import '../site/sonner.css';
|
||||||
|
import '../site/viewerjs.css';
|
||||||
|
|
||||||
const ibmPlexMono = IBM_Plex_Mono({
|
const ibmPlexMono = IBM_Plex_Mono({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import AnimateItems from './AnimateItems';
|
|||||||
import { PATH_ROOT } from '@/site/paths';
|
import { PATH_ROOT } from '@/site/paths';
|
||||||
import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
|
import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
|
||||||
import useMetaThemeColor from '@/site/useMetaThemeColor';
|
import useMetaThemeColor from '@/site/useMetaThemeColor';
|
||||||
|
import useEscapeHandler from '@/photo/useEscapeHandler';
|
||||||
|
|
||||||
export default function Modal({
|
export default function Modal({
|
||||||
onClosePath,
|
onClosePath,
|
||||||
@ -55,6 +56,8 @@ export default function Modal({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEscapeHandler(onClose, true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@ -92,6 +92,7 @@ export default function CommandKClient({
|
|||||||
selectedPhotoIds,
|
selectedPhotoIds,
|
||||||
setSelectedPhotoIds,
|
setSelectedPhotoIds,
|
||||||
isGridHighDensity,
|
isGridHighDensity,
|
||||||
|
areZoomControlsShown,
|
||||||
arePhotosMatted,
|
arePhotosMatted,
|
||||||
shouldShowBaselineGrid,
|
shouldShowBaselineGrid,
|
||||||
shouldDebugImageFallbacks,
|
shouldDebugImageFallbacks,
|
||||||
@ -99,6 +100,7 @@ export default function CommandKClient({
|
|||||||
setShouldRespondToKeyboardCommands,
|
setShouldRespondToKeyboardCommands,
|
||||||
setShouldShowBaselineGrid,
|
setShouldShowBaselineGrid,
|
||||||
setIsGridHighDensity,
|
setIsGridHighDensity,
|
||||||
|
setAreZoomControlsShown,
|
||||||
setArePhotosMatted,
|
setArePhotosMatted,
|
||||||
setShouldDebugImageFallbacks,
|
setShouldDebugImageFallbacks,
|
||||||
} = useAppState();
|
} = useAppState();
|
||||||
@ -250,6 +252,10 @@ export default function CommandKClient({
|
|||||||
heading: 'Debug Tools',
|
heading: 'Debug Tools',
|
||||||
accessory: <RiToolsFill size={16} className="translate-x-[-1px]" />,
|
accessory: <RiToolsFill size={16} className="translate-x-[-1px]" />,
|
||||||
items: [{
|
items: [{
|
||||||
|
label: 'Toggle Zoom Controls',
|
||||||
|
action: () => setAreZoomControlsShown?.(prev => !prev),
|
||||||
|
annotation: areZoomControlsShown ? <FaCheck size={12} /> : undefined,
|
||||||
|
}, {
|
||||||
label: 'Toggle Photo Matting',
|
label: 'Toggle Photo Matting',
|
||||||
action: () => setArePhotosMatted?.(prev => !prev),
|
action: () => setArePhotosMatted?.(prev => !prev),
|
||||||
annotation: arePhotosMatted ? <FaCheck size={12} /> : undefined,
|
annotation: arePhotosMatted ? <FaCheck size={12} /> : undefined,
|
||||||
|
|||||||
81
src/components/image/useImageZoomControls.ts
Normal file
81
src/components/image/useImageZoomControls.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -110,7 +110,6 @@ export default function PhotoDetailPage({
|
|||||||
shouldShareTag={tag !== undefined}
|
shouldShareTag={tag !== undefined}
|
||||||
shouldShareCamera={camera !== undefined}
|
shouldShareCamera={camera !== undefined}
|
||||||
shouldShareSimulation={simulation !== undefined}
|
shouldShareSimulation={simulation !== undefined}
|
||||||
shouldScrollOnShare={false}
|
|
||||||
includeFavoriteInAdminMenu={includeFavoriteInAdminMenu}
|
includeFavoriteInAdminMenu={includeFavoriteInAdminMenu}
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
|
|||||||
@ -1,32 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { getEscapePath } from '@/site/paths';
|
import { getEscapePath } from '@/site/paths';
|
||||||
import { useAppState } from '@/state/AppState';
|
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { useEffect } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import useEscapeHandler from './useEscapeHandler';
|
||||||
const LISTENER_KEYUP = 'keyup';
|
|
||||||
|
|
||||||
export default function PhotoEscapeHandler() {
|
export default function PhotoEscapeHandler() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
const { shouldRespondToKeyboardCommands } = useAppState();
|
|
||||||
|
|
||||||
const escapePath = getEscapePath(pathname);
|
const escapePath = getEscapePath(pathname);
|
||||||
|
|
||||||
useEffect(() => {
|
const escapeHandler = useCallback(() => {
|
||||||
if (shouldRespondToKeyboardCommands) {
|
if (escapePath) { router.push(escapePath, { scroll: false }); }
|
||||||
const onKeyUp = (e: KeyboardEvent) => {
|
}, [escapePath, router]);
|
||||||
if (e.key?.toUpperCase() === 'ESCAPE' && escapePath) {
|
|
||||||
router.push(escapePath, { scroll: false });
|
useEscapeHandler(escapeHandler);
|
||||||
};
|
|
||||||
};
|
|
||||||
window.addEventListener(LISTENER_KEYUP, onKeyUp);
|
|
||||||
return () => window.removeEventListener(LISTENER_KEYUP, onKeyUp);
|
|
||||||
}
|
|
||||||
}, [shouldRespondToKeyboardCommands, router, escapePath]);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,9 @@ import { useRef } from 'react';
|
|||||||
import useOnVisible from '@/utility/useOnVisible';
|
import useOnVisible from '@/utility/useOnVisible';
|
||||||
import PhotoDate from './PhotoDate';
|
import PhotoDate from './PhotoDate';
|
||||||
import { useAppState } from '@/state/AppState';
|
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({
|
export default function PhotoLarge({
|
||||||
photo,
|
photo,
|
||||||
@ -49,6 +52,8 @@ export default function PhotoLarge({
|
|||||||
showTitleAsH1,
|
showTitleAsH1,
|
||||||
showCamera = true,
|
showCamera = true,
|
||||||
showSimulation = true,
|
showSimulation = true,
|
||||||
|
showZoomControls: showZoomControlsProp = true,
|
||||||
|
shouldZoomOnFKeydown = true,
|
||||||
shouldShare = true,
|
shouldShare = true,
|
||||||
shouldShareTag,
|
shouldShareTag,
|
||||||
shouldShareCamera,
|
shouldShareCamera,
|
||||||
@ -68,16 +73,26 @@ export default function PhotoLarge({
|
|||||||
showTitleAsH1?: boolean
|
showTitleAsH1?: boolean
|
||||||
showCamera?: boolean
|
showCamera?: boolean
|
||||||
showSimulation?: boolean
|
showSimulation?: boolean
|
||||||
|
showZoomControls?: boolean
|
||||||
|
shouldZoomOnFKeydown?: boolean
|
||||||
shouldShare?: boolean
|
shouldShare?: boolean
|
||||||
shouldShareTag?: boolean
|
shouldShareTag?: boolean
|
||||||
shouldShareCamera?: boolean
|
shouldShareCamera?: boolean
|
||||||
shouldShareSimulation?: boolean
|
shouldShareSimulation?: boolean
|
||||||
shouldShareFocalLength?: boolean
|
shouldShareFocalLength?: boolean
|
||||||
shouldScrollOnShare?: boolean
|
|
||||||
includeFavoriteInAdminMenu?: boolean
|
includeFavoriteInAdminMenu?: boolean
|
||||||
onVisible?: () => void
|
onVisible?: () => void
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
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);
|
const tags = sortTags(photo.tags, primaryTag);
|
||||||
|
|
||||||
@ -89,7 +104,11 @@ export default function PhotoLarge({
|
|||||||
|
|
||||||
useOnVisible(ref, onVisible);
|
useOnVisible(ref, onVisible);
|
||||||
|
|
||||||
const { arePhotosMatted, isUserSignedIn } = useAppState();
|
const { open } = useImageZoomControls(
|
||||||
|
refZoomControlsContainer,
|
||||||
|
showZoomControls,
|
||||||
|
shouldZoomOnFKeydown,
|
||||||
|
);
|
||||||
|
|
||||||
const hasTitle =
|
const hasTitle =
|
||||||
showTitle &&
|
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 (
|
return (
|
||||||
<SiteGrid
|
<SiteGrid
|
||||||
containerRef={ref}
|
containerRef={ref}
|
||||||
className={className}
|
className={className}
|
||||||
contentMain={
|
contentMain={showZoomControls
|
||||||
<Link
|
? <div className={largePhotoContainerClassName}>
|
||||||
|
{largePhotoContent}
|
||||||
|
</div>
|
||||||
|
: <Link
|
||||||
href={pathForPhoto({ photo })}
|
href={pathForPhoto({ photo })}
|
||||||
className={clsx(arePhotosMatted &&
|
className={largePhotoContainerClassName}
|
||||||
'flex items-center justify-center aspect-[3/2] bg-gray-100',
|
|
||||||
)}
|
|
||||||
prefetch={prefetch}
|
prefetch={prefetch}
|
||||||
>
|
>
|
||||||
<div className={clsx(
|
{largePhotoContent}
|
||||||
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>
|
|
||||||
</Link>}
|
</Link>}
|
||||||
contentSide={
|
contentSide={
|
||||||
<DivDebugBaselineGrid className={clsx(
|
<DivDebugBaselineGrid className={clsx(
|
||||||
@ -271,6 +303,7 @@ export default function PhotoLarge({
|
|||||||
)}>
|
)}>
|
||||||
{shouldShare &&
|
{shouldShare &&
|
||||||
<ShareButton
|
<ShareButton
|
||||||
|
title="Share Photo"
|
||||||
photo={photo}
|
photo={photo}
|
||||||
tag={shouldShareTag ? primaryTag : undefined}
|
tag={shouldShareTag ? primaryTag : undefined}
|
||||||
camera={shouldShareCamera ? camera : undefined}
|
camera={shouldShareCamera ? camera : undefined}
|
||||||
@ -280,6 +313,14 @@ export default function PhotoLarge({
|
|||||||
focal={shouldShareFocalLength ? photo.focalLength : undefined}
|
focal={shouldShareFocalLength ? photo.focalLength : undefined}
|
||||||
prefetch={prefetchRelatedLinks}
|
prefetch={prefetchRelatedLinks}
|
||||||
/>}
|
/>}
|
||||||
|
{showZoomControls &&
|
||||||
|
<LoaderButton
|
||||||
|
title="Open Image Viewer"
|
||||||
|
icon={<LuZoomIn size={17} />}
|
||||||
|
onClick={open}
|
||||||
|
styleAs="link"
|
||||||
|
className="text-medium"
|
||||||
|
/>}
|
||||||
{ALLOW_PUBLIC_DOWNLOADS &&
|
{ALLOW_PUBLIC_DOWNLOADS &&
|
||||||
<DownloadButton
|
<DownloadButton
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export default function PhotosLarge({
|
|||||||
priority={index <= 1}
|
priority={index <= 1}
|
||||||
prefetchRelatedLinks={prefetchFirstPhotoLinks && index === 0}
|
prefetchRelatedLinks={prefetchFirstPhotoLinks && index === 0}
|
||||||
revalidatePhoto={revalidatePhoto}
|
revalidatePhoto={revalidatePhoto}
|
||||||
|
shouldZoomOnFKeydown={false}
|
||||||
onVisible={index === photos.length - 1
|
onVisible={index === photos.length - 1
|
||||||
? onLastPhotoVisible
|
? onLastPhotoVisible
|
||||||
: undefined}
|
: undefined}
|
||||||
|
|||||||
12
src/photo/useEscapeHandler.ts
Normal file
12
src/photo/useEscapeHandler.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import useKeydownHandler from '@/utility/useKeydownHandler';
|
||||||
|
|
||||||
|
export default function useEscapeHandler(
|
||||||
|
onEscape?: () => void,
|
||||||
|
ignoreShouldRespondToKeyboardCommands?: boolean,
|
||||||
|
) {
|
||||||
|
useKeydownHandler(
|
||||||
|
onEscape,
|
||||||
|
['ESCAPE'],
|
||||||
|
ignoreShouldRespondToKeyboardCommands,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,11 +11,13 @@ import { useRouter } from 'next/navigation';
|
|||||||
let prefetchedImage: HTMLImageElement | null = null;
|
let prefetchedImage: HTMLImageElement | null = null;
|
||||||
|
|
||||||
export default function ShareButton({
|
export default function ShareButton({
|
||||||
|
title,
|
||||||
dim,
|
dim,
|
||||||
prefetch,
|
prefetch,
|
||||||
className,
|
className,
|
||||||
...rest
|
...rest
|
||||||
}: {
|
}: {
|
||||||
|
title?: string
|
||||||
dim?: boolean
|
dim?: boolean
|
||||||
prefetch?: boolean
|
prefetch?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
@ -35,6 +37,7 @@ export default function ShareButton({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LoaderButton
|
<LoaderButton
|
||||||
|
title={title}
|
||||||
onClick={() => setShareModalProps?.({ ...rest })}
|
onClick={() => setShareModalProps?.({ ...rest })}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
className,
|
className,
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import Modal from '@/components/Modal';
|
|||||||
import { TbPhotoShare } from 'react-icons/tb';
|
import { TbPhotoShare } from 'react-icons/tb';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { BiCopy } from 'react-icons/bi';
|
import { BiCopy } from 'react-icons/bi';
|
||||||
import { JSX, ReactNode } from 'react';
|
import { JSX, ReactNode, useEffect } from 'react';
|
||||||
import { shortenUrl } from '@/utility/url';
|
import { shortenUrl } from '@/utility/url';
|
||||||
import { toastSuccess } from '@/toast';
|
import { toastSuccess } from '@/toast';
|
||||||
import { PiXLogo } from 'react-icons/pi';
|
import { PiXLogo } from 'react-icons/pi';
|
||||||
@ -24,7 +24,15 @@ export default function ShareModal({
|
|||||||
socialText: string
|
socialText: string
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const { setShareModalProps } = useAppState();
|
const {
|
||||||
|
setShareModalProps,
|
||||||
|
setShouldRespondToKeyboardCommands,
|
||||||
|
} = useAppState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShouldRespondToKeyboardCommands?.(false);
|
||||||
|
return () => setShouldRespondToKeyboardCommands?.(true);
|
||||||
|
}, [setShouldRespondToKeyboardCommands]);
|
||||||
|
|
||||||
const renderIcon = (
|
const renderIcon = (
|
||||||
icon: JSX.Element,
|
icon: JSX.Element,
|
||||||
|
|||||||
@ -60,6 +60,7 @@ export default function SiteChecklistClient({
|
|||||||
isBlurEnabled,
|
isBlurEnabled,
|
||||||
// Display
|
// Display
|
||||||
showExifInfo,
|
showExifInfo,
|
||||||
|
showZoomControls,
|
||||||
showTakenAtTimeHidden,
|
showTakenAtTimeHidden,
|
||||||
showSocial,
|
showSocial,
|
||||||
showFilmSimulations,
|
showFilmSimulations,
|
||||||
@ -473,6 +474,15 @@ export default function SiteChecklistClient({
|
|||||||
Set environment variable to {'"1"'} to hide EXIF data:
|
Set environment variable to {'"1"'} to hide EXIF data:
|
||||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
|
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
|
||||||
</ChecklistRow>
|
</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
|
<ChecklistRow
|
||||||
title="Show taken at time"
|
title="Show taken at time"
|
||||||
status={showTakenAtTimeHidden}
|
status={showTakenAtTimeHidden}
|
||||||
|
|||||||
@ -165,6 +165,8 @@ export const BLUR_ENABLED =
|
|||||||
|
|
||||||
export const SHOW_EXIF_DATA =
|
export const SHOW_EXIF_DATA =
|
||||||
process.env.NEXT_PUBLIC_HIDE_EXIF_DATA !== '1';
|
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 =
|
export const SHOW_TAKEN_AT_TIME =
|
||||||
process.env.NEXT_PUBLIC_HIDE_TAKEN_AT_TIME !== '1';
|
process.env.NEXT_PUBLIC_HIDE_TAKEN_AT_TIME !== '1';
|
||||||
export const SHOW_SOCIAL =
|
export const SHOW_SOCIAL =
|
||||||
@ -262,6 +264,7 @@ export const CONFIG_CHECKLIST_STATUS = {
|
|||||||
isBlurEnabled: BLUR_ENABLED,
|
isBlurEnabled: BLUR_ENABLED,
|
||||||
// DISPLAY
|
// DISPLAY
|
||||||
showExifInfo: SHOW_EXIF_DATA,
|
showExifInfo: SHOW_EXIF_DATA,
|
||||||
|
showZoomControls: SHOW_ZOOM_CONTROLS,
|
||||||
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
|
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
|
||||||
showSocial: SHOW_SOCIAL,
|
showSocial: SHOW_SOCIAL,
|
||||||
showFilmSimulations: SHOW_FILM_SIMULATIONS,
|
showFilmSimulations: SHOW_FILM_SIMULATIONS,
|
||||||
|
|||||||
60
src/site/viewerjs.css
Normal file
60
src/site/viewerjs.css
Normal 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;
|
||||||
|
}
|
||||||
@ -33,6 +33,8 @@ export interface AppStateContext {
|
|||||||
// DEBUG
|
// DEBUG
|
||||||
isGridHighDensity?: boolean
|
isGridHighDensity?: boolean
|
||||||
setIsGridHighDensity?: Dispatch<SetStateAction<boolean>>
|
setIsGridHighDensity?: Dispatch<SetStateAction<boolean>>
|
||||||
|
areZoomControlsShown?: boolean
|
||||||
|
setAreZoomControlsShown?: Dispatch<SetStateAction<boolean>>
|
||||||
arePhotosMatted?: boolean
|
arePhotosMatted?: boolean
|
||||||
setArePhotosMatted?: Dispatch<SetStateAction<boolean>>
|
setArePhotosMatted?: Dispatch<SetStateAction<boolean>>
|
||||||
shouldDebugImageFallbacks?: boolean
|
shouldDebugImageFallbacks?: boolean
|
||||||
|
|||||||
@ -6,7 +6,11 @@ import { AnimationConfig } from '@/components/AnimateItems';
|
|||||||
import usePathnames from '@/utility/usePathnames';
|
import usePathnames from '@/utility/usePathnames';
|
||||||
import { getAuthAction } from '@/auth/actions';
|
import { getAuthAction } from '@/auth/actions';
|
||||||
import useSWR from 'swr';
|
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 { getPhotosHiddenMetaCachedAction } from '@/photo/actions';
|
||||||
import { ShareModalProps } from '@/share';
|
import { ShareModalProps } from '@/share';
|
||||||
import { storeTimezoneCookie } from '@/utility/timezone';
|
import { storeTimezoneCookie } from '@/utility/timezone';
|
||||||
@ -46,6 +50,8 @@ export default function AppStateProvider({
|
|||||||
// DEBUG
|
// DEBUG
|
||||||
const [isGridHighDensity, setIsGridHighDensity] =
|
const [isGridHighDensity, setIsGridHighDensity] =
|
||||||
useState(HIGH_DENSITY_GRID);
|
useState(HIGH_DENSITY_GRID);
|
||||||
|
const [areZoomControlsShown, setAreZoomControlsShown] =
|
||||||
|
useState(SHOW_ZOOM_CONTROLS);
|
||||||
const [arePhotosMatted, setArePhotosMatted] =
|
const [arePhotosMatted, setArePhotosMatted] =
|
||||||
useState(MATTE_PHOTOS);
|
useState(MATTE_PHOTOS);
|
||||||
const [shouldDebugImageFallbacks, setShouldDebugImageFallbacks] =
|
const [shouldDebugImageFallbacks, setShouldDebugImageFallbacks] =
|
||||||
@ -116,6 +122,8 @@ export default function AppStateProvider({
|
|||||||
// DEBUG
|
// DEBUG
|
||||||
isGridHighDensity,
|
isGridHighDensity,
|
||||||
setIsGridHighDensity,
|
setIsGridHighDensity,
|
||||||
|
areZoomControlsShown,
|
||||||
|
setAreZoomControlsShown,
|
||||||
arePhotosMatted,
|
arePhotosMatted,
|
||||||
setArePhotosMatted,
|
setArePhotosMatted,
|
||||||
shouldDebugImageFallbacks,
|
shouldDebugImageFallbacks,
|
||||||
|
|||||||
32
src/utility/useKeydownHandler.ts
Normal file
32
src/utility/useKeydownHandler.ts
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user