diff --git a/src/editor/core/cm/indentGuides.ts b/src/editor/core/cm/indentGuides.ts index 150a23449..7ff452d96 100644 --- a/src/editor/core/cm/indentGuides.ts +++ b/src/editor/core/cm/indentGuides.ts @@ -6,7 +6,6 @@ import { RangeSetBuilder, type EditorState, type Extension } from "@codemirror/state"; import { Decoration, ViewPlugin, type DecorationSet, type EditorView, type ViewUpdate } from "@codemirror/view"; import { getIndentUnit } from "@codemirror/language"; -import { isScrollSettled } from "./scrollThrottle"; const cache = new Map(); function guideDeco(levels: number, step: number) { @@ -77,14 +76,8 @@ export const indentGuides: Extension = ViewPlugin.fromClass( this.decorations = build(view); } update(u: ViewUpdate) { - if (u.docChanged || u.startState.tabSize !== u.state.tabSize || isScrollSettled(u)) { + if (u.docChanged || u.viewportChanged || u.startState.tabSize !== u.state.tabSize) this.decorations = build(u.view); - return; - } - // During a scroll burst keep the previous decorations (stale ranges - // for a moment) instead of rebuilding per frame; the scrollSettled - // effect above triggers one rebuild once scrolling stops. - if (u.viewportChanged && !u.view.dom.classList.contains("cm-scrolling")) this.decorations = build(u.view); } }, { decorations: (v) => v.decorations }, diff --git a/src/editor/core/cm/scrollThrottle.test.ts b/src/editor/core/cm/scrollThrottle.test.ts deleted file mode 100644 index 672e9be9e..000000000 --- a/src/editor/core/cm/scrollThrottle.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { Compartment, EditorState } from "@codemirror/state"; -import { EditorView, ViewPlugin } from "@codemirror/view"; -import { isScrollSettled, scrollThrottle } from "./scrollThrottle"; -import { stickyScroll } from "./stickyScroll"; - -let view: EditorView | undefined; -type MeasureRequest = NonNullable[0]>; - -afterEach(() => { - view?.destroy(); - view = undefined; - vi.restoreAllMocks(); - vi.useRealTimers(); -}); - -function editor(extensions: Parameters[0] = {}) { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - view = new EditorView({ state: EditorState.create(extensions), parent: document.body }); - return view; -} - -describe("scroll throttle", () => { - it("marks scrolling before previously installed bubble listeners run", () => { - const seen: boolean[] = []; - const listener = ViewPlugin.define((v) => { - const onScroll = () => seen.push(v.dom.classList.contains("cm-scrolling")); - v.scrollDOM.addEventListener("scroll", onScroll); - return { destroy: () => v.scrollDOM.removeEventListener("scroll", onScroll) }; - }); - const v = editor({ extensions: [listener, scrollThrottle] }); - v.scrollDOM.dispatchEvent(new Event("scroll")); - expect(seen).toEqual([true]); - }); - - it("settles once after the last event in a burst", () => { - const settled = vi.fn(); - const observer = EditorView.updateListener.of((u) => { - if (isScrollSettled(u)) settled(u.view.dom.classList.contains("cm-scrolling")); - }); - const v = editor({ extensions: [scrollThrottle, observer] }); - v.scrollDOM.dispatchEvent(new Event("scroll")); - vi.advanceTimersByTime(100); - v.scrollDOM.dispatchEvent(new Event("scroll")); - vi.advanceTimersByTime(139); - expect(v.dom.classList.contains("cm-scrolling")).toBe(true); - expect(settled).not.toHaveBeenCalled(); - vi.advanceTimersByTime(1); - expect(settled).toHaveBeenCalledExactlyOnceWith(false); - }); - - it("removes its capture listener and pending timer when disabled", () => { - const compartment = new Compartment(); - const settled = vi.fn(); - const v = editor({ - extensions: [ - compartment.of(scrollThrottle), - EditorView.updateListener.of((u) => { - if (isScrollSettled(u)) settled(); - }), - ], - }); - v.scrollDOM.dispatchEvent(new Event("scroll")); - v.dispatch({ effects: compartment.reconfigure([]) }); - expect(v.dom.classList.contains("cm-scrolling")).toBe(false); - v.scrollDOM.dispatchEvent(new Event("scroll")); - expect(v.dom.classList.contains("cm-scrolling")).toBe(false); - vi.advanceTimersByTime(200); - expect(settled).not.toHaveBeenCalled(); - }); - - it("defers sticky measurements from the first scroll event until settling", () => { - const requests: MeasureRequest[] = []; - const measure = vi.spyOn(EditorView.prototype, "requestMeasure").mockImplementation((request) => { - if (request) requests.push(request); - }); - // Match setup.ts: stickyScroll is installed before scrollThrottle. - const v = editor({ extensions: [stickyScroll, scrollThrottle] }); - const initial = requests.shift()!; - initial.write?.(initial.read(v), v); - measure.mockClear(); - v.scrollDOM.dispatchEvent(new Event("scroll")); - expect(measure).not.toHaveBeenCalled(); - vi.advanceTimersByTime(140); - expect(requests).toHaveLength(1); - }); - - it("keeps painted sticky rows when a queued measurement meets a new burst", () => { - const requests: MeasureRequest[] = []; - vi.spyOn(EditorView.prototype, "requestMeasure").mockImplementation((request) => { - if (request) requests.push(request); - }); - const v = editor({ extensions: [stickyScroll, scrollThrottle] }); - const sticky = v.dom.querySelector(".cm-openidea-sticky")!; - sticky.style.display = ""; - sticky.textContent = "previous header"; - const initial = requests.shift()!; - const geometry = vi.spyOn(v.scrollDOM, "getBoundingClientRect"); - v.scrollDOM.dispatchEvent(new Event("scroll")); - initial.write?.(initial.read(v), v); - expect(geometry).not.toHaveBeenCalled(); - expect(sticky.textContent).toBe("previous header"); - expect(sticky.style.display).toBe(""); - vi.advanceTimersByTime(140); - expect(requests).toHaveLength(1); - const settled = requests.shift()!; - settled.write?.(settled.read(v), v); - expect(geometry).toHaveBeenCalled(); - expect(sticky.style.display).toBe("none"); - }); -}); diff --git a/src/editor/core/cm/scrollThrottle.ts b/src/editor/core/cm/scrollThrottle.ts deleted file mode 100644 index f41744051..000000000 --- a/src/editor/core/cm/scrollThrottle.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Marks the editor with a `cm-scrolling` class while the user is actively - * scrolling; expensive decorations (sticky header, indent guides) keep - * their previous paint and defer re-measuring until the wheel / trackpad - * rests for a moment. Keeps fast mouse-wheel scrolling from spending every - * frame on layout work that will be stale by the next scroll event anyway. - */ -import { StateEffect, type Extension } from "@codemirror/state"; -import { ViewPlugin, type EditorView, type ViewUpdate } from "@codemirror/view"; - -const IDLE_MS = 140; - -/** Dispatched once a scroll burst ends so plugins that skipped work can catch up. */ -export const scrollSettled = StateEffect.define(); - -class ScrollThrottle { - private timer = 0; - private active = false; - - constructor(private view: EditorView) { - // Run before bubble listeners, including stickyScroll when it was - // installed first, so the very first event is throttled too. - view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true, capture: true }); - } - - private onScroll = () => { - if (!this.active) { - this.active = true; - this.view.dom.classList.add("cm-scrolling"); - } - clearTimeout(this.timer); - this.timer = window.setTimeout(() => { - this.active = false; - this.view.dom.classList.remove("cm-scrolling"); - // Wake plugins that suppressed viewport work during the burst. - this.view.dispatch({ effects: scrollSettled.of(null) }); - }, IDLE_MS); - }; - - destroy() { - clearTimeout(this.timer); - this.view.scrollDOM.removeEventListener("scroll", this.onScroll, true); - this.view.dom.classList.remove("cm-scrolling"); - } -} - -/** Check whether an update marks the end of a scroll burst. */ -export function isScrollSettled(u: ViewUpdate): boolean { - return u.transactions.some((tr) => tr.effects.some((e) => e.is(scrollSettled))); -} - -export const scrollThrottle: Extension = ViewPlugin.fromClass(ScrollThrottle); diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index bd33cea9c..bcecc965e 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -39,7 +39,6 @@ import { syntaxHighlightField } from "./syntaxHighlight"; import { indentGuides } from "./indentGuides"; import { rainbowBrackets } from "./rainbowBrackets"; import { stickyMaxLines, stickyScroll } from "./stickyScroll"; -import { scrollThrottle } from "./scrollThrottle"; import { findExtension } from "./find"; import { codeLensExtension } from "./codeLens"; import { caretExtension, fontZoom, methodLinesField, prefsExtension, softWrapActive } from "./prefs"; @@ -365,10 +364,6 @@ export function baseExtensions(o: SetupOptions): Extension { compartments.wrap.of(wrapExtension(o.settings, o.ctx.path, o.wrapOverride)), compartments.sticky.of(stickyExtension(o.settings)), compartments.heavy.of(heavyExtension(o.docLength, o.settings)), - // Flag "cm-scrolling" while the wheel is moving; expensive decorations - // (sticky header, indent guides, ...) keep their previous paint and - // defer re-measuring until scrolling settles. - scrollThrottle, compartments.prefs.of( prefsFor({ settings: o.settings, diff --git a/src/editor/core/cm/stickyScroll.ts b/src/editor/core/cm/stickyScroll.ts index c30e50310..7bb8b4aa4 100644 --- a/src/editor/core/cm/stickyScroll.ts +++ b/src/editor/core/cm/stickyScroll.ts @@ -10,7 +10,6 @@ import { foldNodeProp, syntaxTree } from "@codemirror/language"; import { highlightTree } from "@lezer/highlight"; import { backendFolds } from "./folding"; import { openideaHighlightStyle } from "./highlight"; -import { isScrollSettled } from "./scrollThrottle"; /** Maximum number of sticky lines (Settings › Editor › Code Editing). */ export const stickyMaxLines = Facet.define({ @@ -121,36 +120,23 @@ class StickyScroll { update(u: ViewUpdate) { if ( u.docChanged || + u.viewportChanged || u.geometryChanged || syntaxTree(u.startState) !== syntaxTree(u.state) || - u.startState.field(backendFolds, false) !== u.state.field(backendFolds, false) || - isScrollSettled(u) + u.startState.field(backendFolds, false) !== u.state.field(backendFolds, false) ) this.schedule(); - // Viewport moves during a scroll burst are wasteful: the previous rows - // stay painted (just no longer aligned), so skip re-measuring until the - // scrollSettled effect above re-runs one pass. - if (u.viewportChanged && !u.view.dom.classList.contains("cm-scrolling")) this.schedule(); } - private onScroll = () => { - // The class is set by the scrollThrottle plugin on the same element; - // during a burst the stale rows stay visible instead of re-measuring - // every frame — catch up on the trailing update after scrolling settles. - if (this.view.dom.classList.contains("cm-scrolling")) return; - this.schedule(); - }; + private onScroll = () => this.schedule(); private schedule() { - if (this.scheduled || this.view.dom.classList.contains("cm-scrolling")) return; + if (this.scheduled) return; this.scheduled = true; this.view.requestMeasure({ key: this, read: (view) => { this.scheduled = false; - // A queued measurement may outlive the start of a scroll burst. - // Preserve the painted rows until scrollSettled schedules a fresh pass. - if (view.dom.classList.contains("cm-scrolling")) return null; const scroller = view.scrollDOM; const scrollTop = scroller.scrollTop; const docOffset = view.documentTop - scroller.getBoundingClientRect().top + scrollTop; @@ -180,9 +166,7 @@ class StickyScroll { } return { lines: headers, gutterWidth, numberWidth, numberLeft, scrollLeft: scroller.scrollLeft }; }, - write: (measured) => { - if (!measured) return; - const { lines, gutterWidth, numberWidth, numberLeft, scrollLeft } = measured; + write: ({ lines, gutterWidth, numberWidth, numberLeft, scrollLeft }) => { const state = this.view.state; const texts = lines.map((n) => (n <= state.doc.lines ? state.doc.line(n).text : "")); const tree = syntaxTree(state);