diff --git a/src/editor/core/cm/clipboard.test.ts b/src/editor/core/cm/clipboard.test.ts index 0b8b7fe0e..4839a2994 100644 --- a/src/editor/core/cm/clipboard.test.ts +++ b/src/editor/core/cm/clipboard.test.ts @@ -256,13 +256,45 @@ describe("multi-caret paste", () => { ); }); - it("pastes the whole text at every caret when the counts differ", () => { + it("drops the extra lines when there are more lines than carets (IDEA parity)", () => { + // IntelliJ's ClipboardTextPerCaretSplitter gives caret i line i and simply + // ignores the lines beyond the caret count: 2 carets + "x\ny\nz" → "x", "y". const v = view("a b", [ [1, 1], [3, 3], ]); pasteEditorText(v, "x\ny\nz"); - expect(v.state.doc.toString()).toBe("ax\ny\nz bx\ny\nz"); + expect(v.state.doc.toString()).toBe("ax by"); + }); + + it("pastes empty lines into the extra carets when there are more carets than lines", () => { + const v = view("a b c", [ + [1, 1], + [3, 3], + [5, 5], + ]); + pasteEditorText(v, "x\ny"); + expect(v.state.doc.toString()).toBe("ax by c"); + }); + + it("repeats a single line at every caret", () => { + const v = view("a b", [ + [1, 1], + [3, 3], + ]); + pasteEditorText(v, "xyz"); + expect(v.state.doc.toString()).toBe("axyz bxyz"); + }); + + it("treats a single copied line with a trailing newline as one line (IDEA parity)", () => { + // IntelliJ's splitter ignores the trailing newline of a one-line clipboard, + // so "x\n" at two carets repeats "x" instead of spreading "x" and "". + const v = view("a b", [ + [1, 1], + [3, 3], + ]); + pasteEditorText(v, "x\n"); + expect(v.state.doc.toString()).toBe("ax bx"); }); it("pastes the whole text at a single caret", () => { diff --git a/src/editor/core/cm/clipboard.ts b/src/editor/core/cm/clipboard.ts index 4a4e8b283..a82ba5881 100644 --- a/src/editor/core/cm/clipboard.ts +++ b/src/editor/core/cm/clipboard.ts @@ -15,16 +15,19 @@ function clipboardFileFormatter(view: EditorView): ((paths: string[]) => string) /** * The dispatch shared by every paste entry point. With several carets and a - * clipboard of exactly one line per caret, each caret gets its own line - * (JetBrains behavior): `"a\nb"` at two carets inserts `a` then `b`, not the - * block twice. Any other count pastes the full text at every caret. + * multi-line clipboard, the lines spread across the carets the way IntelliJ + * does (its ClipboardTextPerCaretSplitter): caret *i* gets line *i*, missing + * lines become empty inserts, and lines beyond the caret count are dropped — + * `"a\nb"` at two carets inserts `a` then `b`, not the block twice, while a + * single line (a trailing newline doesn't make it multi-line) repeats at every + * caret without the newline. * `userEvent` is `"input.paste"` unless the caller passes another (plain-text * paste opts out of Reformat on Paste through `"input.pastePlain"`). */ export function dispatchPaste(view: EditorView, text: string, userEvent = "input.paste") { const ranges = view.state.selection.ranges; - const lines = text.replace(/\r\n?/g, "\n").split("\n"); - if (ranges.length > 1 && lines.length === ranges.length) { + const lines = pasteLinesPerCaret(text, ranges.length); + if (lines) { let i = 0; const spec = view.state.changeByRange((range) => { const insert = lines[i++]!; @@ -39,6 +42,22 @@ export function dispatchPaste(view: EditorView, text: string, userEvent = "input } } +/** + * The per-caret inserts for `text` at `caretCount` carets, mirroring IntelliJ's + * ClipboardTextPerCaretSplitter: line *i* for caret *i*, `""` for the carets + * left over when there are more carets than lines, and the lines beyond the + * caret count dropped. A clipboard of a single line — a lone trailing newline + * is an artifact of copying, not an extra empty line — repeats at every caret + * without its newline. `undefined` keeps the single-caret fallback. + */ +function pasteLinesPerCaret(text: string, caretCount: number): string[] | undefined { + if (caretCount <= 1) return undefined; + const lines = text.replace(/\r\n?/g, "\n").split("\n"); + if (lines.length === 2 && lines[1] === "") lines.pop(); + if (lines.length === 1) return Array(caretCount).fill(lines[0]!); + return Array.from({ length: caretCount }, (_, i) => lines[i] ?? ""); +} + export function pasteEditorText(view: EditorView, text: string) { if (!text || !view.dom.isConnected || view.state.readOnly) return; dispatchPaste(view, text);