Added text highlight and minimap

This commit is contained in:
2025-06-17 17:31:01 +08:00
parent 0927b921c3
commit 1d6cf7cf68
15 changed files with 2237 additions and 12 deletions

View File

@@ -19,6 +19,7 @@
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-less": "^6.0.2",
"@codemirror/lang-lezer": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.3",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lang-php": "^6.0.1",
@@ -38,12 +39,15 @@
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.37.1",
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2",
"@types/uuid": "^10.0.0",
"@vueuse/core": "^13.3.0",
"codemirror": "^6.0.1",
"codemirror-lang-elixir": "^4.0.0",
"colors-named": "^1.0.2",
"colors-named-hex": "^1.0.2",
"hsl-matcher": "^1.2.4",
"lezer": "^0.13.5",
"pinia": "^3.0.2",
"sass": "^1.89.1",
"uuid": "^11.1.0",
@@ -53,6 +57,7 @@
},
"devDependencies": {
"@eslint/js": "^9.28.0",
"@lezer/generator": "^1.7.3",
"@types/node": "^22.15.29",
"@vitejs/plugin-vue": "^5.2.4",
"@wailsio/runtime": "latest",
@@ -252,6 +257,18 @@
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/lang-lezer": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-lezer/-/lang-lezer-6.0.1.tgz",
"integrity": "sha512-WHwjI7OqKFBEfkunohweqA5B/jIlxaZso6Nl3weVckz8EafYbPZldQEKSDb4QQ9H9BUkle4PVELP4sftKoA0uQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/lezer": "^1.0.0"
}
},
"node_modules/@codemirror/lang-liquid": {
"version": "6.2.3",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-liquid/-/lang-liquid-6.2.3.tgz",
@@ -1220,6 +1237,20 @@
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/generator": {
"version": "1.7.3",
"resolved": "https://registry.npmmirror.com/@lezer/generator/-/generator-1.7.3.tgz",
"integrity": "sha512-vAI2O1tPF8QMMgp+bdUeeJCneJNkOZvqsrtyb4ohnFVFdboSqPwBEacnt0HH4E+5h+qsIwTHUSAhffU4hzKl1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.1.0",
"@lezer/lr": "^1.3.0"
},
"bin": {
"lezer-generator": "src/lezer-generator.cjs"
}
},
"node_modules/@lezer/go": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/@lezer/go/-/go-1.0.0.tgz",
@@ -1284,6 +1315,16 @@
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lezer": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@lezer/lezer/-/lezer-1.1.2.tgz",
"integrity": "sha512-O8yw3CxPhzYHB1hvwbdozjnAslhhR8A5BH7vfEMof0xk3p+/DFDfZkA9Tde6J+88WgtwaHy4Sy6ThZSkaI0Evw==",
"license": "MIT",
"dependencies": {
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.2",
"resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.2.tgz",
@@ -2857,6 +2898,15 @@
"@codemirror/view": "^6.0.0"
}
},
"node_modules/codemirror-lang-elixir": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/codemirror-lang-elixir/-/codemirror-lang-elixir-4.0.0.tgz",
"integrity": "sha512-mzFesxo/t6KOxwnkqVd34R/q7yk+sMtHh6vUKGAvjwHmpL7bERHB+vQAsmU/nqrndkwVeJEHWGw/z/ybfdiudA==",
"dependencies": {
"@codemirror/language": "^6.0.0",
"lezer-elixir": "^1.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
@@ -3764,6 +3814,32 @@
"node": ">= 0.8.0"
}
},
"node_modules/lezer": {
"version": "0.13.5",
"resolved": "https://registry.npmmirror.com/lezer/-/lezer-0.13.5.tgz",
"integrity": "sha512-cAiMQZGUo2BD8mpcz7Nv1TlKzWP7YIdIRrX41CiP5bk5t4GHxskOxWUx2iAOuHlz8dO+ivbuXr0J1bfHsWD+lQ==",
"deprecated": "This package has been replaced by @lezer/lr",
"license": "MIT",
"dependencies": {
"lezer-tree": "^0.13.2"
}
},
"node_modules/lezer-elixir": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/lezer-elixir/-/lezer-elixir-1.1.2.tgz",
"integrity": "sha512-K3yPMJcNhqCL6ugr5NkgOC1g37rcOM38XZezO9lBXy0LwWFd8zdWXfmRbY829vZVk0OGCQoI02yDWp9FF2OWZA==",
"dependencies": {
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/lezer-tree": {
"version": "0.13.2",
"resolved": "https://registry.npmmirror.com/lezer-tree/-/lezer-tree-0.13.2.tgz",
"integrity": "sha512-15ZxW8TxVNAOkHIo43Iouv4zbSkQQ5chQHBpwXcD2bBFz46RB4jYLEEww5l1V0xyIx9U2clSyyrLes+hAUFrGQ==",
"deprecated": "This package has been replaced by @lezer/common",
"license": "MIT"
},
"node_modules/local-pkg": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.1.tgz",

View File

