From 97ff542cdda600cee014ce432e2304debe3bd335 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:03:35 +0800 Subject: [PATCH] fix(editor): prevent duplicate Linux clipboard commands (#2392) --- src/main.tsx | 2 + src/platform/webkitClipboard.test.ts | 172 +++++++++++++++++++++++++++ src/platform/webkitClipboard.ts | 39 ++++++ 3 files changed, 213 insertions(+) create mode 100644 src/platform/webkitClipboard.test.ts create mode 100644 src/platform/webkitClipboard.ts diff --git a/src/main.tsx b/src/main.tsx index f1b019cdf..90952ff75 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -17,6 +17,7 @@ import { installRepeatedDoubleClick } from "./platform/doubleClick"; import { installErrorLogging, logError } from "./platform/errorLog"; import { guessOs } from "./platform/platformInfo"; import { installReorderedTapClick } from "./platform/reordered-tap-click"; +import { installWebKitClipboardGuard } from "./platform/webkitClipboard"; import { installPageSwipeTracking } from "./platform/pageSwipe"; import { installImeKeyGuard } from "./platform/ime"; import { installKeyboardDispatcher } from "./platform/keymap"; @@ -38,6 +39,7 @@ installRepeatedDoubleClick(); // second press like the release before it, which the shim would take for a reordered tap and // click twice. if (guessOs() === "macos" && !/Chrome|Firefox/.test(navigator.userAgent)) installReorderedTapClick(); +if (guessOs() === "linux" && isTauri()) installWebKitClipboardGuard(); installWindowDrag(); installPointerFocus(); installImeKeyGuard(); diff --git a/src/platform/webkitClipboard.test.ts b/src/platform/webkitClipboard.test.ts new file mode 100644 index 000000000..2887ffd66 --- /dev/null +++ b/src/platform/webkitClipboard.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { EditorSelection, EditorState, StateEffect } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { installWebKitClipboardGuard } from "./webkitClipboard"; + +const views: EditorView[] = []; +let dispose: () => void; +beforeEach(() => { + dispose = installWebKitClipboardGuard(); +}); +afterEach(() => { + dispose(); + views.splice(0).forEach((view) => view.destroy()); + document.body.replaceChildren(); +}); + +function editor(anchor = 5, head = anchor) { + const view = new EditorView({ + doc: "first\nsecond\nthird", + selection: EditorSelection.single(anchor, head), + parent: document.body, + }); + views.push(view); + view.focus(); + return view; +} + +function key(target: HTMLElement, type = "keydown", init: KeyboardEventInit = { key: "x", ctrlKey: true }) { + const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init }); + target.dispatchEvent(event); + return event; +} + +function clipboard(target: HTMLElement, type = "cut") { + const data = new Map(); + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.assign(event, { clipboardData: { clearData: () => data.clear(), setData: data.set.bind(data) } }); + target.dispatchEvent(event); + return data.get("text/plain"); +} + +describe("WebKitGTK clipboard events", () => { + it.each([ + { key: "x", ctrlKey: true }, + { key: "Delete", shiftKey: true }, + ])("cuts only the caret line for one $key keydown and two cut events (#962)", (shortcut) => { + const view = editor(); + key(view.contentDOM, "keydown", shortcut); + expect(clipboard(view.contentDOM)).toBe("first"); + expect(clipboard(view.contentDOM)).toBeUndefined(); + expect(view.state.doc.toString()).toBe("second\nthird"); + }); + + it.each([0, 2, 5, 6, 12, 18])("protects a caret at offset %i", (position) => { + const view = editor(position); + const line = view.state.doc.lineAt(position); + const expected = view.state.doc.toString().slice(0, line.from) + view.state.doc.toString().slice(line.to + 1); + key(view.contentDOM); + expect(clipboard(view.contentDOM)).toBe(line.text); + clipboard(view.contentDOM); + expect(view.state.doc.toString()).toBe(expected); + }); + + it("preserves a selected-text cut and the following line", () => { + const view = editor(0, 5); + key(view.contentDOM); + expect(clipboard(view.contentDOM)).toBe("first"); + clipboard(view.contentDOM); + expect(view.state.doc.toString()).toBe("\nsecond\nthird"); + }); + + it("cuts multiple caret lines once", () => { + const view = editor(); + view.dispatch({ effects: StateEffect.appendConfig.of(EditorState.allowMultipleSelections.of(true)) }); + view.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(5), EditorSelection.cursor(12)]) }); + key(view.contentDOM); + expect(clipboard(view.contentDOM)).toBe("first\nsecond"); + clipboard(view.contentDOM); + expect(view.state.doc.toString()).toBe("third"); + }); + + it("keeps a read-only document unchanged", () => { + const view = editor(); + view.dispatch({ effects: StateEffect.appendConfig.of(EditorState.readOnly.of(true)) }); + key(view.contentDOM); + expect(clipboard(view.contentDOM)).toBe("first"); + clipboard(view.contentDOM); + expect(view.state.doc.toString()).toBe("first\nsecond\nthird"); + }); + + it("suppresses duplicate copies without changing the document", () => { + const view = editor(); + key(view.contentDOM, "keydown", { key: "c", ctrlKey: true }); + expect(clipboard(view.contentDOM, "copy")).toBe("first"); + expect(clipboard(view.contentDOM, "copy")).toBeUndefined(); + expect(view.state.doc.toString()).toBe("first\nsecond\nthird"); + }); + + it.each(["cut", "copy"])("cancels the keypress fallback after the first %s", (type) => { + const view = editor(); + const down = key(view.contentDOM); + expect(down.defaultPrevented).toBe(false); + clipboard(view.contentDOM, type); + expect(down.defaultPrevented).toBe(true); + }); + + it("allows successive key presses and auto-repeat", () => { + const view = editor(); + for (const repeat of [false, true]) { + key(view.contentDOM, "keydown", { key: "x", ctrlKey: true, repeat }); + expect(clipboard(view.contentDOM)).toBe(repeat ? "second" : "first"); + clipboard(view.contentDOM); + } + expect(view.state.doc.toString()).toBe("third"); + }); + + it.each(["keyup", "pointerdown", "blur"])("allows menu cuts after %s", (type) => { + const view = editor(); + key(view.contentDOM); + clipboard(view.contentDOM); + if (type === "blur") window.dispatchEvent(new Event(type)); + else view.contentDOM.dispatchEvent(new Event(type, { bubbles: true })); + expect(clipboard(view.contentDOM)).toBe("second"); + }); + + it("does not suppress repeated menu cuts without a keydown", () => { + const view = editor(); + expect(clipboard(view.contentDOM)).toBe("first"); + expect(clipboard(view.contentDOM)).toBe("second"); + }); + + it("allows multiple explicit cuts from an action that handled its key", () => { + const view = editor(); + view.contentDOM.addEventListener("keydown", (event) => event.preventDefault()); + key(view.contentDOM); + expect(clipboard(view.contentDOM)).toBe("first"); + expect(clipboard(view.contentDOM)).toBe("second"); + }); + + it("does not carry a key press into another editor", () => { + const first = editor(); + const second = editor(); + key(first.contentDOM); + clipboard(first.contentDOM); + expect(clipboard(second.contentDOM)).toBe("first"); + expect(clipboard(second.contentDOM)).toBe("second"); + }); + + it("does not intercept plain inputs or unrelated clipboard events", () => { + const input = document.body.appendChild(document.createElement("input")); + let cuts = 0; + input.addEventListener("cut", () => cuts++); + key(input); + clipboard(input); + clipboard(input); + expect(cuts).toBe(2); + const view = editor(); + let pastes = 0; + view.contentDOM.addEventListener("paste", () => pastes++); + key(view.contentDOM); + for (let i = 0; i < 2; i++) view.contentDOM.dispatchEvent(new Event("paste", { bubbles: true })); + expect(pastes).toBe(2); + }); + + it("removes the guard on disposal", () => { + const view = editor(); + key(view.contentDOM); + clipboard(view.contentDOM); + dispose(); + expect(clipboard(view.contentDOM)).toBe("second"); + }); +}); diff --git a/src/platform/webkitClipboard.ts b/src/platform/webkitClipboard.ts new file mode 100644 index 000000000..f3fbe63f1 --- /dev/null +++ b/src/platform/webkitClipboard.ts @@ -0,0 +1,39 @@ +/** WebKitGTK 2.54 can dispatch copy/cut twice for one keydown with a collapsed selection. */ +export function installWebKitClipboardGuard(): () => void { + let pending: { editor: Element; keydown: KeyboardEvent; seen: Set } | null = null; + const reset = () => { + pending = null; + }; + const onKeyDown = (event: KeyboardEvent) => { + const editor = event.target instanceof Element ? event.target.closest(".cm-content") : null; + pending = editor ? { editor, keydown: event, seen: new Set() } : null; + }; + const onClipboard = (event: Event) => { + if (!pending || !(event.target instanceof Element) || event.target.closest(".cm-content") !== pending.editor) + return; + // Actions that already handled the key may intentionally run several clipboard commands (macros). + if (pending.seen.size === 0 && pending.keydown.defaultPrevented) return; + if (pending.seen.has(event.type)) { + event.preventDefault(); + event.stopImmediatePropagation(); + } else { + pending.seen.add(event.type); + // The native command can report failure after CodeMirror handles it. Stop its keypress fallback too. + pending.keydown.preventDefault(); + } + }; + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("keyup", reset, true); + window.addEventListener("pointerdown", reset, true); + window.addEventListener("blur", reset, true); + window.addEventListener("copy", onClipboard, true); + window.addEventListener("cut", onClipboard, true); + return () => { + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("keyup", reset, true); + window.removeEventListener("pointerdown", reset, true); + window.removeEventListener("blur", reset, true); + window.removeEventListener("copy", onClipboard, true); + window.removeEventListener("cut", onClipboard, true); + }; +}