⚡ Optimize minmap performance
This commit is contained in:
@@ -9,11 +9,20 @@ import {
|
|||||||
import { LineBasedState } from "./linebasedstate";
|
import { LineBasedState } from "./linebasedstate";
|
||||||
import { DrawContext } from "./types";
|
import { DrawContext } from "./types";
|
||||||
import { Lines, LinesState, foldsChanged } from "./linesState";
|
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 Severity = Diagnostic["severity"];
|
||||||
|
type DiagnosticRange = { from: number; to: number };
|
||||||
|
type LineDiagnostics = {
|
||||||
|
severity: Severity;
|
||||||
|
ranges: Array<DiagnosticRange>;
|
||||||
|
};
|
||||||
|
const MIN_PIXEL_WIDTH = 1 / Scale.PixelMultiplier;
|
||||||
|
const snapToDevice = (value: number) =>
|
||||||
|
Math.round(value * Scale.PixelMultiplier) / Scale.PixelMultiplier;
|
||||||
|
|
||||||
export class DiagnosticState extends LineBasedState<Severity> {
|
export class DiagnosticState extends LineBasedState<LineDiagnostics> {
|
||||||
private count: number | undefined = undefined;
|
private count: number | undefined = undefined;
|
||||||
|
|
||||||
public constructor(view: EditorView) {
|
public constructor(view: EditorView) {
|
||||||
@@ -63,70 +72,74 @@ export class DiagnosticState extends LineBasedState<Severity> {
|
|||||||
this.count = diagnosticCount(update.state);
|
this.count = diagnosticCount(update.state);
|
||||||
|
|
||||||
forEachDiagnostic(update.state, (diagnostic, from, to) => {
|
forEachDiagnostic(update.state, (diagnostic, from, to) => {
|
||||||
// Find the start and end lines for the diagnostic
|
const lineStart = lineNumberAt(lines, from);
|
||||||
const lineStart = this.findLine(from, lines);
|
const lineEnd = lineNumberAt(lines, to);
|
||||||
const lineEnd = this.findLine(to, lines);
|
if (lineStart <= 0 || lineEnd <= 0) {
|
||||||
|
|
||||||
// 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];
|
|
||||||
}
|
|
||||||
this.set(i, severity);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public drawLine(ctx: DrawContext, lineNumber: number) {
|
|
||||||
const { context, lineHeight, offsetX, offsetY } = ctx;
|
|
||||||
const severity = this.get(lineNumber);
|
|
||||||
if (!severity) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw the full line width rectangle in the background
|
for (let lineNumber = lineStart; lineNumber <= lineEnd; lineNumber++) {
|
||||||
context.globalAlpha = 0.65;
|
const spans = lines[lineNumber - 1];
|
||||||
context.beginPath();
|
if (!spans || spans.length === 0) {
|
||||||
context.rect(
|
continue;
|
||||||
offsetX,
|
|
||||||
offsetY /* TODO Scaling causes anti-aliasing in rectangles */,
|
|
||||||
context.canvas.width - offsetX,
|
|
||||||
lineHeight
|
|
||||||
);
|
|
||||||
context.fillStyle = this.color(severity);
|
|
||||||
context.fill();
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const length = lineLength(spans);
|
||||||
* 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) {
|
const startOffset =
|
||||||
return false;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
return start.from <= pos && pos <= end.to;
|
this.pushRange(lineNumber, diagnostic.severity, {
|
||||||
|
from: fromOffset,
|
||||||
|
to: toOffset,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Line numbers begin at 1
|
this.mergeRanges();
|
||||||
return index + 1;
|
}
|
||||||
|
|
||||||
|
public drawLine(ctx: DrawContext, lineNumber: number) {
|
||||||
|
const diagnostics = this.get(lineNumber);
|
||||||
|
if (!diagnostics) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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<Severity> {
|
|||||||
: "#999";
|
: "#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) {
|
private score(s: Severity) {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case "error": {
|
case "error": {
|
||||||
@@ -160,6 +167,47 @@ export class DiagnosticState extends LineBasedState<Severity> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<DiagnosticRange> = [];
|
||||||
|
|
||||||
|
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 {
|
export function diagnostics(view: EditorView): DiagnosticState {
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ const minimapClass = ViewPlugin.fromClass(
|
|||||||
private dom: HTMLElement | undefined;
|
private dom: HTMLElement | undefined;
|
||||||
private inner: HTMLElement | undefined;
|
private inner: HTMLElement | undefined;
|
||||||
private canvas: HTMLCanvasElement | undefined;
|
private canvas: HTMLCanvasElement | undefined;
|
||||||
|
private renderHandle: number | ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private cancelRender: (() => void) | null = null;
|
||||||
|
|
||||||
public text: TextState;
|
public text: TextState;
|
||||||
public selection: SelectionState;
|
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) {
|
if (config.autohide) {
|
||||||
this.dom.classList.add('cm-minimap-autohide');
|
this.dom.classList.add('cm-minimap-autohide');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
private remove() {
|
private remove() {
|
||||||
|
this.cancelRenderRequest();
|
||||||
if (this.dom) {
|
if (this.dom) {
|
||||||
this.dom.remove();
|
this.dom.remove();
|
||||||
}
|
}
|
||||||
|
this.dom = undefined;
|
||||||
|
this.inner = undefined;
|
||||||
|
this.canvas = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
@@ -153,7 +141,7 @@ const minimapClass = ViewPlugin.fromClass(
|
|||||||
this.text.update(update);
|
this.text.update(update);
|
||||||
this.selection.update(update);
|
this.selection.update(update);
|
||||||
this.diagnostic.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 gutters = this.view.state.facet(Config).gutters;
|
||||||
|
|
||||||
for (let i = startIndex; i < endIndex; i++) {
|
|
||||||
const lines = this.view.state.field(LinesState);
|
const lines = this.view.state.field(LinesState);
|
||||||
|
|
||||||
|
for (let i = startIndex; i < endIndex; i++) {
|
||||||
if (i >= lines.length) break;
|
if (i >= lines.length) break;
|
||||||
|
|
||||||
const drawContext = {
|
const drawContext = {
|
||||||
@@ -233,8 +222,40 @@ const minimapClass = ViewPlugin.fromClass(
|
|||||||
|
|
||||||
offsetY += lineHeight;
|
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(
|
private canvasStartAndEndIndex(
|
||||||
@@ -291,7 +312,7 @@ const minimapClass = ViewPlugin.fromClass(
|
|||||||
{
|
{
|
||||||
eventHandlers: {
|
eventHandlers: {
|
||||||
scroll() {
|
scroll() {
|
||||||
requestAnimationFrame(() => this.render());
|
this.requestRender();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
provide: (plugin) => {
|
provide: (plugin) => {
|
||||||
@@ -323,17 +344,6 @@ export type MinimapConfig = Omit<Options, "enabled"> & {
|
|||||||
*/
|
*/
|
||||||
const showMinimapFacet = Facet.define<MinimapConfig | null, MinimapConfig | null>({
|
const showMinimapFacet = Facet.define<MinimapConfig | null, MinimapConfig | null>({
|
||||||
combine: (c) => c.find((o) => o !== null) ?? null,
|
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
|
* @returns
|
||||||
*/
|
*/
|
||||||
export function minimap(options: Partial<Omit<MinimapConfig, 'create'>> = {}) {
|
export function minimap(options: Partial<Omit<MinimapConfig, 'create'>> = {}) {
|
||||||
return showMinimapFacet.of({
|
const config: MinimapConfig = {
|
||||||
create: defaultCreateFn,
|
create: defaultCreateFn,
|
||||||
...options
|
...options,
|
||||||
});
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// 保持原始接口兼容性
|
return [
|
||||||
export { showMinimapFacet as showMinimap };
|
showMinimapFacet.of(config),
|
||||||
|
Config.compute([showMinimapFacet], (s) => s.facet(showMinimapFacet)),
|
||||||
|
Theme,
|
||||||
|
LinesState,
|
||||||
|
minimapClass,
|
||||||
|
Overlay,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
85
frontend/src/views/editor/extensions/minimap/lineGeometry.ts
Normal file
85
frontend/src/views/editor/extensions/minimap/lineGeometry.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -51,6 +51,9 @@ const OverlayView = ViewPlugin.fromClass(
|
|||||||
|
|
||||||
private _isDragging: boolean = false;
|
private _isDragging: boolean = false;
|
||||||
private _dragStartY: number | undefined;
|
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) {
|
public constructor(private view: EditorView) {
|
||||||
if (view.state.facet(Config).enabled) {
|
if (view.state.facet(Config).enabled) {
|
||||||
@@ -59,14 +62,16 @@ const OverlayView = ViewPlugin.fromClass(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private create(view: EditorView) {
|
private create(view: EditorView) {
|
||||||
|
this.remove();
|
||||||
|
|
||||||
this.container = crelt("div", { class: "cm-minimap-overlay-container" });
|
this.container = crelt("div", { class: "cm-minimap-overlay-container" });
|
||||||
this.dom = crelt("div", { class: "cm-minimap-overlay" });
|
this.dom = crelt("div", { class: "cm-minimap-overlay" });
|
||||||
this.container.appendChild(this.dom);
|
this.container.appendChild(this.dom);
|
||||||
|
|
||||||
// Attach event listeners for overlay
|
// Attach event listeners for overlay
|
||||||
this.container.addEventListener("mousedown", this.onMouseDown.bind(this));
|
this.container.addEventListener("mousedown", this._boundMouseDown);
|
||||||
window.addEventListener("mouseup", this.onMouseUp.bind(this));
|
window.addEventListener("mouseup", this._boundMouseUp);
|
||||||
window.addEventListener("mousemove", this.onMouseMove.bind(this));
|
window.addEventListener("mousemove", this._boundMouseMove);
|
||||||
|
|
||||||
// Attach the overlay elements to the minimap
|
// Attach the overlay elements to the minimap
|
||||||
const inner = view.dom.querySelector(".cm-minimap-inner");
|
const inner = view.dom.querySelector(".cm-minimap-inner");
|
||||||
@@ -82,10 +87,12 @@ const OverlayView = ViewPlugin.fromClass(
|
|||||||
|
|
||||||
private remove() {
|
private remove() {
|
||||||
if (this.container) {
|
if (this.container) {
|
||||||
this.container.removeEventListener("mousedown", this.onMouseDown);
|
this.container.removeEventListener("mousedown", this._boundMouseDown);
|
||||||
window.removeEventListener("mouseup", this.onMouseUp);
|
window.removeEventListener("mouseup", this._boundMouseUp);
|
||||||
window.removeEventListener("mousemove", this.onMouseMove);
|
window.removeEventListener("mousemove", this._boundMouseMove);
|
||||||
this.container.remove();
|
this.container.remove();
|
||||||
|
this.container = undefined;
|
||||||
|
this.dom = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { LineBasedState } from "./linebasedstate";
|
import { LineBasedState } from "./linebasedstate";
|
||||||
import { EditorView, ViewUpdate } from "@codemirror/view";
|
import { EditorView, ViewUpdate } from "@codemirror/view";
|
||||||
import { LinesState, foldsChanged } from "./linesState";
|
import { Lines, LinesState, foldsChanged } from "./linesState";
|
||||||
import { DrawContext } from "./types";
|
import { DrawContext } from "./types";
|
||||||
import { Config } from "./config";
|
import { Config } from "./config";
|
||||||
|
import { lineLength, lineNumberAt, offsetWithinLine } from "./lineGeometry";
|
||||||
|
|
||||||
type Selection = { from: number; to: number; extends: boolean };
|
type Selection = { from: number; to: number; extends: boolean };
|
||||||
type DrawInfo = { backgroundColor: string };
|
type DrawInfo = { backgroundColor: string };
|
||||||
@@ -52,95 +53,40 @@ export class SelectionState extends LineBasedState<Array<Selection>> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.map.clear();
|
|
||||||
|
|
||||||
/* If class list has changed, clear and recalculate the selection style */
|
|
||||||
if (this._themeClasses !== this.view.dom.classList.value) {
|
if (this._themeClasses !== this.view.dom.classList.value) {
|
||||||
this._drawInfo = undefined;
|
this._drawInfo = undefined;
|
||||||
this._themeClasses = this.view.dom.classList.value;
|
this._themeClasses = this.view.dom.classList.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ranges } = update.state.selection;
|
const lines = update.state.field(LinesState);
|
||||||
|
const nextSelections = new Map<number, Array<Selection>>();
|
||||||
|
|
||||||
let selectionIndex = 0;
|
for (const range of update.state.selection.ranges) {
|
||||||
for (const [index, line] of update.state.field(LinesState).entries()) {
|
if (range.empty) {
|
||||||
const selections: Array<Selection> = [];
|
|
||||||
|
|
||||||
let offset = 0;
|
|
||||||
for (const span of line) {
|
|
||||||
do {
|
|
||||||
// We've already processed all selections
|
|
||||||
if (selectionIndex >= ranges.length) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The next selection begins after this span
|
const startLine = lineNumberAt(lines, range.from);
|
||||||
if (span.to < ranges[selectionIndex].from) {
|
const endLine = lineNumberAt(
|
||||||
continue;
|
lines,
|
||||||
}
|
Math.max(range.from, range.to - 1)
|
||||||
|
|
||||||
// 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 (startLine <= 0 || endLine <= 0) {
|
||||||
}
|
|
||||||
|
|
||||||
// If we don't have any selections on this line, we don't need to store anything
|
|
||||||
if (selections.length === 0) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lines are indexed beginning at 1 instead of 0
|
this.collectRangeSelections(
|
||||||
const lineNumber = index + 1;
|
nextSelections,
|
||||||
this.map.set(lineNumber, selections);
|
lines,
|
||||||
|
range.from,
|
||||||
|
range.to,
|
||||||
|
startLine,
|
||||||
|
endLine
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.applySelectionDiff(nextSelections);
|
||||||
}
|
}
|
||||||
|
|
||||||
public drawLine(ctx: DrawContext, lineNumber: number) {
|
public drawLine(ctx: DrawContext, lineNumber: number) {
|
||||||
@@ -199,6 +145,104 @@ export class SelectionState extends LineBasedState<Array<Selection>> {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private collectRangeSelections(
|
||||||
|
store: Map<number, Array<Selection>>,
|
||||||
|
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<number, Array<Selection>>,
|
||||||
|
lineNumber: number
|
||||||
|
) {
|
||||||
|
let selections = store.get(lineNumber);
|
||||||
|
if (!selections) {
|
||||||
|
selections = [];
|
||||||
|
store.set(lineNumber, selections);
|
||||||
|
}
|
||||||
|
return selections;
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendSelection(target: Array<Selection>, 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<number, Array<Selection>>) {
|
||||||
|
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<Selection>, b: Array<Selection>) {
|
||||||
|
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 {
|
export function selections(view: EditorView): SelectionState {
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { Config, Options, Scale } from "./config";
|
|||||||
import { LinesState, foldsChanged } from "./linesState";
|
import { LinesState, foldsChanged } from "./linesState";
|
||||||
import crelt from "crelt";
|
import crelt from "crelt";
|
||||||
import { ChangeSet, EditorState } from "@codemirror/state";
|
import { ChangeSet, EditorState } from "@codemirror/state";
|
||||||
|
import { createDocInput } from "./text/docInput";
|
||||||
type TagSpan = { text: string; tags: string };
|
import { TagSpan, FontInfo } from "./text/textTypes";
|
||||||
type FontInfo = { color: string; font: string; lineHeight: number };
|
import { GlyphAtlas } from "./text/glyphAtlas";
|
||||||
|
import { LineRenderer } from "./text/lineRenderer";
|
||||||
|
|
||||||
export class TextState extends LineBasedState<Array<TagSpan>> {
|
export class TextState extends LineBasedState<Array<TagSpan>> {
|
||||||
private _previousTree: Tree | undefined;
|
private _previousTree: Tree | undefined;
|
||||||
@@ -18,11 +19,22 @@ export class TextState extends LineBasedState<Array<TagSpan>> {
|
|||||||
private _fontInfoMap: Map<string, FontInfo> = new Map();
|
private _fontInfoMap: Map<string, FontInfo> = new Map();
|
||||||
private _themeClasses: Set<string> | undefined;
|
private _themeClasses: Set<string> | undefined;
|
||||||
private _highlightingCallbackId: number | NodeJS.Timeout | 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) {
|
public constructor(view: EditorView) {
|
||||||
super(view);
|
super(view);
|
||||||
|
|
||||||
this._themeClasses = new Set(Array.from(view.dom.classList));
|
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) {
|
if (view.state.facet(Config).enabled) {
|
||||||
this.updateImpl(view.state);
|
this.updateImpl(view.state);
|
||||||
@@ -68,15 +80,9 @@ export class TextState extends LineBasedState<Array<TagSpan>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateImpl(state: EditorState, changes?: ChangeSet) {
|
private updateImpl(state: EditorState, changes?: ChangeSet) {
|
||||||
this.map.clear();
|
|
||||||
|
|
||||||
/* Store display text setting for rendering */
|
/* Store display text setting for rendering */
|
||||||
this._displayText = state.facet(Config).displayText;
|
this._displayText = state.facet(Config).displayText;
|
||||||
|
this.refreshFontCachesIfNeeded();
|
||||||
/* If class list has changed, clear and recalculate the font info map */
|
|
||||||
if (this.themeChanged()) {
|
|
||||||
this._fontInfoMap.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Incrementally parse the tree based on previous tree + changes */
|
/* Incrementally parse the tree based on previous tree + changes */
|
||||||
let treeFragments: ReadonlyArray<TreeFragment> | undefined = undefined;
|
let treeFragments: ReadonlyArray<TreeFragment> | undefined = undefined;
|
||||||
@@ -95,9 +101,10 @@ export class TextState extends LineBasedState<Array<TagSpan>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Parse the document into a lezer tree */
|
/* Parse the document into a lezer tree */
|
||||||
const docToString = state.doc.toString();
|
|
||||||
const parser = state.facet(language)?.parser;
|
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;
|
this._previousTree = tree;
|
||||||
|
|
||||||
/* Highlight the document, and store the text and tags for each line */
|
/* Highlight the document, and store the text and tags for each line */
|
||||||
@@ -106,6 +113,12 @@ export class TextState extends LineBasedState<Array<TagSpan>> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let highlights: Array<{ from: number; to: number; tags: string }> = [];
|
let highlights: Array<{ from: number; to: number; tags: string }> = [];
|
||||||
|
let viewportLines:
|
||||||
|
| {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
if (tree) {
|
if (tree) {
|
||||||
/**
|
/**
|
||||||
@@ -143,19 +156,32 @@ export class TextState extends LineBasedState<Array<TagSpan>> {
|
|||||||
|
|
||||||
const vpLineTop = state.doc.lineAt(this.view.viewport.from).number;
|
const vpLineTop = state.doc.lineAt(this.view.viewport.from).number;
|
||||||
const vpLineBottom = state.doc.lineAt(this.view.viewport.to).number;
|
const vpLineBottom = state.doc.lineAt(this.view.viewport.to).number;
|
||||||
const vpLineCount = vpLineBottom - vpLineTop;
|
const vpLineCount = Math.max(1, vpLineBottom - vpLineTop);
|
||||||
const vpScroll = vpLineTop / (state.doc.lines - vpLineCount);
|
const scrollDenominator = Math.max(1, state.doc.lines - vpLineCount);
|
||||||
|
const vpScroll = Math.min(1, Math.max(0, vpLineTop / scrollDenominator));
|
||||||
|
|
||||||
const { SizeRatio, PixelMultiplier } = Scale;
|
const { SizeRatio, PixelMultiplier } = Scale;
|
||||||
const mmLineCount = vpLineCount * SizeRatio * PixelMultiplier;
|
const mmLineCount = vpLineCount * SizeRatio * PixelMultiplier;
|
||||||
const mmLineRatio = vpScroll * mmLineCount;
|
const mmLineRatio = vpScroll * mmLineCount;
|
||||||
|
|
||||||
const mmLineTop = Math.max(1, Math.floor(vpLineTop - mmLineRatio));
|
const mmLineTopRaw = Math.max(1, Math.floor(vpLineTop - mmLineRatio));
|
||||||
const mmLineBottom = Math.min(
|
const mmLineBottomRaw = Math.min(
|
||||||
vpLineBottom + Math.floor(mmLineCount - mmLineRatio),
|
vpLineBottom + Math.floor(mmLineCount - mmLineRatio),
|
||||||
state.doc.lines
|
state.doc.lines
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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
|
// Highlight the in-view lines synchronously
|
||||||
highlightTree(
|
highlightTree(
|
||||||
tree,
|
tree,
|
||||||
@@ -167,9 +193,14 @@ export class TextState extends LineBasedState<Array<TagSpan>> {
|
|||||||
state.doc.line(mmLineBottom).to
|
state.doc.line(mmLineBottom).to
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasExistingData = this.map.size > 0;
|
||||||
|
const lineRange =
|
||||||
|
viewportLines && hasExistingData ? viewportLines : undefined;
|
||||||
|
|
||||||
// Update the map
|
// Update the map
|
||||||
this.updateMapImpl(state, highlights);
|
this.updateMapImpl(state, highlights, lineRange);
|
||||||
|
|
||||||
// Highlight the entire tree in an idle callback
|
// Highlight the entire tree in an idle callback
|
||||||
highlights = [];
|
highlights = [];
|
||||||
@@ -178,7 +209,10 @@ export class TextState extends LineBasedState<Array<TagSpan>> {
|
|||||||
highlightTree(tree, highlighter, (from, to, tags) => {
|
highlightTree(tree, highlighter, (from, to, tags) => {
|
||||||
highlights.push({ 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;
|
this._highlightingCallbackId = undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -188,17 +222,58 @@ export class TextState extends LineBasedState<Array<TagSpan>> {
|
|||||||
: setTimeout(highlightingCallback);
|
: 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(
|
private updateMapImpl(
|
||||||
state: EditorState,
|
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();
|
const highlightsIterator = highlights.values();
|
||||||
let highlightPtr = highlightsIterator.next();
|
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<TagSpan> = [];
|
const spans: Array<TagSpan> = [];
|
||||||
|
|
||||||
for (const span of line) {
|
for (const span of line) {
|
||||||
@@ -225,7 +300,7 @@ export class TextState extends LineBasedState<Array<TagSpan>> {
|
|||||||
|
|
||||||
// Append unstyled text before the highlight begins
|
// Append unstyled text before the highlight begins
|
||||||
if (from > position) {
|
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
|
// A highlight may start before and extend beyond the current span
|
||||||
@@ -233,7 +308,7 @@ export class TextState extends LineBasedState<Array<TagSpan>> {
|
|||||||
const end = Math.min(to, span.to);
|
const end = Math.min(to, span.to);
|
||||||
|
|
||||||
// Append the highlighted text
|
// Append the highlighted text
|
||||||
spans.push({ text: docToString.slice(start, end), tags });
|
spans.push({ text: slice(start, end), tags });
|
||||||
position = end;
|
position = end;
|
||||||
|
|
||||||
// If the highlight continues beyond this span, break from this loop
|
// If the highlight continues beyond this span, break from this loop
|
||||||
@@ -248,15 +323,20 @@ export class TextState extends LineBasedState<Array<TagSpan>> {
|
|||||||
// If there are remaining spans that did not get highlighted, append them unstyled
|
// If there are remaining spans that did not get highlighted, append them unstyled
|
||||||
if (position !== span.to) {
|
if (position !== span.to) {
|
||||||
spans.push({
|
spans.push({
|
||||||
text: docToString.slice(position, span.to),
|
text: slice(position, span.to),
|
||||||
tags: "",
|
tags: "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lines are indexed beginning at 1 instead of 0
|
// Lines are indexed beginning at 1 instead of 0
|
||||||
const lineNumber = index + 1;
|
const lineNumber = rawIndex + 1;
|
||||||
this.map.set(lineNumber, spans);
|
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<Array<TagSpan>> {
|
|||||||
context.fillStyle = color;
|
context.fillStyle = color;
|
||||||
context.font = font;
|
context.font = font;
|
||||||
|
|
||||||
|
if (
|
||||||
|
this._measurementCache &&
|
||||||
|
this._measurementCache.version === this._fontInfoVersion
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
|
charWidth: this._measurementCache.charWidth,
|
||||||
|
lineHeight: this._measurementCache.lineHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const measurements = {
|
||||||
charWidth: context.measureText("_").width,
|
charWidth: context.measureText("_").width,
|
||||||
lineHeight: lineHeight,
|
lineHeight: lineHeight,
|
||||||
|
version: this._fontInfoVersion,
|
||||||
|
};
|
||||||
|
this._measurementCache = measurements;
|
||||||
|
|
||||||
|
return {
|
||||||
|
charWidth: measurements.charWidth,
|
||||||
|
lineHeight: measurements.lineHeight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public beforeDraw() {
|
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) {
|
public drawLine(ctx: DrawContext, lineNumber: number) {
|
||||||
const line = this.get(lineNumber);
|
const spans = this.get(lineNumber);
|
||||||
if (!line) {
|
if (!spans) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { context, charWidth, lineHeight, offsetX, offsetY } = ctx;
|
const displayMode = this._displayText ?? "characters";
|
||||||
|
this._lineRenderer.drawLine(lineNumber, spans, displayMode, 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFontInfo(tags: string): FontInfo {
|
private getFontInfo(tags: string): FontInfo {
|
||||||
@@ -382,31 +417,52 @@ export class TextState extends LineBasedState<Array<TagSpan>> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setLine(lineNumber: number, spans: Array<TagSpan>) {
|
||||||
|
this.map.set(lineNumber, spans);
|
||||||
|
this._lineRenderer.markLineChanged(lineNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
private areSpansEqual(a: Array<TagSpan>, b: Array<TagSpan>) {
|
||||||
|
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 {
|
private themeChanged(): boolean {
|
||||||
const previous = this._themeClasses;
|
const previous = this._themeClasses;
|
||||||
const now = new Set<string>(Array.from(this.view.dom.classList));
|
const now = new Set<string>(Array.from(this.view.dom.classList));
|
||||||
this._themeClasses = now;
|
this._themeClasses = now;
|
||||||
|
|
||||||
if (!previous) {
|
if (!previous) {
|
||||||
|
this._fontInfoDirty = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore certain classes being added/removed
|
// Ignore certain classes being added/removed
|
||||||
previous.delete("cm-focused");
|
const previousComparable = new Set(previous);
|
||||||
now.delete("cm-focused");
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let containsAll = true;
|
for (const theme of previousComparable) {
|
||||||
previous.forEach((theme) => {
|
if (!nowComparable.has(theme)) {
|
||||||
if (!now.has(theme)) {
|
this._fontInfoDirty = true;
|
||||||
containsAll = false;
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return !containsAll;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
145
frontend/src/views/editor/extensions/minimap/text/glyphAtlas.ts
Normal file
145
frontend/src/views/editor/extensions/minimap/text/glyphAtlas.ts
Normal file
@@ -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<string, Map<string, GlyphBitmap>>();
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -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<Options>["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<number, number>();
|
||||||
|
private readonly _lineCache = new Map<number, LineBitmap>();
|
||||||
|
|
||||||
|
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<TagSpan>,
|
||||||
|
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<TagSpan>,
|
||||||
|
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<TagSpan>,
|
||||||
|
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<TagSpan>,
|
||||||
|
displayText: DisplayMode,
|
||||||
|
charWidth: number,
|
||||||
|
baseLineHeight: number,
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number,
|
||||||
|
availableWidth: number,
|
||||||
|
fontInfos?: Array<FontInfo>
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type TagSpan = { text: string; tags: string };
|
||||||
|
|
||||||
|
export type FontInfo = {
|
||||||
|
color: string;
|
||||||
|
font: string;
|
||||||
|
lineHeight: number;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user