CodeMirror 6 代码块渲染踩坑:一个空行毒死全文档(鸿蒙编辑器卡片化/折叠/点击进源码)
所见即所得编辑器里,代码块是最复杂的渲染对象:既要卡片化的视觉(圆角、隔离边距、行号、语言标签),又要折叠和一键复制,还要支持"点击进入源码态编辑"。这篇讲 MarkPin 代码块渲染态改造里的四个代表性问题------每个都按「现象 → 分析定位 → 修复代码 → 验证」展开,其中一个空代码块毒死全文档渲染的案例,堪称本系列最经典的"一行排序引发的血案"。
一、背景与目标
改造前的代码块是"逐行铺背景":相邻两个代码块没有空行隔开时,视觉上连成一片。改造目标四件事:
- 卡片化:圆角卡片、与相邻块有隔离边距、带行号与语言标签;
- 折叠:点击折叠为「▸ 已折叠 N 行」,且不影响行数/字数统计;
- 复制:悬停出现复制按钮,复制块内纯代码(不含围栏与语言标记);
- 点击进源码态:预览模式点击渲染态代码块,切换为可编辑源码,光标精确落在点击处。
实现机制延续系列一贯的做法:装饰(Decoration)只改视觉,源码零改动------折叠、卡片、行号全部是装饰层的事,文档字节永远不动,所以行数/字数/大纲天然不受影响。
二、坑一:一个空首行,毒死全文档
现象
用户报告:某个代码块首行是空的 时,不只是这个块渲染残缺,全文档所有代码块的渲染全坏。
分析定位
这不是渲染逻辑错了,是装饰构建抛了异常 。代码块的装饰有严格的添加顺序要求:CodeMirror 的 RangeSetBuilder 要求同一位置的装饰按 startSide 升序添加(行装饰 → side 负数 widget → side 正数 widget)。旧代码构建装饰数组后只按位置排序(from → to),稳定排序保持插入顺序------当首内容行是空行时 ,语言标签工具区(side 为正,位置相同)恰好被排到了行装饰前面,顺序违反约束,builder 直接抛异常。而渲染插件有个兜底:构建异常时回退为"无装饰"------于是一个块的异常传染成全文档裸奔。
修复代码
给装饰加"秩"(rank),对齐 CodeMirror 的 startSide 语义,排序比较器从两维变三维:
ts
// editor-build/src/render/renderPlugin.ts(真实代码,节选)
// §3.20.2 排序秩:RangeSetBuilder 要求同 from 位置按 startSide 升序添加
// (line deco < widget side -1 < side +1/replace)。
// 旧排序只看 from/to:首内容行为空行时 push 在前的 toolbar 稳定排序后
// 仍排行装饰前 → builder 抛异常 → 兜底清空全文档装饰(一个空首行毒死全篇)。
const RANK_LINE = 0;
const RANK_SIDE_NEG1 = 2;
const RANK_SIDE_POS1 = 3;
// 排序:from → rank(startSide 升序)→ to
decorations.sort((a, b) => a.from - b.from || a.rank - b.rank || a.to - b.to);
const builder = new RangeSetBuilder<Decoration>();
for (const d of decorations) builder.add(d.from, d.to, d.deco);
验证
空代码块、首行空代码块、多块共存三个场景全绿,console 零错误;顺带补齐了空代码块的完整卡片渲染(此前空块走的是"裸条"降级路径)。
三、坑二:折叠的两个"高度"陷阱
折叠的原理:第 2..N 内容行逐行替换为空(Decoration.replace),插一个「▸ 已折叠 N 行」标记决定行高。但落地时踩了两个高度陷阱:
- 陷阱一(0.5px) :渲染态头部行比编辑态高 0.5px。根因是 CodeMirror 会在行内 widget 相邻处自动插入一个
cm-widgetBuffer元素(带 vertical-align),恰好撑高行框。修复是在渲染态作用域内把它display:none------等高战争以 22.4px = 22.4px 告终; - 陷阱二(折叠后卡片不收缩):折叠后内容消失了,卡片区域大小却不变。根因是行级 CSS 只压掉了 span 内容,没压掉行框自身的 strut(行距撑起的最小高度)------每行残留约 21px。修复是行级类直接把字号与行高压到零:
ts
// editor-build/src/render/renderPlugin.ts(折叠分支,节选,真实代码)
for (let ln = firstContent; ln <= lastContent; ln++) {
const line = doc.line(ln);
// §3.20.4 教训延续:行级 font-size/line-height 0 压掉行框 strut
decorations.push({
from: line.from,
to: line.from,
deco: Decoration.line({ class: 'mk-code-line mk-code-fold-zero' }),
rank: RANK_LINE
});
// 再 replace 行内容(不含行末换行符,避免跨行约束)
}
配套 CSS 一行:.mk-code-fold-zero { font-size: 0; line-height: 0 }。修复后折叠态卡片高度精确等于"首行 + 标记行",展开还原零残留。教训沉淀:压高度要压"行框"而不是只压"内容"------CSS 行盒模型的 strut 会替你保留最小高度。
四、坑三:点击进源码态,光标总偏一行
现象
预览模式点击渲染态代码块进入源码态编辑,光标落点系统性偏移一行 (点空行落下一行)。这个现象在普通文本行也存在(原生 posAtCoords 的既有偏差),但代码块区域因为有零高围栏行,偏移更严重。
分析定位
排查结论:渲染态布局下 CodeMirror 原生坐标换算(posAtCoords)的落点解析不可信。既然不可信,就不修它、绕开它------预览模式主编辑器的所有点击统一接管,用确定性映射计算光标位置:行命中用几何区间判定,字符位置用行内文本节点遍历测量(TreeWalker + getClientRects),只有视口边缘等极端情况放行原生兜底。
修复代码
源码态本身用 StateField 管理:点击命中代码块 → 派发 effect 加入源码态集合;光标离开块范围自动恢复渲染态(Obsidian 式),且集合随文档编辑自动映射位置:
ts
// editor-build/src/render/renderPlugin.ts(节选,真实代码)
export const codeSourceEditField = StateField.define<CodeFoldRange[]>({
update(value, tr) {
let next = value.map(r => ({ // 文档变更时映射区间
from: tr.changes.mapPos(r.from, -1),
to: tr.changes.mapPos(r.to, 1)
}));
for (const e of tr.effects) {
if (e.is(setCodeSourceEdit) && !next.some(r => r.from === e.value.from && r.to === e.value.to)) {
next = next.concat([e.value]);
}
}
// 光标离开块范围 → 恢复渲染态(主规则)
if (tr.selection !== undefined) {
const head = tr.state.selection.main.head;
next = next.filter(r => head >= r.from && head <= r.to);
}
return next;
}
});
// 点击入口判定:pos 归属哪个代码块 token
export function findCodeBlockTokenAt(state: EditorState, pos: number): CodeFoldRange | undefined {
for (const t of getTokens(state)) {
if (t.type === 'code_block' && pos >= t.from && pos <= t.to) {
return { from: t.from, to: t.to };
}
}
return undefined;
}
进入源码态时还要做一次视口稳定补偿(点击瞬间围栏行从零高变整行高,页面会跳)------进入/退出各做一次帧末补偿,实测位移从半行级压到 1px 量级。
五、坑四:选区高亮被卡片底色"吃掉"
卡片底色最初画在行级伪元素上(z 序在选区层之上),代码块内拖选文字看不到高亮。修复是把卡片底色迁移到 CodeMirror 的 layer 体系:新建一层专画卡片矩形,z 序排到选区层之下 (-3 < -2),选区高亮自然盖在卡片上;层容器 pointer-events:none 不拦截点击。同一套 layer 后面又承担了头部行、底边框的绘制------视觉层与交互层分离后,"谁画在哪层"变成显式声明。
顺带把高亮语言也升了级:highlight.js 从手动注册 51 种改为全量 193 种语言注册,别名(arkts/ets/ts/c++ 等)内置识别,语言标签显示官方名(ts → TypeScript),配色对齐 VS Code 官方色板并适配浅色/深色/护眼三主题。
六、验证与效果
验证组合:jsdom 套件(装饰结构断言)+ Chrome 行为验证(点击映射、折叠往返、复制内容)+ 装机 CDP 断言(真 ArkWeb 内 JS 断言 + 系统剪贴板闭环:复制后真按 Ctrl+V,文档增量恰为纯代码)+ 像素级 diff(悬停按钮显隐的截屏对比中,全屏唯一差异簇就是按钮显现区)。最终用户确认四目标全部达成,且源码模式零影响。
七、能力边界表
| 事项 | AI 表现 | 我的结论 |
|---|---|---|
| 卡片化/折叠/复制的机制设计 | 一次成型,装饰方案干净 | "装饰只改视觉、源码零改动"是正确的第一性原则 |
| 排序约束(startSide) | 首版遗漏,异常后快速定位 | 兜底逻辑(异常回退空集)会放大局部错误,设计时要考虑传染半径 |
| CSS 行盒模型(strut) | 两次踩坑(0.5px/不收缩) | 压高度必须压行框;widget 缓冲元素是隐形变量 |
| 坐标换算可信度 | 识别出 posAtCoords 系统性偏差并绕开 | 不可信的基元就别修它,用确定性映射接管入口 |
| 层序管理 | 迁移到显式 layer 体系 | 视觉分层显式化后,遮挡类 bug 消失 |
八、三条心得
- 渲染插件的"兜底"要有传染半径意识:异常回退全文档清空装饰,等于让一个块的 bug 变成全文档的 bug------兜底要降级到最小作用域;
- 统一排序约束要在数据结构层解决(排序秩),不要靠"注意插入顺序"的约定------约定必然被打破;
- 所见即所得的交互闭环里,"精确"是硬指标:光标偏一行这种像素级问题,在编辑器里就是功能性 bug,值得动用接管+确定性映射的重手段。
如果你在做编辑器渲染,或者想看 MarkPin 后续,关注专栏。