Skip to content

08 · 路线图

原则:每个里程碑结束时,产物都是可用的(可以真的拿来写东西),而不是「半个功能」。 排序依据是原则 P5:先把 CommonMark 做到零损耗,再铺功能面。

当前进度

里程碑状态
M0 骨架✅ 完成
M1 实时预览内核✅ 完成
M1.5 外壳打磨(多窗口、改名、代码块)✅ 完成 · 来自首次试用反馈
M2 GFM 与结构化编辑✅ 完成
M3 工作区与内容扩展✅ 完成
M4 主题与导出✅ 完成(用户主题目录顺延)
M4.5 硬骨头✅ 六条全部完成(3、5 是缩范围版,6 是换做法版)
M5 插件系统⬜ 未开始
M6 打磨与 1.0⬜ 未开始

M0/M1 相对原计划的偏差记录在各自小节末尾 —— 有意为之的删减和推迟都写清楚, 免得后来的人以为「做了但坏了」。


M0 · 骨架(2–3 周)· ✅ 完成

搭好地基,让后面的人能并行干活。

  • pnpm workspace + TS project references + Vite/tsup 构建
  • Electron 三进程骨架、HostBridge 接口与 Electron/内存两套实现
  • 最小编辑器:CodeMirror 6 + Markdown 语法高亮(还没有实时预览
  • 打开 / 保存 / 另存为;原子写入
  • CI:lint、typecheck、单测、三平台构建

验收:能打开一个 .md,以纯源码模式编辑并保存,保存后文件逐字节一致。

实际偏差

  • 构建工具用的是 Vite(渲染进程)+ esbuild(main/preload),没引 tsup —— main/preload 打成自包含 CJS 后,安装包不必带 node_modules,顺带绕开了 pnpm 软链接与 asar 的老问题。
  • 分层依赖检查没用 dependency-cruiser,而是 scripts/check-layers.mjs(约 40 行)。 同时检查 package.json 的声明与源码里实际的 import,不值得为此再加一个依赖。
  • 渲染进程页面用自定义的 typo-app:// 协议加载,不用 file://。 原因见 apps/desktop/src/main/app-protocol.tsfile:// 下 CSP 的 'self' 匹配不到任何东西,script-src 'self' 会把应用自己的 bundle 挡掉 —— 而这个问题只在打包版本出现,开发模式一切正常。
  • 编码支持范围收窄为 UTF-8 / UTF-8 BOM / 带 BOM 的 UTF-16。GBK 等无 BOM 的 遗留编码嗅探推迟;当前遇到就明确拒绝打开,不用替换字符糊过去。
  • 安装包未签名。CI 产物在 macOS 上会被 Gatekeeper 报「已损坏」、 Windows 上会被 SmartScreen 拦。已加 ad-hoc 签名让 Apple Silicon 至少能跑起来, 但这只解决「能不能执行」,解决不了「用户要不要手动放行」。 真正的解法是开发者证书 + 公证,属于 M6 的发布工程。README 里写了绕过办法。
  • 应用图标与 Linux 桌面项尚未配置。当前用的是 Electron 默认图标, desktopName 也没设(Linux 桌面环境可能无法把运行中的窗口关联到启动器条目)。 属于品牌与打磨范畴,随 M4 的主题工作一起做。
  • 已知缺口:渲染进程若卡死,带未保存内容的窗口关不掉(关闭需要渲染进程回应)。 没有加「超时后强制关闭」——那等于在最不该丢数据的时候丢数据。 正确解法是崩溃恢复草稿(M3 的 04 §4),届时一并处理。

M1 · 实时预览内核(4–6 周)· ✅ 完成 · 项目成败在这里

  • 装饰引擎:Decoration 四类的规则框架(02 §2)
  • 「光标进入即显源码」算法与激活范围规则(02 §3)
  • CommonMark 全集的呈现规则:标题、强调、行内代码、链接、图片、列表、引用、 代码块、分隔线
  • 交互细节:atomicRanges 光标穿越、点击定位、行首缩进补偿(02 §4)
  • IME 正确性:装饰冻结机制 + 中日韩 Playwright 用例
  • 撤销 / 重做、查找 / 替换
  • 源码模式切换
  • 保真闭环测试 + CommonMark 规范符合性测试

验收:能用它舒服地写一篇纯 CommonMark 长文;输入延迟 p95 < 16ms; IME 用例全绿;打开-保存字节一致。

实际偏差

  • 点击定位与行首缩进补偿没有单独实现。CodeMirror 对 mark 装饰的 posAtCoords 本来就正确,隐藏前缀带来的横向位移在实际使用中不明显, 没有为一个观察不到的问题写代码。等 M2 的表格 widget 进来之后再评估。
  • Setext 标题的下划线不隐藏,只做弱化显示。隐藏整行会留下一个空行, 观感比不隐藏更糟。
  • 围栏代码块的 ``` 保持可见。藏了之后用户没有办法删掉这个代码块。
  • 裸的 [文字] 不折叠。解析器出于容错会把它标成 Link,但它多半只是 普通方括号([[Wiki 链接]][TODO] 待办);折叠等于让方括号凭空消失。 只有确实带跳转目标(有 URL 或 LinkLabel)的链接才折叠。
  • atomicRanges 的实际作用范围与设计文档的预期不同(实现后才看清): 横向方向键基本用不到它 —— 闭区间激活规则让标记在光标走到边界前就已显形。 它真正兜住的是上下方向键、点击定位和拖选。已回填到 02 §4.1, 端到端用例也改成断言「用户能观察到的那条保证」。
  • 性能验收降级为冒烟docs/design/07 §2 要求的 p95 输入延迟门槛需要 独立的基准工程与基线对比,M1 只放了一条「一万行文档全量装饰构建 < 2s」的 警戒线。正式的性能门槛与 CI 基线对比留到 M4。
  • 规范符合性测试的范围要说清楚:CommonMark 官方 652 个用例全部跑, 但验证的是「保真 + 装饰引擎的结构不变量」,不是 HTML 输出与规范期望值的比对。 后者需要语义解析器(remark/mdast),随 M3 的导出管线一起做 (见 ADR-0003 的一致性测试要求)。

这个里程碑如果做不好,后面做什么都没意义。 尚未做的事:真实用户试用(找 5 个人各写一篇长文)。 自动化测试能证明规则算得对,证明不了手感好 —— 在开始 M2 之前应该先做这一步, 因为 ADR-0002 说明的「重新评估技术路线的最后窗口期」就在这里。


M1.5 · 外壳打磨(1–2 周)· ✅ 完成 · 来自首次试用反馈

M1 做完之后的第一次真实试用暴露的问题。刻意单列一个小里程碑而不是塞进 M2, 因为这几件事几乎不碰编辑器内核(多窗口只动 main 进程,改名只动配置与文案), 可以和 M2 的编辑器工作并行推进,也可以先合入让日常使用不难受。

1. 产品改名为 Brainforge Typo

仓库名与 npm 包名(@typo/*)暂不动,只换用户可见的部分: productName、窗口标题、document.title<title>、菜单里的应用名、 文件关联描述、README 与文档中的产品名。

需要一并想清楚的两处副作用:

  • appId 改为 com.ohgiantai.typo。它是 macOS 的 bundle id, 改了等于系统眼里换了个应用 —— 现在改代价为零,1.0 之后再改就要处理迁移。 单个应用不需要再叠 .editor 层级,那是应用套件区分子应用时才用的。
  • Electron 用 productName 推导 userData 目录,改名会让旧配置成为孤儿。 当前没有真实用户,可以接受;写进变更说明即可。
  • 产物文件名要用无空格的 slug(BrainforgeTypo-0.1.0-arm64.dmg), 别让空格进文件路径 —— 这个坑 M0 已经踩过一次(scope 名进路径)。

2. 多窗口

模型与实施顺序见 ADR-0005。M1.5 只做窗口这一层, 标签页留给 M3。范围:

  • main 侧窗口注册表,清掉 mainWindow 单例
  • 修掉 respond-close 不带窗口身份的缺陷(两个窗口同时确认会关错窗口)
  • 菜单命令转向 getFocusedWindow()
  • 新建窗口(⌘⇧N)、在新窗口打开(⌘⇧O)
  • 「打开文件」的落点规则:当前窗口是空的未命名文档就复用,否则新开
  • 窗口几何尺寸的持久化与恢复
  • CSP 注册挪到 app ready 时一次性完成

3. 代码块:不折行 + 语言高亮

  • 代码行 white-space: pre,配滚动同步(见 02 §6.1
  • codeLanguages 接入 @codemirror/language-data(见 03 §7
  • 接入后实测安装包体积(预算已放宽到 180MB,见 07 §2)

验收:能同时开两个窗口分别写两篇文档;代码块横向滚动且按语言着色; 应用各处显示的名字都是 Brainforge Typo;安装包体积仍在预算内。

实际偏差

  • ⌘N 的含义变了:从「当前窗口新建」改成「新建窗口」。没有标签页的前提下, 在当前窗口新建会把用户正在写的东西顶掉。M3 加标签之后 ⌘N 回归「新标签」、 ⌘⇧N 变成「新窗口」,这是文档型应用的通行分工。 DocumentController.newFile() 保留(有测试覆盖),只是暂时没有菜单入口。
  • 窗口几何只存一份全局值,不是每窗口一份。新窗口按打开数量做层叠错位。 真正的「每窗口独立记忆」要等 M3 的会话恢复(那时才有窗口身份的持久化)。
  • 体积实测:接入 @codemirror/language-data 后,Linux AppImage 为 114.7MB (接入前 118MB —— 略降是压缩差异,不是语言包不占地方)。主 bundle 从 558KB 涨到 578KB,只多了语言描述表;112 个语言解析器被切成独立 chunk 按需加载。 预算 180MB,余量充足,无需收窄语言清单。
  • 代码块的横向滚动同步依赖 DOM 相邻关系,不额外打标记。 代码块的行在文档里连续,在 DOM 里也就连续 —— 中间隔着任何非代码行自然断开。 行数极多的代码块(数百行)监听器数量会上去,届时可退化为只同步视口内的行。

M2 · GFM 与结构化编辑 · ✅ 完成

  • 表格渲染:管道表格的对齐与样式(网格编辑器已移入 M4.5) —— 首次试用反馈里最集中的一项,M2 开工就先做它
  • 任务列表(可点击勾选)、删除线、自动链接、脚注
  • 图片:粘贴 / 拖入 → 附件目录,相对路径解析(04 §5)
  • 输入行为:列表续行、Tab 缩进、自动配对、选中包裹(02 §7)
  • 代码块:语言选择器(高亮与不折行已在 M1.5 完成)
  • 补上 CommonMark 的两个遗漏:转义字符 \* 藏掉反斜杠、HTML 实体解码
  • 翻开 GFM 方言开关

验收:能完成一篇带表格、图片、代码的技术文档,全程不用切源码模式。

实际偏差

  • 表格没有用 widget。网格编辑器搁置之后反而找到了更简单的路子:靠 CSS 的匿名表格盒把文本摆成表格(02 §6.0)。没有第二状态,撤销与协同天然正确。 代价是列宽不可拖、Tab 不能跳格 —— 那些正是 M4.5 里搁置的东西。
  • 脚注是自己写的 Lezer 扩展:上游 GFM 包不含脚注(03 §3.2)。 两条已知限制(标签不含空白、不支持多段落定义)写在那一节里。
  • GFM 是显式拼出来的,没有直接用上游的 markdownLanguage —— 后者会连带打开下标、上标、emoji 三样,而它们排在 M4 且要求可开关。
  • 默认方言从 commonmark 改成 gfm。测试辅助的默认值同步改掉了, 免得测试悄悄跑在一个用户永远碰不到的方言上;CommonMark 语料库的四条 结构不变量现在两个方言各跑一遍
  • 图片只做了「复制到附件目录」一种策略,另外三种(保持原位、Base64、 图床插件)连同策略开关留到后面。文件名用内容哈希,重复粘贴自动去重。
  • 修掉一个装饰引擎的真 bugBuilder.hide() 原先边收边裁, 悄悄依赖「装饰按文档顺序发出」。表格行一次性发完整行竖线直接跨到行尾, 把行内还没轮到的强调标记当成重叠静默丢弃 —— 表现是「单元格里的加粗 不生效」。改成攒起来统一排序消解,这个前提就不存在了。
  • 修掉一处语言匹配的分家:语言选择器与高亮原本各自匹配一次, 而上游只认名字和别名 —— py 是扩展名,于是 ```py 一直不高亮。 现在两边共用同一个函数,并补了扩展名兜底(03 §7.1)。
  • editor.ts 里写死过一份方言默认值,导致解析器默认改成 GFM 之后编辑器 还在跑 commonmark,表格在应用里完全不渲染 —— 是 e2e 抓出来的, 单元测试全绿。默认值现在只有 @typo/markdown 一处。
  • 体积实测:Linux AppImage 114.8MB(M1.5 是 114.7MB),预算 180MB。 语言选择器和 GFM 都没有引入新的运行时依赖。

M3 · 工作区与内容扩展 · ✅ 完成

(文件树 / 多标签页 / 会话恢复曾移入 M4.5,现已随 M4.5 的部分解封完成)

  • ✅ 大纲面板、字数统计
  • ✅ 文件监听、外部修改冲突处理、崩溃恢复(04 §3、§4)
  • ✅ 命令面板;可配置快捷键只做到一半(见下方偏差)
  • ✅ YAML front matter
  • ✅ 数学公式(KaTeX,行内 + 块级)
  • ✅ Mermaid 图表(懒加载)

验收:可以把它当日常主力编辑器用一整周而不想切回去。

实际偏差

  • YAML front matter 没用上游的 yamlFrontmatter,自己写了个块解析器。 上游那份在开围栏没闭合时会把剩下的全文都当成 YAML,而「没闭合」正是 用户敲下 --- 之后、写完元数据之前的每一秒 —— 整篇文章的渲染当场塌掉。 自己写还额外换来两件事:顶层语言仍是 Markdown(语法树不多两级包装, 既有的装饰规则与 isActiveAt 判定全不受影响),YAML 高亮靠 parseMixed 嵌回来一点没丢。 一处妥协:Lezer 的块解析器一旦 nextLine() 就没有回退, 所以做不到「没闭合就整块退化」,退而以空行为界—— 真实文档里元数据与正文之间一定隔着空行,塌掉的范围因此收窄到一段之内。
  • 「可配置快捷键」当时只做了前半截。 命令注册表(稳定 id + 标题 + 默认绑定) 已经立起来了,菜单和命令面板共用同一份定义;但绑定的编辑界面属于设置界面, 随 M4.5 一起搁置。后半截已在 M4.5 补完(见那一档的「快捷键编辑」一节)。
  • 大纲与命令面板用裸 DOM 写,没引 React。当时的说法是「真正需要 @typo/ui 那一层的是文件树 / 标签页,届时这两个文件会被重写」—— 后来没有重写:文件树与标签条做下来也是裸 DOM,那一层至今没立起来 (理由见 M4.5 #2)。
  • 文件监听用 fs.watch 而不是 chokidar:只盯已经打开的那几个文件, chokidar 的价值几乎全在递归遍历目录树上。文件树做完之后仍然没换 —— 因为树是懒展开、不监听目录的(04 §1.1),递归遍历这个需求根本没出现。
  • 大纲是只读导航,不支持拖拽重排 —— 那要移动整段文本、处理层级合法性, 属于结构化编辑,跟表格网格编辑器是一类东西。
  • 数学公式的解析规则花在「别把钱当公式」上的力气比渲染多。 $ 在自然语言里 首先是货币符号,我花了 $5 买了 $10 的东西 中间那段会被天真的实现渲染成公式。 沿用 pandoc / remark-math 的成熟约束(开定界符右边不能是空白、闭定界符左边 不能是空白、闭定界符右边不能是数字、不跨行),没有自创。 额外补了一条上游规则够不着的:写在一行里的 $$…$$ —— 块级规则要求 $$ 独占一行,不处理的话行内规则会从第二个 $ 起手,把它啃成 $ 公式 $ 外加两个孤零零的 $
  • KaTeX 懒加载:连字体近 1MB,而绝大多数文档一个公式都没有。 Vite 切成独立 chunk(260KB JS + 30KB CSS),主 bundle 不受影响。 AppImage 从 114.8MB 涨到 117.5MB,预算 180MB。 代价是 widget 的 toDOM 同步而加载异步 —— 先画源码占位、加载完原地替换, 比留白好(留白会让人以为编辑器坏了)。
  • 补了一条语料库检查:块级装饰不抛异常、不越界。 块级装饰走 StateField 这条独立的路,语料库原先完全没覆盖到。补它的直接原因是踩过一次 —— 块级公式在「$$ 正好是文档最后一行」时造出了超出文档长度的节点, 下游 doc.lineAt() 当场抛错。
  • Mermaid 的集成成本主要在「别让它碰我们的 DOM」上:默认它会扫描整个页面 改写 DOM(startOnLoad: false 关掉),出错时会往 document.body 塞一个错误 SVG 且不会清理(suppressErrorRendering: true 关掉,自己接住异常显示源码)。
  • 块级 widget 补了「点一下还原成源码」。 块级 widget 盖住了原来那几行, 点击映射出来的位置未必落进被替换的区间,表现是点了没反应 —— 而「点一下改它」是看到一张图或一个公式之后最自然的动作。
  • 打包时排掉 node_modules,安装包从 152.9MB 降到 113.6MB。 这是接 mermaid 时顺带挖出来的既有缺陷:electron-builder 默认会把生产依赖 复制进 app.asar,files 里列了 dist/** 也拦不住(那是两套机制)—— 配置文件顶上那句「不需要把 node_modules 装进包里」原来只是句愿望。 app.asar 一度 145MB,排掉之后 6.4MB。source map 也一并排掉(占渲染层产物七成)。 同时新增打包产物的冒烟测试并接进 CI:e2e 跑的是源码目录, 验不到「装进 asar 之后还能不能起来」,而打包配置的改动恰恰只在产物里出问题。
  • 被外部删除的文件,保存时就地重新创建,而不是像 04 §8 原先写的那样 强制走「另存为」。用户按下保存的意图就是「把我这份存下来」, 在原地重建比弹一个文件选择框更符合预期;提示语里也是这么承诺的。

M4 · 主题与导出 · ✅ 完成

(PDF 导出 / 设置界面 / 原始 HTML 渲染已移入 M4.5)

  • ✅ 主题引擎:变量契约、5 套内置主题(05)
  • ✅ 深色模式、跟随系统
  • ✅ 打印样式(CSS 层面的 @media print,不含 PDF 生成管线)
  • ✅ 导出 HTML(自包含单文件)—— mdast → rehype 管线
  • ✅ 复制为富文本(复用同一条管线的片段产物)
  • ⬜ 用户主题目录与热加载(唯一顺延项,见下方偏差)

验收:社区能凭文档做出自己的主题;导出的 HTML 单文件能直接分发, 粘贴进邮件 / 飞书 / Word 格式不散。

实际偏差(截至目前)

  • 变量契约收敛到一处。 之前外壳的 styles.css 和编辑器的 baseTheme 各自维护一份变量,改主题时总有一处会漏。现在 themes.css[data-typo-theme='light'] 那一份就是契约本身,外壳只用不定义。
  • 「跟随系统」是单独一档,不是「没设置时的默认行为」。 只在未设置时跟随 系统的话,用户手动选过一次就再也回不到跟随状态 —— 那是个只能靠改配置文件 才能脱身的死角。
  • index.html 上预置了 data-typo-theme:等 JS 读完设置再挂的话, 启动瞬间会闪一下无样式白底。
  • 打印一律回到浅色,且状态栏 / 大纲 / 命令面板 / 语言选择器不上纸。 深色主题打出来是一整页黑,既费墨又难读 —— 这是默认行为,不是给主题作者的建议。
  • 导出管线是 ADR-0003 里语义解析器那一侧的第一次落地。 编辑期用 @lezer/markdown(增量、位置精确),导出期用 remark/mdast(语义完整、生态)。 独立成 @typo/export 包,不认识编辑器 —— 导出不该依赖「界面上现在是什么样」。
  • 不复用编辑器已渲染的 DOM。 那是最省事的做法,但 DOM 里全是 CodeMirror 的实现细节,而且视口之外的内容根本不存在 —— 导出一篇长文只会得到 当前屏幕上那几段。
  • 消毒默认开,白名单而非黑名单。 导出产物是要发给别人的,黑名单永远漏, 漏掉的那一条就是别人机器上的一次 XSS。class 放行(主题靠它上色), style 不放行。要保留原始 HTML 必须显式打开开关。
  • 宿主能力靠注入ExportHooks):内联图片要读文件、渲染图表要跑 mermaid, 两者都不属于纯函数层。不注入时各有明确降级 —— 图退化成代码块(源码还在)、 图片保留相对路径(产物不自包含但引用是对的),绝不静默丢内容。
  • KaTeX 的字体也内联。 「自包含」的标准是一个文件发出去、收件人双击就能看; 字体外链的话公式在别人机器上是一堆方框。
  • 导出时挖出两个既有缺陷,都只在真应用里才暴露:
    1. protocol.registerSchemesAsPrivileged 被调用了两次,而 Electron 只认 一次 —— typo-asset 的特权被静默丢弃。表现极具迷惑性:<img> 加载一切正常 (它不需要特权),但 fetch() 一律报「URL scheme not supported」, 于是导出时图片永远内联不进去。现在各模块只导出声明,由 index.ts 一次注册。
    2. 保存对话框把文件类型写死成 Markdown,「导出为 HTML」弹出来只让存 .md
  • 导出仍跑在渲染进程,不是 06 §1 要求的 utility 进程。一篇常规文档的转换是 毫秒级,utility 进程的价值在超长文档上;等有了性能基线再搬, 现在搬属于凭想象优化。
  • 用户主题目录与热加载顺延:它要新增一条「读 userData 下的任意 CSS 并注入」 的通路,涉及 CSP 与路径白名单,跟内置主题不是一个量级的事, 单独做比塞进这一批稳妥。
  • 类名契约(05 §3 的 .typo-*)尚未落地。 编辑器目前用的是 .cm-typo-* 前缀,跟设计文档里写的 .typo-* 不是一套。 统一它需要同时改装饰规则和文档,且会影响将来的社区主题兼容性, 应当在导出管线定型、两边的类名需求都清楚之后一次做完 —— 现在改等于改两遍。

M4.5 · 硬骨头(六条全部完成,外加快捷键编辑)

这一档不是「以后再做的功能列表」,而是从 M2/M3/M4 里剥出来的、 压缩它就会牺牲正确性的东西。判据只有一条:

砍掉时间会直接换来错误的行为,而不是更少的功能。

工作量大但可以线性铺开的(比如再写十条装饰规则)不在此列 —— 那些留在原里程碑里。

#硬骨头原属状态难在哪
1表格网格编辑器M2✅ 缩范围完成widget 里是一份第二状态(列宽、选中单元格、拖拽中的列),它和文本缓冲区必须始终同调。撤销、协同、外部改文件都会打破同调。做浅了就是「编辑完表格按 ⌘Z 文档烂掉」
2文件树 + 多标签页 + 会话恢复M3✅ 完成需要先立起 @typo/ui 这一层 React 外壳,现在的 UI 是刻意做薄的裸 DOM。标签页还要把「文档状态」从窗口里拆出来(ADR-0005),会话恢复要处理文件被删/被移动/被改的所有分支
3PDF 导出M4✅ 缩范围完成打印管线本身不难,难的是分页:CSS Paged Media 在 Chromium 里支持不全,页眉页脚、避免表格/代码块被切断、目录页码,每一项都要单独跟浏览器的分页算法搏斗
4粘贴 HTML → MarkdownM2✅ 完成转换质量是个无底洞。从网页/Word/Notion 复制过来的 HTML 千奇百怪,能跑通 80% 只要一天,剩下 20% 能吃掉一个月,而用户恰恰只记得失败的那次
5设置界面(JSON Schema 驱动的表单)M4✅ 缩范围完成「用 schema 自动生成表单」实质上是另起一个项目:条件显隐、校验、分组、搜索、重置、迁移。不做通用化就是几百个手写控件,做通用化就是造一个表单引擎
6原始 HTML 的渲染与消毒M4✅ 换做法完成唯一一条安全相关的。渲染用户文档里的 <script>/<iframe>/on* 属性,在 Electron 里消毒漏一个就是 XSS→RCE。要做就得配白名单、CSP、以及给用户「完全不渲染」的开关,并为此写攻击语料测试

为什么部分解封

重新审视时发现,这六条的难度不是一个量级,而其中三条有「缩一下范围硬骨头就 消失了」的版本。解封顺序定为 4 → 1 → 3 → 2 → 5:前三条互相独立、风险可控, 2 会大面积动到现有结构,单独一批做、单独测最稳妥。

4 · 粘贴 HTML → Markdown(✅ 完成)

当初就不该进这一档。 M4.5 的判据是「砍掉时间会直接换来错误的行为」, 而这一条的失败模式是良性的:转换质量不够好,用户得到的是有点丑的 Markdown,然后手改一下 —— 没有数据丢失、没有文档损坏。它属于「工作量大但能 线性铺开」的典型,按判据本该留在 M2。

实现见 03 §8。关键决定:转换同步(避开「插入位置在等待期间失效」那一整类 问题)、清洗规则针对具体来源而非通用清洗、没有可转的结构时退回纯文本。

1 · 表格编辑(✅ 缩范围完成)

硬在哪的判断是对的 —— 但难点集中在一个点上:只有拖拽调列宽真正需要一份 跨帧存活的第二状态。其余全部可以做成立即落到文本上的纯变换:Tab 跳单元格、 增删行列、设置列对齐、整理表格、插入表格。做成纯变换之后,撤销、脏标记、 外部改文件、将来的协同全部白送,「编辑完表格按 ⌘Z 文档烂掉」这个失败模式 根本不存在。

所以做了无状态的那一部分,明确不做拖拽调列宽。实现与全部取舍见 02 §6.4。

顺带修掉一个只在边界上出现的缺陷:光标停在文档最开头、而文档正好以表格起头时, resolveInner(pos, -1) 解析到的是表格外面的节点,于是「光标明明在表里」却找不到表。 现在两侧都试。

3 · PDF 导出(✅ 缩范围完成)

「难在分页」的判断是对的,但那说的是自己造分页。M4 的 HTML 导出一落地, 成本就塌了一半:把已经自包含的 HTML 交给 Chromium 打印即可。

所以做的是缩水版,页眉页脚、目录页码、奇偶页差异化仍然拿不到, 纸张与页边距也还没有界面可调(那需要设置界面,仍在搁置)。全部取舍见 06 §3.2。

在 CI 上才发现的两件事(本地只跑 Linux,两条都看不见):

  • 字体是否嵌入取决于平台。 内联的 Web 字体(KaTeX)会嵌,具名系统字体只被 按名引用。当时那条「字体跟着嵌进去」的断言在 macOS 上是红的,被判成「测的是 平台不是产品」而换掉 —— 那个判断害得不轻:它其实正指着下面那条的根因, 字体嵌入与文字绘制在 PDF 里本就是同一件事。
  • PDF 导出在 macOS 上产出空白页(已修)。根因是 PingFang SC 画不进 PDF —— macOS 的默认中文无衬线字体,Chromium 的 PDF 后端画不出它的任何字形 (连拉丁字母都画不出)。而字体匹配是逐字形的:拉丁字体栈里没有汉字, 每个汉字都回退到它,于是中文文档整页空白。打印路径把它换成 'Hiragino Sans GB', 'Songti SC'(且必须排在泛型 sans-serif 前面)后修好。 这个缺陷让我在七轮 CI 里连着提出并推翻了六个结论(字体没嵌、KaTeX 太大、 测试切错了流、viewport 压成零宽、写死的 langsystem-ui 有毒), 全过程与教训记在 06 §3.3。最贵的一步是第一条:那条「字体没嵌进去」的失败断言 当时正指着根因,被我当成「测的是平台不是产品」删掉了。

2 · 文件树 + 多标签页 + 会话恢复(✅ 完成)

这一条没有缩范围 —— 它本来就缩不了。做完之后回头看,当初对成本的判断错了 一半:难的不是渲染,是状态。

  • 没有立 @typo/ui 这层 React 外壳。 判据是「标签条与文件树需要组件框架」, 但真做下来它们就是两个列表,裸 DOM 写出来跟既有的大纲、命令面板是同一种 东西;引入 React 反而要连带重写那两个已经好用的面板。真需要框架的是设置界面 的表单引擎,等 5 解封时再一起决定。
  • 真正的工作量在「把文档状态从窗口里拆出来」(ADR-0005):每个标签一整套 TypoEditor + DocumentController,撤销栈、脏标记、保真元数据、冲突基线 全部随标签走。共用一个编辑器的实现能通过「两个标签显示不同内容」, 但一定过不了「在 A 标签按撤销不该倒出 B 标签的内容」。
  • 会话写在 userData 而不是 .typo/workspace.json(04 §7.1):会话是跨工作区 的,而且往用户的项目目录里写状态文件是要被 git 记一笔的。

顺带补上或修掉四处:

  1. fs.list 之前是个明确抛错的占位实现(「目录浏览尚未实现(M3)」)。
  2. 白名单里「列目录」独立成一条许可(04 §1.2)。之前只有「读文件」一条, 而它按定义不接受目录自身 —— 于是刚打开的工作区根列不出来。合并成一条又会 顺带放开「只打开过一个文件时,那个目录里还有什么别的文件」。
  3. fs.exists 只查了授权、没查存在。已删除但仍在白名单里的文件会被报成 存在,工作区目录自身反而被报成不存在 —— 会话恢复正好同时踩中这两条。
  4. 正常退出会把会话擦掉。退出时窗口逐个关闭,而每个 closed 都会把自己从 会话里移走 —— 表现极具迷惑性:崩溃之后能恢复,正常退出反而恢复不了。
  5. 会话文件不是原子写入的,退出时那一次刷盘可能被进程结束截断成半截 JSON。 下次启动解析失败、会话丢失,而且时有时无:本地十次中一次,CI 上换台 慢机器就变成三次挂两次。改成「临时文件 + rename」,并在退出时等它真的落盘 (见 04 §7.3)。这一条是 Windows 的 CI 逼出来的 —— 本地一直是绿的。

没做的:标签拖出来变成独立窗口(ADR 里已列为 v1 之外)、标签数上限时卸载 非活跃编辑器实例(ADR 里说的「留到真出现问题再做」)、文件树里的重命名 / 新建 / 删除(那是文件系统写操作,属于单独一批工作,见 04 §1.1)。

5 · 设置界面(✅ 缩范围完成)

判词描述的清单当时不存在。 「用 schema 自动生成表单实质上是另起一个项目」 这话没错,但它说的是一个有几十上百项设置要统一渲染、校验、分组的场面 —— 而动手时整个应用只有一条用户可见的设置(appearance.theme)。

让那个清单存在的是插件(05 二 §2 的 manifest 里那句 "settings": { JSON Schema })。 所以通用化的正确时机是 M5,不是现在。现在手写:一个类型、一张默认值表、 一个逐字段的校验器。

真正花力气的地方不是表单,是校验settings.json 就摆在用户数据目录里让人 直接改,读进来的每个值都可能是任何东西。三条规则写进了 05 三 §2:一个坏值只作废 它自己(整份设置因为一个手滑回到出厂状态,比手滑本身糟糕得多)、范围类的值夹住 而不是拒绝、读不出来不报错不提示。main 侧还会再兜一次底 —— 渲染进程验过了, 但 main 不信任传进来的任何东西(01 §5)。

面板是当前窗口里的浮层而不是新窗口:开窗口要再来一份渲染进程入口、一套主题 初始化、一条跨窗口同步设置的通路,而设置项只有六条,管道比要装的东西还重。

现在能调的:主题、新标签页默认视图模式、行内 HTML 的渲染开关、 导出 PDF 的纸张 / 方向 / 页边距。 「只影响新建的标签」是刻意的 —— 改一个设置就把所有已开标签的视图切一遍, 用户正在读的文章会突然变成源码。

快捷键编辑随后补上了(见下面的「快捷键编辑」一节)。仍然没进来的: 用户主题目录与热加载、附件目录名。见 05 三 §6。

6 · 原始 HTML 的渲染(✅ 换做法完成)

判词是对的,所以没照判词做。 「配白名单 + 消毒 + CSP + 攻击语料测试」 描述的是「把用户的 HTML 解析出来放进 DOM,再想办法让它变得安全」这条路。 那条路上消毒器漏一个就是 XSS→RCE,而渲染进程手里握着 fs.writeshell.openExternal。判词对它的代价估计没有错。

落地的做法绕开了那条路:不解析 HTML,也不往 DOM 里放 HTML。 渲染效果全部由 mark 装饰 + CSS 类名达成 —— 往 DOM 里写的只有类名, 而类名是 cm-typo-html- 加上一个白名单里的标签名,封闭到没有可控部分。 于是「消毒漏一个怎么办」这个问题不存在,因为没有东西需要消毒。 完整说明见 02 §5.1。

代价是能渲染的东西少:b strong i em u s del ins sub supkbd mark br,且必须不带属性。这不是保守,是这个做法的定义 —— 属性正好是绝大多数注入面的载体,而这几个标签的属性对排版毫无用处。

明确没做的

  • 块级 HTML(<div>…</div>)不渲染。 它的意义几乎全在属性和布局上 (表格、iframe、带样式的容器),照「不带属性」这条规则根本渲染不出有价值的 东西,而放开属性就正好踩回那条安全路径;
  • <a> <img> <span> <code> 不在白名单里 —— 前两个离了属性没意义, <span> 同理,<code> 与 Markdown 的 ` 撞车(两套写法渲染成同一个 样子,只会让人分不清源码里写的是哪一个);
  • 交叉嵌套(<b><i>x</b>)只认能确定的那一层,不猜用户想要什么。

单测 26 条,其中一半以上断言的是不渲染。端到端另有 6 条,验的是单测问不到 的那件事:真 DOM 里有没有元素被造出来(<script><img onerror> 一个都没有)。

附 · 快捷键编辑(✅ 完成)

不在那六条硬骨头里,但一直挂着 —— M3 的「可配置快捷键」只做了前半截: 命令注册表立起来了,绑定的编辑界面随设置界面一起搁置。设置面板做完之后, 它就只差自己那部分了。

真正的工作量不在界面,在于同一个绑定原本写在三个地方main/menu.tsaccelerator、渲染进程给命令面板显示的那份、CodeMirror 的 keymap。三种格式互不 相同,改一处漏两处是必然的。第一步是把三份合成一份(shared/keys.ts, main 与渲染进程共用),并由它负责翻译。

设计取舍与「明确不可配置的那些」写在 05 三 §5。最要紧的一条:真正拦住按键的是 原生菜单,所以改完必须重建菜单 —— 界面显示新的、菜单还挂着旧的,是这个功能 最可能的坏法。端到端用例因此断言的是菜单项上的 accelerator,不是界面文字。

没做的:给「插入表格」这类目前没有默认绑定的命令预留位置是有的(表里列出 全部命令,未设置就显示「未设置」),但没有做「按下组合键时提示它已被系统占用」 —— 那要枚举各平台的系统级快捷键,是一张永远追不上的表。

为什么 HTML 导出留在 M4 而不进这一档

ADR-0003 里对 remark 管线的顾虑是长期一致性成本(两个解析器可能对同一段 文本给出不同解读),不是实现难度。导出这条路上 mdast → rehype → 单文件 HTML 本身大约十行,主题 CSS 内联、图片转 data URI 是机械工作。而它一旦通了, 「复制为富文本」几乎白送 —— 这是日常用得最多的一个能力。

所以当时的结论是:HTML 导出做,PDF 导出不做。 PDF 的成本全在分页, 不在管线。

(这条结论后来被改了一半 —— 见上面 3 的状态。HTML 导出一落地,PDF 就不必再 自己造分页:把导出的 HTML 交给 Chromium 打印即可。成本降下来的是范围, 不是难度,所以做的是缩水版,页眉页脚与目录页码仍然拿不到。)

搁置之后,用户会明确缺什么

不粉饰,列清楚:

  • 表格不能拖拽调列宽(其余的单元格导航与增删行列已经有了);
  • 文件树只能看和打开,不能重命名 / 新建 / 删除;标签不能拖出来变成窗口;
  • 导出的 PDF 没有页眉页脚与目录页码,纸张 / 页边距也还不能调;
  • 块级 HTML(<div>…</div>)仍然按原文显示 —— 行内的那一批已经渲染了,见 6。

六条都有了着落。

另外还有一条只在截图时才注意到的粗糙之处:代码块右上角的语言选择器是绝对 定位的,首行代码长到右边时会从它下面穿过去,观感像渲染出错(其实两者都画对了, 只是重叠)。没有顺手修,因为直觉解法(给首行加 padding-right)会让首行的横向 滚动区比其他行窄一截,而代码块的横向滚动是逐行同步的 —— 首行从此跟其余行 对不齐,比重叠难看得多。真正的解法要么让选择器跟着横向滚动走,要么只在悬停时 出现,都得单独想清楚。说明记在 CodeLanguageWidget 上。


M5 · 插件系统(4–5 周)

  • @typo/plugin-api v1.0 冻结
  • 插件加载、生命周期、自动注销、热重载
  • 权限清单与 main 侧代理校验(05 二、ADR-0004)
  • 把内置的数学 / 图表 / 脚注重构成插件,验证 API 完备性
  • Pandoc 检测与 DOCX / ePub / LaTeX 导出;DOCX 兜底路径
  • 全文搜索(Level 1 无索引)
  • 插件开发文档 + 模板仓库

验收:第三方能照文档写出一个语法扩展插件并正常工作。


M6 · 打磨与 1.0(4–6 周)

  • 专注模式、打字机模式
  • 拼写检查(系统词典)
  • 国际化(中 / 英)+ RTL
  • 无障碍整改(axe 扫描清零主要违规)
  • 全文搜索 Level 2(SQLite FTS)
  • 自动更新、代码签名与公证、便携版
  • 性能基准全部达标
  • 用户文档站

验收:达到 00 号文档中 G1–G4 的全部指标,可以发 1.0。


1.0 之后的候选方向

按价值排序,不承诺时间:

  1. 插件真隔离:把插件挪进 utility 进程 / Worker + RPC,兑现 ADR-0004 的演进项。
  2. 协同编辑:Y.Text 直接套在缓冲区文本上(源码优先模型的红利), 配合 y-webrtc / 自建服务。
  3. Web 版apps/web 补完 File System Access API 实现,内核零改动。
  4. 版本历史:本地快照 / Git 集成,文档级 diff 视图。
  5. 移动端:内核已是纯 Web,理论可行,但触屏交互要重新设计。

团队与节奏的说明

上面的周数按 1–2 名全职工程师 估算,且 M1 的不确定性最大(可能翻倍)。 如果是业余时间开发,建议:

  • 把 M0 + M1 当作唯一目标先做出来,其余全部砍掉 —— 一个只支持 CommonMark 但实时预览做得扎实的编辑器,已经比一堆功能齐全但手感别扭的项目更有价值;
  • M2 之后再考虑对外招募贡献者,因为那时 API 边界与测试基线已经稳定, 新人能安全地并行工作。

MIT 许可发布。与 Typora 无关联,是一个独立实现。