mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
feat(web): add lightbox preview for chat images (#715)
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import type { AttachmentMetadata } from '@/types/api'
|
import type { AttachmentMetadata } from '@/types/api'
|
||||||
import { FileIcon } from '@/components/FileIcon'
|
import { FileIcon } from '@/components/FileIcon'
|
||||||
import { isImageMimeType } from '@/lib/fileAttachments'
|
import { isImageMimeType } from '@/lib/fileAttachments'
|
||||||
|
import { ImagePreview } from '@/components/ImagePreview'
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`
|
if (bytes < 1024) return `${bytes} B`
|
||||||
@@ -11,18 +12,20 @@ function formatFileSize(bytes: number): string {
|
|||||||
function ImageAttachment(props: { attachment: AttachmentMetadata }) {
|
function ImageAttachment(props: { attachment: AttachmentMetadata }) {
|
||||||
const { attachment } = props
|
const { attachment } = props
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-lg">
|
<ImagePreview
|
||||||
<img
|
src={attachment.previewUrl ?? ''}
|
||||||
src={attachment.previewUrl}
|
fileName={attachment.filename}
|
||||||
alt={attachment.filename}
|
label={attachment.filename}
|
||||||
className="max-h-48 max-w-full object-contain"
|
buttonClassName="relative overflow-hidden rounded-lg text-left cursor-zoom-in"
|
||||||
/>
|
imageClassName="max-h-48 max-w-full object-contain"
|
||||||
|
caption={(
|
||||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5">
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5">
|
||||||
<span className="text-xs text-white/90 line-clamp-1">
|
<span className="text-xs text-white/90 line-clamp-1">
|
||||||
{attachment.filename}
|
{attachment.filename}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ToolCard } from '@/components/ToolCard/ToolCard'
|
|||||||
import { useHappyChatContext } from '@/components/AssistantChat/context'
|
import { useHappyChatContext } from '@/components/AssistantChat/context'
|
||||||
import { CliOutputBlock } from '@/components/CliOutputBlock'
|
import { CliOutputBlock } from '@/components/CliOutputBlock'
|
||||||
import { UserBubbleContent, getUserBubbleClassName, shouldShowMessageStatus } from '@/components/AssistantChat/messages/user-bubble'
|
import { UserBubbleContent, getUserBubbleClassName, shouldShowMessageStatus } from '@/components/AssistantChat/messages/user-bubble'
|
||||||
|
import { ImagePreview } from '@/components/ImagePreview'
|
||||||
|
|
||||||
function isToolCallBlock(value: unknown): value is ToolCallBlock {
|
function isToolCallBlock(value: unknown): value is ToolCallBlock {
|
||||||
if (!isObject(value)) return false
|
if (!isObject(value)) return false
|
||||||
@@ -84,11 +85,12 @@ function GeneratedImageCard(props: { block: GeneratedImageBlock }) {
|
|||||||
Generated image · {props.block.fileName}
|
Generated image · {props.block.fileName}
|
||||||
</div>
|
</div>
|
||||||
{objectUrl ? (
|
{objectUrl ? (
|
||||||
<img
|
<ImagePreview
|
||||||
src={objectUrl}
|
src={objectUrl}
|
||||||
alt={props.block.fileName}
|
fileName={props.block.fileName}
|
||||||
className="max-h-[min(28rem,60vh)] max-w-full rounded-xl object-contain"
|
label={props.block.fileName}
|
||||||
draggable={false}
|
buttonClassName="block max-w-full cursor-zoom-in rounded-xl text-left"
|
||||||
|
imageClassName="max-h-[min(28rem,60vh)] max-w-full rounded-xl object-contain"
|
||||||
/>
|
/>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="text-sm text-[var(--app-hint)]">
|
<div className="text-sm text-[var(--app-hint)]">
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState, type PointerEvent, type ReactNode, type WheelEvent } from 'react'
|
||||||
|
import { CloseIcon } from '@/components/icons'
|
||||||
|
|
||||||
|
const MIN_IMAGE_SCALE = 0.25
|
||||||
|
const MAX_IMAGE_SCALE = 8
|
||||||
|
const IMAGE_SCALE_STEP = 0.25
|
||||||
|
|
||||||
|
function clampImageScale(value: number): number {
|
||||||
|
return Math.min(MAX_IMAGE_SCALE, Math.max(MIN_IMAGE_SCALE, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImagePoint = { x: number; y: number }
|
||||||
|
|
||||||
|
function getPointDistance(a: ImagePoint, b: ImagePoint): number {
|
||||||
|
return Math.hypot(a.x - b.x, a.y - b.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPointCenter(a: ImagePoint, b: ImagePoint): ImagePoint {
|
||||||
|
return {
|
||||||
|
x: (a.x + b.x) / 2,
|
||||||
|
y: (a.y + b.y) / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImagePreview(props: {
|
||||||
|
src: string
|
||||||
|
fileName: string
|
||||||
|
label: string
|
||||||
|
buttonClassName?: string
|
||||||
|
imageClassName?: string
|
||||||
|
caption?: ReactNode
|
||||||
|
}) {
|
||||||
|
const [viewerOpen, setViewerOpen] = useState(false)
|
||||||
|
const [scale, setScale] = useState(1)
|
||||||
|
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
||||||
|
const scaleRef = useRef(scale)
|
||||||
|
const offsetRef = useRef(offset)
|
||||||
|
const activePointersRef = useRef(new Map<number, ImagePoint>())
|
||||||
|
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null)
|
||||||
|
const pinchRef = useRef<{ startDistance: number; startScale: number; startCenter: ImagePoint; origin: ImagePoint } | null>(null)
|
||||||
|
|
||||||
|
const updateScale = useCallback((next: number | ((current: number) => number)) => {
|
||||||
|
setScale((current) => {
|
||||||
|
const value = typeof next === 'function' ? next(current) : next
|
||||||
|
scaleRef.current = value
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateOffset = useCallback((next: ImagePoint) => {
|
||||||
|
offsetRef.current = next
|
||||||
|
setOffset(next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resetView = useCallback(() => {
|
||||||
|
updateScale(1)
|
||||||
|
updateOffset({ x: 0, y: 0 })
|
||||||
|
}, [updateOffset, updateScale])
|
||||||
|
|
||||||
|
const closeViewer = useCallback(() => {
|
||||||
|
setViewerOpen(false)
|
||||||
|
activePointersRef.current.clear()
|
||||||
|
dragRef.current = null
|
||||||
|
pinchRef.current = null
|
||||||
|
resetView()
|
||||||
|
}, [resetView])
|
||||||
|
|
||||||
|
const zoomBy = useCallback((delta: number) => {
|
||||||
|
updateScale((current) => clampImageScale(current + delta))
|
||||||
|
}, [updateScale])
|
||||||
|
|
||||||
|
const handleWheel = useCallback((event: WheelEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const delta = event.deltaY < 0 ? IMAGE_SCALE_STEP : -IMAGE_SCALE_STEP
|
||||||
|
zoomBy(delta)
|
||||||
|
}, [zoomBy])
|
||||||
|
|
||||||
|
const beginPinch = useCallback(() => {
|
||||||
|
const pointers = Array.from(activePointersRef.current.values())
|
||||||
|
if (pointers.length < 2) return
|
||||||
|
|
||||||
|
const [first, second] = pointers
|
||||||
|
pinchRef.current = {
|
||||||
|
startDistance: getPointDistance(first, second),
|
||||||
|
startScale: scaleRef.current,
|
||||||
|
startCenter: getPointCenter(first, second),
|
||||||
|
origin: offsetRef.current
|
||||||
|
}
|
||||||
|
dragRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
|
activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||||
|
|
||||||
|
if (activePointersRef.current.size >= 2) {
|
||||||
|
beginPinch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dragRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
originX: offsetRef.current.x,
|
||||||
|
originY: offsetRef.current.y
|
||||||
|
}
|
||||||
|
}, [beginPinch])
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!activePointersRef.current.has(event.pointerId)) return
|
||||||
|
activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||||
|
|
||||||
|
if (activePointersRef.current.size >= 2 && pinchRef.current) {
|
||||||
|
const pointers = Array.from(activePointersRef.current.values())
|
||||||
|
const [first, second] = pointers
|
||||||
|
const distance = getPointDistance(first, second)
|
||||||
|
const center = getPointCenter(first, second)
|
||||||
|
const pinch = pinchRef.current
|
||||||
|
const nextScale = pinch.startDistance > 0
|
||||||
|
? clampImageScale(pinch.startScale * (distance / pinch.startDistance))
|
||||||
|
: pinch.startScale
|
||||||
|
|
||||||
|
updateScale(nextScale)
|
||||||
|
updateOffset({
|
||||||
|
x: pinch.origin.x + center.x - pinch.startCenter.x,
|
||||||
|
y: pinch.origin.y + center.y - pinch.startCenter.y
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const drag = dragRef.current
|
||||||
|
if (!drag || drag.pointerId !== event.pointerId) return
|
||||||
|
updateOffset({
|
||||||
|
x: drag.originX + event.clientX - drag.startX,
|
||||||
|
y: drag.originY + event.clientY - drag.startY
|
||||||
|
})
|
||||||
|
}, [updateOffset, updateScale])
|
||||||
|
|
||||||
|
const handlePointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
activePointersRef.current.delete(event.pointerId)
|
||||||
|
if (dragRef.current?.pointerId === event.pointerId) {
|
||||||
|
dragRef.current = null
|
||||||
|
}
|
||||||
|
pinchRef.current = null
|
||||||
|
|
||||||
|
const remainingPointer = activePointersRef.current.entries().next().value as [number, ImagePoint] | undefined
|
||||||
|
if (remainingPointer) {
|
||||||
|
dragRef.current = {
|
||||||
|
pointerId: remainingPointer[0],
|
||||||
|
startX: remainingPointer[1].x,
|
||||||
|
startY: remainingPointer[1].y,
|
||||||
|
originX: offsetRef.current.x,
|
||||||
|
originY: offsetRef.current.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!viewerOpen) return
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeViewer()
|
||||||
|
}
|
||||||
|
if (event.key === '0') {
|
||||||
|
resetView()
|
||||||
|
}
|
||||||
|
if (event.key === '+' || event.key === '=') {
|
||||||
|
zoomBy(IMAGE_SCALE_STEP)
|
||||||
|
}
|
||||||
|
if (event.key === '-') {
|
||||||
|
zoomBy(-IMAGE_SCALE_STEP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [closeViewer, resetView, viewerOpen, zoomBy])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewerOpen(true)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={props.src}
|
||||||
|
alt={props.label}
|
||||||
|
className={props.imageClassName ?? 'max-h-[calc(100vh-14rem)] max-w-full object-contain transition-transform group-hover:scale-[1.01]'}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
{props.caption}
|
||||||
|
<span className="sr-only">{props.fileName}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{viewerOpen ? (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex flex-col bg-black/90 text-white"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={props.label}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-b border-white/10 bg-black/50 px-3 py-2">
|
||||||
|
<div className="min-w-0 flex-1 truncate text-sm font-medium">{props.fileName}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => zoomBy(-IMAGE_SCALE_STEP)}
|
||||||
|
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40"
|
||||||
|
disabled={scale <= MIN_IMAGE_SCALE}
|
||||||
|
title="Zoom out"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetView}
|
||||||
|
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20"
|
||||||
|
title="Reset zoom"
|
||||||
|
>
|
||||||
|
{Math.round(scale * 100)}%
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => zoomBy(IMAGE_SCALE_STEP)}
|
||||||
|
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40"
|
||||||
|
disabled={scale >= MAX_IMAGE_SCALE}
|
||||||
|
title="Zoom in"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeViewer}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded bg-white/10 hover:bg-white/20"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<CloseIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="relative min-h-0 flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing"
|
||||||
|
onWheel={handleWheel}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
onDoubleClick={resetView}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={props.src}
|
||||||
|
alt={props.label}
|
||||||
|
draggable={false}
|
||||||
|
className="absolute left-1/2 top-1/2 max-h-[90vh] max-w-[90vw] select-none object-contain"
|
||||||
|
style={{
|
||||||
|
transform: `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px)) scale(${scale})`,
|
||||||
|
transformOrigin: 'center center'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent, type WheelEvent } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useParams, useSearch } from '@tanstack/react-router'
|
import { useParams, useSearch } from '@tanstack/react-router'
|
||||||
import type { GitCommandResponse } from '@/types/api'
|
import type { GitCommandResponse } from '@/types/api'
|
||||||
import { FileIcon } from '@/components/FileIcon'
|
import { FileIcon } from '@/components/FileIcon'
|
||||||
import { CopyIcon, CheckIcon, CloseIcon } from '@/components/icons'
|
import { CopyIcon, CheckIcon } from '@/components/icons'
|
||||||
import { useAppContext } from '@/lib/app-context'
|
import { useAppContext } from '@/lib/app-context'
|
||||||
import { useAppGoBack } from '@/hooks/useAppGoBack'
|
import { useAppGoBack } from '@/hooks/useAppGoBack'
|
||||||
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
|
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
|
||||||
@@ -12,11 +12,9 @@ import { queryKeys } from '@/lib/query-keys'
|
|||||||
import { langAlias, useShikiHighlighter } from '@/lib/shiki'
|
import { langAlias, useShikiHighlighter } from '@/lib/shiki'
|
||||||
import { useTranslation } from '@/lib/use-translation'
|
import { useTranslation } from '@/lib/use-translation'
|
||||||
import { decodeBase64 } from '@/lib/utils'
|
import { decodeBase64 } from '@/lib/utils'
|
||||||
|
import { ImagePreview } from '@/components/ImagePreview'
|
||||||
|
|
||||||
const MAX_COPYABLE_FILE_BYTES = 1_000_000
|
const MAX_COPYABLE_FILE_BYTES = 1_000_000
|
||||||
const MIN_IMAGE_SCALE = 0.25
|
|
||||||
const MAX_IMAGE_SCALE = 8
|
|
||||||
const IMAGE_SCALE_STEP = 0.25
|
|
||||||
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
|
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
|
||||||
apng: 'image/apng',
|
apng: 'image/apng',
|
||||||
avif: 'image/avif',
|
avif: 'image/avif',
|
||||||
@@ -143,260 +141,6 @@ function extractCommandError(result: GitCommandResponse | undefined): string | n
|
|||||||
return result.error ?? result.stderr ?? 'Failed to load diff'
|
return result.error ?? result.stderr ?? 'Failed to load diff'
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampImageScale(value: number): number {
|
|
||||||
return Math.min(MAX_IMAGE_SCALE, Math.max(MIN_IMAGE_SCALE, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
type ImagePoint = { x: number; y: number }
|
|
||||||
|
|
||||||
function getPointDistance(a: ImagePoint, b: ImagePoint): number {
|
|
||||||
return Math.hypot(a.x - b.x, a.y - b.y)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPointCenter(a: ImagePoint, b: ImagePoint): ImagePoint {
|
|
||||||
return {
|
|
||||||
x: (a.x + b.x) / 2,
|
|
||||||
y: (a.y + b.y) / 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImagePreview(props: { dataUrl: string; fileName: string; label: string }) {
|
|
||||||
const [viewerOpen, setViewerOpen] = useState(false)
|
|
||||||
const [scale, setScale] = useState(1)
|
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
|
||||||
const scaleRef = useRef(scale)
|
|
||||||
const offsetRef = useRef(offset)
|
|
||||||
const activePointersRef = useRef(new Map<number, ImagePoint>())
|
|
||||||
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null)
|
|
||||||
const pinchRef = useRef<{ startDistance: number; startScale: number; startCenter: ImagePoint; origin: ImagePoint } | null>(null)
|
|
||||||
|
|
||||||
const updateScale = useCallback((next: number | ((current: number) => number)) => {
|
|
||||||
setScale((current) => {
|
|
||||||
const value = typeof next === 'function' ? next(current) : next
|
|
||||||
scaleRef.current = value
|
|
||||||
return value
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const updateOffset = useCallback((next: ImagePoint) => {
|
|
||||||
offsetRef.current = next
|
|
||||||
setOffset(next)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const resetView = useCallback(() => {
|
|
||||||
updateScale(1)
|
|
||||||
updateOffset({ x: 0, y: 0 })
|
|
||||||
}, [updateOffset, updateScale])
|
|
||||||
|
|
||||||
const closeViewer = useCallback(() => {
|
|
||||||
setViewerOpen(false)
|
|
||||||
activePointersRef.current.clear()
|
|
||||||
dragRef.current = null
|
|
||||||
pinchRef.current = null
|
|
||||||
resetView()
|
|
||||||
}, [resetView])
|
|
||||||
|
|
||||||
const zoomBy = useCallback((delta: number) => {
|
|
||||||
updateScale((current) => clampImageScale(current + delta))
|
|
||||||
}, [updateScale])
|
|
||||||
|
|
||||||
const handleWheel = useCallback((event: WheelEvent<HTMLDivElement>) => {
|
|
||||||
event.preventDefault()
|
|
||||||
const delta = event.deltaY < 0 ? IMAGE_SCALE_STEP : -IMAGE_SCALE_STEP
|
|
||||||
zoomBy(delta)
|
|
||||||
}, [zoomBy])
|
|
||||||
|
|
||||||
const beginPinch = useCallback(() => {
|
|
||||||
const pointers = Array.from(activePointersRef.current.values())
|
|
||||||
if (pointers.length < 2) return
|
|
||||||
|
|
||||||
const [first, second] = pointers
|
|
||||||
pinchRef.current = {
|
|
||||||
startDistance: getPointDistance(first, second),
|
|
||||||
startScale: scaleRef.current,
|
|
||||||
startCenter: getPointCenter(first, second),
|
|
||||||
origin: offsetRef.current
|
|
||||||
}
|
|
||||||
dragRef.current = null
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handlePointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (event.button !== 0) return
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId)
|
|
||||||
activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
|
||||||
|
|
||||||
if (activePointersRef.current.size >= 2) {
|
|
||||||
beginPinch()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dragRef.current = {
|
|
||||||
pointerId: event.pointerId,
|
|
||||||
startX: event.clientX,
|
|
||||||
startY: event.clientY,
|
|
||||||
originX: offsetRef.current.x,
|
|
||||||
originY: offsetRef.current.y
|
|
||||||
}
|
|
||||||
}, [beginPinch])
|
|
||||||
|
|
||||||
const handlePointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (!activePointersRef.current.has(event.pointerId)) return
|
|
||||||
activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
|
||||||
|
|
||||||
if (activePointersRef.current.size >= 2 && pinchRef.current) {
|
|
||||||
const pointers = Array.from(activePointersRef.current.values())
|
|
||||||
const [first, second] = pointers
|
|
||||||
const distance = getPointDistance(first, second)
|
|
||||||
const center = getPointCenter(first, second)
|
|
||||||
const pinch = pinchRef.current
|
|
||||||
const nextScale = pinch.startDistance > 0
|
|
||||||
? clampImageScale(pinch.startScale * (distance / pinch.startDistance))
|
|
||||||
: pinch.startScale
|
|
||||||
|
|
||||||
updateScale(nextScale)
|
|
||||||
updateOffset({
|
|
||||||
x: pinch.origin.x + center.x - pinch.startCenter.x,
|
|
||||||
y: pinch.origin.y + center.y - pinch.startCenter.y
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const drag = dragRef.current
|
|
||||||
if (!drag || drag.pointerId !== event.pointerId) return
|
|
||||||
updateOffset({
|
|
||||||
x: drag.originX + event.clientX - drag.startX,
|
|
||||||
y: drag.originY + event.clientY - drag.startY
|
|
||||||
})
|
|
||||||
}, [updateOffset, updateScale])
|
|
||||||
|
|
||||||
const handlePointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
|
||||||
activePointersRef.current.delete(event.pointerId)
|
|
||||||
if (dragRef.current?.pointerId === event.pointerId) {
|
|
||||||
dragRef.current = null
|
|
||||||
}
|
|
||||||
pinchRef.current = null
|
|
||||||
|
|
||||||
const remainingPointer = activePointersRef.current.entries().next().value as [number, ImagePoint] | undefined
|
|
||||||
if (remainingPointer) {
|
|
||||||
dragRef.current = {
|
|
||||||
pointerId: remainingPointer[0],
|
|
||||||
startX: remainingPointer[1].x,
|
|
||||||
startY: remainingPointer[1].y,
|
|
||||||
originX: offsetRef.current.x,
|
|
||||||
originY: offsetRef.current.y
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!viewerOpen) return
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
closeViewer()
|
|
||||||
}
|
|
||||||
if (event.key === '0') {
|
|
||||||
resetView()
|
|
||||||
}
|
|
||||||
if (event.key === '+' || event.key === '=') {
|
|
||||||
zoomBy(IMAGE_SCALE_STEP)
|
|
||||||
}
|
|
||||||
if (event.key === '-') {
|
|
||||||
zoomBy(-IMAGE_SCALE_STEP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}, [closeViewer, resetView, viewerOpen, zoomBy])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setViewerOpen(true)}
|
|
||||||
className="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"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={props.dataUrl}
|
|
||||||
alt={props.label}
|
|
||||||
className="max-h-[calc(100vh-14rem)] max-w-full object-contain transition-transform group-hover:scale-[1.01]"
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
<span className="sr-only">{props.fileName}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{viewerOpen ? (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex flex-col bg-black/90 text-white"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={props.label}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 border-b border-white/10 bg-black/50 px-3 py-2">
|
|
||||||
<div className="min-w-0 flex-1 truncate text-sm font-medium">{props.fileName}</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => zoomBy(-IMAGE_SCALE_STEP)}
|
|
||||||
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40"
|
|
||||||
disabled={scale <= MIN_IMAGE_SCALE}
|
|
||||||
title="Zoom out"
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={resetView}
|
|
||||||
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20"
|
|
||||||
title="Reset zoom"
|
|
||||||
>
|
|
||||||
{Math.round(scale * 100)}%
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => zoomBy(IMAGE_SCALE_STEP)}
|
|
||||||
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40"
|
|
||||||
disabled={scale >= MAX_IMAGE_SCALE}
|
|
||||||
title="Zoom in"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeViewer}
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded bg-white/10 hover:bg-white/20"
|
|
||||||
title="Close"
|
|
||||||
>
|
|
||||||
<CloseIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="relative min-h-0 flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing"
|
|
||||||
onWheel={handleWheel}
|
|
||||||
onPointerDown={handlePointerDown}
|
|
||||||
onPointerMove={handlePointerMove}
|
|
||||||
onPointerUp={handlePointerUp}
|
|
||||||
onPointerCancel={handlePointerUp}
|
|
||||||
onDoubleClick={resetView}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={props.dataUrl}
|
|
||||||
alt={props.label}
|
|
||||||
draggable={false}
|
|
||||||
className="absolute left-1/2 top-1/2 max-h-[90vh] max-w-[90vw] select-none object-contain"
|
|
||||||
style={{
|
|
||||||
transform: `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px)) scale(${scale})`,
|
|
||||||
transformOrigin: 'center center'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FilePage() {
|
export default function FilePage() {
|
||||||
const { api } = useAppContext()
|
const { api } = useAppContext()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -560,7 +304,7 @@ export default function FilePage() {
|
|||||||
) : displayMode === 'file' ? (
|
) : displayMode === 'file' ? (
|
||||||
imagePreviewUrl ? (
|
imagePreviewUrl ? (
|
||||||
<ImagePreview
|
<ImagePreview
|
||||||
dataUrl={imagePreviewUrl}
|
src={imagePreviewUrl}
|
||||||
fileName={fileName}
|
fileName={fileName}
|
||||||
label={t('file.page.imagePreviewAlt', { name: fileName })}
|
label={t('file.page.imagePreviewAlt', { name: fileName })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user