Petrichor logoPetrichor
返回深度文章
04 技术向

PlateJS 重度玩家手记:构建一个媲美 Notion 的编辑器

编辑器选型对比、插件 Kit 体系、AI 选区集成、Markdown 互转、SSR 与撤销栈踩坑

2026年5月26日 约 8 分钟阅读 开发者
PlateJS 重度玩家手记:构建一个媲美 Notion 的编辑器 的总结图

写一个”看起来像 Notion”的编辑器,比想象中要难。难的不是渲染——一个能输入文字、加粗、列项目的富文本框,市面上随便挑一个库都能做。难的是 块级体验的连贯性:拖一个标题它要把整段子内容跟着拖;按 / 要立刻弹出一个按使用频率排序的命令菜单;输入 # 自动把当前段落升格为 H1;选中一段调出浮动工具栏;在代码块里 Tab 不能跳出去而要插缩进;撤销一次要刚好回退一个”语义动作”而不是一个字符——这些细节,是 90% 编辑器库写不到位的地方。

Petrichor 的编辑器选了 PlateJS(基于 Slate.js),下面这篇手记记录了过程中的几个关键决定。


选型:为什么是 PlateJS

候选名单是这四个:

内核现状我的判断
TinyMCE / CKEditorDOM contenteditable老牌、能用数据模型还是 HTML,不适合块级编辑
TiptapProseMirror活跃、社区好上层 API 友好,但块级拖拽 / 嵌套布局要自己做很多
LexicalMeta 自研性能好节点模型比较开放,但生态薄、Notion 风格组件要自己造
Slate.js + PlateJSSlate.jsSlate 内核 + Plate 上层节点模型纯 JS(不强绑 DOM),Plate 已经把 Notion 风格组件造完了

最终选 PlateJS 的核心理由是**“已经把活儿干完了”**:

  • 块级拖拽、斜杠菜单、块选择、Toggle 折叠、Column 多列布局、表格、媒体嵌入、Mention、Comment、Suggestion……Plate 都有现成插件
  • 节点是纯 JavaScript 对象,可以序列化为 JSON 存数据库,也可以双向转 Markdown
  • 内核是 Slate.js,架构稳定(Slate 已经维护 5 年以上),可控性极高

代价是:Slate.js 的学习曲线陡,Plate 的 API 表面又比较大。但这是一次性成本,过了上手期之后构建复杂功能反而比 Tiptap 顺。


插件体系:Kit 是 Plate 的灵魂

Plate 把每一种编辑能力封装成”Kit”——一个 Kit 就是一组 Plugin 的集合。Petrichor 自己的 editor-base-kit.tsx 长这样(节选):

export const BaseEditorKit = [
  ...BaseBasicBlocksKit, // 段落、标题、引用、分割线
  ...BaseCodeBlockKit, // 代码块 + Shiki 高亮
  ...BaseCodeDrawingKit, // Code Drawing
  ...BaseTableKit, // 表格
  ...BaseToggleKit, // 折叠块
  ...BaseTocKit, // 目录
  ...BaseMediaKit, // 图片、视频、音频、文件、URL 嵌入
  ...BaseCalloutKit, // 提示框
  ...BaseColumnKit, // 多列布局
  ...BaseMathKit, // KaTeX 行内/行间公式
  ...BaseDateKit, // 日期插入
  ...BaseLinkKit, // 链接
  ...BaseMentionKit, // @ 提及
  ...BaseBasicMarksKit, // 加粗、斜体、下划线、删除线、行内代码
  ...BaseFontKit, // 字号字色
  ...BaseListKit, // 有序/无序/任务列表
  ...BaseAlignKit, // 对齐
  ...BaseLineHeightKit, // 行高
  ...BaseCommentKit, // 评论
  ...BaseSuggestionKit, // 建议改动
  ...MarkdownKit // Markdown 双向序列化
]

每个 Kit 既是配置又是行为:它声明节点类型(element / leaf)、注册键盘快捷键、定义渲染组件、注入序列化逻辑。

注意这里 Base...Kit...Kit(无 Base 前缀)的区别

  • Base...Kit = 纯逻辑 + 数据模型,不含 UI,可在 Node.js 服务端跑(用于 SSR、Markdown 序列化)
  • 不带 Base 前缀的 Kit = 完整版,包含浮动工具栏、对话框、命令菜单等浏览器侧组件

服务端渲染公开博客文章时只 import BaseEditorKit,避免把整个浏览器侧 UI 打进 SSR 产物。这是 Plate 设计上一个很妙的地方。


