mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): add image preview navigation (#1100)
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user