fix(editor): keep drag selection responsive

This commit is contained in:
C.J.S authored and GitHub committed 2026-09-10 11:20:56 +08:00
1 parent dbaa640762
commit 0e32e003f5
2 files changed
+218 -30

No files matched your search

@@ -0,0 +1,113 @@
// @vitest-environment happy-dom
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSelectionMatchMarkerScan, SELECTION_MATCH_SCAN_CHUNK_LENGTH, SELECTION_MATCH_UPDATE_DELAY_MS, selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches";
function createView(doc: string, selection = EditorSelection.cursor(0)) {
const parent = document.createElement("div");
document.body.appendChild(parent);
return new EditorView({
parent,
state: EditorState.create({
doc,
selection,
extensions: [selectionMatchOccurrences()],
}),
});
}
describe("selectionMatchOccurrences", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
document.body.replaceChildren();
});
it("waits for pointer selection updates to settle before rendering scrollbar matches", 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-selectionMatchScrollbarMark")).toHaveLength(0);
await vi.advanceTimersByTimeAsync(SELECTION_MATCH_UPDATE_DELAY_MS - 1);
expect(view.dom.querySelectorAll(".cm-selectionMatchScrollbarMark")).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1);
expect(view.dom.querySelectorAll(".cm-selectionMatchScrollbarMark")).toHaveLength(3);
view.destroy();
});
it("scans large documents incrementally instead of completing in the update callback", async () => {
const prefix = "x".repeat(SELECTION_MATCH_SCAN_CHUNK_LENGTH + 8);
const view = createView(`${prefix}needle`, EditorSelection.range(prefix.length, prefix.length + 6));
await vi.advanceTimersByTimeAsync(0);
expect(view.dom.querySelectorAll(".cm-selectionMatchScrollbarMark")).toHaveLength(0);
await vi.advanceTimersByTimeAsync(20);
expect(view.dom.querySelectorAll(".cm-selectionMatchScrollbarMark")).toHaveLength(1);
view.destroy();
});
it("cancels an in-progress scan when the selection changes", async () => {
const prefix = "x".repeat(SELECTION_MATCH_SCAN_CHUNK_LENGTH + 8);
const view = createView(`${prefix}needle`, EditorSelection.range(prefix.length, prefix.length + 6));
await vi.advanceTimersByTimeAsync(0);
view.dispatch({ selection: { anchor: 0 }, userEvent: "select.pointer" });
await vi.advanceTimersByTimeAsync(SELECTION_MATCH_UPDATE_DELAY_MS + 40);
expect(view.dom.querySelectorAll(".cm-selectionMatchScrollbarMark")).toHaveLength(0);
view.destroy();
});
});
describe("SelectionMatchMarkerScan", () => {
it("finds a match that crosses a scan chunk boundary exactly once", () => {
const prefix = "x".repeat(SELECTION_MATCH_SCAN_CHUNK_LENGTH - 2);
const doc = `${prefix}needle tail`;
const state = EditorState.create({ doc, selection: EditorSelection.range(prefix.length, prefix.length + 6) });
const scan = createSelectionMatchMarkerScan(state)!;
while (!scan.scanNextChunk()) {
// Continue through the remaining chunks.
}
expect(scan.markers).toEqual([{ from: prefix.length, to: prefix.length + 6, lineNumber: 1, selected: true }]);
});
it.each([
{ name: "canonical decomposition", query: "\u00e9x", occurrence: "e\u0301x" },
{ name: "compatibility decomposition", query: "Ax", occurrence: "\u{1d400}x" },
])("finds a $name match across a scan chunk boundary", ({ query, occurrence }) => {
const prefix = "x".repeat(SELECTION_MATCH_SCAN_CHUNK_LENGTH - 1);
const doc = `${prefix}${occurrence}\n${query}`;
const selectedFrom = doc.length - query.length;
const state = EditorState.create({ doc, selection: EditorSelection.range(selectedFrom, doc.length) });
const scan = createSelectionMatchMarkerScan(state)!;
while (!scan.scanNextChunk()) {
// Continue through the remaining chunks.
}
expect(scan.markers).toContainEqual({ from: prefix.length, to: prefix.length + occurrence.length, lineNumber: 1, selected: false });
});
it("caps scanned matches while retaining the selected marker beyond the cap", () => {
const prefix = `${"id ".repeat(100_000)}\n`;
const state = EditorState.create({ doc: `${prefix}id`, selection: EditorSelection.range(prefix.length, prefix.length + 2) });
const scan = createSelectionMatchMarkerScan(state)!;
expect(state.doc.length).toBeGreaterThan(SELECTION_MATCH_SCAN_CHUNK_LENGTH);
expect(scan.scanNextChunk()).toBe(true);
expect(scan.markers).toEqual([
{ from: prefix.length, to: prefix.length + 2, lineNumber: 2, selected: true },
{ from: 0, to: 2, lineNumber: 1, selected: false },
]);
});
});
@@ -4,9 +4,11 @@ import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
const MIN_SELECTION_MATCH_LENGTH = 2;
const MAX_SELECTION_MATCH_LENGTH = 200;
const MAX_SCROLLBAR_MARKS = 1200;
const MAX_SCROLLBAR_MATCHES = 1200;
export const SELECTION_MATCH_SCAN_CHUNK_LENGTH = 64 * 1024;
export const SELECTION_MATCH_UPDATE_DELAY_MS = 120;
interface SelectionMatchMarker {
export interface SelectionMatchMarker {
from: number;
to: number;
lineNumber: number;
@@ -23,57 +25,130 @@ function selectedMatchQuery(state: EditorState): string | null {
return text;
}
function buildSelectionMatchMarkers(state: EditorState): SelectionMatchMarker[] {
const query = selectedMatchQuery(state);
if (!query) return [];
export class SelectionMatchMarkerScan {
readonly markers: SelectionMatchMarker[] = [];
private readonly seenLines = new Set<number>();
private from = 0;
private matchesScanned = 0;
private readonly lookaheadLength: number;
const selection = state.selection.main;
const markers: SelectionMatchMarker[] = [];
const seenLines = new Set<number>();
const cursor = new SearchCursor(state.doc, query);
constructor(
private readonly state: EditorState,
private readonly query: string,
) {
const selection = state.selection.main;
const selectedLine = state.doc.lineAt(selection.from);
this.seenLines.add(selectedLine.number);
this.seenLines.add(-selectedLine.number);
this.markers.push({ from: selection.from, to: selection.to, lineNumber: selectedLine.number, selected: true });
for (let match = cursor.next(); !match.done; match = cursor.next()) {
const { from, to } = match.value;
if (from === to) continue;
const line = state.doc.lineAt(from);
const selected = from === selection.from && to === selection.to;
const lineKey = selected ? -line.number : line.number;
if (seenLines.has(lineKey)) continue;
seenLines.add(lineKey);
markers.push({ from, to, lineNumber: line.number, selected });
if (markers.length >= MAX_SCROLLBAR_MARKS) break;
// SearchCursor compares NFKD text, whose source can use two UTF-16 units per normalized code point.
const normalizedQuery = typeof query.normalize === "function" ? query.normalize("NFKD") : query;
this.lookaheadLength = Math.max(query.length, normalizedQuery.length * 2) - 1;
}
return markers;
scanNextChunk(): boolean {
const doc = this.state.doc;
if (this.from >= doc.length || this.matchesScanned >= MAX_SCROLLBAR_MATCHES) return true;
const chunkFrom = this.from;
const chunkTo = Math.min(doc.length, chunkFrom + SELECTION_MATCH_SCAN_CHUNK_LENGTH);
// Include enough lookahead to catch normalized matches that start in this chunk and cross its boundary.
const searchTo = Math.min(doc.length, chunkTo + this.lookaheadLength);
const selection = this.state.selection.main;
const cursor = new SearchCursor(doc, this.query, chunkFrom, searchTo);
for (let match = cursor.next(); !match.done; match = cursor.next()) {
const { from, to } = match.value;
if (from >= chunkTo) break;
if (from === to) continue;
this.matchesScanned += 1;
const line = doc.lineAt(from);
const selected = from === selection.from && to === selection.to;
const lineKey = selected ? -line.number : line.number;
if (!this.seenLines.has(lineKey)) {
this.seenLines.add(lineKey);
this.markers.push({ from, to, lineNumber: line.number, selected });
}
if (this.matchesScanned >= MAX_SCROLLBAR_MATCHES) return true;
}
this.from = chunkTo;
return this.from >= doc.length;
}
}
export function createSelectionMatchMarkerScan(state: EditorState): SelectionMatchMarkerScan | null {
const query = selectedMatchQuery(state);
return query ? new SelectionMatchMarkerScan(state, query) : null;
}
class SelectionMatchScrollbar {
private readonly layer: HTMLElement;
private readonly win: Window;
private signature = "";
private updateTimer: number | null = null;
private scanFrame: number | null = null;
private revision = 0;
constructor(view: EditorView) {
this.layer = document.createElement("div");
this.layer.className = "cm-selectionMatchScrollbarLayer";
this.win = view.dom.ownerDocument.defaultView ?? window;
view.dom.appendChild(this.layer);
this.render(view);
this.scheduleRender(view, 0);
}
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet || update.geometryChanged) {
this.render(update.view);
}
if (update.docChanged || update.selectionSet) this.scheduleRender(update.view);
}
destroy() {
this.cancelScheduledRender();
this.layer.remove();
}
private render(view: EditorView) {
const markers = buildSelectionMatchMarkers(view.state);
const lineCount = view.state.doc.lines;
const signature = markers.map((marker) => `${marker.lineNumber}:${marker.from}:${marker.to}:${marker.selected}`).join("|");
private cancelScheduledRender() {
this.revision += 1;
if (this.updateTimer !== null) {
this.win.clearTimeout(this.updateTimer);
this.updateTimer = null;
}
if (this.scanFrame !== null) {
this.win.cancelAnimationFrame(this.scanFrame);
this.scanFrame = null;
}
}
private scheduleRender(view: EditorView, delay = SELECTION_MATCH_UPDATE_DELAY_MS) {
this.cancelScheduledRender();
const scan = createSelectionMatchMarkerScan(view.state);
this.renderMarkers([], view.state.doc.lines);
if (!scan) return;
const revision = this.revision;
this.updateTimer = this.win.setTimeout(() => {
this.updateTimer = null;
this.scanNextFrame(view, scan, revision);
}, delay);
}
private scanNextFrame(view: EditorView, scan: SelectionMatchMarkerScan, revision: number) {
if (revision !== this.revision) return;
if (scan.scanNextChunk()) {
this.renderMarkers(scan.markers, view.state.doc.lines);
return;
}
// Yield between chunks so a large document never monopolizes pointer and paint work.
this.scanFrame = this.win.requestAnimationFrame(() => {
this.scanFrame = null;
this.scanNextFrame(view, scan, revision);
});
}
private renderMarkers(markers: SelectionMatchMarker[], lineCount: number) {
const signature = `${lineCount}|${markers.map((marker) => `${marker.lineNumber}:${marker.from}:${marker.to}:${marker.selected}`).join("|")}`;
if (signature === this.signature) return;
this.signature = signature;
this.layer.replaceChildren(