Optimize minmap extension completed

This commit is contained in:
2025-12-11 23:59:06 +08:00
parent 7e9fc0ac3f
commit 4e611db349
12 changed files with 1104 additions and 1099 deletions

View File

@@ -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 {

View File

@@ -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<number, Block>();
private fontInfoMap = new Map<string, FontInfo>();
private fontDirty = true;
private fontVersion = 0;
private measureCache: { charWidth: number; lineHeight: number; version: number } | null = null;
private displayText: 'blocks' | 'characters' = 'characters';
private themeClasses: Set<string>;
private requestId = 0;
private onBlockReady: (() => void) | null = null;
// 批量处理块完成事件
private pendingBlockReadyHandle: ReturnType<typeof setTimeout> | 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<ToMainMessage>) => {
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<number>();
// 正确检测行数变化:比较新旧文档总行数
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<string, FontInfo> = {};
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;
}
}

View File

@@ -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";

View File

@@ -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<typeof setTimeout> | null = null;
private isInitialized = false;
private hasRenderedOnce = false; // 是否已首次渲染
private lastScrollTop = -1;
private scrollStableCount = 0;
// 块渲染防抖500ms
private debouncedBlockRender: ReturnType<typeof createDebounce>['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<Options>['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<Options, "enabled"> & {
export type MinimapConfig = Omit<Options, 'enabled'> & {
/**
* A function that creates the element that contains the minimap
*/
@@ -378,25 +524,22 @@ export type MinimapConfig = Omit<Options, "enabled"> & {
/**
* Facet used to show a minimap in the right gutter of the editor using the
* provided configuration.
*
* If you return `null`, a minimap will not be shown.
*/
const showMinimapFacet = Facet.define<MinimapConfig | null, MinimapConfig | null>({
combine: (c) => c.find((o) => o !== null) ?? null,
});
/**
* 创建默认的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<Omit<MinimapConfig, 'create'>> = {}) {
const config: MinimapConfig = {

View File

@@ -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<Array<TagSpan>> {
private _previousTree: Tree | undefined;
private _displayText: Required<Options>["displayText"] | undefined;
private _fontInfoMap: Map<string, FontInfo> = new Map();
private _themeClasses: Set<string> | undefined;
private _highlightingCallbackId: number | NodeJS.Timeout | undefined;
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<TreeFragment> | undefined = undefined;
if (this._previousTree && changes) {
const previousFragments = TreeFragment.addTree(this._previousTree);
const changedRanges: Array<ChangedRange> = [];
changes.iterChangedRanges((fromA, toA, fromB, toB) =>
changedRanges.push({ fromA, toA, fromB, toB })
);
treeFragments = TreeFragment.applyChanges(
previousFragments,
changedRanges
);
}
/* Parse the document into a lezer tree */
const 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<TagSpan> = [];
for (const span of line) {
// Skip if it's a 0-length span
if (span.from === span.to) {
continue;
}
// Append a placeholder for a folded span
if (span.folded) {
spans.push({ text: "…", tags: "" });
continue;
}
let position = span.from;
while (!highlightPtr.done && highlightPtr.value.from < span.to) {
const { from, to, tags } = highlightPtr.value;
// Iterate until our highlight is over the current span
if (to < position) {
highlightPtr = highlightsIterator.next();
continue;
}
// Append unstyled text before the highlight begins
if (from > position) {
spans.push({ text: 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<TagSpan>) {
this.map.set(lineNumber, spans);
this._lineRenderer.markLineChanged(lineNumber);
}
private areSpansEqual(a: Array<TagSpan>, b: Array<TagSpan>) {
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<string>(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);
}

View File

@@ -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);
},
};
}

View File

@@ -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<string, Map<string, GlyphBitmap>>();
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";
}

View File

@@ -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<Options>["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<number, number>();
private readonly _lineCache = new Map<number, LineBitmap>();
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<TagSpan>,
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<TagSpan>,
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<TagSpan>,
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<TagSpan>,
displayText: DisplayMode,
charWidth: number,
baseLineHeight: number,
offsetX: number,
offsetY: number,
availableWidth: number,
fontInfos?: Array<FontInfo>
) {
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;
}
}

View File

@@ -1,7 +0,0 @@
export type TagSpan = { text: string; tags: string };
export type FontInfo = {
color: string;
font: string;
lineHeight: number;
};

View File

@@ -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<number, TagSpan[]> {
const { highlights, lines, textSlice, textOffset, startLine, endLine } = request;
const result = new Map<number, TagSpan[]>();
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<string, FontInfo>,
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<ToWorkerMessage>) => {
try {
handleMessage(e.data);
} catch (err) {
post({ type: 'error', message: String(err) });
}
};

View File

@@ -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<string, FontInfo>;
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;

View File

@@ -238,10 +238,6 @@ const handleConfigInput = async (
</template>
<style scoped lang="scss">
.settings-page {
//max-width: 1000px;
}
.extension-item {
border-bottom: 1px solid var(--settings-input-border);
@@ -296,36 +292,38 @@ const handleConfigInput = async (
.extension-config {
background-color: var(--settings-input-bg);
border-left: 3px solid var(--settings-accent);
margin: 8px 0 16px 0;
padding: 12px;
border-radius: 6px;
font-size: 13px;
border-left: 2px solid var(--settings-accent);
margin: 4px 0 12px 0;
padding: 8px 10px;
border-radius: 2px;
font-size: 12px;
}
.config-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
margin-bottom: 6px;
}
.config-title {
font-size: 13px;
font-weight: 600;
font-size: 12px;
font-weight: 500;
color: var(--settings-text);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.reset-button {
padding: 6px 12px;
font-size: 11px;
padding: 3px 8px;
font-size: 12px;
border: 1px solid var(--settings-input-border);
border-radius: 4px;
background-color: var(--settings-input-bg);
border-radius: 2px;
background-color: transparent;
color: var(--settings-text-secondary);
cursor: pointer;
transition: all 0.2s ease;
transition: all 0.15s ease;
white-space: nowrap;
&:hover {
@@ -337,8 +335,7 @@ const handleConfigInput = async (
.config-table-wrapper {
border: 1px solid var(--settings-input-border);
border-radius: 6px;
margin-top: 8px;
border-radius: 2px;
overflow: hidden;
background-color: var(--settings-panel, var(--settings-input-bg));
}
@@ -346,7 +343,7 @@ const handleConfigInput = async (
.config-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
font-size: 11px;
}
.config-table tr + tr {
@@ -355,38 +352,41 @@ const handleConfigInput = async (
.config-table th,
.config-table td {
padding: 10px 12px;
padding: 5px 8px;
vertical-align: middle;
}
.config-table-key {
width: 36%;
width: 30%;
text-align: left;
font-weight: 600;
font-weight: 500;
color: var(--settings-text-secondary);
border-right: 1px solid var(--settings-input-border);
background-color: var(--settings-input-bg);
background-color: rgba(0, 0, 0, 0.05);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, monospace;
font-size: 10px;
}
.config-table-value {
padding: 6px;
padding: 3px 4px;
}
.config-value-input {
width: 100%;
padding: 8px 10px;
padding: 4px 6px;
border: 1px solid transparent;
border-radius: 4px;
border-radius: 2px;
background: transparent;
color: var(--settings-text);
font-size: 12px;
line-height: 1.4;
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, monospace;
line-height: 1.3;
box-sizing: border-box;
transition: border-color 0.2s ease, background-color 0.2s ease;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.config-value-input:hover {
border-color: var(--settings-hover-border, var(--settings-input-border));
border-color: var(--settings-input-border);
background-color: var(--settings-hover);
}