
Slate 命令执行指南用自定义 Command 封装富文本编辑器业务逻辑【免费下载链接】slateA completely customizable framework for building rich text editors. (Currently in beta.)项目地址: https://gitcode.com/gh_mirrors/sl/slate导读本篇是 Slate 实战系列教程之一对应 docs/walkthroughs/05-executing-commands.md核心解决一个真实痛点当编辑器里积累了散落在onKeyDown中的一次性格式化逻辑时如何用命令Command把它们组织成可复用、可测试、与 UI 无关的领域方法。读完本文你将掌握如何把代码块与加粗等操作封装为自定义 helper如CustomEditor、如何在键盘快捷键与工具栏按钮中统一调用这些命令以及这些命令在 Slate 底层是如何通过Transforms与内置Editor方法转化为操作的。相关概念背景可参考 docs/concepts/06-commands.md。1. 为什么需要命令从一次性逻辑到领域模型在之前的教程03-defining-custom-elements.md 与 04-applying-custom-formatting.md中我们学会了如何定义代码块元素、如何通过Editor.addMark添加加粗标记并把逻辑写进Editable的onKeyDown处理器中const initialValue [ { type: paragraph, children: [{ text: A line of text in a paragraph. }], }, ] const App () { const [editor] useState(() withReact(createEditor())) const renderElement useCallback(props { switch (props.element.type) { case code: return CodeElement {...props} / default: return DefaultElement {...props} / } }, []) const renderLeaf useCallback(props { return Leaf {...props} / }, []) return ( Slate editor{editor} initialValue{initialValue} Editable renderElement{renderElement} renderLeaf{renderLeaf} onKeyDown{event { if (!event.ctrlKey) { return } switch (event.key) { case : { event.preventDefault() const [match] Editor.nodes(editor, { match: n n.type code, }) Transforms.setNodes( editor, { type: match ? null : code }, { match: n Element.isElement(n) Editor.isBlock(editor, n), } ) break } case b: { event.preventDefault() Editor.addMark(editor, bold, true) break } } }} / /Slate ) }这段代码暴露了两个问题逻辑被绑定在事件处理器里Ctrl-切代码块、Ctrl-B切加粗只能通过键盘触发无法在其他 UI如工具栏按钮、上下文菜单、命令面板中复用可读性与可维护性差Editor.nodesTransforms.setNodes的调用链直接暴露在 UI 层业务语义切换代码块被淹没在实现细节中。Slate 的设计哲学是你可以任意建模自己的富文本领域而不用为每个小功能写一次性代码。命令正是这一哲学的落地载体。2. 什么是 Slate 命令内置命令与自定义命令2.1 命令的本质Editor 接口上的辅助函数在 docs/concepts/06-commands.md 中明确命令Commands是代表用户某种意图的高层动作表现为Editor接口上的辅助函数helper functions。核心内置命令包括Editor.insertText(editor, A new string of text to be inserted.) Editor.deleteBackward(editor, { unit: word }) Editor.insertBreak(editor)这些内置命令并非魔法而是实实在在绑定在每个 editor 实例上的方法。查看 packages/slate/src/create-editor.ts 可以看到createEditor()在创建 editor 对象时把addMark、deleteBackward、deleteForward、deleteFragment、insertBreak、insertText、insertNode、removeMark、normalizeNode等核心方法逐个挂载到实例上如addMark: (...args) addMark(editor, ...args)同时把getMarks、nodes、setNodes、select、splitNodes等约 60 个接口方法一并注入。这也是为什么我们拿到editor后可以直接调用editor.insertText(...)的原因。命令有一个重要约定它总是描述用户正在执行某个动作因此不需要指定位置——它作用于用户当前的 selection选区。调用Editor.addMark(editor, bold, true)时加粗标记只会在当前光标或选区范围内生效。设计溯源命令的概念 loosely 基于 DOM 内置的execCommandAPI但 Slate 定义了自己更简单、可扩展的版本因为 DOM 版过于专断且行为不一致见 docs/concepts/06-commands.md。2.2 命令 → 操作Operation的自动转换命令之所以强大在于 Slate 会在底层自动把每条命令转换为一系列低层操作operations并应用到文档上从而产生新值。正是这个机制让协作编辑collaborative editing成为可能——因为每个操作都可以被记录、同步、撤销。在 docs/concepts/06-commands.md 中强调这一切是自动发生的使用者无需关心。2.3 自定义命令创建自己的命名空间Slate 鼓励你编写属于自己的领域命令比如formatQuote、insertImage、toggleBold等。官方推荐的做法是创建自定义命名空间const MyEditor { ...Editor, insertParagraph(editor) { // ... }, }在编写自定义命令时你通常会大量使用随 Slate 一起发布的Transforms辅助函数。3. Transforms命令的底层积木Transforms是一组允许你对文档执行各种精确变更的辅助函数是编写自定义命令时最常用的工具。来自 docs/concepts/06-commands.md 的几个典型示例// 给一个 range 内的所有文本节点设置 bold 格式。 // 平时应该用 Editor.addMark() 命令来应用加粗样式 // addMark() 内部执行的是一组类似的 setNodes transform // 但使用了更复杂的 match 函数以便在 markableVoid 元素内也能应用 marks。 Transforms.setNodes( editor, { bold: true }, { at: range, match: node Text.isText(node), split: true, } ) // 把文档中某个 point 处最低层的 block 包裹进一个引用块。 Transforms.wrapNodes( editor, { type: quote, children: [] }, { at: point, match: node Editor.isBlock(editor, node), mode: lowest, } ) // 在指定 path 处插入新文本替换该节点中的文本。 Transforms.insertText(editor, A new string of text., { at: path }) // ...还有更多 transformsTransform 辅助函数被设计为可以自由组合——每个命令通常由若干个 transform 协同完成。3.1 源码验证Transforms.setNodes做了什么我们教程中的toggleCodeBlock用到的Transforms.setNodes其实现位于 packages/slate/src/transforms-node/set-nodes.ts。从源码看它有几个关键行为默认at为editor.selectionmatch在未提供时默认匹配块级元素n Node.isElement(n) Editor.isBlock(editor, n)mode默认lowest提供split选项时会先调用Transforms.splitNodes在选区边界把节点拆开再对拆出的节点设置属性对每个匹配的节点比较新旧属性默认compare为prop ! nodeProp有变化时通过editor.apply({ type: set_node, path, properties, newProperties })产生一条set_node操作整个过程包在Editor.withoutNormalizing中且会跳过path.length 0即 editor 根节点与NON_SETTABLE_NODE_PROPERTIES中列出的不可设置属性。这就是命令在底层变成操作的最直观例证setNodes最终产出的是一条条可逆的set_node操作。3.2 源码验证Editor.nodes与匹配逻辑toggleCodeBlock中用来探测当前是否处于代码块内的Editor.nodes(editor, { match })其实现位于 packages/slate/src/editor/nodes.ts。它是一个生成器generator默认at为editor.selection、mode为all、voids为false会按文档顺序逐层遍历选区内的节点把满足match的节点以[node, path]的 NodeEntry 形式 yield 出来。教程中const [match] Editor.nodes(...)取到的就是第一个匹配项若没有匹配match为undefined即可据此判断当前代码块未激活。4. 实战用CustomEditor封装领域命令回到本教程的核心示例。我们把加粗和代码块这两个领域概念封装为一组自定义辅助函数形成自己的命令命名空间// Define our own custom set of helpers. const CustomEditor { isBoldMarkActive(editor) { const marks Editor.marks(editor) return marks ? marks.bold true : false }, isCodeBlockActive(editor) { const [match] Editor.nodes(editor, { match: n n.type code, }) return !!match }, toggleBoldMark(editor) { const isActive CustomEditor.isBoldMarkActive(editor) if (isActive) { Editor.removeMark(editor, bold) } else { Editor.addMark(editor, bold, true) } }, toggleCodeBlock(editor) { const isActive CustomEditor.isCodeBlockActive(editor) Transforms.setNodes( editor, { type: isActive ? null : code }, { match: n Element.isElement(n) Editor.isBlock(editor, n) } ) }, } const initialValue [ { type: paragraph, children: [{ text: A line of text in a paragraph. }], }, ] const App () { const [editor] useState(() withReact(createEditor())) const renderElement useCallback(props { switch (props.element.type) { case code: return CodeElement {...props} / default: return DefaultElement {...props} / } }, []) const renderLeaf useCallback(props { return Leaf {...props} / }, []) return ( Slate editor{editor} initialValue{initialValue} Editable renderElement{renderElement} renderLeaf{renderLeaf} onKeyDown{event { if (!event.ctrlKey) { return } // Replace the onKeyDown logic with our new commands. switch (event.key) { case : { event.preventDefault() CustomEditor.toggleCodeBlock(editor) break } case b: { event.preventDefault() CustomEditor.toggleBoldMark(editor) break } } }} / /Slate ) }现在onKeyDown中不再有任何格式化细节只剩两行语义清晰的调用CustomEditor.toggleCodeBlock(editor)与CustomEditor.toggleBoldMark(editor)。4.1Editor.marks/Editor.addMark/Editor.removeMark的底层行为为了让toggleBoldMark中的先查询再切换逻辑更可信有必要看看这三个内置方法的实现位于 packages/slate/src/editor/marks.ts、packages/slate/src/editor/add-mark.ts、packages/slate/src/editor/remove-mark.tsEditor.marks(editor)优先返回editor.marks缓存的光标处待写入标记若选区是展开的则遍历选区内的文本节点返回其共同属性去除text字段若光标折叠则取光标所在 leaf必要时回退到前一个文本节点且跳过 void 与 markableVoid 的边界处理的属性。这就是isBoldMarkActive能正确读取当前加粗是否激活的原因Editor.addMark(editor, key, value)若选区展开或选中了 markableVoid走Transforms.setNodes(editor, { [key]: value }, { match, split: true, voids: true })分支——注意其match只匹配文本节点marks can only be applied to text并把split: true以确保只在选区精确范围内生效若光标折叠则把新 mark 合并进editor.marks并触发onChange这样后续输入的文字会自动带上该 markEditor.removeMark(editor, key)与addMark对称展开选区时走Transforms.unsetNodes(editor, key, { match, split: true, voids: true })折叠时从editor.marks中删除该 key。这两个对称实现说明toggleBoldMark的激活则移除、未激活则添加完全等价于在 UI 上做一次加粗切换且同时兼容选中文本展开选区与仅光标折叠选区两种场景。4.2toggleCodeBlock的切换逻辑toggleCodeBlock先用Editor.nodes(editor, { match: n n.type code })判断当前选区是否落在代码块内再调用Transforms.setNodes(editor, { type: isActive ? null : code }, { match: ... })已激活isActive为 true时把type设为null——即移除该属性让元素恢复为默认块类型未激活时把匹配到的块级元素的type设为code。match限定为n Element.isElement(n) Editor.isBlock(editor, n)确保只作用于块级元素而不会误改文本节点或行内节点。5. 把命令接到工具栏一份逻辑多处触发封装的直接收益在下一步体现同样的命令可以从任意能拿到editor的地方调用比如假设的工具栏按钮。完整示例同时保留键盘快捷键const initialValue [ { type: paragraph, children: [{ text: A line of text in a paragraph. }], }, ] const App () { const [editor] useState(() withReact(createEditor())) const renderElement useCallback(props { switch (props.element.type) { case code: return CodeElement {...props} / default: return DefaultElement {...props} / } }, []) const renderLeaf useCallback(props { return Leaf {...props} / }, []) return ( // Add a toolbar with buttons that call the same methods. Slate editor{editor} initialValue{initialValue} div button onMouseDown{event { event.preventDefault() CustomEditor.toggleBoldMark(editor) }} Bold /button button onMouseDown{event { event.preventDefault() CustomEditor.toggleCodeBlock(editor) }} Code Block /button /div Editable editor{editor} renderElement{renderElement} renderLeaf{renderLeaf} onKeyDown{event { if (!event.ctrlKey) { return } switch (event.key) { case : { event.preventDefault() CustomEditor.toggleCodeBlock(editor) break } case b: { event.preventDefault() CustomEditor.toggleBoldMark(editor) break } } }} / /Slate ) }注意工具栏按钮用的是onMouseDown而非onClick并调用event.preventDefault()这是为了避免按钮先抢走编辑器焦点、导致点击后editor.selection丢失从而让命令作用于错误的甚至为空的选区。5.1 仓库中的真实范例悬浮工具栏在真实仓库中站点示例 site/examples/ts/hovering-toolbar.tsx 完整演示了同一模式的落地它把加粗、斜体、代码、下划线等格式封装成CustomEditor.toggleFormat(editor, bold)之类的命令从悬浮工具栏按钮与快捷键两处同时调用并实时根据Editor.marks/选区状态高亮激活按钮。需要参考可运行配置时可查看 site/examples/Readme.md 与该示例的 JS 版 site/examples/js/hovering-toolbar.jsx。6. 测试你的命令仓库测试结构参考由于命令只是接收editor的纯函数它们天然易于单元测试——这正是文档所说把所有命令逻辑保持在单一、隔离、可测试的地方的价值。仓库中对setNodes、addMark等底层能力有大量测试用例可作参考例如 packages/slate/test/transforms/setNodes、packages/slate/test/Editor/marks测试位于packages/slate/test/目录采用jestslate-hyperscript的 JSX 描述初始值与期望值的方式。为你的CustomEditor编写类似用例即可在不依赖 React DOM 的情况下验证命令行为。7. 小结回顾本篇你完成了三件事从一次性逻辑到领域命令把散落在onKeyDown中的Editor.nodes/Transforms.setNodes/Editor.addMark调用收敛为CustomEditor上的toggleBoldMark、toggleCodeBlock、isBoldMarkActive、isCodeBlockActive四个语义化方法一处定义、多处复用同一份命令同时服务于键盘快捷键与工具栏按钮onMouseDownpreventDefault未来还能继续扩展到菜单、命令面板等任意入口理解底层原理知道了命令是Editor接口上的辅助函数、由内置Transforms支撑且最终会被转换为低层操作如set_node自动应用——这一机制同时为撤销重做与协作编辑详见 07-enabling-collaborative-editing.md奠定了基础。如文档所言我们只花了很少的工作就给编辑器添加了大量功能并且把所有命令逻辑保持在单一、可测试、隔离的地方让代码更易于维护。 接下来你可以继续阅读 06-saving-to-a-database.md 学习如何持久化这些内容或在 docs/concepts/06-commands.md 中深入了解命令与 transforms 的概念模型。【免费下载链接】slateA completely customizable framework for building rich text editors. (Currently in beta.)项目地址: https://gitcode.com/gh_mirrors/sl/slate创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考