@@ -23,6 +23,7 @@
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-less": "^6.0.2",
"@codemirror/lang-lezer": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.3",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lang-php": "^6.0.1",
@@ -42,12 +43,15 @@
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.37.1",
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2",
"@types/uuid": "^10.0.0",
"@vueuse/core": "^13.3.0",
"codemirror": "^6.0.1",
"codemirror-lang-elixir": "^4.0.0",
"colors-named": "^1.0.2",
"colors-named-hex": "^1.0.2",
"hsl-matcher": "^1.2.4",
"lezer": "^0.13.5",
"pinia": "^3.0.2",
"sass": "^1.89.1",
"uuid": "^11.1.0",
@@ -57,6 +61,7 @@
},
"devDependencies": {
"@eslint/js": "^9.28.0",
"@lezer/generator": "^1.7.3",
"@types/node": "^22.15.29",
"@vitejs/plugin-vue": "^5.2.4",
"@wailsio/runtime": "latest",

View File

@@ -13,11 +13,6 @@ export interface AutoSaveOptions {
/**
* 创建自动保存插件
*
* 新的简化保存策略:
* - 前端只负责将内容变更传递给后端
* - 后端使用定时保存机制,每隔配置的时间间隔自动保存(仅在有变更时)
* - 移除了复杂的阈值保存和最小间隔控制
*
* @param options 配置选项
* @returns EditorView.Plugin
*/

View File

@@ -10,7 +10,6 @@ import {
keymap,
lineNumbers,
rectangularSelection,
KeyBinding,
} from '@codemirror/view';
import {
bracketMatching,
@@ -24,20 +23,27 @@ import {defaultKeymap, history, historyKeymap,} from '@codemirror/commands';
import {highlightSelectionMatches} from '@codemirror/search';
import {autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap} from '@codemirror/autocomplete';
import {lintKeymap} from '@codemirror/lint';
import { vscodeSearch, customSearchKeymap, searchVisibilityField } from './vscodeSearch';
import {searchVisibilityField, vscodeSearch, customSearchKeymap} from './vscodeSearch';
import { hyperLink } from './hyperlink';
import { color } from './colorSelector';
import {hyperLink} from './hyperlink';
import {color} from './colorSelector';
import {textHighlighter} from './textHighlightExtension';
import {minimap} from './minimap';
// 基本编辑器设置,包含常用扩展
// 基本编辑器设置
export const createBasicSetup = (): Extension[] => {
return [
vscodeSearch,
searchVisibilityField,
hyperLink,
color,
textHighlighter,
minimap({
displayText: 'characters',
showOverlay: 'always',
autohide: false,
}),
// 基础UI
lineNumbers(),
@@ -79,4 +85,4 @@ export const createBasicSetup = (): Extension[] => {
...lintKeymap
]),
];
};
};

View File

@@ -0,0 +1,85 @@
import { Facet, combineConfig } from "@codemirror/state";
import { DOMEventMap, EditorView } from "@codemirror/view";
import { MinimapConfig } from ".";
import { Gutter } from "./gutters";
type EventHandler<event extends keyof DOMEventMap> = (
e: DOMEventMap[event],
v: EditorView
) => void;
export type Options = {
/**
* Controls whether the minimap should be hidden on mouseout.
* Defaults to `false`.
*/
autohide?: boolean;
enabled: boolean;
/**
* Determines how to render text. Defaults to `characters`.
*/
displayText?: "blocks" | "characters";
/**
* Attach event handlers to the minimap container element.
*/
eventHandlers?: {
[event in keyof DOMEventMap]?: EventHandler<event>;
};
/**
* The overlay shows the portion of the file currently in the viewport.
* Defaults to `always`.
*/
showOverlay?: "always" | "mouse-over";
/**
* Enables a gutter to be drawn on the given line to the left
* of the minimap, with the given color. Accepts all valid CSS
* color values.
*/
gutters?: Array<Gutter>;
};
const Config = Facet.define<MinimapConfig | null, Required<Options>>({
combine: (c) => {
const configs: Array<Options> = [];
for (let config of c) {
if (!config) {
continue;
}
const { create, gutters, ...rest } = config;
configs.push({
...rest,
enabled: true,
gutters: gutters
? gutters.filter((v) => Object.keys(v).length > 0)
: undefined,
});
}
return combineConfig(configs, {
enabled: configs.length > 0,
displayText: "characters",
eventHandlers: {},
showOverlay: "always",
gutters: [],
autohide: false,
});
},
});
const Scale = {
// Multiply the number of canvas pixels
PixelMultiplier: 2,
// Downscale the editor contents by this ratio
SizeRatio: 4,
// Maximum width of the minimap in pixels
MaxWidth: 120,
} as const;
export { Config, Scale };

View File

@@ -0,0 +1,167 @@
import { EditorView, ViewUpdate } from "@codemirror/view";
import {
Diagnostic,
diagnosticCount,
forEachDiagnostic,
setDiagnosticsEffect,
} from "@codemirror/lint";
import { LineBasedState } from "./linebasedstate";
import { DrawContext } from "./types";
import { Lines, LinesState, foldsChanged } from "./linesState";
import { Config } from "./config";
type Severity = Diagnostic["severity"];
export class DiagnosticState extends LineBasedState<Severity> {
private count: number | undefined = undefined;
public constructor(view: EditorView) {
super(view);
}
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 diagnostics changed
for (const tr of update.transactions) {
for (const ef of tr.effects) {
if (ef.is(setDiagnosticsEffect)) {
return true;
}
}
}
// If the folds changed
if (foldsChanged(update.transactions)) {
return true;
}
// If the minimap was previously hidden
if (this.count === undefined) {
return true;
}
return false;
}
public update(update: ViewUpdate) {
if (!this.shouldUpdate(update)) {
return;
}
this.map.clear();
const lines = update.state.field(LinesState);
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);
// 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;
}
// 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();
// 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();
}
/**
* 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;
}
/**
* Colors from @codemirror/lint
* https://github.com/codemirror/lint/blob/e0671b43c02e72766ad1afe1579b7032fdcdb6c1/src/lint.ts#L597
*/
private color(severity: Severity) {
return severity === "error"
? "#d11"
: severity === "warning"
? "orange"
: "#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": {
return 3;
}
case "warning": {
return 2;
}
default: {
return 1;
}
}
}
}
export function diagnostics(view: EditorView): DiagnosticState {
return new DiagnosticState(view);
}

View File

@@ -0,0 +1,27 @@
import { DrawContext } from "./types";
const GUTTER_WIDTH = 4;
type Line = number;
type Color = string;
export type Gutter = Record<Line, Color>;
/**
* Draws a gutter to the canvas context for the given line number
*/
function drawLineGutter(gutter: Record<Line, Color>, ctx: DrawContext, lineNumber: number) {
const color = gutter[lineNumber];
if (!color) {
return;
}
ctx.context.fillStyle = color;
ctx.context.globalAlpha = 1;
ctx.context.beginPath();
ctx.context.rect(ctx.offsetX, ctx.offsetY, GUTTER_WIDTH, ctx.lineHeight);
ctx.context.fill();
}
export { GUTTER_WIDTH, drawLineGutter }

View File

