fix(editor): cover edge cases of multiline paste with multiple cursors (#1853)

dispatchPaste spread the clipboard only when the line count equaled the
caret count and pasted the whole block at every caret otherwise. Follow
IntelliJ's ClipboardTextPerCaretSplitter instead: caret i gets line i,
extra carets get an empty insert, lines beyond the caret count are
dropped, and a single line repeats at every caret unchanged (a lone
trailing newline stays a single line).

Closes #1828

Signed-off-by: Inf1mum <[email protected]>
Co-authored-by: Inf1mum <[email protected]>
This commit is contained in:
Vadim LazukandInf1mum authored and GitHub committed 2026-11-07 00:24:39 +08:00
1 parent c08657ba17
commit 0ac3f82dfb
2 files changed
+58 -7

No files matched your search

+34 -2
View File
@@ -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", () => {
+24 -5
View File
@@ -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);