✨ Added text highlight and minimap
This commit is contained in:
76
frontend/package-lock.json
generated
76
frontend/package-lock.json
generated
@@ -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",
|
||||
|
@@ -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",
|
||||
|
@@ -13,11 +13,6 @@ export interface AutoSaveOptions {
|
||||
/**
|
||||
* 创建自动保存插件
|
||||
*
|
||||
* 新的简化保存策略:
|
||||
* - 前端只负责将内容变更传递给后端
|
||||
* - 后端使用定时保存机制,每隔配置的时间间隔自动保存(仅在有变更时)
|
||||
* - 移除了复杂的阈值保存和最小间隔控制
|
||||
*
|
||||
* @param options 配置选项
|
||||
* @returns EditorView.Plugin
|
||||
*/
|
||||
|
@@ -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
|
||||
]),
|
||||
];
|
||||
};
|
||||
};
|
85
frontend/src/views/editor/extensions/minimap/config.ts
Normal file
85
frontend/src/views/editor/extensions/minimap/config.ts
Normal 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 };
|
167
frontend/src/views/editor/extensions/minimap/diagnostics.ts
Normal file
167
frontend/src/views/editor/extensions/minimap/diagnostics.ts
Normal 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);
|
||||
}
|
27
frontend/src/views/editor/extensions/minimap/gutters.ts
Normal file
27
frontend/src/views/editor/extensions/minimap/gutters.ts
Normal 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 }
|
332
frontend/src/views/editor/extensions/minimap/index.ts
Normal file
332
frontend/src/views/editor/extensions/minimap/index.ts
Normal 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 };
|
@@ -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);
|
||||
}
|
||||
}
|
95
frontend/src/views/editor/extensions/minimap/linesState.ts
Normal file
95
frontend/src/views/editor/extensions/minimap/linesState.ts
Normal 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 };
|
299
frontend/src/views/editor/extensions/minimap/overlay.ts
Normal file
299
frontend/src/views/editor/extensions/minimap/overlay.ts
Normal 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];
|
206
frontend/src/views/editor/extensions/minimap/selections.ts
Normal file
206
frontend/src/views/editor/extensions/minimap/selections.ts
Normal 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);
|
||||
}
|
415
frontend/src/views/editor/extensions/minimap/text.ts
Normal file
415
frontend/src/views/editor/extensions/minimap/text.ts
Normal 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);
|
||||
}
|
7
frontend/src/views/editor/extensions/minimap/types.ts
Normal file
7
frontend/src/views/editor/extensions/minimap/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type DrawContext = {
|
||||
context: CanvasRenderingContext2D;
|
||||
offsetY: number;
|
||||
lineHeight: number;
|
||||
charWidth: number;
|
||||
offsetX: number;
|
||||
};
|
490
frontend/src/views/editor/extensions/textHighlightExtension.ts
Normal file
490
frontend/src/views/editor/extensions/textHighlightExtension.ts
Normal 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
|
||||
];
|
Reference in New Issue
Block a user