From ef95cd171690a5bbc0b055ed698f84e85a7c6134 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Fri, 6 Nov 2026 21:40:08 +0800 Subject: [PATCH] fix(editor): paste one line per caret when the counts match (#1830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(editor): paste one line per caret when the counts match Multi-caret paste inserted the whole clipboard at every caret: seven carets and a six-line clipboard produced six full copies inside the first pair of quotes and left the rest untouched. When the clipboard has exactly one line per caret, each caret now gets its own line — the JetBrains behavior the issue expects. Any other count still pastes the full text at every caret. Shared by all three paste entry points (normal, plain text, history) through dispatchPaste. Fixes #1828 * test(editor): cover trailing-newline paste at seven carets --- src/editor/core/cm/clipboard.test.ts | 73 ++++++++++++++++++++++++++-- src/editor/core/cm/clipboard.ts | 30 +++++++++++- src/editor/core/extras.ts | 5 +- 3 files changed, 101 insertions(+), 7 deletions(-) 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(); }