Add click-to-view lightbox with zoom for chat images

Tapping a chat image opens a full-screen viewer (same resolved
image source, no extra fetch) with zoom in/out buttons, mouse-wheel
zoom, drag-to-pan once zoomed, and double-click to toggle zoom.
Wheel zoom uses a native non-passive listener rather than JSX onWheel,
since a passive listener's preventDefault() is silently ignored and
would leave the page scrolling under the lightbox.

Also closes a gap the row-click-to-select change (1cc6f506) left open:
image/video/audio/file attachments inside a bubble had no selectMode
awareness, so tapping one during bulk-select would also open the
lightbox / play media / start a download alongside toggling the row.
Added a `locked` prop to all four attachment components (disabling
their own click behavior without changing how they look) and wired
`locked={selectMode}` from both MessageBubble.tsx and drafty.tsx's
IM/EX/AU/VD cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-25 22:32:07 +08:00
co-authored by Claude Sonnet 5
parent aca5f8609b
commit e3eed8bdd4
3 changed files with 152 additions and 19 deletions
+144 -11
View File
@@ -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 `<img>` 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<HTMLImageElement>(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 (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80" onClick={onClose}>
<div className="absolute right-6 top-6 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={zoomOut}
disabled={zoom <= LIGHTBOX_ZOOM_MIN}
aria-label="缩小"
className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white disabled:opacity-30"
>
<Icon name="zoom_out" className="text-[20px]" />
</button>
<span className="min-w-[3.5em] text-center text-sm text-white">{Math.round(zoom * 100)}%</span>
<button
type="button"
onClick={zoomIn}
disabled={zoom >= LIGHTBOX_ZOOM_MAX}
aria-label="放大"
className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white disabled:opacity-30"
>
<Icon name="zoom_in" className="text-[20px]" />
</button>
<button type="button" onClick={onClose} aria-label="关闭" className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white">
<Icon name="close" className="text-[20px]" />
</button>
</div>
{/* 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). */}
<img
ref={imgRef}
src={src}
alt=""
onClick={(e) => 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' : ''}`}
/>
</div>
);
}
/** `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 <img src={src} alt="" className="h-[170px] w-[260px] rounded-lg object-cover" />;
const thumb = <img src={src} alt="" className="h-[170px] w-[260px] rounded-lg object-cover" />;
if (locked) return thumb;
return (
<>
<button type="button" onClick={() => setViewing(true)} className="block">
{thumb}
</button>
{viewing ? <ImageLightbox src={src} onClose={() => 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 `<img>`, 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 `<video>` playing
* from `val`/`ref`, mirroring AudioAttachment's inline-vs-authenticated-fetch duality. */
export function VideoAttachment({ data }: { data: VideoData }) {
* from `val`/`ref`, mirroring AudioAttachment's inline-vs-authenticated-fetch duality.
*
* `locked`: see ImageAttachment's own comment -- reuses the existing `disabled` (already there for
* a not-yet-loaded video) rather than a second, parallel gating mechanism. */
export function VideoAttachment({ data, locked }: { data: VideoData; locked?: boolean }) {
const [playing, setPlaying] = useState(false);
const posterInline = typeof data.preview === 'string' ? `data:image/jpeg;base64,${data.preview}` : null;
const videoInline = typeof data.val === 'string' ? `data:${data.mime || 'video/webm'};base64,${data.val}` : null;
@@ -116,7 +246,7 @@ export function VideoAttachment({ data }: { data: VideoData }) {
<button
type="button"
onClick={() => setPlaying(true)}
disabled={!videoSrc}
disabled={!videoSrc || locked}
className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail"
>
{posterUrl ? (
@@ -141,7 +271,10 @@ interface AudioData {
duration?: number;
}
export function AudioAttachment({ data }: { data: AudioData }) {
/** `locked`: see ImageAttachment's own comment -- reuses the existing `disabled` (already there for a
* not-yet-loaded voice message) rather than a second, parallel gating mechanism. Ongoing playback
* started BEFORE selectMode was entered is left alone -- this only blocks a NEW toggle action. */
export function AudioAttachment({ data, locked }: { data: AudioData; locked?: boolean }) {
const inline = typeof data.val === 'string' ? `data:${data.mime || 'audio/webm'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
const src = inline || remote.url;
@@ -177,7 +310,7 @@ export function AudioAttachment({ data }: { data: AudioData }) {
};
return (
<button type="button" onClick={toggle} disabled={!src} className="flex w-[200px] items-center gap-2">
<button type="button" onClick={toggle} disabled={!src || locked} className="flex w-[200px] items-center gap-2">
<Icon
name={playError ? 'error_outline' : playing ? 'pause_circle' : 'play_circle'}
className={`text-[24px] ${playError ? 'text-danger-text' : 'text-accent'}`}
+4 -4
View File
@@ -239,13 +239,13 @@ export function MessageRow({
<ContactCardBubble content={contactCard} />
) : mobileAttachment ? (
mobileAttachment.kind === 'image' ? (
<ImageAttachment data={mobileAttachment} />
<ImageAttachment data={mobileAttachment} locked={selectMode} />
) : mobileAttachment.kind === 'video' ? (
<VideoAttachment data={{ ...mobileAttachment, previewRef: mobileAttachment.previewRef }} />
<VideoAttachment data={{ ...mobileAttachment, previewRef: mobileAttachment.previewRef }} locked={selectMode} />
) : mobileAttachment.kind === 'voice' ? (
<AudioAttachment data={mobileAttachment} />
<AudioAttachment data={mobileAttachment} locked={selectMode} />
) : (
<FileAttachment data={mobileAttachment} />
<FileAttachment data={mobileAttachment} locked={selectMode} />
)
) : (
renderDrafty(msg.content, undefined, !selectMode)
+4 -4
View File
@@ -117,13 +117,13 @@ function makeFormatNode(highlightQuery: string | undefined, interactive: boolean
);
}
case 'IM':
return <ImageAttachment key={key} data={data || {}} />;
return <ImageAttachment key={key} data={data || {}} locked={!interactive} />;
case 'EX':
return <FileAttachment key={key} data={data || {}} />;
return <FileAttachment key={key} data={data || {}} locked={!interactive} />;
case 'AU':
return <AudioAttachment key={key} data={data || {}} />;
return <AudioAttachment key={key} data={data || {}} locked={!interactive} />;
case 'VD':
return <VideoAttachment key={key} data={data || {}} />;
return <VideoAttachment key={key} data={data || {}} locked={!interactive} />;
case 'QQ':
// Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain
// style span wrapping mention(header,uid) + linebreak + body, already in `children`.