Skip to content

Commit f75a850

Browse files
committed
feat(editor): 剪贴板图片粘贴与外链图下载落盘(v0.15 ⑤)
1 parent 7b769ef commit f75a850

6 files changed

Lines changed: 207 additions & 6 deletions

File tree

‎app/src/components/NoteEditView.tsx‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useSta
99
import { invoke } from "@tauri-apps/api/core";
1010
import { open } from "@tauri-apps/plugin-dialog";
1111
import type { Note } from "../types";
12+
import { useClipboardImagePaste } from "../hooks/useClipboardImagePaste";
1213
// H2:插入/包裹改为纯函数(返回新字符串+光标位置)——受控更新走 setContent;
1314
// A1 段落/层级操作同款纯函数(自本组件抽出,可单测)
1415
import {
@@ -188,6 +189,18 @@ const NoteEditView = forwardRef<NoteEditHandle, Props>(function NoteEditView({ n
188189
});
189190
};
190191

192+
// v0.15:剪贴板图片粘贴(与 RichEditorView 同入口纪律——提取即拦截 → 落盘
193+
// → 插入引用;applyEdit 定义于此后调用——闭包运行时绑定已初始化)
194+
const handleImagePaste = useClipboardImagePaste({
195+
noteId: note.id,
196+
onInsert: (rel) => {
197+
const ta = taRef.current;
198+
if (!ta) return;
199+
applyEdit(insertAtCursor(ta.value, ta.selectionStart, ta.selectionEnd, `![图片](${rel})\n`));
200+
},
201+
onError: (msg) => setStatus(msg),
202+
});
203+
191204
// 工具栏操作(v0.10.1:local-image 需异步导入——dialog 选文件后复制进
192205
// data_dir 再插入相对引用;import 失败明确提示不落脏数据)
193206
const toolbarAction = async (action: string) => {
@@ -228,10 +241,18 @@ const NoteEditView = forwardRef<NoteEditHandle, Props>(function NoteEditView({ n
228241
}
229242
case "latex": applyEdit(wrapSelection(v, s, e, "$", "$")); break;
230243
case "image": {
231-
// 外部链接图(保留原 prompt URL 入口)
244+
// v0.15:外链图自动下载复制(防源站删除丢资源)——失败降级插原 URL + 提示
232245
const alt = prompt("图片描述:") || "";
233246
const url = prompt("图片链接:", "https://");
234-
if (url) applyEdit(insertAtCursor(v, s, e, `![${alt}](${url})\n`));
247+
if (!url) break;
248+
try {
249+
const rel = await invoke<string>("import_note_image_url", { noteId: note.id, url });
250+
// 异步返回后重读光标(dialog 期间选区可能已变)
251+
applyEdit(insertAtCursor(ta.value, ta.selectionStart, ta.selectionEnd, `![${alt}](${rel})\n`));
252+
} catch (err) {
253+
applyEdit(insertAtCursor(ta.value, ta.selectionStart, ta.selectionEnd, `![${alt}](${url})\n`));
254+
setStatus(`外链图下载失败(已插入原链接——可能随源站点删除而失效): ${err}`);
255+
}
235256
break;
236257
}
237258
case "local-image": {
@@ -299,7 +320,7 @@ const NoteEditView = forwardRef<NoteEditHandle, Props>(function NoteEditView({ n
299320
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("table")} title="表格">⊞ 表格</button>
300321
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("link")} title="链接">🔗</button>
301322
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("local-image")} title="插入本地图片(复制进应用数据目录)">🖼 图片</button>
302-
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("image")} title="插入外部链接图">🌐 链接图</button>
323+
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("image")} title="插入外链图(自动下载为本地副本)">🌐 链接图</button>
303324
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("latex")} title="LaTeX">Σ</button>
304325
<span style={{ flex: 1 }} />
305326
{/* A1段落操作提示 */}
@@ -330,6 +351,7 @@ const NoteEditView = forwardRef<NoteEditHandle, Props>(function NoteEditView({ n
330351
value={content}
331352
onChange={(e) => { setContent(e.target.value); setDirty(true); }}
332353
onKeyDown={handleKeyDown}
354+
onPaste={(e) => { handleImagePaste(e); }}
333355
onBlur={() => { if (dirty) void saveDraft(false); }}
334356
autoFocus // v0.12.2:新建即编辑态聚焦首行(去摩擦——零对话框新建后直接可输入)
335357
style={{ flex: 1, padding: 16, fontSize: 14, lineHeight: 1.8, border: "none", outline: "none", resize: "none", fontFamily: "monospace", background: "#fcfcfc" }}

‎app/src/components/RichEditorView.tsx‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { EditorView, keymap } from "@codemirror/view";
1818
import type { Note } from "../types";
1919
import { useCodeMirror } from "../hooks/useCodeMirror";
2020
import { useNoteAutosave } from "../hooks/useNoteAutosave";
21+
import { useClipboardImagePaste } from "../hooks/useClipboardImagePaste";
2122
import { headingCommand, headingKeymap } from "../commands/headingCommand";
2223
import { insertTextCommand, wrapSelectionCommand } from "../commands/toolbarCommands";
2324
import { imageDecorationPlugin } from "./imageDecorationPlugin";
@@ -66,6 +67,26 @@ const RichEditorView = forwardRef<NoteEditHandle, Props>(function RichEditorView
6667
useEffect(() => { onCancelRef.current = onCancel; }, [onCancel]);
6768
useEffect(() => { onImageOpenRef.current = onImageOpen; }, [onImageOpen]);
6869

70+
// ── v0.15 剪贴板图片:粘贴即落盘(import_note_image_b64)+ 插入相对引用 ──
71+
const onInsertImage = useCallback((rel: string) => {
72+
const view = viewRef.current;
73+
if (!view) return;
74+
const main = view.state.selection.main;
75+
const line = view.state.doc.lineAt(main.head);
76+
// 空行粘贴:带尾换行(独立行图片 block 化,不留行内空隙);行中粘贴:纯内联
77+
const text = line.text.trim() === "" ? `![图片](${rel})\n` : `![图片](${rel})`;
78+
view.dispatch({
79+
changes: { from: main.from, to: main.to, insert: text },
80+
selection: { anchor: main.from + text.length },
81+
userEvent: "input.paste",
82+
});
83+
}, []);
84+
const handleImagePaste = useClipboardImagePaste({
85+
noteId: note.id,
86+
onInsert: onInsertImage,
87+
onError: (msg) => setStatus(msg),
88+
});
89+
6990
// ── 保存 + 草稿层(双计时器/flushLatest/卸载保存/草稿节流,spec §4.4)──
7091
const getSnapshot = useCallback(() => ({ title: titleRef.current, content: contentRef.current }), []);
7192
const autosave = useNoteAutosave({
@@ -102,9 +123,11 @@ const RichEditorView = forwardRef<NoteEditHandle, Props>(function RichEditorView
102123
{ key: "Mod-s", run: () => { autosave.saveVersioned(); return true; } },
103124
{ key: "Mod-e", run: () => { void autosave.flushLatestRef.current().finally(() => onCancelRef.current()); return true; } },
104125
]),
126+
// v0.15:粘贴图片 → 拦截 + 落盘 + 插入引用(文字粘贴走默认行为)
127+
EditorView.domEventHandlers({ paste: (e) => handleImagePaste(e) }),
105128
imageDecorationPlugin({ noteId: note.id, onOpen: (url, t) => onImageOpenRef.current?.(url, t) }),
106129
editorTheme,
107-
], [note.id, autosave.saveVersioned, autosave.flushLatestRef]);
130+
], [note.id, autosave.saveVersioned, autosave.flushLatestRef, handleImagePaste]);
108131

109132
const { containerRef, viewRef } = useCodeMirror({
110133
doc: content,
@@ -141,9 +164,17 @@ const RichEditorView = forwardRef<NoteEditHandle, Props>(function RichEditorView
141164
}
142165
case "latex": wrapSelectionCommand("$", "$")(view); break;
143166
case "image": {
167+
// v0.15:外链图自动下载复制(防源站删除丢资源)——失败降级插原 URL + 提示
144168
const alt = prompt("图片描述:") || "";
145169
const url = prompt("图片链接:", "https://");
146-
if (url) insertTextCommand(`![${alt}](${url})\n`)(view);
170+
if (!url) break;
171+
try {
172+
const rel = await invoke<string>("import_note_image_url", { noteId: note.id, url });
173+
insertTextCommand(`![${alt}](${rel})\n`)(view);
174+
} catch (err) {
175+
insertTextCommand(`![${alt}](${url})\n`)(view);
176+
setStatus(`外链图下载失败(已插入原链接——可能随源站点删除而失效): ${err}`);
177+
}
147178
break;
148179
}
149180
case "local-image": {
@@ -214,7 +245,7 @@ const RichEditorView = forwardRef<NoteEditHandle, Props>(function RichEditorView
214245
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("table")} title="表格">⊞ 表格</button>
215246
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("link")} title="链接">🔗</button>
216247
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("local-image")} title="插入本地图片(复制进应用数据目录)">🖼 图片</button>
217-
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("image")} title="插入外部链接图">🌐 链接图</button>
248+
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("image")} title="插入外链图(自动下载为本地副本)">🌐 链接图</button>
218249
<button style={TOOLBAR_BTN} onClick={() => void toolbarAction("latex")} title="LaTeX">Σ</button>
219250
<span style={{ flex: 1 }} />
220251
<span style={{ fontSize: 10, color: "#9ca3af" }}>Ctrl+Shift+↑↓层级 · Ctrl+Z 撤销</span>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* useClipboardImagePaste — 编辑态剪贴板图片导入(v0.15)。
3+
*
4+
* @ai-context: 提取(同步)→ 拦截默认粘贴 → base64 上送 import_note_image_b64
5+
* (落盘 notes-images/{nid}/)→ onInsert 回调把相对引用写进编辑器
6+
* (CM transaction / textarea 受控插入;CM decoration 自动重算 →
7+
* 图片即时内联显示)。同步返回 handled:true=已拦截(调用方不得再走
8+
* 默认粘贴);false=无图片(默认文本粘贴)——DOM 事件同步契约。
9+
*/
10+
import { useCallback, useEffect, useRef } from "react";
11+
import { invoke } from "@tauri-apps/api/core";
12+
import { blobToBase64 } from "../utils/blobToBase64";
13+
import { extractClipboardImage, IMAGE_IMPORT_MAX_BYTES } from "../utils/clipboardImage";
14+
15+
interface Options {
16+
noteId: number;
17+
/** 导入成功 → 插入 `![图片](相对引用)`(调用方把引用写进编辑器) */
18+
onInsert: (rel: string) => void;
19+
/** 失败提示(status 区承接) */
20+
onError?: (msg: string) => void;
21+
}
22+
23+
export function useClipboardImagePaste({ noteId, onInsert, onError }: Options) {
24+
const noteIdRef = useRef(noteId);
25+
const onInsertRef = useRef(onInsert);
26+
const onErrorRef = useRef(onError);
27+
useEffect(() => { noteIdRef.current = noteId; }, [noteId]);
28+
useEffect(() => { onInsertRef.current = onInsert; }, [onInsert]);
29+
useEffect(() => { onErrorRef.current = onError; }, [onError]);
30+
31+
const handleImagePaste = useCallback((e: ClipboardEvent | React.ClipboardEvent): boolean => {
32+
const img = extractClipboardImage(e);
33+
if (!img) return false;
34+
// 同步拦截:异步流程期间剪贴板事件已结束——preventDefault 必须在手势内
35+
e.preventDefault();
36+
void (async () => {
37+
if (img.blob.size > IMAGE_IMPORT_MAX_BYTES) {
38+
throw new Error("图片超过大小上限(10MB)");
39+
}
40+
const b64 = await blobToBase64(img.blob);
41+
const rel = await invoke<string>("import_note_image_b64", { noteId: noteIdRef.current, imageB64: b64 });
42+
onInsertRef.current(rel);
43+
})().catch((err) => onErrorRef.current?.(`剪贴板图片导入失败: ${err}`));
44+
return true;
45+
}, []);
46+
47+
return handleImagePaste;
48+
}

‎app/src/utils/blobToBase64.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* blobToBase64 — Blob → base64 工具(v0.14 C1 快速记录/ v0.15 剪贴板图片共用)。
3+
*
4+
* @ai-context: 分块转换(CHUNK=0x8000)——大截图直接 String.fromCharCode(...buf)
5+
* 会栈溢出(apply 参数个数上限);Tauri IPC 图片走 base64 字符串
6+
* (同 capture_fragment 先例——数字数组序列化 10MB 图≈35MB JSON)。
7+
*/
8+
export async function blobToBase64(blob: Blob): Promise<string> {
9+
const buf = new Uint8Array(await blob.arrayBuffer());
10+
let bin = "";
11+
const CHUNK = 0x8000;
12+
for (let i = 0; i < buf.length; i += CHUNK) {
13+
bin += String.fromCharCode(...buf.subarray(i, i + CHUNK));
14+
}
15+
return btoa(bin);
16+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* clipboardImage.test.ts — 剪贴板图片提取纯函数单测(v0.15)。
3+
*
4+
* @ai-context: 结构契约——image/* 文件项 → {blob, mime};无图片/空剪贴板 → null
5+
* (调用方走默认文本粘贴);多图取首张。纯函数无副作用。
6+
*/
7+
import { describe, expect, it } from "vitest";
8+
import { extractClipboardImage, IMAGE_IMPORT_MAX_BYTES } from "./clipboardImage";
9+
10+
/** 构造最小粘贴事件桩(ClipboardEvent 构造器 jsdom 不支持 clipboardData——结构桩) */
11+
function fakePaste(items: { kind: string; type: string; getAsFile?: () => Blob | null }[]): ClipboardEvent {
12+
return {
13+
clipboardData: {
14+
items: items.map((it) => ({
15+
kind: it.kind,
16+
type: it.type,
17+
getAsFile: it.getAsFile ?? null,
18+
})),
19+
},
20+
} as unknown as ClipboardEvent;
21+
}
22+
23+
describe("extractClipboardImage", () => {
24+
it("image/* 文件项提取为 {blob, mime}", () => {
25+
const blob = new Blob(["x"], { type: "image/png" });
26+
const e = fakePaste([{ kind: "file", type: "image/png", getAsFile: () => blob }]);
27+
const img = extractClipboardImage(e);
28+
expect(img).not.toBeNull();
29+
expect(img?.mime).toBe("image/png");
30+
expect(img?.blob).toBe(blob);
31+
});
32+
33+
it("纯文本粘贴(无图片项)→ null(走默认文本粘贴)", () => {
34+
const e = fakePaste([{ kind: "string", type: "text/plain" }]);
35+
expect(extractClipboardImage(e)).toBeNull();
36+
});
37+
38+
it("多图取首张", () => {
39+
const first = new Blob(["a"], { type: "image/png" });
40+
const e = fakePaste([
41+
{ kind: "file", type: "image/png", getAsFile: () => first },
42+
{ kind: "file", type: "image/jpeg", getAsFile: () => new Blob(["b"], { type: "image/jpeg" }) },
43+
]);
44+
expect(extractClipboardImage(e)?.blob).toBe(first);
45+
});
46+
47+
it("空剪贴板/无 clipboardData → null", () => {
48+
expect(extractClipboardImage({ clipboardData: null } as unknown as ClipboardEvent)).toBeNull();
49+
expect(extractClipboardImage({} as ClipboardEvent)).toBeNull();
50+
});
51+
52+
it("大小上限与后端对齐(10MB)", () => {
53+
expect(IMAGE_IMPORT_MAX_BYTES).toBe(10 * 1024 * 1024);
54+
});
55+
});

‎app/src/utils/clipboardImage.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* clipboardImage — 剪贴板图片提取与导入(v0.15 编辑态粘贴图片)。
3+
*
4+
* @ai-context: 入口纪律——CM domEventHandlers.paste 与 textarea onPaste 共用同一
5+
* 提取逻辑(event.clipboardData 同步读取,WebView2 paste 手势下无
6+
* 权限弹窗);提取到图片即同步 preventDefault(默认粘贴会丢弃图片),
7+
* 导入成功才插入引用——失败 status 提示不落脏内容(能力降级不失效)。
8+
*/
9+
10+
export interface ClipboardImage {
11+
blob: Blob;
12+
mime: string;
13+
}
14+
15+
/** 从粘贴事件提取首张图片(无图片返回 null——调用方走默认文本粘贴) */
16+
export function extractClipboardImage(e: ClipboardEvent | React.ClipboardEvent): ClipboardImage | null {
17+
const items = e.clipboardData?.items;
18+
if (!items) return null;
19+
for (const item of Array.from(items)) {
20+
if (item.kind === "file" && item.type.startsWith("image/")) {
21+
const blob = item.getAsFile();
22+
if (blob) return { blob, mime: item.type };
23+
}
24+
}
25+
return null;
26+
}
27+
28+
/** 前端预检上限(与后端 IMAGE_MAX_BYTES 对齐——超限提前拦截省 IPC 传输) */
29+
export const IMAGE_IMPORT_MAX_BYTES = 10 * 1024 * 1024;

0 commit comments

Comments
 (0)