From 53823f5466c2476aa79e9aae3bd403574a90f852 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 18 Sep 2026 21:29:24 -0400 Subject: [PATCH] fix(media viewer): lightbox focuses itself on open (keys worked only after a click); timeline viewer gets +/-/0 keys, double-click zoom and dialog semantics (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ; 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../components/image-viewer/ImageViewer.tsx | 30 +++++++++++++++++-- src/app/features/room/MediaGallery.tsx | 8 ++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 08763deba..218e4b962 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +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'; @@ -27,12 +27,37 @@ export const ImageViewer = as<'div', ImageViewerProps>( 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(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 ( { + rootRef.current = node; + if (typeof ref === 'function') ref(node); + else if (ref) (ref as React.MutableRefObject).current = node; + }} >
@@ -116,6 +141,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( alt={alt} onMouseDown={onMouseDown} onTouchStart={onTouchStart} + onDoubleClick={() => setZoom(zoom === 1 ? 2 : 1)} /> diff --git a/src/app/features/room/MediaGallery.tsx b/src/app/features/room/MediaGallery.tsx index ba2cb280d..e1ee9e3c8 100644 --- a/src/app/features/room/MediaGallery.tsx +++ b/src/app/features/room/MediaGallery.tsx @@ -302,6 +302,7 @@ function Lightbox({ // Pan is only active for a zoomed-in image; usePan resets its offset when this // flips false (i.e. back to 1x, on navigation, or on a video). const { pan, cursor, onMouseDown, onTouchStart } = usePan(isImage && zoom !== 1); + const dialogRef = useRef(null); const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]); // Reset zoom when navigating to another item (and thus pan, via usePan). @@ -346,12 +347,17 @@ function Lightbox({ }> dialogRef.current ?? false, clickOutsideDeactivates: false, escapeDeactivates: false, }} >