feat(web): add image preview navigation (#1100)

This commit is contained in:
Ananovo
2026-07-27 19:27:45 +08:00
committed by GitHub
parent 24a2656a3b
commit 5e8515c1a5
2 changed files with 119 additions and 7 deletions
+43
View File
@@ -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(
<>
<ImagePreview src="/first.png" fileName="first.png" label="First image" />
<ImagePreview src="/second.png" fileName="second.png" label="Second image" />
</>
)
}
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()
})
})
+76 -7
View File
@@ -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<PreviewImage[]>([])
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<HTMLButtonElement>) => {
event.preventDefault()
event.stopPropagation()
const triggers = Array.from(document.querySelectorAll<HTMLButtonElement>('[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}
>
<div className="flex items-center gap-2 border-b border-white/10 bg-black/50 px-3 pb-2 pt-[calc(env(safe-area-inset-top)+0.5rem)]">
<div className="min-w-0 flex-1 truncate text-sm font-medium">{props.fileName}</div>
<div className="min-w-0 flex-1 truncate text-sm font-medium">{activePreview.fileName}</div>
{hasMultiplePreviews ? (
<>
<button
type="button"
onClick={() => showPreview(previewIndex - 1)}
className="flex h-8 w-8 items-center justify-center rounded bg-white/10 text-lg hover:bg-white/20 disabled:opacity-40"
disabled={previewIndex === 0}
title="Previous image"
aria-label="Previous image"
>
</button>
<span className="min-w-10 text-center text-xs tabular-nums text-white/70">
{previewIndex + 1} / {previewImages.length}
</span>
<button
type="button"
onClick={() => showPreview(previewIndex + 1)}
className="flex h-8 w-8 items-center justify-center rounded bg-white/10 text-lg hover:bg-white/20 disabled:opacity-40"
disabled={previewIndex === previewImages.length - 1}
title="Next image"
aria-label="Next image"
>
</button>
</>
) : null}
<button
type="button"
onClick={() => zoomBy(-IMAGE_SCALE_STEP)}
@@ -287,8 +356,8 @@ export function ImagePreview(props: {
onDoubleClick={resetView}
>
<img
src={props.src}
alt={props.label}
src={activePreview.src}
alt={activePreview.label}
draggable={false}
className="absolute left-1/2 top-1/2 max-h-[90vh] max-w-[90vw] select-none object-contain"
style={{