所见即所得编辑器原理实战:源码与渲染永不失真的三层一致性设计(Markdown/CodeMirror 装饰)

所见即所得编辑器原理实战:源码与渲染永不失真的三层一致性设计(Markdown/CodeMirror 装饰)

做 Markdown 所见即所得编辑器,表面需求是"渲染好看",真正的难题是一致性 :用户看到的渲染形态,和磁盘上的 Markdown 源码,必须在任何交互下都不失真。这篇把 MarkPin 在这件事上的设计完整摊开------不是某个 bug 的修复记,而是一套三层一致性体系:视觉层(符号隐没与活动行回显)、几何层(符号零占位)、数据层(复制吸附回源码)。三层里有两层都是被用户报告的 bug 逼出来的。

一、问题定义:一个 **bbb**,能错多少次?

所见即所得的渲染,主流做法(Typora、Obsidian 的 Live Preview)是:源码符号隐没、只留渲染结果;光标所在行回显源码,离开再收回 。MarkPin 用 CodeMirror 6 的 Decoration 体系照此实现:** 等符号包上 .mk-marker 隐没,光标行叠加 .mk-marker-reveal 回显。

看起来简单,实际埋着两个失真源:

  1. 视觉失真 :符号只是 opacity: 0,仍然占布局宽度 。用户报告:链接 URL 直接把版式撑开,带格式内容与正文之间出现莫名间隔。Chrome 实测占位:](长URL) 445.9px 、** 两侧 32.4px、# 28.4px、$ 两侧 19.2px;粗体行首文字起点 22.2px,纯文本行是 6px------整篇文档左边缘参差不齐;
  2. 数据失真 :渲染态下拖选 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 合成事件取证 读数异常一度误判为"双插入"缺陷 合成事件路径的剪贴板读数不能作结论,真实键盘定案

八、三条心得

  1. 所见即所得 = 三层一致性,缺一层都会失真:视觉层(隐没/回显)让用户看到渲染态,几何层(零占位)让渲染态排版正确,数据层(复制吸附/源码保真)让数据离开视图时仍是源码------三层各自独立成灾,也各自独立可测;
  2. 改几何必扫交互面 :一个 font-size: 0 牵连点击、拖选、双击三处定位逻辑。视觉改动的影响面分析,入口是"谁在读几何";
  3. 克制的粒度设计:块级吸附到行不到块、多行引用补哪行是哪行------吸附语义宁可保守,越保守越符合直觉。

如果你也在试 AI 开发鸿蒙,或者想看后续,关注专栏,所有踩坑都会持续更新。

相关推荐
less_121381 小时前
HarmonyOS WPS Open SDK 二开实践:统一接口如何收敛多套打开链路
sdk·harmonyos·wps·鸿蒙开发
ZzT1 小时前
用 Claude 设计 eval,再一轮轮把分数提上去
ai编程·claude
小虎AI生活2 小时前
月活3.82亿的豆包开始帮你打车,说人话办事的时代到了
aigc·ai编程
m0_738185822 小时前
Flutter 鸿蒙化实战:flutter_app_badger 适配 OpenHarmony,应用角标
flutter·华为·harmonyos·鸿蒙
JavaDog程序狗2 小时前
【AI】iPhone18抢不到?我用 Codex 做了个苹果库存监控工具
ai编程
前端冒菜师3 小时前
我为什么做了 Iris,又为什么停下了它
后端·ai编程
不合格的程序员3 小时前
Agent Memory架构设计与实现
后端·ai编程
undsky_3 小时前
【n8n教程】:Set 节点,实现数据转换魔法!
人工智能·ai·aigc·ai编程
袁震3 小时前
HarmonyOS 7 深色模式与全局换肤实战:一套色板管到底
华为·harmonyos