鸿蒙 Markdown 编辑器表格所见即所得:七个版本的渲染重构(CodeMirror Decoration 实战)

摘要:本文复盘鸿蒙 Markdown 编辑器表格所见即所得功能的七版迭代(v1000037→v1000043)。核心问题在于表格视觉是跨行的整体属性,逐行渲染在架构上即错,因此重构为整块 Widget 渲染;随后主战场转向坐标映射,通过拆分相对/绝对坐标修复了单元格串表、复制错位等缺陷;复制格式经用户实测从 tab 分隔改为 Markdown 源码;最终以三通道装机取证闭环验证。全文提炼出三条心得:渲染形态跟随视觉所有权边界、坐标命名自解释、装机取证不可替代。

上一篇讲到修复流水线,这篇是流水线上打得最久的一场仗:表格的所见即所得。从整块渲染重构到坐标映射修复,构建版本从 v1000037 迭代到 v1000043——七个版本、两轮装机取证、一次架构评估(结论"不需要重构")。这篇按问题拆开讲,代码全部来自真实源码。

一、问题定义:表格所见即所得的四个矛盾

Markdown 表格的源码是无定宽的管道符文本,而用户期望的表格是有列宽、有边框的"表格"。要所见即所得,必须解决四个矛盾:

  1. 列宽:源码里每行长度不同,渲染时同列必须等宽、按内容自适应;
  2. 分隔行|---| 分隔行只在源码里有意义,渲染态不该占一行高度;
  3. 编辑与渲染的切换:光标进表格要能编辑源码,移出要恢复渲染;
  4. 单元格内的行内格式:加粗/斜体/链接在单元格里要正常渲染,且选中复制时输出还能再粘贴回表格。

第一版方案是"逐行渲染":每行分别加装饰。上线后立刻暴露问题——列宽跨行无法协调(每行独立计算,同一列宽度不一致)、分隔行始终占位。结论:表格的视觉是跨行的整体属性,逐行方案在架构上就错了。这就是 v2 重构的起点。

二、重构:整块 Widget 与列宽网格

v2 的核心决策:光标不在表格内时,把整张表用 Decoration.replace 替换成一个自绘 <table> 的 Widget——列宽协调、分隔行零占位、容器级横向滚动全部由这个整体 DOM 承担。列宽算法是纯计算:每列取所有单元格内容宽的最大值,封顶 20 个中文字符,超出换行:

// editor-build/src/render/tableWidget.ts(列宽计算,节选)
// 文本显示宽度估算(em):全角字符(中文/全角标点)= 1.1,
// 英文/数字等半角 = 0.55。列宽自适应与 20 个中文字封顶均按此计量。
const MAX_COLUMN_CHARS = 20;

// 3. 列宽:每列所有单元格(trim 后纯文本长度)最大值 → 封顶 20 字符
const colWidths: number[] = new Array<number>(colCount).fill(0.5);
// ...遍历每行每格,按全角/半角折算 units
if (c < colWidths.length && units > colWidths[c]) {
  colWidths[c] = Math.min(units, MAX_COLUMN_CHARS);
}
// ...按 colWidths 输出 <col>,单元格 word-break 换行

编辑/渲染切换的规则也简单:光标进入表格范围 → 整表回退源码可编辑;移出 → 恢复渲染。但这带来一个新问题:双栏模式的右栏是只读预览,点击落点不是编辑光标,不应该进源码态。解法是把表格装饰 field 参数化——同一套逻辑,两种形态:

// editor-build/src/render/renderPlugin.ts(节选)
export function tableDecorationFieldOf(opts: { mirror: boolean }): StateField<DecorationSet> {
  return StateField.define<DecorationSet>({
    create(state) { return buildTableDecorations(state, opts.mirror); },
    update(deco, tr) {
      if (tr.docChanged || tr.selection) {
        return buildTableDecorations(tr.state, opts.mirror);
      }
      return deco;
    }
  });
}
export const tableDecorationField = tableDecorationFieldOf({ mirror: false });
// 镜像挂 tableDecorationFieldOf({ mirror: true })——跳过光标判定,恒渲染

主编辑器挂 mirror: false(光标判定生效),右栏镜像挂 mirror: true(恒渲染)。同一份代码、参数化两种行为,比复制两套逻辑好维护得多。

三、七版迭代的主战场:坐标映射

表格"渲染态能看"之后,下一场仗是右栏的单元格交互:点击定位、拖选整格高亮、复制。设计思路是把右栏的点击/拖选映射为编辑器的 state 选区(字符级文档位置),高亮与复制都基于这个选区。而它产出了整个表格战役最典型的一次翻车与修复。

现象

v1000037/v1000038 两轮装机反馈:拖选单元格无高亮、复制内容错位、两个结构相同的表格互相串表(在 A 表拖选,B 表高亮了)。

分析定位

