架构:四层状态机
@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/image) - hit 区间
[from-1, to+1](扩一格判定) - selection 相交 → Revealed(标记可见、弱化色)
- 离开且非 composition → Concealed(
font-size:0隐藏标记,语义样式保留)
- 块级 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 推到内容起点;末尾空格为 hm-caret-pad |
| 性能 | 行内解析按行文本缓存;纯 selection 移动只重建 reveal 签名变化的块 |
L2:输入管线
| 阶段 | 职责 |
|---|---|
| Composing | decoration 只 map;禁止 conceal/reveal 迁移 |
| Dispatching | keymap / input;filterTransaction 只读拒写 |
| Append | normalizePlugin 修复有序编号(不进 history) |
| Reconciling | parseDoc → hitTest → 增量 Decorate |
Enter 特殊规则:
- 列表/引用(非行首):split + 续前缀
- 标题行首(内容起点、行非空):上方插空段落,当前行保持
# Title - 标题行中/行末:split,下一行是普通段落(不续
#) - 空前缀行再 Enter:清空前缀,退出块格式
- 表格行:下方插入同列数空表体行(表格请用
insertTable创建,无输入触发)
表格是与 fence 类似的跨行状态机(tableHeader → tableSep → tableRow*);管道符 permanent conceal,分隔行折叠。
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 承载。