鸿蒙 Markdown 编辑器表格所见即所得:七个版本的渲染重构(CodeMirror Decoration 实战)
上一篇讲到修复流水线,这篇是流水线上打得最久的一场仗:表格的所见即所得。从整块渲染重构到坐标映射修复,构建版本从 v1000037 迭代到 v1000043------七个版本、两轮装机取证、一次架构评估(结论"不需要重构")。这篇按问题拆开讲,代码全部来自真实源码。
一、问题定义:表格所见即所得的四个矛盾
Markdown 表格的源码是无定宽的管道符文本,而用户期望的表格是有列宽、有边框的"表格"。要所见即所得,必须解决四个矛盾:
- 列宽:源码里每行长度不同,渲染时同列必须等宽、按内容自适应;
- 分隔行 :
|---|分隔行只在源码里有意义,渲染态不该占一行高度; - 编辑与渲染的切换:光标进表格要能编辑源码,移出要恢复渲染;
- 单元格内的行内格式:加粗/斜体/链接在单元格里要正常渲染,且选中复制时输出还能再粘贴回表格。
第一版方案是"逐行渲染":每行分别加装饰。上线后立刻暴露问题------列宽跨行无法协调(每行独立计算,同一列宽度不一致)、分隔行始终占位。结论:表格的视觉是跨行的整体属性,逐行方案在架构上就错了。这就是 v2 重构的起点。
二、重构:整块 Widget 与列宽网格
v2 的核心决策:光标不在表格内时,把整张表用 Decoration.replace 替换成一个自绘 <table> 的 Widget------列宽协调、分隔行零占位、容器级横向滚动全部由这个整体 DOM 承担。列宽算法是纯计算:每列取所有单元格内容宽的最大值,封顶 20 个中文字符,超出换行:
ts
// 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 参数化------同一套逻辑,两种形态:
ts
// 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 是相对表首的偏移)。选区相交判定全部拿这个错误坐标去比,自然"永远不相交"、复制放行原生、同结构表光标乱串。
修复代码
拆分两种坐标,各自只用在自己的语义里:
ts
// 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 编辑器都还是表格:
ts
// 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) | 给出利弊,用户实测推翻 | 交互语义类决策必须真实目标环境验证 |
| 架构评估(要不要推翻重来) | 给出"不需要重构"的冷静结论 | 修复期敢于评估架构、更敢于保留架构 |
七、三条心得
- 渲染形态要跟着"视觉所有权的边界"走:表格的视觉是跨行的,逐行装饰从架构上就是错的;改对抽象,后面全顺;
- 坐标系统要用命名自解释 :
relCellFrom和contentFrom混用一次,代价是三个版本的范围性 bug------跨边界的数据,命名里就写清楚参照系; - "模拟器/Chrome 正常"不等于完工:表格战役的四个缺陷里三个只在 ArkWeb 出现,装机取证不是可选项。
如果你在做编辑器渲染,或者想看 MarkPin 后续,关注专栏。