diff --git a/src/editor/core/cm/clipboard.test.ts b/src/editor/core/cm/clipboard.test.ts index 930e7524c..0b8b7fe0e 100644 --- a/src/editor/core/cm/clipboard.test.ts +++ b/src/editor/core/cm/clipboard.test.ts @@ -1,9 +1,9 @@ -import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { EditorState } from "@codemirror/state"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorSelection, EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { commands } from "@/platform/ipc"; import { isRemoteWindow } from "@/platform/remote"; -import { clipboardDocumentPath, clipboardFilesExtension, pasteEditorClipboard } from "./clipboard"; +import { clipboardDocumentPath, clipboardFilesExtension, pasteEditorClipboard, pasteEditorText } from "./clipboard"; import { markdownImages } from "./markdownImages"; vi.mock("@/platform/remote", () => ({ isRemoteWindow: vi.fn(() => false) })); @@ -204,3 +204,70 @@ it("does not insert into a read-only editor", async () => { expect(view.state.doc.toString()).toBe(""); expect(list).not.toHaveBeenCalled(); }); + +describe("multi-caret paste", () => { + const view = (doc: string, ranges: Array<[number, number]>) => { + const v = new EditorView({ + state: EditorState.create({ + doc, + selection: EditorSelection.create( + ranges.length > 1 + ? ranges.map(([from, to]) => EditorSelection.range(from, to)) + : [EditorSelection.cursor(ranges[0]![0])], + ), + extensions: EditorState.allowMultipleSelections.of(true), + }), + parent: document.body, + }); + views.push(v); + return v; + }; + + it("pastes one line per caret when the counts match", () => { + const line = 'icjsdiocjosid "" cdscsd'; + // A caret inside each pair of quotes (index 15), as in the issue. + const v = view( + Array(7).fill(line).join("\n"), + Array.from({ length: 7 }, (_, i) => { + const from = i * (line.length + 1) + 15; + return [from, from] as [number, number]; + }), + ); + pasteEditorText(v, "s\nd\nd\ns\nd\nd\ne"); + expect(v.state.doc.toString()).toBe( + Array.from({ length: 7 }, (_, i) => `icjsdiocjosid "${"sddsdde"[i]}" cdscsd`).join("\n"), + ); + }); + + it("keeps a trailing newline as the last caret's empty line (issue #1828)", () => { + const line = 'icjsdiocjosid "" cdscsd'; + // Seven carets, six copied lines with a trailing newline: split keeps the + // empty seventh segment, so the last caret gets "" as the issue expects. + const v = view( + Array(7).fill(line).join("\n"), + Array.from({ length: 7 }, (_, i) => { + const from = i * (line.length + 1) + 15; + return [from, from] as [number, number]; + }), + ); + pasteEditorText(v, "s\nd\nd\ns\nd\nd\n"); + expect(v.state.doc.toString()).toBe( + Array.from({ length: 7 }, (_, i) => `icjsdiocjosid "${"sddsdd"[i] ?? ""}" cdscsd`).join("\n"), + ); + }); + + it("pastes the whole text at every caret when the counts differ", () => { + 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"); + }); + + it("pastes the whole text at a single caret", () => { + const v = view("a", [[1, 1]]); + pasteEditorText(v, "x\ny"); + expect(v.state.doc.toString()).toBe("ax\ny"); + }); +}); diff --git a/src/editor/core/cm/clipboard.ts b/src/editor/core/cm/clipboard.ts index d370299d2..4a4e8b283 100644 --- a/src/editor/core/cm/clipboard.ts +++ b/src/editor/core/cm/clipboard.ts @@ -1,4 +1,4 @@ -import { Facet, Prec } from "@codemirror/state"; +import { EditorSelection, Facet, Prec } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard"; import { isRemoteWindow } from "@/platform/remote"; @@ -13,9 +13,35 @@ function clipboardFileFormatter(view: EditorView): ((paths: string[]) => string) : undefined; } +/** + * 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. + * `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) { + let i = 0; + const spec = view.state.changeByRange((range) => { + const insert = lines[i++]!; + return { + changes: { from: range.from, to: range.to, insert }, + range: EditorSelection.cursor(range.from + insert.length), + }; + }); + view.dispatch({ ...spec, userEvent, scrollIntoView: true }); + } else { + view.dispatch(view.state.replaceSelection(text), { userEvent, scrollIntoView: true }); + } +} + export function pasteEditorText(view: EditorView, text: string) { if (!text || !view.dom.isConnected || view.state.readOnly) return; - view.dispatch(view.state.replaceSelection(text), { userEvent: "input.paste", scrollIntoView: true }); + dispatchPaste(view, text); view.focus(); } diff --git a/src/editor/core/extras.ts b/src/editor/core/extras.ts index 5e1f7d035..e5fb27fce 100644 --- a/src/editor/core/extras.ts +++ b/src/editor/core/extras.ts @@ -6,6 +6,7 @@ import { offsetToPos, posToOffset } from "@/editor/lsp/positions"; import { caretAnchor, openPopup } from "@/editor/lsp/popupStore"; import { executeAction } from "@/platform/actions"; import { clipLabel, clipboardHistory, recordClipboard } from "./clipboardHistory"; +import { dispatchPaste } from "./cm/clipboard"; import { unwrapOptions, type ChainNode, type UnwrapOption } from "./cm/unwrap"; import type { EditorSlot } from "./model/slot"; @@ -13,7 +14,7 @@ const hint = (text: string) => void executeAction("editor.showHint", { text }); export function pasteText(view: EditorView, text: string) { if (view.state.readOnly) return; - view.dispatch(view.state.replaceSelection(text), { userEvent: "input.paste", scrollIntoView: true }); + dispatchPaste(view, text); view.focus(); } @@ -125,6 +126,6 @@ export async function pasteAsPlainText(view: EditorView) { return; } if (!text) return; - view.dispatch(view.state.replaceSelection(text), { userEvent: "input.pastePlain", scrollIntoView: true }); + dispatchPaste(view, text, "input.pastePlain"); view.focus(); }