diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index bf236ef2ad..536f75cff9 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -182,6 +182,7 @@ import { createDbxCodeMirrorSqlDialect, type CodeMirrorSqlDialectName } from "@/ import { sqlSemanticTableNameSpansForSyntaxTree } from "@/lib/editor/codemirrorSqlSemanticHighlight"; import { startsQueryEditorRectangularSelection, startsQueryEditorSelectionDrag, usesQueryEditorObjectNavigationModifier } from "@/lib/editor/queryEditorPointerSelection"; import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste"; +import { queryEditorClipboardPasteChange } from "@/lib/editor/queryEditorClipboardPaste"; import { computePasteCaretResyncTarget } from "@/lib/editor/queryEditorPasteCaretResync"; import { queryEditorCommentTokens, queryEditorLineCommentToken, queryEditorWordLanguageData } from "@/lib/editor/queryEditorLineComment"; import { createShellLineCommentHighlight } from "@/lib/editor/codemirrorShellLineCommentHighlight"; @@ -1910,8 +1911,7 @@ async function pasteClipboardSqlFromContextMenu() { const selection = currentView.state.selection.main; // 粘贴:替换选中内容或在光标处插入 currentView.dispatch({ - changes: { from: selection.from, to: selection.to, insert: text }, - selection: { anchor: selection.from + text.length, head: selection.from + text.length }, + ...queryEditorClipboardPasteChange(text, selection.from, selection.to), scrollIntoView: true, userEvent: "input.paste", }); diff --git a/apps/desktop/src/lib/editor/__tests__/queryEditorClipboardPaste.spec.ts b/apps/desktop/src/lib/editor/__tests__/queryEditorClipboardPaste.spec.ts new file mode 100644 index 0000000000..fb221371a5 --- /dev/null +++ b/apps/desktop/src/lib/editor/__tests__/queryEditorClipboardPaste.spec.ts @@ -0,0 +1,46 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { queryEditorClipboardPasteChange } from "@/lib/editor/queryEditorClipboardPaste"; + +const EXCEL_ROWS = "1\tA\r\n2\tB\r\n3\tC"; + +test("caret lands at the end of the normalized paste text", () => { + const change = queryEditorClipboardPasteChange(EXCEL_ROWS, 10, 20); + assert.equal(change.changes.insert, "1\tA\n2\tB\n3\tC"); + assert.equal(change.changes.from, 10); + assert.equal(change.changes.to, 20); + assert.deepEqual(change.selection, { anchor: 10 + change.changes.insert.length, head: 10 + change.changes.insert.length }); +}); + +test("pasting CRLF text over a selection that reaches the end of the document keeps the caret in range", () => { + const doc = "SELECT 1;\n".repeat(120); + const state = EditorState.create({ doc }); + const transaction = state.update({ + ...queryEditorClipboardPasteChange(EXCEL_ROWS, 0, state.doc.length), + scrollIntoView: true, + userEvent: "input.paste", + }); + assert.equal(transaction.state.doc.toString(), "1\tA\n2\tB\n3\tC"); + assert.equal(transaction.state.selection.main.anchor, transaction.state.doc.length); +}); + +test("raw clipboard length would overshoot the document (the reported RangeError)", () => { + const doc = "SELECT 1;\n".repeat(120); + const state = EditorState.create({ doc }); + assert.throws( + () => + state.update({ + changes: { from: 0, to: state.doc.length, insert: EXCEL_ROWS }, + selection: { anchor: EXCEL_ROWS.length }, + userEvent: "input.paste", + }), + /Selection points outside of document/, + ); +}); + +test("LF clipboard text keeps the same caret as before", () => { + const change = queryEditorClipboardPasteChange("a\nb", 3, 3); + assert.deepEqual(change.changes, { from: 3, to: 3, insert: "a\nb" }); + assert.deepEqual(change.selection, { anchor: 6, head: 6 }); +}); diff --git a/apps/desktop/src/lib/editor/queryEditorClipboardPaste.ts b/apps/desktop/src/lib/editor/queryEditorClipboardPaste.ts new file mode 100644 index 0000000000..00f9249d09 --- /dev/null +++ b/apps/desktop/src/lib/editor/queryEditorClipboardPaste.ts @@ -0,0 +1,22 @@ +import { normalizeQueryEditorPasteText } from "./queryEditorLargePaste"; + +export interface QueryEditorPasteChange { + changes: { from: number; to: number; insert: string }; + selection: { anchor: number; head: number }; +} + +/** + * Build the "replace [from, to) with clipboard text" transaction for the query editor. + * + * CodeMirror normalizes the line endings of inserted text (`\r\n` and `\r` become `\n`), so a + * caret derived from the raw clipboard length overshoots the new document whenever the clipboard + * holds CRLF text (Excel rows, Windows editors) and `dispatch` throws + * `RangeError: Selection points outside of document` -- it only survives while the replaced + * selection leaves at least that many characters behind it. Deriving the caret from the text + * that actually lands in the document keeps the paste working for any selection. + */ +export function queryEditorClipboardPasteChange(rawText: string, from: number, to: number): QueryEditorPasteChange { + const insert = normalizeQueryEditorPasteText(rawText); + const caret = from + insert.length; + return { changes: { from, to, insert }, selection: { anchor: caret, head: caret } }; +} diff --git a/docs/screenshots/issue-9570-after.png b/docs/screenshots/issue-9570-after.png new file mode 100644 index 0000000000..4993d93a3d Binary files /dev/null and b/docs/screenshots/issue-9570-after.png differ diff --git a/docs/screenshots/issue-9570-before.png b/docs/screenshots/issue-9570-before.png new file mode 100644 index 0000000000..6abbc0cebd Binary files /dev/null and b/docs/screenshots/issue-9570-before.png differ