feat(web): add lightbox preview for chat images (#715)

This commit is contained in:
Ananovo
2026-05-27 21:53:06 +08:00
committed by GitHub
parent 6f2bb7d32b
commit cf378dfe31
4 changed files with 293 additions and 276 deletions
@@ -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"
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5"> caption={(
<span className="text-xs text-white/90 line-clamp-1"> <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5">
{attachment.filename} <span className="text-xs text-white/90 line-clamp-1">
</span> {attachment.filename}
</div> </span>
</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)]">
+268
View File
@@ -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}
</>
)
}
+4 -260
View File
@@ -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 })}
/> />