From 0a71f6be88ee110e525269ff6ffef6fc23f4c91c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:41:58 +0800 Subject: [PATCH] feat(editor): paste copied files as Markdown links (#599) Pasting files copied in the project view into a Markdown document now inserts `[](relative/path)`, one link per line, encoded the same way as Markdown images. Other editors and terminals keep inserting the raw path, copied images keep their ![](...) route, and remote windows keep raw paths because the clipboard holds client paths. A context-menu Paste that the native command rejects falls back to reading the clipboard. Fixes #573 --- src/editor/core/cm/clipboard.test.ts | 132 ++++++++++++++++++++++++++- src/editor/core/cm/clipboard.ts | 23 ++++- src/editor/core/cm/setup.ts | 3 +- src/platform/clipboard.ts | 33 +++++-- 4 files changed, 175 insertions(+), 16 deletions(-) diff --git a/src/editor/core/cm/clipboard.test.ts b/src/editor/core/cm/clipboard.test.ts index 19b32b96..930e7524 100644 --- a/src/editor/core/cm/clipboard.test.ts +++ b/src/editor/core/cm/clipboard.test.ts @@ -2,18 +2,102 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { commands } from "@/platform/ipc"; -import { clipboardFilesExtension, pasteEditorClipboard } from "./clipboard"; +import { isRemoteWindow } from "@/platform/remote"; +import { clipboardDocumentPath, clipboardFilesExtension, pasteEditorClipboard } from "./clipboard"; import { markdownImages } from "./markdownImages"; +vi.mock("@/platform/remote", () => ({ isRemoteWindow: vi.fn(() => false) })); + const views: EditorView[] = []; -beforeEach(() => vi.stubGlobal("__TAURI_INTERNALS__", {})); +beforeEach(() => { + vi.stubGlobal("__TAURI_INTERNALS__", {}); + Object.defineProperty(document, "execCommand", { configurable: true, value: vi.fn(() => false) }); + Object.defineProperty(document, "queryCommandSupported", { configurable: true, value: vi.fn(() => false) }); +}); afterEach(() => { views.splice(0).forEach((view) => view.destroy()); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); -for (const route of ["event", "menu"]) { +for (const route of ["event", "menu", "menu unsupported", "menu rejected"]) { + it.each([ + { name: "file", paths: ["/p/docs/file.txt"], expected: "[](file.txt)" }, + { name: "folder", paths: ["/p/docs/folder"], expected: "[](folder)" }, + { name: "several", paths: ["/p/docs/a.txt", "/p/folder"], expected: "[](a.txt)\n[](../folder)" }, + { + name: "spaces, Chinese, and delimiters", + paths: ["/p/docs/中文/a (1)#!'*.txt"], + expected: "[](%E4%B8%AD%E6%96%87/a%20%281%29%23%21%27%2A.txt)", + }, + { name: "parent relative", paths: ["/p/file.txt"], expected: "[](../file.txt)" }, + { + name: "Windows separators", + path: "C:\\p\\docs\\a.md", + paths: ["C:\\p\\folder\\a b.txt"], + expected: "[](../folder/a%20b.txt)", + }, + { name: "no document path", path: "", paths: ["/p/file.txt"], expected: "/p/file.txt" }, + { name: "non-Markdown", path: "/p/a.ts", paths: ["/p/file.txt"], expected: "/p/file.txt" }, + { + name: "remote window with client file paths", + remote: true, + path: "/home/dev/a.md", + paths: ["/Users/me/file.txt", "/Users/me/中文 文件夹"], + expected: "/Users/me/file.txt\n/Users/me/中文 文件夹", + }, + { + name: "remote window with matching text flavor", + remote: true, + path: "/home/dev/a.md", + text: "C:\\Users\\me\\file.txt", + paths: ["C:/Users/me/file.txt"], + expected: "C:\\Users\\me\\file.txt", + }, + { name: "alternate extension", path: "/p/docs/a.MDX", paths: ["/p/docs/file"], expected: "[](file)" }, + { name: "empty list", paths: [], expected: "" }, + { name: "text path", text: "/p/file.txt", paths: ["/stale"], expected: "/p/file.txt" }, + { name: "plain text", text: "plain text 你好", paths: [], expected: "plain text 你好" }, + { + name: "file list with matching text flavor", + text: "C:\\p\\a.txt\r\nC:\\p\\folder", + path: "C:/p/docs/a.md", + paths: ["C:/p/a.txt", "C:/p/folder"], + expected: "[](../a.txt)\n[](../folder)", + }, + ])(`${route}: Markdown $name`, async ({ path = "/p/docs/a.md", paths, text = "", expected, remote = false }) => { + vi.mocked(isRemoteWindow).mockReturnValue(remote); + const list = vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: paths }); + const view = new EditorView({ + extensions: [clipboardDocumentPath.of(path), clipboardFilesExtension], + parent: document.body, + }); + views.push(view); + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { getData: () => text, files: [], types: ["Files"] }, + }); + if (route === "event") { + view.contentDOM.dispatchEvent(event); + } else { + if (route === "menu") { + vi.mocked(document.queryCommandSupported).mockReturnValue(true); + vi.mocked(document.execCommand).mockImplementation(() => { + view.contentDOM.dispatchEvent(event); + return true; + }); + } + if (route === "menu rejected") vi.mocked(document.queryCommandSupported).mockReturnValue(true); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => text } }); + await pasteEditorClipboard(view); + } + await vi.waitFor(() => expect(view.state.doc.toString()).toBe(expected)); + expect(list).toHaveBeenCalledTimes(remote && text ? 0 : 1); + if (route === "menu rejected" && /\.(md|mdx)$/i.test(path)) { + expect(document.execCommand).toHaveBeenCalledExactlyOnceWith("paste"); + } + }); + it.each([ { name: "text present", text: "plain", paths: ["/stale"], expected: "plain", calls: 0 }, { name: "files plus list", text: "", paths: ["/a b", "/中文"], expected: "/a b\n/中文", calls: 1 }, @@ -32,6 +116,7 @@ for (const route of ["event", "menu"]) { }); view.contentDOM.dispatchEvent(event); } else { + if (route === "menu rejected") vi.mocked(document.queryCommandSupported).mockReturnValue(true); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => text } }); await pasteEditorClipboard(view); } @@ -40,7 +125,35 @@ for (const route of ["event", "menu"]) { }); } -it.each([true, false])("keeps copied PNG and screenshot Markdown image handling (file: %s)", async (hasFile) => { +it("does not fall back while a successful native paste is still pending", async () => { + const readText = vi.fn(async () => "fallback"); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText } }); + vi.mocked(document.queryCommandSupported).mockReturnValue(true); + vi.mocked(document.execCommand).mockReturnValue(true); + const view = new EditorView({ + extensions: [clipboardDocumentPath.of("/p/a.md"), clipboardFilesExtension], + parent: document.body, + }); + views.push(view); + + await pasteEditorClipboard(view); + expect(view.state.doc.toString()).toBe(""); + expect(readText).not.toHaveBeenCalled(); + + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { getData: () => "native text", files: [], types: ["text/plain"] }, + }); + view.contentDOM.dispatchEvent(event); + expect(view.state.doc.toString()).toBe("native text"); +}); + +it.each([ + ["event", true], + ["event", false], + ["menu", true], + ["menu", false], +])("%s: keeps copied PNG and screenshot Markdown image handling (file: %s)", async (route, hasFile) => { vi.stubGlobal( "Worker", class { @@ -58,6 +171,7 @@ it.each([true, false])("keeps copied PNG and screenshot Markdown image handling const create = vi.spyOn(commands, "fsCreateFileBytes").mockResolvedValue({ status: "ok", data: null }); const view = new EditorView({ extensions: [ + clipboardDocumentPath.of("/p/a.md"), clipboardFilesExtension, markdownImages({ path: "/p/a.md", folder: "assets", pattern: "image-{timestamp}" }), ], @@ -68,7 +182,15 @@ it.each([true, false])("keeps copied PNG and screenshot Markdown image handling Object.defineProperty(event, "clipboardData", { value: { getData: () => "", files: [new File(["png"], "image.png", { type: "image/png" })], types: ["Files"] }, }); - view.contentDOM.dispatchEvent(event); + if (route === "menu") { + vi.mocked(document.queryCommandSupported).mockReturnValue(true); + vi.mocked(document.execCommand).mockImplementation(() => { + view.contentDOM.dispatchEvent(event); + return true; + }); + await pasteEditorClipboard(view); + expect(document.execCommand).toHaveBeenCalledWith("paste"); + } else view.contentDOM.dispatchEvent(event); await vi.waitFor(() => expect(view.state.doc.toString()).toMatch(/^!\[\]\(assets\/image.*\.png\)$/)); expect(create.mock.calls[0]![0]).toMatch(hasFile ? /assets\/image\.png$/ : /assets\/image-\d{8}-\d{6}\.png$/); }); diff --git a/src/editor/core/cm/clipboard.ts b/src/editor/core/cm/clipboard.ts index 34bc3eaa..d370299d 100644 --- a/src/editor/core/cm/clipboard.ts +++ b/src/editor/core/cm/clipboard.ts @@ -1,6 +1,17 @@ -import { Prec } from "@codemirror/state"; +import { Facet, Prec } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard"; +import { isRemoteWindow } from "@/platform/remote"; +import { isMarkdown, relativeImagePath } from "./markdownImages"; + +export const clipboardDocumentPath = Facet.define({ combine: (paths) => paths[0] ?? "" }); + +function clipboardFileFormatter(view: EditorView): ((paths: string[]) => string) | undefined { + const path = view.state.facet(clipboardDocumentPath); + return isMarkdown(path) && !isRemoteWindow() + ? (paths) => paths.map((target) => `[](${relativeImagePath(path, target)})`).join("\n") + : undefined; +} export function pasteEditorText(view: EditorView, text: string) { if (!text || !view.dom.isConnected || view.state.readOnly) return; @@ -9,8 +20,14 @@ export function pasteEditorText(view: EditorView, text: string) { } export async function pasteEditorClipboard(view: EditorView) { + if (!view.dom.isConnected || view.state.readOnly) return; try { - pasteEditorText(view, await readClipboardText()); + // Successful native paste preserves image blobs and may complete after execCommand returns. + if (isMarkdown(view.state.facet(clipboardDocumentPath)) && document.queryCommandSupported("paste")) { + view.focus(); + if (document.execCommand("paste")) return; + } + pasteEditorText(view, await readClipboardText(undefined, clipboardFileFormatter(view))); } catch { if (!view.dom.isConnected || view.state.readOnly) return; view.focus(); @@ -23,7 +40,7 @@ export const clipboardFilesExtension = Prec.lowest( EditorView.domEventHandlers({ paste(event, view) { if (view.state.readOnly) return false; - return pasteClipboardFiles(event, (text) => pasteEditorText(view, text)); + return pasteClipboardFiles(event, (text) => pasteEditorText(view, text), clipboardFileFormatter(view)); }, }), ); diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index 87f76181..bcecc965 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -51,7 +51,7 @@ import { editorBaseKeymap } from "./baseKeymap"; import { occurrenceSearch } from "./editing"; import { gutterLineDrag } from "./gutterDrag"; import { overwriteField } from "./overwrite"; -import { clipboardFilesExtension } from "./clipboard"; +import { clipboardDocumentPath, clipboardFilesExtension } from "./clipboard"; import { clipboardHistoryExtension } from "../clipboardHistory"; import type { LanguageInfo } from "../backend"; import { tx } from "@/platform/i18n"; @@ -337,6 +337,7 @@ export function baseExtensions(o: SetupOptions): Extension { overwriteField, gutterLineDrag, clipboardHistoryExtension, + clipboardDocumentPath.of(o.ctx.path), clipboardFilesExtension, indentOnInput(), syntaxHighlighting(openideaHighlightStyle), diff --git a/src/platform/clipboard.ts b/src/platform/clipboard.ts index 850a235f..bab48c0a 100644 --- a/src/platform/clipboard.ts +++ b/src/platform/clipboard.ts @@ -1,13 +1,23 @@ import { call, commands, isTauri } from "./ipc"; +import { ipcPath } from "./paths"; + +type FormatFiles = (paths: string[]) => string; +const rawPaths: FormatFiles = (paths) => paths.join("\n"); /** WebKit exposes native file URLs as Files, hiding the pasteboard's text flavor. */ -export async function readClipboardText(transfer?: DataTransfer | null): Promise { +export async function readClipboardText( + transfer?: DataTransfer | null, + formatFiles: FormatFiles = rawPaths, +): Promise { const text = transfer ? transfer.getData("text/plain") : ((await navigator.clipboard?.readText()) ?? ""); - if (text || (transfer && !hasClipboardFiles(transfer)) || !isTauri()) return text; + if ((text && formatFiles === rawPaths) || (transfer && !hasClipboardFiles(transfer)) || !isTauri()) return text; try { - return (await call(commands.clipboardFileList())).join("\n"); + const paths = await call(commands.clipboardFileList()); + // Windows also supplies the file list as text; unrelated text still wins. + if (text && text.replace(/\r\n/g, "\n").split("\n").map(ipcPath).join("\n") !== paths.join("\n")) return text; + return formatFiles(paths); } catch { - return ""; + return text; } } @@ -16,13 +26,22 @@ function hasClipboardFiles(transfer: DataTransfer): boolean { } /** Claim synchronously so the native handler cannot emit an empty paste while IPC runs. */ -export function pasteClipboardFiles(event: ClipboardEvent, paste: (text: string) => void): boolean { +export function pasteClipboardFiles( + event: ClipboardEvent, + paste: (text: string) => void, + formatFiles: FormatFiles = rawPaths, +): boolean { const transfer = event.clipboardData; - if (event.defaultPrevented || !transfer || transfer.getData("text/plain") || !hasClipboardFiles(transfer)) + if ( + event.defaultPrevented || + !transfer || + (transfer.getData("text/plain") && formatFiles === rawPaths) || + !hasClipboardFiles(transfer) + ) return false; event.preventDefault(); event.stopImmediatePropagation(); - void readClipboardText(transfer).then((text) => { + void readClipboardText(transfer, formatFiles).then((text) => { if (text) paste(text); }); return true;