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
|
||||
- `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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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 (
|
||||
<motion.div
|
||||
className={clsx(
|
||||
|
||||
@ -92,6 +92,7 @@ export default function CommandKClient({
|
||||
selectedPhotoIds,
|
||||
setSelectedPhotoIds,
|
||||
isGridHighDensity,
|
||||
areZoomControlsShown,
|
||||
arePhotosMatted,
|
||||
shouldShowBaselineGrid,
|
||||
shouldDebugImageFallbacks,
|
||||
@ -99,6 +100,7 @@ export default function CommandKClient({
|
||||
setShouldRespondToKeyboardCommands,
|
||||
setShouldShowBaselineGrid,
|
||||
setIsGridHighDensity,
|
||||
setAreZoomControlsShown,
|
||||
setArePhotosMatted,
|
||||
setShouldDebugImageFallbacks,
|
||||
} = useAppState();
|
||||
@ -250,6 +252,10 @@ export default function CommandKClient({
|
||||
heading: 'Debug Tools',
|
||||
accessory: <RiToolsFill size={16} className="translate-x-[-1px]" />,
|
||||
items: [{
|
||||
label: 'Toggle Zoom Controls',
|
||||
action: () => setAreZoomControlsShown?.(prev => !prev),
|
||||
annotation: areZoomControlsShown ? <FaCheck size={12} /> : undefined,
|
||||
}, {
|
||||
label: 'Toggle Photo Matting',
|
||||
action: () => setArePhotosMatted?.(prev => !prev),
|
||||
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}
|
||||
shouldShareCamera={camera !== undefined}
|
||||
shouldShareSimulation={simulation !== undefined}
|
||||
shouldScrollOnShare={false}
|
||||
includeFavoriteInAdminMenu={includeFavoriteInAdminMenu}
|
||||
/>,
|
||||
]}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<HTMLDivElement>(null);
|
||||
const refZoomControlsContainer = useRef<HTMLDivElement>(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 =
|
||||
<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 (
|
||||
<SiteGrid
|
||||
containerRef={ref}
|
||||
className={className}
|
||||
contentMain={
|
||||
<Link
|
||||
contentMain={showZoomControls
|
||||
? <div className={largePhotoContainerClassName}>
|
||||
{largePhotoContent}
|
||||
</div>
|
||||
: <Link
|
||||
href={pathForPhoto({ photo })}
|
||||
className={clsx(arePhotosMatted &&
|
||||
'flex items-center justify-center aspect-[3/2] bg-gray-100',
|
||||
)}
|
||||
className={largePhotoContainerClassName}
|
||||
prefetch={prefetch}
|
||||
>
|
||||
<div className={clsx(
|
||||
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>
|
||||
{largePhotoContent}
|
||||
</Link>}
|
||||
contentSide={
|
||||
<DivDebugBaselineGrid className={clsx(
|
||||
@ -271,6 +303,7 @@ export default function PhotoLarge({
|
||||
)}>
|
||||
{shouldShare &&
|
||||
<ShareButton
|
||||
title="Share Photo"
|
||||
photo={photo}
|
||||
tag={shouldShareTag ? primaryTag : undefined}
|
||||
camera={shouldShareCamera ? camera : undefined}
|
||||
@ -280,6 +313,14 @@ export default function PhotoLarge({
|
||||
focal={shouldShareFocalLength ? photo.focalLength : undefined}
|
||||
prefetch={prefetchRelatedLinks}
|
||||
/>}
|
||||
{showZoomControls &&
|
||||
<LoaderButton
|
||||
title="Open Image Viewer"
|
||||
icon={<LuZoomIn size={17} />}
|
||||
onClick={open}
|
||||
styleAs="link"
|
||||
className="text-medium"
|
||||
/>}
|
||||
{ALLOW_PUBLIC_DOWNLOADS &&
|
||||
<DownloadButton
|
||||
className={clsx(
|
||||
|
||||
@ -31,6 +31,7 @@ export default function PhotosLarge({
|
||||
priority={index <= 1}
|
||||
prefetchRelatedLinks={prefetchFirstPhotoLinks && index === 0}
|
||||
revalidatePhoto={revalidatePhoto}
|
||||
shouldZoomOnFKeydown={false}
|
||||
onVisible={index === photos.length - 1
|
||||
? onLastPhotoVisible
|
||||
: 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;
|
||||
|
||||
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 (
|
||||
<LoaderButton
|
||||
title={title}
|
||||
onClick={() => setShareModalProps?.({ ...rest })}
|
||||
className={clsx(
|
||||
className,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'])}
|
||||
</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
|
||||
title="Show taken at time"
|
||||
status={showTakenAtTimeHidden}
|
||||
|
||||
@ -165,6 +165,8 @@ export const BLUR_ENABLED =
|
||||
|
||||
export const SHOW_EXIF_DATA =
|
||||
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 =
|
||||
process.env.NEXT_PUBLIC_HIDE_TAKEN_AT_TIME !== '1';
|
||||
export const SHOW_SOCIAL =
|
||||
@ -262,6 +264,7 @@ export const CONFIG_CHECKLIST_STATUS = {
|
||||
isBlurEnabled: BLUR_ENABLED,
|
||||
// DISPLAY
|
||||
showExifInfo: SHOW_EXIF_DATA,
|
||||
showZoomControls: SHOW_ZOOM_CONTROLS,
|
||||
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
|
||||
showSocial: SHOW_SOCIAL,
|
||||
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
|
||||
isGridHighDensity?: boolean
|
||||
setIsGridHighDensity?: Dispatch<SetStateAction<boolean>>
|
||||
areZoomControlsShown?: boolean
|
||||
setAreZoomControlsShown?: Dispatch<SetStateAction<boolean>>
|
||||
arePhotosMatted?: boolean
|
||||
setArePhotosMatted?: Dispatch<SetStateAction<boolean>>
|
||||
shouldDebugImageFallbacks?: boolean
|
||||
|
||||
@ -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,
|
||||
|
||||
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