From ff072d1a935690fce75a05f022342b8e22388489 Mon Sep 17 00:00:00 2001 From: landaiqing Date: Wed, 10 Dec 2025 00:41:24 +0800 Subject: [PATCH] :zap: Optimize minmap performance --- .../editor/extensions/minimap/diagnostics.ts | 168 ++++++---- .../views/editor/extensions/minimap/index.ts | 98 +++--- .../editor/extensions/minimap/lineGeometry.ts | 85 +++++ .../editor/extensions/minimap/overlay.ts | 19 +- .../editor/extensions/minimap/selections.ts | 204 +++++++----- .../views/editor/extensions/minimap/text.ts | 284 +++++++++------- .../extensions/minimap/text/docInput.ts | 22 ++ .../extensions/minimap/text/glyphAtlas.ts | 145 ++++++++ .../extensions/minimap/text/lineRenderer.ts | 311 ++++++++++++++++++ .../extensions/minimap/text/textTypes.ts | 7 + 10 files changed, 1042 insertions(+), 301 deletions(-) create mode 100644 frontend/src/views/editor/extensions/minimap/lineGeometry.ts create mode 100644 frontend/src/views/editor/extensions/minimap/text/docInput.ts create mode 100644 frontend/src/views/editor/extensions/minimap/text/glyphAtlas.ts create mode 100644 frontend/src/views/editor/extensions/minimap/text/lineRenderer.ts create mode 100644 frontend/src/views/editor/extensions/minimap/text/textTypes.ts diff --git a/frontend/src/views/editor/extensions/minimap/diagnostics.ts b/frontend/src/views/editor/extensions/minimap/diagnostics.ts index d4a7a9d..b68ae85 100644 --- a/frontend/src/views/editor/extensions/minimap/diagnostics.ts +++ b/frontend/src/views/editor/extensions/minimap/diagnostics.ts @@ -9,11 +9,20 @@ import { import { LineBasedState } from "./linebasedstate"; import { DrawContext } from "./types"; import { Lines, LinesState, foldsChanged } from "./linesState"; -import { Config } from "./config"; +import { Config, Scale } from "./config"; +import { lineLength, lineNumberAt, offsetWithinLine } from "./lineGeometry"; type Severity = Diagnostic["severity"]; +type DiagnosticRange = { from: number; to: number }; +type LineDiagnostics = { + severity: Severity; + ranges: Array; +}; +const MIN_PIXEL_WIDTH = 1 / Scale.PixelMultiplier; +const snapToDevice = (value: number) => + Math.round(value * Scale.PixelMultiplier) / Scale.PixelMultiplier; -export class DiagnosticState extends LineBasedState { +export class DiagnosticState extends LineBasedState { private count: number | undefined = undefined; public constructor(view: EditorView) { @@ -63,70 +72,74 @@ export class DiagnosticState extends LineBasedState { this.count = diagnosticCount(update.state); forEachDiagnostic(update.state, (diagnostic, from, to) => { - // Find the start and end lines for the diagnostic - const lineStart = this.findLine(from, lines); - const lineEnd = this.findLine(to, lines); + const lineStart = lineNumberAt(lines, from); + const lineEnd = lineNumberAt(lines, to); + if (lineStart <= 0 || lineEnd <= 0) { + return; + } - // Populate each line in the range with the highest severity diagnostic - let severity = diagnostic.severity; - for (let i = lineStart; i <= lineEnd; i++) { - const previous = this.get(i); - if (previous) { - severity = [severity, previous] - .sort(this.sort.bind(this)) - .slice(0, 1)[0]; + for (let lineNumber = lineStart; lineNumber <= lineEnd; lineNumber++) { + const spans = lines[lineNumber - 1]; + if (!spans || spans.length === 0) { + continue; } - this.set(i, severity); + + const length = lineLength(spans); + + const startOffset = + lineNumber === lineStart + ? offsetWithinLine(from, spans) + : 0; + const endOffset = + lineNumber === lineEnd ? offsetWithinLine(to, spans) : length; + + const fromOffset = Math.max(0, Math.min(length, startOffset)); + let toOffset = Math.max(fromOffset, Math.min(length, endOffset)); + if (toOffset === fromOffset) { + toOffset = Math.min(length, fromOffset + 1); + } + + this.pushRange(lineNumber, diagnostic.severity, { + from: fromOffset, + to: toOffset, + }); } }); + + this.mergeRanges(); } public drawLine(ctx: DrawContext, lineNumber: number) { - const { context, lineHeight, offsetX, offsetY } = ctx; - const severity = this.get(lineNumber); - if (!severity) { + const diagnostics = this.get(lineNumber); + if (!diagnostics) { return; } - // Draw the full line width rectangle in the background - context.globalAlpha = 0.65; - context.beginPath(); - context.rect( - offsetX, - offsetY /* TODO Scaling causes anti-aliasing in rectangles */, - context.canvas.width - offsetX, - lineHeight - ); - context.fillStyle = this.color(severity); - context.fill(); + const { context, lineHeight, charWidth, offsetX, offsetY } = ctx; + const color = this.color(diagnostics.severity); + const snappedY = snapToDevice(offsetY); + const snappedHeight = + Math.max(MIN_PIXEL_WIDTH, snapToDevice(offsetY + lineHeight) - snappedY) || + MIN_PIXEL_WIDTH; - // Draw diagnostic range rectangle in the foreground - // TODO: We need to update the state to have specific ranges - // context.globalAlpha = 1; - // context.beginPath(); - // context.rect(offsetX, offsetY, textWidth, lineHeight); - // context.fillStyle = this.color(severity); - // context.fill(); - } + context.fillStyle = color; + for (const range of diagnostics.ranges) { + const startX = offsetX + range.from * charWidth; + const width = Math.max( + MIN_PIXEL_WIDTH, + (range.to - range.from) * charWidth + ); + const snappedX = snapToDevice(startX); + const snappedWidth = + Math.max(MIN_PIXEL_WIDTH, snapToDevice(startX + width) - snappedX) || + MIN_PIXEL_WIDTH; - /** - * Given a position and a set of line ranges, return - * the line number the position falls within - */ - private findLine(pos: number, lines: Lines) { - const index = lines.findIndex((spans) => { - const start = spans.slice(0, 1)[0]; - const end = spans.slice(-1)[0]; - - if (!start || !end) { - return false; - } - - return start.from <= pos && pos <= end.to; - }); - - // Line numbers begin at 1 - return index + 1; + context.globalAlpha = 0.65; + context.beginPath(); + context.rect(snappedX, snappedY, snappedWidth, snappedHeight); + context.fill(); + } + context.globalAlpha = 1; } /** @@ -141,12 +154,6 @@ export class DiagnosticState extends LineBasedState { : "#999"; } - /** Sorts severity from most to least severe */ - private sort(a: Severity, b: Severity) { - return this.score(b) - this.score(a); - } - - /** Assigns a score to severity, with most severe being the highest */ private score(s: Severity) { switch (s) { case "error": { @@ -160,6 +167,47 @@ export class DiagnosticState extends LineBasedState { } } } + + private pushRange( + lineNumber: number, + severity: Severity, + range: DiagnosticRange + ) { + let entry = this.get(lineNumber); + if (!entry) { + entry = { severity, ranges: [range] }; + this.set(lineNumber, entry); + return; + } + + if (this.score(severity) > this.score(entry.severity)) { + entry.severity = severity; + } + + entry.ranges.push(range); + } + + private mergeRanges() { + for (const entry of this.map.values()) { + if (entry.ranges.length <= 1) { + continue; + } + + entry.ranges.sort((a, b) => a.from - b.from); + const merged: Array = []; + + for (const range of entry.ranges) { + const last = merged[merged.length - 1]; + if (last && range.from <= last.to) { + last.to = Math.max(last.to, range.to); + } else { + merged.push({ ...range }); + } + } + + entry.ranges = merged; + } + } } export function diagnostics(view: EditorView): DiagnosticState { diff --git a/frontend/src/views/editor/extensions/minimap/index.ts b/frontend/src/views/editor/extensions/minimap/index.ts index a739ff8..fa7f2c9 100644 --- a/frontend/src/views/editor/extensions/minimap/index.ts +++ b/frontend/src/views/editor/extensions/minimap/index.ts @@ -51,6 +51,8 @@ const minimapClass = ViewPlugin.fromClass( private dom: HTMLElement | undefined; private inner: HTMLElement | undefined; private canvas: HTMLCanvasElement | undefined; + private renderHandle: number | ReturnType | null = null; + private cancelRender: (() => void) | null = null; public text: TextState; public selection: SelectionState; @@ -97,35 +99,21 @@ const minimapClass = ViewPlugin.fromClass( } } - // 阻止小地图上的右键菜单 - this.dom.addEventListener('contextmenu', (e) => { - e.preventDefault(); - e.stopPropagation(); - return false; - }); - - // 阻止小地图内部元素和画布上的右键菜单 - this.inner.addEventListener('contextmenu', (e) => { - e.preventDefault(); - e.stopPropagation(); - return false; - }); - - this.canvas.addEventListener('contextmenu', (e) => { - e.preventDefault(); - e.stopPropagation(); - return false; - }); - if (config.autohide) { this.dom.classList.add('cm-minimap-autohide'); } + + this.requestRender(); } private remove() { + this.cancelRenderRequest(); if (this.dom) { this.dom.remove(); } + this.dom = undefined; + this.inner = undefined; + this.canvas = undefined; } update(update: ViewUpdate) { @@ -153,7 +141,7 @@ const minimapClass = ViewPlugin.fromClass( this.text.update(update); this.selection.update(update); this.diagnostic.update(update); - this.render(); + this.requestRender(); } } @@ -202,8 +190,9 @@ const minimapClass = ViewPlugin.fromClass( const gutters = this.view.state.facet(Config).gutters; + const lines = this.view.state.field(LinesState); + for (let i = startIndex; i < endIndex; i++) { - const lines = this.view.state.field(LinesState); if (i >= lines.length) break; const drawContext = { @@ -233,8 +222,40 @@ const minimapClass = ViewPlugin.fromClass( offsetY += lineHeight; } + } - context.restore(); + requestRender() { + if (this.renderHandle !== null) { + return; + } + + if (typeof requestAnimationFrame === "function") { + const handle = requestAnimationFrame(() => { + this.renderHandle = null; + this.cancelRender = null; + this.render(); + }); + this.renderHandle = handle; + this.cancelRender = () => cancelAnimationFrame(handle); + return; + } + + const handle = setTimeout(() => { + this.renderHandle = null; + this.cancelRender = null; + this.render(); + }, 16); + this.renderHandle = handle; + this.cancelRender = () => clearTimeout(handle); + } + + cancelRenderRequest() { + if (!this.cancelRender) { + return; + } + this.cancelRender(); + this.renderHandle = null; + this.cancelRender = null; } private canvasStartAndEndIndex( @@ -291,7 +312,7 @@ const minimapClass = ViewPlugin.fromClass( { eventHandlers: { scroll() { - requestAnimationFrame(() => this.render()); + this.requestRender(); }, }, provide: (plugin) => { @@ -323,17 +344,6 @@ export type MinimapConfig = Omit & { */ const showMinimapFacet = Facet.define({ combine: (c) => c.find((o) => o !== null) ?? null, - enables: (f) => { - return [ - [ - Config.compute([f], (s) => s.facet(f)), - Theme, - LinesState, - minimapClass, // TODO, codemirror-ify this one better - Overlay, - ], - ]; - }, }); /** @@ -350,11 +360,17 @@ const defaultCreateFn = (view: EditorView) => { * @returns */ export function minimap(options: Partial> = {}) { - return showMinimapFacet.of({ + const config: MinimapConfig = { create: defaultCreateFn, - ...options - }); -} + ...options, + }; -// 保持原始接口兼容性 -export { showMinimapFacet as showMinimap }; + return [ + showMinimapFacet.of(config), + Config.compute([showMinimapFacet], (s) => s.facet(showMinimapFacet)), + Theme, + LinesState, + minimapClass, + Overlay, + ]; +} diff --git a/frontend/src/views/editor/extensions/minimap/lineGeometry.ts b/frontend/src/views/editor/extensions/minimap/lineGeometry.ts new file mode 100644 index 0000000..e4583b0 --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/lineGeometry.ts @@ -0,0 +1,85 @@ +import { Lines } from "./linesState"; + +const DEFAULT_LINE_NUMBER = 0; + +function lineBoundary(spans: Lines[number]) { + if (!spans || spans.length === 0) { + return { start: 0, end: 0 }; + } + const start = spans[0].from; + const end = spans[spans.length - 1].to; + return { start, end }; +} + +export function lineNumberAt(lines: Lines, position: number): number { + if (!lines.length) { + return DEFAULT_LINE_NUMBER; + } + + const first = lineBoundary(lines[0]); + const last = lineBoundary(lines[lines.length - 1]); + + let target = position; + if (target < first.start) { + target = first.start; + } else if (target > last.end) { + target = last.end; + } + + let low = 0; + let high = lines.length - 1; + + while (low <= high) { + const mid = (low + high) >> 1; + const spans = lines[mid]; + const { start, end } = lineBoundary(spans); + + if (target < start) { + high = mid - 1; + continue; + } + + if (target > end) { + low = mid + 1; + continue; + } + + return mid + 1; + } + + return Math.max(1, Math.min(lines.length, low + 1)); +} + +export function lineLength(spans: Lines[number]) { + if (!spans) { + return 1; + } + + let length = 0; + for (const span of spans) { + length += span.folded ? 1 : Math.max(0, span.to - span.from); + } + + return Math.max(1, length); +} + +export function offsetWithinLine(pos: number, spans: Lines[number]) { + if (!spans) { + return 0; + } + + let offset = 0; + + for (const span of spans) { + const spanLength = span.folded ? 1 : Math.max(0, span.to - span.from); + if (!span.folded && pos < span.to) { + return offset + Math.max(0, pos - span.from); + } + if (span.folded && pos <= span.to) { + return offset; + } + offset += spanLength; + } + + return offset; +} diff --git a/frontend/src/views/editor/extensions/minimap/overlay.ts b/frontend/src/views/editor/extensions/minimap/overlay.ts index b5fa183..03e0e98 100644 --- a/frontend/src/views/editor/extensions/minimap/overlay.ts +++ b/frontend/src/views/editor/extensions/minimap/overlay.ts @@ -51,6 +51,9 @@ const OverlayView = ViewPlugin.fromClass( private _isDragging: boolean = false; private _dragStartY: number | 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); public constructor(private view: EditorView) { if (view.state.facet(Config).enabled) { @@ -59,14 +62,16 @@ const OverlayView = ViewPlugin.fromClass( } private create(view: EditorView) { + this.remove(); + 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.onMouseDown.bind(this)); - window.addEventListener("mouseup", this.onMouseUp.bind(this)); - window.addEventListener("mousemove", this.onMouseMove.bind(this)); + this.container.addEventListener("mousedown", this._boundMouseDown); + window.addEventListener("mouseup", this._boundMouseUp); + window.addEventListener("mousemove", this._boundMouseMove); // Attach the overlay elements to the minimap const inner = view.dom.querySelector(".cm-minimap-inner"); @@ -82,10 +87,12 @@ const OverlayView = ViewPlugin.fromClass( private remove() { if (this.container) { - this.container.removeEventListener("mousedown", this.onMouseDown); - window.removeEventListener("mouseup", this.onMouseUp); - window.removeEventListener("mousemove", this.onMouseMove); + 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 3f0a94c..56bcc82 100644 --- a/frontend/src/views/editor/extensions/minimap/selections.ts +++ b/frontend/src/views/editor/extensions/minimap/selections.ts @@ -1,8 +1,9 @@ import { LineBasedState } from "./linebasedstate"; import { EditorView, ViewUpdate } from "@codemirror/view"; -import { LinesState, foldsChanged } from "./linesState"; +import { Lines, LinesState, foldsChanged } 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 }; @@ -52,95 +53,40 @@ export class SelectionState extends LineBasedState> { return; } - this.map.clear(); - - /* If class list has changed, clear and recalculate the selection style */ if (this._themeClasses !== this.view.dom.classList.value) { this._drawInfo = undefined; this._themeClasses = this.view.dom.classList.value; } - const { ranges } = update.state.selection; + const lines = update.state.field(LinesState); + const nextSelections = new Map>(); - let selectionIndex = 0; - for (const [index, line] of update.state.field(LinesState).entries()) { - const selections: Array = []; - - let offset = 0; - for (const span of line) { - do { - // We've already processed all selections - if (selectionIndex >= ranges.length) { - continue; - } - - // The next selection begins after this span - if (span.to < ranges[selectionIndex].from) { - continue; - } - - // Ignore 0-length selections - if (ranges[selectionIndex].from === ranges[selectionIndex].to) { - selectionIndex++; - continue; - } - - // Build the selection for the current span - const range = ranges[selectionIndex]; - const selection = { - from: offset + Math.max(span.from, range.from) - span.from, - to: offset + Math.min(span.to, range.to) - span.from, - extends: range.to > span.to, - }; - - const lastSelection = selections.slice(-1)[0]; - if (lastSelection && lastSelection.to === selection.from) { - // The selection in this span may just be a continuation of the - // selection in the previous span - - // Adjust `to` depending on if we're in a folded span - let { to } = selection; - if (span.folded && selection.extends) { - to = selection.from + 1; - } else if (span.folded && !selection.extends) { - to = lastSelection.to; - } - - selections[selections.length - 1] = { - ...lastSelection, - to, - extends: selection.extends, - }; - } else if (!span.folded) { - // It's a new selection; if we're not in a folded span we - // should push it onto the stack - selections.push(selection); - } - - // If the selection doesn't end in this span, break out of the loop - if (selection.extends) { - break; - } - - // Otherwise, move to the next selection - selectionIndex++; - } while ( - selectionIndex < ranges.length && - span.to >= ranges[selectionIndex].from - ); - - offset += span.folded ? 1 : span.to - span.from; - } - - // If we don't have any selections on this line, we don't need to store anything - if (selections.length === 0) { + for (const range of update.state.selection.ranges) { + if (range.empty) { continue; } - // Lines are indexed beginning at 1 instead of 0 - const lineNumber = index + 1; - this.map.set(lineNumber, selections); + 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); } public drawLine(ctx: DrawContext, lineNumber: number) { @@ -199,6 +145,104 @@ 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) { + target[target.length - 1] = { + from: Math.min(last.from, entry.from), + to: Math.max(last.to, entry.to), + extends: entry.extends || last.extends, + }; + return; + } + target.push(entry); + } + + private applySelectionDiff(nextMap: Map>) { + if (nextMap.size === 0 && this.map.size === 0) { + return; + } + + 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); + } + } + } + + 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; + } + } + return true; + } } export function selections(view: EditorView): SelectionState { diff --git a/frontend/src/views/editor/extensions/minimap/text.ts b/frontend/src/views/editor/extensions/minimap/text.ts index 3f04d4f..0b3cb6d 100644 --- a/frontend/src/views/editor/extensions/minimap/text.ts +++ b/frontend/src/views/editor/extensions/minimap/text.ts @@ -8,9 +8,10 @@ import { Config, Options, Scale } from "./config"; import { LinesState, foldsChanged } from "./linesState"; import crelt from "crelt"; import { ChangeSet, EditorState } from "@codemirror/state"; - -type TagSpan = { text: string; tags: string }; -type FontInfo = { color: string; font: string; lineHeight: number }; +import { createDocInput } from "./text/docInput"; +import { TagSpan, FontInfo } from "./text/textTypes"; +import { GlyphAtlas } from "./text/glyphAtlas"; +import { LineRenderer } from "./text/lineRenderer"; export class TextState extends LineBasedState> { private _previousTree: Tree | undefined; @@ -18,11 +19,22 @@ export class TextState extends LineBasedState> { private _fontInfoMap: Map = new Map(); private _themeClasses: Set | undefined; private _highlightingCallbackId: number | NodeJS.Timeout | undefined; + private _fontInfoDirty: boolean = true; + private _fontInfoVersion: number = 0; + private _measurementCache: + | { charWidth: number; lineHeight: number; version: number } + | undefined; + private _glyphAtlas = new GlyphAtlas(); + private _lineRenderer: LineRenderer; public constructor(view: EditorView) { super(view); this._themeClasses = new Set(Array.from(view.dom.classList)); + this._lineRenderer = new LineRenderer( + this._glyphAtlas, + this.getFontInfo.bind(this) + ); if (view.state.facet(Config).enabled) { this.updateImpl(view.state); @@ -68,15 +80,9 @@ export class TextState extends LineBasedState> { } private updateImpl(state: EditorState, changes?: ChangeSet) { - this.map.clear(); - /* Store display text setting for rendering */ this._displayText = state.facet(Config).displayText; - - /* If class list has changed, clear and recalculate the font info map */ - if (this.themeChanged()) { - this._fontInfoMap.clear(); - } + this.refreshFontCachesIfNeeded(); /* Incrementally parse the tree based on previous tree + changes */ let treeFragments: ReadonlyArray | undefined = undefined; @@ -95,9 +101,10 @@ export class TextState extends LineBasedState> { } /* Parse the document into a lezer tree */ - const docToString = state.doc.toString(); const parser = state.facet(language)?.parser; - const tree = parser ? parser.parse(docToString, treeFragments) : undefined; + const tree = parser + ? parser.parse(createDocInput(state.doc), treeFragments) + : undefined; this._previousTree = tree; /* Highlight the document, and store the text and tags for each line */ @@ -106,6 +113,12 @@ export class TextState extends LineBasedState> { }; let highlights: Array<{ from: number; to: number; tags: string }> = []; + let viewportLines: + | { + from: number; + to: number; + } + | undefined; if (tree) { /** @@ -143,33 +156,51 @@ export class TextState extends LineBasedState> { const vpLineTop = state.doc.lineAt(this.view.viewport.from).number; const vpLineBottom = state.doc.lineAt(this.view.viewport.to).number; - const vpLineCount = vpLineBottom - vpLineTop; - const vpScroll = vpLineTop / (state.doc.lines - vpLineCount); + const vpLineCount = Math.max(1, vpLineBottom - vpLineTop); + const scrollDenominator = Math.max(1, state.doc.lines - vpLineCount); + const vpScroll = Math.min(1, Math.max(0, vpLineTop / scrollDenominator)); const { SizeRatio, PixelMultiplier } = Scale; const mmLineCount = vpLineCount * SizeRatio * PixelMultiplier; const mmLineRatio = vpScroll * mmLineCount; - const mmLineTop = Math.max(1, Math.floor(vpLineTop - mmLineRatio)); - const mmLineBottom = Math.min( + const mmLineTopRaw = Math.max(1, Math.floor(vpLineTop - mmLineRatio)); + const mmLineBottomRaw = Math.min( vpLineBottom + Math.floor(mmLineCount - mmLineRatio), state.doc.lines ); - // Highlight the in-view lines synchronously - highlightTree( - tree, - highlighter, - (from, to, tags) => { - highlights.push({ from, to, tags }); - }, - state.doc.line(mmLineTop).from, - state.doc.line(mmLineBottom).to - ); + if ( + Number.isFinite(mmLineTopRaw) && + Number.isFinite(mmLineBottomRaw) + ) { + const mmLineTop = Math.max(1, Math.floor(mmLineTopRaw)); + const mmLineBottom = Math.max(mmLineTop, Math.floor(mmLineBottomRaw)); + + viewportLines = { + from: mmLineTop, + to: mmLineBottom, + }; + + // Highlight the in-view lines synchronously + highlightTree( + tree, + highlighter, + (from, to, tags) => { + highlights.push({ from, to, tags }); + }, + state.doc.line(mmLineTop).from, + state.doc.line(mmLineBottom).to + ); + } } + const hasExistingData = this.map.size > 0; + const lineRange = + viewportLines && hasExistingData ? viewportLines : undefined; + // Update the map - this.updateMapImpl(state, highlights); + this.updateMapImpl(state, highlights, lineRange); // Highlight the entire tree in an idle callback highlights = []; @@ -178,7 +209,10 @@ export class TextState extends LineBasedState> { highlightTree(tree, highlighter, (from, to, tags) => { highlights.push({ from, to, tags }); }); - this.updateMapImpl(state, highlights); + this.updateMapImpl(state, highlights, { + from: 1, + to: state.doc.lines, + }); this._highlightingCallbackId = undefined; } }; @@ -188,17 +222,58 @@ export class TextState extends LineBasedState> { : setTimeout(highlightingCallback); } + private refreshFontCachesIfNeeded() { + if (!this._fontInfoDirty) { + return; + } + + this._fontInfoMap.clear(); + this._glyphAtlas.bust(); + this._measurementCache = undefined; + this._lineRenderer.markAllChanged(); + this._fontInfoDirty = false; + this._fontInfoVersion++; + } + private updateMapImpl( state: EditorState, - highlights: Array<{ from: number; to: number; tags: string }> + highlights: Array<{ from: number; to: number; tags: string }>, + lineRange?: { from: number; to: number } ) { - this.map.clear(); + const lines = state.field(LinesState); + const totalLines = lines.length; + const startIndex = lineRange + ? Math.max(0, Math.min(totalLines, lineRange.from) - 1) + : 0; + const endIndex = lineRange + ? Math.min(totalLines, Math.max(lineRange.to, lineRange.from)) + : totalLines; - const docToString = state.doc.toString(); + if (!lineRange) { + this.map.clear(); + this._lineRenderer.markAllChanged(); + } else { + this._lineRenderer.pruneLines(totalLines); + for (const lineNumber of Array.from(this.map.keys())) { + if (lineNumber > totalLines) { + this.map.delete(lineNumber); + } + } + } + + if (startIndex >= endIndex) { + return; + } + + const slice = (from: number, to: number) => state.doc.sliceString(from, to); const highlightsIterator = highlights.values(); let highlightPtr = highlightsIterator.next(); - for (const [index, line] of state.field(LinesState).entries()) { + for (let rawIndex = startIndex; rawIndex < endIndex; rawIndex++) { + const line = lines[rawIndex]; + if (!line) { + continue; + } const spans: Array = []; for (const span of line) { @@ -225,7 +300,7 @@ export class TextState extends LineBasedState> { // Append unstyled text before the highlight begins if (from > position) { - spans.push({ text: docToString.slice(position, from), tags: "" }); + spans.push({ text: slice(position, from), tags: "" }); } // A highlight may start before and extend beyond the current span @@ -233,7 +308,7 @@ export class TextState extends LineBasedState> { const end = Math.min(to, span.to); // Append the highlighted text - spans.push({ text: docToString.slice(start, end), tags }); + spans.push({ text: slice(start, end), tags }); position = end; // If the highlight continues beyond this span, break from this loop @@ -248,15 +323,20 @@ export class TextState extends LineBasedState> { // If there are remaining spans that did not get highlighted, append them unstyled if (position !== span.to) { spans.push({ - text: docToString.slice(position, span.to), + text: slice(position, span.to), tags: "", }); } } // Lines are indexed beginning at 1 instead of 0 - const lineNumber = index + 1; - this.map.set(lineNumber, spans); + const lineNumber = rawIndex + 1; + const previous = this.map.get(lineNumber); + if (previous && this.areSpansEqual(previous, spans)) { + continue; + } + + this.setLine(lineNumber, spans); } } @@ -270,86 +350,41 @@ export class TextState extends LineBasedState> { context.fillStyle = color; context.font = font; - return { + if ( + this._measurementCache && + this._measurementCache.version === this._fontInfoVersion + ) { + return { + charWidth: this._measurementCache.charWidth, + lineHeight: this._measurementCache.lineHeight, + }; + } + + const measurements = { charWidth: context.measureText("_").width, lineHeight: lineHeight, + version: this._fontInfoVersion, + }; + this._measurementCache = measurements; + + return { + charWidth: measurements.charWidth, + lineHeight: measurements.lineHeight, }; } public beforeDraw() { - this._fontInfoMap.clear(); // Confirm this worked for theme changes or get rid of it because it's slow + this.refreshFontCachesIfNeeded(); // Confirm this worked for theme changes or get rid of it because it's slow } public drawLine(ctx: DrawContext, lineNumber: number) { - const line = this.get(lineNumber); - if (!line) { + const spans = this.get(lineNumber); + if (!spans) { return; } - let { context, charWidth, lineHeight, offsetX, offsetY } = ctx; - - let prevInfo: FontInfo | undefined; - context.textBaseline = "ideographic"; - - for (const span of line) { - const info = this.getFontInfo(span.tags); - - if (!prevInfo || prevInfo.color !== info.color) { - context.fillStyle = info.color; - } - - if (!prevInfo || prevInfo.font !== info.font) { - context.font = info.font; - } - - prevInfo = info; - - lineHeight = Math.max(lineHeight, info.lineHeight); - - switch (this._displayText) { - case "characters": { - // TODO: `fillText` takes up the majority of profiling time in `render` - // Try speeding it up with `drawImage` - // https://stackoverflow.com/questions/8237030/html5-canvas-faster-filltext-vs-drawimage/8237081 - - context.fillText(span.text, offsetX, offsetY + lineHeight); - offsetX += span.text.length * charWidth; - break; - } - - case "blocks": { - const nonWhitespace = /\S+/g; - let start: RegExpExecArray | null; - while ((start = nonWhitespace.exec(span.text)) !== null) { - const startX = offsetX + start.index * charWidth; - let width = (nonWhitespace.lastIndex - start.index) * charWidth; - - // Reached the edge of the minimap - if (startX > context.canvas.width) { - break; - } - - // Limit width to edge of minimap - if (startX + width > context.canvas.width) { - width = context.canvas.width - startX; - } - - // Scaled 2px buffer between lines - const yBuffer = 2 / Scale.SizeRatio; - const height = lineHeight - yBuffer; - - context.fillStyle = info.color; - context.globalAlpha = 0.65; // Make the blocks a bit faded - context.beginPath(); - context.rect(startX, offsetY, width, height); - context.fill(); - } - - offsetX += span.text.length * charWidth; - break; - } - } - } + const displayMode = this._displayText ?? "characters"; + this._lineRenderer.drawLine(lineNumber, spans, displayMode, ctx); } private getFontInfo(tags: string): FontInfo { @@ -382,31 +417,52 @@ export class TextState extends LineBasedState> { return result; } + private setLine(lineNumber: number, spans: Array) { + this.map.set(lineNumber, spans); + this._lineRenderer.markLineChanged(lineNumber); + } + + private areSpansEqual(a: Array, b: Array) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i].text !== b[i].text || a[i].tags !== b[i].tags) { + return false; + } + } + return true; + } + private themeChanged(): boolean { const previous = this._themeClasses; const now = new Set(Array.from(this.view.dom.classList)); this._themeClasses = now; if (!previous) { + this._fontInfoDirty = true; return true; } // Ignore certain classes being added/removed - previous.delete("cm-focused"); - now.delete("cm-focused"); + const previousComparable = new Set(previous); + const nowComparable = new Set(now); + previousComparable.delete("cm-focused"); + nowComparable.delete("cm-focused"); - if (previous.size !== now.size) { + if (previousComparable.size !== nowComparable.size) { + this._fontInfoDirty = true; return true; } - let containsAll = true; - previous.forEach((theme) => { - if (!now.has(theme)) { - containsAll = false; + for (const theme of previousComparable) { + if (!nowComparable.has(theme)) { + this._fontInfoDirty = true; + return true; } - }); + } - return !containsAll; + return false; } } diff --git a/frontend/src/views/editor/extensions/minimap/text/docInput.ts b/frontend/src/views/editor/extensions/minimap/text/docInput.ts new file mode 100644 index 0000000..6ae2967 --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/text/docInput.ts @@ -0,0 +1,22 @@ +import { Text } from "@codemirror/state"; +import { Input } from "@lezer/common"; + +const INPUT_CHUNK_SIZE = 2048; + +export function createDocInput(doc: Text): Input { + return { + length: doc.length, + lineChunks: false, + chunk(from: number) { + if (from >= doc.length) { + return ""; + } + + const to = Math.min(doc.length, from + INPUT_CHUNK_SIZE); + return doc.sliceString(from, to); + }, + read(from: number, to: number) { + return doc.sliceString(from, to); + }, + }; +} diff --git a/frontend/src/views/editor/extensions/minimap/text/glyphAtlas.ts b/frontend/src/views/editor/extensions/minimap/text/glyphAtlas.ts new file mode 100644 index 0000000..4051d2a --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/text/glyphAtlas.ts @@ -0,0 +1,145 @@ +import { FontInfo } from "./textTypes"; + +export type GlyphCanvas = OffscreenCanvas | HTMLCanvasElement; + +type GlyphBitmap = { + source: CanvasImageSource; + sw: number; + sh: number; +}; + +export class GlyphAtlas { + private static measurementCanvas: + | OffscreenCanvas + | HTMLCanvasElement + | undefined; + private static measurementContext: + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null + | undefined; + + private readonly atlases = new Map>(); + private readonly enabled: boolean; + + public constructor() { + this.enabled = + typeof OffscreenCanvas !== "undefined" || typeof document !== "undefined"; + } + + public isAvailable() { + return this.enabled; + } + + public get( + info: FontInfo, + char: string, + intrinsicLineHeight: number + ): GlyphBitmap | undefined { + if (!this.enabled) { + return undefined; + } + + const key = `${info.font}|${info.color}|${intrinsicLineHeight.toFixed(2)}`; + let atlas = this.atlases.get(key); + if (!atlas) { + atlas = new Map(); + this.atlases.set(key, atlas); + } + + let glyph = atlas.get(char); + if (!glyph) { + glyph = this.createGlyph(info, char, intrinsicLineHeight); + if (glyph) { + atlas.set(char, glyph); + } + } + + return glyph; + } + + public bust() { + this.atlases.clear(); + } + + private createGlyph( + info: FontInfo, + char: string, + lineHeight: number + ): GlyphBitmap | undefined { + const measurement = GlyphAtlas.ensureMeasurementContext(); + if (!measurement) { + return undefined; + } + + measurement.font = info.font; + const metrics = measurement.measureText(char); + const width = Math.max( + Math.ceil( + metrics.actualBoundingBoxRight !== undefined && + metrics.actualBoundingBoxLeft !== undefined + ? metrics.actualBoundingBoxRight - metrics.actualBoundingBoxLeft + : metrics.width + ), + 1 + ); + const height = Math.max(1, Math.ceil(lineHeight)); + + const canvas = this.createCanvas(width, height); + const ctx = canvas.getContext("2d"); + if (!isCanvas2DContext(ctx)) { + return undefined; + } + + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = info.color; + ctx.font = info.font; + ctx.textBaseline = "ideographic"; + ctx.fillText(char, 0, height); + + return { source: canvas, sw: width, sh: height }; + } + + private createCanvas(width: number, height: number): GlyphCanvas { + if (typeof OffscreenCanvas !== "undefined") { + return new OffscreenCanvas(width, height); + } + + if (typeof document === "undefined") { + throw new Error("Unable to create canvas without DOM"); + } + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + return canvas; + } + + private static ensureMeasurementContext(): + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | undefined { + if (!GlyphAtlas.measurementCanvas) { + if (typeof OffscreenCanvas !== "undefined") { + GlyphAtlas.measurementCanvas = new OffscreenCanvas(1, 1); + } else if (typeof document !== "undefined") { + GlyphAtlas.measurementCanvas = document.createElement("canvas"); + } + } + + if (GlyphAtlas.measurementCanvas && !GlyphAtlas.measurementContext) { + const context = GlyphAtlas.measurementCanvas.getContext("2d"); + GlyphAtlas.measurementContext = isCanvas2DContext(context) + ? context + : undefined; + } + + return GlyphAtlas.measurementContext ?? undefined; + } +} + +export function isCanvas2DContext( + ctx: unknown +): ctx is CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D { + return !!ctx && typeof (ctx as CanvasRenderingContext2D).fillText === "function"; +} diff --git a/frontend/src/views/editor/extensions/minimap/text/lineRenderer.ts b/frontend/src/views/editor/extensions/minimap/text/lineRenderer.ts new file mode 100644 index 0000000..23d5e52 --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/text/lineRenderer.ts @@ -0,0 +1,311 @@ +import { DrawContext } from "../types"; +import { Options, Scale } from "../config"; +import { GlyphAtlas, GlyphCanvas, isCanvas2DContext } from "./glyphAtlas"; +import { TagSpan, FontInfo } from "./textTypes"; + +type DisplayMode = Required["displayText"]; + +type LineBitmap = { + version: number; + charWidth: number; + baseLineHeight: number; + availableWidth: number; + height: number; + displayMode: DisplayMode; + canvas: GlyphCanvas; +}; + +export class LineRenderer { + private readonly _lineVersions = new Map(); + private readonly _lineCache = new Map(); + + public constructor( + private readonly glyphAtlas: GlyphAtlas, + private readonly resolveFontInfo: (tags: string) => FontInfo + ) {} + + public markLineChanged(lineNumber: number) { + const version = (this._lineVersions.get(lineNumber) ?? 0) + 1; + this._lineVersions.set(lineNumber, version); + this._lineCache.delete(lineNumber); + } + + public markAllChanged() { + this._lineVersions.clear(); + this._lineCache.clear(); + } + + public pruneLines(totalLines: number) { + for (const key of this._lineVersions.keys()) { + if (key > totalLines) { + this._lineVersions.delete(key); + this._lineCache.delete(key); + } + } + } + + public drawLine( + lineNumber: number, + spans: Array, + displayText: DisplayMode, + ctx: DrawContext + ) { + if (spans.length === 0) { + return; + } + + const availableWidth = Math.max( + 0, + Math.floor(ctx.context.canvas.width - ctx.offsetX) + ); + if (availableWidth <= 0) { + return; + } + + const version = this._lineVersions.get(lineNumber) ?? 0; + const cached = this.ensureLineBitmap( + lineNumber, + spans, + version, + displayText, + ctx, + availableWidth + ); + + if (!cached) { + this.paintLineDirectly(spans, displayText, ctx, availableWidth); + return; + } + + ctx.context.drawImage( + cached.canvas, + 0, + 0, + cached.availableWidth, + cached.height, + ctx.offsetX, + ctx.offsetY, + cached.availableWidth, + cached.height + ); + } + + private paintLineDirectly( + spans: Array, + displayText: DisplayMode, + ctx: DrawContext, + availableWidth: number + ) { + this.paintSpans( + ctx.context, + spans, + displayText, + ctx.charWidth, + ctx.lineHeight, + ctx.offsetX, + ctx.offsetY, + availableWidth + ); + } + + private ensureLineBitmap( + lineNumber: number, + spans: Array, + version: number, + displayText: DisplayMode, + ctx: DrawContext, + availableWidth: number + ): LineBitmap | undefined { + const cached = this._lineCache.get(lineNumber); + if ( + cached && + cached.version === version && + cached.charWidth === ctx.charWidth && + cached.baseLineHeight === ctx.lineHeight && + cached.availableWidth === availableWidth && + cached.displayMode === displayText + ) { + return cached; + } + + const fontInfos = spans.map((span) => this.resolveFontInfo(span.tags)); + let maxLineHeight = ctx.lineHeight; + for (const info of fontInfos) { + maxLineHeight = Math.max(maxLineHeight, info.lineHeight); + } + + const width = Math.max(1, availableWidth); + const height = Math.max(1, Math.ceil(maxLineHeight)); + const canvas = this.createLineCanvas(width, height); + if (!canvas) { + return undefined; + } + + const lineCtx = canvas.getContext("2d"); + if (!isCanvas2DContext(lineCtx)) { + return undefined; + } + + lineCtx.clearRect(0, 0, width, height); + this.paintSpans( + lineCtx, + spans, + displayText, + ctx.charWidth, + maxLineHeight, + 0, + 0, + width, + fontInfos + ); + + const entry: LineBitmap = { + version, + charWidth: ctx.charWidth, + baseLineHeight: ctx.lineHeight, + availableWidth: width, + height, + displayMode: displayText, + canvas, + }; + + this._lineCache.set(lineNumber, entry); + return entry; + } + + private paintSpans( + context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, + spans: Array, + displayText: DisplayMode, + charWidth: number, + baseLineHeight: number, + offsetX: number, + offsetY: number, + availableWidth: number, + fontInfos?: Array + ) { + let cursorX = offsetX; + let prevInfo: FontInfo | undefined; + context.textBaseline = "ideographic"; + + for (let i = 0; i < spans.length; i++) { + const span = spans[i]; + const info = fontInfos?.[i] ?? this.resolveFontInfo(span.tags); + + if (!prevInfo || prevInfo.color !== info.color) { + context.fillStyle = info.color; + } + + if (!prevInfo || prevInfo.font !== info.font) { + context.font = info.font; + } + + prevInfo = info; + const spanLineHeight = Math.max(baseLineHeight, info.lineHeight); + + if (displayText === "characters") { + cursorX = this.drawCharactersSpan( + context, + span.text, + info, + cursorX, + offsetY, + spanLineHeight, + charWidth + ); + continue; + } + + const nonWhitespace = /\S+/g; + let start: RegExpExecArray | null; + while ((start = nonWhitespace.exec(span.text)) !== null) { + const startX = cursorX + start.index * charWidth; + let width = (nonWhitespace.lastIndex - start.index) * charWidth; + const relativeStart = startX - offsetX; + + if (relativeStart > availableWidth) { + break; + } + + if (relativeStart + width > availableWidth) { + width = availableWidth - relativeStart; + } + + if (width <= 0) { + continue; + } + + const yBuffer = 2 / Scale.SizeRatio; + const height = spanLineHeight - yBuffer; + + context.fillStyle = info.color; + context.globalAlpha = 0.65; + context.beginPath(); + context.rect(startX, offsetY, width, height); + context.fill(); + } + + cursorX += span.text.length * charWidth; + context.globalAlpha = 1; + } + } + + private drawCharactersSpan( + context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, + text: string, + fontInfo: FontInfo, + offsetX: number, + offsetY: number, + lineHeight: number, + charWidth: number + ) { + if (!text) { + return offsetX; + } + + context.globalAlpha = 1; + + if (!this.glyphAtlas.isAvailable()) { + context.fillText(text, offsetX, offsetY + lineHeight); + return offsetX + text.length * charWidth; + } + + for (const char of text) { + const glyph = this.glyphAtlas.get(fontInfo, char, fontInfo.lineHeight); + if (glyph) { + const destY = offsetY + (lineHeight - glyph.sh); + context.drawImage( + glyph.source, + 0, + 0, + glyph.sw, + glyph.sh, + offsetX, + destY, + charWidth, + glyph.sh + ); + } else { + context.fillText(char, offsetX, offsetY + lineHeight); + } + offsetX += charWidth; + } + + return offsetX; + } + + private createLineCanvas(width: number, height: number): GlyphCanvas | undefined { + if (typeof OffscreenCanvas !== "undefined") { + return new OffscreenCanvas(width, height); + } + + if (typeof document !== "undefined") { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + return canvas; + } + + return undefined; + } +} diff --git a/frontend/src/views/editor/extensions/minimap/text/textTypes.ts b/frontend/src/views/editor/extensions/minimap/text/textTypes.ts new file mode 100644 index 0000000..7dae88e --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/text/textTypes.ts @@ -0,0 +1,7 @@ +export type TagSpan = { text: string; tags: string }; + +export type FontInfo = { + color: string; + font: string; + lineHeight: number; +};