按取证流程在装机环境挂上 [tbl-debug] 诊断日志,日志实锤了自相矛盾:单元格挂载的文档区间是 cell=20..28,而整表的真实区间是 88..256——单元格坐标比表格自己的起点还小。根因:widget 在 DOM 上挂数据时,把相对表格的偏移当成了绝对文档位置cell=20 是相对表首的偏移)。选区相交判定全部拿这个错误坐标去比,自然"永远不相交"、复制放行原生、同结构表光标乱串。

修复代码

拆分两种坐标,各自只用在自己的语义里:

// editor-build/src/render/tableWidget.ts(修复后,节选)
let contentFrom: number = t.from;   // 绝对 doc pos——元数据挂载用
let contentTo: number = t.to;
// link:from 含 '[',内容区从 +1 起
if (t.type === 'link') {
  contentFrom = t.from + 1;
  contentTo = t.markerRanges[1].from;
}
// 标记区(相对单元格偏移)——渲染输出时删除
for (const m of t.markerRanges) {
  const s: number = m.from - tableFrom - cellRelFrom;  // 相对坐标:tableFrom + cellRelFrom 换算
  const e: number = m.to - tableFrom - cellRelFrom;
  if (s >= 0 && e <= cellText.length && e > s) {
    skips.push({ start: s, end: e });
  }
}
let relStart: number = contentFrom - tableFrom - cellRelFrom;  // 渲染切片用相对

relCellFrom(相对表格,渲染切片用)与 contentFrom(绝对 doc pos,交互元数据用)彻底分家。修复后专项 7/7、回归 24/24 全绿。这条修复还顺带救了一个静默缺陷:真实文档表格内的行内格式此前因坐标语义错乱而静默丢失。

后续两连修

坐标修完,装机又暴露两个只会在 ArkWeb 出现的问题:点击单元格后光标消失(CM6 的 hasFocus 要求 activeElement 必须是 contentDOM——editable:false 把 tabindex 设在了错误元素上,焦点回归修复);表头高亮被压住(z-index 被运行时注入样式压制,用 !important 提权,hilog + 截图像素 + CDP 三通道取证闭环)。加上前面的坐标修复,正是这七版:37 初版 → 38 诊断 → 39 坐标修复 → 40 表头/选区塌缩 → 41 焦点回归 → 42 z-index 提权 → 43 清洁版。

四、复制的格式抉择:一次被用户实测推翻的决策

右栏拖选单元格后复制,最初的设计输出"单元格内容 tab 分隔"(电子表格惯例)。用户实测:粘贴到别处不成表格。决策推翻,改为输出 Markdown 表格源码(管道符 + 表头分隔行),粘贴到任何 Markdown 编辑器都还是表格:

// editor-build/src/render/tableSelection.ts(节选)
export function buildCopyText(view: EditorView, from: number, to: number): string | null {
  const tables: TableWrapMeta[] = tableWrapsIn(view);
  let hasIntersect = false;
  for (const t of tables) {
    if (from < t.docTo && to > t.docFrom) { hasIntersect = true; break; }
  }
  if (!hasIntersect) return null;   // 选区不与表格相交 → 放行普通复制
  // ...按行收集相交单元格 → 输出 | a | b | 行 + | --- | 分隔行(列数=表头选中格数)
}

这条教训被总结成一句:视觉粒度只服务选中体验,复制以语法完整为准——边界格哪怕只选中半个字,复制也输出整格源码,因为半格源码粘出去是残缺语法。

五、验证与效果

最终状态:整块渲染 jsdom 24/24、表格交互专项 7/7、复制单测 7 场景、十套件回归全绿;装机验证走 hilog 实时抓取 + CDP 断言 + 截图像素统计三通道;七版之后清洁版闭环。用户确认:拖选整格覆盖无缝隙、复制出来是规范 Markdown 表格、光标不再穿透单元格。

六、能力边界表

事项AI 表现我的结论
整块 Widget 架构重构一次成型,列宽算法稳定"跨行视觉属性"用整体 DOM 承担是正确抽象
相对/绝对坐标混用两版未发现,日志取证实锤跨边界坐标必须显式命名区分(rel*/content*/doc*)
ArkWeb 特有缺陷(焦点/z 序)Chrome 无法复现,装机取证才能定位三通道取证(hilog+CDP+像素)是标准动作
复制格式决策(tab vs Markdown)给出利弊,用户实测推翻交互语义类决策必须真实目标环境验证
架构评估(要不要推翻重来)给出"不需要重构"的冷静结论修复期敢于评估架构、更敢于保留架构

七、三条心得

  1. 渲染形态要跟着"视觉所有权的边界"走:表格的视觉是跨行的,逐行装饰从架构上就是错的;改对抽象,后面全顺;
  2. 坐标系统要用命名自解释relCellFromcontentFrom 混用一次,代价是三个版本的范围性 bug——跨边界的数据,命名里就写清楚参照系;
  3. "模拟器/Chrome 正常"不等于完工:表格战役的四个缺陷里三个只在 ArkWeb 出现,装机取证不是可选项。

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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