@@ -0,0 +1,332 @@
import { Facet } from "@codemirror/state";
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
import { Overlay } from "./overlay";
import { Config, Options, Scale } from "./config";
import { DiagnosticState, diagnostics } from "./diagnostics";
import { SelectionState, selections } from "./selections";
import { TextState, text } from "./text";
import { LinesState } from "./linesState";
import crelt from "crelt";
import { GUTTER_WIDTH, drawLineGutter } from "./gutters";
const Theme = EditorView.theme({
"&": {
height: "100%",
overflowY: "auto",
},
"& .cm-minimap-gutter": {
borderRight: 0,
flexShrink: 0,
left: "unset",
position: "sticky",
right: 0,
top: 0,
},
'& .cm-minimap-autohide': {
opacity: 0.0,
transition: 'opacity 0.3s',
},
'& .cm-minimap-autohide:hover': {
opacity: 1.0,
},
"& .cm-minimap-inner": {
height: "100%",
position: "absolute",
right: 0,
top: 0,
overflowY: "hidden",
"& canvas": {
display: "block",
},
},
"& .cm-minimap-box-shadow": {
boxShadow: "12px 0px 20px 5px #6c6c6c",
},
});
const WIDTH_RATIO = 6;
const minimapClass = ViewPlugin.fromClass(
class {
private dom: HTMLElement | undefined;
private inner: HTMLElement | undefined;
private canvas: HTMLCanvasElement | undefined;
public text: TextState;
public selection: SelectionState;
public diagnostic: DiagnosticState;
public constructor(private view: EditorView) {
this.text = text(view);
this.selection = selections(view);
this.diagnostic = diagnostics(view);
if (view.state.facet(showMinimapFacet)) {
this.create(view);
}
}
private create(view: EditorView) {
const config = view.state.facet(showMinimapFacet);
if (!config) {
throw Error("Expected nonnull");
}
this.inner = crelt("div", { class: "cm-minimap-inner" });
this.canvas = crelt("canvas") as HTMLCanvasElement;
this.dom = config.create(view).dom;
this.dom.classList.add("cm-gutters");
this.dom.classList.add("cm-minimap-gutter");
this.inner.appendChild(this.canvas);
this.dom.appendChild(this.inner);
// For now let's keep this same behavior. We might want to change
// this in the future and have the extension figure out how to mount.
// Or expose some more generic right gutter api and use that
this.view.scrollDOM.insertBefore(
this.dom,
this.view.contentDOM.nextSibling
);
for (const key in this.view.state.facet(Config).eventHandlers) {
const handler = this.view.state.facet(Config).eventHandlers[key];
if (handler) {
this.dom.addEventListener(key, (e) => handler(e, this.view));
}
}
if (config.autohide) {
this.dom.classList.add('cm-minimap-autohide');
}
}
private remove() {
if (this.dom) {
this.dom.remove();
}
}
update(update: ViewUpdate) {
const prev = update.startState.facet(showMinimapFacet);
const now = update.state.facet(showMinimapFacet);
if (prev && !now) {
this.remove();
return;
}
if (!prev && now) {
this.create(update.view);
}
if (now) {
this.text.update(update);
this.selection.update(update);
this.diagnostic.update(update);
this.render();
}
}
getWidth(): number {
const editorWidth = this.view.dom.clientWidth;
if (editorWidth <= Scale.MaxWidth * WIDTH_RATIO) {
const ratio = editorWidth / (Scale.MaxWidth * WIDTH_RATIO);
return Scale.MaxWidth * ratio;
}
return Scale.MaxWidth;
}
render() {
// If we don't have elements to draw to exit early
if (!this.dom || !this.canvas || !this.inner) {
return;
}
this.text.beforeDraw();
this.updateBoxShadow();
this.dom.style.width = this.getWidth() + "px";
this.canvas.style.maxWidth = this.getWidth() + "px";
this.canvas.width = this.getWidth() * Scale.PixelMultiplier;
const domHeight = this.view.dom.getBoundingClientRect().height;
this.inner.style.minHeight = domHeight + "px";
this.canvas.height = domHeight * Scale.PixelMultiplier;
this.canvas.style.height = domHeight + "px";
const context = this.canvas.getContext("2d");
if (!context) {
return;
}
context.clearRect(0, 0, this.canvas.width, this.canvas.height);
/* We need to get the correct font dimensions before this to measure characters */
const { charWidth, lineHeight } = this.text.measure(context);
let { startIndex, endIndex, offsetY } = this.canvasStartAndEndIndex(
context,
lineHeight
);
const gutters = this.view.state.facet(Config).gutters;
for (let i = startIndex; i < endIndex; i++) {
const lines = this.view.state.field(LinesState);
if (i >= lines.length) break;
const drawContext = {
offsetX: 0,
offsetY,
context,
lineHeight,
charWidth,
};
if (gutters.length) {
/* Small leading buffer */
drawContext.offsetX += 2;
for (let gutter of gutters) {
drawLineGutter(gutter, drawContext, i + 1);
drawContext.offsetX += GUTTER_WIDTH;
}
/* Small trailing buffer */
drawContext.offsetX += 2;
}
this.text.drawLine(drawContext, i + 1);
this.selection.drawLine(drawContext, i + 1);
this.diagnostic.drawLine(drawContext, i + 1);
offsetY += lineHeight;
}
context.restore();
}
private canvasStartAndEndIndex(
context: CanvasRenderingContext2D,
lineHeight: number
) {
let { top: pTop, bottom: pBottom } = this.view.documentPadding;
(pTop /= Scale.SizeRatio), (pBottom /= Scale.SizeRatio);
const canvasHeight = context.canvas.height;
const { clientHeight, scrollHeight, scrollTop } = this.view.scrollDOM;
let scrollPercent = scrollTop / (scrollHeight - clientHeight);
if (isNaN(scrollPercent)) {
scrollPercent = 0;
}
const lineCount = this.view.state.field(LinesState).length;
const totalHeight = pTop + pBottom + lineCount * lineHeight;
const canvasTop = Math.max(
0,
scrollPercent * (totalHeight - canvasHeight)
);
const offsetY = Math.max(0, pTop - canvasTop);
const startIndex = Math.round(Math.max(0, canvasTop - pTop) / lineHeight);
const spaceForLines = Math.round((canvasHeight - offsetY) / lineHeight);
return {
startIndex,
endIndex: startIndex + spaceForLines,
offsetY,
};
}
private updateBoxShadow() {
if (!this.canvas) {
return;
}
const { clientWidth, scrollWidth, scrollLeft } = this.view.scrollDOM;
if (clientWidth + scrollLeft < scrollWidth) {
this.canvas.classList.add("cm-minimap-box-shadow");
} else {
this.canvas.classList.remove("cm-minimap-box-shadow");
}
}
destroy() {
this.remove();
}
},
{
eventHandlers: {
scroll() {
requestAnimationFrame(() => this.render());
},
},
provide: (plugin) => {
return EditorView.scrollMargins.of((view) => {
const width = view.plugin(plugin)?.getWidth();
if (!width) {
return null;
}
return { right: width };
});
},
}
);
// 使用type定义而不是interface
export type MinimapConfig = Omit<Options, "enabled"> & {
/**
* A function that creates the element that contains the minimap
*/
create: (view: EditorView) => { dom: HTMLElement };
};
/**
* Facet used to show a minimap in the right gutter of the editor using the
* provided configuration.
*
* If you return `null`, a minimap will not be shown.
*/
const showMinimapFacet = Facet.define<MinimapConfig | null, MinimapConfig | 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,
],
];
},
});
/**
* 创建默认的minimap DOM元素
*/
const defaultCreateFn = (view: EditorView) => {
const dom = document.createElement('div');
return { dom };
};
/**
* 添加minimap到编辑器
* @param options Minimap配置项
* @returns
*/
export function minimap(options: Partial<Omit<MinimapConfig, 'create'>> = {}) {
return showMinimapFacet.of({
create: defaultCreateFn,
...options
});
}
// 保持原始接口兼容性
export { showMinimapFacet as showMinimap };

