diff --git a/web/src/features/messages/AttachmentPreview.tsx b/web/src/features/messages/AttachmentPreview.tsx index 6579dc0d..2240fc18 100644 --- a/web/src/features/messages/AttachmentPreview.tsx +++ b/web/src/features/messages/AttachmentPreview.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Icon } from '@/app/Icon'; -import { useAuthedBlobUrl } from '@/lib/hooks'; +import { useAuthedBlobUrl, useEscapeKey } from '@/lib/hooks'; import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments'; interface ImageData { @@ -12,10 +12,122 @@ interface ImageData { height?: number; } -export function ImageAttachment({ data }: { data: ImageData }) { +const LIGHTBOX_ZOOM_MIN = 1; +const LIGHTBOX_ZOOM_MAX = 4; +const LIGHTBOX_ZOOM_STEP = 0.5; + +/** Full-screen "查看大图" overlay opened by tapping a chat image -- `src` is the SAME blob/data URI + * ImageAttachment already resolved (real full-resolution image; the 260x170 thumbnail is only a CSS + * crop of it, not a separately-fetched smaller asset), so opening this needs no extra network work. + * Zoom is CSS transform scale/translate on the `` itself, not a native browser zoom -- wheel + * and the +/- buttons zoom around the center; dragging only does anything once zoomed in, otherwise + * the image has nowhere to pan to and a drag gesture would just fight the backdrop's click-to-close. */ +function ImageLightbox({ src, onClose }: { src: string; onClose: () => void }) { + const [zoom, setZoom] = useState(LIGHTBOX_ZOOM_MIN); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const imgRef = useRef(null); + const dragRef = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(null); + + useEscapeKey(onClose); + + const clampZoom = (z: number) => Math.min(LIGHTBOX_ZOOM_MAX, Math.max(LIGHTBOX_ZOOM_MIN, z)); + const applyZoom = (next: number) => { + const clamped = clampZoom(next); + setZoom(clamped); + if (clamped === LIGHTBOX_ZOOM_MIN) setPan({ x: 0, y: 0 }); + }; + const zoomIn = () => applyZoom(zoom + LIGHTBOX_ZOOM_STEP); + const zoomOut = () => applyZoom(zoom - LIGHTBOX_ZOOM_STEP); + const toggleZoom = () => applyZoom(zoom > LIGHTBOX_ZOOM_MIN ? LIGHTBOX_ZOOM_MIN : LIGHTBOX_ZOOM_MAX); + + // Native listener with `passive:false`, not JSX `onWheel` -- React's synthetic wheel handler isn't + // guaranteed non-passive across versions/browsers, and a passive listener's preventDefault() is + // silently ignored, which would leave the page scrolling underneath the lightbox while "zooming". + useEffect(() => { + const el = imgRef.current; + if (!el) return undefined; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + applyZoom(zoom + (e.deltaY < 0 ? LIGHTBOX_ZOOM_STEP : -LIGHTBOX_ZOOM_STEP)); + }; + el.addEventListener('wheel', onWheel, { passive: false }); + return () => el.removeEventListener('wheel', onWheel); + // eslint-disable-next-line react-hooks/exhaustive-deps -- re-binds each render so onWheel always + // closes over the current `zoom`; the listener itself is cheap to re-add and this avoids a stale + // closure that would zoom from an outdated level. + }, [zoom]); + + const onMouseDown = (e: React.MouseEvent) => { + if (zoom <= LIGHTBOX_ZOOM_MIN) return; + e.stopPropagation(); + dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y }; + }; + const onMouseMove = (e: React.MouseEvent) => { + if (!dragRef.current) return; + setPan({ x: dragRef.current.panX + (e.clientX - dragRef.current.startX), y: dragRef.current.panY + (e.clientY - dragRef.current.startY) }); + }; + const endDrag = () => { + dragRef.current = null; + }; + + return ( +
+
e.stopPropagation()}> + + {Math.round(zoom * 100)}% + + +
+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- pan/zoom drag + target inside an already-dismissible dialog; alt="" is correct here, same reasoning as + ImageAttachment's own thumbnail (chat image content has no meaningful alt text to give it). */} + e.stopPropagation()} + onDoubleClick={(e) => { + e.stopPropagation(); + toggleZoom(); + }} + onMouseDown={onMouseDown} + onMouseMove={onMouseMove} + onMouseUp={endDrag} + onMouseLeave={endDrag} + style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }} + className={`max-h-[85vh] max-w-[85vw] select-none rounded object-contain ${zoom > LIGHTBOX_ZOOM_MIN ? 'cursor-grab active:cursor-grabbing' : ''}`} + /> +
+ ); +} + +/** `locked` (MessageBubble's selectMode): a tap must only toggle the row's own selection, same + * reasoning as QuoteBar/the @mention buttons going inert during bulk-select. Renders the same + * thumbnail either way -- only the click behavior (and the lightbox it would open) is disabled, so + * entering select mode doesn't visually change every image bubble in the list. */ +export function ImageAttachment({ data, locked }: { data: ImageData; locked?: boolean }) { const inline = typeof data.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null; const remote = useAuthedBlobUrl(inline ? undefined : data.ref); const src = inline || remote.url; + const [viewing, setViewing] = useState(false); if (!src) { return ( @@ -25,7 +137,16 @@ export function ImageAttachment({ data }: { data: ImageData }) { ); } // README §3.2: fixed 260x170, not a same-aspect-ratio-preserving max-height/width box. - return ; + const thumb = ; + if (locked) return thumb; + return ( + <> + + {viewing ? setViewing(false)} /> : null} + + ); } interface FileData { @@ -36,7 +157,11 @@ interface FileData { size?: number; } -export function FileAttachment({ data }: { data: FileData }) { +/** `locked`: see ImageAttachment's own comment -- a tap during selectMode must only toggle the row, + * not start a download (which `preventDefault` blocks for an ordinary click; middle-click/"open in + * new tab" aren't specially guarded against, matching this codebase's existing level of rigor for + * other locked-during-selectMode controls). */ +export function FileAttachment({ data, locked }: { data: FileData; locked?: boolean }) { const style = fileTypeStyle(data.name, data.mime); const remote = useAuthedBlobUrl(typeof data.val === 'string' ? undefined : data.ref); const inline = typeof data.val === 'string' ? `data:${data.mime || 'application/octet-stream'};base64,${data.val}` : null; @@ -47,7 +172,9 @@ export function FileAttachment({ data }: { data: FileData }) { href={href} download={data.name} onClick={(e) => { - if (remote.error) { + if (locked) { + e.preventDefault(); + } else if (remote.error) { e.preventDefault(); remote.retry(); } else if (!href) { @@ -93,8 +220,11 @@ function formatDuration(ms: number | undefined): string { * favorites' identical `VD` entity shape, docs/design_handoff plan file Part D) -- not paintable as * an ``, so the static poster comes from the separate inline `preview` field (a real JPEG, * confirmed by its base64 signature) instead. Tapping swaps the poster for a real `