网页版支持粘贴图片发送、聊天图片保存到本地

老板反馈:"没有复制粘贴图片功能,只能保存在电脑里面发,图片也没有保存功能"

- 聊天输入框新增粘贴图片检测:剪贴板里有图片(截图工具/复制图片)时直接走发图逻辑,
  不再要求先存到电脑里再用文件选择器上传;普通文本粘贴不受影响
- "查看大图"预览里新增"保存图片"下载按钮,取消息自带的原始文件名,
  取不到时按mime类型生成默认文件名

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-29 02:42:21 +08:00
co-authored by Claude Sonnet 5
parent 9abae5030b
commit 3d48abdf73
2 changed files with 58 additions and 2 deletions
@@ -10,6 +10,24 @@ interface ImageData {
mime?: string; mime?: string;
width?: number; width?: number;
height?: 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_MIN = 1;
@@ -22,7 +40,7 @@ const LIGHTBOX_ZOOM_STEP = 0.5;
* Zoom is CSS transform scale/translate on the `<img>` itself, not a native browser zoom -- wheel * 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 * 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. */ * 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 [zoom, setZoom] = useState(LIGHTBOX_ZOOM_MIN);
const [pan, setPan] = useState({ x: 0, y: 0 }); const [pan, setPan] = useState({ x: 0, y: 0 });
const imgRef = useRef<HTMLImageElement>(null); const imgRef = useRef<HTMLImageElement>(null);
@@ -92,6 +110,20 @@ function ImageLightbox({ src, onClose }: { src: string; onClose: () => void }) {
> >
<Icon name="zoom_in" className="text-[20px]" /> <Icon name="zoom_in" className="text-[20px]" />
</button> </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"> <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]" /> <Icon name="close" className="text-[20px]" />
</button> </button>
@@ -144,7 +176,7 @@ export function ImageAttachment({ data, locked }: { data: ImageData; locked?: bo
<button type="button" onClick={() => setViewing(true)} className="block"> <button type="button" onClick={() => setViewing(true)} className="block">
{thumb} {thumb}
</button> </button>
{viewing ? <ImageLightbox src={src} onClose={() => setViewing(false)} /> : null} {viewing ? <ImageLightbox src={src} filename={imageDownloadName(data)} onClose={() => setViewing(false)} /> : null}
</> </>
); );
} }
@@ -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 `<input type="file">` 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<HTMLTextAreaElement>) => {
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 ( return (
<div className="flex h-full w-full flex-col bg-surface-chat"> <div className="flex h-full w-full flex-col bg-surface-chat">
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-5"> <header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-5">
@@ -1214,6 +1237,7 @@ export function ConversationView() {
value={draft} value={draft}
onChange={handleDraftChange} onChange={handleDraftChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handleComposerPaste}
placeholder="输入消息,Enter 发送,Shift+Enter 换行" 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" className="min-h-[72px] flex-1 resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
/> />