View File

@@ -0,0 +1,20 @@
import { EditorView } from "@codemirror/view";
export abstract class LineBasedState<TValue> {
protected map: Map<number, TValue>;
protected view: EditorView;
public constructor(view: EditorView) {
this.map = new Map();
this.view = view;
}
public get(lineNumber: number): TValue | undefined {
return this.map.get(lineNumber);
}
protected set(lineNumber: number, value: TValue) {
this.map.set(lineNumber, value);
}
}

View File

@@ -0,0 +1,95 @@
import { foldEffect, foldedRanges, unfoldEffect } from "@codemirror/language";
import { StateField, EditorState, Transaction } from "@codemirror/state";
import { Config } from "./config";
type Span = { from: number; to: number; folded: boolean };
type Line = Array<Span>;
export type Lines = Array<Line>;
function computeLinesState(state: EditorState): Lines {
if (!state.facet(Config).enabled) {
return [];
}
const lines: Lines = [];
const lineCursor = state.doc.iterLines();
const foldedRangeCursor = foldedRanges(state).iter();
let textOffset = 0;
lineCursor.next();
while (!lineCursor.done) {
const lineText = lineCursor.value;
let from = textOffset;
let to = from + lineText.length;
// Iterate through folded ranges until we're at or past the current line
while (foldedRangeCursor.value && foldedRangeCursor.to < from) {
foldedRangeCursor.next();
}
const { from: foldFrom, to: foldTo } = foldedRangeCursor;
const lineStartInFold = from >= foldFrom && from < foldTo;
const lineEndsInFold = to > foldFrom && to <= foldTo;
if (lineStartInFold) {
let lastLine = lines.pop() ?? [];
let lastRange = lastLine.pop();
// If the last range is folded, we extend the folded range
if (lastRange && lastRange.folded) {
lastRange.to = foldTo;
}
// If we popped the last range, add it back
if (lastRange) {
lastLine.push(lastRange);
}
// If we didn't have a previous range, or the previous range wasn't folded add a new range
if (!lastRange || !lastRange.folded) {
lastLine.push({ from: foldFrom, to: foldTo, folded: true });
}
// If the line doesn't end in a fold, we add another token for the unfolded section
if (!lineEndsInFold) {
lastLine.push({ from: foldTo, to, folded: false });
}
lines.push(lastLine);
} else if (lineEndsInFold) {
lines.push([
{ from, to: foldFrom, folded: false },
{ from: foldFrom, to: foldTo, folded: true },
]);
} else {
lines.push([{ from, to, folded: false }]);
}
textOffset = to + 1;
lineCursor.next();
}
return lines;
}
const LinesState = StateField.define<Lines>({
create: (state) => computeLinesState(state),
update: (current, tr) => {
if (foldsChanged([tr]) || tr.docChanged) {
return computeLinesState(tr.state);
}
return current;
},
});
/** Returns if the folds have changed in this update */
function foldsChanged(transactions: readonly Transaction[]) {
return transactions.find((tr) =>
tr.effects.find((ef) => ef.is(foldEffect) || ef.is(unfoldEffect))
);
}
export { foldsChanged, LinesState };

View File

