Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/core/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,11 @@ export function createEditor(config: EditorConfig): EditorAPI {
if ((update.selectionSet || update.docChanged) && !destroyed) {
const sel = update.state.selection.main;

if (update.selectionSet) {
// 作者:tobegold574(2026/9/17)
// 原因:没有 selectionChange 监听者时跳过 payload 构造(含 ranges
// 数组的逐范围映射)——鼠标拖选时该事件按 mousemove 频率触发,
// 无人监听时这些对象分配纯属浪费。
if (update.selectionSet && emitter.hasListeners("selectionChange")) {
const selection = update.state.selection;
emitter.emit("selectionChange", {
anchor: sel.anchor,
Expand All @@ -677,7 +681,11 @@ export function createEditor(config: EditorConfig): EditorAPI {
});
}

if (slashCommands.length > 0) {
// 作者:tobegold574(2026/9/17)
// 原因:斜杠状态计算会物化整个文档并给命令重新排序;没有
// slashMenuChange 监听者时整个跳过——否则算出来的命令列表
// 直接被丢弃,纯粹浪费。
if (slashCommands.length > 0 && emitter.hasListeners("slashMenuChange")) {
const doc = update.state.doc.toString();
const state = computeSlashState(doc, sel.head, slashCommands, {
limit: config.slashMenuLimit,
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/event-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ export class EventEmitter<EventMap extends { [K in keyof EventMap]: (...args: an
this.listeners.get(event)?.delete(handler);
}

/**
* 作者:tobegold574(2026/9/17)
* 原因:判断某事件是否至少有一个监听者,让派发方在无人监听时跳过 payload
* 构造(省去每次更新的对象+数组分配)——轻量宿主里 selection/slash 菜单
* 事件长期无人监听是常态。内部方法,不进公开 API。
*/
hasListeners<K extends keyof EventMap>(event: K): boolean {
const set = this.listeners.get(event);
return set !== undefined && set.size > 0;
}

emit<K extends keyof EventMap>(event: K, ...args: Parameters<EventMap[K]>): void {
const set = this.listeners.get(event);
if (set) {
Expand Down
82 changes: 69 additions & 13 deletions packages/core/src/live-preview-highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,35 @@ function cacheSet(key: string, value: CodeHighlightToken[]): void {
}
}

// 作者:tobegold574(2026/9/17)
// 原因:二级 LRU,键为 (lang, code, contentStart),缓存已换算成绝对偏移的
// token 数组。文档每次变更重建时 buildDecorations 都会重新请求所有代码块;
// 没有这一层的话,即使一级缓存全命中,每块每次仍要为每个 token 新建对象,
// 仅为做偏移 rebase。未变更的块是常态,其 rebase 数组一直有效——块内容或
// 位置任一变化都会产生新键,自然失效。
const rebasedCache = new Map<string, CodeHighlightToken[]>();

function rebasedCacheGet(key: string): CodeHighlightToken[] | undefined {
// 作者:tobegold574(2026/9/17)
// 原因:与上方一级缓存相同的 LRU 触碰语义——命中即删除再重插以提升
// 活跃度,让热点条目尽量不被淘汰。
const hit = rebasedCache.get(key);
if (!hit) return undefined;
rebasedCache.delete(key);
rebasedCache.set(key, hit);
return hit;
}

function rebasedCacheSet(key: string, value: CodeHighlightToken[]): void {
// 作者:tobegold574(2026/9/17)
// 原因:超限即淘汰 Map 首项(最久未命中的条目),上限与一级缓存一致。
rebasedCache.set(key, value);
if (rebasedCache.size > CACHE_LIMIT) {
const oldest = rebasedCache.keys().next().value;
if (oldest !== undefined) rebasedCache.delete(oldest);
}
}

/**
* Highlight a single fenced code block. `contentStart` is the document offset
* where the code body begins (one past the opening fence's newline). Returns
Expand All @@ -105,12 +134,27 @@ export function highlightCodeBlock(
if (code.length > MAX_BLOCK_LEN) return [];
if (!hljs.getLanguage(lang)) return [];

const key = `${lang}\u0000${code}`;
const cached = cacheGet(key);
// 作者:tobegold574(2026/9/17)
// 原因:先查二级缓存——rebase 键里含 contentStart,命中说明同一块在文档
// 同一位置被重复请求(打字期间未变更块的常态),直接返回已换算好绝对偏移
// 的数组,一级缓存查找与逐 token 重 rebase 全部省掉;contentStart=0 时
// 无需 rebase,天然走一级缓存原数组。
const baseKey = `${lang}\u0000${code}`;
const rebaseKey = contentStart === 0 ? null : `${baseKey}\u0000${contentStart}`;
if (rebaseKey) {
const rebased = rebasedCacheGet(rebaseKey);
if (rebased) return rebased;
}

const cached = cacheGet(baseKey);
if (cached) {
if (contentStart === 0) return cached;
// Cached tokens are stored at contentStart=0; rebase on read.
return cached.map((t) => ({ from: t.from + contentStart, to: t.to + contentStart, className: t.className }));
// 作者:tobegold574(2026/9/17)
// 原因:一级缓存里的 token 以 contentStart=0 存放;读取时 rebase 一份并
// 记入二级缓存,让文档其他位置打字时的重复读取零分配。
const rebased = cached.map((t) => ({ from: t.from + contentStart, to: t.to + contentStart, className: t.className }));
rebasedCacheSet(rebaseKey!, rebased);
return rebased;
}

let result: { _emitter: unknown };
Expand All @@ -122,15 +166,21 @@ export function highlightCodeBlock(

const tokens: CodeHighlightToken[] = [];
emit(result._emitter, 0, tokens);
cacheSet(key, tokens);
cacheSet(baseKey, tokens);
if (contentStart === 0) return tokens;
return tokens.map((t) => ({ from: t.from + contentStart, to: t.to + contentStart, className: t.className }));
// 作者:tobegold574(2026/9/17)
// 原因:冷解析后的首次 rebase 也写入二级缓存——此后相同的
// (lang, code, contentStart) 请求即可零分配复用。
const rebased = tokens.map((t) => ({ from: t.from + contentStart, to: t.to + contentStart, className: t.className }));
rebasedCacheSet(rebaseKey!, rebased);
return rebased;
}

// hljs's internal token stream walker. `_emitter.rootNode` is a TokenTree
// whose leaves are strings (untagged spans) and whose branches carry a
// `kind` (e.g. "string" or "string.regexp"). We accumulate position offsets
// and emit one CodeHighlightToken per leaf string with non-empty scope.
// 作者:tobegold574(2026/9/17)
// 原因:hljs 内部令牌流的遍历器。`_emitter.rootNode` 是 TokenTree:叶子为
// 字符串(无标签片段),分支携带 scope 字段(如 "keyword"、"string"),kind
// 仅作兜底。按偏移累加位置,为每个带非空 scope 的叶子产出一个
// CodeHighlightToken。(原注释写的字段名 kind 与 hljs 实际结构不符,已更正。)
function emit(emitter: unknown, offset: number, out: CodeHighlightToken[]): void {
const root = emitter as { rootNode?: { children?: unknown[] } } | null;
if (!root || !root.rootNode) return;
Expand All @@ -154,9 +204,15 @@ function walkNode(
pos += c.length;
continue;
}
const child = c as { kind?: string; children?: unknown[] };
if (child && child.kind) {
pos = walkNode(child, pos, [...scope, child.kind], out);
const child = c as { kind?: string; scope?: string; children?: unknown[] };
// 作者:tobegold574(2026/9/17)
// 原因:修复存量 bug——hljs 令牌树分支的字段名是 scope(形如
// {children:["const"], scope:"keyword"}),原代码检查的 kind 永远不匹配,
// 导致 emit() 恒产出 0 个 token、代码块从未真正被高亮过。改为优先读
// scope,保留 kind 作为对其他 hljs 构建的兜底。
const scopeName = child && (child.scope || child.kind);
if (child && scopeName) {
pos = walkNode(child, pos, [...scope, scopeName], out);
} else if (child && child.children) {
pos = walkNode(child, pos, scope, out);
}
Expand Down
29 changes: 20 additions & 9 deletions packages/core/src/live-preview-ranges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,10 @@ export function selectionOnSameLine(
});
}

// 作者:tobegold574(2026/9/17)
// 原因:selection 参数在本函数内从未被读取,随"输出与光标无关"的解耦一并移除。
function collectImageRanges(
doc: string,
selection: readonly SelectionRange[]
doc: string
): LivePreviewRange[] {
const ranges: LivePreviewRange[] = [];
const pattern = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g;
Expand Down Expand Up @@ -109,10 +110,11 @@ function shouldSkipInsideWikiLink(node: Content, from: number, to: number, wikiL
);
}

// 作者:tobegold574(2026/9/17)
// 原因:同理移除从未被读取的 selection 参数,递归调用点随之同步收敛。
function visit(
node: Parent | Root,
doc: string,
selection: readonly SelectionRange[],
ranges: LivePreviewRange[],
wikiLinkSpans: readonly [number, number][]
): void {
Expand All @@ -134,7 +136,7 @@ function visit(
ranges.push({ from, to, node: child, source: doc.slice(from, to) });

if ("children" in child && Array.isArray(child.children)) {
visit(child, doc, selection, ranges, wikiLinkSpans);
visit(child, doc, ranges, wikiLinkSpans);
}
continue;
}
Expand All @@ -145,27 +147,36 @@ function visit(
// with color changes — so the heightmap stays perfectly stable.
ranges.push({ from, to, node: child, source: doc.slice(from, to) });
if ("children" in child && Array.isArray(child.children)) {
visit(child as Parent, doc, selection, ranges, wikiLinkSpans);
visit(child as Parent, doc, ranges, wikiLinkSpans);
}
continue;
}

if ("children" in child && Array.isArray(child.children)) {
visit(child, doc, selection, ranges, wikiLinkSpans);
visit(child, doc, ranges, wikiLinkSpans);
}
}
}

/**
* 收集文档中所有 live-preview 候选区间。
*
* 作者:tobegold574(2026/9/17)
* 原因:返回值只由 (ast, doc) 决定——历史上的 selection 参数本就不会被函数体
* 读取(光标感知的显示/编辑切换在 buildDecorations 里做),这里改为可选参数
* 仅为兼容旧调用方。输出与 selection 无关,是 StateField 能在纯光标移动时
* 缓存复用该结果的前提。
*/
export function collectLivePreviewRanges(
ast: Root,
doc: string,
selection: readonly SelectionRange[]
_selection?: readonly SelectionRange[]
): LivePreviewRange[] {
const ranges: LivePreviewRange[] = [];
const wikiLinkSpans = scanWikiLinks(doc).map((link) => [link.from, link.to] as [number, number]);

visit(ast, doc, selection, ranges, wikiLinkSpans);
ranges.push(...collectImageRanges(doc, selection));
visit(ast, doc, ranges, wikiLinkSpans);
ranges.push(...collectImageRanges(doc));

return ranges.sort((left, right) => left.from - right.from);
}
62 changes: 53 additions & 9 deletions packages/core/src/live-preview-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,18 +362,46 @@ function extractCellText(cell: any): string {

function visualLength(text: string): number {
let length = 0;
for (const char of text) {
length += /[\u2e80-\u9fff\uff00-\uffef]/u.test(char) ? 2 : 1;
for (let i = 0; i < text.length; ) {
const code = text.charCodeAt(i);
if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
const next = text.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
// 作者:tobegold574(2026/9/17)
// 原因:增补平面码点(代理对)——原来按码点循环时计 1(非 CJK),
// 这里保持行为完全一致。
length += 1;
i += 2;
continue;
}
}
// 作者:tobegold574(2026/9/17)
// 原因:CJK 区间改用 charCode 区间判断,替代逐字符 RegExp 检测
// (原来每个字符都要走一遍正则对象 + unicode 机制)。
length +=
(code >= 0x2e80 && code <= 0x9fff) || (code >= 0xff00 && code <= 0xffef) ? 2 : 1;
i++;
}
return length;
}

// 作者:tobegold574(2026/9/17)
// 原因:提升为模块常量——String.replace 配 /g 正则每次都从头开始并重置
// lastIndex,共享实例是安全的,省去每次调用的正则求值。
const BR_RE = /<br\s*\/?>/gi;

function estimateCellLineCount(cellSource: string): number {
const normalized = cellSource.replace(/<br\s*\/?>/gi, "\n");
return Math.max(
1,
...normalized.split("\n").map((line) => Math.ceil(visualLength(line.trim()) / TABLE_ESTIMATE_UNITS_PER_LINE))
);
const normalized = cellSource.replace(BR_RE, "\n");
// 作者:tobegold574(2026/9/17)
// 原因:用标量循环取最大值,替代 Math.max(1, ...split().map())——省去中间
// 数组与参数展开(超大单元格内容下参数展开还有栈溢出风险)。
let max = 1;
const lines = normalized.split("\n");
for (const line of lines) {
const count = Math.ceil(visualLength(line.trim()) / TABLE_ESTIMATE_UNITS_PER_LINE);
if (count > max) max = count;
}
return max;
}

function estimateTableHeight(source: string): number {
Expand All @@ -382,7 +410,14 @@ function estimateTableHeight(source: string): number {
if (SEPARATOR_RE.test(line)) continue;
const parts = line.split("|");
const cells = parts.length > 2 ? parts.slice(1, -1) : parts;
const lineCount = Math.max(1, ...cells.map(estimateCellLineCount));
// 作者:tobegold574(2026/9/17)
// 原因:同 estimateCellLineCount——Math.max(1, ...cells.map(...)) 的参数
// 展开改为标量循环,省去中间数组且对大表栈安全。
let lineCount = 1;
for (const cell of cells) {
const count = estimateCellLineCount(cell);
if (count > lineCount) lineCount = count;
}
const rowHeight = TABLE_BASE_ROW_HEIGHT + (lineCount - 1) * TABLE_EXTRA_LINE_HEIGHT;
height += Math.min(TABLE_MAX_ESTIMATED_ROW_HEIGHT, rowHeight);
}
Expand Down Expand Up @@ -564,6 +599,9 @@ export class EditableTableWidget extends WidgetType {
private editing = false;
private reusable = true;
private cleanupEditingLocks: (() => void) | null = null;
// 作者:tobegold574(2026/9/17)
// 原因:estimatedHeight 的 memo 槽,null 表示尚未计算(见下方 getter)。
private estimatedHeightCache: number | null = null;

constructor(
private node: Table,
Expand All @@ -589,7 +627,13 @@ export class EditableTableWidget extends WidgetType {
get estimatedHeight(): number {
// 长表格里大量中文/链接会换行,固定 32px/行会严重低估高度。
// 底部单元格编辑后 CM6 可能按低估 heightmap 把 widget 判出 viewport,导致 TD 被卸载失焦。
return estimateTableHeight(this.source);
// 作者:tobegold574(2026/9/17)
// 原因:source 在 widget 实例内不可变,故对估算值做 memo——CM6 每次高度图
// 刷新都会读这个 getter,而估算本身要重新解析整张表源码。
if (this.estimatedHeightCache === null) {
this.estimatedHeightCache = estimateTableHeight(this.source);
}
return this.estimatedHeightCache;
}

private dispatch(newSource: string): void {
Expand Down
Loading