From 5e8515c1a58b891551271b64ac5b4242a7f96bf9 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Mon, 27 Jul 2026 19:27:45 +0800 Subject: [PATCH] feat(web): add image preview navigation (#1100) --- web/src/components/ImagePreview.test.tsx | 43 ++++++++++++ web/src/components/ImagePreview.tsx | 83 ++++++++++++++++++++++-- 2 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 web/src/components/ImagePreview.test.tsx diff --git a/web/src/components/ImagePreview.test.tsx b/web/src/components/ImagePreview.test.tsx new file mode 100644 index 00000000..14a98123 --- /dev/null +++ b/web/src/components/ImagePreview.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { ImagePreview } from './ImagePreview' + +function renderGallery() { + render( + <> + + + + ) +} + +describe('ImagePreview gallery navigation', () => { + it('navigates between rendered image previews with toolbar buttons', () => { + renderGallery() + + fireEvent.click(screen.getByRole('button', { name: /first image/i })) + + const dialog = screen.getByRole('dialog', { name: 'First image' }) + expect(within(dialog).getByText('1 / 2')).toBeInTheDocument() + expect(within(dialog).getByRole('button', { name: 'Previous image' })).toBeDisabled() + + fireEvent.click(within(dialog).getByRole('button', { name: 'Next image' })) + + const nextDialog = screen.getByRole('dialog', { name: 'Second image' }) + expect(within(nextDialog).getByText('second.png')).toBeInTheDocument() + expect(within(nextDialog).getByText('2 / 2')).toBeInTheDocument() + expect(within(nextDialog).getByRole('img', { name: 'Second image' })).toHaveAttribute('src', '/second.png') + expect(within(nextDialog).getByRole('button', { name: 'Next image' })).toBeDisabled() + }) + + it('supports left and right arrow keys', () => { + renderGallery() + + fireEvent.click(screen.getByRole('button', { name: /first image/i })) + fireEvent.keyDown(window, { key: 'ArrowRight' }) + expect(screen.getByRole('dialog', { name: 'Second image' })).toBeInTheDocument() + + fireEvent.keyDown(window, { key: 'ArrowLeft' }) + expect(screen.getByRole('dialog', { name: 'First image' })).toBeInTheDocument() + }) +}) diff --git a/web/src/components/ImagePreview.tsx b/web/src/components/ImagePreview.tsx index 594977f3..ccf10ccd 100644 --- a/web/src/components/ImagePreview.tsx +++ b/web/src/components/ImagePreview.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type PointerEvent, type ReactNode, type SyntheticEvent, type WheelEvent } from 'react' +import { useCallback, useEffect, useRef, useState, type MouseEvent, type PointerEvent, type ReactNode, type SyntheticEvent, type WheelEvent } from 'react' import { CloseIcon } from '@/components/icons' const MIN_IMAGE_SCALE = 0.25 @@ -12,6 +12,12 @@ function clampImageScale(value: number): number { type ImagePoint = { x: number; y: number } +type PreviewImage = { + src: string + fileName: string + label: string +} + function getPointDistance(a: ImagePoint, b: ImagePoint): number { return Math.hypot(a.x - b.x, a.y - b.y) } @@ -32,6 +38,8 @@ export function ImagePreview(props: { caption?: ReactNode }) { const [viewerOpen, setViewerOpen] = useState(false) + const [previewImages, setPreviewImages] = useState([]) + const [previewIndex, setPreviewIndex] = useState(0) const [scale, setScale] = useState(1) const [offset, setOffset] = useState({ x: 0, y: 0 }) const scaleRef = useRef(scale) @@ -45,9 +53,22 @@ export function ImagePreview(props: { event.stopPropagation() }, []) - const openViewer = useCallback((event: SyntheticEvent) => { + const openViewer = useCallback((event: MouseEvent) => { event.preventDefault() event.stopPropagation() + const triggers = Array.from(document.querySelectorAll('[data-image-preview-trigger]')) + const images = triggers.flatMap((trigger): PreviewImage[] => { + const image = trigger.querySelector('img') + if (!image) return [] + return [{ + src: image.getAttribute('src') ?? image.src, + fileName: trigger.dataset.imagePreviewFileName ?? image.alt, + label: trigger.dataset.imagePreviewLabel ?? image.alt + }] + }) + const index = triggers.indexOf(event.currentTarget) + setPreviewImages(images) + setPreviewIndex(index >= 0 ? index : 0) setViewerOpen(true) }, []) @@ -78,6 +99,11 @@ export function ImagePreview(props: { resetView() }, [resetView]) + const showPreview = useCallback((index: number) => { + setPreviewIndex(index) + resetView() + }, [resetView]) + const zoomBy = useCallback((delta: number) => { updateScale((current) => clampImageScale(current + delta)) }, [updateScale]) @@ -206,11 +232,24 @@ export function ImagePreview(props: { if (event.key === '-') { zoomBy(-IMAGE_SCALE_STEP) } + if (event.key === 'ArrowLeft' && previewIndex > 0) { + showPreview(previewIndex - 1) + } + if (event.key === 'ArrowRight' && previewIndex < previewImages.length - 1) { + showPreview(previewIndex + 1) + } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [closeViewer, resetView, viewerOpen, zoomBy]) + }, [closeViewer, previewImages.length, previewIndex, resetView, showPreview, viewerOpen, zoomBy]) + + const activePreview = previewImages[previewIndex] ?? { + src: props.src, + fileName: props.fileName, + label: props.label + } + const hasMultiplePreviews = previewImages.length > 1 return ( <> @@ -220,6 +259,9 @@ export function ImagePreview(props: { onMouseDown={stopEvent} onTouchStart={stopEvent} onClick={openViewer} + data-image-preview-trigger="" + data-image-preview-file-name={props.fileName} + data-image-preview-label={props.label} className={props.buttonClassName ?? 'group flex min-h-[18rem] w-full items-center justify-center overflow-auto rounded-md border border-[var(--app-border)] bg-[var(--app-code-bg)] p-3 text-left'} title="Click to zoom" > @@ -238,10 +280,37 @@ export function ImagePreview(props: { className="fixed inset-0 z-50 flex flex-col bg-black/90 text-white" role="dialog" aria-modal="true" - aria-label={props.label} + aria-label={activePreview.label} >
-
{props.fileName}
+
{activePreview.fileName}
+ {hasMultiplePreviews ? ( + <> + + + {previewIndex + 1} / {previewImages.length} + + + + ) : null}