infonomic/payload-alternative-lexical-editor

GitHub: infonomic/payload-alternative-lexical-editor

为 Payload CMS 提供基于 Meta Lexical 的替代富文本编辑器,解决移动端兼容、内部链接序列化、嵌套编辑和状态同步等高级定制需求。

Stars: 11 | Forks: 0

# Payload CMS 替代版 Lexical 富文本编辑器 一个用于 Payload CMS 的基于适配器的替代版 [Lexical 富文本编辑器](https://lexical.dev/)。 ## 我们的 Lexical 编辑器 lexical-editor-screenshot ## 背景 我们在 2022 年开始使用 [Lexical](https://lexical.dev/),当时正在为我们的代理商寻找替代的 CMS。随后我们发现了 [Payload CMS](https://payloadcms.com/)——这在当时非常符合我们的需求,但有一个明显的例外——它使用 [Slate](https://github.com/ianstormtaylor/slate) 作为其富文本编辑器。我们之前使用过 Slate 和其他编辑器,非常希望能使用 Lexical。 我们也痛苦地意识到,使用基于 Contenteditable 的编辑器是多么困难——特别是对于移动端的支持,尤其是在 Android 上。请阅读 [Prose Mirror](https://prosemirror.net/) 的 Jesse Jorgenson 写的这篇有点著名的文章——[Contenteditable on Android is the Absolute Worst](https://discuss.prosemirror.net/t/contenteditable-on-android-is-the-absolute-worst/3810)。顺便说一句,[CKEditor](https://ckeditor.com/) 拥有出色的编辑界面和非常好的移动端支持(有针对 Android 大多数怪癖的解决方案)——遗憾的是它的模型不支持将结构化内容作为其原生序列化格式(它期望输入和输出都是 HTML——尽管它有相当不错的内部模型)。Lexical 的模型和原生序列化格式非常出色——“数据进,数据出”,而且移动端支持也“足够好”。 因此,我们开始为 Payload 开发基于 Lexical 的富文本字段。 2023 年初,我们发现了 [Alessio Gravili 的 Payload Lexical 插件](https://github.com/AlessioGr/payload-plugin-lexical),这对我们入门 Payload 和自定义字段帮助巨大。我们也尝试通过向 Alessio 的公共仓库做贡献来“回馈”他的工作。 很大程度上得益于 Alessio 的努力,Lexical 现已被 Payload 团队采用,并成为 Payload 的默认编辑器,这非常棒。 就我们而言,仍有一些问题(和一些看法)意味着目前继续使用我们自己的编辑器是我们首选的方案。我们也对新的 [Lexical Extensions](https://lexical.dev/docs/extensions/intro) 框架非常感兴趣,并且可能会在 Lexical Extensions API 稳定后迁移到该框架。 ## 理由 以下是我们希望维护自己编辑器的主要驱动因素: 1. 我们已经创建了一个自定义的 Lexical 富文本字段(在 Lexical 被包含在 Payload 之前),并且觉得当时将其转换为适配器比将我们的插件和节点转换为功能要容易。 2. 作为现有项目的候选编辑器——特别是对于我们的 Drupal 用户——我们需要一个包含 `LexicalNestedComposer` 支持的“横跨顶部”的编辑器[工具栏](https://github.com/infonomic/payload-alternative-lexical-editor/blob/main/packages/payload-alternative-lexical-editor/src/field/plugins/toolbar-plugin/index.tsx)。好消息是,官方 Payload Lexical 编辑器即将支持固定工具栏。 3. 我们需要一种方法从图像插件标题和 admonition 插件文本中的 `LexicalNestedComposer` 调用 RichText 字段的 `setValue`,因此创建了 `SharedOnChangeContext`。当启用版本控制时,这意味着当 `LexicalNestedComposer` 文本更改时,“保存草稿”和“发布更改”按钮会变为“启用”状态。总的来说,我们编辑器的[上下文提供者](https://github.com/infonomic/payload-alternative-lexical-editor/blob/main/packages/payload-alternative-lexical-editor/src/field/editor-context.tsx)结构也略有不同。 4. 我们希望控制内部链接的序列化。请参阅下面关于编辑器链接策略的专门章节。 5. 在 Payload 3.0 中——我们希望使用新的字段 API 和 `RenderFields` 试验仅客户端表单。您可以在我们的 [Admonition 插件](https://github.com/infonomic/payload-alternative-lexical-editor/blob/main/packages/payload-alternative-lexical-editor/src/field/plugins/admonition-plugin/admonition-drawer.tsx)中看到一个示例。这完全是实验性的。据我们所知它是有效的,并且我们将其用于所有需要带有 Payload 字段的模态框或抽屉的自定义组件。 6. 我们希望分享我们的插件——特别是我们的内联图像插件(已被 [Lexical playground](https://playground.lexical.dev/) 接纳)和我们的 Admonition 插件。事实上,我们的内联图像插件是我们选择 Lexical 作为首选编辑器的主要原因之一。尝试在任何“其他编辑器”中创建一个在管理 UI 和前端应用程序中都能正确显示的浮动内联元素,你就会明白为什么了 ;-). 这个仓库中的大多数其他插件都跟踪 Lexical Playground 插件并从那里更新。 7. 最后,我们希望保持编辑器轻量且快速——特别是对于长文档。 ## 编辑器链接策略 正如上面的理由部分提到的,我们希望控制内部链接的序列化。我们不想通过 `afterRead` 字段钩子为编辑器中的每个内部链接检索并填充整个文档,而是希望仅用 slug 和 title 来增强关系。 这是我们的 Lexical 链接节点版本: ``` { "direction": "ltr", "format": "", "indent": 0, "type": "link", "version": 2, "attributes": { "newTab": false, "linkType": "internal", "doc": { "value": "6635e07947922a2b9194d9a2", "relationTo": "minimal", "data": { "id": "6635e07947922a2b9194d9a2", "title": "This is a Test Minimal Page", "slug": "this-is-a-test-minimal-page" } }, "text": "Click Me!" } ``` 我们添加了一个名为 `data` 的额外属性,其中我们添加了目标文档的 id、title 和 slug。当与 relationTo 属性结合使用时,这就是前端应用程序创建指向目标文档的完整链接或路由链接所需的一切。 ### afterRead 当使用 `afterRead` 钩子时——我们添加 `data` 属性,并在文档读取期间动态填充相关文档的 title 和 slug。这是我们的 [`afterRead`](https://github.com/infonomic/payload-alternative-lexical-editor/blob/main/packages/payload-alternative-lexical-editor/src/field/lexical-after-read-populate-links.ts) 字段钩子。但请注意,对于包含不止一两个链接的文档,这会为单个源文档增加大量的文档请求,因为每个内部链接的相关文档都需要被检索以填充我们的 data 属性(O(n) 线性时间复杂度)。根据我们的经验,这可能会对整体性能和用户体验产生重大影响。 ### beforeChange 当使用 `beforeChange` 钩子时——我们在保存文档时将 `data` 属性添加到文档本身。这是我们的 [`beforeChange`](https://github.com/infonomic/payload-alternative-lexical-editor/blob/main/packages/payload-alternative-lexical-editor/src/field/lexical-before-change-populate-links.ts) 钩子。显然,这对过期链接(标题或 slug 可能已更改的源文档)有影响。但是,这对整体性能和用户体验没有影响,因为源文档已经包含内部链接所需的数据(O(1) 常数时间复杂度)。 此仓库中的配置使用的是 `beforeChange` 策略,尽管可以在 [richtext 适配器](https://github.com/infonomic/payload-alternative-lexical-editor/blob/main/packages/payload-alternative-lexical-editor/src/adapter.ts)的 hooks 属性中更改此设置。 ## 编辑器架构 我们编辑器的架构旨在解决富文本编辑器中常见的“双向绑定问题”。 富文本编辑器维护着自己复杂的内部状态(DOM/虚拟 DOM)。当你尝试将其与 React 状态同步时,经常会出现**无限循环**(编辑器更改 $\rightarrow$ React 更新 $\rightarrow$ Prop 更改 $\rightarrow$ 编辑器更新 $\rightarrow$ 编辑器更改...)或**光标跳动**(在输入时重新渲染编辑器)。 在 Payload 中——适配器通过 `rsc-entry.tsx` 桩文件和[适配器](https://github.com/infonomic/payload-alternative-lexical-editor/blob/main/packages/payload-alternative-lexical-editor/src/adapter.ts)中的组件映射条目加载我们的编辑器。这遵循了 Payload CMS 本身当前的适配器/组件映射策略。注意:虽然我们编辑器的返回形状略有不同,但我们的编辑器文档根节点和 SerializedEditorState 与官方 Payload Lexical 编辑器相同,因此迁移到 Payload 版本或从 Payload 版本迁移主要取决于使用了哪些功能或插件。 ### 组件层级 一旦编辑器从适配器“引导”启动,编辑器字段将通过以下组件层级进行渲染和管理: ``` +-----------------------------------------------------------------------+ | EditorField (src/field/editor-field.tsx) | | - Handles Lazy Loading & Suspense | +-----------------------------------+-----------------------------------+ | v +-----------------------------------+-----------------------------------+ | EditorComponent (src/field/editor-component.tsx) | | - Connects to Payload Forms (useField) | | - Manages Hash Refs (lastEmitted, normalizedIncoming) | | - Handles onChange (Debouncing & Hash Checks) | +-----------------------------------+-----------------------------------+ | | (renders) v +-----------------------------------+----------------------------------+ | EditorContext (src/field/editor-context.tsx) | | - Wraps everything in | | - Provides SharedHistory & SharedOnChange Contexts | +------------------+---------------------------------+-----------------+ | | | (passed as children) | (renders) v v +------------------+------------------+ +-----------+------------------+ | ApplyValuePlugin | | Editor (src/field/editor.tsx)| | (src/field/apply-value-plugin.tsx) | | - ToolbarPlugin | | | | - ContentEditable | | - Watches: incoming value & hash | | - Floating Toolbars | | - Action: editor.update() | | - Auto-resize logic | | (Syncs external props -> Editor) | +------------------------------+ +-------------------------------------+ ``` ### 关键关系 1. `EditorComponent` 是“大脑”。它持有与 Payload 表单状态的连接(`useField`),并根据哈希值决定何时更新表单值。 2. `EditorContext` 是“桥梁”。它初始化 Lexical 实例(`LexicalComposer`),但不包含用于同步值或渲染 UI 本身的特定逻辑。 3. `ApplyValuePlugin` 是“同步器”。它位于 Lexical 上下文内部。当 `EditorComponent` 从数据库(或父级)接收到新值时,它会将其传递到这里。该插件强制 Lexical 实例更新其状态以进行匹配。 4. `Editor` 是“视图”。它处理视觉呈现、工具栏和实际的 `contentEditable` DOM 元素。 ### 编辑器组件内部的稳定性 以下是 editor-component.tsx 中的 `useRef` 钩子如何解决特定于编辑器的问题: #### 1. “防抖” Ref **`dispatchFieldUpdateTask`** * **目的:** 性能。 * **工作原理:** 当用户输入时,Lexical 在每次击键时都会触发 `onChange`。我们不希望每秒 60 次更新主要的 React 状态(并触发树状结构向上的重新渲染)。 * **机制:** 此 ref 存储当前 `requestIdleCallback` 的 ID。如果用户在回调运行之前再次输入,我们将取消旧的并启动一个新的。它确保我们仅在浏览器空闲时处理*最新*状态。 #### 2. “新鲜 Props” Refs **`valueRef`** 和 **`initialValueRef`** * **目的:** 在 `useCallback` 内部访问状态而没有依赖项。 * **工作原理:** `handleChange` 函数通过 `useCallback` 进行记忆化。如果我们向其依赖数组中添加 `value`,`handleChange` 将在用户每次输入时重新创建,从而破坏我们的防抖逻辑。 * **机制:** 我们在每次渲染时将 props 复制到这些 refs 中。在 `handleChange` 内部,我们读取 `valueRef.current`。这使得函数保持稳定(相同的内存地址),同时仍能看到最新的数据。 #### 3. “出站循环阻断器” **`lastEmittedHashRef`** * **目的:** 防止“回声”循环。 * **问题:** 你输入“A”。编辑器发出“A”。父级保存“A”并将“A”作为 prop 传回。编辑器看到“A”并认为,“哦,一个新值!我应该处理这个。” * **修复:** 在调用 `onChange` 之前,我们计算内容的哈希值并将其存储在此处。 * **逻辑:** “如果我要发送的哈希值与我上次发送的相同,则不执行任何操作。” #### 4. “入站规范化” Refs **`normalizedIncomingHashRef`** 和 **`hasNormalizedBaselineRef`** * **目的:** 处理 Lexical 的严格性。 * **问题:** Lexical 是固执己见的。如果你加载一个像 `{"text": "hello"}` 这样的值,Lexical 可能会立即将其转换为更复杂的结构(添加 ID、版本等)。这种立即转换看起来像一个“更改”事件,这可能会在用户甚至还没碰键盘之前触发保存。 * **`normalizedIncomingHashRef`**: * `ApplyValuePlugin` 加载数据,等待 Lexical “稳定”(规范化它),然后在此处保存该*稳定*状态的哈希值。 * `handleChange` 函数检查这一点:“这个‘新’更改仅仅是我刚加载的数据的结果吗?”如果是,则忽略它。 * **`hasNormalizedBaselineRef`**: * 这充当看门人。它以 `false` 开始。 * 它阻止编辑器发出*任何*更改,直到初始值已完全加载并规范化。这可以防止编辑器在安装所需的瞬间意外地用空状态覆盖数据库。 ### 流程总结 1. **用户输入:** `handleChange` 触发。 2. **防抖:** `dispatchFieldUpdateTask` 确保我们等待暂停。 3. **检查基线:** `hasNormalizedBaselineRef` 确保我们不仅仅是在启动。 4. **检查入站:** `normalizedIncomingHashRef` 确保我们不仅仅是在报告 Lexical 对我们刚刚提供的数据的自动格式化。 5. **检查出站:** `lastEmittedHashRef` 确保我们不会两次报告相同的事情。 6. **成功:** 只有那样我们才调用 `onChange`。 ## 入门指南 ### 在此仓库中运行编辑器示例: 1. 克隆此仓库 2. 如果你还没有在本地运行的 MongoDB 实例,我们提供了一个 docker composer 文件和一个 shell 启动脚本。要启动,请从项目根目录 `cd mongodb`。`mkdir data` 然后 `./mongo.sh up` 以启动一个带有新数据库的本地 MongoDB 实例。 3. 在 `apps/next` 目录中——将 `.env.example` 复制到 `.env`(注意:在更改你的 PAYLOAD_SECRET 之前,不要将其部署到生产环境或公共服务)。 4. 从根目录——运行 `pnpm install`,然后运行 `pnpm dev`。 5. 要运行生产构建——从根目录运行 `pnpm build`,然后运行 `pnpm start`。 ### 在你自己的项目中安装并运行编辑器。 1. `pnpm add @infonomic/payload-alternative-lexical-editor` 或 `npm install @infonomic/payload-alternative-lexical-editor` 2. 在 `payload.config.ts` 中配置编辑器 ``` import { lexicalEditor } from '@infonomic/payload-alternative-lexical-editor' ... // @ts-expect-error: our return type for editorConfig is slightly different editor: lexicalEditor(), ... ``` 请遵循此仓库中 apps/next 下的示例来了解编辑器的配置选项和设置(开启或关闭编辑器功能)。 非常欢迎提出想法、建议或贡献。我们希望这其中的一些内容能有所帮助。
标签:CMS插件, DNS解析, Lexical, Payload CMS, React, Slate替代方案, Syscall, Syscalls, TypeScript, Web开发, 内容管理系统, 前端组件, 安全插件, 富文本编辑器, 开源项目, 无头CMS, 替代编辑器, 移动端支持, 结构化内容, 自动化攻击, 请求拦截, 调试插件, 适配器模式