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 { FileIcon } from '@/components/FileIcon'
|
||||
import { isImageMimeType } from '@/lib/fileAttachments'
|
||||
import { ImagePreview } from '@/components/ImagePreview'
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
@@ -11,18 +12,20 @@ function formatFileSize(bytes: number): string {
|
||||
function ImageAttachment(props: { attachment: AttachmentMetadata }) {
|
||||
const { attachment } = props
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={attachment.previewUrl}
|
||||
alt={attachment.filename}
|
||||
className="max-h-48 max-w-full object-contain"
|
||||
/>
|
||||
<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">
|
||||
{attachment.filename}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ImagePreview
|
||||
src={attachment.previewUrl ?? ''}
|
||||
fileName={attachment.filename}
|
||||
label={attachment.filename}
|
||||
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">
|
||||
<span className="text-xs text-white/90 line-clamp-1">
|
||||
{attachment.filename}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ToolCard } from '@/components/ToolCard/ToolCard'
|
||||
import { useHappyChatContext } from '@/components/AssistantChat/context'
|
||||
import { CliOutputBlock } from '@/components/CliOutputBlock'
|
||||
import { UserBubbleContent, getUserBubbleClassName, shouldShowMessageStatus } from '@/components/AssistantChat/messages/user-bubble'
|
||||
import { ImagePreview } from '@/components/ImagePreview'
|
||||
|
||||
function isToolCallBlock(value: unknown): value is ToolCallBlock {
|
||||
if (!isObject(value)) return false
|
||||
@@ -84,11 +85,12 @@ function GeneratedImageCard(props: { block: GeneratedImageBlock }) {
|
||||
Generated image · {props.block.fileName}
|
||||
</div>
|
||||
{objectUrl ? (
|
||||
<img
|
||||
<ImagePreview
|
||||
src={objectUrl}
|
||||
alt={props.block.fileName}
|
||||
className="max-h-[min(28rem,60vh)] max-w-full rounded-xl object-contain"
|
||||
draggable={false}
|
||||
fileName={props.block.fileName}
|
||||
label={props.block.fileName}
|
||||
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 ? (
|
||||
<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 { useParams, useSearch } from '@tanstack/react-router'
|
||||
import type { GitCommandResponse } from '@/types/api'
|
||||
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 { useAppGoBack } from '@/hooks/useAppGoBack'
|
||||
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
|
||||
@@ -12,11 +12,9 @@ import { queryKeys } from '@/lib/query-keys'
|
||||
import { langAlias, useShikiHighlighter } from '@/lib/shiki'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import { decodeBase64 } from '@/lib/utils'
|
||||
import { ImagePreview } from '@/components/ImagePreview'
|
||||
|
||||
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> = {
|
||||
apng: 'image/apng',
|
||||
avif: 'image/avif',
|
||||
@@ -143,260 +141,6 @@ function extractCommandError(result: GitCommandResponse | undefined): string | n
|
||||
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() {
|
||||
const { api } = useAppContext()
|
||||
const { t } = useTranslation()
|
||||
@@ -560,7 +304,7 @@ export default function FilePage() {
|
||||
) : displayMode === 'file' ? (
|
||||
imagePreviewUrl ? (
|
||||
<ImagePreview
|
||||
dataUrl={imagePreviewUrl}
|
||||
src={imagePreviewUrl}
|
||||
fileName={fileName}
|
||||
label={t('file.page.imagePreviewAlt', { name: fileName })}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user