CodeMirror 6 代码块渲染踩坑:一个空行毒死全文档(鸿蒙编辑器卡片化/折叠/点击进源码)

CodeMirror 6 代码块渲染踩坑:一个空行毒死全文档(鸿蒙编辑器卡片化/折叠/点击进源码)

所见即所得编辑器里,代码块是最复杂的渲染对象:既要卡片化的视觉(圆角、隔离边距、行号、语言标签),又要折叠和一键复制,还要支持"点击进入源码态编辑"。这篇讲 MarkPin 代码块渲染态改造里的四个代表性问题------每个都按「现象 → 分析定位 → 修复代码 → 验证」展开,其中一个空代码块毒死全文档渲染的案例,堪称本系列最经典的"一行排序引发的血案"。

一、背景与目标

改造前的代码块是"逐行铺背景":相邻两个代码块没有空行隔开时,视觉上连成一片。改造目标四件事:

  1. 卡片化:圆角卡片、与相邻块有隔离边距、带行号与语言标签;
  2. 折叠:点击折叠为「▸ 已折叠 N 行」,且不影响行数/字数统计;
  3. 复制:悬停出现复制按钮,复制块内纯代码(不含围栏与语言标记);
  4. 点击进源码态:预览模式点击渲染态代码块,切换为可编辑源码,光标精确落在点击处。

实现机制延续系列一贯的做法:装饰(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 消失

八、三条心得

  1. 渲染插件的"兜底"要有传染半径意识:异常回退全文档清空装饰,等于让一个块的 bug 变成全文档的 bug------兜底要降级到最小作用域;
  2. 统一排序约束要在数据结构层解决(排序秩),不要靠"注意插入顺序"的约定------约定必然被打破;
  3. 所见即所得的交互闭环里,"精确"是硬指标:光标偏一行这种像素级问题,在编辑器里就是功能性 bug,值得动用接管+确定性映射的重手段。

如果你在做编辑器渲染,或者想看 MarkPin 后续,关注专栏。

相关推荐
威哥爱编程1 小时前
HarmonyOS 6.1 端侧 3DGS 重建实战:重建在 C 层,ArkTS 只管"看"和"改"
华为·harmonyos·arkts
桃西西呀1 小时前
AI 客服为什么翻车:一个客服 Agent 的四类结构性问题
人工智能·llm·ai编程
znnnk1 小时前
【AI应用】从 Prompt 到 Skill:AI 到底“会什么”?
ai·prompt·ai编程·ai应用·skill
小小猪的春天1 小时前
MCP 半小时跑通和敢上生产之间,隔着这 5 个坑
java·ai编程
不会喷火的小火龙2 小时前
我让 GPT-6 做了一池锦鲤
经验分享·ai编程·vibecoding
贾伟康2 小时前
【HarmonyOS 7新能力|014】冷启网络预建链入门实战:从能力边界到最小可运行链路
harmonyos·arkts·启动优化·网络优化·harmonyos 7
不羁的木木2 小时前
给鸿蒙 App 增加广播收发能力 —— flutter_broadcasts 的鸿蒙使用指南
flutter·harmonyos
舒灿2 小时前
全网都在测鹈鹕,我用 DeepSeek-V4.1-Flash 跑了50+小时、超5000MTokens消耗的真实开发任务
chatgpt·ai编程·deepseek
不羁的木木2 小时前
给鸿蒙 App 增加打开外部网页能力 —— flutter_web_browser 的鸿蒙使用指南
前端·flutter·harmonyos