Markdown ↔ Slate:双向转换的坑

公开博客文章在数据库里保存的是 Slate JSON,但是 SEO 抓取、RSS Feed、外部分享时我们想要 Markdown。PlateJS 提供了 serializeMddeserializeMd 函数:

import { MarkdownPlugin } from '@platejs/markdown'

const markdown = editor.api.markdown.serialize()
editor.api.markdown.deserialize(rawMarkdown)

听起来很美好,但实操中有几个坑:

坑 1:Markdown 无法表达所有 Slate 节点

白板(Excalidraw)、思维导图(Mind Elixir)、Callout、Column、Toggle 这些块,Markdown 里没有对应语法。PlateJS 的默认序列化会把它们 降级为 HTML 标签或纯文本。我们的做法是:

  • 公开博客的”摘要”用 Markdown(容许信息丢失,反正是预览)
  • 公开博客的正文渲染时仍走 Slate JSON,前端把它转成 HTML 渲染,保留所有特殊块
  • RSS / Atom Feed 用 Markdown(订阅端本来就只支持基础格式)

坑 2:代码块语言标识

Markdown 的 ```ts 在 Slate 里需要映射成 code_block 节点的 lang 属性。Plate 默认能识别常见语言,但对一些罕见语言(如 mermaidtsx)需要在 MarkdownPlugin 配置里手动声明:

MarkdownPlugin.configure({
  languages: ['ts', 'tsx', 'js', 'jsx', 'go', 'rust', 'sql', 'mermaid', 'bash']
})

坑 3:列表嵌套

Slate 的列表有两种风格——“经典嵌套列表”(<ul><li><ul>...</ul></li></ul>)和”扁平 indent 列表”(用 indent 字段表示层级)。Plate 默认是后者,但 Markdown 反序列化器经常生成前者。如果两边不一致,会出现”列表项明明在嵌套但渲染时不缩进”的诡异 bug。我们的做法是统一用 @platejs/list-classic,强制经典嵌套,输入和输出都对齐。


斜杠菜单 + 块拖拽:交互的细节

斜杠菜单的实现用的是 @platejs/slash-command。看似简单,但有几个交互上的细节非常关键:

排序:按使用频率,不是字典序

固定字典序的命令菜单,第三个标题用户永远要划到第十个位置去找。我们改成按本地点击次数排序——用 localStorage 记录每个命令的使用次数,下次打开时常用的自动排到最前。这个改动让”我刚才用过那个,但是叫什么来着”的场景从找几秒变成 0.5 秒。

块拖拽:手柄、嵌套、placeholder

@platejs/dnd 提供了拖拽基础,但你需要自己控制:

  • 拖拽手柄出现时机:默认是 hover 整个块时显示,但在长段落里手柄漂在中间会很丑。我们让手柄只在首行高度内出现
  • 嵌套块整体拖:拖一个 Toggle 块时,它的子内容要跟着走。这需要在 onDrop 里判断目标位置不能落在自己的子树里
  • 拖拽 placeholder:默认是一条蓝线,我们改成”半透明镜像 + 横向高亮条”,更接近 Notion 手感

AI 写作:怎么”嵌进”编辑器

这部分是 Petrichor 编辑器最特别的地方。AI 写作助手不是”另开一个聊天窗”,而是直接作用在当前选区。整个交互链路是这样的:

1. 用户选中一段文字 → 浮动工具栏弹出 → 点 AI 按钮
2. 弹出 6 种动作(续写 / 改写 / 扩展 / 精简 / 翻译 / 语气)
3. 客户端把选区前 N 个字 + 选区内容 + 选区后 M 个字打包成 payload
4. 服务端 /api/ai/write 收到,构造 system prompt + user message,调 AI SDK
5. AI SDK 返回 ReadableStream,服务端原样转发给客户端
6. 客户端用 transform 模式:每收到一个 chunk,调用 editor.tf.insertText(chunk) 插入选区光标处
7. 用户随时按 Esc 中断 stream,已生成的文字保留

关键点:

上下文窗口的”前后文”

为了让续写有上下文,不能只发选中的那一段给 AI。我们的做法是发送:

interface WriteRequestPayload {
  action: WriteAction
  selectedText: string // 选区内容
  contextBefore: string // 选区前 ~500 字
  contextAfter: string // 选区后 ~200 字
  language: TranslateLanguage | null
  tone: TonePreset | null
}

contextBeforecontextAfter 更长,因为续写更依赖前文。500/200 这两个数字是凭手感调出来的——太短上下文不够,太长 token 浪费且响应慢。

Prompt 的”动作槽位”

不同动作的 system prompt 用同一个模板,只换”动作描述”那一段:

function buildWriteSystemPrompt(action: WriteAction): string {
  const actionDesc = {
    continue: '在 contextBefore 之后续写一段,风格保持一致……',
    rewrite: '改写 selectedText,保持原意但换一种表达……',
    expand: '把 selectedText 中的要点扩展为完整段落……',
    shorten: '把 selectedText 压缩为核心要点……',
    translate: '把 selectedText 翻译为 {language}……',
    tone: '把 selectedText 调整为 {tone} 语气……'
  }[action]
  return `你是一名写作助手。${actionDesc}\n输出规则:…`
}

这个 prompt 的结构经过了至少 5 轮调整,最关键的两条:

  • 只输出新内容,不要重复 selectedText” —— 否则模型经常把原文复读一遍
  • 不要加引号、不要加 markdown 围栏” —— 模型默认习惯用 ... 包裹回答,插入编辑器后会变成代码块

流式插入与中断

流式插入用 PlateJS 的 editor.tf.insertText,每个 chunk 一次插入。但有个微妙的问题:用户在生成过程中也能编辑文档。如果 AI 在插入文字 A 时用户按下了 Backspace,下一个 chunk 插入的位置会错乱。

我们的做法:进入 AI 生成状态时,编辑器对当前选区所在的块临时设置 readOnly,其他块照常可编辑。生成结束或被中断时解锁。这是一个细节但极大影响了”AI 写到一半被打断的体验”。


SSR 与撤销栈:两个最坑的细节

SSR:React 18 hydration 不匹配

Plate 的浮动工具栏 / 弹窗组件大量依赖 useLayoutEffectwindow 对象。如果在 Next.js App Router 的 SSR 里直接 import 完整版 Kit,会爆出 hydration mismatch。

解决方案:

  1. 公开博客页用 BaseEditorKit(只渲染节点,不挂浏览器组件)
  2. 编辑器页用 dynamic(() => import(...), { ssr: false }) 包一层,确保编辑器只在客户端实例化
const Editor = dynamic(() => import('@/components/editor/editor'), { ssr: false })

这两步缺一不可。

撤销栈:批量操作要 “withoutNormalizing”

Slate.js 的 undo 默认是按 operation 粒度的——你输入 5 个字符就是 5 个 undo 步。Plate 在普通输入上做了 throttle,但程序触发的批量修改(比如 AI 流式插入、Markdown 反序列化)会爆出几百个 undo 步,按 Cmd+Z 时一字一字地往回吐。

解决:在程序触发的批量操作外包一层 editor.withoutNormalizing + 单次 history 提交:

editor.withoutNormalizing(() => {
  // 批量插入 / 修改
})
editor.tf.history.merge(operations.length) // 把这一组 ops 合并成一个 undo 单元

AI 流式插入特殊处理:每个 chunk 进来时 push 到 history,但整个 stream 完成后才”提交”一次 undo 单元。用户按 Cmd+Z 时 AI 的整段输出一次性撤销。


一些没说但很重要的细节

  • <EditorStatic> 用于公开博客渲染:纯 SSR、零 JS,速度快。<Editor> 用于编辑场景,全功能但需要 hydrate。
  • .platejs/dnd + react-dnd-html5-backend 组合:移动端走 react-dnd-touch-backend 而不是默认 HTML5 backend
  • @platejs/code-block + lowlight + Shiki:lowlight 做语法分析,Shiki 做最终 token 渲染,分工很干净
  • @excalidraw/excalidraw 的资源加载:默认会从 CDN 拉字体和素材,我们本地化打包,避免国内网络访问失败

总结:编辑器是产品的基本面

如果说架构选型决定了 Petrichor 能不能跑得起来、跑得便宜,那编辑器就决定了 Petrichor 写得爽不爽。一个写得不爽的编辑器,再花哨的功能都救不回来。

PlateJS 让 Petrichor 能在合理的工作量内做出”长得像 Notion、用起来像 Notion、且能塞进任何业务场景”的编辑器。它不完美——上手成本高、API 表面大、文档分散——但它是当下能做到这件事的最优选择

下一篇我们看看编辑器之上的 AI 系统:「AI 多模型集成 + Better Auth:Petrichor 的智能与安全设计」