From 7e9fc0ac3f9a0c5f86bf8fc68f96c56a2b76ad66 Mon Sep 17 00:00:00 2001 From: landaiqing Date: Wed, 10 Dec 2025 22:25:19 +0800 Subject: [PATCH] :zap: Optimize minmap extension --- .../views/editor/extensions/minimap/index.ts | 55 ++++- .../extensions/minimap/linebasedstate.ts | 4 + .../editor/extensions/minimap/linesState.ts | 14 ++ .../editor/extensions/minimap/overlay.ts | 18 +- .../editor/extensions/minimap/selections.ts | 225 +++++++++--------- .../views/editor/extensions/minimap/text.ts | 8 +- .../extensions/minimap/text/lineRenderer.ts | 13 + 7 files changed, 208 insertions(+), 129 deletions(-) diff --git a/frontend/src/views/editor/extensions/minimap/index.ts b/frontend/src/views/editor/extensions/minimap/index.ts index fa7f2c9..7d429d8 100644 --- a/frontend/src/views/editor/extensions/minimap/index.ts +++ b/frontend/src/views/editor/extensions/minimap/index.ts @@ -37,6 +37,7 @@ const Theme = EditorView.theme({ overflowY: "hidden", "& canvas": { display: "block", + willChange: "transform, opacity", }, }, "& .cm-minimap-box-shadow": { @@ -45,6 +46,7 @@ const Theme = EditorView.theme({ }); const WIDTH_RATIO = 6; +type RenderReason = "scroll" | "data"; const minimapClass = ViewPlugin.fromClass( class { @@ -53,6 +55,9 @@ const minimapClass = ViewPlugin.fromClass( private canvas: HTMLCanvasElement | undefined; private renderHandle: number | ReturnType | null = null; private cancelRender: (() => void) | null = null; + private pendingScrollTop: number | null = null; + private lastRenderedScrollTop: number = -1; + private pendingRenderReason: RenderReason | null = null; public text: TextState; public selection: SelectionState; @@ -160,6 +165,11 @@ const minimapClass = ViewPlugin.fromClass( return; } + const effectiveScrollTop = this.pendingScrollTop ?? this.view.scrollDOM.scrollTop; + this.pendingScrollTop = null; + this.pendingRenderReason = null; + this.lastRenderedScrollTop = effectiveScrollTop; + this.text.beforeDraw(); this.updateBoxShadow(); @@ -185,7 +195,8 @@ const minimapClass = ViewPlugin.fromClass( let { startIndex, endIndex, offsetY } = this.canvasStartAndEndIndex( context, - lineHeight + lineHeight, + effectiveScrollTop ); const gutters = this.view.state.facet(Config).gutters; @@ -216,15 +227,39 @@ const minimapClass = ViewPlugin.fromClass( drawContext.offsetX += 2; } - this.text.drawLine(drawContext, i + 1); - this.selection.drawLine(drawContext, i + 1); - this.diagnostic.drawLine(drawContext, i + 1); + const lineNumber = i + 1; + this.text.drawLine(drawContext, lineNumber); + this.selection.drawLine(drawContext, lineNumber); + + if (this.diagnostic.has(lineNumber)) { + this.diagnostic.drawLine(drawContext, lineNumber); + } offsetY += lineHeight; } } - requestRender() { + requestRender(reason: RenderReason = "data") { + if (reason === "scroll") { + const scrollTop = this.view.scrollDOM.scrollTop; + if (this.lastRenderedScrollTop === scrollTop && !this.pendingRenderReason) { + return; + } + if ( + this.pendingRenderReason === "scroll" && + this.pendingScrollTop === scrollTop + ) { + return; + } + this.pendingScrollTop = scrollTop; + } else { + this.pendingScrollTop = null; + } + + if (reason === "data" || this.pendingRenderReason === null) { + this.pendingRenderReason = reason; + } + if (this.renderHandle !== null) { return; } @@ -256,17 +291,21 @@ const minimapClass = ViewPlugin.fromClass( this.cancelRender(); this.renderHandle = null; this.cancelRender = null; + this.pendingScrollTop = null; + this.pendingRenderReason = null; } private canvasStartAndEndIndex( context: CanvasRenderingContext2D, - lineHeight: number + lineHeight: number, + scrollTopOverride?: number ) { let { top: pTop, bottom: pBottom } = this.view.documentPadding; (pTop /= Scale.SizeRatio), (pBottom /= Scale.SizeRatio); const canvasHeight = context.canvas.height; - const { clientHeight, scrollHeight, scrollTop } = this.view.scrollDOM; + const { clientHeight, scrollHeight } = this.view.scrollDOM; + const scrollTop = scrollTopOverride ?? this.view.scrollDOM.scrollTop; let scrollPercent = scrollTop / (scrollHeight - clientHeight); if (isNaN(scrollPercent)) { scrollPercent = 0; @@ -312,7 +351,7 @@ const minimapClass = ViewPlugin.fromClass( { eventHandlers: { scroll() { - this.requestRender(); + this.requestRender("scroll"); }, }, provide: (plugin) => { diff --git a/frontend/src/views/editor/extensions/minimap/linebasedstate.ts b/frontend/src/views/editor/extensions/minimap/linebasedstate.ts index 19dc91f..4257513 100644 --- a/frontend/src/views/editor/extensions/minimap/linebasedstate.ts +++ b/frontend/src/views/editor/extensions/minimap/linebasedstate.ts @@ -14,6 +14,10 @@ export abstract class LineBasedState { return this.map.get(lineNumber); } + public has(lineNumber: number): boolean { + return this.map.has(lineNumber); + } + protected set(lineNumber: number, value: TValue) { this.map.set(lineNumber, value); } diff --git a/frontend/src/views/editor/extensions/minimap/linesState.ts b/frontend/src/views/editor/extensions/minimap/linesState.ts index fb5a4cd..d138f2f 100644 --- a/frontend/src/views/editor/extensions/minimap/linesState.ts +++ b/frontend/src/views/editor/extensions/minimap/linesState.ts @@ -77,6 +77,12 @@ function computeLinesState(state: EditorState): Lines { const LinesState = StateField.define({ create: (state) => computeLinesState(state), update: (current, tr) => { + const prevEnabled = tr.startState.facet(Config).enabled; + const nextEnabled = tr.state.facet(Config).enabled; + if (prevEnabled !== nextEnabled) { + return computeLinesState(tr.state); + } + if (foldsChanged([tr]) || tr.docChanged) { return computeLinesState(tr.state); } @@ -93,3 +99,11 @@ function foldsChanged(transactions: readonly Transaction[]) { } export { foldsChanged, LinesState }; + +export function getLinesSnapshot(state: EditorState): Lines { + const lines = state.field(LinesState); + if (lines.length || !state.facet(Config).enabled) { + return lines; + } + return computeLinesState(state); +} diff --git a/frontend/src/views/editor/extensions/minimap/overlay.ts b/frontend/src/views/editor/extensions/minimap/overlay.ts index 03e0e98..0b78bd2 100644 --- a/frontend/src/views/editor/extensions/minimap/overlay.ts +++ b/frontend/src/views/editor/extensions/minimap/overlay.ts @@ -51,6 +51,7 @@ const OverlayView = ViewPlugin.fromClass( private _isDragging: boolean = false; private _dragStartY: number | undefined; + private abortController: AbortController | undefined; private readonly _boundMouseDown = (event: MouseEvent) => this.onMouseDown(event); private readonly _boundMouseUp = (event: MouseEvent) => this.onMouseUp(event); private readonly _boundMouseMove = (event: MouseEvent) => this.onMouseMove(event); @@ -64,14 +65,17 @@ const OverlayView = ViewPlugin.fromClass( private create(view: EditorView) { this.remove(); + this.abortController = new AbortController(); + const signal = this.abortController.signal; + this.container = crelt("div", { class: "cm-minimap-overlay-container" }); this.dom = crelt("div", { class: "cm-minimap-overlay" }); this.container.appendChild(this.dom); // Attach event listeners for overlay - this.container.addEventListener("mousedown", this._boundMouseDown); - window.addEventListener("mouseup", this._boundMouseUp); - window.addEventListener("mousemove", this._boundMouseMove); + this.container.addEventListener("mousedown", this._boundMouseDown, { signal }); + window.addEventListener("mouseup", this._boundMouseUp, { signal }); + window.addEventListener("mousemove", this._boundMouseMove, { signal }); // Attach the overlay elements to the minimap const inner = view.dom.querySelector(".cm-minimap-inner"); @@ -86,10 +90,12 @@ const OverlayView = ViewPlugin.fromClass( } private remove() { + if (this.abortController) { + this.abortController.abort(); + this.abortController = undefined; + } + if (this.container) { - this.container.removeEventListener("mousedown", this._boundMouseDown); - window.removeEventListener("mouseup", this._boundMouseUp); - window.removeEventListener("mousemove", this._boundMouseMove); this.container.remove(); this.container = undefined; this.dom = undefined; diff --git a/frontend/src/views/editor/extensions/minimap/selections.ts b/frontend/src/views/editor/extensions/minimap/selections.ts index 56bcc82..53683a5 100644 --- a/frontend/src/views/editor/extensions/minimap/selections.ts +++ b/frontend/src/views/editor/extensions/minimap/selections.ts @@ -1,16 +1,27 @@ import { LineBasedState } from "./linebasedstate"; import { EditorView, ViewUpdate } from "@codemirror/view"; -import { Lines, LinesState, foldsChanged } from "./linesState"; +import { EditorState } from "@codemirror/state"; +import { Lines, foldsChanged, getLinesSnapshot } from "./linesState"; import { DrawContext } from "./types"; import { Config } from "./config"; import { lineLength, lineNumberAt, offsetWithinLine } from "./lineGeometry"; type Selection = { from: number; to: number; extends: boolean }; type DrawInfo = { backgroundColor: string }; +type RangeInfo = { + from: number; + to: number; + lineFrom: number; + lineTo: number; +}; + +const MAX_CACHED_LINES = 800; export class SelectionState extends LineBasedState> { private _drawInfo: DrawInfo | undefined; private _themeClasses: string; + private _rangeInfo: Array = []; + private _linesSnapshot: Lines = []; public constructor(view: EditorView) { super(view); @@ -53,40 +64,7 @@ export class SelectionState extends LineBasedState> { return; } - if (this._themeClasses !== this.view.dom.classList.value) { - this._drawInfo = undefined; - this._themeClasses = this.view.dom.classList.value; - } - - const lines = update.state.field(LinesState); - const nextSelections = new Map>(); - - for (const range of update.state.selection.ranges) { - if (range.empty) { - continue; - } - - const startLine = lineNumberAt(lines, range.from); - const endLine = lineNumberAt( - lines, - Math.max(range.from, range.to - 1) - ); - - if (startLine <= 0 || endLine <= 0) { - continue; - } - - this.collectRangeSelections( - nextSelections, - lines, - range.from, - range.to, - startLine, - endLine - ); - } - - this.applySelectionDiff(nextSelections); + this.rebuild(update.state); } public drawLine(ctx: DrawContext, lineNumber: number) { @@ -97,7 +75,7 @@ export class SelectionState extends LineBasedState> { offsetX: startOffsetX, offsetY, } = ctx; - const selections = this.get(lineNumber); + const selections = this.ensureSelections(lineNumber); if (!selections) { return; } @@ -146,56 +124,6 @@ export class SelectionState extends LineBasedState> { return result; } - private collectRangeSelections( - store: Map>, - lines: Lines, - rangeFrom: number, - rangeTo: number, - startLine: number, - endLine: number - ) { - for (let lineNumber = startLine; lineNumber <= endLine; lineNumber++) { - const spans = lines[lineNumber - 1]; - if (!spans || spans.length === 0) { - continue; - } - - const length = lineLength(spans); - const fromOffset = - lineNumber === startLine ? offsetWithinLine(rangeFrom, spans) : 0; - - let toOffset = - lineNumber === endLine ? offsetWithinLine(rangeTo, spans) : length; - if (toOffset === fromOffset) { - toOffset = Math.min(length, fromOffset + 1); - } - - const lastSpan = spans[spans.length - 1]; - const spanEnd = lastSpan ? lastSpan.to : rangeTo; - const extendsLine = - lineNumber < endLine || (lineNumber === endLine && rangeTo > spanEnd); - - const selections = this.ensureLineEntry(store, lineNumber); - this.appendSelection(selections, { - from: fromOffset, - to: toOffset, - extends: extendsLine, - }); - } - } - - private ensureLineEntry( - store: Map>, - lineNumber: number - ) { - let selections = store.get(lineNumber); - if (!selections) { - selections = []; - store.set(lineNumber, selections); - } - return selections; - } - private appendSelection(target: Array, entry: Selection) { const last = target[target.length - 1]; if (last && entry.from <= last.to) { @@ -208,40 +136,109 @@ export class SelectionState extends LineBasedState> { } target.push(entry); } - - private applySelectionDiff(nextMap: Map>) { - if (nextMap.size === 0 && this.map.size === 0) { - return; + private rebuild(state: EditorState) { + if (this._themeClasses !== this.view.dom.classList.value) { + this._drawInfo = undefined; + this._themeClasses = this.view.dom.classList.value; } - for (const key of Array.from(this.map.keys())) { - if (!nextMap.has(key)) { - this.map.delete(key); - } - } - - for (const [lineNumber, selections] of nextMap) { - const existing = this.map.get(lineNumber); - if (!existing || !this.areSelectionsEqual(existing, selections)) { - this.map.set(lineNumber, selections); - } - } + this._linesSnapshot = getLinesSnapshot(state); + this._rangeInfo = this.buildRangeInfo(state, this._linesSnapshot); + this.map.clear(); } - private areSelectionsEqual(a: Array, b: Array) { - if (a.length !== b.length) { - return false; - } - for (let i = 0; i < a.length; i++) { - if ( - a[i].from !== b[i].from || - a[i].to !== b[i].to || - a[i].extends !== b[i].extends - ) { - return false; + private buildRangeInfo(state: EditorState, lines: Lines) { + const info: Array = []; + for (const range of state.selection.ranges) { + if (range.empty) { + continue; } + + const startLine = lineNumberAt(lines, range.from); + const endLine = lineNumberAt(lines, Math.max(range.from, range.to - 1)); + if (startLine <= 0 || endLine <= 0) { + continue; + } + + info.push({ + from: range.from, + to: range.to, + lineFrom: startLine, + lineTo: endLine, + }); } - return true; + return info; + } + + private ensureSelections(lineNumber: number) { + const cached = this.get(lineNumber); + if (cached) { + return cached; + } + + const computed = this.buildSelectionsForLine(lineNumber); + if (!computed || computed.length === 0) { + return undefined; + } + + if (this.map.has(lineNumber)) { + this.map.delete(lineNumber); + } + this.map.set(lineNumber, computed); + while (this.map.size > MAX_CACHED_LINES) { + const oldest = this.map.keys().next(); + if (oldest.done) { + break; + } + this.map.delete(oldest.value); + } + + return computed; + } + + private buildSelectionsForLine(lineNumber: number) { + const spans = this._linesSnapshot[lineNumber - 1]; + if (!spans || spans.length === 0) { + return undefined; + } + + const relevant = this._rangeInfo.filter( + (info) => lineNumber >= info.lineFrom && lineNumber <= info.lineTo + ); + if (!relevant.length) { + return undefined; + } + + const selections: Array = []; + for (const range of relevant) { + const length = lineLength(spans); + const fromOffset = + lineNumber === range.lineFrom + ? offsetWithinLine(range.from, spans) + : 0; + + let toOffset = + lineNumber === range.lineTo + ? offsetWithinLine(range.to, spans) + : length; + if (toOffset === fromOffset) { + toOffset = Math.min(length, fromOffset + 1); + } + + const lastSpan = spans[spans.length - 1]; + const spanEnd = lastSpan ? lastSpan.to : range.to; + const extendsLine = + lineNumber < range.lineTo || + (lineNumber === range.lineTo && range.to > spanEnd); + + this.appendSelection(selections, { + from: fromOffset, + to: toOffset, + extends: extendsLine, + }); + } + + return selections; } } diff --git a/frontend/src/views/editor/extensions/minimap/text.ts b/frontend/src/views/editor/extensions/minimap/text.ts index 0b3cb6d..184899f 100644 --- a/frontend/src/views/editor/extensions/minimap/text.ts +++ b/frontend/src/views/editor/extensions/minimap/text.ts @@ -404,7 +404,13 @@ export class TextState extends LineBasedState> { // Get style information and store it const style = window.getComputedStyle(mockToken); - const lineHeight = parseFloat(style.lineHeight) / Scale.SizeRatio; + const rawLineHeight = parseFloat(style.lineHeight); + const fallbackLineHeight = parseFloat(style.fontSize) || this.view.defaultLineHeight; + const resolvedLineHeight = + Number.isFinite(rawLineHeight) && rawLineHeight > 0 + ? rawLineHeight + : fallbackLineHeight; + const lineHeight = Math.max(1, resolvedLineHeight / Scale.SizeRatio); const result = { color: style.color, font: `${style.fontStyle} ${style.fontWeight} ${lineHeight}px ${style.fontFamily}`, diff --git a/frontend/src/views/editor/extensions/minimap/text/lineRenderer.ts b/frontend/src/views/editor/extensions/minimap/text/lineRenderer.ts index 23d5e52..8c7755b 100644 --- a/frontend/src/views/editor/extensions/minimap/text/lineRenderer.ts +++ b/frontend/src/views/editor/extensions/minimap/text/lineRenderer.ts @@ -18,6 +18,7 @@ type LineBitmap = { export class LineRenderer { private readonly _lineVersions = new Map(); private readonly _lineCache = new Map(); + private static readonly MAX_CACHE_LINES = 2000; public constructor( private readonly glyphAtlas: GlyphAtlas, @@ -28,6 +29,7 @@ export class LineRenderer { const version = (this._lineVersions.get(lineNumber) ?? 0) + 1; this._lineVersions.set(lineNumber, version); this._lineCache.delete(lineNumber); + this.trimCache(); } public markAllChanged() { @@ -42,6 +44,7 @@ export class LineRenderer { this._lineCache.delete(key); } } + this.trimCache(); } public drawLine( @@ -170,9 +173,19 @@ export class LineRenderer { }; this._lineCache.set(lineNumber, entry); + this.trimCache(); return entry; } + private trimCache() { + while (this._lineCache.size > LineRenderer.MAX_CACHE_LINES) { + const oldest = this._lineCache.keys().next(); + if (oldest.done) break; + this._lineCache.delete(oldest.value); + this._lineVersions.delete(oldest.value); + } + } + private paintSpans( context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, spans: Array,