12k
All articles

初探 Wordgard:一款全新的文本编辑器

Wordgard 是 Marijn Haverbeke 推出的新富文本编辑器库,具备单次更改事务、corrections、facets 和库内选择功能。

OpenReplay Team
OpenReplay Team
初探 Wordgard:一款全新的文本编辑器

Wordgard 是由 ProseMirror 和 CodeMirror 的作者 Marijn Haverbeke 编写的 JavaScript 库,用于构建文档符合 schema 约束的富文本编辑器;它附带了一个编辑器 UI 组件,但并不是一个通用的、自由形式的所见即所得(WYSIWYG)或 HTML 编辑器。

维护一套 ProseMirror 集成,往往意味着要在一串 step 中逐个映射位置,或者写一个”通用”命令却不得不在每一步都去检查 content expression。Wordgard 正是同一位作者对这些抱怨给出的回应——它是从零构建的,而不是嫁接在 ProseMirror 之上。

本文将介绍这个库改变了什么:变更模型、内容约束的移除、基于 facet 的扩展系统与库内选区处理,以及这位作者的首个发布版本在 ProseMirror、TipTap 和 Lexical 之中处于什么位置。

核心要点

  • Wordgard 于 2026 年 7 月 2 日以 0.1.0 版本首次发布,采用 MIT 许可证,可通过 npm 以 wordgard 安装;作者在发布时表示,该项目很可能至少还会在 0.x 版本上停留一年。
  • 一个 Wordgard transaction 只携带一次变更,由若干 section 构成,这些 section 分别表示保留某个 token 区间、替换它,或在其上添加/移除 mark,因此受影响的区间可以直接读取,而不必从一串 step 中重建。
  • Wordgard 的 schema 可以限制父节点能包含哪些节点类型,但不能限制它们的顺序;诸如”表格必须是矩形”这类不变式,由 correction(返回修正变更规格的观察者函数)来接管。
  • 配置是一棵扩展树,支持逐值设定优先级以及用户自定义 facet,这一设计沿用自 CodeMirror 6。
  • Wordgard 在库内处理键盘与指针选区,并自行绘制光标;触摸选区则交给浏览器处理。

什么是 Wordgard?

Wordgard 是一套面向”符合特定 schema 的内容”的富文本编辑系统,它既不是一个开箱即用的 WYSIWYG 组件,也不是一个应用程序。根据系统指南,编辑界面在使用感受上应当接近所见即所得,但内容和编辑操作是按其含义命名的(标题、列表、强调),而非按其外观命名(字体、段落缩进、加粗)。该库最主要的导出是 Wordgard UI 类。其下则是文档、编辑器状态和编辑操作的各类类型,其中大部分完全不依赖浏览器即可使用。

日期为 2026 年 7 月 2 日的 0.1 发布公告说明了 MIT 许可证、npm 包名 wordgard,以及源码托管在作者自建的 Forgejo 实例上。项目主页确认了许可证信息,并补充说明:欢迎提交 bug 报告,但不接受 pull request。主页还列出了基于 schema 的文档、模块化扩展、双向文本、表格与嵌套列表等结构化内容,以及协同编辑等特性;这些条目应视为项目自身的宣称。

如何搭建一个 Wordgard 编辑器?

一个最小化的 Wordgard 编辑器就是一次 Wordgard.create 调用,传入文档、配置和父元素。以下是指南中的搭建示例:

import {Wordgard, menuBar} from "wordgard/editor"
import {fullSchema} from "wordgard/schema"
import {history} from "wordgard/history"

let editor = Wordgard.create({
  doc: `<p>Starting content</p>`,
  config: [
    fullSchema(), // A predefined document schema
    history(),    // Enable the undo history
    menuBar()     // Show a menu
  ],
  parent: document.body
})

config 数组就是扩展树,其中三个条目每一个都是一组扩展的集合,而不是分别对应一个 schema 对象、一个插件和一个组件。fullSchema() 会引入 wordgard/schema 中的全部 schema 元素,其自身文档也提醒:随着库功能增加,这个集合可能会纳入更多元素;指南后续的示例使用的是 basicSchema(),它打包了块级文档、段落、标题、换行,以及 strong、emphasis 和 link 这几个 mark。doc 字符串会依据该 schema 被解析为 HTML。整个包被拆分为 wordgard/docwordgard/statewordgard/editorwordgard/commandwordgard/historywordgard/schemawordgard/types 等模块,由于各部分耦合紧密,指南推荐使用 TypeScript。

Wordgard 的变更模型与 ProseMirror 有何不同?

