富文本、Markdown 和附件怎样一起保存
编辑器能贴进一张图片,不代表正文已经保存;文件传到对象存储,也不代表附件已经可以被业务系统引用。
要把这些能力做稳,需要分别回答三个问题:正文保存什么格式、异步结果还能插到哪里、附件到了哪一步才算可用。
本文取材于一套笔记系统的源码。代码示例重新编写,省略真实业务名称和接口;“已有实现”和“建议改进”分别说明,文末场景是建议验证清单。
先决定正文的持久化格式
现有编辑器支持 Markdown 粘贴,但更新时输出 HTML,正文也以 HTML 保存。Markdown 在这里承担输入转换的职责。
text
纯文本 / Markdown / 网页 HTML
↓ 按来源解析
编辑器文档模型
↓ 序列化
HTML 正文明确这条链,才能解释为什么附件卡片、图片尺寸和表格需要各自的序列化规则,也能避免把“支持 Markdown”误写成“任意内容都能无损导出 Markdown”。
现有兼容层还会把历史纯文本包装成段落,再交给编辑器加载。这个做法降低了迁移成本,但文本和 HTML 的识别仍依赖启发式判断。
建议改进:逐步加入显式的内容格式与格式版本。以后调整节点结构时,可以按版本迁移,而不必持续增加字符串猜测规则。
保存确认与本地草稿的关系,见保存按钮背后的生命周期。
Markdown 粘贴要先尊重输入意图
编辑器不能看到下划线、星号就改写文本。代码、路径和标识符也经常包含这些字符。
现有实现关闭通用粘贴规则,使用以下分流:
| 输入情况 | 已有处理 |
|---|---|
| 用户使用纯文本粘贴快捷键 | 按字面字符插入,保留换行 |
| 剪贴板包含图片文件 | 进入图片处理流程 |
| 当前位于代码块 | 留给编辑器默认处理 |
| 剪贴板带有 HTML | 保留默认 HTML 解析流程 |
| 只有文本,且命中 Markdown 特征 | 交给 Markdown 解析器 |
| 其余普通文本 | 不主动转换为 Markdown |
这里的关键是顺序。网页复制通常同时提供 HTML 和文本,优先使用 HTML 可以保留表格与行内格式;纯文本快捷键则代表用户已经明确选择不要这些格式。
Markdown 特征识别仍可能误判。保留纯文本入口,就是让用户有可预期的退出方式,而不是继续堆更多正则表达式。
自定义节点有两份职责
附件在编辑器里可以显示为一张卡片,包含文件名、大小、下载和删除按钮。但这些按钮是交互视图,不必全部进入正文。
现有实现把附件定义为独立节点:HTML 属性保存稳定 ID 和展示元数据,节点视图负责操作。重新打开正文时,再从 HTML 恢复节点。
Tiptap 的节点定义可以配置 HTML 解析与序列化;这部分决定内容如何保存和重新识别。具体职责见官方 Node API。
下面是重新设计的存储示意,只表达“引用与二进制分开”,不对应项目中的字段协议:
json
{
"kind": "file-reference",
"resourceId": "sample-file-a",
"label": "演示资料.pdf"
}HTML 能恢复自定义节点,并不能推出 Markdown 也能完整恢复它。Markdown 的解析与输出需要单独定义转换规则,参见官方扩展集成说明。
现有附件节点实现了 HTML 读写和交互视图;本文没有把自定义节点的 Markdown 往返转换当作已完成能力。若要增加导出,应先明确:附件转成链接、保留扩展语法,还是打包导出文件。
上传时继续输入,插入点就不能是旧数字
用户选择文件后,可能又输入几行正文。若上传前记下一个固定位置,完成后直接插入,这个位置可能已经指向别处。
现有实现使用编辑器选区书签,并在每次文档事务后映射位置。上传完成后解析书签,从更新后的文档中找到插入点。
text
选择文件 → 记录选区书签 → 开始上传
↓
继续编辑 → 文档发生事务 → 映射书签位置
↓
上传结束 → 检查会话有效 → 在当前位置插入节点位置正确只是一个条件。已有代码还检查编辑器是否仍存在、是否可编辑、是否仍属于同一账号会话;图片处理另有内容代次检查,切换正文后会作废旧图片结果。
这些检查避免把迟到结果写入无关页面。它们也说明了取消请求与拒绝过期结果的区别:请求可能已经完成,但结果仍然不该继续影响当前文档。
建议改进:文档附件上传也可绑定明确的内容会话标识。现有图片代次保护不能直接视作文档上传已经具备同等隔离,尤其需要关注复用同一编辑器实例切换正文的场景。
同样的异步边界也会出现在AI 编辑结果的应用过程中。
附件 ID 长期保存,签名地址按需获取
现有文档附件节点保存稳定 ID。用户预览或下载时,客户端用 ID 请求当前可用的访问地址,旧存储方式则有读取文件字节的兼容路径。
这样可以把三个概念分开:
| 概念 | 用途 | 是否应长期写进正文 |
|---|---|---|
| 附件 ID | 找到业务系统中的附件记录 | 是 |
| 展示元数据 | 显示名称、大小和类型 | 可以,但不能代替权限判断 |
| 临时签名地址 | 在有效期内读取文件 | 不应作为永久引用 |
正文需要的是稳定引用。一个临时地址过期后,重新获取访问地址即可,不需要修改每篇引用它的正文。
图片和文档的现有策略不同:图片可以使用公开资源地址,文档按附件 ID 获取访问地址。不能把“图片能直接显示”的经验推广为“所有文档都应公开访问”。
预览还会引入浏览器资源。现有 PDF 预览使用 Blob URL,关闭预览时释放;若请求返回前用户已经关闭,则直接释放刚生成的 URL。文本预览按文本显示,办公文档保持下载入口。
直传成功、登记成功、正文保存是三件事
现有文档流程先取得上传许可,把文件直传到对象存储,再向业务服务登记附件元数据。登记成功返回附件信息后,编辑器才插入节点。
text
取得上传许可
→ 文件传输成功
→ 附件登记成功
→ 编辑器插入引用
→ 正文保存成功每一步确认的事实不同。对象存储接收了字节,只能证明文件存在;登记确认了业务记录;正文保存才确认这篇文档已经持久化了引用。
现有上传任务在失败重试时保留附件 ID。这为服务端识别同一次附件操作提供了稳定身份,但客户端 ID 本身不能证明服务端已经具备幂等保证。
建议改进:把“文件已传好、登记失败”作为可恢复状态保存,优先重试登记。现有客户端会重新执行上传流程,可能再次传输文件;本文不把它描述为断点续传或仅重试登记。
还应单独设计失效文件的清理机制。用户取消、关闭页面或登记失败都可能留下尚未被正文引用的对象;直接删除所有“当前未引用文件”又可能误伤正在编辑的草稿。
这些改进涉及业务服务与存储的协作,不能只在上传按钮里补一个重试。更一般的确认边界可参阅同步与并发一致性。
降级路径也要告诉用户代价
现有图片上传失败时,可以回落为正文内嵌图片,并提示它会占用更多正文空间。这个方案保持了编辑连续性,但不能保证大图最终满足正文长度限制。
文档附件仅在上传服务被判定为未配置时使用旧 BLOB 兼容路径;直传或登记失败不会自动切换存储方式,而是报告失败并等待重试。两种降级服务于不同条件,不应合并成“任何文件失败都放进正文”的策略。
建议让界面分别表达“正在上传”“文件上传失败”“附件登记失败”“正文尚未保存”,避免一个“成功”提示掩盖后续步骤。
建议验证的失败场景
以下清单用于开发或测试环境的后续验证,不代表本文整理时已经执行了业务测试:
- [ ] 粘贴带格式的网页,表格与链接保持预期;纯文本快捷键能保留字面字符。
- [ ] 粘贴带下划线的代码或标识符,不被意外改成斜体。
- [ ] 附件保存后重新打开,节点 ID 和展示信息仍能恢复。
- [ ] 上传期间继续输入,附件插入位置随文档变化,不覆盖新内容。
- [ ] 上传期间切换正文、只读模式或账号,迟到结果不进入错误文档。
- [ ] 文件上传成功但登记失败,错误能被识别,重试不会显示虚假的完成状态。
- [ ] 附件已插入但正文保存失败,页面仍明确显示未保存,并保留可恢复内容。
- [ ] 访问地址过期后重新请求;无权限附件不因持有 ID 就被开放读取。
- [ ] 预览关闭后请求才返回,释放临时资源且不重新打开预览。
- [ ] 图片回落为内嵌内容后检查大小限制,超过限制时保留修改并明确提示。
继续阅读笔记系统的模块边界,可以把编辑器、保存会话、附件服务和共享状态放回完整系统中理解。