所见即所得编辑器原理实战:源码与渲染永不失真的三层一致性设计(Markdown/CodeMirror 装饰)
做 Markdown 所见即所得编辑器,表面需求是"渲染好看",真正的难题是一致性 :用户看到的渲染形态,和磁盘上的 Markdown 源码,必须在任何交互下都不失真。这篇把 MarkPin 在这件事上的设计完整摊开------不是某个 bug 的修复记,而是一套三层一致性体系:视觉层(符号隐没与活动行回显)、几何层(符号零占位)、数据层(复制吸附回源码)。三层里有两层都是被用户报告的 bug 逼出来的。
一、问题定义:一个 **bbb**,能错多少次?
所见即所得的渲染,主流做法(Typora、Obsidian 的 Live Preview)是:源码符号隐没、只留渲染结果;光标所在行回显源码,离开再收回 。MarkPin 用 CodeMirror 6 的 Decoration 体系照此实现:** 等符号包上 .mk-marker 隐没,光标行叠加 .mk-marker-reveal 回显。
看起来简单,实际埋着两个失真源:
- 视觉失真 :符号只是
opacity: 0,仍然占布局宽度 。用户报告:链接 URL 直接把版式撑开,带格式内容与正文之间出现莫名间隔。Chrome 实测占位:](长URL)445.9px 、**两侧 32.4px、#28.4px、$两侧 19.2px;粗体行首文字起点 22.2px,纯文本行是 6px------整篇文档左边缘参差不齐; - 数据失真 :渲染态下拖选
bbb复制,粘贴出来的是bbb而非**bbb**------用户在渲染视图里复制的内容,格式标记丢了。更糟的组合失真:开关关时复制**bbb**粘贴得**bbb(选区把符号切了一半),开关开时粘贴得**bbb**bbb(格式重复拼接)。
二、分析方法:先量化现象,再拆根因链
两个失真源走了同一条分析路径:先在 Chrome 里加载真实构建产物做几何实测 (符号占位宽度、行首起点差值),再用行为探针拆根因链。
零占位问题实测出占位清单后,定位很直接:.mk-marker { opacity: 0 } 只隐视觉、不撤布局,行内盒模型宽度照算。而项目里其实已有两个先例------引用前缀占位 15.02px 导致软换行错位(当时用 absolute 定向修复)、表格管道符 width: 0------说明这是同一族问题,值得一次性根治。
复制失真的根因链更曲折,拆出来是三个独立问题叠加:
- 问题 A :符号零占位后,隐藏符号与紧随内容的字符边界 x 坐标完全重合 。点击定位函数按"最近字符边界"取值,边界竞争恒选符号起始边界------拖选
bbb实际选区是**b,双击选词落在符号上直接返回空。复制忠实切片,于是**bbb; - 问题 B :带格式复制的 HTML(如
<p>bbb</p>)在应用内粘贴时被判为"无结构"走纯文本回退,与原内容拼接出**bbb**bbb。本质:富文本复制是面向外部应用设计的,从未定义过应用内粘贴的语义; - 问题 C:取证中的"双插入"假象------后证实是 CDP 合成按键路径下剪贴板读数异常,不是产品缺陷(合成事件的剪贴板读数不能作结论,这本身就是个教训)。
三、解决代码(一):几何层------符号零占位
修复是纯 CSS 两行,但每行背后都有兼容核查:
css
/* editor-build/src/render/renderStyles.css(真实代码,节选)
opacity: 0 只隐视觉,符号仍占布局宽度------实测 ](长URL) 段占位 445.9px、
**×2 占位 32.4px、# 占位 28.4px。font-size: 0 归零占位;行框高度由
.cm-content 行距 strut 撑持(实测围栏头部行 22.4 / 代码行 22.9 /
普通行 22.4 / 标题行 42.6 均不变,fence 等高机制存活)。 */
.mk-marker {
opacity: 0;
font-size: 0;
}
/* 活动行回显:符号恢复显示与占位(Live Preview 惯例;
inherit 使标题行回显符号随 mk-h1 字号阶梯缩放) */
.mk-marker-reveal {
opacity: 1;
font-size: inherit;
}
为什么敢用 font-size: 0 这种"狠招"?三个前提核查过:行框高度由行距 strut 撑持,四类行(普通/标题/代码/围栏头部)实测高度全部不变;定向类不受影响(表格管道符竖线是 border、bullet 图形类非回显时不挂 mk-marker、引用前缀走 absolute);replace 型装饰(图片/公式/表格)本就整段替换零占位。零 JS 改动,双栏镜像和导出 HTML 天然跟随。
四、解决代码(二):副作用修复------隐藏字符退出定位竞争
零占位落地的当天,问题 A(选区漂移)就暴露了。修复思路:隐藏字符不可见,就不该参与点击/拖选定位------在 TreeWalker 遍历文本节点时整体排除:
ts
// editor-build/src/mode/modeController.ts posInLineAt(真实代码,节选)
// §3.36 后渲染态符号零占位:mk-marker span 与紧随内容的字符边界 x 完全重合
// ------若参与最近边界竞争,TreeWalker 顺序 + 严格小于会恒选符号起始边界:
// 点击落点漂移进符号、拖选选区含前导符号(复制出 **bbb)、双击选词失效。
function posInLineAt(view, lineEl, clientX, clientY): number | undefined {
const skipSelector: string = MIRROR_SKIP_TEXT_CLASSES.map((cls: string): string => '.' + cls)
.concat(['.mk-marker'])
.join(',');
const walker: TreeWalker = document.createTreeWalker(lineEl, NodeFilter.SHOW_TEXT, {
acceptNode: (node: Node): number => {
const parent: Element | null = node.parentElement;
return parent !== null && parent.closest(skipSelector) !== null
? NodeFilter.FILTER_REJECT // 隐藏符号子树整体拒绝
: NodeFilter.FILTER_ACCEPT;
}
});
// ......逐字符边界取最近 x(活动行回显是独立的 mk-marker-reveal 类,不受影响)
}
与行号/工具区子树的既有 skip 机制同款------同一类问题复用同一套排除范式,这是装饰体系编辑器里很值得沉淀的模式。
五、解决代码(三):数据层------复制吸附 + 源码保真标记
复制失真的修复定义了明确的产品语义(用户拍板):应用内复制粘贴 = 源码保真;跨应用复制 = HTML 格式。实现是两条机制:
ts
// editor-build/src/main.ts richCopyExtension(真实代码,节选)
// ① 选区吸附:渲染态选中的是格式内容(bbb),直接切片得 'bbb' 不含符号------
// 边界落在格式 token 内 → 扩展到完整源码范围(**bbb**);块级(标题/列表/
// 任务/引用)按行级粒度补全(# Comment 拖选得 # Comment)
const docText: string = view.state.doc.toString();
const expanded = expandRangeToTokens(
parseMarkdown(docText),
main.from,
main.to,
docText // 第 4 参传原文,块级行边界由 src 直接推
);
const source: string = view.state.sliceDoc(expanded.from, expanded.to);
// ② 来源标记:HTML 头部携带注释------应用内粘贴识别后直插 text 记录(源码保真);
// 外部应用忽略 HTML 注释按原样式渲染,互不影响
const html: string = RICH_COPY_MARK + renderHtmlPlain(source); // '<!--markpin-->' + 渲染 HTML
if (event.clipboardData !== null) {
event.clipboardData.setData('text/html', html);
event.clipboardData.setData('text/plain', source);
}
吸附扩展是纯函数、分两轮迭代(先行内七类 token、后块级四类行级粒度),34 个单测用例覆盖 ATX/Setext 标题、有序/无序/任务列表、单行/多行引用、跨块、近邻不越界等边界。块级"行级粒度"是个克制的设计:多行引用拖选哪行补哪行 > 前缀,不扩大到整块。
六、验证与效果
零占位:Chrome 真实构建产物复测------全部符号占位归零、带格式行文字起点与纯文本行四方对齐(差值 =6px)、四类行高不变、活动行回显恢复、console 零错误;jsdom 回归 7 套件 215/215 全绿。复制保真:渲染态复制 bbb → 应用内粘贴得 **bbb**(格式保留、源码不失真);拖选标题 → # Comment 完整源码;装机(模拟器)用户真实手势复验通过,34 用例回归无破坏。
七、能力边界表
| 事项 | AI 表现 | 我的结论 |
|---|---|---|
| 符号占位量化取证 | 逐类符号实测出占位清单(445.9px 起) | 先量化再动手,修复方案(font-size:0)有了实测依据才敢上 |
| 零占位副作用预判 | 未预判选区边界竞争失效,当天暴露 | 视觉与几何耦合------改几何必查一切依赖几何的交互(点击/拖选/双击) |
| 复制失真根因拆解 | 三个独立问题一次拆清(A/B/C) | 组合失真别急着修,先拆成独立根因再逐个击破 |
| 产品语义定义 | 提出"应用内=源码保真/跨应用=HTML"两分 | 数据层失真的根治是定义语义,不是打补丁 |
| CDP 合成事件取证 | 读数异常一度误判为"双插入"缺陷 | 合成事件路径的剪贴板读数不能作结论,真实键盘定案 |
八、三条心得
- 所见即所得 = 三层一致性,缺一层都会失真:视觉层(隐没/回显)让用户看到渲染态,几何层(零占位)让渲染态排版正确,数据层(复制吸附/源码保真)让数据离开视图时仍是源码------三层各自独立成灾,也各自独立可测;
- 改几何必扫交互面 :一个
font-size: 0牵连点击、拖选、双击三处定位逻辑。视觉改动的影响面分析,入口是"谁在读几何"; - 克制的粒度设计:块级吸附到行不到块、多行引用补哪行是哪行------吸附语义宁可保守,越保守越符合直觉。
如果你也在试 AI 开发鸿蒙,或者想看后续,关注专栏,所有踩坑都会持续更新。