handymd GitHub

架构:四层状态机

@21stware/handymd 的核心不是富文本模型,而是:

文档即 Markdown 源码 + 按光标位置选择性隐藏标记符

每个 Markdown 元素在 Concealed(渲染态)与 Revealed(源码态)之间切换,由 selection 驱动。实现上不为每个元素建状态对象——每次事务后由 (doc, selection, composing, readOnly) 四元组纯函数推导。

flowchart TB subgraph L1["L1 编辑器生命周期(全局)"] A[Loading / Ready / Error] end subgraph L2["L2 输入事务管线(每次按键/粘贴)"] B[Idle → Composing → Dispatching → Reconciling] end subgraph L3["L3 元素渲染状态"] C[Concealed ⇄ Revealed / permanent] end subgraph L4["L4 持久化"] D[Clean → Dirty → Saving] end L1 -->|ready 后挂载 EditorView| L2 L2 -->|tr.selection / tr.docChanged| C L2 -->|tr.docChanged| D
代码
L1src/editor.tsHandyEditor
L2src/ime.ts + src/normalize.ts + src/caret.ts + keymap + filterTransaction
L3src/conceal/ — hitTest 纯函数 + 按块签名增量 decoration
L4src/autosave.ts

L3:Conceal / Reveal

两级语义

  1. 行内元素strong / em / code / strike / mark / link / image
  2. hit 区间 [from-1, to+1](扩一格判定)
  3. selection 相交 → Revealed(标记可见、弱化色)
  4. 离开且非 composition → Concealed(font-size:0 隐藏标记,语义样式保留)
  1. 块级 permanentquote / bullet / todo / hr
  2. 一旦解析立即渲染,永不因光标进入回到源码
  3. 标记仍在源码中(序列化无损)

2.5 diagram block ```mermaid diagramOpen / diagramLine / diagramClose

  • 结构化解析层就与 code block 分开;三种行共享整块区域作为 hit 区间
  • Concealed(光标在区域外)→ 源码整块隐藏(开行折叠成 widget 宿主,体行/闭行零高),渲染图表 widget
  • Revealed(光标进入区域 / 点击图表)→ 与普通代码块一致的围栏源码编辑态
  • 渲染只发生在 Concealed 态,结果按 (lang, code) 缓存;readOnly 强制渲染态
  1. 标题(特殊)
  2. 源码 #/## 永远隐藏
  3. 聚焦时 gutter 展示层级图标(非源码)
  4. 因此permanent,以便参与 reveal 判定驱动图标显隐
  1. statictag / codeLine / ordered 序号样式)
  2. 永不参与 reveal

关键转移细节

细节实现
扩一格判定hitFrom = from - 1,避免右侧退格闪烁
IME 冻结composingapplydecorations.map,禁止 hitTest
InteractiveConcealed 链接单击打开;Cmd/Ctrl+点击进入编辑
BrokenRevealed 态破坏标记 → 重解析无元素 → decoration 消失
光标保护caretGuardPlugin 把落入隐藏前缀的 caret 推到内容起点;末尾空格为 hm-caret-pad
性能行内解析按行文本缓存;纯 selection 移动只重建 reveal 签名变化的块
stateDiagram-v2 [*] --> Concealed : 解析出元素 Concealed --> Revealed : cursorEnter\n(selection ∩ hitRange) Revealed --> Concealed : cursorLeave\n(!composing) note right of Concealed permanent / static 永不离开 Concealed heading 的"Revealed"只控制层级图标 end note

L2:输入管线

stateDiagram-v2 [*] --> Idle Idle --> Composing : compositionstart Idle --> Dispatching : beforeinput / paste / keymap Composing --> Dispatching : compositionend Dispatching --> Reconciling : dispatch(tr) Reconciling --> Idle : view 更新完成
阶段职责
Composingdecoration 只 map;禁止 conceal/reveal 迁移
Dispatchingkeymap / input;filterTransaction 只读拒写
AppendnormalizePlugin 修复有序编号(不进 history)
ReconcilingparseDoc → hitTest → 增量 Decorate

Enter 特殊规则:

  • 列表/引用(非行首):split + 续前缀
  • 标题行首(内容起点、行非空):上方插空段落,当前行保持 # Title
  • 标题行中/行末:split,下一行是普通段落(不续 #
  • 空前缀行再 Enter:清空前缀,退出块格式
  • 表格行:下方插入同列数空表体行(表格请用 insertTable 创建,无输入触发)

表格是与 fence 类似的跨行状态机(tableHeadertableSeptableRow*);管道符 permanent conceal,分隔行折叠。


L1:生命周期

stateDiagram-v2 [*] --> Loading Loading --> Ready : load OK → EditorView Loading --> Error : load 失败 Error --> Loading : retry() state Ready { [*] --> Editable Editable --> ReadOnly : setReadOnly(true) ReadOnly --> Editable : setReadOnly(false) } Ready --> Conflicted : notifyRemote + 本地 dirty Conflicted --> Ready : resolveConflict Ready --> [*] : destroy(flush 后)

ReadOnly:L3 全强制 Concealed + 拒写;链接/checkbox 展示仍工作。

editable 是一个读 phasereadOnly 的闭包,但 ProseMirror 只在 update 时重新求值它。 EditorView 是在 Loading 里建出来的,所以每次 phase 迁移都要 view.setProps({})contenteditable 重新同步一遍 —— 否则加载完成的编辑器会一直停在不可编辑态。 setReadOnly 走的是 dispatch,本身就带 update,不需要额外处理。


L4:持久化

stateDiagram-v2 [*] --> Clean Clean --> Dirty : docChanged Dirty --> Dirty : 继续输入(重置防抖) Dirty --> Saving : 防抖到期 / flush / blur / ⌘S Saving --> Clean : OK 且无新输入 Saving --> Saving : 保存期间又有输入 → 完成后立即再存 Saving --> Retrying : 失败 Retrying --> Saving : 指数退避 Retrying --> Offline : 超过 maxRetries Offline --> Saving : online / retryNow

序列化: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/Leaveapply(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.tsbigdoc_* 用绝对预算守住不退回 O(N²)。

测试约定

修交互 / keymap / L3 decoration bug 时按这个顺序补回归:

  1. 单元test/keymap.test.tstest/conceal.test.tstest/hittest.test.ts 等能纯推状态的用例
  2. 差分:涉及 decoration map / 续行 / split 时,加到 test/decoconsistency.test.ts
  3. 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 承载。