网页版支持粘贴图片发送、聊天图片保存到本地
老板反馈:"没有复制粘贴图片功能,只能保存在电脑里面发,图片也没有保存功能" - 聊天输入框新增粘贴图片检测:剪贴板里有图片(截图工具/复制图片)时直接走发图逻辑, 不再要求先存到电脑里再用文件选择器上传;普通文本粘贴不受影响 - "查看大图"预览里新增"保存图片"下载按钮,取消息自带的原始文件名, 取不到时按mime类型生成默认文件名 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
9abae5030b
commit
3d48abdf73
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user