架构:四层状态机
@21stware/handymd 的核心不是富文本模型,而是:
文档即 Markdown 源码 + 按光标位置选择性隐藏标记符
每个 Markdown 元素在 Concealed(渲染态)与 Revealed(源码态)之间切换,由 selection 驱动。实现上不为每个元素建状态对象——每次事务后由 (doc, selection, composing, readOnly) 四元组纯函数推导。
| 层 | 代码 |
|---|---|
| L1 | src/editor.ts — HandyEditor |
| L2 | src/ime.ts + src/normalize.ts + src/caret.ts + keymap + filterTransaction |
| L3 | src/conceal/ — hitTest 纯函数 + 按块签名增量 decoration |
| L4 | src/autosave.ts |
L3:Conceal / Reveal
两级语义
- 行内元素(
strong/em/code/strike/mark/link) - hit 区间
[from-1, to+1](扩一格判定) - selection 相交 → Revealed(标记可见、弱化色)
- 离开且非 composition → Concealed(
font-size:0隐藏标记,语义样式保留) - 范围选区只在两个端点处判定:选区覆盖的中间部分保持渲染态(全选 / 拖选不整片回源码)
1.5 图片(image,原子行内元素)
- 源码永远隐藏,只画预览 widget;对它而言 "revealed" = 选区完整覆盖整段源码(选中态)
- imagePlugin 负责原子手感:单击选中、
Backspace/Delete先选中再删、方向键整体跨过;
落进源码内部的光标 / 选区端点被 appendTransaction 推到图片边界
- 块级 permanent(
quote/bullet/todo/hr) - 一旦解析立即渲染,永不因光标进入回到源码
- 标记仍在源码中(序列化无损)
2.5 diagram block( ```mermaid → diagramOpen / diagramLine / diagramClose)
- 结构化解析层就与 code block 分开;三种行共享整块区域作为 hit 区间
- Concealed(光标在区域外)→ 源码整块隐藏(开行折叠成 widget 宿主,体行/闭行零高),渲染图表 widget
- Revealed(光标进入区域 / 点击图表)→ 与普通代码块一致的围栏源码编辑态
- 渲染只发生在 Concealed 态,结果按
(lang, code)缓存;readOnly 强制渲染态
- 标题(特殊)
- 源码
#/##永远隐藏 - 聚焦时 gutter 展示层级图标(非源码)
- 因此不标
permanent,以便参与 reveal 判定驱动图标显隐
- static(
tag/codeLine/ordered序号样式) - 永不参与 reveal
关键转移细节
| 细节 | 实现 |
|---|---|
| 扩一格判定 | hitFrom = from - 1,避免右侧退格闪烁 |
| IME 冻结 | composing 时 apply 只 decorations.map,禁止 hitTest |
| Interactive | Concealed 链接单击打开;Cmd/Ctrl+点击进入编辑 |
| Broken | Revealed 态破坏标记 → 重解析无元素 → decoration 消失 |
| 光标保护 | caretGuardPlugin 把落入隐藏前缀的 caret / 选区两端推到内容起点(handleTextInput 兜底 selectionchange 尚未同步时的快速输入);末尾空格为 hm-caret-pad |
| 源码模式 | ConcealMeta.source:blocks 照常解析(续行等命令要用),decoration 为空,光标保护与前缀相关命令全部让路 |
| 性能 | 行内解析按行文本缓存;纯 selection 移动只重建 reveal 签名变化的块 |
L2:输入管线
| 阶段 | 职责 |
|---|---|
| Composing | decoration 只 map;禁止 conceal/reveal 迁移 |
| Dispatching | keymap / input;filterTransaction 只读拒写 |
| Append | normalizePlugin 修复有序编号(不进 history) |
| Reconciling | parseDoc → hitTest → 增量 Decorate |
Enter 特殊规则:
- 列表/引用(非行首):split + 续前缀
- 列表/引用行首(内容起点、行非空):上方插入空项(待办一律未勾选),当前项原样下移
- 标题行首(内容起点、行非空):上方插空段落,当前行保持
# Title - 标题行中/行末:split,下一行是普通段落(不续
#) - 空前缀行再 Enter:嵌套项先退一级缩进,顶层项清空前缀、退出块格式
- 行内标记中间(
**bo|ld**):左半补闭合、右半补开启标记;紧贴标记内侧时拆分点移到元素外侧 - 未闭合的围栏开行末尾:自动补闭合行(也覆盖"会错配后面代码块闭合行"的情况)
- Shift-Enter:split 但不续前缀
- 表格行(仅源码模式;渲染态下编辑在单元格编辑框内,见下):下方插入同列数空表体行
Backspace / Delete:
- 内容起点 Backspace 去掉块格式(标题、有序列表同样);嵌套列表先退一级;标题上方是空行时先删空行
- 行尾 Delete 合并下一行时丢弃其块前缀;下一行是 hr 则整行删除
- 紧贴表格的行首 Backspace / 行尾 Delete 不把正文并进管道源码:空行直接删除,否则进入相邻单元格
剪贴板(clipboardPlugin):纯文本进出都按 \n 一行一块;外部 HTML 转 Markdown; 带 data-pm-slice 的内部复制走 ProseMirror 默认路径(保留空白)。
表格是与 fence 类似的跨行状态机(tableHeader → tableSep → tableRow*),全部 permanent:
- 表头元素携带整表源码(
attrs.tableSrc),表头行渲染一个<table>widget;分隔行与表体行折叠为零高 - 单元格编辑:点击或选区经由键盘落进表格行时,对应格变为 widget 内的 plaintext 编辑框,
展示该格源码;每次输入把这一格写回对应行的源码区间 → widget 以新源码重建 → 焦点与光标还原到同一格
- widget 的
stopEvent让 ProseMirror 不接管编辑框的事件与 DOM 变更;导航、撤销、IME、粘贴在tableview.ts自行处理 - 编辑期间锁定列宽(按预览态测量),避免展示源码时列宽随输入跳动
L1:生命周期
ReadOnly:L3 全强制 Concealed + 拒写;链接/checkbox 展示仍工作。
editable 是一个读 phase 与 readOnly 的闭包,但 ProseMirror 只在 update 时重新求值它。 EditorView 是在 Loading 里建出来的,所以每次 phase 迁移都要 view.setProps({}) 把 contenteditable 重新同步一遍 —— 否则加载完成的编辑器会一直停在不可编辑态。 setReadOnly 走的是 dispatch,本身就带 update,不需要额外处理。
L4:持久化
序列化:docToMarkdown = 按行拼接 textContent,零成本、无损。
ProseMirror 映射
| 设计概念 | 原语 |
|---|---|
| 文档模型 | 源码保真 schema:doc → block+(一行一块),无 marks |
| 元素范围表 | concealPlugin state:{ blocks, sigs, set } |
| 隐藏标记 | Decoration.inline + .hm-concealed { font-size: 0 } |
| 行首光标垫 | .hm-caret-pad(透明、正常字号) |
| 语义样式 | Decoration.inline / Decoration.node |
| checkbox / 图片 / hr / 标题图标 / 语言徽标 / 图表 | Decoration.widget |
| cursorEnter/Leave | apply(tr) 比较 selection 与 ranges,按块签名增量重建 |
| IME 冻结 | composing 期间只 map;end 后 meta 事务重算 |
| 链接打开 | handleDOMEvents.mousedown |
| 只读锁 | filterTransaction + editable: () => false |
| 撤销重做 | prosemirror-history(decoration 不进 history) |
最重要的架构决策:L3 状态不存对象。一切改动路径(undo/redo、粘贴、协同 patch)只是产生新的 (doc, selection, composing, readOnly) 四元组,结果自动正确。
decoration 的性能约束
DecorationSet.create() 的代价是 O(块数 × decoration 数)。在本项目「一行一个 block」的 扁平文档里 decoration 数正比于块数,所以重建整个 set 就是 O(N²)。因此稳态路径不允许 重建:
| 路径 | 做法 |
|---|---|
| 按键(docChanged) | set.map(tr.mapping, doc) 一次,然后只对脏块 remove + add |
| 移光标 | 只对签名变化的块 remove + add;无变化直接复用整个 state 对象 |
| 首次构建 / readOnly 切换 / IME 解冻 | 才允许 DecorationSet.create() 全量重建 |
「脏块」= 内容变了(contentReusable 为假)或 reveal 签名变了。其余块的 decoration 靠 map 平移即可,位置正确性由 ProseMirror 保证。
两个容易踩的坑:
DecorationSet.create / add / remove会就地把传入数组的元素置为null,所以只能
传函数内部的临时数组,绝不能传状态里缓存的数组。
DecorationSet.find(from, to)用的是闭区间判定,会把端点正好落在边界上的相邻块
node decoration 一起带出来;按块取 decoration 时必须再收窄一次。
test/decoconsistency.test.ts 用「增量结果 vs 全量重建结果」的差分断言守住这套优化, test/perf.test.ts 的 bigdoc_* 用绝对预算守住不退回 O(N²)。
测试约定
修交互 / keymap / L3 decoration bug 时按这个顺序补回归:
- 单元:
test/keymap.test.ts、test/conceal.test.ts、test/hittest.test.ts等能纯推状态的用例 - 差分:涉及 decoration map / 续行 / split 时,加到
test/decoconsistency.test.ts - E2E:必须看见真 DOM、真修饰键或宿主集成时,加到
scripts/e2e.ts(打example/)
本地:bun run test;E2E:bun run dev:sdk 后另开终端 bun run e2e。CI 两者都跑。
已知上限:每个块级样式都是一个 Decoration.node,而 ProseMirror 的 NodeType.valid() 内部走 Fragment.findIndex() 线性扫描,所以 DecorationSet.map() 本身仍是 O(块数 × node decoration 数)。4k 行约 7 ms/键、8k 行以上会明显变慢。要彻底 线性化,需要把块级 class 从 node decoration 改为 nodeViews 承载。