fix(editor): resync WebKit caret after paste
This commit is contained in:
1 parent
2f2303e1d6
commit
030627cc10
3 files changed
+79
No files matched your search
@@ -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<boolean> {
|
||||
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);
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user