mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
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:
@@ -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$/);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user