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
This commit is contained in:
l0ng-ai
2026-11-01 01:41:58 +08:00
committed by GitHub
parent b3c3150489
commit 0a71f6be88
4 changed files with 175 additions and 16 deletions
+127 -5
View File
@@ -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$/);
});
+20 -3
View File
@@ -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<string, string>({ 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));
},
}),
);
+2 -1
View File
@@ -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),
+26 -7
View File
@@ -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<string> {
export async function readClipboardText(
transfer?: DataTransfer | null,
formatFiles: FormatFiles = rawPaths,
): Promise<string> {
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;