老板反馈:"没有复制粘贴图片功能,只能保存在电脑里面发,图片也没有保存功能" - 聊天输入框新增粘贴图片检测:剪贴板里有图片(截图工具/复制图片)时直接走发图逻辑, 不再要求先存到电脑里再用文件选择器上传;普通文本粘贴不受影响 - "查看大图"预览里新增"保存图片"下载按钮,取消息自带的原始文件名, 取不到时按mime类型生成默认文件名 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
363 lines
17 KiB
TypeScript
363 lines
17 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
|
|
import { Icon } from '@/app/Icon';
|
|
import { useAuthedBlobUrl, useEscapeKey } from '@/lib/hooks';
|
|
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
|
|
|
|
interface ImageData {
|
|
val?: string;
|
|
ref?: string;
|
|
mime?: string;
|
|
width?: number;
|
|
height?: number;
|
|
/** Drafty.insertImage's own `filename` field -- present on every image this WEB client sent
|
|
* (see sendImage in lib/tinode/attachments.ts). */
|
|
filename?: string;
|
|
/** mobileAttachmentContentOf's `name` -- the equivalent field on an image sent from the iOS/Android
|
|
* app, which flows through a completely different (non-Drafty) flat shape than the web client's
|
|
* own `IM` entity above; MessageBubble passes that content straight through as this same ImageData
|
|
* shape, so both need a name field here even though the two clients don't call it the same thing. */
|
|
name?: string;
|
|
}
|
|
|
|
/** Best-effort filename for the "保存图片" download below -- prefers whatever name the sender's own
|
|
* client attached (see ImageData's own field comments), falling back to a generic name derived from
|
|
* the mime type for the inline-base64/mobile-relay cases where neither field is set. */
|
|
function imageDownloadName(data: ImageData): string {
|
|
if (data.filename) return data.filename;
|
|
if (data.name) return data.name;
|
|
const ext = data.mime?.split('/')[1]?.replace('jpeg', 'jpg') || 'jpg';
|
|
return `image.${ext}`;
|
|
}
|
|
|
|
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, filename, onClose }: { src: string; filename: 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>
|
|
{/* Real anchor + `download`, same technique FileAttachment already relies on below -- works
|
|
for both inline `data:` URIs (small images) and the `blob:` URL useAuthedBlobUrl fetches
|
|
for larger ones, triggering a normal save-to-disk with no extra round trip. User report
|
|
(老板, 2026-09-29): "图片也没有保存功能" -- right-click "另存为" technically already works on
|
|
an `<img>`, but wasn't discoverable enough; this puts an explicit, visible affordance here,
|
|
same place every mainstream IM's own "查看大图" view puts it. */}
|
|
<a
|
|
href={src}
|
|
download={filename}
|
|
aria-label="保存图片"
|
|
className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white"
|
|
>
|
|
<Icon name="download" className="text-[20px]" />
|
|
</a>
|
|
<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 (
|
|
<div className="grid h-[170px] w-[260px] place-items-center rounded-lg bg-surface-rail">
|
|
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[28px] text-text-tertiary-3" />
|
|
</div>
|
|
);
|
|
}
|
|
// README §3.2: fixed 260x170, not a same-aspect-ratio-preserving max-height/width box.
|
|
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} filename={imageDownloadName(data)} onClose={() => setViewing(false)} /> : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface FileData {
|
|
val?: string;
|
|
ref?: string;
|
|
mime?: string;
|
|
name?: string;
|
|
size?: number;
|
|
}
|
|
|
|
/** `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;
|
|
const href = inline || remote.url || undefined;
|
|
|
|
return (
|
|
<a
|
|
href={href}
|
|
download={data.name}
|
|
onClick={(e) => {
|
|
if (locked) {
|
|
e.preventDefault();
|
|
} else if (remote.error) {
|
|
e.preventDefault();
|
|
remote.retry();
|
|
} else if (!href) {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
className="flex w-[280px] items-center gap-3 rounded-lg bg-white px-3.5 py-3 no-underline shadow-bubble"
|
|
>
|
|
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg" style={{ backgroundColor: style.bg }}>
|
|
<Icon name={style.icon} className="text-[20px]" style={{ color: style.fg }} />
|
|
</span>
|
|
<span className="flex min-w-0 flex-col">
|
|
<span className="truncate text-sm font-medium text-text-body">{data.name || '文件'}</span>
|
|
<span className={`text-xs ${remote.error ? 'text-danger-text' : 'text-text-tertiary-2'}`}>
|
|
{typeof data.size === 'number' ? formatFileSize(data.size) : ''} ·{' '}
|
|
{remote.error ? '下载失败,点击重试' : remote.loading ? '加载中…' : '点击下载'}
|
|
</span>
|
|
</span>
|
|
<Icon
|
|
name={remote.error ? 'error_outline' : 'download'}
|
|
className={`ml-auto flex-none text-[20px] ${remote.error ? 'text-danger-text' : 'text-text-placeholder-2'}`}
|
|
/>
|
|
</a>
|
|
);
|
|
}
|
|
|
|
interface VideoData {
|
|
val?: string;
|
|
ref?: string;
|
|
mime?: string;
|
|
preview?: string;
|
|
previewRef?: string;
|
|
duration?: number;
|
|
}
|
|
|
|
function formatDuration(ms: number | undefined): string {
|
|
const totalSeconds = Math.round((ms || 0) / 1000);
|
|
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
|
}
|
|
|
|
/** README §3.2: 260x170 like ImageAttachment, `play_circle` overlay + duration badge, no controls
|
|
* until tapped. The video's own `val`/`mime` describe the full video body (confirmed live via
|
|
* 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.
|
|
*
|
|
* `locked`: see ImageAttachment's own comment -- gated in `onClick`, not folded into `disabled`,
|
|
* since a disabled <button> never dispatches `click` at all and would swallow the row-toggle this
|
|
* click is supposed to bubble up to (unlike FileAttachment's plain anchor, this is a real button). */
|
|
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;
|
|
const remote = useAuthedBlobUrl(videoInline ? undefined : data.ref);
|
|
// Mobile-sent videos (see mobileAttachmentContentOf) have no inline base64 poster at all -- their
|
|
// thumbnail is a separately-hosted file (`previewRef`, the same `/v0/file/s/{id}` shape as the
|
|
// video body itself), not the base64 `preview` field this component was built around for the web
|
|
// client's own Drafty `VD` entities (confirmed live, favorites' identical shape). Only fetched when
|
|
// there's no inline poster, same short-circuit `remote` above already uses for the video body.
|
|
const posterRemote = useAuthedBlobUrl(posterInline ? undefined : data.previewRef);
|
|
const posterUrl = posterInline || posterRemote.url;
|
|
const videoSrc = videoInline || remote.url;
|
|
|
|
if (playing && videoSrc) {
|
|
// eslint-disable-next-line jsx-a11y/media-has-caption -- user-generated chat media, no captions exist to attach.
|
|
return <video src={videoSrc} controls autoPlay className="h-[170px] w-[260px] rounded-lg bg-black object-contain" />;
|
|
}
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (locked) return;
|
|
setPlaying(true);
|
|
}}
|
|
disabled={!videoSrc}
|
|
className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail"
|
|
>
|
|
{posterUrl ? (
|
|
<img src={posterUrl} alt="" className="h-full w-full object-cover" />
|
|
) : (
|
|
<div className="grid h-full w-full place-items-center">
|
|
<Icon name={posterRemote.error ? 'broken_image' : 'videocam'} className="text-[28px] text-text-tertiary-3" />
|
|
</div>
|
|
)}
|
|
{videoSrc ? <Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" /> : null}
|
|
{typeof data.duration === 'number' ? (
|
|
<span className="absolute bottom-1.5 right-1.5 rounded bg-black/50 px-1 text-[11px] text-white">{formatDuration(data.duration)}</span>
|
|
) : null}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface AudioData {
|
|
val?: string;
|
|
ref?: string;
|
|
mime?: string;
|
|
duration?: number;
|
|
}
|
|
|
|
/** `locked`: see VideoAttachment's own comment -- gated in `toggle()`, not folded into `disabled`,
|
|
* so the click still bubbles to the row toggle instead of being swallowed by the native button.
|
|
* 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;
|
|
const [playing, setPlaying] = useState(false);
|
|
const [playError, setPlayError] = useState(false);
|
|
const [audioEl] = useState(() => (typeof Audio !== 'undefined' ? new Audio() : null));
|
|
|
|
useEffect(() => {
|
|
if (!audioEl) return undefined;
|
|
const onEnded = () => setPlaying(false);
|
|
audioEl.addEventListener('ended', onEnded);
|
|
// Without this, navigating away (or scrolling this bubble out) while a voice message is still
|
|
// playing leaves it playing in the background with no control left on screen to stop it.
|
|
return () => {
|
|
audioEl.removeEventListener('ended', onEnded);
|
|
audioEl.pause();
|
|
};
|
|
}, [audioEl]);
|
|
|
|
const toggle = () => {
|
|
if (locked) return;
|
|
if (!audioEl || !src) return;
|
|
if (playing) {
|
|
audioEl.pause();
|
|
setPlaying(false);
|
|
return;
|
|
}
|
|
if (audioEl.src !== src) audioEl.src = src;
|
|
setPlayError(false);
|
|
audioEl
|
|
.play()
|
|
.then(() => setPlaying(true))
|
|
.catch(() => setPlayError(true));
|
|
};
|
|
|
|
return (
|
|
<button type="button" onClick={toggle} disabled={!src} 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'}`}
|
|
/>
|
|
<span className="h-4 flex-1 rounded bg-[repeating-linear-gradient(90deg,#b7c3d4_0px,#b7c3d4_2px,transparent_2px,transparent_5px)]" />
|
|
<span className={`flex-none text-xs ${playError ? 'text-danger-text' : 'text-text-secondary'}`}>
|
|
{playError ? '播放失败' : remote.loading ? '…' : formatDuration(data.duration)}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|