diff --git a/web/src/features/messages/AttachmentPreview.tsx b/web/src/features/messages/AttachmentPreview.tsx index 26b65d74..d5294595 100644 --- a/web/src/features/messages/AttachmentPreview.tsx +++ b/web/src/features/messages/AttachmentPreview.tsx @@ -10,6 +10,24 @@ interface ImageData { 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; @@ -22,7 +40,7 @@ const LIGHTBOX_ZOOM_STEP = 0.5; * 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 }) { +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(null); @@ -92,6 +110,20 @@ function ImageLightbox({ src, onClose }: { src: string; onClose: () => void }) { > + {/* 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 ``, but wasn't discoverable enough; this puts an explicit, visible affordance here, + same place every mainstream IM's own "查看大图" view puts it. */} + + + @@ -144,7 +176,7 @@ export function ImageAttachment({ data, locked }: { data: ImageData; locked?: bo - {viewing ? setViewing(false)} /> : null} + {viewing ? setViewing(false)} /> : null} ); } diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 8b04ac21..08261b4f 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -936,6 +936,29 @@ export function ConversationView() { } }; + /** User report (老板, 2026-09-29): "没有复制粘贴图片功能,只能保存在电脑里面发" -- e.g. a screenshot + * tool, or "复制图片" from a browser/chat app, only ever puts raw pixel data on the clipboard, never + * a File the hidden `` above can see; without this, getting that image into a + * message required saving it to disk first and picking it via handlePickImage. `item.getAsFile()` + * returns a real File, indistinguishable from one chosen through the file picker, so this reuses + * the exact same send path. Only preventDefault once an actual image item is found -- an ordinary + * text paste (no image on the clipboard at all) is left completely untouched. */ + const handleComposerPaste = (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + for (let i = 0; i < items.length; i += 1) { + const item = items[i]; + if (item.kind === 'file' && item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (!file) continue; + e.preventDefault(); + const reply = consumePendingReply(); + sendImage(topic, file, reply).catch((err) => showError(err, '图片发送失败')); + return; + } + } + }; + return (
@@ -1214,6 +1237,7 @@ export function ConversationView() { value={draft} onChange={handleDraftChange} onKeyDown={handleKeyDown} + onPaste={handleComposerPaste} placeholder="输入消息,Enter 发送,Shift+Enter 换行" className="min-h-[72px] flex-1 resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1" />