CI / Build & Quality Checks (push) Successful in 1m31s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 5s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 2m19s
Gallery lightbox: FocusTrap had initialFocus:false, so focus stayed on the tile behind the overlay and ←/→/Esc/+/- were dead until the user clicked inside. Timeline ImageViewer: no keyboard zoom, no role/aria-modal/label, focus landed on the <img>; now mirrors the lightbox (+ = / - / 0, double-click toggles 1×↔2×, role=dialog aria-modal labelled by the file name, focuses on open). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
151 lines
5.1 KiB
TypeScript
151 lines
5.1 KiB
TypeScript
import React, { useEffect, useRef } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import classNames from 'classnames';
|
||
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
||
import { useSaveFile } from '../../hooks/useSaveFile';
|
||
import * as css from './ImageViewer.css';
|
||
import { useZoom } from '../../hooks/useZoom';
|
||
import { usePan } from '../../hooks/usePan';
|
||
import { downloadMedia } from '../../utils/matrix';
|
||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||
|
||
export type ImageViewerProps = {
|
||
alt: string;
|
||
src: string;
|
||
requestClose: () => void;
|
||
};
|
||
|
||
export const ImageViewer = as<'div', ImageViewerProps>(
|
||
({ className, alt, src, requestClose, ...props }, ref) => {
|
||
const { t } = useTranslation();
|
||
const saveFile = useSaveFile();
|
||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||
const { pan, cursor, onMouseDown, onTouchStart } = usePan(zoom !== 1);
|
||
|
||
const handleDownload = async () => {
|
||
const fileContent = await downloadMedia(src);
|
||
saveFile(fileContent, alt);
|
||
};
|
||
|
||
// [Gitea #164] Same keyboard model as the gallery lightbox: + / = zoom
|
||
// in, - zoom out, 0 reset; double-click toggles 1× ↔ 2×. The root takes
|
||
// focus on open so those keys (and Escape, handled by the modal) work
|
||
// immediately instead of only after clicking inside the viewer.
|
||
const rootRef = useRef<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
rootRef.current?.focus({ preventScroll: true });
|
||
}, []);
|
||
const handleKeyDown = (evt: React.KeyboardEvent) => {
|
||
if (evt.key === '+' || evt.key === '=') zoomIn();
|
||
else if (evt.key === '-') zoomOut();
|
||
else if (evt.key === '0') setZoom(1);
|
||
else return;
|
||
evt.preventDefault();
|
||
};
|
||
|
||
return (
|
||
<Box
|
||
className={classNames(css.ImageViewer, className)}
|
||
direction="Column"
|
||
role="dialog"
|
||
aria-modal
|
||
aria-label={alt || 'Image viewer'}
|
||
tabIndex={-1}
|
||
onKeyDown={handleKeyDown}
|
||
{...props}
|
||
ref={(node: HTMLDivElement | null) => {
|
||
rootRef.current = node;
|
||
if (typeof ref === 'function') ref(node);
|
||
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||
}}
|
||
>
|
||
<Header className={css.ImageViewerHeader} size="400">
|
||
<Box grow="Yes" alignItems="Center" gap="200">
|
||
<IconButton
|
||
size="300"
|
||
radii="300"
|
||
className={MobileTouchTarget}
|
||
onClick={requestClose}
|
||
aria-label="Close"
|
||
>
|
||
<Icon size="50" src={Icons.ArrowLeft} />
|
||
</IconButton>
|
||
<Text size="T300" truncate>
|
||
{alt}
|
||
</Text>
|
||
</Box>
|
||
<Box shrink="No" alignItems="Center" gap="200">
|
||
<IconButton
|
||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
||
outlined={zoom < 1}
|
||
size="300"
|
||
radii="Pill"
|
||
className={MobileTouchTarget}
|
||
onClick={zoomOut}
|
||
aria-label="Zoom Out"
|
||
>
|
||
<Icon size="50" src={Icons.Minus} />
|
||
</IconButton>
|
||
<Chip
|
||
variant="SurfaceVariant"
|
||
radii="Pill"
|
||
className={MobileTouchTarget}
|
||
onClick={() => setZoom(zoom === 1 ? 2 : 1)}
|
||
>
|
||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
||
</Chip>
|
||
<IconButton
|
||
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
||
outlined={zoom > 1}
|
||
size="300"
|
||
radii="Pill"
|
||
className={MobileTouchTarget}
|
||
onClick={zoomIn}
|
||
aria-label="Zoom In"
|
||
>
|
||
<Icon size="50" src={Icons.Plus} />
|
||
</IconButton>
|
||
<Chip
|
||
variant="Primary"
|
||
onClick={handleDownload}
|
||
radii="300"
|
||
className={MobileTouchTarget}
|
||
before={<Icon size="50" src={Icons.Download} />}
|
||
>
|
||
<Text size="B300">{t('Organisms.ImageViewer.download')}</Text>
|
||
</Chip>
|
||
</Box>
|
||
</Header>
|
||
<Box
|
||
grow="Yes"
|
||
className={css.ImageViewerContent}
|
||
justifyContent="Center"
|
||
alignItems="Center"
|
||
onWheel={(evt) => {
|
||
if (evt.deltaY < 0) zoomIn();
|
||
else if (evt.deltaY > 0) zoomOut();
|
||
}}
|
||
>
|
||
<img
|
||
className={css.ImageViewerImg}
|
||
style={{
|
||
cursor,
|
||
// translate runs inside scale(), so divide by zoom to make a dragged
|
||
// pixel move the image one screen pixel (1:1 with the cursor) rather
|
||
// than `zoom` pixels.
|
||
transform: `scale(${zoom}) translate(${pan.translateX / zoom}px, ${
|
||
pan.translateY / zoom
|
||
}px)`,
|
||
}}
|
||
src={src}
|
||
alt={alt}
|
||
onMouseDown={onMouseDown}
|
||
onTouchStart={onTouchStart}
|
||
onDoubleClick={() => setZoom(zoom === 1 ? 2 : 1)}
|
||
/>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
},
|
||
);
|