@@ -0,0 +1,299 @@
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
import { Config, Scale } from "./config";
import crelt from "crelt";
const Theme = EditorView.theme({
".cm-minimap-overlay-container": {
position: "absolute",
top: 0,
height: "100%",
width: "100%",
"&.cm-minimap-overlay-mouse-over": {
opacity: 0,
transition: "visibility 0s linear 300ms, opacity 300ms",
},
"&.cm-minimap-overlay-mouse-over:hover": {
opacity: 1,
transition: "visibility 0s linear 0ms, opacity 300ms",
},
"&.cm-minimap-overlay-off": {
display: "none",
},
"& .cm-minimap-overlay": {
background: "rgb(121, 121, 121)",
opacity: "0.2",
position: "absolute",
right: 0,
top: 0,
width: "100%",
transition: "top 0s ease-in 0ms",
"&:hover": {
opacity: "0.3",
},
},
"&.cm-minimap-overlay-active": {
opacity: 1,
visibility: "visible",
transition: "visibility 0s linear 0ms, opacity 300ms",
"& .cm-minimap-overlay": {
opacity: "0.4",
},
},
},
});
const SCALE = Scale.PixelMultiplier * Scale.SizeRatio;
const OverlayView = ViewPlugin.fromClass(
class {
private container: HTMLElement | undefined;
private dom: HTMLElement | undefined;
private _isDragging: boolean = false;
private _dragStartY: number | undefined;
public constructor(private view: EditorView) {
if (view.state.facet(Config).enabled) {
this.create(view);
}
}
private create(view: EditorView) {
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));
// Attach the overlay elements to the minimap
const inner = view.dom.querySelector(".cm-minimap-inner");
if (inner) {
inner.appendChild(this.container);
}
// Initially set overlay configuration styles, height, top
this.computeShowOverlay();
this.computeHeight();
this.computeTop();
}
private remove() {
if (this.container) {
this.container.removeEventListener("mousedown", this.onMouseDown);
window.removeEventListener("mouseup", this.onMouseUp);
window.removeEventListener("mousemove", this.onMouseMove);
this.container.remove();
}
}
update(update: ViewUpdate) {
const prev = update.startState.facet(Config).enabled;
const now = update.state.facet(Config).enabled;
if (prev && !now) {
this.remove();
return;
}
if (!prev && now) {
this.create(update.view);
}
if (now) {
this.computeShowOverlay();
if (update.geometryChanged) {
this.computeHeight();
this.computeTop();
}
}
}
public computeHeight() {
if (!this.dom) {
return;
}
const height = this.view.dom.clientHeight / SCALE;
this.dom.style.height = height + "px";
}
public computeTop() {
if (!this._isDragging && this.dom) {
const { clientHeight, scrollHeight, scrollTop } = this.view.scrollDOM;
const maxScrollTop = scrollHeight - clientHeight;
const topForNonOverflowing = scrollTop / SCALE;
const height = clientHeight / SCALE;
const maxTop = clientHeight - height;
let scrollRatio = scrollTop / maxScrollTop;
if (isNaN(scrollRatio)) scrollRatio = 0;
const topForOverflowing = maxTop * scrollRatio;
const top = Math.min(topForOverflowing, topForNonOverflowing);
this.dom.style.top = top + "px";
}
}
public computeShowOverlay() {
if (!this.container) {
return;
}
const { showOverlay } = this.view.state.facet(Config);
if (showOverlay === "mouse-over") {
this.container.classList.add("cm-minimap-overlay-mouse-over");
} else {
this.container.classList.remove("cm-minimap-overlay-mouse-over");
}
const { clientHeight, scrollHeight } = this.view.scrollDOM;
if (clientHeight === scrollHeight) {
this.container.classList.add("cm-minimap-overlay-off");
} else {
this.container.classList.remove("cm-minimap-overlay-off");
}
}
private onMouseDown(event: MouseEvent) {
if (!this.container) {
return;
}
// Ignore right click
if (event.button === 2) {
return;
}
// If target is the overlay start dragging
const { clientY, target } = event;
if (target === this.dom) {
this._dragStartY = event.clientY;
this._isDragging = true;
this.container.classList.add("cm-minimap-overlay-active");
return;
}
// Updates the scroll position of the EditorView based on the
// position of the MouseEvent on the minimap canvas
const { clientHeight, scrollHeight, scrollTop } = this.view.scrollDOM;
const targetTop = (target as HTMLElement).getBoundingClientRect().top;
const deltaY = (clientY - targetTop) * SCALE;
const scrollRatio = scrollTop / (scrollHeight - clientHeight);
const visibleRange = clientHeight * SCALE - clientHeight;
const visibleTop = visibleRange * scrollRatio;
const top = Math.max(0, scrollTop - visibleTop);
this.view.scrollDOM.scrollTop = top + deltaY - clientHeight / 2;
}
private onMouseUp(_event: MouseEvent) {
// Stop dragging on mouseup
if (this._isDragging && this.container) {
this._dragStartY = undefined;
this._isDragging = false;
this.container.classList.remove("cm-minimap-overlay-active");
}
}
private onMouseMove(event: MouseEvent) {
if (!this._isDragging || !this.dom) {
return;
}
event.preventDefault();
event.stopPropagation();
// Without an existing position, we're just beginning to drag.
if (!this._dragStartY) {
this._dragStartY = event.clientY;
return;
}
const deltaY = event.clientY - this._dragStartY;
const movingUp = deltaY < 0;
const movingDown = deltaY > 0;
// Update drag position for the next tick
this._dragStartY = event.clientY;
const canvasHeight = this.dom.getBoundingClientRect().height;
const canvasAbsTop = this.dom.getBoundingClientRect().y;
const canvasAbsBot = canvasAbsTop + canvasHeight;
const canvasRelTopDouble = parseFloat(this.dom.style.top);
const scrollPosition = this.view.scrollDOM.scrollTop;
const editorHeight = this.view.scrollDOM.clientHeight;
const contentHeight = this.view.scrollDOM.scrollHeight;
const atTop = scrollPosition === 0;
const atBottom =
Math.round(scrollPosition) >= Math.round(contentHeight - editorHeight);
// We allow over-dragging past the top/bottom, but the overlay just sticks
// to the top or bottom of its range. These checks prevent us from immediately
// moving the overlay when the drag changes direction. We should wait until
// the cursor has returned to, and begun to pass the bottom/top of the range
if ((atTop && movingUp) || (atTop && event.clientY < canvasAbsTop)) {
return;
}
if (
(atBottom && movingDown) ||
(atBottom && event.clientY > canvasAbsBot)
) {
return;
}
// Set view scroll directly
const scrollHeight = this.view.scrollDOM.scrollHeight;
const clientHeight = this.view.scrollDOM.clientHeight;
const maxTopNonOverflowing = (scrollHeight - clientHeight) / SCALE;
const maxTopOverflowing = clientHeight - clientHeight / SCALE;
const change = canvasRelTopDouble + deltaY;
/**
* ScrollPosOverflowing is calculated by:
* - Calculating the offset (change) relative to the total height of the container
* - Multiplying by the maximum scrollTop position for the scroller
* - The maximum scrollTop position for the scroller is the total scroll height minus the client height
*/
const relativeToMax = change / maxTopOverflowing;
const scrollPosOverflowing =
(scrollHeight - clientHeight) * relativeToMax;
const scrollPosNonOverflowing = change * SCALE;
this.view.scrollDOM.scrollTop = Math.max(
scrollPosOverflowing,
scrollPosNonOverflowing
);
// view.scrollDOM truncates if out of bounds. We need to mimic that behavior here with min/max guard
const top = Math.min(
Math.max(0, change),
Math.min(maxTopOverflowing, maxTopNonOverflowing)
);
this.dom.style.top = top + "px";
}
public destroy() {
this.remove();
}
},
{
eventHandlers: {
scroll() {
requestAnimationFrame(() => this.computeTop());
},
},
}
);
export const Overlay = [Theme, OverlayView];

View File

@@ -0,0 +1,206 @@
import { LineBasedState } from "./linebasedstate";
import { EditorView, ViewUpdate } from "@codemirror/view";
import { LinesState, foldsChanged } from "./linesState";
import { DrawContext } from "./types";
import { Config } from "./config";
type Selection = { from: number; to: number; extends: boolean };
type DrawInfo = { backgroundColor: string };
export class SelectionState extends LineBasedState<Array<Selection>> {
private _drawInfo: DrawInfo | undefined;
private _themeClasses: string;
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.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;
let selectionIndex = 0;
for (const [index, line] of update.state.field(LinesState).entries()) {
const selections: Array<Selection> = [];
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) {
continue;
}
// Lines are indexed beginning at 1 instead of 0
const lineNumber = index + 1;
this.map.set(lineNumber, selections);
}
}
public drawLine(ctx: DrawContext, lineNumber: number) {
let {
context,
lineHeight,
charWidth,
offsetX: startOffsetX,
offsetY,
} = ctx;
const selections = this.get(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;
}
}
export function selections(view: EditorView): SelectionState {
return new SelectionState(view);
}

View File

