From cf378dfe31b7ee1f624d35618b8e2316c31bd381 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Wed, 27 May 2026 21:53:06 +0800 Subject: [PATCH] feat(web): add lightbox preview for chat images (#715) --- .../messages/MessageAttachments.tsx | 27 +- .../AssistantChat/messages/ToolMessage.tsx | 10 +- web/src/components/ImagePreview.tsx | 268 ++++++++++++++++++ web/src/routes/sessions/file.tsx | 264 +---------------- 4 files changed, 293 insertions(+), 276 deletions(-) create mode 100644 web/src/components/ImagePreview.tsx diff --git a/web/src/components/AssistantChat/messages/MessageAttachments.tsx b/web/src/components/AssistantChat/messages/MessageAttachments.tsx index 11eb6b36..4d6f83ec 100644 --- a/web/src/components/AssistantChat/messages/MessageAttachments.tsx +++ b/web/src/components/AssistantChat/messages/MessageAttachments.tsx @@ -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 ( -
- {attachment.filename} -
- - {attachment.filename} - -
-
+ + + {attachment.filename} + + + )} + /> ) } diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index 4d7dfdcd..871b463b 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -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} {objectUrl ? ( - {props.block.fileName} ) : error ? (
diff --git a/web/src/components/ImagePreview.tsx b/web/src/components/ImagePreview.tsx new file mode 100644 index 00000000..6e189338 --- /dev/null +++ b/web/src/components/ImagePreview.tsx @@ -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()) + 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) => { + 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) => { + 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) => { + 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) => { + 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 ( + <> + + + {viewerOpen ? ( +
+
+
{props.fileName}
+ + + + +
+
+ {props.label} +
+
+ ) : null} + + ) +} diff --git a/web/src/routes/sessions/file.tsx b/web/src/routes/sessions/file.tsx index 5c4948ed..a2327404 100644 --- a/web/src/routes/sessions/file.tsx +++ b/web/src/routes/sessions/file.tsx @@ -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 = { 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()) - 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) => { - 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) => { - 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) => { - 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) => { - 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 ( - <> - - - {viewerOpen ? ( -
-
-
{props.fileName}
- - - - -
-
- {props.label} -
-
- ) : null} - - ) -} - export default function FilePage() { const { api } = useAppContext() const { t } = useTranslation() @@ -560,7 +304,7 @@ export default function FilePage() { ) : displayMode === 'file' ? ( imagePreviewUrl ? (