Compare commits
3 Commits
markdown
...
4e611db349
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e611db349 | |||
| 7e9fc0ac3f | |||
| ff072d1a93 |
@@ -142,7 +142,7 @@ onBeforeUnmount(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-family: var(--voidraft-font-mono),serif;
|
font-family: Menlo, monospace,serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-word {
|
.loading-word {
|
||||||
|
|||||||
498
frontend/src/views/editor/extensions/minimap/blockManager.ts
Normal file
498
frontend/src/views/editor/extensions/minimap/blockManager.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,12 +8,21 @@ import {
|
|||||||
|
|
||||||
import { LineBasedState } from "./linebasedstate";
|
import { LineBasedState } from "./linebasedstate";
|
||||||
import { DrawContext } from "./types";
|
import { DrawContext } from "./types";
|
||||||
import { Lines, LinesState, foldsChanged } from "./linesState";
|
import { LinesState, foldsChanged } from "./linesState";
|
||||||
import { Config } from "./config";
|
import { Config, Scale } from "./config";
|
||||||
|
import { lineLength, lineNumberAt, offsetWithinLine } from "./lineGeometry";
|
||||||
|
|
||||||
type Severity = Diagnostic["severity"];
|
type Severity = Diagnostic["severity"];
|
||||||
|
type DiagnosticRange = { from: number; to: number };
|
||||||
|
type LineDiagnostics = {
|
||||||
|
severity: Severity;
|
||||||
|
ranges: Array<DiagnosticRange>;
|
||||||
|
};
|
||||||
|
const MIN_PIXEL_WIDTH = 1 / Scale.PixelMultiplier;
|
||||||
|
const snapToDevice = (value: number) =>
|
||||||
|
Math.round(value * Scale.PixelMultiplier) / Scale.PixelMultiplier;
|
||||||
|
|
||||||
export class DiagnosticState extends LineBasedState<Severity> {
|
export class DiagnosticState extends LineBasedState<LineDiagnostics> {
|
||||||
private count: number | undefined = undefined;
|
private count: number | undefined = undefined;
|
||||||
|
|
||||||
public constructor(view: EditorView) {
|
public constructor(view: EditorView) {
|
||||||
@@ -63,70 +72,74 @@ export class DiagnosticState extends LineBasedState<Severity> {
|
|||||||
this.count = diagnosticCount(update.state);
|
this.count = diagnosticCount(update.state);
|
||||||
|
|
||||||
forEachDiagnostic(update.state, (diagnostic, from, to) => {
|
forEachDiagnostic(update.state, (diagnostic, from, to) => {
|
||||||
// Find the start and end lines for the diagnostic
|
const lineStart = lineNumberAt(lines, from);
|
||||||
const lineStart = this.findLine(from, lines);
|
const lineEnd = lineNumberAt(lines, to);
|
||||||
const lineEnd = this.findLine(to, lines);
|
if (lineStart <= 0 || lineEnd <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Populate each line in the range with the highest severity diagnostic
|
for (let lineNumber = lineStart; lineNumber <= lineEnd; lineNumber++) {
|
||||||
let severity = diagnostic.severity;
|
const spans = lines[lineNumber - 1];
|
||||||
for (let i = lineStart; i <= lineEnd; i++) {
|
if (!spans || spans.length === 0) {
|
||||||
const previous = this.get(i);
|
continue;
|
||||||
if (previous) {
|
|
||||||
severity = [severity, previous]
|
|
||||||
.sort(this.sort.bind(this))
|
|
||||||
.slice(0, 1)[0];
|
|
||||||
}
|
}
|
||||||
this.set(i, severity);
|
|
||||||
|
const length = lineLength(spans);
|
||||||
|
|
||||||
|
const startOffset =
|
||||||
|
lineNumber === lineStart
|
||||||
|
? offsetWithinLine(from, spans)
|
||||||
|
: 0;
|
||||||
|
const endOffset =
|
||||||
|
lineNumber === lineEnd ? offsetWithinLine(to, spans) : length;
|
||||||
|
|
||||||
|
const fromOffset = Math.max(0, Math.min(length, startOffset));
|
||||||
|
let toOffset = Math.max(fromOffset, Math.min(length, endOffset));
|
||||||
|
if (toOffset === fromOffset) {
|
||||||
|
toOffset = Math.min(length, fromOffset + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pushRange(lineNumber, diagnostic.severity, {
|
||||||
|
from: fromOffset,
|
||||||
|
to: toOffset,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.mergeRanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
public drawLine(ctx: DrawContext, lineNumber: number) {
|
public drawLine(ctx: DrawContext, lineNumber: number) {
|
||||||
const { context, lineHeight, offsetX, offsetY } = ctx;
|
const diagnostics = this.get(lineNumber);
|
||||||
const severity = this.get(lineNumber);
|
if (!diagnostics) {
|
||||||
if (!severity) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw the full line width rectangle in the background
|
const { context, lineHeight, charWidth, offsetX, offsetY } = ctx;
|
||||||
context.globalAlpha = 0.65;
|
const color = this.color(diagnostics.severity);
|
||||||
context.beginPath();
|
const snappedY = snapToDevice(offsetY);
|
||||||
context.rect(
|
const snappedHeight =
|
||||||
offsetX,
|
Math.max(MIN_PIXEL_WIDTH, snapToDevice(offsetY + lineHeight) - snappedY) ||
|
||||||
offsetY /* TODO Scaling causes anti-aliasing in rectangles */,
|
MIN_PIXEL_WIDTH;
|
||||||
context.canvas.width - offsetX,
|
|
||||||
lineHeight
|
|
||||||
);
|
|
||||||
context.fillStyle = this.color(severity);
|
|
||||||
context.fill();
|
|
||||||
|
|
||||||
// Draw diagnostic range rectangle in the foreground
|
context.fillStyle = color;
|
||||||
// TODO: We need to update the state to have specific ranges
|
for (const range of diagnostics.ranges) {
|
||||||
// context.globalAlpha = 1;
|
const startX = offsetX + range.from * charWidth;
|
||||||
// context.beginPath();
|
const width = Math.max(
|
||||||
// context.rect(offsetX, offsetY, textWidth, lineHeight);
|
MIN_PIXEL_WIDTH,
|
||||||
// context.fillStyle = this.color(severity);
|
(range.to - range.from) * charWidth
|
||||||
// context.fill();
|
);
|
||||||
}
|
const snappedX = snapToDevice(startX);
|
||||||
|
const snappedWidth =
|
||||||
|
Math.max(MIN_PIXEL_WIDTH, snapToDevice(startX + width) - snappedX) ||
|
||||||
|
MIN_PIXEL_WIDTH;
|
||||||
|
|
||||||
/**
|
context.globalAlpha = 0.65;
|
||||||
* Given a position and a set of line ranges, return
|
context.beginPath();
|
||||||
* the line number the position falls within
|
context.rect(snappedX, snappedY, snappedWidth, snappedHeight);
|
||||||
*/
|
context.fill();
|
||||||
private findLine(pos: number, lines: Lines) {
|
}
|
||||||
const index = lines.findIndex((spans) => {
|
context.globalAlpha = 1;
|
||||||
const start = spans.slice(0, 1)[0];
|
|
||||||
const end = spans.slice(-1)[0];
|
|
||||||
|
|
||||||
if (!start || !end) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return start.from <= pos && pos <= end.to;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Line numbers begin at 1
|
|
||||||
return index + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,12 +154,6 @@ export class DiagnosticState extends LineBasedState<Severity> {
|
|||||||
: "#999";
|
: "#999";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sorts severity from most to least severe */
|
|
||||||
private sort(a: Severity, b: Severity) {
|
|
||||||
return this.score(b) - this.score(a);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assigns a score to severity, with most severe being the highest */
|
|
||||||
private score(s: Severity) {
|
private score(s: Severity) {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case "error": {
|
case "error": {
|
||||||
@@ -160,6 +167,47 @@ export class DiagnosticState extends LineBasedState<Severity> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pushRange(
|
||||||
|
lineNumber: number,
|
||||||
|
severity: Severity,
|
||||||
|
range: DiagnosticRange
|
||||||
|
) {
|
||||||
|
let entry = this.get(lineNumber);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { severity, ranges: [range] };
|
||||||
|
this.set(lineNumber, entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.score(severity) > this.score(entry.severity)) {
|
||||||
|
entry.severity = severity;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.ranges.push(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeRanges() {
|
||||||
|
for (const entry of this.map.values()) {
|
||||||
|
if (entry.ranges.length <= 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.ranges.sort((a, b) => a.from - b.from);
|
||||||
|
const merged: Array<DiagnosticRange> = [];
|
||||||
|
|
||||||
|
for (const range of entry.ranges) {
|
||||||
|
const last = merged[merged.length - 1];
|
||||||
|
if (last && range.from <= last.to) {
|
||||||
|
last.to = Math.max(last.to, range.to);
|
||||||
|
} else {
|
||||||
|
merged.push({ ...range });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.ranges = merged;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function diagnostics(view: EditorView): DiagnosticState {
|
export function diagnostics(view: EditorView): DiagnosticState {
|
||||||
|
|||||||
@@ -1,27 +1,37 @@
|
|||||||
import { Facet } from "@codemirror/state";
|
/**
|
||||||
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
|
* Minimap Extension Entry
|
||||||
import { Overlay } from "./overlay";
|
* Uses block rendering for visible area only
|
||||||
import { Config, Options, Scale } from "./config";
|
*/
|
||||||
import { DiagnosticState, diagnostics } from "./diagnostics";
|
|
||||||
import { SelectionState, selections } from "./selections";
|
import { Facet } from '@codemirror/state';
|
||||||
import { TextState, text } from "./text";
|
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
|
||||||
import { LinesState } from "./linesState";
|
import { Overlay } from './overlay';
|
||||||
import crelt from "crelt";
|
import { Config, Options, Scale } from './config';
|
||||||
import { GUTTER_WIDTH, drawLineGutter } from "./gutters";
|
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({
|
const Theme = EditorView.theme({
|
||||||
"&": {
|
'&': {
|
||||||
height: "100%",
|
height: '100%',
|
||||||
overflowY: "auto",
|
overflowY: 'auto',
|
||||||
},
|
},
|
||||||
"& .cm-minimap-gutter": {
|
'& .cm-minimap-gutter': {
|
||||||
borderRight: 0,
|
borderRight: 0,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
left: "unset",
|
left: 'unset',
|
||||||
position: "sticky",
|
position: 'sticky',
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
},
|
},
|
||||||
|
// 初始化时隐藏,避免宽度未设置时闪烁
|
||||||
|
'& .cm-minimap-initializing': {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
'& .cm-minimap-autohide': {
|
'& .cm-minimap-autohide': {
|
||||||
opacity: 0.0,
|
opacity: 0.0,
|
||||||
transition: 'opacity 0.3s',
|
transition: 'opacity 0.3s',
|
||||||
@@ -29,62 +39,130 @@ const Theme = EditorView.theme({
|
|||||||
'& .cm-minimap-autohide:hover': {
|
'& .cm-minimap-autohide:hover': {
|
||||||
opacity: 1.0,
|
opacity: 1.0,
|
||||||
},
|
},
|
||||||
"& .cm-minimap-inner": {
|
'& .cm-minimap-inner': {
|
||||||
height: "100%",
|
height: '100%',
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
overflowY: "hidden",
|
overflowY: 'hidden',
|
||||||
"& canvas": {
|
'& canvas': {
|
||||||
display: "block",
|
display: 'block',
|
||||||
|
willChange: 'transform, opacity',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"& .cm-minimap-box-shadow": {
|
'& .cm-minimap-box-shadow': {
|
||||||
boxShadow: "12px 0px 20px 5px #6c6c6c",
|
boxShadow: '12px 0px 20px 5px #6c6c6c',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const WIDTH_RATIO = 6;
|
const WIDTH_RATIO = 6;
|
||||||
|
type RenderReason = 'scroll' | 'data';
|
||||||
|
|
||||||
|
// 渲染类型:blocks=块内容变化需要重渲染, overlays=只需重绘选区等覆盖层
|
||||||
|
type RenderType = 'blocks' | 'overlays';
|
||||||
|
|
||||||
const minimapClass = ViewPlugin.fromClass(
|
const minimapClass = ViewPlugin.fromClass(
|
||||||
class {
|
class {
|
||||||
private dom: HTMLElement | undefined;
|
private dom: HTMLElement | undefined;
|
||||||
private inner: HTMLElement | undefined;
|
private inner: HTMLElement | undefined;
|
||||||
private canvas: HTMLCanvasElement | undefined;
|
private canvas: HTMLCanvasElement | undefined;
|
||||||
|
private renderHandle: number | ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private cancelRender: (() => void) | null = null;
|
||||||
|
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 selection: SelectionState;
|
||||||
public diagnostic: DiagnosticState;
|
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) {
|
public constructor(private view: EditorView) {
|
||||||
this.text = text(view);
|
this.blockManager = new BlockManager(view);
|
||||||
this.selection = selections(view);
|
this.selection = selections(view);
|
||||||
this.diagnostic = diagnostics(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)) {
|
if (view.state.facet(showMinimapFacet)) {
|
||||||
this.create(view);
|
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) {
|
private create(view: EditorView) {
|
||||||
const config = view.state.facet(showMinimapFacet);
|
const config = view.state.facet(showMinimapFacet);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw Error("Expected nonnull");
|
throw Error('Expected nonnull');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.inner = crelt("div", { class: "cm-minimap-inner" });
|
this.inner = crelt('div', { class: 'cm-minimap-inner' });
|
||||||
this.canvas = crelt("canvas") as HTMLCanvasElement;
|
this.canvas = crelt('canvas') as HTMLCanvasElement;
|
||||||
|
|
||||||
this.dom = config.create(view).dom;
|
this.dom = config.create(view).dom;
|
||||||
this.dom.classList.add("cm-gutters");
|
this.dom.classList.add('cm-gutters');
|
||||||
this.dom.classList.add("cm-minimap-gutter");
|
this.dom.classList.add('cm-minimap-gutter');
|
||||||
|
this.dom.classList.add('cm-minimap-initializing'); // 初始隐藏
|
||||||
|
|
||||||
this.inner.appendChild(this.canvas);
|
this.inner.appendChild(this.canvas);
|
||||||
this.dom.appendChild(this.inner);
|
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.view.scrollDOM.insertBefore(
|
||||||
this.dom,
|
this.dom,
|
||||||
this.view.contentDOM.nextSibling
|
this.view.contentDOM.nextSibling
|
||||||
@@ -97,35 +175,23 @@ const minimapClass = ViewPlugin.fromClass(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 阻止小地图上的右键菜单
|
|
||||||
this.dom.addEventListener('contextmenu', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 阻止小地图内部元素和画布上的右键菜单
|
|
||||||
this.inner.addEventListener('contextmenu', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.canvas.addEventListener('contextmenu', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (config.autohide) {
|
if (config.autohide) {
|
||||||
this.dom.classList.add('cm-minimap-autohide');
|
this.dom.classList.add('cm-minimap-autohide');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置显示模式
|
||||||
|
this.blockManager.setDisplayText(view.state.facet(Config).displayText);
|
||||||
}
|
}
|
||||||
|
|
||||||
private remove() {
|
private remove() {
|
||||||
|
this.cancelRenderRequest();
|
||||||
if (this.dom) {
|
if (this.dom) {
|
||||||
this.dom.remove();
|
this.dom.remove();
|
||||||
}
|
}
|
||||||
|
this.dom = undefined;
|
||||||
|
this.inner = undefined;
|
||||||
|
this.canvas = undefined;
|
||||||
|
this.hasRenderedOnce = false; // 重置首次渲染标记
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
@@ -142,6 +208,9 @@ const minimapClass = ViewPlugin.fromClass(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (now) {
|
if (now) {
|
||||||
|
let needBlockRender = false;
|
||||||
|
let needOverlayRender = false;
|
||||||
|
|
||||||
if (prev && this.dom && prev.autohide !== now.autohide) {
|
if (prev && this.dom && prev.autohide !== now.autohide) {
|
||||||
if (now.autohide) {
|
if (now.autohide) {
|
||||||
this.dom.classList.add('cm-minimap-autohide');
|
this.dom.classList.add('cm-minimap-autohide');
|
||||||
@@ -150,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.selection.update(update);
|
||||||
this.diagnostic.update(update);
|
this.diagnostic.update(update);
|
||||||
this.render();
|
if (update.selectionSet) {
|
||||||
|
needOverlayRender = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据变化类型决定渲染方式
|
||||||
|
if (needBlockRender) {
|
||||||
|
this.debouncedBlockRender();
|
||||||
|
} else if (needOverlayRender) {
|
||||||
|
this.requestRender('data', 'overlays');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,85 +271,182 @@ const minimapClass = ViewPlugin.fromClass(
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
// If we don't have elements to draw to exit early
|
|
||||||
if (!this.dom || !this.canvas || !this.inner) {
|
if (!this.dom || !this.canvas || !this.inner) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.text.beforeDraw();
|
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.updateBoxShadow();
|
this.updateBoxShadow();
|
||||||
|
|
||||||
this.dom.style.width = this.getWidth() + "px";
|
// Set canvas size
|
||||||
this.canvas.style.maxWidth = this.getWidth() + "px";
|
const width = this.getWidth();
|
||||||
this.canvas.width = this.getWidth() * Scale.PixelMultiplier;
|
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;
|
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.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) {
|
if (!context) {
|
||||||
return;
|
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,
|
context,
|
||||||
lineHeight
|
lineHeight,
|
||||||
|
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++) {
|
for (let i = startIndex; i < endIndex; i++) {
|
||||||
const lines = this.view.state.field(LinesState);
|
|
||||||
if (i >= lines.length) break;
|
if (i >= lines.length) break;
|
||||||
|
|
||||||
const drawContext = {
|
let offsetX = 0;
|
||||||
offsetX: 0,
|
const lineNumber = i + 1;
|
||||||
offsetY,
|
|
||||||
context,
|
|
||||||
lineHeight,
|
|
||||||
charWidth,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// 渲染 gutters
|
||||||
if (gutters.length) {
|
if (gutters.length) {
|
||||||
/* Small leading buffer */
|
offsetX += 2;
|
||||||
drawContext.offsetX += 2;
|
|
||||||
|
|
||||||
for (const gutter of gutters) {
|
for (const gutter of gutters) {
|
||||||
drawLineGutter(gutter, drawContext, i + 1);
|
drawLineGutter(gutter, { offsetX, offsetY, context, lineHeight, charWidth }, lineNumber);
|
||||||
drawContext.offsetX += GUTTER_WIDTH;
|
offsetX += GUTTER_WIDTH;
|
||||||
}
|
}
|
||||||
|
offsetX += 2;
|
||||||
/* Small trailing buffer */
|
|
||||||
drawContext.offsetX += 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.text.drawLine(drawContext, i + 1);
|
// 渲染选区
|
||||||
this.selection.drawLine(drawContext, i + 1);
|
this.selection.drawLine({ offsetX, offsetY, context, lineHeight, charWidth }, lineNumber);
|
||||||
this.diagnostic.drawLine(drawContext, i + 1);
|
|
||||||
|
// 渲染诊断
|
||||||
|
if (this.diagnostic.has(lineNumber)) {
|
||||||
|
this.diagnostic.drawLine({ offsetX, offsetY, context, lineHeight, charWidth }, lineNumber);
|
||||||
|
}
|
||||||
|
|
||||||
offsetY += lineHeight;
|
offsetY += lineHeight;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
context.restore();
|
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.pendingScrollTop === scrollTop
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pendingScrollTop = scrollTop;
|
||||||
|
} else {
|
||||||
|
this.pendingScrollTop = 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') {
|
||||||
|
const handle = requestAnimationFrame(() => {
|
||||||
|
this.renderHandle = null;
|
||||||
|
this.cancelRender = null;
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
this.renderHandle = handle;
|
||||||
|
this.cancelRender = () => cancelAnimationFrame(handle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = setTimeout(() => {
|
||||||
|
this.renderHandle = null;
|
||||||
|
this.cancelRender = null;
|
||||||
|
this.render();
|
||||||
|
}, 16);
|
||||||
|
this.renderHandle = handle;
|
||||||
|
this.cancelRender = () => clearTimeout(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelRenderRequest() {
|
||||||
|
if (!this.cancelRender) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.cancelRender();
|
||||||
|
this.renderHandle = null;
|
||||||
|
this.cancelRender = null;
|
||||||
|
this.pendingScrollTop = null;
|
||||||
|
this.pendingRenderReason = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private canvasStartAndEndIndex(
|
private canvasStartAndEndIndex(
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
lineHeight: number
|
lineHeight: number,
|
||||||
|
scrollTopOverride?: number
|
||||||
) {
|
) {
|
||||||
let { top: pTop, bottom: pBottom } = this.view.documentPadding;
|
let { top: pTop, bottom: pBottom } = this.view.documentPadding;
|
||||||
(pTop /= Scale.SizeRatio), (pBottom /= Scale.SizeRatio);
|
(pTop /= Scale.SizeRatio), (pBottom /= Scale.SizeRatio);
|
||||||
|
|
||||||
const canvasHeight = context.canvas.height;
|
const canvasHeight = context.canvas.height;
|
||||||
const { clientHeight, scrollHeight, scrollTop } = this.view.scrollDOM;
|
const { clientHeight, scrollHeight } = this.view.scrollDOM;
|
||||||
|
const scrollTop = scrollTopOverride ?? this.view.scrollDOM.scrollTop;
|
||||||
let scrollPercent = scrollTop / (scrollHeight - clientHeight);
|
let scrollPercent = scrollTop / (scrollHeight - clientHeight);
|
||||||
if (isNaN(scrollPercent)) {
|
if (isNaN(scrollPercent)) {
|
||||||
scrollPercent = 0;
|
scrollPercent = 0;
|
||||||
@@ -278,20 +479,26 @@ const minimapClass = ViewPlugin.fromClass(
|
|||||||
const { clientWidth, scrollWidth, scrollLeft } = this.view.scrollDOM;
|
const { clientWidth, scrollWidth, scrollLeft } = this.view.scrollDOM;
|
||||||
|
|
||||||
if (clientWidth + scrollLeft < scrollWidth) {
|
if (clientWidth + scrollLeft < scrollWidth) {
|
||||||
this.canvas.classList.add("cm-minimap-box-shadow");
|
this.canvas.classList.add('cm-minimap-box-shadow');
|
||||||
} else {
|
} else {
|
||||||
this.canvas.classList.remove("cm-minimap-box-shadow");
|
this.canvas.classList.remove('cm-minimap-box-shadow');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
if (this.initialRenderDelay) {
|
||||||
|
clearTimeout(this.initialRenderDelay);
|
||||||
|
this.initialRenderDelay = null;
|
||||||
|
}
|
||||||
|
this.cancelDebounce();
|
||||||
|
this.blockManager.destroy();
|
||||||
this.remove();
|
this.remove();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
eventHandlers: {
|
eventHandlers: {
|
||||||
scroll() {
|
scroll() {
|
||||||
requestAnimationFrame(() => this.render());
|
this.requestRender('scroll', 'blocks');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
provide: (plugin) => {
|
provide: (plugin) => {
|
||||||
@@ -307,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
|
* A function that creates the element that contains the minimap
|
||||||
*/
|
*/
|
||||||
@@ -318,43 +524,35 @@ export type MinimapConfig = Omit<Options, "enabled"> & {
|
|||||||
/**
|
/**
|
||||||
* Facet used to show a minimap in the right gutter of the editor using the
|
* Facet used to show a minimap in the right gutter of the editor using the
|
||||||
* provided configuration.
|
* provided configuration.
|
||||||
*
|
|
||||||
* If you return `null`, a minimap will not be shown.
|
|
||||||
*/
|
*/
|
||||||
const showMinimapFacet = Facet.define<MinimapConfig | null, MinimapConfig | null>({
|
const showMinimapFacet = Facet.define<MinimapConfig | null, MinimapConfig | null>({
|
||||||
combine: (c) => c.find((o) => o !== null) ?? null,
|
combine: (c) => c.find((o) => o !== null) ?? null,
|
||||||
enables: (f) => {
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
Config.compute([f], (s) => s.facet(f)),
|
|
||||||
Theme,
|
|
||||||
LinesState,
|
|
||||||
minimapClass, // TODO, codemirror-ify this one better
|
|
||||||
Overlay,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建默认的minimap DOM元素
|
* 创建默认的 minimap DOM 元素
|
||||||
*/
|
*/
|
||||||
const defaultCreateFn = (view: EditorView) => {
|
const defaultCreateFn = (_view: EditorView) => {
|
||||||
const dom = document.createElement('div');
|
const dom = document.createElement('div');
|
||||||
return { dom };
|
return { dom };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加minimap到编辑器
|
* 添加 minimap 到编辑器
|
||||||
* @param options Minimap配置项
|
* @param options Minimap 配置项
|
||||||
* @returns
|
|
||||||
*/
|
*/
|
||||||
export function minimap(options: Partial<Omit<MinimapConfig, 'create'>> = {}) {
|
export function minimap(options: Partial<Omit<MinimapConfig, 'create'>> = {}) {
|
||||||
return showMinimapFacet.of({
|
const config: MinimapConfig = {
|
||||||
create: defaultCreateFn,
|
create: defaultCreateFn,
|
||||||
...options
|
...options,
|
||||||
});
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// 保持原始接口兼容性
|
return [
|
||||||
export { showMinimapFacet as showMinimap };
|
showMinimapFacet.of(config),
|
||||||
|
Config.compute([showMinimapFacet], (s) => s.facet(showMinimapFacet)),
|
||||||
|
Theme,
|
||||||
|
LinesState,
|
||||||
|
minimapClass,
|
||||||
|
Overlay,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
85
frontend/src/views/editor/extensions/minimap/lineGeometry.ts
Normal file
85
frontend/src/views/editor/extensions/minimap/lineGeometry.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Lines } from "./linesState";
|
||||||
|
|
||||||
|
const DEFAULT_LINE_NUMBER = 0;
|
||||||
|
|
||||||
|
function lineBoundary(spans: Lines[number]) {
|
||||||
|
if (!spans || spans.length === 0) {
|
||||||
|
return { start: 0, end: 0 };
|
||||||
|
}
|
||||||
|
const start = spans[0].from;
|
||||||
|
const end = spans[spans.length - 1].to;
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lineNumberAt(lines: Lines, position: number): number {
|
||||||
|
if (!lines.length) {
|
||||||
|
return DEFAULT_LINE_NUMBER;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = lineBoundary(lines[0]);
|
||||||
|
const last = lineBoundary(lines[lines.length - 1]);
|
||||||
|
|
||||||
|
let target = position;
|
||||||
|
if (target < first.start) {
|
||||||
|
target = first.start;
|
||||||
|
} else if (target > last.end) {
|
||||||
|
target = last.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
let low = 0;
|
||||||
|
let high = lines.length - 1;
|
||||||
|
|
||||||
|
while (low <= high) {
|
||||||
|
const mid = (low + high) >> 1;
|
||||||
|
const spans = lines[mid];
|
||||||
|
const { start, end } = lineBoundary(spans);
|
||||||
|
|
||||||
|
if (target < start) {
|
||||||
|
high = mid - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target > end) {
|
||||||
|
low = mid + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mid + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, Math.min(lines.length, low + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lineLength(spans: Lines[number]) {
|
||||||
|
if (!spans) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let length = 0;
|
||||||
|
for (const span of spans) {
|
||||||
|
length += span.folded ? 1 : Math.max(0, span.to - span.from);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function offsetWithinLine(pos: number, spans: Lines[number]) {
|
||||||
|
if (!spans) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const span of spans) {
|
||||||
|
const spanLength = span.folded ? 1 : Math.max(0, span.to - span.from);
|
||||||
|
if (!span.folded && pos < span.to) {
|
||||||
|
return offset + Math.max(0, pos - span.from);
|
||||||
|
}
|
||||||
|
if (span.folded && pos <= span.to) {
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
offset += spanLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ export abstract class LineBasedState<TValue> {
|
|||||||
return this.map.get(lineNumber);
|
return this.map.get(lineNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public has(lineNumber: number): boolean {
|
||||||
|
return this.map.has(lineNumber);
|
||||||
|
}
|
||||||
|
|
||||||
protected set(lineNumber: number, value: TValue) {
|
protected set(lineNumber: number, value: TValue) {
|
||||||
this.map.set(lineNumber, value);
|
this.map.set(lineNumber, value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ function computeLinesState(state: EditorState): Lines {
|
|||||||
const LinesState = StateField.define<Lines>({
|
const LinesState = StateField.define<Lines>({
|
||||||
create: (state) => computeLinesState(state),
|
create: (state) => computeLinesState(state),
|
||||||
update: (current, tr) => {
|
update: (current, tr) => {
|
||||||
|
const prevEnabled = tr.startState.facet(Config).enabled;
|
||||||
|
const nextEnabled = tr.state.facet(Config).enabled;
|
||||||
|
if (prevEnabled !== nextEnabled) {
|
||||||
|
return computeLinesState(tr.state);
|
||||||
|
}
|
||||||
|
|
||||||
if (foldsChanged([tr]) || tr.docChanged) {
|
if (foldsChanged([tr]) || tr.docChanged) {
|
||||||
return computeLinesState(tr.state);
|
return computeLinesState(tr.state);
|
||||||
}
|
}
|
||||||
@@ -93,3 +99,11 @@ function foldsChanged(transactions: readonly Transaction[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { foldsChanged, LinesState };
|
export { foldsChanged, LinesState };
|
||||||
|
|
||||||
|
export function getLinesSnapshot(state: EditorState): Lines {
|
||||||
|
const lines = state.field(LinesState);
|
||||||
|
if (lines.length || !state.facet(Config).enabled) {
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
return computeLinesState(state);
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ const OverlayView = ViewPlugin.fromClass(
|
|||||||
|
|
||||||
private _isDragging: boolean = false;
|
private _isDragging: boolean = false;
|
||||||
private _dragStartY: number | undefined;
|
private _dragStartY: number | undefined;
|
||||||
|
private abortController: AbortController | undefined;
|
||||||
|
private readonly _boundMouseDown = (event: MouseEvent) => this.onMouseDown(event);
|
||||||
|
private readonly _boundMouseUp = (event: MouseEvent) => this.onMouseUp(event);
|
||||||
|
private readonly _boundMouseMove = (event: MouseEvent) => this.onMouseMove(event);
|
||||||
|
|
||||||
public constructor(private view: EditorView) {
|
public constructor(private view: EditorView) {
|
||||||
if (view.state.facet(Config).enabled) {
|
if (view.state.facet(Config).enabled) {
|
||||||
@@ -59,14 +63,19 @@ const OverlayView = ViewPlugin.fromClass(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private create(view: EditorView) {
|
private create(view: EditorView) {
|
||||||
|
this.remove();
|
||||||
|
|
||||||
|
this.abortController = new AbortController();
|
||||||
|
const signal = this.abortController.signal;
|
||||||
|
|
||||||
this.container = crelt("div", { class: "cm-minimap-overlay-container" });
|
this.container = crelt("div", { class: "cm-minimap-overlay-container" });
|
||||||
this.dom = crelt("div", { class: "cm-minimap-overlay" });
|
this.dom = crelt("div", { class: "cm-minimap-overlay" });
|
||||||
this.container.appendChild(this.dom);
|
this.container.appendChild(this.dom);
|
||||||
|
|
||||||
// Attach event listeners for overlay
|
// Attach event listeners for overlay
|
||||||
this.container.addEventListener("mousedown", this.onMouseDown.bind(this));
|
this.container.addEventListener("mousedown", this._boundMouseDown, { signal });
|
||||||
window.addEventListener("mouseup", this.onMouseUp.bind(this));
|
window.addEventListener("mouseup", this._boundMouseUp, { signal });
|
||||||
window.addEventListener("mousemove", this.onMouseMove.bind(this));
|
window.addEventListener("mousemove", this._boundMouseMove, { signal });
|
||||||
|
|
||||||
// Attach the overlay elements to the minimap
|
// Attach the overlay elements to the minimap
|
||||||
const inner = view.dom.querySelector(".cm-minimap-inner");
|
const inner = view.dom.querySelector(".cm-minimap-inner");
|
||||||
@@ -81,11 +90,15 @@ const OverlayView = ViewPlugin.fromClass(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private remove() {
|
private remove() {
|
||||||
|
if (this.abortController) {
|
||||||
|
this.abortController.abort();
|
||||||
|
this.abortController = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.container) {
|
if (this.container) {
|
||||||
this.container.removeEventListener("mousedown", this.onMouseDown);
|
|
||||||
window.removeEventListener("mouseup", this.onMouseUp);
|
|
||||||
window.removeEventListener("mousemove", this.onMouseMove);
|
|
||||||
this.container.remove();
|
this.container.remove();
|
||||||
|
this.container = undefined;
|
||||||
|
this.dom = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
import { LineBasedState } from "./linebasedstate";
|
import { LineBasedState } from "./linebasedstate";
|
||||||
import { EditorView, ViewUpdate } from "@codemirror/view";
|
import { EditorView, ViewUpdate } from "@codemirror/view";
|
||||||
import { LinesState, foldsChanged } from "./linesState";
|
import { EditorState } from "@codemirror/state";
|
||||||
|
import { Lines, foldsChanged, getLinesSnapshot } from "./linesState";
|
||||||
import { DrawContext } from "./types";
|
import { DrawContext } from "./types";
|
||||||
import { Config } from "./config";
|
import { Config } from "./config";
|
||||||
|
import { lineLength, lineNumberAt, offsetWithinLine } from "./lineGeometry";
|
||||||
|
|
||||||
type Selection = { from: number; to: number; extends: boolean };
|
type Selection = { from: number; to: number; extends: boolean };
|
||||||
type DrawInfo = { backgroundColor: string };
|
type DrawInfo = { backgroundColor: string };
|
||||||
|
type RangeInfo = {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
lineFrom: number;
|
||||||
|
lineTo: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_CACHED_LINES = 800;
|
||||||
|
|
||||||
export class SelectionState extends LineBasedState<Array<Selection>> {
|
export class SelectionState extends LineBasedState<Array<Selection>> {
|
||||||
private _drawInfo: DrawInfo | undefined;
|
private _drawInfo: DrawInfo | undefined;
|
||||||
private _themeClasses: string;
|
private _themeClasses: string;
|
||||||
|
private _rangeInfo: Array<RangeInfo> = [];
|
||||||
|
private _linesSnapshot: Lines = [];
|
||||||
|
|
||||||
public constructor(view: EditorView) {
|
public constructor(view: EditorView) {
|
||||||
super(view);
|
super(view);
|
||||||
@@ -52,95 +64,7 @@ export class SelectionState extends LineBasedState<Array<Selection>> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.map.clear();
|
this.rebuild(update.state);
|
||||||
|
|
||||||
/* If class list has changed, clear and recalculate the selection style */
|
|
||||||
if (this._themeClasses !== this.view.dom.classList.value) {
|
|
||||||
this._drawInfo = undefined;
|
|
||||||
this._themeClasses = this.view.dom.classList.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { ranges } = update.state.selection;
|
|
||||||
|
|
||||||
let selectionIndex = 0;
|
|
||||||
for (const [index, line] of update.state.field(LinesState).entries()) {
|
|
||||||
const selections: Array<Selection> = [];
|
|
||||||
|
|
||||||
let offset = 0;
|
|
||||||
for (const span of line) {
|
|
||||||
do {
|
|
||||||
// We've already processed all selections
|
|
||||||
if (selectionIndex >= ranges.length) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The next selection begins after this span
|
|
||||||
if (span.to < ranges[selectionIndex].from) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ignore 0-length selections
|
|
||||||
if (ranges[selectionIndex].from === ranges[selectionIndex].to) {
|
|
||||||
selectionIndex++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the selection for the current span
|
|
||||||
const range = ranges[selectionIndex];
|
|
||||||
const selection = {
|
|
||||||
from: offset + Math.max(span.from, range.from) - span.from,
|
|
||||||
to: offset + Math.min(span.to, range.to) - span.from,
|
|
||||||
extends: range.to > span.to,
|
|
||||||
};
|
|
||||||
|
|
||||||
const lastSelection = selections.slice(-1)[0];
|
|
||||||
if (lastSelection && lastSelection.to === selection.from) {
|
|
||||||
// The selection in this span may just be a continuation of the
|
|
||||||
// selection in the previous span
|
|
||||||
|
|
||||||
// Adjust `to` depending on if we're in a folded span
|
|
||||||
let { to } = selection;
|
|
||||||
if (span.folded && selection.extends) {
|
|
||||||
to = selection.from + 1;
|
|
||||||
} else if (span.folded && !selection.extends) {
|
|
||||||
to = lastSelection.to;
|
|
||||||
}
|
|
||||||
|
|
||||||
selections[selections.length - 1] = {
|
|
||||||
...lastSelection,
|
|
||||||
to,
|
|
||||||
extends: selection.extends,
|
|
||||||
};
|
|
||||||
} else if (!span.folded) {
|
|
||||||
// It's a new selection; if we're not in a folded span we
|
|
||||||
// should push it onto the stack
|
|
||||||
selections.push(selection);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the selection doesn't end in this span, break out of the loop
|
|
||||||
if (selection.extends) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, move to the next selection
|
|
||||||
selectionIndex++;
|
|
||||||
} while (
|
|
||||||
selectionIndex < ranges.length &&
|
|
||||||
span.to >= ranges[selectionIndex].from
|
|
||||||
);
|
|
||||||
|
|
||||||
offset += span.folded ? 1 : span.to - span.from;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we don't have any selections on this line, we don't need to store anything
|
|
||||||
if (selections.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lines are indexed beginning at 1 instead of 0
|
|
||||||
const lineNumber = index + 1;
|
|
||||||
this.map.set(lineNumber, selections);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public drawLine(ctx: DrawContext, lineNumber: number) {
|
public drawLine(ctx: DrawContext, lineNumber: number) {
|
||||||
@@ -151,7 +75,7 @@ export class SelectionState extends LineBasedState<Array<Selection>> {
|
|||||||
offsetX: startOffsetX,
|
offsetX: startOffsetX,
|
||||||
offsetY,
|
offsetY,
|
||||||
} = ctx;
|
} = ctx;
|
||||||
const selections = this.get(lineNumber);
|
const selections = this.ensureSelections(lineNumber);
|
||||||
if (!selections) {
|
if (!selections) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -199,6 +123,123 @@ export class SelectionState extends LineBasedState<Array<Selection>> {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private appendSelection(target: Array<Selection>, entry: Selection) {
|
||||||
|
const last = target[target.length - 1];
|
||||||
|
if (last && entry.from <= last.to) {
|
||||||
|
target[target.length - 1] = {
|
||||||
|
from: Math.min(last.from, entry.from),
|
||||||
|
to: Math.max(last.to, entry.to),
|
||||||
|
extends: entry.extends || last.extends,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.push(entry);
|
||||||
|
}
|
||||||
|
private rebuild(state: EditorState) {
|
||||||
|
if (this._themeClasses !== this.view.dom.classList.value) {
|
||||||
|
this._drawInfo = undefined;
|
||||||
|
this._themeClasses = this.view.dom.classList.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._linesSnapshot = getLinesSnapshot(state);
|
||||||
|
this._rangeInfo = this.buildRangeInfo(state, this._linesSnapshot);
|
||||||
|
this.map.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRangeInfo(state: EditorState, lines: Lines) {
|
||||||
|
const info: Array<RangeInfo> = [];
|
||||||
|
for (const range of state.selection.ranges) {
|
||||||
|
if (range.empty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startLine = lineNumberAt(lines, range.from);
|
||||||
|
const endLine = lineNumberAt(lines, Math.max(range.from, range.to - 1));
|
||||||
|
if (startLine <= 0 || endLine <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
info.push({
|
||||||
|
from: range.from,
|
||||||
|
to: range.to,
|
||||||
|
lineFrom: startLine,
|
||||||
|
lineTo: endLine,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureSelections(lineNumber: number) {
|
||||||
|
const cached = this.get(lineNumber);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const computed = this.buildSelectionsForLine(lineNumber);
|
||||||
|
if (!computed || computed.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.map.has(lineNumber)) {
|
||||||
|
this.map.delete(lineNumber);
|
||||||
|
}
|
||||||
|
this.map.set(lineNumber, computed);
|
||||||
|
while (this.map.size > MAX_CACHED_LINES) {
|
||||||
|
const oldest = this.map.keys().next();
|
||||||
|
if (oldest.done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.map.delete(oldest.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return computed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSelectionsForLine(lineNumber: number) {
|
||||||
|
const spans = this._linesSnapshot[lineNumber - 1];
|
||||||
|
if (!spans || spans.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relevant = this._rangeInfo.filter(
|
||||||
|
(info) => lineNumber >= info.lineFrom && lineNumber <= info.lineTo
|
||||||
|
);
|
||||||
|
if (!relevant.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selections: Array<Selection> = [];
|
||||||
|
for (const range of relevant) {
|
||||||
|
const length = lineLength(spans);
|
||||||
|
const fromOffset =
|
||||||
|
lineNumber === range.lineFrom
|
||||||
|
? offsetWithinLine(range.from, spans)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
let toOffset =
|
||||||
|
lineNumber === range.lineTo
|
||||||
|
? offsetWithinLine(range.to, spans)
|
||||||
|
: length;
|
||||||
|
if (toOffset === fromOffset) {
|
||||||
|
toOffset = Math.min(length, fromOffset + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSpan = spans[spans.length - 1];
|
||||||
|
const spanEnd = lastSpan ? lastSpan.to : range.to;
|
||||||
|
const extendsLine =
|
||||||
|
lineNumber < range.lineTo ||
|
||||||
|
(lineNumber === range.lineTo && range.to > spanEnd);
|
||||||
|
|
||||||
|
this.appendSelection(selections, {
|
||||||
|
from: fromOffset,
|
||||||
|
to: toOffset,
|
||||||
|
extends: extendsLine,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return selections;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selections(view: EditorView): SelectionState {
|
export function selections(view: EditorView): SelectionState {
|
||||||
|
|||||||
@@ -1,415 +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";
|
|
||||||
|
|
||||||
type TagSpan = { text: string; tags: string };
|
|
||||||
type FontInfo = { color: string; font: string; lineHeight: number };
|
|
||||||
|
|
||||||
export class TextState extends LineBasedState<Array<TagSpan>> {
|
|
||||||
private _previousTree: Tree | undefined;
|
|
||||||
private _displayText: Required<Options>["displayText"] | undefined;
|
|
||||||
private _fontInfoMap: Map<string, FontInfo> = new Map();
|
|
||||||
private _themeClasses: Set<string> | undefined;
|
|
||||||
private _highlightingCallbackId: number | NodeJS.Timeout | undefined;
|
|
||||||
|
|
||||||
public constructor(view: EditorView) {
|
|
||||||
super(view);
|
|
||||||
|
|
||||||
this._themeClasses = new Set(Array.from(view.dom.classList));
|
|
||||||
|
|
||||||
if (view.state.facet(Config).enabled) {
|
|
||||||
this.updateImpl(view.state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private shouldUpdate(update: ViewUpdate) {
|
|
||||||
// If the doc changed
|
|
||||||
if (update.docChanged) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If configuration settings changed
|
|
||||||
if (update.state.facet(Config) !== update.startState.facet(Config)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the theme changed
|
|
||||||
if (this.themeChanged()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the folds changed
|
|
||||||
if (foldsChanged(update.transactions)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public update(update: ViewUpdate) {
|
|
||||||
if (!this.shouldUpdate(update)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._highlightingCallbackId) {
|
|
||||||
typeof window.requestIdleCallback !== "undefined"
|
|
||||||
? cancelIdleCallback(this._highlightingCallbackId as number)
|
|
||||||
: clearTimeout(this._highlightingCallbackId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateImpl(update.state, update.changes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateImpl(state: EditorState, changes?: ChangeSet) {
|
|
||||||
this.map.clear();
|
|
||||||
|
|
||||||
/* Store display text setting for rendering */
|
|
||||||
this._displayText = state.facet(Config).displayText;
|
|
||||||
|
|
||||||
/* If class list has changed, clear and recalculate the font info map */
|
|
||||||
if (this.themeChanged()) {
|
|
||||||
this._fontInfoMap.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Incrementally parse the tree based on previous tree + changes */
|
|
||||||
let treeFragments: ReadonlyArray<TreeFragment> | undefined = undefined;
|
|
||||||
if (this._previousTree && changes) {
|
|
||||||
const previousFragments = TreeFragment.addTree(this._previousTree);
|
|
||||||
|
|
||||||
const changedRanges: Array<ChangedRange> = [];
|
|
||||||
changes.iterChangedRanges((fromA, toA, fromB, toB) =>
|
|
||||||
changedRanges.push({ fromA, toA, fromB, toB })
|
|
||||||
);
|
|
||||||
|
|
||||||
treeFragments = TreeFragment.applyChanges(
|
|
||||||
previousFragments,
|
|
||||||
changedRanges
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Parse the document into a lezer tree */
|
|
||||||
const docToString = state.doc.toString();
|
|
||||||
const parser = state.facet(language)?.parser;
|
|
||||||
const tree = parser ? parser.parse(docToString, treeFragments) : undefined;
|
|
||||||
this._previousTree = tree;
|
|
||||||
|
|
||||||
/* Highlight the document, and store the text and tags for each line */
|
|
||||||
const highlighter: Highlighter = {
|
|
||||||
style: (tags) => highlightingFor(state, tags),
|
|
||||||
};
|
|
||||||
|
|
||||||
let highlights: Array<{ from: number; to: number; tags: string }> = [];
|
|
||||||
|
|
||||||
if (tree) {
|
|
||||||
/**
|
|
||||||
* The viewport renders a few extra lines above and below the editor view. To approximate
|
|
||||||
* the lines visible in the minimap, we multiply the lines in the viewport by the scale multipliers.
|
|
||||||
*
|
|
||||||
* Based on the current scroll position, the minimap may show a larger portion of lines above or
|
|
||||||
* below the lines currently in the editor view. On a long document, when the scroll position is
|
|
||||||
* near the top of the document, the minimap will show a small number of lines above the lines
|
|
||||||
* in the editor view, and a large number of lines below the lines in the editor view.
|
|
||||||
*
|
|
||||||
* To approximate this ratio, we can use the viewport scroll percentage
|
|
||||||
*
|
|
||||||
* ┌─────────────────────┐
|
|
||||||
* │ │
|
|
||||||
* │ Extra viewport │
|
|
||||||
* │ buffer │
|
|
||||||
* ├─────────────────────┼───────┐
|
|
||||||
* │ │Minimap│
|
|
||||||
* │ │Gutter │
|
|
||||||
* │ ├───────┤
|
|
||||||
* │ Editor View │Scaled │
|
|
||||||
* │ │View │
|
|
||||||
* │ │Overlay│
|
|
||||||
* │ ├───────┤
|
|
||||||
* │ │ │
|
|
||||||
* │ │ │
|
|
||||||
* ├─────────────────────┼───────┘
|
|
||||||
* │ │
|
|
||||||
* │ Extra viewport │
|
|
||||||
* │ buffer │
|
|
||||||
* └─────────────────────┘
|
|
||||||
*
|
|
||||||
**/
|
|
||||||
|
|
||||||
const vpLineTop = state.doc.lineAt(this.view.viewport.from).number;
|
|
||||||
const vpLineBottom = state.doc.lineAt(this.view.viewport.to).number;
|
|
||||||
const vpLineCount = vpLineBottom - vpLineTop;
|
|
||||||
const vpScroll = vpLineTop / (state.doc.lines - vpLineCount);
|
|
||||||
|
|
||||||
const { SizeRatio, PixelMultiplier } = Scale;
|
|
||||||
const mmLineCount = vpLineCount * SizeRatio * PixelMultiplier;
|
|
||||||
const mmLineRatio = vpScroll * mmLineCount;
|
|
||||||
|
|
||||||
const mmLineTop = Math.max(1, Math.floor(vpLineTop - mmLineRatio));
|
|
||||||
const mmLineBottom = Math.min(
|
|
||||||
vpLineBottom + Math.floor(mmLineCount - mmLineRatio),
|
|
||||||
state.doc.lines
|
|
||||||
);
|
|
||||||
|
|
||||||
// Highlight the in-view lines synchronously
|
|
||||||
highlightTree(
|
|
||||||
tree,
|
|
||||||
highlighter,
|
|
||||||
(from, to, tags) => {
|
|
||||||
highlights.push({ from, to, tags });
|
|
||||||
},
|
|
||||||
state.doc.line(mmLineTop).from,
|
|
||||||
state.doc.line(mmLineBottom).to
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the map
|
|
||||||
this.updateMapImpl(state, highlights);
|
|
||||||
|
|
||||||
// Highlight the entire tree in an idle callback
|
|
||||||
highlights = [];
|
|
||||||
const highlightingCallback = () => {
|
|
||||||
if (tree) {
|
|
||||||
highlightTree(tree, highlighter, (from, to, tags) => {
|
|
||||||
highlights.push({ from, to, tags });
|
|
||||||
});
|
|
||||||
this.updateMapImpl(state, highlights);
|
|
||||||
this._highlightingCallbackId = undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this._highlightingCallbackId =
|
|
||||||
typeof window.requestIdleCallback !== "undefined"
|
|
||||||
? requestIdleCallback(highlightingCallback)
|
|
||||||
: setTimeout(highlightingCallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateMapImpl(
|
|
||||||
state: EditorState,
|
|
||||||
highlights: Array<{ from: number; to: number; tags: string }>
|
|
||||||
) {
|
|
||||||
this.map.clear();
|
|
||||||
|
|
||||||
const docToString = state.doc.toString();
|
|
||||||
const highlightsIterator = highlights.values();
|
|
||||||
let highlightPtr = highlightsIterator.next();
|
|
||||||
|
|
||||||
for (const [index, line] of state.field(LinesState).entries()) {
|
|
||||||
const spans: Array<TagSpan> = [];
|
|
||||||
|
|
||||||
for (const span of line) {
|
|
||||||
// Skip if it's a 0-length span
|
|
||||||
if (span.from === span.to) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append a placeholder for a folded span
|
|
||||||
if (span.folded) {
|
|
||||||
spans.push({ text: "…", tags: "" });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let position = span.from;
|
|
||||||
while (!highlightPtr.done && highlightPtr.value.from < span.to) {
|
|
||||||
const { from, to, tags } = highlightPtr.value;
|
|
||||||
|
|
||||||
// Iterate until our highlight is over the current span
|
|
||||||
if (to < position) {
|
|
||||||
highlightPtr = highlightsIterator.next();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append unstyled text before the highlight begins
|
|
||||||
if (from > position) {
|
|
||||||
spans.push({ text: docToString.slice(position, from), tags: "" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// A highlight may start before and extend beyond the current span
|
|
||||||
const start = Math.max(from, span.from);
|
|
||||||
const end = Math.min(to, span.to);
|
|
||||||
|
|
||||||
// Append the highlighted text
|
|
||||||
spans.push({ text: docToString.slice(start, end), tags });
|
|
||||||
position = end;
|
|
||||||
|
|
||||||
// If the highlight continues beyond this span, break from this loop
|
|
||||||
if (to > end) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, move to the next highlight
|
|
||||||
highlightPtr = highlightsIterator.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there are remaining spans that did not get highlighted, append them unstyled
|
|
||||||
if (position !== span.to) {
|
|
||||||
spans.push({
|
|
||||||
text: docToString.slice(position, span.to),
|
|
||||||
tags: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lines are indexed beginning at 1 instead of 0
|
|
||||||
const lineNumber = index + 1;
|
|
||||||
this.map.set(lineNumber, spans);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public measure(context: CanvasRenderingContext2D): {
|
|
||||||
charWidth: number;
|
|
||||||
lineHeight: number;
|
|
||||||
} {
|
|
||||||
const { color, font, lineHeight } = this.getFontInfo("");
|
|
||||||
|
|
||||||
context.textBaseline = "ideographic";
|
|
||||||
context.fillStyle = color;
|
|
||||||
context.font = font;
|
|
||||||
|
|
||||||
return {
|
|
||||||
charWidth: context.measureText("_").width,
|
|
||||||
lineHeight: lineHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public beforeDraw() {
|
|
||||||
this._fontInfoMap.clear(); // Confirm this worked for theme changes or get rid of it because it's slow
|
|
||||||
}
|
|
||||||
|
|
||||||
public drawLine(ctx: DrawContext, lineNumber: number) {
|
|
||||||
const line = this.get(lineNumber);
|
|
||||||
if (!line) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { context, charWidth, lineHeight, offsetX, offsetY } = ctx;
|
|
||||||
|
|
||||||
let prevInfo: FontInfo | undefined;
|
|
||||||
context.textBaseline = "ideographic";
|
|
||||||
|
|
||||||
for (const span of line) {
|
|
||||||
const info = this.getFontInfo(span.tags);
|
|
||||||
|
|
||||||
if (!prevInfo || prevInfo.color !== info.color) {
|
|
||||||
context.fillStyle = info.color;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!prevInfo || prevInfo.font !== info.font) {
|
|
||||||
context.font = info.font;
|
|
||||||
}
|
|
||||||
|
|
||||||
prevInfo = info;
|
|
||||||
|
|
||||||
lineHeight = Math.max(lineHeight, info.lineHeight);
|
|
||||||
|
|
||||||
switch (this._displayText) {
|
|
||||||
case "characters": {
|
|
||||||
// TODO: `fillText` takes up the majority of profiling time in `render`
|
|
||||||
// Try speeding it up with `drawImage`
|
|
||||||
// https://stackoverflow.com/questions/8237030/html5-canvas-faster-filltext-vs-drawimage/8237081
|
|
||||||
|
|
||||||
context.fillText(span.text, offsetX, offsetY + lineHeight);
|
|
||||||
offsetX += span.text.length * charWidth;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "blocks": {
|
|
||||||
const nonWhitespace = /\S+/g;
|
|
||||||
let start: RegExpExecArray | null;
|
|
||||||
while ((start = nonWhitespace.exec(span.text)) !== null) {
|
|
||||||
const startX = offsetX + start.index * charWidth;
|
|
||||||
let width = (nonWhitespace.lastIndex - start.index) * charWidth;
|
|
||||||
|
|
||||||
// Reached the edge of the minimap
|
|
||||||
if (startX > context.canvas.width) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit width to edge of minimap
|
|
||||||
if (startX + width > context.canvas.width) {
|
|
||||||
width = context.canvas.width - startX;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scaled 2px buffer between lines
|
|
||||||
const yBuffer = 2 / Scale.SizeRatio;
|
|
||||||
const height = lineHeight - yBuffer;
|
|
||||||
|
|
||||||
context.fillStyle = info.color;
|
|
||||||
context.globalAlpha = 0.65; // Make the blocks a bit faded
|
|
||||||
context.beginPath();
|
|
||||||
context.rect(startX, offsetY, width, height);
|
|
||||||
context.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
offsetX += span.text.length * charWidth;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getFontInfo(tags: string): FontInfo {
|
|
||||||
const cached = this._fontInfoMap.get(tags);
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a mock token wrapped in a cm-line
|
|
||||||
const mockToken = crelt("span", { class: tags });
|
|
||||||
const mockLine = crelt(
|
|
||||||
"div",
|
|
||||||
{ class: "cm-line", style: "display: none" },
|
|
||||||
mockToken
|
|
||||||
);
|
|
||||||
this.view.contentDOM.appendChild(mockLine);
|
|
||||||
|
|
||||||
// Get style information and store it
|
|
||||||
const style = window.getComputedStyle(mockToken);
|
|
||||||
const lineHeight = parseFloat(style.lineHeight) / Scale.SizeRatio;
|
|
||||||
const result = {
|
|
||||||
color: style.color,
|
|
||||||
font: `${style.fontStyle} ${style.fontWeight} ${lineHeight}px ${style.fontFamily}`,
|
|
||||||
lineHeight,
|
|
||||||
};
|
|
||||||
this._fontInfoMap.set(tags, result);
|
|
||||||
|
|
||||||
// Clean up and return
|
|
||||||
this.view.contentDOM.removeChild(mockLine);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private themeChanged(): boolean {
|
|
||||||
const previous = this._themeClasses;
|
|
||||||
const now = new Set<string>(Array.from(this.view.dom.classList));
|
|
||||||
this._themeClasses = now;
|
|
||||||
|
|
||||||
if (!previous) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ignore certain classes being added/removed
|
|
||||||
previous.delete("cm-focused");
|
|
||||||
now.delete("cm-focused");
|
|
||||||
|
|
||||||
if (previous.size !== now.size) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let containsAll = true;
|
|
||||||
previous.forEach((theme) => {
|
|
||||||
if (!now.has(theme)) {
|
|
||||||
containsAll = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return !containsAll;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function text(view: EditorView): TextState {
|
|
||||||
return new TextState(view);
|
|
||||||
}
|
|
||||||
@@ -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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -238,10 +238,6 @@ const handleConfigInput = async (
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.settings-page {
|
|
||||||
//max-width: 1000px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.extension-item {
|
.extension-item {
|
||||||
border-bottom: 1px solid var(--settings-input-border);
|
border-bottom: 1px solid var(--settings-input-border);
|
||||||
|
|
||||||
@@ -296,36 +292,38 @@ const handleConfigInput = async (
|
|||||||
|
|
||||||
.extension-config {
|
.extension-config {
|
||||||
background-color: var(--settings-input-bg);
|
background-color: var(--settings-input-bg);
|
||||||
border-left: 3px solid var(--settings-accent);
|
border-left: 2px solid var(--settings-accent);
|
||||||
margin: 8px 0 16px 0;
|
margin: 4px 0 12px 0;
|
||||||
padding: 12px;
|
padding: 8px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 2px;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-header {
|
.config-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-title {
|
.config-title {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: var(--settings-text);
|
color: var(--settings-text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reset-button {
|
.reset-button {
|
||||||
padding: 6px 12px;
|
padding: 3px 8px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
border: 1px solid var(--settings-input-border);
|
border: 1px solid var(--settings-input-border);
|
||||||
border-radius: 4px;
|
border-radius: 2px;
|
||||||
background-color: var(--settings-input-bg);
|
background-color: transparent;
|
||||||
color: var(--settings-text-secondary);
|
color: var(--settings-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.15s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -337,8 +335,7 @@ const handleConfigInput = async (
|
|||||||
|
|
||||||
.config-table-wrapper {
|
.config-table-wrapper {
|
||||||
border: 1px solid var(--settings-input-border);
|
border: 1px solid var(--settings-input-border);
|
||||||
border-radius: 6px;
|
border-radius: 2px;
|
||||||
margin-top: 8px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: var(--settings-panel, var(--settings-input-bg));
|
background-color: var(--settings-panel, var(--settings-input-bg));
|
||||||
}
|
}
|
||||||
@@ -346,7 +343,7 @@ const handleConfigInput = async (
|
|||||||
.config-table {
|
.config-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-table tr + tr {
|
.config-table tr + tr {
|
||||||
@@ -355,38 +352,41 @@ const handleConfigInput = async (
|
|||||||
|
|
||||||
.config-table th,
|
.config-table th,
|
||||||
.config-table td {
|
.config-table td {
|
||||||
padding: 10px 12px;
|
padding: 5px 8px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-table-key {
|
.config-table-key {
|
||||||
width: 36%;
|
width: 30%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: var(--settings-text-secondary);
|
color: var(--settings-text-secondary);
|
||||||
border-right: 1px solid var(--settings-input-border);
|
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 {
|
.config-table-value {
|
||||||
padding: 6px;
|
padding: 3px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-value-input {
|
.config-value-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 10px;
|
padding: 4px 6px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 4px;
|
border-radius: 2px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--settings-text);
|
color: var(--settings-text);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
line-height: 1.4;
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, monospace;
|
||||||
|
line-height: 1.3;
|
||||||
box-sizing: border-box;
|
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 {
|
.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);
|
background-color: var(--settings-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user