@@ -0,0 +1,415 @@
import { LineBasedState } from "./linebasedstate";
import { Highlighter, highlightTree } from "@lezer/highlight";
import { ChangedRange, Tree, TreeFragment } from "@lezer/common";
import { highlightingFor, language } from "@codemirror/language";
import { EditorView, ViewUpdate } from "@codemirror/view";
import { DrawContext } from "./types";
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 };
export class TextState extends LineBasedState<Array<TagSpan>> {
private _previousTree: Tree | undefined;
private _displayText: Required<Options>["displayText"] | undefined;
private _fontInfoMap: Map<string, FontInfo> = new Map();
private _themeClasses: Set<string> | undefined;
private _highlightingCallbackId: number | NodeJS.Timeout | undefined;
public constructor(view: EditorView) {
super(view);
this._themeClasses = new Set(Array.from(view.dom.classList));
if (view.state.facet(Config).enabled) {
this.updateImpl(view.state);
}
}
private shouldUpdate(update: ViewUpdate) {
// If the doc changed
if (update.docChanged) {
return true;
}
// If configuration settings changed
if (update.state.facet(Config) !== update.startState.facet(Config)) {
return true;
}
// If the theme changed
if (this.themeChanged()) {
return true;
}
// If the folds changed
if (foldsChanged(update.transactions)) {
return true;
}
return false;
}
public update(update: ViewUpdate) {
if (!this.shouldUpdate(update)) {
return;
}
if (this._highlightingCallbackId) {
typeof window.requestIdleCallback !== "undefined"
? cancelIdleCallback(this._highlightingCallbackId as number)
: clearTimeout(this._highlightingCallbackId);
}
this.updateImpl(update.state, update.changes);
}
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();
}
/* Incrementally parse the tree based on previous tree + changes */
let treeFragments: ReadonlyArray<TreeFragment> | undefined = undefined;
if (this._previousTree && changes) {
const previousFragments = TreeFragment.addTree(this._previousTree);
const changedRanges: Array<ChangedRange> = [];
changes.iterChangedRanges((fromA, toA, fromB, toB) =>
changedRanges.push({ fromA, toA, fromB, toB })
);
treeFragments = TreeFragment.applyChanges(
previousFragments,
changedRanges
);
}
/* 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;
this._previousTree = tree;
/* Highlight the document, and store the text and tags for each line */
const highlighter: Highlighter = {
style: (tags) => highlightingFor(state, tags),
};
let highlights: Array<{ from: number; to: number; tags: string }> = [];
if (tree) {
/**
* The viewport renders a few extra lines above and below the editor view. To approximate
* the lines visible in the minimap, we multiply the lines in the viewport by the scale multipliers.
*
* Based on the current scroll position, the minimap may show a larger portion of lines above or
* below the lines currently in the editor view. On a long document, when the scroll position is
* near the top of the document, the minimap will show a small number of lines above the lines
* in the editor view, and a large number of lines below the lines in the editor view.
*
* To approximate this ratio, we can use the viewport scroll percentage
*
* ┌─────────────────────┐
* │ │
* │ Extra viewport │
* │ buffer │
* ├─────────────────────┼───────┐
* │ │Minimap│
* │ │Gutter │
* │ ├───────┤
* │ Editor View │Scaled │
* │ │View │
* │ │Overlay│
* │ ├───────┤
* │ │ │
* │ │ │
* ├─────────────────────┼───────┘
* │ │
* │ Extra viewport │
* │ buffer │
* └─────────────────────┘
*
**/
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 { 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(
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
);
}
// Update the map
this.updateMapImpl(state, highlights);
// Highlight the entire tree in an idle callback
highlights = [];
const highlightingCallback = () => {
if (tree) {
highlightTree(tree, highlighter, (from, to, tags) => {
highlights.push({ from, to, tags });
});
this.updateMapImpl(state, highlights);
this._highlightingCallbackId = undefined;
}
};
this._highlightingCallbackId =
typeof window.requestIdleCallback !== "undefined"
? requestIdleCallback(highlightingCallback)
: setTimeout(highlightingCallback);
}
private updateMapImpl(
state: EditorState,
highlights: Array<{ from: number; to: number; tags: string }>
) {
this.map.clear();
const docToString = state.doc.toString();
const highlightsIterator = highlights.values();
let highlightPtr = highlightsIterator.next();
for (const [index, line] of state.field(LinesState).entries()) {
const spans: Array<TagSpan> = [];
for (const span of line) {
// Skip if it's a 0-length span
if (span.from === span.to) {
continue;
}
// Append a placeholder for a folded span
if (span.folded) {
spans.push({ text: "…", tags: "" });
continue;
}
let position = span.from;
while (!highlightPtr.done && highlightPtr.value.from < span.to) {
const { from, to, tags } = highlightPtr.value;
// Iterate until our highlight is over the current span
if (to < position) {
highlightPtr = highlightsIterator.next();
continue;
}
// Append unstyled text before the highlight begins
if (from > position) {
spans.push({ text: docToString.slice(position, from), tags: "" });
}
// A highlight may start before and extend beyond the current span
const start = Math.max(from, span.from);
const end = Math.min(to, span.to);
// Append the highlighted text
spans.push({ text: docToString.slice(start, end), tags });
position = end;
// If the highlight continues beyond this span, break from this loop
if (to > end) {
break;
}
// Otherwise, move to the next highlight
highlightPtr = highlightsIterator.next();
}
// 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),
tags: "",
});
}
}
// Lines are indexed beginning at 1 instead of 0
const lineNumber = index + 1;
this.map.set(lineNumber, spans);
}
}
public measure(context: CanvasRenderingContext2D): {
charWidth: number;
lineHeight: number;
} {
const { color, font, lineHeight } = this.getFontInfo("");
context.textBaseline = "ideographic";
context.fillStyle = color;
context.font = font;
return {
charWidth: context.measureText("_").width,
lineHeight: lineHeight,
};
}
public beforeDraw() {
this._fontInfoMap.clear(); // 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) {
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;
}
}
}
}
private getFontInfo(tags: string): FontInfo {
const cached = this._fontInfoMap.get(tags);
if (cached) {
return cached;
}
// Create a mock token wrapped in a cm-line
const mockToken = crelt("span", { class: tags });
const mockLine = crelt(
"div",
{ class: "cm-line", style: "display: none" },
mockToken
);
this.view.contentDOM.appendChild(mockLine);
// Get style information and store it
const style = window.getComputedStyle(mockToken);
const lineHeight = parseFloat(style.lineHeight) / Scale.SizeRatio;
const result = {
color: style.color,
font: `${style.fontStyle} ${style.fontWeight} ${lineHeight}px ${style.fontFamily}`,
lineHeight,
};
this._fontInfoMap.set(tags, result);
// Clean up and return
this.view.contentDOM.removeChild(mockLine);
return result;
}
private themeChanged(): boolean {
const previous = this._themeClasses;
const now = new Set<string>(Array.from(this.view.dom.classList));
this._themeClasses = now;
if (!previous) {
return true;
}
// Ignore certain classes being added/removed
previous.delete("cm-focused");
now.delete("cm-focused");
if (previous.size !== now.size) {
return true;
}
let containsAll = true;
previous.forEach((theme) => {
if (!now.has(theme)) {
containsAll = false;
}
});
return !containsAll;
}
}
export function text(view: EditorView): TextState {
return new TextState(view);
}

View File

