diff --git a/frontend/src/components/loading/LoadingScreen.vue b/frontend/src/components/loading/LoadingScreen.vue index cb82dfc..8b8b7a1 100644 --- a/frontend/src/components/loading/LoadingScreen.vue +++ b/frontend/src/components/loading/LoadingScreen.vue @@ -142,7 +142,7 @@ onBeforeUnmount(() => { display: flex; align-items: center; justify-content: center; - font-family: var(--voidraft-font-mono),serif; + font-family: Menlo, monospace,serif; } .loading-word { diff --git a/frontend/src/views/editor/extensions/minimap/blockManager.ts b/frontend/src/views/editor/extensions/minimap/blockManager.ts new file mode 100644 index 0000000..85fc48b --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/blockManager.ts @@ -0,0 +1,498 @@ +import { EditorView } from '@codemirror/view'; +import { EditorState, ChangeSet } from '@codemirror/state'; +import { syntaxTree, highlightingFor } from '@codemirror/language'; +import { Highlighter, highlightTree } from '@lezer/highlight'; +import { Scale } from './config'; +import { LinesState, getLinesSnapshot } from './linesState'; +import { + ToWorkerMessage, + ToMainMessage, + BlockRequest, + Highlight, + LineSpan, + FontInfo, +} from './worker/protocol'; +import crelt from 'crelt'; + +const BLOCK_LINES = 50; +const MAX_BLOCKS = 20; + +interface Block { + index: number; + startLine: number; + endLine: number; + bitmap: ImageBitmap | null; + dirty: boolean; + rendering: boolean; + requestId: number; + lastUsed: number; // LRU 时间戳 +} + +export class BlockManager { + private worker: Worker | null = null; + private blocks = new Map(); + private fontInfoMap = new Map(); + private fontDirty = true; + private fontVersion = 0; + private measureCache: { charWidth: number; lineHeight: number; version: number } | null = null; + private displayText: 'blocks' | 'characters' = 'characters'; + private themeClasses: Set; + private requestId = 0; + private onBlockReady: (() => void) | null = null; + + // 批量处理块完成事件 + private pendingBlockReadyHandle: ReturnType | null = null; + private renderingCount = 0; + + constructor(private view: EditorView) { + this.themeClasses = new Set(Array.from(view.dom.classList)); + this.initWorker(); + } + + private initWorker(): void { + this.worker = new Worker( + new URL('./worker/block.worker.ts', import.meta.url), + { type: 'module' } + ); + + this.worker.onmessage = (e: MessageEvent) => { + this.handleWorkerMessage(e.data); + }; + + this.worker.onerror = (e) => { + console.error('[BlockManager] Worker error:', e); + }; + + this.worker.postMessage({ type: 'init' } as ToWorkerMessage); + } + + private handleWorkerMessage(msg: ToMainMessage): void { + switch (msg.type) { + case 'ready': + break; + case 'blockComplete': { + const block = this.blocks.get(msg.blockIndex); + if (block && block.requestId === msg.blockId) { + block.bitmap = msg.bitmap; + block.dirty = false; + block.rendering = false; + this.renderingCount = Math.max(0, this.renderingCount - 1); + this.scheduleBlockReady(); + } + break; + } + case 'error': + console.error('[BlockManager] Worker error:', msg.message); + break; + } + } + + // 批量触发块就绪回调 + // 策略:等 30ms,让多个块完成事件合并 + private scheduleBlockReady(): void { + if (this.pendingBlockReadyHandle !== null) { + clearTimeout(this.pendingBlockReadyHandle); + } + + this.pendingBlockReadyHandle = setTimeout(() => { + this.pendingBlockReadyHandle = null; + if (this.renderingCount === 0) { + this.onBlockReady?.(); + } + }, 30); + } + + setOnBlockReady(callback: () => void): void { + this.onBlockReady = callback; + } + + setDisplayText(mode: 'blocks' | 'characters'): void { + if (this.displayText !== mode) { + this.displayText = mode; + this.markAllDirty(); + } + } + + checkThemeChange(): boolean { + const nowClasses = Array.from(this.view.dom.classList); + const now = new Set(nowClasses); + const prev = this.themeClasses; + this.themeClasses = now; + + if (!prev) { + this.fontDirty = true; + this.markAllDirty(); + return true; + } + + const prevSet = new Set(prev); + const nowSet = new Set(now); + prevSet.delete('cm-focused'); + nowSet.delete('cm-focused'); + + if (prevSet.size !== nowSet.size) { + this.fontDirty = true; + this.markAllDirty(); + return true; + } + + for (const cls of prevSet) { + if (!nowSet.has(cls)) { + this.fontDirty = true; + this.markAllDirty(); + return true; + } + } + + return false; + } + + markAllDirty(): void { + for (const block of this.blocks.values()) { + block.dirty = true; + } + } + + handleDocChange(state: EditorState, changes: ChangeSet, oldLineCount: number): void { + const totalLines = state.doc.lines; + const maxIndex = Math.ceil(totalLines / BLOCK_LINES) - 1; + + // 找出变化影响的块 + const affectedBlocks = new Set(); + + // 正确检测行数变化:比较新旧文档总行数 + const hasLineCountChange = totalLines !== oldLineCount; + + changes.iterChanges((fromA, toA, fromB, toB) => { + // 找出新文档中受影响的行范围 + const startLine = state.doc.lineAt(fromB).number; + const endLine = state.doc.lineAt(Math.min(toB, state.doc.length)).number; + + const startBlock = Math.floor((startLine - 1) / BLOCK_LINES); + const endBlock = Math.floor((endLine - 1) / BLOCK_LINES); + + for (let i = startBlock; i <= endBlock; i++) { + affectedBlocks.add(i); + } + }); + + // 如果行数变化,后续所有块都需要标记为 dirty + let markRest = false; + + for (const [index, block] of this.blocks) { + if (index > maxIndex) { + block.bitmap?.close(); + this.blocks.delete(index); + } else if (affectedBlocks.has(index)) { + block.dirty = true; + if (hasLineCountChange) { + markRest = true; // 从这个块开始,后续块都需要更新 + } + } else if (markRest) { + block.dirty = true; + } + } + + } + + /** + * 计算可见范围(提取公共计算逻辑) + */ + private getVisibleRange( + canvasHeight: number, + lineHeight: number, + scrollInfo: { scrollTop: number; clientHeight: number; scrollHeight: number } + ): { + valid: boolean; + totalLines: number; + scaledPTop: number; + canvasTop: number; + startBlock: number; + endBlock: number; + } | null { + const totalLines = this.view.state.field(LinesState).length; + if (totalLines === 0 || canvasHeight <= 0) { + return null; + } + + const { top: pTop, bottom: pBottom } = this.view.documentPadding; + const scaledPTop = pTop / Scale.SizeRatio; + const scaledPBottom = pBottom / Scale.SizeRatio; + const totalHeight = scaledPTop + scaledPBottom + totalLines * lineHeight; + + const { scrollTop, clientHeight, scrollHeight } = scrollInfo; + const scrollPercent = Math.max(0, Math.min(1, scrollTop / (scrollHeight - clientHeight))) || 0; + const canvasTop = Math.max(0, scrollPercent * (totalHeight - canvasHeight)); + + const visibleStart = Math.max(1, Math.floor((canvasTop - scaledPTop) / lineHeight) + 1); + const visibleEnd = Math.min(totalLines, Math.ceil((canvasTop + canvasHeight - scaledPTop) / lineHeight)); + + if (visibleEnd < visibleStart) { + return null; + } + + return { + valid: true, + totalLines, + scaledPTop, + canvasTop, + startBlock: Math.floor((visibleStart - 1) / BLOCK_LINES), + endBlock: Math.floor((visibleEnd - 1) / BLOCK_LINES), + }; + } + + render( + canvas: HTMLCanvasElement, + ctx: CanvasRenderingContext2D, + scrollInfo: { scrollTop: number; clientHeight: number; scrollHeight: number } + ): void { + if (this.fontDirty) { + this.refreshFontCache(); + } + + const { charWidth, lineHeight } = this.measure(ctx); + const range = this.getVisibleRange(canvas.height, lineHeight, scrollInfo); + if (!range) return; + + const { totalLines, scaledPTop, canvasTop, startBlock, endBlock } = range; + const state = this.view.state; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + for (let i = startBlock; i <= endBlock; i++) { + const block = this.getOrCreateBlock(i, state, totalLines); + if (!block) continue; + + if (block.dirty && !block.rendering) { + this.requestBlockRender(block, state, charWidth, lineHeight); + } + + if (block.bitmap) { + const blockY = scaledPTop + (block.startLine - 1) * lineHeight - canvasTop; + ctx.drawImage(block.bitmap, 0, blockY); + } + } + + this.fontDirty = false; + this.evictOldBlocks(); + } + + /** + * 只绘制缓存的块,不请求新渲染(用于 overlay-only 更新) + */ + drawCachedBlocks( + canvas: HTMLCanvasElement, + ctx: CanvasRenderingContext2D, + scrollInfo: { scrollTop: number; clientHeight: number; scrollHeight: number } + ): void { + const { lineHeight } = this.measure(ctx); + const range = this.getVisibleRange(canvas.height, lineHeight, scrollInfo); + if (!range) return; + + const { scaledPTop, canvasTop, startBlock, endBlock } = range; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + for (let i = startBlock; i <= endBlock; i++) { + const block = this.blocks.get(i); + if (block?.bitmap) { + const blockY = scaledPTop + (block.startLine - 1) * lineHeight - canvasTop; + ctx.drawImage(block.bitmap, 0, blockY); + } + } + } + + private getOrCreateBlock(index: number, state: EditorState, totalLines: number): Block | null { + const startLine = index * BLOCK_LINES + 1; + if (startLine > totalLines) return null; + + const endLine = Math.min((index + 1) * BLOCK_LINES, totalLines); + const now = performance.now(); + + let block = this.blocks.get(index); + if (!block) { + block = { + index, + startLine, + endLine, + bitmap: null, + dirty: true, + rendering: false, + requestId: 0, + lastUsed: now, + }; + this.blocks.set(index, block); + } else { + block.startLine = startLine; + block.endLine = endLine; + block.lastUsed = now; // 更新 LRU 时间戳 + } + + return block; + } + + private requestBlockRender( + block: Block, + state: EditorState, + charWidth: number, + lineHeight: number + ): void { + if (!this.worker) return; + + block.rendering = true; + block.requestId = ++this.requestId; + this.renderingCount++; + + const { startLine, endLine } = block; + const linesSnapshot = getLinesSnapshot(state); + const tree = syntaxTree(state); + + // Collect highlights + const highlights: Highlight[] = []; + if (tree.length > 0 && startLine <= state.doc.lines) { + const highlighter: Highlighter = { + style: (tags) => highlightingFor(state, tags), + }; + const startPos = state.doc.line(startLine).from; + const endPos = state.doc.line(Math.min(endLine, state.doc.lines)).to; + + highlightTree(tree, highlighter, (from, to, tags) => { + highlights.push({ from, to, tags }); + }, startPos, endPos); + } + + // Extract relevant lines + const startIdx = startLine - 1; + const endIdx = Math.min(endLine, linesSnapshot.length); + const lines: LineSpan[][] = linesSnapshot.slice(startIdx, endIdx).map(line => + line.map(span => ({ from: span.from, to: span.to, folded: span.folded })) + ); + + // Get text slice + let textOffset = 0; + let textEnd = 0; + if (lines.length > 0 && lines[0].length > 0) { + textOffset = lines[0][0].from; + const lastLine = lines[lines.length - 1]; + if (lastLine.length > 0) { + textEnd = lastLine[lastLine.length - 1].to; + } + } + const textSlice = state.doc.sliceString(textOffset, textEnd); + + // Build font info map + const fontInfoMap: Record = {}; + for (const hl of highlights) { + if (!fontInfoMap[hl.tags]) { + const info = this.getFontInfo(hl.tags); + fontInfoMap[hl.tags] = info; + } + } + fontInfoMap[''] = this.getFontInfo(''); + + const blockLines = endLine - startLine + 1; + const request: BlockRequest = { + type: 'renderBlock', + blockId: block.requestId, + blockIndex: block.index, + startLine, + endLine, + width: Math.ceil(Scale.MaxWidth * Scale.PixelMultiplier), + height: Math.ceil(blockLines * lineHeight), + highlights, + lines, + textSlice, + textOffset, + fontInfoMap, + defaultFont: fontInfoMap[''], + displayText: this.displayText, + charWidth, + lineHeight, + gutterOffset: 0, + }; + + this.worker.postMessage(request); + } + + private evictOldBlocks(): void { + if (this.blocks.size <= MAX_BLOCKS) return; + + // LRU 驱逐:按 lastUsed 升序排序,驱逐最久未使用的块 + const sorted = Array.from(this.blocks.entries()) + .filter(([, b]) => !b.rendering) + .sort((a, b) => a[1].lastUsed - b[1].lastUsed); + + const toRemove = sorted.slice(0, this.blocks.size - MAX_BLOCKS); + for (const [index, block] of toRemove) { + block.bitmap?.close(); + this.blocks.delete(index); + } + } + + private refreshFontCache(): void { + this.fontInfoMap.clear(); + this.measureCache = null; + // 注意:fontDirty 在成功渲染块后才设为 false + this.fontVersion++; + this.markAllDirty(); + } + + measure(ctx: CanvasRenderingContext2D): { charWidth: number; lineHeight: number } { + const info = this.getFontInfo(''); + ctx.textBaseline = 'ideographic'; + ctx.fillStyle = info.color; + ctx.font = info.font; + + if (this.measureCache?.version === this.fontVersion) { + return { charWidth: this.measureCache.charWidth, lineHeight: this.measureCache.lineHeight }; + } + + const charWidth = ctx.measureText('_').width; + this.measureCache = { charWidth, lineHeight: info.lineHeight, version: this.fontVersion }; + return { charWidth, lineHeight: info.lineHeight }; + } + + private getFontInfo(tags: string): FontInfo { + const cached = this.fontInfoMap.get(tags); + if (cached) return cached; + + const mockToken = crelt('span', { class: tags }); + const mockLine = crelt('div', { class: 'cm-line', style: 'display: none' }, mockToken); + this.view.contentDOM.appendChild(mockLine); + + const style = window.getComputedStyle(mockToken); + + // 获取字体大小(用于渲染字符) + const fontSize = parseFloat(style.fontSize) || this.view.defaultLineHeight; + const scaledFontSize = Math.max(1, fontSize / Scale.SizeRatio); + + // 获取行高(用于行间距) + const rawLineHeight = parseFloat(style.lineHeight); + const resolvedLineHeight = Number.isFinite(rawLineHeight) && rawLineHeight > 0 ? rawLineHeight : fontSize; + const lineHeight = Math.max(1, resolvedLineHeight / Scale.SizeRatio); + + const result: FontInfo = { + color: style.color, + font: `${style.fontStyle} ${style.fontWeight} ${scaledFontSize}px ${style.fontFamily}`, + lineHeight, + }; + + this.view.contentDOM.removeChild(mockLine); + this.fontInfoMap.set(tags, result); + return result; + } + + destroy(): void { + if (this.pendingBlockReadyHandle !== null) { + clearTimeout(this.pendingBlockReadyHandle); + } + for (const block of this.blocks.values()) { + block.bitmap?.close(); + } + this.blocks.clear(); + this.worker?.postMessage({ type: 'destroy' } as ToWorkerMessage); + this.worker?.terminate(); + this.worker = null; + } +} + diff --git a/frontend/src/views/editor/extensions/minimap/diagnostics.ts b/frontend/src/views/editor/extensions/minimap/diagnostics.ts index b68ae85..979e4c4 100644 --- a/frontend/src/views/editor/extensions/minimap/diagnostics.ts +++ b/frontend/src/views/editor/extensions/minimap/diagnostics.ts @@ -8,7 +8,7 @@ import { import { LineBasedState } from "./linebasedstate"; import { DrawContext } from "./types"; -import { Lines, LinesState, foldsChanged } from "./linesState"; +import { LinesState, foldsChanged } from "./linesState"; import { Config, Scale } from "./config"; import { lineLength, lineNumberAt, offsetWithinLine } from "./lineGeometry"; diff --git a/frontend/src/views/editor/extensions/minimap/index.ts b/frontend/src/views/editor/extensions/minimap/index.ts index 7d429d8..05fcb84 100644 --- a/frontend/src/views/editor/extensions/minimap/index.ts +++ b/frontend/src/views/editor/extensions/minimap/index.ts @@ -1,27 +1,37 @@ -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"; +/** + * Minimap Extension Entry + * Uses block rendering for visible area only + */ + +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 { LinesState, foldsChanged } from './linesState'; +import { BlockManager } from './blockManager'; +import crelt from 'crelt'; +import { GUTTER_WIDTH, drawLineGutter } from './gutters'; +import { createDebounce } from '@/common/utils/debounce'; const Theme = EditorView.theme({ - "&": { - height: "100%", - overflowY: "auto", + '&': { + height: '100%', + overflowY: 'auto', }, - "& .cm-minimap-gutter": { + '& .cm-minimap-gutter': { borderRight: 0, flexShrink: 0, - left: "unset", - position: "sticky", + left: 'unset', + position: 'sticky', right: 0, top: 0, }, + // 初始化时隐藏,避免宽度未设置时闪烁 + '& .cm-minimap-initializing': { + opacity: 0, + }, '& .cm-minimap-autohide': { opacity: 0.0, transition: 'opacity 0.3s', @@ -29,24 +39,27 @@ const Theme = EditorView.theme({ '& .cm-minimap-autohide:hover': { opacity: 1.0, }, - "& .cm-minimap-inner": { - height: "100%", - position: "absolute", + '& .cm-minimap-inner': { + height: '100%', + position: 'absolute', right: 0, top: 0, - overflowY: "hidden", - "& canvas": { - display: "block", - willChange: "transform, opacity", + overflowY: 'hidden', + '& canvas': { + display: 'block', + willChange: 'transform, opacity', }, }, - "& .cm-minimap-box-shadow": { - boxShadow: "12px 0px 20px 5px #6c6c6c", + '& .cm-minimap-box-shadow': { + boxShadow: '12px 0px 20px 5px #6c6c6c', }, }); const WIDTH_RATIO = 6; -type RenderReason = "scroll" | "data"; +type RenderReason = 'scroll' | 'data'; + +// 渲染类型:blocks=块内容变化需要重渲染, overlays=只需重绘选区等覆盖层 +type RenderType = 'blocks' | 'overlays'; const minimapClass = ViewPlugin.fromClass( class { @@ -58,40 +71,98 @@ const minimapClass = ViewPlugin.fromClass( private pendingScrollTop: number | null = null; private lastRenderedScrollTop: number = -1; private pendingRenderReason: RenderReason | null = null; + private pendingRenderType: RenderType | null = null; - public text: TextState; + // 块管理器(Worker 渲染) + private blockManager: BlockManager; public selection: SelectionState; public diagnostic: DiagnosticState; + // 等待滚动位置稳定 + private initialRenderDelay: ReturnType | null = null; + private isInitialized = false; + private hasRenderedOnce = false; // 是否已首次渲染 + private lastScrollTop = -1; + private scrollStableCount = 0; + + // 块渲染防抖(500ms) + private debouncedBlockRender: ReturnType['debouncedFn']; + private cancelDebounce: () => void; + public constructor(private view: EditorView) { - this.text = text(view); + this.blockManager = new BlockManager(view); this.selection = selections(view); this.diagnostic = diagnostics(view); + // 创建防抖的块渲染函数 + const { debouncedFn, cancel } = createDebounce(() => { + this.requestRender('data', 'blocks'); + }, { delay: 1000 }); + this.debouncedBlockRender = debouncedFn; + this.cancelDebounce = cancel; + + // 当块渲染完成时,请求重新渲染(只渲染 overlays) + this.blockManager.setOnBlockReady(() => { + if (this.isInitialized) { + this.requestRender('data', 'overlays'); + } + }); + if (view.state.facet(showMinimapFacet)) { this.create(view); + this.waitForScrollStable(); } } + // 等待滚动位置稳定后再渲染 + private waitForScrollStable(): void { + const check = () => { + const scrollTop = this.view.scrollDOM.scrollTop; + + if (scrollTop === this.lastScrollTop) { + this.scrollStableCount++; + // 连续 3 次检测位置不变,认为稳定 + if (this.scrollStableCount >= 3) { + this.isInitialized = true; + this.initialRenderDelay = null; + this.requestRender('data', 'blocks'); + return; + } + } else { + this.scrollStableCount = 0; + this.lastScrollTop = scrollTop; + } + + // 每 20ms 检测一次,最多等待 200ms + if (this.scrollStableCount < 10) { + this.initialRenderDelay = setTimeout(check, 20); + } else { + this.isInitialized = true; + this.initialRenderDelay = null; + this.requestRender('data', 'blocks'); + } + }; + + this.initialRenderDelay = setTimeout(check, 20); + } + private create(view: EditorView) { const config = view.state.facet(showMinimapFacet); if (!config) { - throw Error("Expected nonnull"); + throw Error('Expected nonnull'); } - this.inner = crelt("div", { class: "cm-minimap-inner" }); - this.canvas = crelt("canvas") as HTMLCanvasElement; + 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.dom.classList.add('cm-gutters'); + this.dom.classList.add('cm-minimap-gutter'); + this.dom.classList.add('cm-minimap-initializing'); // 初始隐藏 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 @@ -108,7 +179,8 @@ const minimapClass = ViewPlugin.fromClass( this.dom.classList.add('cm-minimap-autohide'); } - this.requestRender(); + // 设置显示模式 + this.blockManager.setDisplayText(view.state.facet(Config).displayText); } private remove() { @@ -119,6 +191,7 @@ const minimapClass = ViewPlugin.fromClass( this.dom = undefined; this.inner = undefined; this.canvas = undefined; + this.hasRenderedOnce = false; // 重置首次渲染标记 } update(update: ViewUpdate) { @@ -135,6 +208,9 @@ const minimapClass = ViewPlugin.fromClass( } if (now) { + let needBlockRender = false; + let needOverlayRender = false; + if (prev && this.dom && prev.autohide !== now.autohide) { if (now.autohide) { this.dom.classList.add('cm-minimap-autohide'); @@ -143,10 +219,45 @@ const minimapClass = ViewPlugin.fromClass( } } - this.text.update(update); + // Check theme change + if (this.blockManager.checkThemeChange()) { + needBlockRender = true; + } + + // Check config change + const prevConfig = update.startState.facet(Config); + const nowConfig = update.state.facet(Config); + if (prevConfig.displayText !== nowConfig.displayText) { + this.blockManager.setDisplayText(nowConfig.displayText); + needBlockRender = true; + } + + // Check doc change + if (update.docChanged) { + const oldLineCount = update.startState.doc.lines; + this.blockManager.handleDocChange(update.state, update.changes, oldLineCount); + needBlockRender = true; + } + + // Check fold change + if (foldsChanged(update.transactions)) { + this.blockManager.markAllDirty(); + needBlockRender = true; + } + + // Update selection and diagnostics this.selection.update(update); this.diagnostic.update(update); - this.requestRender(); + if (update.selectionSet) { + needOverlayRender = true; + } + + // 根据变化类型决定渲染方式 + if (needBlockRender) { + this.debouncedBlockRender(); + } else if (needOverlayRender) { + this.requestRender('data', 'overlays'); + } } } @@ -160,93 +271,118 @@ const minimapClass = ViewPlugin.fromClass( } render() { - // If we don't have elements to draw to exit early if (!this.dom || !this.canvas || !this.inner) { return; } const effectiveScrollTop = this.pendingScrollTop ?? this.view.scrollDOM.scrollTop; + const renderType = this.pendingRenderType ?? 'blocks'; this.pendingScrollTop = null; this.pendingRenderReason = null; + this.pendingRenderType = null; this.lastRenderedScrollTop = effectiveScrollTop; - 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; + // Set canvas size + const width = this.getWidth(); + this.dom.style.width = width + 'px'; + this.canvas.style.maxWidth = width + 'px'; + this.canvas.width = width * Scale.PixelMultiplier; const domHeight = this.view.dom.getBoundingClientRect().height; - this.inner.style.minHeight = domHeight + "px"; + this.inner.style.minHeight = domHeight + 'px'; this.canvas.height = domHeight * Scale.PixelMultiplier; - this.canvas.style.height = domHeight + "px"; + this.canvas.style.height = domHeight + 'px'; - const context = this.canvas.getContext("2d"); + const context = this.canvas.getContext('2d'); if (!context) { return; } - context.clearRect(0, 0, this.canvas.width, this.canvas.height); + // Get scroll info + const scrollInfo = { + scrollTop: effectiveScrollTop, + clientHeight: this.view.scrollDOM.clientHeight, + scrollHeight: this.view.scrollDOM.scrollHeight, + }; - /* We need to get the correct font dimensions before this to measure characters */ - const { charWidth, lineHeight } = this.text.measure(context); + // 渲染块 + if (renderType === 'blocks') { + this.blockManager.render(this.canvas, context, scrollInfo); + } else { + this.blockManager.drawCachedBlocks(this.canvas, context, scrollInfo); + } - let { startIndex, endIndex, offsetY } = this.canvasStartAndEndIndex( + // Render overlays (gutters, selections, diagnostics) + const gutters = this.view.state.facet(Config).gutters; + this.renderOverlays(context, effectiveScrollTop, gutters); + + // 首次渲染完成后显示 minimap + if (!this.hasRenderedOnce && this.dom) { + this.hasRenderedOnce = true; + this.dom.classList.remove('cm-minimap-initializing'); + } + } + + /** + * 渲染覆盖层(gutters、选区、诊断) + */ + private renderOverlays( + context: CanvasRenderingContext2D, + scrollTop: number, + gutters: Required['gutters'] + ) { + const { charWidth, lineHeight } = this.blockManager.measure(context); + const { startIndex, endIndex, offsetY: initialOffsetY } = this.canvasStartAndEndIndex( context, lineHeight, - effectiveScrollTop + scrollTop ); - const gutters = this.view.state.facet(Config).gutters; - const lines = this.view.state.field(LinesState); + let offsetY = initialOffsetY; for (let i = startIndex; i < endIndex; i++) { if (i >= lines.length) break; - const drawContext = { - offsetX: 0, - offsetY, - context, - lineHeight, - charWidth, - }; + let offsetX = 0; + const lineNumber = i + 1; + // 渲染 gutters if (gutters.length) { - /* Small leading buffer */ - drawContext.offsetX += 2; - + offsetX += 2; for (const gutter of gutters) { - drawLineGutter(gutter, drawContext, i + 1); - drawContext.offsetX += GUTTER_WIDTH; + drawLineGutter(gutter, { offsetX, offsetY, context, lineHeight, charWidth }, lineNumber); + offsetX += GUTTER_WIDTH; } - - /* Small trailing buffer */ - drawContext.offsetX += 2; + offsetX += 2; } - const lineNumber = i + 1; - this.text.drawLine(drawContext, lineNumber); - this.selection.drawLine(drawContext, lineNumber); + // 渲染选区 + this.selection.drawLine({ offsetX, offsetY, context, lineHeight, charWidth }, lineNumber); + // 渲染诊断 if (this.diagnostic.has(lineNumber)) { - this.diagnostic.drawLine(drawContext, lineNumber); + this.diagnostic.drawLine({ offsetX, offsetY, context, lineHeight, charWidth }, lineNumber); } offsetY += lineHeight; } } - requestRender(reason: RenderReason = "data") { - if (reason === "scroll") { + requestRender(reason: RenderReason = 'data', type: RenderType = 'blocks') { + if (!this.isInitialized) { + return; + } + + if (reason === 'scroll') { const scrollTop = this.view.scrollDOM.scrollTop; if (this.lastRenderedScrollTop === scrollTop && !this.pendingRenderReason) { return; } if ( - this.pendingRenderReason === "scroll" && + this.pendingRenderReason === 'scroll' && this.pendingScrollTop === scrollTop ) { return; @@ -256,15 +392,20 @@ const minimapClass = ViewPlugin.fromClass( this.pendingScrollTop = null; } - if (reason === "data" || this.pendingRenderReason === null) { + if (reason === 'data' || this.pendingRenderReason === null) { this.pendingRenderReason = reason; } + // 合并渲染类型:blocks > overlays + if (this.pendingRenderType === null || type === 'blocks') { + this.pendingRenderType = type; + } + if (this.renderHandle !== null) { return; } - if (typeof requestAnimationFrame === "function") { + if (typeof requestAnimationFrame === 'function') { const handle = requestAnimationFrame(() => { this.renderHandle = null; this.cancelRender = null; @@ -338,20 +479,26 @@ const minimapClass = ViewPlugin.fromClass( const { clientWidth, scrollWidth, scrollLeft } = this.view.scrollDOM; if (clientWidth + scrollLeft < scrollWidth) { - this.canvas.classList.add("cm-minimap-box-shadow"); + this.canvas.classList.add('cm-minimap-box-shadow'); } else { - this.canvas.classList.remove("cm-minimap-box-shadow"); + this.canvas.classList.remove('cm-minimap-box-shadow'); } } destroy() { + if (this.initialRenderDelay) { + clearTimeout(this.initialRenderDelay); + this.initialRenderDelay = null; + } + this.cancelDebounce(); + this.blockManager.destroy(); this.remove(); } }, { eventHandlers: { scroll() { - this.requestRender("scroll"); + this.requestRender('scroll', 'blocks'); }, }, provide: (plugin) => { @@ -367,8 +514,7 @@ const minimapClass = ViewPlugin.fromClass( } ); -// 使用type定义 -export type MinimapConfig = Omit & { +export type MinimapConfig = Omit & { /** * A function that creates the element that contains the minimap */ @@ -378,25 +524,22 @@ export type MinimapConfig = Omit & { /** * 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, }); /** - * 创建默认的minimap DOM元素 + * 创建默认的 minimap DOM 元素 */ -const defaultCreateFn = (view: EditorView) => { +const defaultCreateFn = (_view: EditorView) => { const dom = document.createElement('div'); return { dom }; }; /** - * 添加minimap到编辑器 - * @param options Minimap配置项 - * @returns + * 添加 minimap 到编辑器 + * @param options Minimap 配置项 */ export function minimap(options: Partial> = {}) { const config: MinimapConfig = { diff --git a/frontend/src/views/editor/extensions/minimap/text.ts b/frontend/src/views/editor/extensions/minimap/text.ts deleted file mode 100644 index 184899f..0000000 --- a/frontend/src/views/editor/extensions/minimap/text.ts +++ /dev/null @@ -1,477 +0,0 @@ -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"; -import { createDocInput } from "./text/docInput"; -import { TagSpan, FontInfo } from "./text/textTypes"; -import { GlyphAtlas } from "./text/glyphAtlas"; -import { LineRenderer } from "./text/lineRenderer"; - -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; - private _fontInfoDirty: boolean = true; - private _fontInfoVersion: number = 0; - private _measurementCache: - | { charWidth: number; lineHeight: number; version: number } - | undefined; - private _glyphAtlas = new GlyphAtlas(); - private _lineRenderer: LineRenderer; - - public constructor(view: EditorView) { - super(view); - - this._themeClasses = new Set(Array.from(view.dom.classList)); - this._lineRenderer = new LineRenderer( - this._glyphAtlas, - this.getFontInfo.bind(this) - ); - - if (view.state.facet(Config).enabled) { - 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) { - /* Store display text setting for rendering */ - this._displayText = state.facet(Config).displayText; - this.refreshFontCachesIfNeeded(); - - /* 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 parser = state.facet(language)?.parser; - const tree = parser - ? parser.parse(createDocInput(state.doc), 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 }> = []; - let viewportLines: - | { - from: number; - to: number; - } - | undefined; - - 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 = Math.max(1, vpLineBottom - vpLineTop); - const scrollDenominator = Math.max(1, state.doc.lines - vpLineCount); - const vpScroll = Math.min(1, Math.max(0, vpLineTop / scrollDenominator)); - - const { SizeRatio, PixelMultiplier } = Scale; - const mmLineCount = vpLineCount * SizeRatio * PixelMultiplier; - const mmLineRatio = vpScroll * mmLineCount; - - const mmLineTopRaw = Math.max(1, Math.floor(vpLineTop - mmLineRatio)); - const mmLineBottomRaw = Math.min( - vpLineBottom + Math.floor(mmLineCount - mmLineRatio), - state.doc.lines - ); - - if ( - Number.isFinite(mmLineTopRaw) && - Number.isFinite(mmLineBottomRaw) - ) { - const mmLineTop = Math.max(1, Math.floor(mmLineTopRaw)); - const mmLineBottom = Math.max(mmLineTop, Math.floor(mmLineBottomRaw)); - - viewportLines = { - from: mmLineTop, - to: mmLineBottom, - }; - - // Highlight the in-view lines synchronously - highlightTree( - tree, - highlighter, - (from, to, tags) => { - highlights.push({ from, to, tags }); - }, - state.doc.line(mmLineTop).from, - state.doc.line(mmLineBottom).to - ); - } - } - - const hasExistingData = this.map.size > 0; - const lineRange = - viewportLines && hasExistingData ? viewportLines : undefined; - - // Update the map - this.updateMapImpl(state, highlights, lineRange); - - // 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, { - from: 1, - to: state.doc.lines, - }); - this._highlightingCallbackId = undefined; - } - }; - this._highlightingCallbackId = - typeof window.requestIdleCallback !== "undefined" - ? requestIdleCallback(highlightingCallback) - : setTimeout(highlightingCallback); - } - - private refreshFontCachesIfNeeded() { - if (!this._fontInfoDirty) { - return; - } - - this._fontInfoMap.clear(); - this._glyphAtlas.bust(); - this._measurementCache = undefined; - this._lineRenderer.markAllChanged(); - this._fontInfoDirty = false; - this._fontInfoVersion++; - } - - private updateMapImpl( - state: EditorState, - highlights: Array<{ from: number; to: number; tags: string }>, - lineRange?: { from: number; to: number } - ) { - const lines = state.field(LinesState); - const totalLines = lines.length; - const startIndex = lineRange - ? Math.max(0, Math.min(totalLines, lineRange.from) - 1) - : 0; - const endIndex = lineRange - ? Math.min(totalLines, Math.max(lineRange.to, lineRange.from)) - : totalLines; - - if (!lineRange) { - this.map.clear(); - this._lineRenderer.markAllChanged(); - } else { - this._lineRenderer.pruneLines(totalLines); - for (const lineNumber of Array.from(this.map.keys())) { - if (lineNumber > totalLines) { - this.map.delete(lineNumber); - } - } - } - - if (startIndex >= endIndex) { - return; - } - - const slice = (from: number, to: number) => state.doc.sliceString(from, to); - const highlightsIterator = highlights.values(); - let highlightPtr = highlightsIterator.next(); - - for (let rawIndex = startIndex; rawIndex < endIndex; rawIndex++) { - const line = lines[rawIndex]; - if (!line) { - continue; - } - 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: 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: 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: slice(position, span.to), - tags: "", - }); - } - } - - // Lines are indexed beginning at 1 instead of 0 - const lineNumber = rawIndex + 1; - const previous = this.map.get(lineNumber); - if (previous && this.areSpansEqual(previous, spans)) { - continue; - } - - this.setLine(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; - - if ( - this._measurementCache && - this._measurementCache.version === this._fontInfoVersion - ) { - return { - charWidth: this._measurementCache.charWidth, - lineHeight: this._measurementCache.lineHeight, - }; - } - - const measurements = { - charWidth: context.measureText("_").width, - lineHeight: lineHeight, - version: this._fontInfoVersion, - }; - this._measurementCache = measurements; - - return { - charWidth: measurements.charWidth, - lineHeight: measurements.lineHeight, - }; - } - - public beforeDraw() { - this.refreshFontCachesIfNeeded(); // Confirm this worked for theme changes or get rid of it because it's slow - } - - public drawLine(ctx: DrawContext, lineNumber: number) { - const spans = this.get(lineNumber); - if (!spans) { - return; - } - - const displayMode = this._displayText ?? "characters"; - this._lineRenderer.drawLine(lineNumber, spans, displayMode, ctx); - } - - 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 rawLineHeight = parseFloat(style.lineHeight); - const fallbackLineHeight = parseFloat(style.fontSize) || this.view.defaultLineHeight; - const resolvedLineHeight = - Number.isFinite(rawLineHeight) && rawLineHeight > 0 - ? rawLineHeight - : fallbackLineHeight; - const lineHeight = Math.max(1, resolvedLineHeight / 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 setLine(lineNumber: number, spans: Array) { - this.map.set(lineNumber, spans); - this._lineRenderer.markLineChanged(lineNumber); - } - - private areSpansEqual(a: Array, b: Array) { - if (a.length !== b.length) { - return false; - } - for (let i = 0; i < a.length; i++) { - if (a[i].text !== b[i].text || a[i].tags !== b[i].tags) { - return false; - } - } - return true; - } - - private themeChanged(): boolean { - const previous = this._themeClasses; - const now = new Set(Array.from(this.view.dom.classList)); - this._themeClasses = now; - - if (!previous) { - this._fontInfoDirty = true; - return true; - } - - // Ignore certain classes being added/removed - const previousComparable = new Set(previous); - const nowComparable = new Set(now); - previousComparable.delete("cm-focused"); - nowComparable.delete("cm-focused"); - - if (previousComparable.size !== nowComparable.size) { - this._fontInfoDirty = true; - return true; - } - - for (const theme of previousComparable) { - if (!nowComparable.has(theme)) { - this._fontInfoDirty = true; - return true; - } - } - - return false; - } -} - -export function text(view: EditorView): TextState { - return new TextState(view); -} diff --git a/frontend/src/views/editor/extensions/minimap/text/docInput.ts b/frontend/src/views/editor/extensions/minimap/text/docInput.ts deleted file mode 100644 index 6ae2967..0000000 --- a/frontend/src/views/editor/extensions/minimap/text/docInput.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Text } from "@codemirror/state"; -import { Input } from "@lezer/common"; - -const INPUT_CHUNK_SIZE = 2048; - -export function createDocInput(doc: Text): Input { - return { - length: doc.length, - lineChunks: false, - chunk(from: number) { - if (from >= doc.length) { - return ""; - } - - const to = Math.min(doc.length, from + INPUT_CHUNK_SIZE); - return doc.sliceString(from, to); - }, - read(from: number, to: number) { - return doc.sliceString(from, to); - }, - }; -} diff --git a/frontend/src/views/editor/extensions/minimap/text/glyphAtlas.ts b/frontend/src/views/editor/extensions/minimap/text/glyphAtlas.ts deleted file mode 100644 index 4051d2a..0000000 --- a/frontend/src/views/editor/extensions/minimap/text/glyphAtlas.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { FontInfo } from "./textTypes"; - -export type GlyphCanvas = OffscreenCanvas | HTMLCanvasElement; - -type GlyphBitmap = { - source: CanvasImageSource; - sw: number; - sh: number; -}; - -export class GlyphAtlas { - private static measurementCanvas: - | OffscreenCanvas - | HTMLCanvasElement - | undefined; - private static measurementContext: - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null - | undefined; - - private readonly atlases = new Map>(); - private readonly enabled: boolean; - - public constructor() { - this.enabled = - typeof OffscreenCanvas !== "undefined" || typeof document !== "undefined"; - } - - public isAvailable() { - return this.enabled; - } - - public get( - info: FontInfo, - char: string, - intrinsicLineHeight: number - ): GlyphBitmap | undefined { - if (!this.enabled) { - return undefined; - } - - const key = `${info.font}|${info.color}|${intrinsicLineHeight.toFixed(2)}`; - let atlas = this.atlases.get(key); - if (!atlas) { - atlas = new Map(); - this.atlases.set(key, atlas); - } - - let glyph = atlas.get(char); - if (!glyph) { - glyph = this.createGlyph(info, char, intrinsicLineHeight); - if (glyph) { - atlas.set(char, glyph); - } - } - - return glyph; - } - - public bust() { - this.atlases.clear(); - } - - private createGlyph( - info: FontInfo, - char: string, - lineHeight: number - ): GlyphBitmap | undefined { - const measurement = GlyphAtlas.ensureMeasurementContext(); - if (!measurement) { - return undefined; - } - - measurement.font = info.font; - const metrics = measurement.measureText(char); - const width = Math.max( - Math.ceil( - metrics.actualBoundingBoxRight !== undefined && - metrics.actualBoundingBoxLeft !== undefined - ? metrics.actualBoundingBoxRight - metrics.actualBoundingBoxLeft - : metrics.width - ), - 1 - ); - const height = Math.max(1, Math.ceil(lineHeight)); - - const canvas = this.createCanvas(width, height); - const ctx = canvas.getContext("2d"); - if (!isCanvas2DContext(ctx)) { - return undefined; - } - - ctx.clearRect(0, 0, width, height); - ctx.fillStyle = info.color; - ctx.font = info.font; - ctx.textBaseline = "ideographic"; - ctx.fillText(char, 0, height); - - return { source: canvas, sw: width, sh: height }; - } - - private createCanvas(width: number, height: number): GlyphCanvas { - if (typeof OffscreenCanvas !== "undefined") { - return new OffscreenCanvas(width, height); - } - - if (typeof document === "undefined") { - throw new Error("Unable to create canvas without DOM"); - } - - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - return canvas; - } - - private static ensureMeasurementContext(): - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | undefined { - if (!GlyphAtlas.measurementCanvas) { - if (typeof OffscreenCanvas !== "undefined") { - GlyphAtlas.measurementCanvas = new OffscreenCanvas(1, 1); - } else if (typeof document !== "undefined") { - GlyphAtlas.measurementCanvas = document.createElement("canvas"); - } - } - - if (GlyphAtlas.measurementCanvas && !GlyphAtlas.measurementContext) { - const context = GlyphAtlas.measurementCanvas.getContext("2d"); - GlyphAtlas.measurementContext = isCanvas2DContext(context) - ? context - : undefined; - } - - return GlyphAtlas.measurementContext ?? undefined; - } -} - -export function isCanvas2DContext( - ctx: unknown -): ctx is CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D { - return !!ctx && typeof (ctx as CanvasRenderingContext2D).fillText === "function"; -} diff --git a/frontend/src/views/editor/extensions/minimap/text/lineRenderer.ts b/frontend/src/views/editor/extensions/minimap/text/lineRenderer.ts deleted file mode 100644 index 8c7755b..0000000 --- a/frontend/src/views/editor/extensions/minimap/text/lineRenderer.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { DrawContext } from "../types"; -import { Options, Scale } from "../config"; -import { GlyphAtlas, GlyphCanvas, isCanvas2DContext } from "./glyphAtlas"; -import { TagSpan, FontInfo } from "./textTypes"; - -type DisplayMode = Required["displayText"]; - -type LineBitmap = { - version: number; - charWidth: number; - baseLineHeight: number; - availableWidth: number; - height: number; - displayMode: DisplayMode; - canvas: GlyphCanvas; -}; - -export class LineRenderer { - private readonly _lineVersions = new Map(); - private readonly _lineCache = new Map(); - private static readonly MAX_CACHE_LINES = 2000; - - public constructor( - private readonly glyphAtlas: GlyphAtlas, - private readonly resolveFontInfo: (tags: string) => FontInfo - ) {} - - public markLineChanged(lineNumber: number) { - const version = (this._lineVersions.get(lineNumber) ?? 0) + 1; - this._lineVersions.set(lineNumber, version); - this._lineCache.delete(lineNumber); - this.trimCache(); - } - - public markAllChanged() { - this._lineVersions.clear(); - this._lineCache.clear(); - } - - public pruneLines(totalLines: number) { - for (const key of this._lineVersions.keys()) { - if (key > totalLines) { - this._lineVersions.delete(key); - this._lineCache.delete(key); - } - } - this.trimCache(); - } - - public drawLine( - lineNumber: number, - spans: Array, - displayText: DisplayMode, - ctx: DrawContext - ) { - if (spans.length === 0) { - return; - } - - const availableWidth = Math.max( - 0, - Math.floor(ctx.context.canvas.width - ctx.offsetX) - ); - if (availableWidth <= 0) { - return; - } - - const version = this._lineVersions.get(lineNumber) ?? 0; - const cached = this.ensureLineBitmap( - lineNumber, - spans, - version, - displayText, - ctx, - availableWidth - ); - - if (!cached) { - this.paintLineDirectly(spans, displayText, ctx, availableWidth); - return; - } - - ctx.context.drawImage( - cached.canvas, - 0, - 0, - cached.availableWidth, - cached.height, - ctx.offsetX, - ctx.offsetY, - cached.availableWidth, - cached.height - ); - } - - private paintLineDirectly( - spans: Array, - displayText: DisplayMode, - ctx: DrawContext, - availableWidth: number - ) { - this.paintSpans( - ctx.context, - spans, - displayText, - ctx.charWidth, - ctx.lineHeight, - ctx.offsetX, - ctx.offsetY, - availableWidth - ); - } - - private ensureLineBitmap( - lineNumber: number, - spans: Array, - version: number, - displayText: DisplayMode, - ctx: DrawContext, - availableWidth: number - ): LineBitmap | undefined { - const cached = this._lineCache.get(lineNumber); - if ( - cached && - cached.version === version && - cached.charWidth === ctx.charWidth && - cached.baseLineHeight === ctx.lineHeight && - cached.availableWidth === availableWidth && - cached.displayMode === displayText - ) { - return cached; - } - - const fontInfos = spans.map((span) => this.resolveFontInfo(span.tags)); - let maxLineHeight = ctx.lineHeight; - for (const info of fontInfos) { - maxLineHeight = Math.max(maxLineHeight, info.lineHeight); - } - - const width = Math.max(1, availableWidth); - const height = Math.max(1, Math.ceil(maxLineHeight)); - const canvas = this.createLineCanvas(width, height); - if (!canvas) { - return undefined; - } - - const lineCtx = canvas.getContext("2d"); - if (!isCanvas2DContext(lineCtx)) { - return undefined; - } - - lineCtx.clearRect(0, 0, width, height); - this.paintSpans( - lineCtx, - spans, - displayText, - ctx.charWidth, - maxLineHeight, - 0, - 0, - width, - fontInfos - ); - - const entry: LineBitmap = { - version, - charWidth: ctx.charWidth, - baseLineHeight: ctx.lineHeight, - availableWidth: width, - height, - displayMode: displayText, - canvas, - }; - - this._lineCache.set(lineNumber, entry); - this.trimCache(); - return entry; - } - - private trimCache() { - while (this._lineCache.size > LineRenderer.MAX_CACHE_LINES) { - const oldest = this._lineCache.keys().next(); - if (oldest.done) break; - this._lineCache.delete(oldest.value); - this._lineVersions.delete(oldest.value); - } - } - - private paintSpans( - context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, - spans: Array, - displayText: DisplayMode, - charWidth: number, - baseLineHeight: number, - offsetX: number, - offsetY: number, - availableWidth: number, - fontInfos?: Array - ) { - let cursorX = offsetX; - let prevInfo: FontInfo | undefined; - context.textBaseline = "ideographic"; - - for (let i = 0; i < spans.length; i++) { - const span = spans[i]; - const info = fontInfos?.[i] ?? this.resolveFontInfo(span.tags); - - if (!prevInfo || prevInfo.color !== info.color) { - context.fillStyle = info.color; - } - - if (!prevInfo || prevInfo.font !== info.font) { - context.font = info.font; - } - - prevInfo = info; - const spanLineHeight = Math.max(baseLineHeight, info.lineHeight); - - if (displayText === "characters") { - cursorX = this.drawCharactersSpan( - context, - span.text, - info, - cursorX, - offsetY, - spanLineHeight, - charWidth - ); - continue; - } - - const nonWhitespace = /\S+/g; - let start: RegExpExecArray | null; - while ((start = nonWhitespace.exec(span.text)) !== null) { - const startX = cursorX + start.index * charWidth; - let width = (nonWhitespace.lastIndex - start.index) * charWidth; - const relativeStart = startX - offsetX; - - if (relativeStart > availableWidth) { - break; - } - - if (relativeStart + width > availableWidth) { - width = availableWidth - relativeStart; - } - - if (width <= 0) { - continue; - } - - const yBuffer = 2 / Scale.SizeRatio; - const height = spanLineHeight - yBuffer; - - context.fillStyle = info.color; - context.globalAlpha = 0.65; - context.beginPath(); - context.rect(startX, offsetY, width, height); - context.fill(); - } - - cursorX += span.text.length * charWidth; - context.globalAlpha = 1; - } - } - - private drawCharactersSpan( - context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, - text: string, - fontInfo: FontInfo, - offsetX: number, - offsetY: number, - lineHeight: number, - charWidth: number - ) { - if (!text) { - return offsetX; - } - - context.globalAlpha = 1; - - if (!this.glyphAtlas.isAvailable()) { - context.fillText(text, offsetX, offsetY + lineHeight); - return offsetX + text.length * charWidth; - } - - for (const char of text) { - const glyph = this.glyphAtlas.get(fontInfo, char, fontInfo.lineHeight); - if (glyph) { - const destY = offsetY + (lineHeight - glyph.sh); - context.drawImage( - glyph.source, - 0, - 0, - glyph.sw, - glyph.sh, - offsetX, - destY, - charWidth, - glyph.sh - ); - } else { - context.fillText(char, offsetX, offsetY + lineHeight); - } - offsetX += charWidth; - } - - return offsetX; - } - - private createLineCanvas(width: number, height: number): GlyphCanvas | undefined { - if (typeof OffscreenCanvas !== "undefined") { - return new OffscreenCanvas(width, height); - } - - if (typeof document !== "undefined") { - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - return canvas; - } - - return undefined; - } -} diff --git a/frontend/src/views/editor/extensions/minimap/text/textTypes.ts b/frontend/src/views/editor/extensions/minimap/text/textTypes.ts deleted file mode 100644 index 7dae88e..0000000 --- a/frontend/src/views/editor/extensions/minimap/text/textTypes.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type TagSpan = { text: string; tags: string }; - -export type FontInfo = { - color: string; - font: string; - lineHeight: number; -}; diff --git a/frontend/src/views/editor/extensions/minimap/worker/block.worker.ts b/frontend/src/views/editor/extensions/minimap/worker/block.worker.ts new file mode 100644 index 0000000..9c24c11 --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/worker/block.worker.ts @@ -0,0 +1,265 @@ +import { + ToWorkerMessage, + ToMainMessage, + BlockRequest, + TagSpan, + FontInfo, +} from './protocol'; + +function post(msg: ToMainMessage, transfer?: Transferable[]): void { + self.postMessage(msg, { transfer }); +} + +/** + * 二分查找:找到第一个 to > pos 的高亮索引 + */ +function findHighlightStart(highlights: BlockRequest['highlights'], pos: number): number { + let left = 0; + let right = highlights.length; + while (left < right) { + const mid = (left + right) >>> 1; + if (highlights[mid].to <= pos) { + left = mid + 1; + } else { + right = mid; + } + } + return left; +} + +function buildLineSpans( + request: BlockRequest +): Map { + const { highlights, lines, textSlice, textOffset, startLine, endLine } = request; + const result = new Map(); + + const slice = (from: number, to: number): string => { + return textSlice.slice(from - textOffset, to - textOffset); + }; + + // 使用二分查找找到起始高亮索引 + const firstSpanPos = lines[0]?.[0]?.from ?? 0; + let hlIndex = findHighlightStart(highlights, firstSpanPos); + + for (let i = 0; i < lines.length && i < endLine - startLine + 1; i++) { + const lineNumber = startLine + i; + const line = lines[i]; + if (!line) continue; + + const spans: TagSpan[] = []; + + for (const span of line) { + if (span.from === span.to) continue; + + if (span.folded) { + spans.push({ text: '…', tags: '' }); + continue; + } + + let pos = span.from; + + // 从当前索引继续查找 + while (hlIndex < highlights.length && highlights[hlIndex].from < span.to) { + const hl = highlights[hlIndex]; + + if (hl.to <= pos) { + hlIndex++; + continue; + } + + if (hl.from > pos) { + spans.push({ text: slice(pos, Math.min(hl.from, span.to)), tags: '' }); + pos = Math.min(hl.from, span.to); + } + + if (hl.from < span.to) { + const start = Math.max(hl.from, span.from); + const end = Math.min(hl.to, span.to); + if (start < end) { + spans.push({ text: slice(start, end), tags: hl.tags }); + pos = end; + } + } + + if (hl.to <= span.to) { + hlIndex++; + } else { + break; + } + } + + if (pos < span.to) { + spans.push({ text: slice(pos, span.to), tags: '' }); + } + } + + result.set(lineNumber, spans); + } + + return result; +} + +function renderBlock(request: BlockRequest): void { + const { + blockId, + blockIndex, + startLine, + endLine, + width, + height, + fontInfoMap, + defaultFont, + displayText, + charWidth, + lineHeight, + gutterOffset, + } = request; + + // Create OffscreenCanvas for this block + const canvas = new OffscreenCanvas(width, height); + const ctx = canvas.getContext('2d'); + if (!ctx) { + post({ type: 'error', message: 'Failed to get 2d context' }); + return; + } + + // Build line data + const lineSpans = buildLineSpans(request); + + // Render each line + ctx.textBaseline = 'ideographic'; + let offsetY = 0; + + for (let lineNum = startLine; lineNum <= endLine; lineNum++) { + const spans = lineSpans.get(lineNum); + if (spans && spans.length > 0) { + drawLine(ctx, spans, fontInfoMap, defaultFont, displayText, gutterOffset, offsetY, charWidth, lineHeight, width); + } + offsetY += lineHeight; + } + + // Convert to ImageBitmap and send back + const bitmap = canvas.transferToImageBitmap(); + post({ type: 'blockComplete', blockId, blockIndex, bitmap }, [bitmap]); +} + +function drawLine( + ctx: OffscreenCanvasRenderingContext2D, + spans: TagSpan[], + fontInfoMap: Record, + defaultFont: FontInfo, + displayText: 'blocks' | 'characters', + offsetX: number, + offsetY: number, + charWidth: number, + lineHeight: number, + availableWidth: number +): void { + let cursorX = offsetX; + let prevFont: FontInfo | null = null; + + for (const span of spans) { + const info = fontInfoMap[span.tags] || defaultFont; + + if (!prevFont || prevFont.color !== info.color) { + ctx.fillStyle = info.color; + } + if (!prevFont || prevFont.font !== info.font) { + ctx.font = info.font; + } + prevFont = info; + + if (displayText === 'characters') { + cursorX = drawCharacters(ctx, span.text, cursorX, offsetY, charWidth, lineHeight); + } else { + cursorX = drawTextBlocks(ctx, span.text, cursorX, offsetY, charWidth, lineHeight, availableWidth - offsetX); + } + } +} + +function drawCharacters( + ctx: OffscreenCanvasRenderingContext2D, + text: string, + x: number, + y: number, + charWidth: number, + lineHeight: number +): number { + const yPos = y + lineHeight; + let batchStart = -1; + let batchX = x; + + for (let i = 0; i <= text.length; i++) { + const char = text[i]; + const isWhitespace = !char || char === ' ' || char === '\t'; + + if (isWhitespace) { + // 输出之前积累的批次 + if (batchStart !== -1) { + ctx.fillText(text.slice(batchStart, i), batchX, yPos); + batchStart = -1; + } + if (char) x += charWidth; + } else { + // 开始新批次 + if (batchStart === -1) { + batchStart = i; + batchX = x; + } + x += charWidth; + } + } + + return x; +} + +function drawTextBlocks( + ctx: OffscreenCanvasRenderingContext2D, + text: string, + x: number, + y: number, + charWidth: number, + lineHeight: number, + availableWidth: number +): number { + const nonWhitespace = /\S+/g; + let match: RegExpExecArray | null; + + while ((match = nonWhitespace.exec(text)) !== null) { + const startX = x + match.index * charWidth; + let width = (nonWhitespace.lastIndex - match.index) * charWidth; + + if (startX > availableWidth) break; + if (startX + width > availableWidth) { + width = availableWidth - startX; + } + + if (width > 0) { + ctx.fillRect(startX, y, width, lineHeight); + } + } + + return x + text.length * charWidth; +} + +function handleMessage(msg: ToWorkerMessage): void { + switch (msg.type) { + case 'init': + post({ type: 'ready' }); + break; + case 'renderBlock': + renderBlock(msg); + break; + case 'destroy': + break; + } +} + +self.onmessage = (e: MessageEvent) => { + try { + handleMessage(e.data); + } catch (err) { + post({ type: 'error', message: String(err) }); + } +}; + diff --git a/frontend/src/views/editor/extensions/minimap/worker/protocol.ts b/frontend/src/views/editor/extensions/minimap/worker/protocol.ts new file mode 100644 index 0000000..f459229 --- /dev/null +++ b/frontend/src/views/editor/extensions/minimap/worker/protocol.ts @@ -0,0 +1,74 @@ +/** + * Worker Communication Protocol + */ + +export interface TagSpan { + text: string; + tags: string; +} + +export interface Highlight { + from: number; + to: number; + tags: string; +} + +export interface LineSpan { + from: number; + to: number; + folded: boolean; +} + +export interface FontInfo { + color: string; + font: string; + lineHeight: number; +} + +export interface BlockRequest { + type: 'renderBlock'; + blockId: number; + blockIndex: number; + startLine: number; + endLine: number; + width: number; + height: number; + highlights: Highlight[]; + lines: LineSpan[][]; + textSlice: string; + textOffset: number; + fontInfoMap: Record; + defaultFont: FontInfo; + displayText: 'blocks' | 'characters'; + charWidth: number; + lineHeight: number; + gutterOffset: number; +} + +export interface InitRequest { + type: 'init'; +} + +export interface DestroyRequest { + type: 'destroy'; +} + +export type ToWorkerMessage = BlockRequest | InitRequest | DestroyRequest; + +export interface BlockComplete { + type: 'blockComplete'; + blockId: number; + blockIndex: number; + bitmap: ImageBitmap; +} + +export interface WorkerReady { + type: 'ready'; +} + +export interface WorkerError { + type: 'error'; + message: string; +} + +export type ToMainMessage = BlockComplete | WorkerReady | WorkerError; diff --git a/frontend/src/views/settings/pages/ExtensionsPage.vue b/frontend/src/views/settings/pages/ExtensionsPage.vue index 2eebb6f..cc0b24f 100644 --- a/frontend/src/views/settings/pages/ExtensionsPage.vue +++ b/frontend/src/views/settings/pages/ExtensionsPage.vue @@ -238,10 +238,6 @@ const handleConfigInput = async (