Files
voidraft/frontend/src/views/editor/extensions/minimap/selections.ts
2025-12-10 22:25:19 +08:00

248 lines
6.4 KiB
TypeScript

import { LineBasedState } from "./linebasedstate";
import { EditorView, ViewUpdate } from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { Lines, foldsChanged, getLinesSnapshot } from "./linesState";
import { DrawContext } from "./types";
import { Config } from "./config";
import { lineLength, lineNumberAt, offsetWithinLine } from "./lineGeometry";
type Selection = { from: number; to: number; extends: boolean };
type DrawInfo = { backgroundColor: string };
type RangeInfo = {
from: number;
to: number;
lineFrom: number;
lineTo: number;
};
const MAX_CACHED_LINES = 800;
export class SelectionState extends LineBasedState<Array<Selection>> {
private _drawInfo: DrawInfo | undefined;
private _themeClasses: string;
private _rangeInfo: Array<RangeInfo> = [];
private _linesSnapshot: Lines = [];
public constructor(view: EditorView) {
super(view);
this.getDrawInfo();
this._themeClasses = view.dom.classList.value;
}
private shouldUpdate(update: ViewUpdate) {
// If the minimap is disabled
if (!update.state.facet(Config).enabled) {
return false;
}
// If the doc changed
if (update.docChanged) {
return true;
}
// If the selection changed
if (update.selectionSet) {
return true;
}
// If the theme changed
if (this._themeClasses !== this.view.dom.classList.value) {
return true;
}
// If the folds changed
if (foldsChanged(update.transactions)) {
return true;
}
return false;
}
public update(update: ViewUpdate) {
if (!this.shouldUpdate(update)) {
return;
}
this.rebuild(update.state);
}
public drawLine(ctx: DrawContext, lineNumber: number) {
const {
context,
lineHeight,
charWidth,
offsetX: startOffsetX,
offsetY,
} = ctx;
const selections = this.ensureSelections(lineNumber);
if (!selections) {
return;
}
for (const selection of selections) {
const offsetX = startOffsetX + selection.from * charWidth;
const textWidth = (selection.to - selection.from) * charWidth;
const fullWidth = context.canvas.width - offsetX;
if (selection.extends) {
// Draw the full width rectangle in the background
context.globalAlpha = 0.65;
context.beginPath();
context.rect(offsetX, offsetY, fullWidth, lineHeight);
context.fillStyle = this.getDrawInfo().backgroundColor;
context.fill();
}
// Draw text selection rectangle in the foreground
context.globalAlpha = 1;
context.beginPath();
context.rect(offsetX, offsetY, textWidth, lineHeight);
context.fillStyle = this.getDrawInfo().backgroundColor;
context.fill();
}
}
private getDrawInfo(): DrawInfo {
if (this._drawInfo) {
return this._drawInfo;
}
// Create a mock selection
const mockToken = document.createElement("span");
mockToken.setAttribute("class", "cm-selectionBackground");
this.view.dom.appendChild(mockToken);
// Get style information
const style = window.getComputedStyle(mockToken);
const result = { backgroundColor: style.backgroundColor };
// Store the result for the next update
this._drawInfo = result;
this.view.dom.removeChild(mockToken);
return result;
}
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 rebuild(state: EditorState) {
if (this._themeClasses !== this.view.dom.classList.value) {
this._drawInfo = undefined;
this._themeClasses = this.view.dom.classList.value;
}
this._linesSnapshot = getLinesSnapshot(state);
this._rangeInfo = this.buildRangeInfo(state, this._linesSnapshot);
this.map.clear();
}
private buildRangeInfo(state: EditorState, lines: Lines) {
const info: Array<RangeInfo> = [];
for (const range of state.selection.ranges) {
if (range.empty) {
continue;
}
const startLine = lineNumberAt(lines, range.from);
const endLine = lineNumberAt(lines, Math.max(range.from, range.to - 1));
if (startLine <= 0 || endLine <= 0) {
continue;
}
info.push({
from: range.from,
to: range.to,
lineFrom: startLine,
lineTo: endLine,
});
}
return info;
}
private ensureSelections(lineNumber: number) {
const cached = this.get(lineNumber);
if (cached) {
return cached;
}
const computed = this.buildSelectionsForLine(lineNumber);
if (!computed || computed.length === 0) {
return undefined;
}
if (this.map.has(lineNumber)) {
this.map.delete(lineNumber);
}
this.map.set(lineNumber, computed);
while (this.map.size > MAX_CACHED_LINES) {
const oldest = this.map.keys().next();
if (oldest.done) {
break;
}
this.map.delete(oldest.value);
}
return computed;
}
private buildSelectionsForLine(lineNumber: number) {
const spans = this._linesSnapshot[lineNumber - 1];
if (!spans || spans.length === 0) {
return undefined;
}
const relevant = this._rangeInfo.filter(
(info) => lineNumber >= info.lineFrom && lineNumber <= info.lineTo
);
if (!relevant.length) {
return undefined;
}
const selections: Array<Selection> = [];
for (const range of relevant) {
const length = lineLength(spans);
const fromOffset =
lineNumber === range.lineFrom
? offsetWithinLine(range.from, spans)
: 0;
let toOffset =
lineNumber === range.lineTo
? offsetWithinLine(range.to, spans)
: length;
if (toOffset === fromOffset) {
toOffset = Math.min(length, fromOffset + 1);
}
const lastSpan = spans[spans.length - 1];
const spanEnd = lastSpan ? lastSpan.to : range.to;
const extendsLine =
lineNumber < range.lineTo ||
(lineNumber === range.lineTo && range.to > spanEnd);
this.appendSelection(selections, {
from: fromOffset,
to: toOffset,
extends: extendsLine,
});
}
return selections;
}
}
export function selections(view: EditorView): SelectionState {
return new SelectionState(view);
}