fix(editor): resync WebKit caret after paste

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-08-15 23:29:38 +08:00
1 parent 2f2303e1d6
commit 030627cc10
3 files changed
+79

No files matched your search

@@ -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;
}