@@ -0,0 +1,7 @@
export type DrawContext = {
context: CanvasRenderingContext2D;
offsetY: number;
lineHeight: number;
charWidth: number;
offsetX: number;
};

View File

@@ -0,0 +1,490 @@
import {EditorState, StateEffect, StateField} from "@codemirror/state";
import {Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate, WidgetType} from "@codemirror/view";
import {keymap} from "@codemirror/view";
import {Text} from "@codemirror/state";
// 定义高亮标记的语法
const HIGHLIGHT_MARKER_START = "<hl>";
const HIGHLIGHT_MARKER_END = "</hl>";
// 高亮样式
const highlightMark = Decoration.mark({
attributes: {style: `background-color: rgba(255, 215, 0, 0.3)`}
});
// 空白Widget用于隐藏标记
class EmptyWidget extends WidgetType {
toDOM() {
return document.createElement("span");
}
}
const emptyWidget = new EmptyWidget();
// 定义效果用于触发高亮视图刷新
const refreshHighlightEffect = StateEffect.define<null>();
// 存储高亮范围的状态字段
const highlightState = StateField.define<DecorationSet>({
create() {
return Decoration.none;
},
update(decorations, tr) {
decorations = decorations.map(tr.changes);
// 检查是否有刷新效果
for (const effect of tr.effects) {
if (effect.is(refreshHighlightEffect)) {
return findHighlights(tr.state);
}
}
if (tr.docChanged) {
return findHighlights(tr.state);
}
return decorations;
},
provide: field => EditorView.decorations.from(field)
});
// 从文档中查找高亮标记并创建装饰
function findHighlights(state: EditorState): DecorationSet {
const decorations: any[] = [];
const doc = state.doc;
const text = doc.toString();
let pos = 0;
while (pos < text.length) {
const startMarkerPos = text.indexOf(HIGHLIGHT_MARKER_START, pos);
if (startMarkerPos === -1) break;
const contentStart = startMarkerPos + HIGHLIGHT_MARKER_START.length;
const endMarkerPos = text.indexOf(HIGHLIGHT_MARKER_END, contentStart);
if (endMarkerPos === -1) {
pos = contentStart;
continue;
}
// 创建装饰,隐藏标记,高亮中间内容
decorations.push(Decoration.replace({
widget: emptyWidget
}).range(startMarkerPos, contentStart));
decorations.push(highlightMark.range(contentStart, endMarkerPos));
decorations.push(Decoration.replace({
widget: emptyWidget
}).range(endMarkerPos, endMarkerPos + HIGHLIGHT_MARKER_END.length));
pos = endMarkerPos + HIGHLIGHT_MARKER_END.length;
}
return Decoration.set(decorations, true);
}
// 检查文本是否已经被高亮标记包围
function isAlreadyHighlighted(text: string): boolean {
// 检查是否有嵌套标记
let startIndex = 0;
let markerCount = 0;
while (true) {
const nextStart = text.indexOf(HIGHLIGHT_MARKER_START, startIndex);
if (nextStart === -1) break;
markerCount++;
startIndex = nextStart + HIGHLIGHT_MARKER_START.length;
}
// 如果有多个开始标记,表示存在嵌套
if (markerCount > 1) return true;
// 检查简单的包围情况
return text.startsWith(HIGHLIGHT_MARKER_START) && text.endsWith(HIGHLIGHT_MARKER_END);
}
// 添加高亮标记到文本
function addHighlightMarker(view: EditorView, from: number, to: number) {
const text = view.state.sliceDoc(from, to);
// 检查文本是否已经被高亮,防止嵌套高亮
if (isAlreadyHighlighted(text)) {
return false;
}
view.dispatch({
changes: {
from,
to,
insert: `${HIGHLIGHT_MARKER_START}${text}${HIGHLIGHT_MARKER_END}`
},
effects: refreshHighlightEffect.of(null)
});
return true;
}
// 移除文本的高亮标记
function removeHighlightMarker(view: EditorView, region: {from: number, to: number, content: string}) {
view.dispatch({
changes: {
from: region.from,
to: region.to,
insert: region.content
},
effects: refreshHighlightEffect.of(null)
});
return true;
}
// 清理嵌套高亮标记
function cleanNestedHighlights(view: EditorView, from: number, to: number) {
const text = view.state.sliceDoc(from, to);
// 如果没有嵌套标记,直接返回
if (text.indexOf(HIGHLIGHT_MARKER_START) === -1 ||
text.indexOf(HIGHLIGHT_MARKER_END) === -1) {
return false;
}
// 尝试清理嵌套标记
let cleanedText = text;
let changed = false;
// 从内到外清理嵌套标记
while (true) {
const startPos = cleanedText.indexOf(HIGHLIGHT_MARKER_START);
if (startPos === -1) break;
const contentStart = startPos + HIGHLIGHT_MARKER_START.length;
const endPos = cleanedText.indexOf(HIGHLIGHT_MARKER_END, contentStart);
if (endPos === -1) break;
// 提取标记中的内容
const content = cleanedText.substring(contentStart, endPos);
// 替换带标记的部分为纯内容
cleanedText = cleanedText.substring(0, startPos) + content + cleanedText.substring(endPos + HIGHLIGHT_MARKER_END.length);
changed = true;
}
if (changed) {
view.dispatch({
changes: {
from,
to,
insert: cleanedText
},
effects: refreshHighlightEffect.of(null)
});
return true;
}
return false;
}
// 检查选中区域是否包含高亮标记
function isHighlightedRegion(doc: Text, from: number, to: number): {from: number, to: number, content: string} | null {
const fullText = doc.toString();
// 向前搜索起始标记
let startPos = from;
while (startPos > 0) {
const textBefore = fullText.substring(Math.max(0, startPos - 100), startPos);
const markerPos = textBefore.lastIndexOf(HIGHLIGHT_MARKER_START);
if (markerPos !== -1) {
startPos = startPos - textBefore.length + markerPos;
break;
}
if (startPos - 100 <= 0) {
// 没找到标记
return null;
}
startPos = Math.max(0, startPos - 100);
}
// 确认找到的标记范围包含选中区域
const contentStart = startPos + HIGHLIGHT_MARKER_START.length;
// 向后搜索结束标记
const textAfter = fullText.substring(contentStart, Math.min(fullText.length, to + 100));
const endMarkerPos = textAfter.indexOf(HIGHLIGHT_MARKER_END);
if (endMarkerPos === -1) {
return null;
}
const contentEnd = contentStart + endMarkerPos;
const regionEnd = contentEnd + HIGHLIGHT_MARKER_END.length;
// 确保选中区域在高亮区域内
if (from < startPos || to > regionEnd) {
return null;
}
// 获取高亮内容
const content = fullText.substring(contentStart, contentEnd);
return {
from: startPos,
to: regionEnd,
content
};
}
// 查找光标位置是否在高亮区域内
function findHighlightAtCursor(view: EditorView, pos: number): {from: number, to: number, content: string} | null {
const doc = view.state.doc;
const fullText = doc.toString();
// 向前搜索起始标记
let startPos = pos;
let foundStart = false;
while (startPos > 0) {
const textBefore = fullText.substring(Math.max(0, startPos - 100), startPos);
const markerPos = textBefore.lastIndexOf(HIGHLIGHT_MARKER_START);
if (markerPos !== -1) {
startPos = startPos - textBefore.length + markerPos;
foundStart = true;
break;
}
if (startPos - 100 <= 0) {
break;
}
startPos = Math.max(0, startPos - 100);
}
if (!foundStart) {
return null;
}
const contentStart = startPos + HIGHLIGHT_MARKER_START.length;
// 如果光标在开始标记之前,不在高亮区域内
if (pos < contentStart) {
return null;
}
// 向后搜索结束标记
const textAfter = fullText.substring(contentStart);
const endMarkerPos = textAfter.indexOf(HIGHLIGHT_MARKER_END);
if (endMarkerPos === -1) {
return null;
}
const contentEnd = contentStart + endMarkerPos;
// 如果光标在结束标记之后,不在高亮区域内
if (pos > contentEnd) {
return null;
}
// 获取高亮内容
const content = fullText.substring(contentStart, contentEnd);
return {
from: startPos,
to: contentEnd + HIGHLIGHT_MARKER_END.length,
content
};
}
// 切换高亮状态
function toggleHighlight(view: EditorView) {
const selection = view.state.selection.main;
// 如果有选择文本
if (!selection.empty) {
// 先尝试清理选择区域内的嵌套高亮
if (cleanNestedHighlights(view, selection.from, selection.to)) {
return true;
}
// 检查选中区域是否已经在高亮区域内
const highlightRegion = isHighlightedRegion(view.state.doc, selection.from, selection.to);
if (highlightRegion) {
removeHighlightMarker(view, highlightRegion);
return true;
}
// 检查是否选择了带有标记的文本
const selectedText = view.state.sliceDoc(selection.from, selection.to);
if (selectedText.indexOf(HIGHLIGHT_MARKER_START) !== -1 ||
selectedText.indexOf(HIGHLIGHT_MARKER_END) !== -1) {
return cleanNestedHighlights(view, selection.from, selection.to);
}
// 如果选择的是干净文本,添加高亮
addHighlightMarker(view, selection.from, selection.to);
return true;
}
// 如果是光标
else {
// 查找光标位置是否在高亮区域内
const highlightAtCursor = findHighlightAtCursor(view, selection.from);
if (highlightAtCursor) {
removeHighlightMarker(view, highlightAtCursor);
return true;
}
}
return false;
}
// 定义快捷键
const highlightKeymap = keymap.of([
{key: "Mod-h", run: toggleHighlight}
]);
// 处理复制事件,移除高亮标记
function handleCopy(view: EditorView, event: ClipboardEvent) {
if (!event.clipboardData || view.state.selection.main.empty) return false;
const { from, to } = view.state.selection.main;
const selectedText = view.state.sliceDoc(from, to);
// 如果选中的内容包含高亮标记,则处理复制
if (selectedText.indexOf(HIGHLIGHT_MARKER_START) !== -1 ||
selectedText.indexOf(HIGHLIGHT_MARKER_END) !== -1) {
// 清理文本中的所有标记
let cleanText = selectedText;
while (true) {
const startPos = cleanText.indexOf(HIGHLIGHT_MARKER_START);
if (startPos === -1) break;
const contentStart = startPos + HIGHLIGHT_MARKER_START.length;
const endPos = cleanText.indexOf(HIGHLIGHT_MARKER_END, contentStart);
if (endPos === -1) break;
const content = cleanText.substring(contentStart, endPos);
cleanText = cleanText.substring(0, startPos) + content + cleanText.substring(endPos + HIGHLIGHT_MARKER_END.length);
}
// 将清理后的文本设置为剪贴板内容
event.clipboardData.setData('text/plain', cleanText);
event.preventDefault();
return true;
}
return false;
}
// 高亮刷新管理器类
class HighlightRefreshManager {
private view: EditorView;
private refreshPending = false;
private initialSetupDone = false;
private rafId: number | null = null;
constructor(view: EditorView) {
this.view = view;
}
/**
* 使用requestAnimationFrame安排高亮刷新
* 确保在适当的时机执行,且不会重复触发
*/
scheduleRefresh(): void {
if (this.refreshPending) return;
this.refreshPending = true;
// 使用requestAnimationFrame确保在下一帧渲染前执行
this.rafId = requestAnimationFrame(() => {
this.executeRefresh();
});
}
/**
* 执行高亮刷新
*/
private executeRefresh(): void {
this.refreshPending = false;
this.rafId = null;
// 确保视图仍然有效
if (!this.view.state) return;
try {
this.view.dispatch({
effects: refreshHighlightEffect.of(null)
});
} catch (e) {
console.debug("highlight refresh error:", e);
}
}
/**
* 执行初始化设置
*/
performInitialSetup(): void {
if (this.initialSetupDone) return;
// 使用Promise.resolve().then确保在当前执行栈清空后运行
Promise.resolve().then(() => {
this.scheduleRefresh();
// 在DOM完全加载后再次刷新以确保稳定性
window.addEventListener('load', () => {
this.scheduleRefresh();
}, { once: true });
});
this.initialSetupDone = true;
}
/**
* 清理资源
*/
dispose(): void {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
}
}
}
// 确保编辑器初始化时立即扫描高亮
const highlightSetupPlugin = ViewPlugin.define((view) => {
// 添加复制事件监听器
const copyHandler = (event: ClipboardEvent) => handleCopy(view, event);
view.dom.addEventListener('copy', copyHandler);
// 创建刷新管理器实例
const refreshManager = new HighlightRefreshManager(view);
// 执行初始化设置
refreshManager.performInitialSetup();
return {
update(update: ViewUpdate) {
// 不在update回调中直接调用dispatch
if ((update.docChanged || update.selectionSet) && !update.transactions.some(tr =>
tr.effects.some(e => e.is(refreshHighlightEffect)))) {
// 安排一个未来的刷新
refreshManager.scheduleRefresh();
}
},
destroy() {
// 清理资源
refreshManager.dispose();
view.dom.removeEventListener('copy', copyHandler);
}
};
});
// 导出完整扩展
export const textHighlighter = [
highlightState,
highlightKeymap,
highlightSetupPlugin
];