在 Wordgard 中,一个 transaction 只携带一个 change 对象,它由若干 section 构成:保留文档的某一段、替换它,或在其上添加/移除 mark。因此一次编辑所影响的区间可以直接读取,而不必从一串 step 中重建。而在 ProseMirror 中,一个 transaction 是一个有序的原子 step 列表,每个 step 都作用于前一个 step 产生的文档,这就迫使位置运算和区间检查必须沿着整条链逐步推进。

发布公告给出的理由是:CodeMirror 的 delta 格式(其本身源自 ShareJS)既更简单又更强大。一次 change 就是作用在旧文档之上的一个扁平序列。以一个长度为 10 个 token 的文档为例:在位置 4 插入一个 token,表示为”保留 4,用该 token 替换 0,保留 6”;把位置 3 到 6 加粗,则表示为”保留 3,更新 3 并添加该 mark,保留 4”。其中 mark 更新型 section 是 Wordgard 对 CodeMirror 模型的扩展。

这套做法之所以能用于树结构,是因为位置是以 token 计数的。在指南的索引系统中,每个 plot 的开标记、plot 的闭标记、非文本叶子节点以及每个 UTF-16 字符都会让位置加一;位置 0 正好位于第一个子节点之前;文档节点自身的开闭 token 不计入。这使得一次 change 可以像操作扁平序列一样把新的 token 序列拼接进文档,而”检查结果是否仍是良构的树”这项工作则由创建 change 的代码承担。

当多个 change 一并传给 ChangeSet.create 时,所有位置都按原始文档来解释,库会自动进行偏移处理。mark 类变更则完全不触及内容:

let makeStrong = ChangeSet.create(doc, {
  from: 1, to: 5,
  add: Strong
})

这些同样的对象也支持在彼此之上进行变换(transform),而撤销历史和协同编辑正是建立在这一能力之上的。

什么取代了 ProseMirror 的 content expression?

Wordgard 的 schema 可以限制父节点能包含哪些节点类型,以及一个块级 plot 是否允许为空,但不能限制子节点出现的顺序;ProseMirror 那种基于正则表达式的 content expression 在这里没有对应物。公告给出了两点理由:面对任意的顺序约束,通用的文档操作代码除非在每次操作时都做检查,否则根本无法编写;而硬性约束会阻断真实编辑过程中必然经历的那些中间”混乱”状态。

schema 无法表达的规则由 correction(修正)来处理。一个 correction 是绑定在节点查询上的观察者;每当匹配的节点发生变化或出现时它就会运行,并可以返回一个 change spec,由库将其加入该 transaction。由于 correction 本身就是代码,它可以体谅用户当前正在进行的操作,而不是机械地拒绝某种结构。指南中的示例使用 Correction.onChildList(Doc, ...),在文档开头不是一级标题时插入一个一级标题;公告则把”矩形表格”举为 ProseMirror 的 content expression 永远无法表达的典型场景。

为什么 Wordgard 使用 facet 而不是插件?

Wordgard 用一棵细粒度的扩展值树取代了 ProseMirror 中作为配置与优先级单位的插件,树中每个值都可以携带自己的优先级。公告的批评相当精准:一个 ProseMirror 插件会把若干钩子捆绑在同一个优先级位置上,因此一个插件如果需要某个钩子高优先级、另一个钩子低优先级,就无法两者兼得。

在指南的配置章节中,扩展可以是三种东西之一:库内置扩展类型之一的值;任何在其 extension 字段中携带扩展的对象;或者是一个装着更多同类项的数组。显式优先级来自 GardState.prec 中的函数;在同一优先级层级内,由树中的顺序决定。facet 是带类型的扩展点,任何代码都可以定义,并可选配一个 combine 函数将多个输入归约为单一输出;compartment 则允许在不丢弃状态的前提下替换配置的某些部分。“插件”这个词并未消失:Wordgard.Plugin.define 依然存在,用于那些持有自身状态、需要贴近 DOM 的对象——库自带的 tooltip 和 panel 就是这样构建的。

由库自行绘制的选区

Wordgard 在库内处理键盘与指针选区,并隐藏原生光标以绘制自己的光标,而原生选区高亮本身则仍保持可见。公告将此归因于浏览器行为的不可靠:光标无法越过某些内容、落到错误位置或干脆不被绘制,鼠标拖拽选择也会出错。因此该库自行构建了一套内容布局的表示,自行处理双向文本,并自己放置光标。指南中的 DOM 示例展示了一个覆盖在内容之上的专用光标层元素,迁移文档则说明保留原生高亮是因为不去动它反而问题更少。

