返回随笔工程笔记 · tMarkdown

从扁平列表到块树:tMarkdown 编辑器的一次重构

tMarkdown 最初用一个扁平块列表加两个深度字段来表达文档。它跑得很快,但每遇到一种新的嵌套组合,就要打一个补丁。

扁平列表的天花板

扁平模型的问题不在于性能,而在于语义。引用里套列表、列表里再套引用,这些在 Markdown 里完全合法的结构,一旦被拍平成“块 + depth”,编辑器就必须在每次回车、缩进、删除时推断边界。推断逻辑会随着组合数量指数增长,最后没有人能说清所有分支。

更矛盾的是,解析层其实早就有一棵完整的树。Rust 侧的 comrak 会产出标准 AST,我们却用 walk 把它重新拍平,再在 Dart 侧猜结构。信息在进入编辑器之前就被丢掉了。

块树模型

新的模型很直接:文档是一棵树,容器块持有 children。引用块是容器,列表项也是容器;叶子块保存文本或代码。连续列表项的分组由相邻关系隐式表达,不再维护额外的 depth。

class EditableBlock {
  final BlockId id;
  final EditableBlockType type;
  final String text;
  final List<EditableBlock> children; // 容器块
}

渐进式迁移

重构没有推倒重来。第一步只把树作为新能力加入:Rust 输出嵌套 JSON,Dart 递归建树,旧的扁平路径继续运行,测试保持全绿。第二步让渲染切到树,再把编辑操作逐个迁移。第三步才删除旧字段。

这样做的好处是每一步都可以独立发布与回滚。对于个人项目,能随时停下来而不留下半成品,比一次性追求“架构纯洁”重要得多。

性能与编辑语义

树带来表达能力,也带来渲染成本。虚拟列表需要把树先序展开成扁平项,只渲染视口附近的部分;大文件的打开路径仍然走 Rust,避免在 Dart 侧做重复解析。

真正困难的部分是编辑语义:在容器边界按回车应该发生什么?在空列表项上退格应该退出容器还是升级层级?这些问题没有通用答案,只能逐条写成测试,再用真实文稿反复验证。

经验: 如果解析器已经产出树,不要在中间层把它拍平。保留结构,把复杂度留在它本该存在的地方。

回到顶部

威威牛 独立手艺人

独立数字手艺人。打磨 tMarkdown、tPicCraft、Renamer 等小工具,记录创造与探索的全过程。相信优雅的代码与真诚的文字能够经受时间淘洗。