fix(editor): keep selection highlights aligned during dragging

This commit is contained in:
C.J.S authored and GitHub committed 2026-09-18 17:34:14 +08:00
1 parent 985d55ba85
commit 06bbf99e3d
2 files changed
+171 -7

No files matched your search

@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { indentMore } from "@codemirror/commands";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -67,6 +68,70 @@ describe("selectionMatchOccurrences", () => {
});
});
describe("deferred selection match highlights", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
document.body.replaceChildren();
});
it("rebuilds highlights only after pointer selection updates settle", async () => {
const view = createView("foo one\nfoo two\nfoo three");
view.dispatch({ selection: { anchor: 0, head: 3 }, userEvent: "select.pointer" });
expect(view.dom.querySelectorAll(".cm-selectionMatch")).toHaveLength(0);
// Later pointer updates must not rebuild the marks mid-drag.
view.dispatch({ selection: { anchor: 0, head: 4 }, userEvent: "select.pointer" });
await vi.advanceTimersByTimeAsync(SELECTION_MATCH_UPDATE_DELAY_MS - 1);
expect(view.dom.querySelectorAll(".cm-selectionMatch")).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1);
// The selected occurrence itself keeps the editor's selection background.
expect(view.dom.querySelectorAll(".cm-selectionMatch")).toHaveLength(2);
view.destroy();
});
it("clears highlights as soon as the selection collapses", async () => {
const view = createView("foo one\nfoo two\nfoo three");
view.dispatch({ selection: { anchor: 0, head: 3 }, userEvent: "select.pointer" });
await vi.advanceTimersByTimeAsync(SELECTION_MATCH_UPDATE_DELAY_MS);
expect(view.dom.querySelectorAll(".cm-selectionMatch")).toHaveLength(2);
view.dispatch({ selection: { anchor: 3 } });
expect(view.dom.querySelectorAll(".cm-selectionMatch")).toHaveLength(0);
view.destroy();
});
it("keeps existing highlights aligned while a document rescan is pending", async () => {
const view = createView("foo one\nfoo two\nfoo three", EditorSelection.range(0, 3));
await vi.advanceTimersByTimeAsync(SELECTION_MATCH_UPDATE_DELAY_MS);
expect(Array.from(view.dom.querySelectorAll(".cm-selectionMatch"), (node) => node.textContent)).toEqual(["foo", "foo"]);
expect(indentMore(view)).toBe(true);
expect(Array.from(view.dom.querySelectorAll(".cm-selectionMatch"), (node) => node.textContent)).toEqual(["foo", "foo"]);
await vi.advanceTimersByTimeAsync(SELECTION_MATCH_UPDATE_DELAY_MS);
expect(Array.from(view.dom.querySelectorAll(".cm-selectionMatch"), (node) => node.textContent)).toEqual(["foo", "foo"]);
view.destroy();
});
it("drops pending work when the view is destroyed", async () => {
const view = createView("foo one\nfoo two\nfoo three");
view.dispatch({ selection: { anchor: 0, head: 3 }, userEvent: "select.pointer" });
view.destroy();
await vi.advanceTimersByTimeAsync(SELECTION_MATCH_UPDATE_DELAY_MS + 40);
expect(document.querySelectorAll(".cm-selectionMatch")).toHaveLength(0);
});
});
describe("SelectionMatchMarkerScan", () => {
it("finds a match that crosses a scan chunk boundary exactly once", () => {
const prefix = "x".repeat(SELECTION_MATCH_SCAN_CHUNK_LENGTH - 2);
@@ -1,10 +1,11 @@
import type { EditorState, Extension } from "@codemirror/state";
import { highlightSelectionMatches, SearchCursor } from "@codemirror/search";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { Annotation, type EditorState, type Extension, type Range } from "@codemirror/state";
import { SearchCursor } from "@codemirror/search";
import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
const MIN_SELECTION_MATCH_LENGTH = 2;
const MAX_SELECTION_MATCH_LENGTH = 200;
const MAX_SCROLLBAR_MATCHES = 1200;
const MAX_SELECTION_MATCH_HIGHLIGHTS = 500;
export const SELECTION_MATCH_SCAN_CHUNK_LENGTH = 64 * 1024;
export const SELECTION_MATCH_UPDATE_DELAY_MS = 120;
@@ -165,12 +166,110 @@ class SelectionMatchScrollbar {
const selectionMatchScrollbar = ViewPlugin.fromClass(SelectionMatchScrollbar);
interface SelectionMatchHighlightSpec {
query: string;
selectionFrom: number;
selectionTo: number;
}
const selectionMatchDecoration = Decoration.mark({ class: "cm-selectionMatch" });
/** Re-reads the pending decoration set without asking for another rescan. */
const flushSelectionMatchHighlights = Annotation.define<boolean>();
/**
* Same query rules as `@codemirror/search`'s `highlightSelectionMatches` with
* the options this app used (no whole-word filter, no word-around-cursor), so
* only the timing of the visible highlight set changes.
*/
function selectionMatchHighlightSpec(state: EditorState): SelectionMatchHighlightSpec | null {
const selection = state.selection;
if (selection.ranges.length > 1) return null;
const range = selection.main;
if (range.empty) return null;
const length = range.to - range.from;
if (length < MIN_SELECTION_MATCH_LENGTH || length > MAX_SELECTION_MATCH_LENGTH) return null;
const query = state.sliceDoc(range.from, range.to);
return query ? { query, selectionFrom: range.from, selectionTo: range.to } : null;
}
function buildSelectionMatchHighlights(view: EditorView, spec: SelectionMatchHighlightSpec): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const visible of view.visibleRanges) {
const cursor = new SearchCursor(view.state.doc, spec.query, visible.from, visible.to);
for (let match = cursor.next(); !match.done; match = cursor.next()) {
const { from, to } = match.value;
// The selected occurrence keeps the editor's own selection background.
if (from >= spec.selectionTo || to <= spec.selectionFrom) decorations.push(selectionMatchDecoration.range(from, to));
if (decorations.length > MAX_SELECTION_MATCH_HIGHLIGHTS) return Decoration.none;
}
}
return Decoration.set(decorations);
}
class DeferredSelectionMatchHighlighter {
decorations: DecorationSet;
private readonly win: Window;
private updateTimer: number | null = null;
constructor(private readonly view: EditorView) {
this.win = view.dom.ownerDocument.defaultView ?? window;
const spec = selectionMatchHighlightSpec(view.state);
this.decorations = spec ? buildSelectionMatchHighlights(view, spec) : Decoration.none;
}
update(update: ViewUpdate) {
if (update.transactions.some((transaction) => transaction.annotation(flushSelectionMatchHighlights))) {
const spec = selectionMatchHighlightSpec(update.state);
this.decorations = spec ? buildSelectionMatchHighlights(update.view, spec) : Decoration.none;
return;
}
// Clearing is cheap, so a selection that no longer has a highlightable
// query never keeps stale ranges on screen.
if (!selectionMatchHighlightSpec(update.state)) {
this.cancelScheduledUpdate();
if (this.decorations.size) this.decorations = Decoration.none;
return;
}
if (update.docChanged) this.decorations = this.decorations.map(update.changes);
if (update.docChanged || update.selectionSet || update.viewportChanged || update.geometryChanged) this.scheduleUpdate();
}
destroy() {
this.cancelScheduledUpdate();
}
private cancelScheduledUpdate() {
if (this.updateTimer === null) return;
this.win.clearTimeout(this.updateTimer);
this.updateTimer = null;
}
private scheduleUpdate() {
this.cancelScheduledUpdate();
// Rebuilding these marks re-lays out every decorated line, which on WebKit
// costs more than the drag frame itself. Wait for the selection to settle,
// then flush with one transaction so the decorations get picked up.
this.updateTimer = this.win.setTimeout(() => {
this.updateTimer = null;
this.view.dispatch({ annotations: flushSelectionMatchHighlights.of(true) });
}, SELECTION_MATCH_UPDATE_DELAY_MS);
}
}
const selectionMatchHighlighter = ViewPlugin.fromClass(DeferredSelectionMatchHighlighter, {
decorations: (plugin) => plugin.decorations,
});
export function selectionMatchOccurrences(): Extension {
return [
highlightSelectionMatches({
minSelectionLength: MIN_SELECTION_MATCH_LENGTH,
maxMatches: 500,
}),
selectionMatchHighlighter,
selectionMatchScrollbar,
EditorView.theme({
"&": {