diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 5ff5ae97ba..778bd9c5b7 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -116,6 +116,7 @@ import { createDbxCodeMirrorSqlDialect, type CodeMirrorSqlDialectName } from "@/ import { sqlSemanticTableNameSpansForSyntaxTree } from "@/lib/editor/codemirrorSqlSemanticHighlight"; import { startsQueryEditorRectangularSelection, usesQueryEditorObjectNavigationModifier } from "@/lib/editor/queryEditorPointerSelection"; import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste"; +import { computePasteCaretResyncTarget } from "@/lib/editor/queryEditorPasteCaretResync"; import { extendQueryEditorSelection, runQueryEditorAltExtendSelection } from "@/lib/editor/queryEditorExtendSelection"; import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation"; import { isSchemaAware, isSingleDatabase, supportsDatabaseNameCompletion, supportsDatabaseSchemaQualifier, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport"; @@ -1487,6 +1488,21 @@ async function pasteClipboardAsSqlInCondition(): Promise { return true; } +// See queryEditorPasteCaretResync.ts for why this nudge is needed (WebKit-only caret bug). +function resyncCaretAfterPaste(view: EditorViewType) { + const EditorSelection = codeMirrorEditorSelection; + if (!EditorSelection) return; + const selection = view.state.selection; + const pos = selection.main.head; + const nudged = computePasteCaretResyncTarget(selection, view.state.doc.length); + if (nudged === null) return; + requestAnimationFrame(() => { + if (!view.dom.isConnected || view.state.selection.ranges.length !== 1 || view.state.selection.main.head !== pos || !view.state.selection.main.empty) return; + view.dispatch({ selection: EditorSelection.cursor(nudged) }); + view.dispatch({ selection: EditorSelection.cursor(pos) }); + }); +} + function recoverLargeTauriPaste(event: ClipboardEvent, currentView: EditorViewType): boolean { const eventText = event.clipboardData?.getData("text/plain") ?? ""; if (props.readOnly || currentView.state.selection.ranges.length !== 1 || !shouldRecoverLargeTauriPaste(eventText, isTauriRuntime())) return false; @@ -4929,6 +4945,9 @@ onMounted(async () => { scheduleSqlCompletionStart(update.view); } } + if (update.transactions.some((tr) => tr.isUserEvent("input.paste"))) { + resyncCaretAfterPaste(update.view); + } } if (update.selectionSet || update.docChanged) { syncContextMenuState(update.view); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorPasteCaretResync.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorPasteCaretResync.spec.ts new file mode 100644 index 0000000000..4cc8f0a4a8 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorPasteCaretResync.spec.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import { EditorSelection } from "@codemirror/state"; +import { describe, expect, it } from "vitest"; +import { computePasteCaretResyncTarget } from "@/lib/editor/queryEditorPasteCaretResync"; + +const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8"); + +describe("computePasteCaretResyncTarget", () => { + it("nudges a single caret forward when there is room", () => { + expect(computePasteCaretResyncTarget(EditorSelection.single(3), 10)).toBe(4); + }); + + it("nudges a single caret backward at the end of the document", () => { + expect(computePasteCaretResyncTarget(EditorSelection.single(10), 10)).toBe(9); + }); + + it("does not collapse multiple CodeMirror cursors", () => { + const selection = EditorSelection.create([EditorSelection.cursor(2), EditorSelection.cursor(6)]); + expect(computePasteCaretResyncTarget(selection, 10)).toBeNull(); + }); + + it("does not replace a non-empty selection with a caret", () => { + expect(computePasteCaretResyncTarget(EditorSelection.single(2, 5), 10)).toBeNull(); + }); + + it("returns null for an empty document", () => { + expect(computePasteCaretResyncTarget(EditorSelection.single(0), 0)).toBeNull(); + }); +}); + +describe("QueryEditor paste caret resync wiring", () => { + it("resyncs only an unchanged single caret after an input.paste transaction", () => { + expect(queryEditorSource).toMatch(/update\.transactions\.some\(\(tr\) => tr\.isUserEvent\("input\.paste"\)\)[\s\S]*?resyncCaretAfterPaste\(update\.view\)/); + expect(queryEditorSource).toMatch(/computePasteCaretResyncTarget\(selection, view\.state\.doc\.length\)/); + }); +}); diff --git a/apps/desktop/src/lib/editor/queryEditorPasteCaretResync.ts b/apps/desktop/src/lib/editor/queryEditorPasteCaretResync.ts new file mode 100644 index 0000000000..46f4a05d73 --- /dev/null +++ b/apps/desktop/src/lib/editor/queryEditorPasteCaretResync.ts @@ -0,0 +1,24 @@ +/** + * WebKit (Safari / macOS WKWebView) bug: after CodeMirror replaces DOM text + * nodes for a paste, `document.getSelection()` reports the correct caret + * position, but WebKit's native typing insertion point silently keeps + * pointing at the pre-paste position — the next typed character lands there + * instead of after the pasted text (e.g. typing "," after pasting "2" into + * "1,|)" produces "1,,2)" instead of "1,2,)"). A genuine subsequent selection + * change (as from an arrow key) forces WebKit to re-anchor correctly. + * + * Returns a nearby position to briefly move the caret to (and back from) + * to replicate that nudge, or null if there is nowhere to nudge to. + */ +export interface PasteCaretSelection { + ranges: readonly unknown[]; + main: { empty: boolean; head: number }; +} + +export function computePasteCaretResyncTarget(selection: PasteCaretSelection, docLength: number): number | null { + if (selection.ranges.length !== 1 || !selection.main.empty) return null; + const head = selection.main.head; + const nudged = head < docLength ? head + 1 : head - 1; + if (nudged === head || nudged < 0) return null; + return nudged; +}