diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d0d2f64..26cf7c2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 03020b4..9d5ac59 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/views/editor/extensions/autoSaveExtension.ts b/frontend/src/views/editor/extensions/autoSaveExtension.ts index 2f86628..7c53d43 100644 --- a/frontend/src/views/editor/extensions/autoSaveExtension.ts +++ b/frontend/src/views/editor/extensions/autoSaveExtension.ts @@ -13,11 +13,6 @@ export interface AutoSaveOptions { /** * 创建自动保存插件 * - * 新的简化保存策略: - * - 前端只负责将内容变更传递给后端 - * - 后端使用定时保存机制,每隔配置的时间间隔自动保存(仅在有变更时) - * - 移除了复杂的阈值保存和最小间隔控制 - * * @param options 配置选项 * @returns EditorView.Plugin */ diff --git a/frontend/src/views/editor/extensions/basicSetup.ts b/frontend/src/views/editor/extensions/basicSetup.ts index 9bd58ab..cd1d3ec 100644 --- a/frontend/src/views/editor/extensions/basicSetup.ts +++ b/frontend/src/views/editor/extensions/basicSetup.ts @@ -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 ]), ]; -}; \ No newline at end of file +}; \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/minimap/config.ts b/frontend/src/views/editor/extensions/minimap/config.ts new file mode 100644 index 0000000..cbe4a44 --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/config.ts @@ -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 = ( + 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; + }; + + /** + * 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; +}; + +const Config = Facet.define>({ + combine: (c) => { + const configs: Array = []; + 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 }; diff --git a/frontend/src/views/editor/extensions/minimap/diagnostics.ts b/frontend/src/views/editor/extensions/minimap/diagnostics.ts new file mode 100644 index 0000000..d4a7a9d --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/diagnostics.ts @@ -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 { + 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); +} diff --git a/frontend/src/views/editor/extensions/minimap/gutters.ts b/frontend/src/views/editor/extensions/minimap/gutters.ts new file mode 100644 index 0000000..bd9cc0e --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/gutters.ts @@ -0,0 +1,27 @@ +import { DrawContext } from "./types"; + +const GUTTER_WIDTH = 4; + +type Line = number; +type Color = string; +export type Gutter = Record; + + +/** + * Draws a gutter to the canvas context for the given line number + */ +function drawLineGutter(gutter: Record, 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 } \ No newline at end of file diff --git a/frontend/src/views/editor/extensions/minimap/index.ts b/frontend/src/views/editor/extensions/minimap/index.ts new file mode 100644 index 0000000..e9d7bb9 --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/index.ts @@ -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 & { + /** + * 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({ + 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> = {}) { + return showMinimapFacet.of({ + create: defaultCreateFn, + ...options + }); +} + +// 保持原始接口兼容性 +export { showMinimapFacet as showMinimap }; diff --git a/frontend/src/views/editor/extensions/minimap/linebasedstate.ts b/frontend/src/views/editor/extensions/minimap/linebasedstate.ts new file mode 100644 index 0000000..19dc91f --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/linebasedstate.ts @@ -0,0 +1,20 @@ +import { EditorView } from "@codemirror/view"; + + +export abstract class LineBasedState { + protected map: Map; + 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); + } +} diff --git a/frontend/src/views/editor/extensions/minimap/linesState.ts b/frontend/src/views/editor/extensions/minimap/linesState.ts new file mode 100644 index 0000000..5843de9 --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/linesState.ts @@ -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; +export type Lines = Array; + +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({ + 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 }; diff --git a/frontend/src/views/editor/extensions/minimap/overlay.ts b/frontend/src/views/editor/extensions/minimap/overlay.ts new file mode 100644 index 0000000..b5fa183 --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/overlay.ts @@ -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]; diff --git a/frontend/src/views/editor/extensions/minimap/selections.ts b/frontend/src/views/editor/extensions/minimap/selections.ts new file mode 100644 index 0000000..e3d3d34 --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/selections.ts @@ -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> { + 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 = []; + + 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); +} diff --git a/frontend/src/views/editor/extensions/minimap/text.ts b/frontend/src/views/editor/extensions/minimap/text.ts new file mode 100644 index 0000000..3f04d4f --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/text.ts @@ -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> { + private _previousTree: Tree | undefined; + private _displayText: Required["displayText"] | undefined; + private _fontInfoMap: Map = new Map(); + private _themeClasses: Set | 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 | undefined = undefined; + if (this._previousTree && changes) { + const previousFragments = TreeFragment.addTree(this._previousTree); + + const changedRanges: Array = []; + 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 = []; + + 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(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); +} diff --git a/frontend/src/views/editor/extensions/minimap/types.ts b/frontend/src/views/editor/extensions/minimap/types.ts new file mode 100644 index 0000000..e93259d --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/types.ts @@ -0,0 +1,7 @@ +export type DrawContext = { + context: CanvasRenderingContext2D; + offsetY: number; + lineHeight: number; + charWidth: number; + offsetX: number; +}; diff --git a/frontend/src/views/editor/extensions/textHighlightExtension.ts b/frontend/src/views/editor/extensions/textHighlightExtension.ts new file mode 100644 index 0000000..0db00c8 --- /dev/null +++ b/frontend/src/views/editor/extensions/textHighlightExtension.ts @@ -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 = ""; +const HIGHLIGHT_MARKER_END = ""; + +// 高亮样式 +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(); + +// 存储高亮范围的状态字段 +const highlightState = StateField.define({ + 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 +]; \ No newline at end of file