Skip to content

Commit cf378df

Browse files
authored
feat(web): add lightbox preview for chat images (#715)
1 parent 6f2bb7d commit cf378df

4 files changed

Lines changed: 293 additions & 276 deletions

File tree

web/src/components/AssistantChat/messages/MessageAttachments.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { AttachmentMetadata } from '@/types/api'
22
import { FileIcon } from '@/components/FileIcon'
33
import { isImageMimeType } from '@/lib/fileAttachments'
4+
import { ImagePreview } from '@/components/ImagePreview'
45

56
function formatFileSize(bytes: number): string {
67
if (bytes < 1024) return `${bytes} B`
@@ -11,18 +12,20 @@ function formatFileSize(bytes: number): string {
1112
function ImageAttachment(props: { attachment: AttachmentMetadata }) {
1213
const { attachment } = props
1314
return (
14-
<div className="relative overflow-hidden rounded-lg">
15-
<img
16-
src={attachment.previewUrl}
17-
alt={attachment.filename}
18-
className="max-h-48 max-w-full object-contain"
19-
/>
20-
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5">
21-
<span className="text-xs text-white/90 line-clamp-1">
22-
{attachment.filename}
23-
</span>
24-
</div>
25-
</div>
15+
<ImagePreview
16+
src={attachment.previewUrl ?? ''}
17+
fileName={attachment.filename}
18+
label={attachment.filename}
19+
buttonClassName="relative overflow-hidden rounded-lg text-left cursor-zoom-in"
20+
imageClassName="max-h-48 max-w-full object-contain"
21+
caption={(
22+
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5">
23+
<span className="text-xs text-white/90 line-clamp-1">
24+
{attachment.filename}
25+
</span>
26+
</div>
27+
)}
28+
/>
2629
)
2730
}
2831

web/src/components/AssistantChat/messages/ToolMessage.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { ToolCard } from '@/components/ToolCard/ToolCard'
1414
import { useHappyChatContext } from '@/components/AssistantChat/context'
1515
import { CliOutputBlock } from '@/components/CliOutputBlock'
1616
import { UserBubbleContent, getUserBubbleClassName, shouldShowMessageStatus } from '@/components/AssistantChat/messages/user-bubble'
17+
import { ImagePreview } from '@/components/ImagePreview'
1718

1819
function isToolCallBlock(value: unknown): value is ToolCallBlock {
1920
if (!isObject(value)) return false
@@ -84,11 +85,12 @@ function GeneratedImageCard(props: { block: GeneratedImageBlock }) {
8485
Generated image · {props.block.fileName}
8586
</div>
8687
{objectUrl ? (
87-
<img
88+
<ImagePreview
8889
src={objectUrl}
89-
alt={props.block.fileName}
90-
className="max-h-[min(28rem,60vh)] max-w-full rounded-xl object-contain"
91-
draggable={false}
90+
fileName={props.block.fileName}
91+
label={props.block.fileName}
92+
buttonClassName="block max-w-full cursor-zoom-in rounded-xl text-left"
93+
imageClassName="max-h-[min(28rem,60vh)] max-w-full rounded-xl object-contain"
9294
/>
9395
) : error ? (
9496
<div className="text-sm text-[var(--app-hint)]">
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { useCallback, useEffect, useRef, useState, type PointerEvent, type ReactNode, type WheelEvent } from 'react'
2+
import { CloseIcon } from '@/components/icons'
3+
4+
const MIN_IMAGE_SCALE = 0.25
5+
const MAX_IMAGE_SCALE = 8
6+
const IMAGE_SCALE_STEP = 0.25
7+
8+
function clampImageScale(value: number): number {
9+
return Math.min(MAX_IMAGE_SCALE, Math.max(MIN_IMAGE_SCALE, value))
10+
}
11+
12+
type ImagePoint = { x: number; y: number }
13+
14+
function getPointDistance(a: ImagePoint, b: ImagePoint): number {
15+
return Math.hypot(a.x - b.x, a.y - b.y)
16+
}
17+
18+
function getPointCenter(a: ImagePoint, b: ImagePoint): ImagePoint {
19+
return {
20+
x: (a.x + b.x) / 2,
21+
y: (a.y + b.y) / 2
22+
}
23+
}
24+
25+
export function ImagePreview(props: {
26+
src: string
27+
fileName: string
28+
label: string
29+
buttonClassName?: string
30+
imageClassName?: string
31+
caption?: ReactNode
32+
}) {
33+
const [viewerOpen, setViewerOpen] = useState(false)
34+
const [scale, setScale] = useState(1)
35+
const [offset, setOffset] = useState({ x: 0, y: 0 })
36+
const scaleRef = useRef(scale)
37+
const offsetRef = useRef(offset)
38+
const activePointersRef = useRef(new Map<number, ImagePoint>())
39+
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null)
40+
const pinchRef = useRef<{ startDistance: number; startScale: number; startCenter: ImagePoint; origin: ImagePoint } | null>(null)
41+
42+
const updateScale = useCallback((next: number | ((current: number) => number)) => {
43+
setScale((current) => {
44+
const value = typeof next === 'function' ? next(current) : next
45+
scaleRef.current = value
46+
return value
47+
})
48+
}, [])
49+
50+
const updateOffset = useCallback((next: ImagePoint) => {
51+
offsetRef.current = next
52+
setOffset(next)
53+
}, [])
54+
55+
const resetView = useCallback(() => {
56+
updateScale(1)
57+
updateOffset({ x: 0, y: 0 })
58+
}, [updateOffset, updateScale])
59+
60+
const closeViewer = useCallback(() => {
61+
setViewerOpen(false)
62+
activePointersRef.current.clear()
63+
dragRef.current = null
64+
pinchRef.current = null
65+
resetView()
66+
}, [resetView])
67+
68+
const zoomBy = useCallback((delta: number) => {
69+
updateScale((current) => clampImageScale(current + delta))
70+
}, [updateScale])
71+
72+
const handleWheel = useCallback((event: WheelEvent<HTMLDivElement>) => {
73+
event.preventDefault()
74+
const delta = event.deltaY < 0 ? IMAGE_SCALE_STEP : -IMAGE_SCALE_STEP
75+
zoomBy(delta)
76+
}, [zoomBy])
77+
78+
const beginPinch = useCallback(() => {
79+
const pointers = Array.from(activePointersRef.current.values())
80+
if (pointers.length < 2) return
81+
82+
const [first, second] = pointers
83+
pinchRef.current = {
84+
startDistance: getPointDistance(first, second),
85+
startScale: scaleRef.current,
86+
startCenter: getPointCenter(first, second),
87+
origin: offsetRef.current
88+
}
89+
dragRef.current = null
90+
}, [])
91+
92+
const handlePointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
93+
if (event.button !== 0) return
94+
event.currentTarget.setPointerCapture(event.pointerId)
95+
activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
96+
97+
if (activePointersRef.current.size >= 2) {
98+
beginPinch()
99+
return
100+
}
101+
102+
dragRef.current = {
103+
pointerId: event.pointerId,
104+
startX: event.clientX,
105+
startY: event.clientY,
106+
originX: offsetRef.current.x,
107+
originY: offsetRef.current.y
108+
}
109+
}, [beginPinch])
110+
111+
const handlePointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
112+
if (!activePointersRef.current.has(event.pointerId)) return
113+
activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
114+
115+
if (activePointersRef.current.size >= 2 && pinchRef.current) {
116+
const pointers = Array.from(activePointersRef.current.values())
117+
const [first, second] = pointers
118+
const distance = getPointDistance(first, second)
119+
const center = getPointCenter(first, second)
120+
const pinch = pinchRef.current
121+
const nextScale = pinch.startDistance > 0
122+
? clampImageScale(pinch.startScale * (distance / pinch.startDistance))
123+
: pinch.startScale
124+
125+
updateScale(nextScale)
126+
updateOffset({
127+
x: pinch.origin.x + center.x - pinch.startCenter.x,
128+
y: pinch.origin.y + center.y - pinch.startCenter.y
129+
})
130+
return
131+
}
132+
133+
const drag = dragRef.current
134+
if (!drag || drag.pointerId !== event.pointerId) return
135+
updateOffset({
136+
x: drag.originX + event.clientX - drag.startX,
137+
y: drag.originY + event.clientY - drag.startY
138+
})
139+
}, [updateOffset, updateScale])
140+
141+
const handlePointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => {
142+
activePointersRef.current.delete(event.pointerId)
143+
if (dragRef.current?.pointerId === event.pointerId) {
144+
dragRef.current = null
145+
}
146+
pinchRef.current = null
147+
148+
const remainingPointer = activePointersRef.current.entries().next().value as [number, ImagePoint] | undefined
149+
if (remainingPointer) {
150+
dragRef.current = {
151+
pointerId: remainingPointer[0],
152+
startX: remainingPointer[1].x,
153+
startY: remainingPointer[1].y,
154+
originX: offsetRef.current.x,
155+
originY: offsetRef.current.y
156+
}
157+
}
158+
}, [])
159+
160+
useEffect(() => {
161+
if (!viewerOpen) return
162+
163+
const handleKeyDown = (event: KeyboardEvent) => {
164+
if (event.key === 'Escape') {
165+
closeViewer()
166+
}
167+
if (event.key === '0') {
168+
resetView()
169+
}
170+
if (event.key === '+' || event.key === '=') {
171+
zoomBy(IMAGE_SCALE_STEP)
172+
}
173+
if (event.key === '-') {
174+
zoomBy(-IMAGE_SCALE_STEP)
175+
}
176+
}
177+
178+
window.addEventListener('keydown', handleKeyDown)
179+
return () => window.removeEventListener('keydown', handleKeyDown)
180+
}, [closeViewer, resetView, viewerOpen, zoomBy])
181+
182+
return (
183+
<>
184+
<button
185+
type="button"
186+
onClick={() => setViewerOpen(true)}
187+
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'}
188+
title="Click to zoom"
189+
>
190+
<img
191+
src={props.src}
192+
alt={props.label}
193+
className={props.imageClassName ?? 'max-h-[calc(100vh-14rem)] max-w-full object-contain transition-transform group-hover:scale-[1.01]'}
194+
draggable={false}
195+
/>
196+
{props.caption}
197+
<span className="sr-only">{props.fileName}</span>
198+
</button>
199+
200+
{viewerOpen ? (
201+
<div
202+
className="fixed inset-0 z-50 flex flex-col bg-black/90 text-white"
203+
role="dialog"
204+
aria-modal="true"
205+
aria-label={props.label}
206+
>
207+
<div className="flex items-center gap-2 border-b border-white/10 bg-black/50 px-3 py-2">
208+
<div className="min-w-0 flex-1 truncate text-sm font-medium">{props.fileName}</div>
209+
<button
210+
type="button"
211+
onClick={() => zoomBy(-IMAGE_SCALE_STEP)}
212+
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40"
213+
disabled={scale <= MIN_IMAGE_SCALE}
214+
title="Zoom out"
215+
>
216+
217+
</button>
218+
<button
219+
type="button"
220+
onClick={resetView}
221+
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20"
222+
title="Reset zoom"
223+
>
224+
{Math.round(scale * 100)}%
225+
</button>
226+
<button
227+
type="button"
228+
onClick={() => zoomBy(IMAGE_SCALE_STEP)}
229+
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40"
230+
disabled={scale >= MAX_IMAGE_SCALE}
231+
title="Zoom in"
232+
>
233+
+
234+
</button>
235+
<button
236+
type="button"
237+
onClick={closeViewer}
238+
className="flex h-8 w-8 items-center justify-center rounded bg-white/10 hover:bg-white/20"
239+
title="Close"
240+
>
241+
<CloseIcon className="h-4 w-4" />
242+
</button>
243+
</div>
244+
<div
245+
className="relative min-h-0 flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing"
246+
onWheel={handleWheel}
247+
onPointerDown={handlePointerDown}
248+
onPointerMove={handlePointerMove}
249+
onPointerUp={handlePointerUp}
250+
onPointerCancel={handlePointerUp}
251+
onDoubleClick={resetView}
252+
>
253+
<img
254+
src={props.src}
255+
alt={props.label}
256+
draggable={false}
257+
className="absolute left-1/2 top-1/2 max-h-[90vh] max-w-[90vw] select-none object-contain"
258+
style={{
259+
transform: `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px)) scale(${scale})`,
260+
transformOrigin: 'center center'
261+
}}
262+
/>
263+
</div>
264+
</div>
265+
) : null}
266+
</>
267+
)
268+
}

0 commit comments

Comments
 (0)