截至 0.1 发布公告,触摸选区是唯一的例外,仍然保持原生行为,因为重新实现它会破坏平台的上下文菜单。不过这一点后来有所变化:更新日志记载,0.5.0 为原生选区无法到达的位置引入了触摸选区支持,0.5.1 又在内联 plot 的边缘增加了一个额外的光标位置,让触摸拖拽选择有了可以停靠的落点。公告还把输入处理定位为临时方案:Wordgard 通过 beforeinput 处理除输入法组合之外的所有情况,并放弃了 ProseMirror 的 DOM 变更解析机制,有待真实场景的检验。目前尚未公布浏览器支持矩阵。

Wordgard 与 ProseMirror、TipTap 和 Lexical 的对比

ProseMirrorTipTapLexicalWordgard
变更模型有序 step继承自 ProseMirror自有模型基于 section 的单次变更
内容结构约束正则式 content expression继承自 ProseMirror自有模型子节点类型集合 + correction
配置方式插件基于 ProseMirror 插件的 extension自有模型支持逐值优先级的 facet 扩展
选区浏览器原生浏览器原生自有模型库绘制光标,触摸走原生

TipTap 是构建在 ProseMirror 之上的框架层,继承了其核心模型;Lexical 是 Meta 独立开发的编辑器框架。两者都不与 Wordgard 共享接口。

谁应该再等等:目前来看是大多数团队。 Wordgard 的首个发布版本是 0.1.0,此后 npm 上的这个包已经经历了多次发布;更新日志中最新的条目是 0.5.2,日期为 2026 年 9 月 6 日,且日志记录了 0.2.0、0.3.0、0.4.0 和 0.5.0 中的破坏性变更。作者预计还会重新设计公开接口的部分内容,并很可能在一年或更久的时间内继续停留在 0.x。也不存在从 ProseMirror 迁移的升级路径:项目的《从 ProseMirror 迁移》文档把每个 ProseMirror 包映射到对应的 Wordgard 模块,并明确指出项目并未尝试保持接口兼容。

结语

Wordgard 是 ProseMirror 这一脉编辑器中,第一个在同一套设计里同时舍弃了 step、有序 content expression 和浏览器托管选区的产品——真正值得关注的是这三项决策,而不是作者的名气。如果你在维护一款基于 ProseMirror 的产品,不妨先读一读迁移文档以及指南中的 Changes 和 Corrections 章节,然后拿一个棘手的 schema 不变式用 correction 做个原型;这个练习能告诉你的适配情况,比任何功能清单都要多。

常见问题

Wordgard 是否内置协同编辑?还是需要我自己搭建服务端?

Wordgard 在 wordgard/collab 中提供了客户端协同编辑扩展,但不包含服务端。collab() 扩展会跟踪尚未确认的本地变更;collab.sendableUpdate 和 collab.receive 负责与你自行实现的中心权威节点交换更新,而 collab.transformUpdate(在 0.2.0 中加入)可让服务端对过期的更新进行 rebase。correction 会跳过远程 transaction,因此请确保客户端配置和服务端变换使用相同的 correction,并且列出的顺序也保持一致。

Wordgard 中的 plot 和 leaf 有什么区别?

plot 是带内容的节点,例如段落、列表、表格或文档本身;leaf 是不带内容的节点,例如文本、图片或换行。它们是两个独立的类 Plot 和 Leaf,在 TypeScript 中可通过 isPlot 和 isLeaf 属性进行类型收窄。leaf 本身就是一个 tag(类型、参数、mark),而 plot 则包含一个 tag 外加一个 content 数组。

我可以在浏览器之外创建或修改 Wordgard 文档吗,比如在 Node 中?

对文档模型来说可以,对编辑器来说不行。wordgard/doc、wordgard/state 和 wordgard/types 这几个模块被设计为无需 DOM 即可运行,因此你可以在服务端构建文档、应用 change set、运行 correction 并序列化为 JSON;wordgard/types 仅依赖 wordgard/doc。wordgard/editor 在浏览器之外虽然能加载,但不会起任何实际作用;此外,以 HTML 字符串形式提供的文档需要浏览器解析器,因此请改传 JSON 或使用 jsdom。

Wordgard 支持表格吗?它如何保证表格是矩形的?

支持。wordgard/table 模块导出了一个 tables() 扩展包,其中加入了表格相关的 schema 元素、用于选中矩形单元格区域的 CellSelection 类型、粘贴与拖放处理器、表格菜单,以及 tables.correction——一个内置的 correction,用于修复单元格无法排列成规整矩形的表格。它的选项包括 headerCells、cellSpanning 和 cellContent(inline 或 block)。合并单元格使用 RowSpan 和 ColSpan 这两个 mark。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.