编辑器换代方案(Vditor → CodeMirror 6)
状态:M0 / M1 / M2 已实施(2026-10);M3 未实施;M4 只交付了导出 (表格辅助与大纲未做)。 本文规定的目标、实现边界、分期和验收条件继续有效;各期的实际交付、与本文的偏差 与已知限制集中在 §7 末尾的「实施记录」——那才是现状,别只看这张表。 已落地的形状:CodeMirror 6 保存与编辑 Markdown 原文,完整预览与正文共用同一台 渲染器(
MarkdownRenderer),首版是源码编辑加可切换的完整预览; 就地装饰(在源码里直接看到图片 / 公式)属于 M3,未做。 2026-10-12 校对:本文因 M3 / M4 仍有未完成范围移入docs/plan/; 当前待办见 §7.2,已完成的迁移与加固记录保留在 §7.1。
1. 目标与需求边界
1.1 已知问题与尚未确认的偏好
⚠️ 本节是迁移前(旧构建 f4762bb)收到的反馈与当时的判断,原样保留 —— Vditor 已不在工作区,下面说「现有」的地方都指那一次换代之前。
现有反馈涉及三件事:
- 编辑工作流不顺手,粘贴代码时还会自动补代码围栏。
- 博客与剪贴板编辑页缺少站内资源选择入口,内容引用需要手打,不能预览。
- Vditor 的展示结果与发布后的正文存在格式差异。
反馈中「不适应 Typora 的工作流」与「希望像 Typora 一样就地渲染」不是同一个需求。
现有两处表单已经使用 Vditor 的 ir 模式,不能仅凭这一点推断用户想要更彻底地隐藏源码。
编辑布局和源码隐藏偏好仍需通过原型确认。
本方案先采用一个可以继续调整的默认值:Markdown 原文始终可编辑,用户能切换完整预览; 宽屏可以并排显示,窄屏切换编辑与预览。这个默认值不是已确认的用户偏好,也不把就地 渲染列为替换内核的先决条件。
1.2 本次换代必须交付的行为
- 纯文本粘贴以
text/plain为准,不根据剪贴板中的 HTML 自动补围栏或改写 Markdown。 - 博客发布 / 编辑、剪贴板新建 / 编辑共用一个编辑组件,继续保存 Markdown 原文。
- 保留两处新建页的本地草稿,以及剪贴板已有的手动保存和自动保存。
- 保留图片上传、拖拽和粘贴入口,完整预览能显示图片、公式及既有内容引用。
- 提供收藏夹、图床、音频、投票、剪贴板五类资源选择入口。
- 完整预览与正文使用同一条渲染管线;亮 / 暗主题跟随站点。
文本粘贴与图片粘贴分别处理。剪贴板同时含文本、HTML 和图片时,以用户明确粘贴的 纯文本为优先;仅含可接受图片时走图片上传。混合内容的处理需有固定规则和测试, 不能为了找图片先阻断正常文本粘贴。
1.3 本次换代范围之外
- 从零实现文本引擎、富文本文档模型或 HTML → Markdown 自动转换。
- 默认隐藏源码、整篇就地渲染、可视化表格编辑。
- 扩展博客 / 剪贴板的引用种类:表情和用户名片暂不接入这两处。
- 在预览里投票或执行其他业务写操作。
- 模拟匿名读者、其他账号或所有可见性档位的预览。
表格仍可写 Markdown 源码并在完整预览中查看。导出、大纲、全屏等能力的处理见 §7; 它们是否进入首版,需要在替换前明确记录,不能靠删除旧工具条隐式决定。
2. 现状与选型
2.1 现有实现(迁移前 —— 历史)
⚠️ 这一节记的是换代之前(旧构建 f4762bb)的样子,作用是对照「取代了什么」。 表里的 Vditor 行为,以及下面提到的 helper / 复制脚本 (
src/lib/vditor-upload.ts、src/lib/vditor-theme.ts、scripts/copy-vditor-assets.mjs) 都是历史落点 —— 这三个文件在 M2 的清理阶段随依赖一起删除(提交a35cda67), 别当成现行文件去翻。现状见 §7.1。
| 位置 | 现有行为 |
|---|---|
src/app/components/BlogForm.tsx |
Vditor ir;新建缓存键 blog-upload-editor;编辑态不启用本地缓存;发布成功清草稿 |
src/app/clipboard/upload/UploadForm.tsx |
Vditor ir;新建缓存键 clipboard-upload-editor;编辑态不启用本地缓存;Ctrl / Cmd+S 保存,编辑态可每分钟自动保存 |
src/app/components/MarkdownRenderer.tsx |
博客 / 剪贴板正文的整篇渲染与 DOM 后处理 |
正文管线包含引用预处理、公式保护、marked、代码高亮、公式还原、DOMPurify,以及
MathJax、外链加固、图片放大和投票组件等后处理。约定见 docs/architecture.md §6.7
和 docs/guide/内容引用语法指南.md。
Vditor 还带来上传协议适配、三条主题轨道、本地资源复制、全局文件选择器绕行和样式
特异性适配。主要落点为 src/lib/vditor-upload.ts、src/lib/vditor-theme.ts、
scripts/copy-vditor-assets.mjs、public/static/js/core/base.js 与
src/styles-scss/pages/_clipboard.scss。
2.2 选择 CodeMirror 6 的理由
选型判据是:Markdown 原文是权威文档,完整预览只有一条权威渲染管线。 编辑器是否附带可选渲染功能,不是单独的否决理由;关键在于本站有没有绕过它、复用 正文管线,以及是否会为了编辑而往返转换原文。
| 路线 | 本项目的取舍 |
|---|---|
| Vditor 加资源面板与独立站点预览 | 可以改善插入与预览;仍需维护原来的 IR 输入行为和集成适配,可作为短期方案 |
| CM6 + 本站集成层 + 正文预览 | 直接编辑文本,选区 / 撤销 / 语言支持有现成内核;装饰可逐步增加,选定为长期路线 |
| ProseMirror / Milkdown / Lexical 富文本路线 | 需要定义文档模型、导入导出和原文保真规则;目前没有足够的富文本需求支持这笔投入 |
| 从零实现 contenteditable 编辑器 | 输入法、选区、撤销和跨浏览器行为均需自行承担,不采用 |
CM6 自身也使用 contenteditable。它的优势是成熟的文本状态模型、事务和输入处理,
不是避开了这一浏览器机制。粘贴改写与输入法组合输入也应分别验证,不能视为同一个问题。
CM6 的 Markdown 语言包提供 CommonMark 与扩展 Markdown 语言。实施时显式选择
markdownLanguage 并配置 markdown({ base: markdownLanguage }),验证表格、任务列表
和删除线;编辑侧语法树用于辅助编辑,不取代 marked 的终稿语义。
不在本文维护「最新版」和各包独立打包大小的表。实施时记录选用版本与锁文件,比较 实际编辑页的 JS / CSS / 字体请求、压缩传输量和解析开销。npm 包解压大小不能与 gzip 传输大小直接比较,也不能把依赖包各自的打包结果相加当成页面载荷。
技术依据:
这些链接用于说明 API;锁定依赖时以所选版本的源码和类型定义再核对一次。GitHub 镜像 是否归档,不单独作为项目停止维护的证据。
3. 架构与一致性承诺
3.1 分层
BlogForm / UploadForm
└── MarkdownEditor
├── EditorShell:工具条、布局、面板、状态
├── CM6:Markdown 原文、选区、事务、撤销
├── 资源插入与上传:只通过事务修改原文
├── 草稿适配:由调用方提供新建 / 编辑上下文
└── 完整预览:复用 MarkdownRenderer 与共享正文管线
MarkdownRenderer / 后续可选 widget
└── 共享渲染与引用模块
├── 整篇引用预处理 + 资源读取
├── 公式保护 → marked / hljs → 还原 → DOMPurify
└── DOM 后处理:MathJax、外链、图片、投票展示
CM6 实例持有文档状态。React 接收修改通知、驱动面板和预览,不在每次输入后重建编辑器, 也不把整篇字符串重新灌进 CM6 来模拟受控输入。重置文档必须是明确操作。
3.2 完整预览承诺什么
相同 Markdown 原文、引用读取模式和引用数据快照,应产生相同正文结构与样式。 完整预览复用正文的语法、净化白名单、代码高亮、公式配置和引用预算,不单独实现一套 「看起来接近」的转换器。预览里的资源取数复用共享 resolver,不另外请求一遍。
首版预览是当前作者的读取视角。本人能读到私有素材,不代表其他成员或匿名读者也能
读到;资源面板应标明私有状态,相关边界沿用既有读口。对外模式仍由服务端决定,
保留 contentRefs 和 externalClips 的现有语义,不为了编辑器放宽 API 权限。
引用内容可能在编辑期间或发布后变化。一致性以同一份引用快照为比较条件,不承诺 发布后的最新资源永远与先前预览相同。提供「刷新预览」以重新读取引用。
预览使用共享的投票展示结构,但不绑定投票提交处理器。正文保留现有交互;只读预览 差异由显式的交互参数控制,不通过复制投票组件实现。外链和素材链接不能覆盖当前编辑页。
3.3 为什么 widget 不等于完整预览
剪贴板引用先把正文拼进整篇 Markdown,再统一解析。引用内的列表、链接定义、代码 围栏等可能影响引用之外的内容。例如引用里未闭合的代码围栏,会改变后续正文的解析。 逐个 token 独立渲染,再拼成 widget,无法保证得到同样的结果。
隐藏标题标记、强调符号或链接 URL,也需要自己的语法判断和样式。Lezer 与 marked 的 语法树并不因此成为同一份。后续装饰只能承诺支持范围内的辅助显示,完整预览始终是 核对终稿的入口。若将来要求就地显示与整篇终稿严格等价,需另行设计源文到展开文档的 位置映射,不能把它当作普通 token widget 的小补丁。
3.4 前置重构的边界
建议拆出以下职责,名称在实施时可调整:
| 模块 | 职责 |
|---|---|
content-ref-processor |
保留整篇引用扫描、替换顺序、代码屏蔽、预算与降级语义 |
content-ref-resolver |
读取剪贴板 / 投票 / 收藏夹,保留完整解析结果,去重与管理缓存 |
blog-renderer |
整篇公式保护、marked / hljs、公式还原、DOMPurify,返回 HTML 与后处理所需数据 |
blog-content-dom |
有生命周期和清理入口的 DOM 后处理;正文交互与只读预览分开配置 |
| hljs 主题 hook | 共用亮 / 暗主题样式,处理多个实例并存时的生命周期 |
已有 src/lib/blog-markdown.ts 的净化白名单与投票安全构造继续作为权威。
src/lib/markdown-math.ts、src/lib/audio-refs.ts 和 src/lib/favorite-refs.ts
沿用现有职责。拆模块不得引入 Prisma 或其他服务端依赖到客户端包。
M0 先保持正文行为不变。可提供按 token 读取资源的接口,但它不负责决定整篇替换位置 或预算,也不代替整篇渲染入口。现有语法的兼容范围,包括数字 ID 引用中的空白容忍, 不能在抽模块时顺便收紧;具名 token 继续遵守白名单和不含空白的规则。
4. 资源面板、插入和上传
4.1 五类入口与复用范围
| 资源 | 现有列表读口 | 首版插入结果 |
|---|---|---|
| 图床 | GET /api/images,自己的未删除图片 |
标准图片 Markdown,沿用当前上传插入形状 |
| 音频 | GET /api/audio,自己的未删除音频 |
[@音频/<10位ID>] |
| 剪贴板 | GET /api/clipboard,自己的未删除剪贴板 |
[@<8位ID>] |
| 投票 | GET /api/votes,自己创建的未删除投票 |
[@<9位ID>] |
| 收藏夹 | GET /api/favorites,自己的收藏夹 |
仅有 public_id 的公开收藏夹可插入 [@<6位数字ID>] |
图床可复用 src/app/components/ImagePickerModal.tsx 的列表、搜索和渐进展示,适配
回调即可;其余四类新增面板。不要同时把图床计入「已有组件」和「必须新写的组件」。
这些读口目前没有通用服务端分页和搜索。首版在已取得的列表上过滤并渐进展示;如果 实际数据量要求服务端分页,再单独扩展接口,不能把客户端截断写成已经有分页协议。 面板沿用现有账号权限,不创建跨账号的资源广场;手动输入引用仍由实际读口判权。
表情与用户名片选择器目前用于评论 / 讨论。它们产出的 token 不被博客 / 剪贴板终稿 支持,因此首版不直接复用。若未来扩展,须同时更新终稿、匿名视图规则、预算、指南 与测试,不能只在编辑器里做一个会显示的 widget。
图片上传和图床选择默认统一插入 ,保持现有上传行为。
用户已有的 [@<10位ID>] 继续支持,不自动改写存量内容。两种形状的引用预算并不相同,
不得为了统一按钮而改动正文的既有数量限制。标准 Markdown 也不保证私有图片对站外可读。
4.2 插入必须走 CM6 事务
src/app/components/textarea-insert.ts 可参考选区捕获与焦点恢复的交互语义,不能直接
复用 textarea 的赋值、setSelectionRange 和量高实现。
- 打开面板前捕获选区;期间发生文档修改时,通过事务的 change mapping 更新插入区间。
- 工具条命令、token 插入、图片完成插入统一 dispatch,进入撤销历史。
- 明确多选区策略:格式命令可逐区间应用,资源选择首版只作用于主选区。
- 面板取消不改原文;确认后恢复编辑焦点和选区。键盘操作与移动端焦点行为需验证。
- 不对 CM6 管理的编辑 DOM 直接赋值或改结构。
4.3 上传生命周期
复用 src/lib/image-client.ts 的单文件校验、XHR 上传与弱网重试。它不负责选图、
拖拽事件、多文件调度或 CM6 插入;总体积常量也不是自动执行的多文件闸门。
集成层负责多文件校验和有限并发、进度 / 失败提示、插入位置及撤销。先保留每次上传 操作的锚点,并随事务映射;操作之间独立管理。多选按用户选择顺序插入,不能由网络 完成顺序决定排版。上传占位状态不写进持久化 Markdown。
用户删除上传锚点、撤销操作或离开编辑页后,迟到的结果不得重新插回原文。已成功 上传的资源仍留在图床;不能通过物理删除素材来补偿一次取消的编辑操作。
5. 缓存、异步与草稿
5.1 引用解析与缓存
缓存默认限于当前编辑会话,跨预览重渲染复用,编辑器销毁时释放。缓存键至少包括
资源类型、ID、读取模式;如果以后跨编辑实例共享,还必须隔离账号 / 会话上下文。
匿名 externalClips 使用调用方提供的快照,不复用成员模式的数据。
- 同一资源的并发读取复用 in-flight Promise;正文和投票后处理共用返回数据。
- 缓存只保留当前文档所需资源,并设容量上限。失败不能永久驻留:手动刷新可重试, 自动重试有次数上限,不在每次按键时重试。
- 预览更新防抖并使用文档版本 / generation;旧响应可充实合法缓存,但不得覆盖较新的预览。
- 卸载时取消或忽略未完成请求,不向已经销毁的编辑器 dispatch。
- 「刷新预览」使当前引用缓存失效并重新请求;401 / 403 按现有降级口径处理,不能 用上一份成功缓存掩盖本次拒绝。
- 替换名额按整篇文档及既有处理顺序计算,不按视口、唯一 ID 数量或各 widget 单独计算。
保留 MAX_BLOG_REF_ITEMS、收藏夹与音频的独立上限以及各自降级行为。代码屏蔽与
嵌入内容的扫描顺序沿用正文规则;缓存命中不会给超预算引用额外展示名额。
5.2 两处本地草稿
新建页保留原有正文缓存键和纯文本内容:
| 场景 | 键 | 初始行为 |
|---|---|---|
| 博客新建 | blog-upload-editor |
恢复旧草稿;成功发布后清除 |
| 剪贴板新建 | clipboard-upload-editor |
恢复旧草稿;成功创建后清除 |
| 两处编辑态 | 不使用上述新建键 | 从服务端原文初始化,不覆盖或读取新建草稿 |
不把两处草稿合成一个键,不为了改格式丢弃旧缓存。首版仍保存正文,不暗中扩成标题、 可见性等整张表单缓存。读取失败或 localStorage 不可用时允许继续编辑,并显示无法 保留本地草稿的提示;保存失败不得清草稿。
监听最新文档状态进行防抖写入,页面离开时尽力落下最后一份。发布成功后先停止待执行 的草稿写入,再清键,防止延迟回调把刚清掉的草稿写回来。新建时若同时存在服务端 初值和本地草稿,提示恢复选择,不静默覆盖任一份。
未来若要按账号隔离草稿,需要另写旧键迁移规则;本次不把历史键替换成不可读取的新键。
5.3 保存与失败回退
剪贴板的 Ctrl / Cmd+S、每分钟自动保存及其偏好
localStorage.clipboard_autosave_enabled 仍由表单负责。编辑器提供同步的最新原文
读取口,不从防抖后的预览状态取提交内容。避免同一次保存被 CM6 快捷键和页面监听重复触发。
组件初始化失败时保留基础 textarea 回退;编辑中若辅助预览或装饰失败,关闭该辅助 能力并继续编辑,不清空原文。两处表单的禁言判定、提交接口、长度校验和可见性规则 不在本次换代中改变。
6. 就地装饰的后续设计
6.1 启用条件与支持边界
就地装饰默认关闭。先用原型确认隐藏标记是否改善实际工作流,再按明确清单启用。 源码模式与完整预览必须一直可达;装饰出错时能退回源码显示。
第一批可考虑标题 / 强调 / 删除线标记、图片和音频辅助预览。剪贴板展开、跨行公式、 表格和复杂引用块分别验证,不能承诺它们只是同一个 widget 的不同皮肤。
选区与组合输入相交的语法区间保留源码;不简单套用「光标行」处理跨行语法。要定义 点击、方向键、拖选、复制和删除的行为,以及 widget 如何回到可编辑的 token。
6.2 CM6 装饰的提供方式
同步计算装饰不意味着装饰只能来自 ViewPlugin。
- 行内标记、搜索提示等不改变块布局的装饰,可由
ViewPlugin根据视口计算。 - 块级 widget 和覆盖换行的替换装饰,放在
StateField<DecorationSet>中,直接向EditorView.decorations提供;不能通过依赖视口的间接装饰提供。 - 异步 resolver 将结果通过
StateEffect写入状态,再同步生成装饰。 - 装饰区间随事务映射;需要整体移动 / 删除的范围使用
atomicRanges,同时定义 点击显示源码的方式,不能让 token 成为无法进入的对象。 - 图片加载、音频元数据和公式排版改变高度时,请求重新测量;widget 复用与销毁要 保留播放状态并清理事件,不能每敲一个字就重建媒体 DOM。
6.3 输入法与性能
组合输入期间保持活动区间的源码和 DOM 稳定,延后会替换该区间的装饰更新。文档仍 正常更新,已有区间仍须随事务映射;不能把「暂停装饰」实现成丢弃输入或停止位置映射。 组合结束后处理积压结果并重算,具体时序以 CM6 所选版本与真实输入法验证为准。
视口外的轻量装饰按需计算,整篇引用预算仍独立计算。正文最大 250000 字符,引用还 可能展开成长内容;不能每次按键同步执行整篇 hljs 自动识别、MathJax 和 DOM 重建。 完整预览防抖且可关闭,性能不足时保留手动刷新,不默认把问题转移到更多请求或无限缓存。
7. 分期、发布与回滚
每期有独立提交和验收范围。回滚是退回前一期代码与资源配置,文档格式和数据库不随 编辑器切换;本方案不需要 schema 迁移。不要通过在首版产品里长期维护两个编辑器来 代替回滚方案。
| 期 | 交付 | 完成条件 | 状态 |
|---|---|---|---|
| M0 | 抽共享引用 / 整篇渲染 / DOM 后处理 / 主题模块 | 两处正文行为不变,成员与对外模式回归通过 | ✅ 已实施 |
| M1 | 两处表单换 CM6;工具条、完整预览、上传、两处草稿、主题和失败回退 | 原文保真与保存回归通过,图片 / 公式 / 表格 / 既有引用可在完整预览查看 | ✅ 已实施(有两处偏差,见下) |
| M2 | 图床适配与其余四类资源面板 | 五类插入均能被完整预览和正文正确处理,私有状态与收藏夹可引用条件清楚 | ✅ 已实施 |
| M3 | 可选装饰原型及通过验证的有限能力 | 用户确认隐藏源码习惯;真实 IME、选区、撤销、媒体布局与性能通过 | ❌ 未实施 |
| M4 | 可选表格辅助、导出或大纲 | 有明确需求、单独范围和验收,不作为 M1 完成条件 | ⚠️ 部分(导出已随 M1 交付;表格辅助与大纲未做) |
M1 可以先在开发过程中验证博客,再验证剪贴板;发布时两处一起切换。完整预览在 M1 即交付,不能删除旧即时展示后等到 M3 才补图片与公式。五类面板在 M2 完成,因此 只完成 M1 时不得宣称资源集成目标已经完成。
表格首版提供源码编辑、基础插入模板和完整预览;可视化增删行列或对齐弹窗另行安排。 导出是否保留必须在 M1 发布前决定并记录;大纲、全屏和字数统计按实际已有使用与首版 需求确定,不用「很便宜」代替范围和验收。
M1 清理 Vditor 前保存实际编辑页的资源 / 体积基线,并核对字体、上传和回退路径。
回滚时同时恢复 package / lockfile、资源复制脚本与页面引用,执行 npm ci 和重新 build;
不能只换回组件却漏掉它依赖的本地资源。原来的两个草稿键保持兼容,回滚仍能读取。
7.1 实施记录(2026-10)
M0 交付:content-ref-resolver.ts(取数与缓存:类型化键、同键在飞去重、代际切换的
invalidate()、容量上限;'expand' 与 'external' 的分档判据也搬到了这里)、
content-ref-processor.ts(整篇扫描 / 按区间切片 / 盖码顺序 / 引用预算)、
blog-renderer.ts(protectMath → marked + hljs → restoreMath → DOMPurify 的唯一实现)、
blog-content-dom.ts(挂载后的 DOM 后处理,带 interactive 开关)、
useHljsThemeStyles.ts。MarkdownRenderer 的对外 props 不变,只多了
interactive / resolver / refreshToken 三个可选入口给编辑器预览用。
M1 交付:两处表单共用 src/app/components/MarkdownEditor.tsx;工具条(三档视图、
格式按钮、上传、导出 HTML / 打印);src/lib/md-editor/* 的命令 / 草稿 / 上传调度 /
上传锚点;主题走 markdown-editor/cm-theme.ts;依赖逐个钉死精确版本。
M2 交付:markdown-editor/ResourcePanel.tsx + md-editor/resources.ts(五类资源,
插入语法与解析同源,见 docs/architecture.md §6.7)。
与本文的偏差(两条)
- §4.1 的「五类各一颗工具条按钮」实装成一颗「插入引用」按钮 + 五颗标签。工具条在 窄屏本来就会折行,再挂五颗(还要拆出四个弹窗)是把同一套加载 / 搜索 / 空 / 失败 状态抄五遍。复用范围、权限边界与插入语法仍按 §4.1 / §4.2 执行。
- 导出在 M1 就交付了(
markdown-editor/export.ts:下载 HTML / 打印),没有等 M4。
已知限制(没做到的部分)
- ⚠️ 没有真机与真实输入法的证据(§9.3 的验收项)。e2e 跑的是 Playwright 的 iPhone 13
模拟 +
keyboard.insertText()—— 它把字符直接塞进编辑区,绕过了 IME 的组合输入 路径。中文 / 日文候选框、compositionstart/compositionend期间的行为、触屏软键盘 顶起视口之后的滚动与光标可见性,一律未验证。 - ⚠️ 输入性能只做过本机 headless 的一次取样,不是 §9.3 要的验收预算 (取数与口径见下方 §7.1.4)—— 真人打字曲线、真机、以及接近 250000 字的长文 都还没量过。
- 基线与回滚演练已补做(2026-10,见下方 §7.1.4)—— 上一版这里写的是「基线补不 出来」「回滚没演练过」,两条都已作废。性能那条另有取舍,一并写在那一节。
- 剪贴板编辑态没有本地草稿(沿用旧行为,不是本次引入):草稿键只在新建页读写,
所以编辑页提交后继续键入的那一段不会被草稿留下。⚠️ 但**「提交成功就跳走」已经不是
现状**:有未保存的改动时(新建态与编辑态同一条判据)不跳转,留在页面上并点名说清
改了哪几格(§7.1.2 第 1 条、§7.1.3 第 1 条)—— 编辑页那一版改动因此不会随跳转消失,
只是它也没进草稿。博客编辑页同款。
新建页那两处「在飞时继续键入」的竞态另有 e2e 钉着(
tests/e2e/editor-draft.spec.ts)。 - 就地装饰(M3)未做:源码里看不到图片 / 公式的渲染结果,要切到预览档才看得见。
7.1.1 加固轮(2026-10,交付后复审)
第一版交付后过了一轮外部复审,逐条修完后新增的限制记在这里(实现与理由在各自 文件头,不在这里复述):
- 上传图片的 alt 文本里,方括号写成 HTML 实体(
![图[1].png](…)), 反斜杠和换行按标准 Markdown 处理。不能改成\[…\]:那正是 LaTeX 块级公式的 定界符,公式保护先于 Markdown 解析出手,会把这一段吃掉(顺带把公式计数 +1), 表现是 alt 里凭空多出反斜杠。见src/lib/md-editor/upload.ts的escapeAltText。 - 导出件只保证「正文 + 已经展开的内容」自足:正文里的站内相对地址补成了站点
绝对地址、导出件内部的
#锚点留在文件自己身上(故意不写<base>)、代码块的 复制按钮摘掉。但样式表与媒体仍要联网,而且按站内读口判权 —— 私有图 / 私有音频 在别处打开读不到,MathJax 的字体也一样(它是url(/static/mathjax/…),只补了地址)。 没有做「把媒体打进压缩包或内联成data:」的打包器,别把它当成待补的缺口: 那会绕开权限判定。 - 打印等的是「资源画得出来」,不是「写完 iframe」:等
document.fonts.ready与 未完成的<img>/<link>,上限 3 秒(PRINT_READY_MAX_MS)。到上限照打, 绝不吊死;走的是隐藏 iframe,没有window.open弹窗那条路。 - 引用展开是单趟的、不递归:正文里引用出来的剪贴板正文,其中再出现的
[@…]不再展开。这是 M0 就跟下来的口径(草稿内容里的 token 不该在别人的文章里二次取数), 不是这一轮的缺失。 - 保存保护只覆盖「提交了这一趟、回来之后又改过」:新建页照发但提醒(并把还有 哪些字段变了写进提示),编辑页不跳转,让人自己再存一次。判据是提交时那一趟的 字段快照(标题 / 摘要 / 可见范围 / 正文都在内),不是只比正文。编辑态的本地草稿 仍然没有(沿用旧行为,见上一条)。 ⚠️ 这一条是那一轮的实际行为,已被后续改动扩大,别照它描述现在:加固轮当时只有 「新建页照发 + 提醒、编辑页不跳」;第二轮复审(§7.1.2 第 1 条)把新建态也改成不跳 转(那些字段没有草稿接得住),云剪贴板同样处理(§7.1.3 第 1 条)。
- 导出 / 打印取的是当下的正文:走的是同一套渲染管线(
ContentRefProcessor→renderBlogMarkdown→enhanceBlogContent)在离屏容器里现渲一份,不读预览那棵 防抖 DOM。文件名与<title>都取此刻的标题;渲染失败给一条错误提示,不产出半成品。 - 性能只留下了可回归的那一条:这一轮钉住了「同一份正文的重复渲染不再重发投票请求」
(
tests/unit/md-editor-preview-votes.test.ts)。体积与一帧延迟的实测补在 §7.1.4 —— 那是本机 headless 的一次取样,不是 §9.3 要的验收预算(真人打字曲线、真机、 接近 250000 字的长文都还没量)。 - ⚠️ 「把预算整体挪到取数之前」不是等价优化(复审提过一版「省掉不会展开的那些
请求」):名额是「取到了才占一格」的 —— 取不到的(超预算 / 被拒 / 404)保持字面量、
不吃后面那些取得到的名额(
content-ref-processor.ts里那句continue就是这件事)。 所以哪几条会被丢掉,只有取完才知道;按扫描顺序提前把预算用完,会在「前面几条恰好 取不到」时少展开后面本来出得来的引用,屏幕上看着只是「引用没展开」,不报错。 这只是说这一种改法不等价,不等于取数次数没有可省的地方 —— 真要少发请求,得先有 「哪几条一定会被展开」的判据(例如按引用类型与档位分预算),那是另一件事,本轮没做。
7.1.2 第二轮复审(2026-10,七条 + 收尾一条)
第二轮复审又提出七条,逐条修完后行为上的差别记在这里(理由与判据在各自文件头, 单测 / e2e 的落点见括号):
「有未保存的改动就不跳」对新建态也成立(
BlogForm.tsx)。此前只有编辑态不跳; 新建态改了标题 / 摘要 / 可见范围之后照样跳走,而那些字段没有草稿接得住(草稿 只存正文)。跳转前那一次复核也不再是「弹一句提示然后照跳」——现在跳与不跳的判据 完全一致:整份快照(标题 / 摘要 / 可见范围 / 匿名开关 / 正文)里只要有一格变了, 就留在页面上并点名说清是哪几格。新建态留在页面上要付一个代价:这一页已经建出 过一篇文章了,再点一次若还是 POST 就会多出一篇。所以成功之后把刚建出来的 id 钉在内存里,之后的提交走PUT /api/blogs/<id>,按钮也改口叫「保存修改」。 ⚠️ 钉的是内存里这一页的身份,刷新页面会丢(再提交仍会新建一篇)——这是刻意 接受的:草稿横幅恢复的是「上次没发出去的正文」,服务端那一篇还在,用户从文章页 看得见。要根治得把「本页已建出的 id」也落进草稿,不在这一轮。 另外,开始新一笔保存前先撤掉上一笔的待跳转定时器:那枚旧定时器只认上一次的快照, 会在新请求还在飞的时候把页面抢走(第二笔的结果再也看不到)。 (tests/e2e/save-navigation.spec.ts三条:编辑态窗口里改标题、新建态窗口里改正文 并确认再提交是 PUT 那一篇、旧定时器不许抢页面。)云剪贴板提交的并发(
UploadForm.tsx)。两个「提交」在同一笔在飞时各挂一次await,它一落地两边同时往下走,各自doSave—— 从头到尾两笔写请求叠着跑,新建态 就是两篇剪贴板。现在等完要循环重判锁(第 2 圈一定看到「锁已被拿走」,因为拿锁 那一段是同步的),并在「刚落地的那一笔成功且存的就是现在这一份」时把这次点击合并 掉。手动 / 自动保存(Ctrl+S、每分钟)与在飞那一笔仍是互斥:只回一句「正在保存」, 不排队。新建语义保留:第一笔落地之后再按 Ctrl+S 照旧是新的一笔。 ⚠️ 排队者补发时写的必须是第一笔已经钉下的那一篇(PUT),所以那个 id 存的是同步 ref(UploadForm.tsx的pinnedIdRef)而不是显示用的 state:排队者攥着的是发起 排队那次渲染的闭包,那份pinnedId在它眼里永远是 null —— 读 state 就会补发一笔 POST,同一份内容多出第二篇(页面还跳去第二篇,与刚弹的「再点一次是更新这一篇」打架)。 (tests/unit/clipboard-save-lock.test.ts:连点三次只发一笔;两个排队者 + 等待期间 改了内容时只补发一笔、且那一笔是 PUT 到首篇;排队期间真改了就必须补发; Ctrl+S 不排队;tests/e2e/save-navigation.spec.ts另有三条真浏览器用例:按住响应 连点、首笔悬着时排队并在排队期间改标题与公开状态、Ctrl+S 在飞时点提交。)资源面板搜索框:组字期间的按键交给输入法(
markdown-editor/ResourcePanel.tsx)。 Enter 是「确认候选」、↑↓ 是「翻候选词」——拦下来的后果是字打不进去(候选选不中), 而面板看着一切正常。判据两条一起用:nativeEvent.isComposing,外加本地composingRef—— 两者覆盖的是组字期间不同的到达顺序(keydown 早于 compositionstart,或落在 compositionend 复位标志之前)。⚠️ 它们管不到「compositionend 之后那一下** keydown」: 那时原生标志可能仍是 false、本地标志已在 compositionend 里复位;这一格刻意不补 时序补丁**(没有任何实测证据支撑某种输入法的具体行为,加了只会制造新的静默错判)。 这一行必须在任何preventDefault之前。 (tests/unit/md-editor-resource-panel.test.ts三条:两条路各测一遍、↑↓ 不动高亮。 ⚠️ 合成事件不是真 IME 证据,§9.3 那条仍然空着。)打印等的是「样式 / 图片 → 布局 → 字体」这个顺序(
markdown-editor/export.ts)。 刚doc.write完那一瞬间样式表还没解析、也就没有@font-face和字体请求,此时读document.fonts.ready是个已经兑现的 Promise —— 看着等了,实际一个字体都没等, MathJax 的公式用回退字体打出错误字形且不报错。所以先等<img>/<link>、再走两帧让 布局发生,然后才读当时的fonts.ready;整段是一个 3 秒总上限,连那几帧也算 在里面(requestAnimationFrame在后台标签页里不回调 —— 裸等它就没有上界, 真机上「点了打印没反应」最像的一种)。到点照打,不再往下等。 (tests/unit/md-editor-export.test.ts:字体必须排在布局之后;资源一直不来也照打; rAF 完全不回调时预算到点仍打印。)连点两下导出 / 打印要说话(
MarkdownEditor.tsx)。snapshotBusyRef挡掉第二份是 对的,但静默早退在用户那边就是「点了没反应」—— 而这一趟偏偏最慢。现在第二下给一句 「正在生成导出内容,请稍候…」。tests/e2e/editor-hardening.spec.ts用page.route把引用取数按住在飞,断言提示出现且只下一份。引用展开的「本轮快照」(
content-ref-processor.ts/MarkdownRenderer.tsx)。 处理器是「先await取数、再替换」两段式的,第二段若回头去问会话缓存,中间任何一次invalidate()(「刷新引用」、另一次预览渲染)都会让缓存变空 —— 正文里的引用静默 退成字面量,越慢的接口越容易撞上。现在第一段await的返回值就是这一轮的唯一真值 (preprocessRound交出来的entries),预览的投票小组件也从同一份快照取数。 ⚠️ 这条数据口只给带会话级resolver的调用方(编辑器预览):正文页不传,小组件 照旧自己拉一条,行为与换代前逐字一致(resolver那一段的口径因此仍然成立)。导出件里的地址(
markdown-editor/export.ts)。//cdn…这类协议相对地址补上本站 协议(否则从file://打开会解析成file://cdn…),普通相对路径按这一页的地址 解析(../、./、内联style与正文里<style>的url(...)都在内),纯#fragment的链接摘掉target="_blank"。没有改安全白名单、取数权限或 session cookie:媒体仍要联网、仍按站内读口判权(§7.1.1 那条不变)。重渲染不再把正文 DOM 整块重写(
MarkdownRenderer.tsx)—— 这一条不在那七项里, 是收尾拍截图时撞出来的(想拍一张「完整预览」,结果公式在预览态显示成$$…$$原文)。 React 19 diffdangerouslySetInnerHTML用的是对象身份:react-dom里判prevProp !== nextProp才走setProp,而setProp里才去读__html。内联的{{ __html: doc.html }}每次渲染都是新对象 → 任何一次父级重渲染(切一下视图、 字数统计变化都算)都会把容器 innerHTML 整块重写。而正文后处理(MathJax 排出来的mjx-container、投票小组件)是 commit 之后那次效果做的,效果只认 doc 那几个依赖 —— 「DOM 被重写」它看不见,于是不会重跑。症状就是上面那句,控制台一个字都不报。 修法:把这个对象按docmemo 住,doc 变了才重写,与效果的重跑严格同步。 (机制本身:tests/unit/blog-ref-render.test.ts的「重渲染不重写正文 DOM」; 用户真会撞上的那个动作:tests/e2e/editor-hardening.spec.ts的「切视图不抹掉预览」。 两条都做了反向验证 —— 把 memo 拿掉就是红的。)
7.1.3 收尾复核(2026-10,定点复核后)的四处实质修正
第三轮只动四处,都是复核点出来的「静默丢东西」类:
- 云剪贴板:新建态在飞改动不再随跳转丢掉,且留在页面上会钉住刚建出来的那一篇
(
UploadForm.tsx)。此前「有未保存的改动就不跳」只判isEdit—— 新建态请求在飞时 改标题 / 公开状态(正文有草稿接得住,这两格没有),响应一落地照样router.push, 改动无声消失。现在新建 / 编辑同一条判据(整份快照里有一格变了就留下、点名说清是哪几格), 并按BlogForm.pinnedId同款机制把提交成功那一次建出来的 id 钉在内存里:再点提交 走PUT /api/clipboard/<id>,不会多出第二篇,按钮也从「提交」改口叫「更新」。 ⚠️ 只在「提交」这条路上钉 —— 新建页的 Ctrl+S 是「另外存一篇上去」的既有语义 (连按两下就是两篇),把它一起改成「第二下起是更新」是另一件事,没有做。 - 合并掉的提交要把「去看它」补完(同上)。「Ctrl+S 在飞时点提交且没有改动」这一路
此前只是
return:写请求被正确合并掉了,但这次提交的导航也跟着没了(不跳、不提示), 用户以为没生效、再点一次 —— 那时锁已空,新建态就真的又发一笔 POST、多出一篇。 现在合并分支记的除了「刚存下的那份字段」还有它落在哪一篇,提交这一路据此补完最后 一步(同一篇只跳一次;在飞那笔失败则什么都不记,这一次提交照旧写得出去)。 (tests/unit/clipboard-save-lock.test.ts三条:Ctrl+S 在飞时提交要跳过去、失败仍可重试、 新建态在飞改标题与公开状态后留下 + 再提交是 PUT 同一篇;tests/e2e/save-navigation.spec.ts两条真浏览器回归。) - 打印的有界性扩到那几帧(
markdown-editor/export.ts)。顺序(样式 / 图片 → 布局 → 字体)不变,但requestAnimationFrame在后台标签页里不回调 —— 裸等它就是一个没有 上界的等待,print()永远不执行,屏幕上只有「点了打印没反应」。现在整段等待(含 那几帧)共用同一个 3 秒截止,到点照打。 (tests/unit/md-editor-export.test.ts新增「rAF 完全不回调」那条:预算到点仍然打印。) - 预览里的音频播放器不再被裁(
src/styles-scss/pages/blog/_blog.scss)。截图复核时量到的: 手机 390 下预览滚动盒scrollWidth 336 > clientWidth 276,<audio>是浏览器默认的 300px、 右边缘越过正文列 80px —— 控件右半截要横向拖动才看得见。根因是.blog-content-container里img有max-width: 100%而 audio 一条都没有。现在只给 audio 补max-width: 100%(宽屏不动,仍是 300px;不是width: 100%)。 ⚠️ 这个溢出documentElement.scrollWidth量不出来(外层没溢出,是滚动盒子吃掉的), 判据只能落在预览容器与控件自己的 boundingBox 上 —— 见tests/e2e/editor.spec.ts的「预览里的音频控件不撑破正文容器」(拔掉那条 CSS 即转红)。
另外两处只是注释与文档的事实校正,没有行为变化:ResourcePanel.tsx 原写「Safari 在
compositionend 之后那一下 keydown 上给 false,本地标志兜住它」——说反了也说不通(本地
标志正是在 compositionend 里复位的),已改成「两条判据覆盖的是组字期间不同的到达顺序」,
并写明「compositionend 之后那一下」刻意不补时序补丁(没有实测证据);
content-ref-resolver.ts 的 peek 原写「那时所有 resolve 都已 await 完(所以读得到)」——
await 过不保证随后读得到(invalidate() / 容量淘汰 / 本来就取不到),已改成真实条件,
并点明现在的替换阶段用的是 resolve 的返回值。
量了但没改的两处(复核里的视觉项,结论都是「不是缺陷」):资源面板五个标签的「当前档」
在真实 DOM 里是对的(只有它带 is-active + aria-selected="true",焦点不参与)—— 旧截图里那幕
「图床亮着、列表却是剪贴板的」是站点那条全局 300ms transition 的中间帧,
现在截图改在过渡结束后拍;hover 与当前档同色(btn-tab 共用同一套底色)是真实的
轻微歧义,但属共享 mixin 的观感口径,本轮不动。
(这两条的原始记录(截图夹具与 tab-probe.json)在站外的复核工作目录、不在本仓 ——
所以只能这样文字点名;写成反引号路径会被互指守卫当成断链。)
⚠️ 仍然没有的:真机打印对话框下的 PDF 字形(只能证明等待顺序与有界性)、真中文输入法 与移动端浏览器、以及 §9.3 的性能预算项 —— 都还没有证据,本轮也没有加快捷结论。 (体积与输入延迟在定版上做过本机 headless 的一次取样,见下面的 §7.1.4 —— 它是「没有变差」的量级参考,不是 §9.3 那条验收。)
7.1.4 基线与回滚演练(2026-10 补测;体积与延迟只在当时定版、历史被测提交 9c13871e 上由独立会话复测)
⚠️ 本节所有体积 / 延迟数字都属于历史被测提交
9c13871e(当时的定版),不是当前 HEAD。 那之后编辑器域又继续改动(见 §7.1.5、§7.1.6),这些数字不能用来描述 修复后的版本 —— 要「现在的数」必须重测、重做基线,本轮没有做。
- 测量环境(复测那一轮):被测提交
9c13871e(用git archive出的仅跟踪 文件快照,隔离核验过无.env/instance//.agents/);Next 15.5.20 (package.json声明^15.1.4,同大版本)、React 19.2.7、Prisma 6.19.3、@codemirror/state6.7.6、Node v22.17.1;浏览器是 Playwright 打包的 Chromium 149(headless);next start起在隔离端口、NODE_ENV=production。 全部只在本机回环上,不外推到真实网络。 - 体积:主指标是「观察窗口内
performance.getEntriesByType('resource')的encodedBodySize合计」(资源响应体压缩后大小),不是无条件的真实网络传输量 —— 不含响应头、连接建立与 TLS。同一窗口里transferSize逐轮精确等于encodedBodySize + 300 × 资源条数;那 300 B/条是 Resource Timing 的报告口径约定, 别当成真实包头字节(列出它只为说明那份额差的来源)。 三档:893126 B(旧,回滚点 f4762bb)→ 995911 B(中间 5a1e0bf3)→ 998362 B (最终 9c13871e),最终比旧版 +11.78%;decodedBodySize对应 4947256 → 3243065 → 3250316(解压后显著更小)。最终版那一轮 3 次里 run1 是 31 条资源 / 997331 B / decoded 3249251,run2 与 run3 是 39 条 / 998362 B / decoded 3250316(基线里也出现过同样的 31/39 形状)—— 报告取稳定档、run1 照实 保留。⚠️ 中间那版(5a1e0bf3)不是最终版,别拿 995911 当「现在的数」。 - 一帧延迟:先往编辑区填好一份 2 KB 或 50 KB 的文档当作起点,再每轮连续
100 次、每次只
insertText一个字符,逐次量beforeinput→ 下一次requestAnimationFrame回调执行时刻(toNextFrameMs;另有一条beforeinput→ DOM 反映的perKeystrokeMs作对照,p50 只有 0.6–0.8 ms,远小于一帧)。 ⚠️ 量的不是「一次灌进整篇正文要多久」 —— 一次insertText就是一个字, 文档是量之前就填好的。最终版 3 轮 × 100 次:源码档 2 KB p50 8.5–8.6 ms、 50 KB p50 8.7–9.2 ms(p90 约 15 ms、max 16.8–18.4 ms),2 KB 与 50 KB 几乎无差 —— 这与 CM6 的视口虚拟化相符(只渲染可见行:100 次输入之后 DOM 字数各自只 +100,2 KB 是 778→878、50 KB 是 378→478)。 旧版(f4762bb)同口径:50 KB 档 p50 33.1–33.3 ms(p90 67–69、max 71–86), 而它 2 KB 档本来就是 8.9–9.4 ms —— ⚠️ 所以那个「旧版 33 ms」只指长文那一档, 不是旧版对任何文档都这么慢。 ⚠️ 样本是insertText直塞、不是真人逐字打字,也不是长文输入延迟曲线 —— 「没有变差」可以说,不能当验收预算。 - 并排档单独记,不与上面合并:最终版并排档同样 3 轮 × 100 次,2 KB p50 8.8–9.0 ms / 50 KB p50 8.9–9.3 ms。并排档三轮的响应体三值完全一致:39 条 / encoded 998362 B / decoded 3250316 —— 与源码档的 run2、run3 相同(预览相关资源在 源码档就已下载,切档只切 CSS 显隐);⚠️ 但源码档 run1 不是这一组(31 条 / 997331 / 3249251),所以别把「同值」写成「源码档逐轮都等于并排档」。 墙钟也逐轮不同 —— 源码档 223 / 201 / 191 ms,并排档 193 / 194 / 192 ms —— 同样 别写成「两档完全一样」。 ⚠️ 可比边界:旧版 vditor 是 IR 单栏(源码与渲染在同一块区域),新版并排是 双栏(左源码 + 右预览),DOM 结构、重排范围、可见区尺寸都不同。同一套公开事件指标 测出的两组数只能当两种真实工作流的描述性对照,不能相减求「编辑器内核单独的 收益」,也不能说两者配置相同。能说的只有:在新编辑器内部,源码档与并排档差别很小。
- 源码档不是「停了渲染」:源码档下预览面板仍在 DOM 里、调度机制仍启用(面板不在
任何
mode === …条件里,updateListener里每次文档变动照样schedulePreview, 400 ms 防抖),只是被 CSS 隐藏 (.md-editor[data-mode='edit'] .md-editor__pane--preview { display: none })。 ⚠️ 「机制启用」≠「输入窗口里真跑过渲染」:测量用的两篇文档只有标题 / 段落 / 列表、mathCount = 0(不含公式,MathJax 那一层根本不会启动),而连续insertText的 间隔远小于 400 ms 防抖、排好的那次渲染会被不断推迟 —— 本轮没有统计这 100 次 输入里它实际执行了几次、各花多少时间。所以既不能说样本「包含了后台预览的排版代价」, 也不能把两档指标相近归因成「预览在后头跑」。别把「编辑档隐藏预览」描述成 「停了渲染」。 - 几何(同环境,1280×900 与 390×844):两张表单的页面、资源对话框、五档面板
overflowPx(scrollWidth − clientWidth)全为 0(面板宽度桌面 508 / 手机 298); 预览态音频原生控件在窄屏收缩到正文列宽(blog 204 px / clipboard 228 px, 越界 0),桌面上保持原生 300 px —— 与主会话shots-final那套夹具独立吻合。 ⚠️ headless Chromium 用 overlay 滚动条(verticalScrollbarPx恒 0),所以 「有滚动条时会不会溢出」这一层在这个环境里量不出来。 - 归因边界:不把 JS 体积增量全部归给 CodeMirror(也不做别的无证据因果解释)。
现有旁证只是启发式:
/blog/upload路由自带 14 个 chunk、gzip 合计 865431 B, 其中最大的单个 chunk(7642-…js,gzip 527983 B)在旧、新两个构建里解压长度相同 (1794612 B)、关键词命中 MathJax + hljs。⚠️ 长度相同 ≠ 内容逐字节相同(两个构建的 sha256 本就不同),关键词命中 ≠ 模块归属 —— 这不是 bundle attribution。 - 回滚契约仍成立,本轮未重跑演练:最终版与 5a1e0bf3 两个快照逐字节比对,
prisma/schema.prisma、prisma/migrations/、package.json/package-lock.json全部相同(无新迁移、依赖未动),两个草稿键(blog-upload-editor/clipboard-upload-editor)与草稿载荷形状也未变。唯一相关改动是md-editor/draft.ts的dispose():先flush()再摘pagehide监听(站内换页是客户端跳转、pagehide不发,只有 React 卸载;不改的话最后 500 ms 内敲的字会丢)—— 这是「何时落盘」的时序 修正,不影响旧版本读回草稿。回滚演练此前已实测通过(退回旧构建 f4762bb 后两个草稿 键都照旧读得到、正文编辑保存后哈希校验通过),契约没变,故本轮按指示不重复跑。
仍未验证(这次测量不代表已验):真实中文输入法(全部样本是 insertText 直塞,
绕过组合输入)、真机(390×844 只是桌面 Chromium 上 setViewportSize 的布局宽度)、
Safari / 微信内建浏览器、真实网络(只有本机回环)、接近 250000 字符的长文预算,
以及真实打印下的 PDF 字形。
测试记录(截至 9c13871e)
- 完整单测:
npm run test整轮全绿发生在994455a8—— 169 个文件 / 3748 条。 ⚠️ 那之后没有再跑过全量,所以这句不能读成「定版9c13871e也全量跑过」。 - 最终定版
9c13871e(补发修复)实际跑的:相关单测 73 条,加tests/e2e/editor.spec.ts与tests/e2e/save-navigation.spec.ts的浏览器用例 23 条(desktop 与 mobile 两个 project 合计),npx tsc --noEmit与npm run build也通过。这些都是局部,不是全量。 - ⚠️ 一条 flake 尚未定性:
tests/e2e/blog.spec.ts的「记忆恢复」那条曾单独跑失败, 原因是 Playwright 的 locator strict mode violation —— 排序切换时router.replace('/blog?sort=updated')期间两个.blog-list容器并存约 1.6 秒,#id<种子文章>这个 locator 那一刻命中 2 个元素就报错(不是 React StrictMode: 它不是渲染两次,是页面上真的短期有两份列表)。整文件复跑 21 条通过, 且它与本轮改动无交集(git diff里没有任何src/app/blog/文件)。 ⚠️ 但它没有在旧版(f4762bb)上实跑过 —— 所以既不能说这个失败是本次改动引入的, 也不能说它是既有的;只能照实记成「一次未复现的 flake」。 - ⚠️
npm run check:links不是全通过:仍有两条已知失败 ——/api/avatar(src/lib/avatar-refs.ts)与/api/frames(src/lib/frame-refs.ts)。这两处是前缀 常量('/api/avatar/'、'/api/frames/'),真实的 route 是带动态段的 (api/avatar/[id]、api/frames/[key]),脚本把前缀当完整地址去核对,于是误报。 写「守卫全通过」时必须带上这两条。
7.1.5 独立审查轮的五个修复(2026-10)
一轮独立(Codex)审查在编辑器域报了五条,逐条修完。实现动机与判据写在各自文件头, 这里只记「修了什么、实跑结果、边界在哪」。
预览里代码块的「复制」按钮会顺手提交外层表单(P1)。按钮 HTML 少一个
type="button",而只读预览整块挂在博客 / 剪贴板的<form>下 —— 省略type的<button>默认submit,于是「必填字段填好 → 预览里点一下复制」会当场发出去一篇文章 (剪贴板那边是一次没打算做的保存)。修法是给渲染出的按钮补type="button"(白名单里保留了type)。真实浏览器回归:填好必填字段、点复制,确认两张表单 一个写请求都没有、页面不跳转、正文还在(tests/e2e/editor-hardening.spec.ts)。 ⚠️ jsdom 是能抓这一条的(它执行提交按钮的隐式提交:未写type的按钮点一下会 触发提交、写了type="button"则不触发),此前「单测里永远绿、那里没有表单默认动作」 的说法有误 —— 那几条单测之所以绿,是因为它们断言的是渲染出的 HTML 字符串、根本没点 那颗按钮。真正只有真浏览器验得到的是链路末端:真的发出写请求、真的把页面带走 (jsdom 不发起请求、也不导航)。已完成上传槽位的固定长度不随编辑映射(P2)。槽位原来只记「当时插了几个字符」, 用户给第一张图补 alt 之后再完成第二张,图会插进第一张的
alt/URL里。改成每个槽 记自己的区间[from, to)、随每个事务映射(from 贴右、to 贴左)——区间内部改多长就 跟着多长,两端插入不撑开它,第二张永远在第一张后面。选择顺序、乱序完成、独立批次、 撤销的既有行为不变。回归里补了上传未完成时修改已插入文本这一路(不只是纯乱序)。删除锚点后迟到结果仍写回正文(P2)。判据落在锚点左右两个邻居字符是否都被这次 改动删掉(文档边界算「本来就没有那一侧」):全选删除、文档中间删除都判得出,而删掉 锚点一侧、正常打字、空文档上传一律不误伤。判「锚点没了」即整批退场,组件放弃插入。 修正了一条反向断言(旧用例把「重新插回去」当成预期)。撤销不会让退场的批次回来 ——一批上传只对它出发时那份正文负责。素材不删(图床里照旧留着)。
行前缀命令的落点手算,行首算出负坐标(P2)。取消标题 / 取消列表 / 取消引用 / 减少缩进原来写
位置 ± 前缀长度,光标在行首时是0 - 2 = -2:这一步不报错, 下一次改动才炸RangeError: Invalid change range -2 to -2(报错的是点粗体那一下)。 三条命令统一改走ChangeSet.mapPos。回归覆盖行首与前缀内部两种光标,每条都跑一次 后续格式命令(只断文本抓不到),另有一条反向护栏钉「不是一律甩到行首」。组字期间的 Escape 冒泡关掉资源面板(P2)。搜索框自己的
onKeyDown早就在组字期间 让开了,但 Escape 冒泡到面板挂在document上的监听,那里原来无条件onClose()—— 中文用户打了一半拼音、按 Esc 想收回候选,整个面板跟着关了。document监听收的是原生 事件,判据与搜索框同源(原生isComposing+compositionstart/end翻的本地标志)。
实跑结果(本轮提交,HEAD 8b490b1f)
npm run test(全量单测):首次 169 个文件 / 3778 条全部通过(含本轮新增 30 条)。 ⚠️ 但那不是「每次都全绿」:随后的复跑里出现过一次 —— 3778 条全部通过之外, vitest 另打印了一行Errors 1 error(unhandled)。那一行的正文当时没被捕获, 之后两次复跑也未再出现。所以它的来源、以及是否与本轮改动相关,都未确定:既不能 说它和本轮无关,也不能把它定性成既有问题 —— 只能照实记成「一次只出现过、之后未复现 的观察」,不当作已解决。npx tsc --noEmit、npm run build通过。- 受影响浏览器回归
editor/editor-upload/editor-hardening/editor-resources/save-navigation:57 条通过(desktop 全跑、mobile 按RESPONSIVE_SPECS跑editor与editor-resources)。 - 新断言的反向验证:第 2、3 条在旧实现(槽位区间冻结、无退场判定)上实测 8 条红;
第 4 条在旧算式上 6 条红;第 5 条两条信号各红一条;第 1 条在把
type="button"去掉并 重新 build 之后,e2e 报出「点预览里的「复制」触发了写请求」。都不是只镜像实现。
边界(不要让上面的结论外溢)
- §7.1.4 的体积 / 输入延迟数字仍然只对应被测提交
9c13871e,本轮没有重测、没有重新 做基线,也不能拿它们描述本轮的修复后版本。 - 第 5 条的用例是合成事件(
compositionstart/end与带isComposing的keydown), 不是真 IME 验收 —— 真中文 / 日文输入法的候选框行为本站仍无实测证据(同 §7.1.4 末的 「仍未验证」清单:真机、Safari / 微信内建浏览器、真实网络、长文预算、真实打印)。
7.1.6 独立只读复核后的边界修复(2026-10-08)
一次只读复核(未改仓库文件)复现了 §7.1.5 五条修复的正向结果,同时在第 2 条 引入的上传槽区间映射上抓到一条新的静默错序回归,另指出两处注释事实误述。本轮只修这些。
1. 已完成槽被整段删除再撤销后,迟到的下一张插到了它前面(本轮新引入,P2)。 第 2 条把槽位改成区间后,已完成槽的那段正文被整段删掉时区间会塌成一点 —— 与 「还没完成的 pending 点」在数值上一模一样。零宽分支此前一律贴左映射,于是撤销 (Ctrl+Z,在正文上的形状 = 把删掉的那段正文原样插回这个点)时这个点原地不动, 第二张图落到了恢复回来的第一张前面:版面顺序与选择顺序相反,不报错、不留日志。 剪切后粘贴是同一改动形状,同样复现。
修法是判据改用 status:done 的零宽槽两端各贴各边(from 贴左、to 贴右),撤销
插入时区间重新张开盖住恢复回来的那段,后一张因此仍旧落在它后面;pending / failed
仍是「该插哪儿的点」、两端同向贴左(在那个点上打的字不会被认成槽内容)。两端各自贴边
天然保证 from <= to,不会出现负区间。既有的「整段删除且不恢复 → 第二张落在原位置」
不变;整批退场规则(isAnchorGone)一字未动 —— 它与「单个已完成槽塌零」是两件事,
文件头已分开写清,别再互相套用。
2. 两处注释事实误述(仅注释,行为无碍):
commands.ts头部把mapPos(…, 1)(贴右)落在被删 / 被替换区间内部的收口写成 「区间起点」,实为这段改动新内容的终点(纯删除时新内容为空、起点=终点,故行为无差、 仅措辞错)。editor-hardening.spec.ts(及本文件 §7.1.5 第 1 条)称「jsdom 没有表单默认动作、 单测永远绿」—— 事实相反:jsdom 会执行提交按钮的隐式提交(未写type的按钮点一下 触发提交、type="button"则不触发),那条单测绿是因为断言的是渲染出的 HTML 字符串、 根本没点按钮。两处都改成准确理由:这几条验的是真表单标记 → 真点击 → 隐式提交 → 真网络请求与导航这一整条链路(jsdom 不发起请求、也不导航)。
验证(本轮提交)
- 新增真命令回归(
@codemirror/commands的 undo / redo,配isolateHistory隔离 删除组 —— 不隔离时 undo 会把「插 A + 删 A」并成一组,测的就成了「回到上传前」), 与剪切后粘贴、完整删除不恢复、乱序完成、跨批次合在同一个 describe,共 +7 条。 回退到本轮初版的零宽映射(两端同向贴左)时其中 4 条实测变红 —— 不是只镜像实现。 - 相关单测
tests/unit/md-editor-upload-anchors.test.ts37 条通过(本轮前 30)。 npx tsc --noEmit、npm run build通过。- 受影响浏览器回归
editor/editor-upload/editor-hardening:36 条通过 (desktop 全跑、mobile 按RESPONSIVE_SPECS只跑editor)。 - 只读复核带的两份外部复现脚本从
REVERSED转为OK(真 history undo 那一路、 删除-恢复边界那一路);另跑一份覆盖验收全部情形的探针(完整删除不恢复 / 真 undo / 真 redo / 反复 undo-redo / 剪切粘贴 / 乱序三张 / 跨批次 / pending 不被误吸附)全过。 - 本轮提交(按语义拆,均未 push):
d9a700e4fix(槽位映射)、5da063e8test (真 undo/redo 回归)、73b9adcedocs(两处注释校正)、本条 §7.1.6。
边界
- 这一轮没有重测、重做 §7.1.4 的体积 / 延迟;那些数字依旧只属于历史被测提交
9c13871e。 - 仍是合成事件 / headless 环境,不是真 IME 验收:真中文 / 日文输入法、真机、 Safari / 微信内建浏览器、真实网络、长文预算、真实打印一律仍无证据。
7.2 未完成范围(2026-10-12 校对)
| 事项 | 状态 | 开始或完成的依据 |
|---|---|---|
| M3 就地装饰 | 未实施;可选扩展 | 先确认隐藏标记的实际需求,再按 §6 验证原型;源码与完整预览始终可达 |
| M4 表格辅助与大纲 | 未实施;可选扩展 | 分别确认范围和验收;导出已经完成,不再列为待办 |
| 真实设备与输入法验收 | 尚无实测证据 | 按 §9.3 补桌面中文 / 日文 IME、移动端 Safari / 微信、软键盘与光标滚动记录 |
| 当前版本的长文性能预算 | 未完成 | 补短文、接近 250000 字符及引用展开后的测量,记录输入延迟、预览耗时与请求数,定下预算 |
| 真实网络与打印验证 | 尚无实测证据 | 补真实环境记录;历史 headless / 合成事件结果不能替代 |
M0–M2、导出、资源面板、基线补测与回滚演练都已有交付记录,不重列为待办。
§7.1.4 的数字仍只对应历史提交 9c13871e,不能作为当前版本的性能证据。
全屏、匿名读者视角预览、博客表情 / 名片属于范围之外的候选,未确认需求前不算必做项。
8. 文件清单
这三张清单是迁移前拟的计划(M0–M2 已完成,见 §7.1)。真实的落点以代码为准 —— 清单里没预见到的东西(比如资源面板最终是「一颗按钮 + 五颗标签」)不会回填到这里。
8.1 新增或抽取
以下为拟定落点,不表示文件已经存在:
src/app/components/MarkdownEditor.tsx:外壳、CM6 生命周期和读取 / 修改接口。src/app/components/markdown-editor/:工具条、布局、四类新面板与草稿适配。src/lib/md-editor/:事务命令、上传锚点;M3 才加入装饰与 widget。- 共享引用、整篇渲染、DOM 后处理和主题模块,按 §3.4 的职责拆分。
- CM6 的 state / view / commands / language / search / lang-markdown 及实际需要的 highlight 依赖;按 import 确认直接依赖,不先引入整套包。
锁定经过验证的版本,同步 package.json 与 package-lock.json;随后用 npm ci
验证干净安装。不要顺带升级 Next 或其他无关依赖。保留 mathjax 与 emoji 的资源准备步骤。
8.2 修改
src/app/components/BlogForm.tsx与src/app/clipboard/upload/UploadForm.tsx。src/app/components/MarkdownRenderer.tsx:复用抽取后的管线与显式交互模式。src/app/components/ImagePickerModal.tsx:如需调整回调,兼容评论 / 讨论已有调用方。src/styles-scss/pages/_clipboard.scss及编辑器外壳样式;颜色取docs/frontend-styles.md的令牌,复用正文样式而不是全局覆盖.cm-*。public/static/js/core/base.js:确认新的文件输入符合现有公共样式后,再删除 Vditor 早退。package.json、锁文件和.gitignore:M1 两处替换通过后清理 Vditor 项。src/lib/image-client.ts:只改已失效的 Vditor 注释,上传行为仍供评论 / 讨论共用。
随功能实际落地再更新 docs/architecture.md、docs/deploy.md、
docs/frontend-styles.md、docs/legacy-constraints.md、docs/guide/图床使用指南.md
和 docs/guide/内容引用语法指南.md。预览可用后,后者才删除「编辑器中不可预览」。
本文标题或摘要变化同步 docs/README.md 与 src/lib/docs-catalog.ts。
8.3 移除与测试迁移
✅ 本节写的移除已经执行完毕(M1 验收后):下面点名的文件今天都不在工作区, 列在这里只是留下「当时要拆掉什么」的台账 —— 不是待办。
原要求(已在 M1 验收后执行):移除 src/lib/vditor-upload.ts、src/lib/vditor-theme.ts
和 scripts/copy-vditor-assets.mjs,清掉 postinstall 中对应步骤、资源检查与生成目录。
tests/e2e/vditor-theme.spec.ts、tests/e2e/vditor-upload.spec.ts 和
tests/unit/vditor-upload.test.ts 中仍适用的主题 / 上传行为迁到新测试;然后删除旧实现
断言。tests/unit/base-js-filepick.test.ts 的 Vditor 例外与
tests/e2e/global-setup.ts 的资源自愈逻辑,在依赖确实移除时一起清理。
实际留下的痕迹只有指称本身,没有活依赖:base.js 有一句「历史上的 vditor
工具栏就是这么多出一颗」,两张表单的草稿键说「沿用 Vditor 时代的老键」
(键名本身是契约,见 §5.2,别跟着改名),tests/e2e/editor-draft.spec.ts 是同类
说明,src/lib/docs-catalog.ts 里那处是本文自己的标题。
git grep -i vditor 除 docs/ 外只剩这几处。
9. 验收与守卫
9.1 共享语义
- 编辑器不得引入独立 Markdown → HTML 转换路径;静态依赖检查只能辅助,不能代替 真实渲染结果对比。共享模块本身允许使用 marked。
- 固定引用数据、读取模式和原文后,比较完整预览与正文的结构和样式;交互模式差异 单独断言。覆盖引用影响上下文、重复 ID、预算边界、错误降级与代码屏蔽。
- 每类面板输出通过对应语法校验,再走真实整篇渲染;具名 token 的字符集和无空白 约束同源。保持既有表情 / 名片在博客里不展开的规则。
- 成员模式和匿名对外模式的数据不串用;私有素材、公开剪贴板、收藏夹和投票维持 现有读口边界。预览切换、关闭与刷新不产生业务写请求。
M0 先建立现有行为基线。若暴露既有代码屏蔽或引用顺序的缺陷,单独记录并修复,不能 在「无行为变化的抽取」里悄悄改语法或预算。
9.2 编辑与保存
- 无围栏代码的纯文本粘贴不自动补围栏;已有围栏、反引号、缩进和中文保持原文。
- 同一内容分别以仅
text/plain、同时带text/html的剪贴板输入验证,特别覆盖从 代码编辑器复制的内容;图片粘贴和混合内容按 §1.2 的固定规则处理。 - 工具条、面板、上传插入的位置正确,撤销 / 重做不破坏原文;上传期间继续编辑、 删除锚点、多选乱序完成、离页后完成均有回归。
- 两处旧草稿都能恢复;编辑态不读新建草稿;失败不清草稿;成功后延迟写入不复活草稿。
- 剪贴板手动 / 自动保存读到最新正文,提交前不等待预览;禁言与长度上限不变。
- 主题切换、编辑页离开后的文章高亮、初始化回退、窄屏布局和键盘操作通过。
9.3 异步、真实设备与性能
- 重复引用只发一次并发读取;输入与光标移动不重复取相同数据;刷新能重取。
- 慢请求、乱序响应、网络失败、权限拒绝和组件卸载不覆盖新状态或恢复被取消的插入。
- M1 用真实中文输入法验证基本编辑;M3 再验证装饰区间的 composition。合成 composition 事件只作辅助,不能代替桌面中文 IME 与移动端 Safari / 微信等实际环境。
- 记录短文、接近 250000 字符的长文及引用展开后长文的输入延迟、预览耗时和请求数。
发布前依据旧实现基线定下预算;若完整预览持续阻塞输入,先调整更新策略再发布。
现状:只有 2 KB / 50 KB 两档的本机 headless
insertText取样(口径、数字与 限制见 §7.1.4),按上面的要求衡量则一条都还不够 —— 长文档、预览耗时、 请求数没量,预算本身也还没定。 - 新测试断言行为,避免绑定
.vditor--dark等已移除类名,或换成等价脆弱的 CM6 DOM 细节。
按改动运行相关单测和 e2e。改了 src/ 必须先 build;npm run e2e 使用现有产物,
完整运行用 npm run e2e:ci。仅改本文时运行文档登记、互指与渲染相关守卫即可。
10. 实施前需要确认的事项
这一节按「当时提的问题」原样保留,逐条现状见 §7.1:1 首版布局已定(源码编辑 + 可切换的完整预览);2 导出按 M1 要求交付,全屏未做、大纲未做、字数统计已有; 3 基线已补测(体积与一帧延迟见 §7.1.4;那一轮的数字取在定版 9c13871e 上, 但真机、真实输入法与长文预算仍未定);4 表情 / 名片与可视化表格都没有混进本次替换,匿名读者视角 预览未做 —— 第 4 条里列的几种能力今天仍然一条都没加。
- 用一个包含代码粘贴、图片、公式和引用的原型确认首版布局。源码编辑与完整预览 是当前建议,是否增加隐藏标记在 M3 前由实际使用反馈决定。
- 明确导出是否为 M1 发布要求,以及大纲 / 全屏 / 字数统计的范围,形成旧能力对照表。
- 记录版本、资源体积和输入性能基线,设定 M1 发布验收预算。
- 如果需要匿名读者视角预览、表情 / 名片或可视化表格,另行补充权限、语义与验收, 不默认混进本次替换。
M0 可以先推进共享管线重构;M1 发布必须通过上述范围确认与验收。计划完成的判据是 两处编辑器迁移和五类资源集成都可用,而不是 CM6 实例能输入文字。