构建富文本编辑器 (WYSIWYG)

已发表: 2022-03-10
快速总结↬在本文中,我们将学习如何构建一个所见即所得/富文本编辑器,该编辑器支持富文本、图像、链接和文字处理应用程序的一些细微差别的功能。 我们将使用 SlateJS 构建编辑器的外壳,然后添加工具栏和自定义配置。 该应用程序的代码可在 GitHub 上获取以供参考。

近年来,数字平台上的内容创建和表示领域发生了巨大的变化。 Quip、Google Docs 和 Dropbox Paper 等产品的广泛成功表明,公司正在竞相为企业领域的内容创建者打造最佳体验,并试图找到打破传统内容共享和消费模式的创新方法。 利用社交媒体平台的大规模推广,新一波独立内容创作者使用 Medium 等平台创作内容并与观众分享。

由于有如此多来自不同专业和背景的人尝试在这些产品上创建内容,因此这些产品提供高性能和无缝的内容创建体验并拥有设计师和工程师团队,随着时间的推移在该领域开发一定程度的领域专业知识,这一点很重要. 通过本文,我们不仅尝试为构建编辑器奠定基础,而且还让读者了解将这些功能组合在一起时如何为内容创建者创造出色的用户体验。

了解文档结构

在我们深入构建编辑器之前,让我们看看如何为富文本编辑器构建文档以及涉及哪些不同类型的数据结构。

文档节点

文档节点用于表示文档的内容。 富文本文档可能包含的常见节点类型是段落、标题、图像、视频、代码块和拉引号。 其中一些可能包含其他节点作为其中的子节点(例如,段落节点在其中包含文本节点)。 节点还包含特定于它们所代表的对象的任何属性,这些属性是在编辑器中呈现这些节点所需的。 (例如,图像节点包含图像src属性,代码块可能包含language属性等)。

主要有两种类型的节点表示它们应该如何呈现 -

  • 块节点(类似于块级元素的 HTML 概念),每个都呈现在新行上并占据可用宽度。 块节点可以在其中包含其他块节点或内联节点。 这里的一个观察是文档的顶级节点总是块节点。
  • 与前一个节点在同一行开始渲染的内联节点(类似于内联元素的 HTML 概念)。 内联元素在不同编辑库中的表示方式存在一些差异。 SlateJS 允许内联元素本身就是节点。 DraftJS 是另一个流行的富文本编辑库,它允许您使用实体的概念来呈现内联元素。 链接和内联图像是内联节点的示例。
  • Void Nodes — SlateJS 还允许我们将在本文后面使用的第三类节点来渲染媒体。

如果您想了解有关这些类别的更多信息,SlateJS 的 Nodes 文档是一个不错的起点。

跳跃后更多! 继续往下看↓

属性

类似于 HTML 的属性概念,富文本文档中的属性用于表示节点或其子节点的非内容属性。 例如,文本节点可以具有字符样式属性,告诉我们文本是粗体/斜体/下划线等。 尽管本文将标题表示为节点本身,但表示它们的另一种方式可能是节点具有段落样式( paragraphh1-h6 )作为它们的属性。

下图给出了一个示例,说明如何使用节点和属性在更精细的级别上描述文档的结构(在 JSON 中),突出显示左侧结构中的一些元素。

图像显示编辑器内的示例文档,其结构表示在左侧
示例文档及其结构表示。 (大预览)

结构中值得一提的一些事情是:

  • 文本节点表示为{text: 'text content'}
  • 节点的属性直接存储在节点上(例如链接的url和图像的caption
  • 如果字符样式发生变化,SlateJS 特定的文本属性表示会将文本节点分解为它们自己的节点。 因此,文本“ Duis aute irure dolor ”是它自己的文本节点,上面设置了bold: true 。 本文档中的斜体、下划线和代码样式文本也是如此。

地点和选择

在构建富文本编辑器时,了解如何用某种坐标表示文档中最细粒度的部分(例如字符)至关重要。 这有助于我们在运行时导航文档结构,以了解我们在文档层次结构中的位置。 最重要的是,位置对象为我们提供了一种表示用户选择的方法,该方法被广泛用于实时定制编辑器的用户体验。 我们将在本文后面使用选择来构建我们的工具栏。 这些例子可能是:

  • 用户的光标当前是否在链接中,也许我们应该向他们显示一个菜单来编辑/删除链接?
  • 用户是否选择了图像? 也许我们给他们一个菜单来调整图像的大小。
  • 如果用户选择了某些文本并点击了 DELETE 按钮,我们会确定用户选择的文本是什么并将其从文档中删除。

SlateJS 在 Location 上的文档对这些数据结构进行了广泛的解释,但我们在这里快速浏览它们,因为我们在文章的不同实例中使用这些术语,并在下图中显示了一个示例。

  • 小路
    由数字数组表示,路径是到达文档中节点的方式。 例如,路径[2,3]表示文档中第二个节点的第三个子节点。
  • 观点
    由路径 + 偏移量表示的更精细的内容位置。 例如, {path: [2,3], offset: 14}的一个点表示文档的第 2 个节点内的第 3 个子节点的第 14 个字符。
  • 范围
    一对表示文档内文本范围的点(称为anchorfocus )。 这个概念来自 Web 的 Selection API,其中anchor是用户选择的开始位置, focus是用户选择的结束位置。 折叠的范围/选择表示锚点和焦点相同的位置(例如,考虑文本输入中的闪烁光标)。

举个例子,假设我们上面的文档示例中用户的选择是ipsum

在编辑器中选择文本“ipsum”的图像
用户选择单词ipsum 。 (大预览)

用户的选择可以表示为:

 { anchor: {path: [2,0], offset: 5}, /*0th text node inside the paragraph node which itself is index 2 in the document*/ focus: {path: [2,0], offset: 11}, // space + 'ipsum' }`

设置编辑器

在本节中,我们将设置应用程序并获得一个与 SlateJS 一起使用的基本富文本编辑器。 样板应用程序将是添加了 SlateJS 依赖create-react-app 。 我们正在使用react-bootstrap中的组件构建应用程序的 UI。 让我们开始吧!

创建一个名为wysiwyg-editor的文件夹,然后从目录中运行以下命令来设置 react 应用程序。 然后我们运行一个yarn start命令,该命令应该启动本地 Web 服务器(端口默认为 3000)并显示一个 React 欢迎屏幕。

 npx create-react-app . yarn start

然后我们继续将 SlateJS 依赖项添加到应用程序中。

 yarn add slate slate-react

slate是 SlateJS 的核心包, slate-react包括一组 React 组件,我们将使用它来呈现 Slate 编辑器。 SlateJS 公开了更多按功能组织的包,人们可能会考虑将其添加到他们的编辑器中。

我们首先创建一个utils文件夹,其中包含我们在此应用程序中创建的任何实用程序模块。 我们首先创建一个ExampleDocument.js ,它返回一个基本的文档结构,其中包含一个带有一些文本的段落。 该模块如下所示:

 const ExampleDocument = [ { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, ], }, ]; export default ExampleDocument;

我们现在添加一个名为components的文件夹,它将保存我们所有的 React 组件并执行以下操作:

  • 将我们的第一个 React 组件Editor.js添加到其中。 它现在只返回一个div
  • 更新App.js组件以将文档保持在其初始化为上面的ExampleDocument的状态。
  • 在应用程序中渲染编辑器并将文档状态和onChange处理程序传递给编辑器,以便我们的文档状态在用户更新时更新。
  • 我们也使用 React bootstrap 的 Nav 组件向应用程序添加导航栏。

App.js组件现在如下所示:

 import Editor from './components/Editor'; function App() { const [document, updateDocument] = useState(ExampleDocument); return ( <> <Navbar bg="dark" variant="dark"> <Navbar.Brand href="#"> <img alt="" src="/app-icon.png" width="30" height="30" className="d-inline-block align-top" />{" "} WYSIWYG Editor </Navbar.Brand> </Navbar> <div className="App"> <Editor document={document} onChange={updateDocument} /> </div> </> );

在 Editor 组件中,我们然后实例化 SlateJS 编辑器并将其保存在useMemo中,以便对象在重新渲染之间不会发生变化。

 // dependencies imported as below. import { withReact } from "slate-react"; import { createEditor } from "slate"; const editor = useMemo(() => withReact(createEditor()), []);

createEditor为我们提供了 SlateJS editor实例,我们通过应用程序广泛使用它来访问选择、运行数据转换等。 withReact 是一个 SlateJS 插件,它将 React 和 DOM 行为添加到编辑器对象。 SlateJS 插件是接收editor对象并附加一些配置的 Javascript 函数。 这允许 Web 开发人员以可组合的方式将配置添加到他们的 SlateJS 编辑器实例。

现在,我们使用从 App.js 获得的文档道具从 SlateJS 导入和渲染<Slate /><Editable />组件。 Slate公开了一堆我们用来在应用程序代码中访问的 React 上下文。 Editable是呈现文档层次结构以供编辑的组件。 总体而言,此阶段的Editor.js模块如下所示:

 import { Editable, Slate, withReact } from "slate-react"; import { createEditor } from "slate"; import { useMemo } from "react"; export default function Editor({ document, onChange }) { const editor = useMemo(() => withReact(createEditor()), []); return ( <Slate editor={editor} value={document} onChange={onChange}> <Editable /> </Slate> ); }

至此,我们添加了必要的 React 组件,并在编辑器中填充了示例文档。 现在应该设置我们的编辑器,允许我们实时输入和更改内容 - 如下面的截屏视频所示。

基本编辑器设置在行动

现在,让我们继续下一节,我们将编辑器配置为呈现字符样式和段落节点。

自定义文本渲染和工具栏

段落样式节点

目前,我们的编辑器使用 SlateJS 的默认渲染来处理我们可能添加到文档中的任何新节点类型。 在本节中,我们希望能够渲染标题节点。 为了能够做到这一点,我们为 Slate 的组件提供了一个renderElement函数 prop。 当 Slate 试图遍历文档树并渲染每个节点时,该函数在运行时被 Slate 调用。 renderElement 函数获取三个参数——

  • attributes
    SlateJS 特定的,必须应用于从该函数返回的顶级 DOM 元素。
  • element
    节点对象本身存在于文档结构中
  • children
    此节点的子节点在文档结构中定义。

我们将renderElement实现添加到名为useEditorConfig的钩子中,我们将在其中添加更多编辑器配置。 然后我们在Editor.js中的编辑器实例上使用钩子。

 import { DefaultElement } from "slate-react"; export default function useEditorConfig(editor) { return { renderElement }; } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { case "paragraph": return <p {...attributes}>{children}</p>; case "h1": return <h1 {...attributes}>{children}</h1>; case "h2": return <h2 {...attributes}>{children}</h2>; case "h3": return <h3 {...attributes}>{children}</h3>; case "h4": return <h4 {...attributes}>{children}</h4>; default: // For the default case, we delegate to Slate's default rendering. return <DefaultElement {...props} />; } }

由于这个函数让我们可以访问element (即节点本身),我们可以自定义renderElement来实现更自定义的渲染,而不仅仅是检查element.type 。 例如,您可以有一个具有isInline属性的图像节点,我们可以使用该属性返回不同的 DOM 结构,帮助我们渲染内联图像而不是块图像。

我们现在更新 Editor 组件以使用此钩子,如下所示:

 const { renderElement } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} /> );

自定义渲染到位后,我们更新 ExampleDocument 以包含我们的新节点类型,并验证它们在编辑器中正确渲染。

 const ExampleDocument = [ { type: "h1", children: [{ text: "Heading 1" }], }, { type: "h2", children: [{ text: "Heading 2" }], }, // ...more heading nodes 
显示在编辑器中呈现的不同标题和段落节点的图像
编辑器中的标题和段落节点。 (大预览)

字符样式

renderElement类似,SlateJS 提供了一个名为 renderLeaf 的函数 prop,可用于自定义文本节点的呈现( Leaf指的是文本节点,它们是文档树的叶子/最低级别节点)。 按照renderElement的例子,我们为renderLeaf编写一个实现。

 export default function useEditorConfig(editor) { return { renderElement, renderLeaf }; } // ... function renderLeaf({ attributes, children, leaf }) { let el = <>{children}</>; if (leaf.bold) { el = <strong>{el}</strong>; } if (leaf.code) { el = <code>{el}</code>; } if (leaf.italic) { el = <em>{el}</em>; } if (leaf.underline) { el = <u>{el}</u>; } return <span {...attributes}>{el}</span>; }

对上述实现的一个重要观察是它允许我们尊重字符样式的 HTML 语义。 由于 renderLeaf 让我们可以访问文本节点leaf本身,我们可以自定义函数来实现更自定义的渲染。 例如,您可能有一种方法让用户为文本选择一个highlightColor并在此处检查该叶子属性以附加相应的样式。

我们现在更新 Editor 组件以使用上面的ExampleDocument以在段落中有一些文本节点以及这些样式的组合,并使用我们使用的语义标签验证它们在 Editor 中是否按预期呈现。

 # src/components/Editor.js const { renderElement, renderLeaf } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} renderLeaf={renderLeaf} /> );
 # src/utils/ExampleDocument.js { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, { text: "Bold text.", bold: true, code: true }, { text: "Italic text.", italic: true }, { text: "Bold and underlined text.", bold: true, underline: true }, { text: "variableFoo", code: true }, ], }, 
UI 中的字符样式以及它们在 DOM 树中的呈现方式
UI 中的字符样式以及它们在 DOM 树中的呈现方式。 (大预览)

添加工具栏

让我们从添加一个新组件Toolbar.js开始,我们在其中添加一些用于字符样式的按钮和一个用于段落样式的下拉列表,我们稍后会在本节中将它们连接起来。

 const PARAGRAPH_STYLES = ["h1", "h2", "h3", "h4", "paragraph", "multiple"]; const CHARACTER_STYLES = ["bold", "italic", "underline", "code"]; export default function Toolbar({ selection, previousSelection }) { return ( <div className="toolbar"> {/* Dropdown for paragraph styles */} <DropdownButton className={"block-style-dropdown"} disabled={false} title={getLabelForBlockStyle("paragraph")} > {PARAGRAPH_STYLES.map((blockType) => ( <Dropdown.Item eventKey={blockType} key={blockType}> {getLabelForBlockStyle(blockType)} </Dropdown.Item> ))} </DropdownButton> {/* Buttons for character styles */} {CHARACTER_STYLES.map((style) => ( <ToolBarButton key={style} icon={<i className={`bi ${getIconForButton(style)}`} />} isActive={false} /> ))} </div> ); } function ToolBarButton(props) { const { icon, isActive, ...otherProps } = props; return ( <Button variant="outline-primary" className="toolbar-btn" active={isActive} {...otherProps} > {icon} </Button> ); }

我们将按钮抽象为ToolbarButton组件,该组件是 React Bootstrap Button 组件的包装器。 然后,我们在Editable inside Editor组件上方渲染工具栏,并验证工具栏是否显示在应用程序中。

图像显示工具栏和在编辑器上方呈现的按钮
带按钮的工具栏(大预览)

以下是我们需要工具栏支持的三个关键功能:

  1. 当用户的光标位于文档中的某个位置并单击其中一个字符样式按钮时,我们需要切换他们接下来可能键入的文本的样式。
  2. 当用户选择一系列文本并单击其中一个字符样式按钮时,我们需要切换该特定部分的样式。
  3. 当用户选择文本范围时,我们希望更新段落样式下拉列表以反映所选内容的段落类型。 如果他们确实从选择中选择了不同的值,我们希望将整个选择的段落样式更新为他们选择的内容。

在我们开始实现它们之前,让我们看看这些功能是如何在编辑器上工作的。

字符样式切换行为

聆听选择

工具栏需要能够执行上述功能的最重要的事情是文档的选择状态。 在撰写本文时,SlateJS 没有公开可以为我们提供文档最新选择状态的onSelectionChange方法。 然而,当编辑器中的选择发生变化时,SlateJS 会调用onChange方法,即使文档内容没有改变。 我们使用它作为通知选择更改并将其存储在Editor组件状态中的一种方式。 我们将其抽象为一个钩子useSelection ,我们可以在其中对选择状态进行更优化的更新。 这很重要,因为对于所见即所得的编辑器实例,选择是一个经常更改的属性。

 import areEqual from "deep-equal"; export default function useSelection(editor) { const [selection, setSelection] = useState(editor.selection); const setSelectionOptimized = useCallback( (newSelection) => { // don't update the component state if selection hasn't changed. if (areEqual(selection, newSelection)) { return; } setSelection(newSelection); }, [setSelection, selection] ); return [selection, setSelectionOptimized]; }

我们在Editor组件中使用这个钩子,如下所示,并将选择传递给 Toolbar 组件。

 const [selection, setSelection] = useSelection(editor); const onChangeHandler = useCallback( (document) => { onChange(document); setSelection(editor.selection); }, [editor.selection, onChange, setSelection] ); return ( <Slate editor={editor} value={document} onChange={onChangeHandler}> <Toolbar selection={selection} /> ...

性能考虑

在我们拥有更大的编辑器代码库和更多功能的应用程序中,以高性能的方式(例如使用某些状态管理库)存储和侦听选择更改非常重要,因为侦听选择更改的组件也可能会渲染经常。 一种方法是在保存特定选择信息的选择状态之上优化选择器。 例如,编辑器可能希望在选择图像时呈现图像大小调整菜单。 在这种情况下,从编辑器的选择状态计算选择器isImageSelected可能会有所帮助,并且仅当此选择器的值更改时,图像菜单才会重新呈现。 Redux 的 Reselect 就是这样一个支持构建选择器的库。

直到稍后我们才在工具栏中使用selection ,但将其作为道具传递会使工具栏在每次编辑器上的选择更改时重新渲染。 我们这样做是因为我们不能仅仅依靠文档内容更改来触发层次结构( App -> Editor -> Toolbar )的重新渲染,因为用户可能只是不断单击文档,从而更改选择,但从未真正更改文档内容本身。

切换字符样式

我们现在开始从 SlateJS 获取活动字符样式,并在编辑器中使用这些样式。 让我们添加一个新的 JS 模块EditorUtils ,它将托管我们构建的所有 util 函数,以使用 SlateJS 获取/执行操作。 我们在模块中的第一个函数是getActiveStyles ,它在编辑器中提供一Set活动样式。 我们还添加了一个在编辑器功能上切换样式的功能 - toggleStyle

 # src/utils/EditorUtils.js import { Editor } from "slate"; export function getActiveStyles(editor) { return new Set(Object.keys(Editor.marks(editor) ?? {})); } export function toggleStyle(editor, style) { const activeStyles = getActiveStyles(editor); if (activeStyles.has(style)) { Editor.removeMark(editor, style); } else { Editor.addMark(editor, style, true); } }

这两个函数都将作为 Slate 实例的editor对象作为参数,我们将在本文后面添加许多实用函数。在 Slate 术语中,格式化样式称为标记,我们使用编辑器接口上的辅助方法来获取、添加并删除这些标记。我们在工具栏中导入这些 util 函数并将它们连接到我们之前添加的按钮。

 # src/components/Toolbar.js import { getActiveStyles, toggleStyle } from "../utils/EditorUtils"; import { useEditor } from "slate-react"; export default function Toolbar({ selection }) { const editor = useEditor(); return <div ... {CHARACTER_STYLES.map((style) => ( <ToolBarButton key={style} characterStyle={style} icon={<i className={`bi ${getIconForButton(style)}`} />} isActive={getActiveStyles(editor).has(style)} onMouseDown={(event) => { event.preventDefault(); toggleStyle(editor, style); }} /> ))} </div>

useEditor是一个 Slate 钩子,它使我们能够从渲染层次结构中较高的&lt;Slate>组件附加的上下文中访问 Slate 实例。

有人可能想知道为什么我们在这里使用onMouseDown而不是onClick ? 当编辑器以任何方式失去焦点时,Slate 如何将selection变为null ,有一个开放的 Github 问题。 因此,如果我们将onClick处理程序附加到我们的工具栏按钮,则selection变为null ,并且用户在尝试切换样式时会丢失光标位置,这不是一种很好的体验。 相反,我们通过附加一个onMouseDown事件来切换样式,以防止选择被重置。 另一种方法是自己跟踪选择,以便我们知道最后一个选择是什么,并使用它来切换样式。 我们会在文章后面介绍previousSelection的概念,但要解决一个不同的问题。

SlateJS 允许我们在编辑器上配置事件处理程序。 我们使用它来连接键盘快捷键以切换字符样式。 为此,我们在useEditorConfig中添加一个KeyBindings对象,其中我们公开了一个附加到Editable组件的onKeyDown事件处理程序。 我们使用is-hotkey工具来确定组合键并切换相应的样式。

 # src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { const onKeyDown = useCallback( (event) => KeyBindings.onKeyDown(editor, event), [editor] ); return { renderElement, renderLeaf, onKeyDown }; } const KeyBindings = { onKeyDown: (editor, event) => { if (isHotkey("mod+b", event)) { toggleStyle(editor, "bold"); return; } if (isHotkey("mod+i", event)) { toggleStyle(editor, "italic"); return; } if (isHotkey("mod+c", event)) { toggleStyle(editor, "code"); return; } if (isHotkey("mod+u", event)) { toggleStyle(editor, "underline"); return; } }, }; # src/components/Editor.js ... <Editable renderElement={renderElement} renderLeaf={renderLeaf} onKeyDown={onKeyDown} /> 
使用键盘快捷键切换的字符样式。

使段落样式下拉菜单起作用

让我们继续制作段落样式下拉菜单。 类似于段落样式下拉菜单在 MS Word 或 Google Docs 等流行文字处理应用程序中的工作方式,我们希望用户选择中顶级块的样式反映在下拉菜单中。 如果选择中存在单一一致的样式,我们将下拉值更新为该样式。 如果有多个,我们将下拉值设置为“多个”。 此行为必须同时适用于折叠和展开的选择。

为了实现这种行为,我们需要能够找到跨越用户选择的顶级块。 为此,我们使用 Slate 的Editor.nodes - 一个帮助函数,通常用于搜索由不同选项过滤的树中的节点。

 nodes( editor: Editor, options?: { at?: Location | Span match?: NodeMatch<T> mode?: 'all' | 'highest' | 'lowest' universal?: boolean reverse?: boolean voids?: boolean } ) => Generator<NodeEntry<T>, void, undefined>

辅助函数接受一个 Editor 实例和一个options对象,这是一种在遍历树时过滤树中节点的方法。 该函数返回NodeEntry的生成器。 Slate 术语中的NodeEntry是一个节点及其路径的元组 - [node, pathToNode] 。 此处找到的选项可用于大多数 Slate 辅助函数。 让我们来看看每一个的含义:

  • at
    这可以是辅助函数用于向下遍历树的路径/点/范围。 如果未提供,则默认为editor.selection 。 我们还在下面的用例中使用默认值,因为我们对用户选择中的节点感兴趣。
  • match
    这是一个可以提供的匹配函数,在每个节点上调用并在匹配时包含在内。 我们在下面的实现中使用此参数仅过滤到块元素。
  • mode
    让辅助函数知道我们是否对给定位置匹配match函数at的所有最高级别或最低级别节点感兴趣。 这个参数(设置highest )帮助我们避免尝试自己遍历树以找到顶级节点。
  • universal
    标记以在节点的全部或部分匹配之间进行选择。 (此标志提案的 GitHub 问题有一些解释它的示例)
  • reverse
    如果节点搜索应该是在传入位置的起点和终点的相反方向。
  • voids
    如果搜索应仅过滤为 void 元素。

SlateJS 公开了许多帮助函数,让您可以以不同的方式查询节点、遍历树、以复杂的方式更新节点或选择。 在 Slate 之上构建复杂的编辑功能时,值得深入研究其中的一些界面(在本文末尾列出)。

有了帮助函数的背景,下面是getTextBlockStyle的实现。

 # src/utils/EditorUtils.js export function getTextBlockStyle(editor) { const selection = editor.selection; if (selection == null) { return null; } const topLevelBlockNodesInSelection = Editor.nodes(editor, { at: editor.selection, mode: "highest", match: (n) => Editor.isBlock(editor, n), }); let blockType = null; let nodeEntry = topLevelBlockNodesInSelection.next(); while (!nodeEntry.done) { const [node, _] = nodeEntry.value; if (blockType == null) { blockType = node.type; } else if (blockType !== node.type) { return "multiple"; } nodeEntry = topLevelBlockNodesInSelection.next(); } return blockType; }

性能考虑

Editor.nodes的当前实现在at参数范围内的所有级别上查找整个树中的所有节点,然后在其上运行匹配过滤器(检查nodeEntries和稍后的过滤 - 源)。 这适用于较小的文档。 但是,对于我们的用例,如果用户选择了 3 个标题和 2 个段落(每个段落包含 10 个文本节点),它将循环通过至少 25 个节点(3 + 2 + 2*10)并尝试运行过滤器在他们。 由于我们已经知道我们只对顶级节点感兴趣,我们可以从选择中找到顶级块的开始和结束索引并自行迭代。 这样的逻辑将仅循环通过 3 个节点条目(2 个标题和 1 个段落)。 代码如下所示:

 export function getTextBlockStyle(editor) { const selection = editor.selection; if (selection == null) { return null; } // gives the forward-direction points in case the selection was // was backwards. const [start, end] = Range.edges(selection); //path[0] gives us the index of the top-level block. let startTopLevelBlockIndex = start.path[0]; const endTopLevelBlockIndex = end.path[0]; let blockType = null; while (startTopLevelBlockIndex <= endTopLevelBlockIndex) { const [node, _] = Editor.node(editor, [startTopLevelBlockIndex]); if (blockType == null) { blockType = node.type; } else if (blockType !== node.type) { return "multiple"; } startTopLevelBlockIndex++; } return blockType; }

当我们向 WYSIWYG 编辑器添加更多功能并且需要经常遍历文档树时,为手头的用例考虑最高效的方法很重要,因为可用的 API 或辅助方法可能并不总是最这样做的有效方法。

一旦我们实现了getTextBlockStyle ,块样式的切换就相对简单了。 如果当前样式不是用户在下拉列表中选择的样式,我们将样式切换为该样式。 如果它已经是用户选择的内容,我们将其切换为段落。 因为我们将段落样式表示为文档结构中的节点,所以切换段落样式本质上意味着更改节点上的type属性。 我们使用 Slate 提供的Transforms.setNodes来更新节点上的属性。

我们的toggleBlockType的实现如下:

 # src/utils/EditorUtils.js export function toggleBlockType(editor, blockType) { const currentBlockType = getTextBlockStyle(editor); const changeTo = currentBlockType === blockType ? "paragraph" : blockType; Transforms.setNodes( editor, { type: changeTo }, // Node filtering options supported here too. We use the same // we used with Editor.nodes above. { at: editor.selection, match: (n) => Editor.isBlock(editor, n) } ); }

最后,我们更新了段落样式下拉菜单以使用这些实用功能。

 #src/components/Toolbar.js const onBlockTypeChange = useCallback( (targetType) => { if (targetType === "multiple") { return; } toggleBlockType(editor, targetType); }, [editor] ); const blockType = getTextBlockStyle(editor); return ( <div className="toolbar"> <DropdownButton ..... disabled={blockType == null} title={getLabelForBlockStyle(blockType ?? "paragraph")} onSelect={onBlockTypeChange} > {PARAGRAPH_STYLES.map((blockType) => ( <Dropdown.Item eventKey={blockType} key={blockType}> {getLabelForBlockStyle(blockType)} </Dropdown.Item> ))} </DropdownButton> .... ); 
选择多个块类型并使用下拉菜单更改类型。

链接

在本节中,我们将添加对显示、添加、删除和更改链接的支持。 我们还将添加一个链接检测器功能——非常类似于 Google Docs 或 MS Word 扫描用户输入的文本并检查其中是否有链接的方式。 如果有,它们将被转换为链接对象,这样用户就不必自己使用工具栏按钮来执行此操作。

渲染链接

在我们的编辑器中,我们将使用 SlateJS 将链接实现为内联节点。 我们更新了我们的编辑器配置以将链接标记为 SlateJS 的内联节点,并提供一个组件来渲染,以便 Slate 知道如何渲染链接节点。

 # src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { ... editor.isInline = (element) => ["link"].includes(element.type); return {....} } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { ... case "link": return <Link {...props} url={element.url} />; ... } }
 # src/components/Link.js export default function Link({ element, attributes, children }) { return ( <a href={element.url} {...attributes} className={"link"}> {children} </a> ); }

We then add a link node to our ExampleDocument and verify that it renders correctly (including a case for character styles inside a link) in the Editor.

 # src/utils/ExampleDocument.js { type: "paragraph", children: [ ... { text: "Some text before a link." }, { type: "link", url: "https://www.google.com", children: [ { text: "Link text" }, { text: "Bold text inside link", bold: true }, ], }, ... } 
Image showing Links rendered in the Editor and DOM tree of the editor
Links rendered in the Editor (Large preview)

Adding A Link Button To The Toolbar

Let's add a Link Button to the toolbar that enables the user to do the following:

  • Selecting some text and clicking on the button converts that text into a link
  • Having a blinking cursor (collapsed selection) and clicking the button inserts a new link there
  • If the user's selection is inside a link, clicking on the button should toggle the link — meaning convert the link back to text.

To build these functionalities, we need a way in the toolbar to know if the user's selection is inside a link node. We add a util function that traverses the levels in upward direction from the user's selection to find a link node if there is one, using Editor.above helper function from SlateJS.

 # src/utils/EditorUtils.js export function isLinkNodeAtSelection(editor, selection) { if (selection == null) { return false; } return ( Editor.above(editor, { at: selection, match: (n) => n.type === "link", }) != null ); }

Now, let's add a button to the toolbar that is in active state if the user's selection is inside a link node.

 # src/components/Toolbar.js return ( <div className="toolbar"> ... {/* Link Button */} <ToolBarButton isActive={isLinkNodeAtSelection(editor, editor.selection)} label={<i className={`bi ${getIconForButton("link")}`} />} /> </div> ); 
Link button in Toolbar becomes active if selection is inside a link.

To toggle links in the editor, we add a util function toggleLinkAtSelection . Let's first look at how the toggle works when you have some text selected. When the user selects some text and clicks on the button, we want only the selected text to become a link. What this inherently means is that we need to break the text node that contains selected text and extract the selected text into a new link node. The before and after states of these would look something like below:

Before and After node structures after a link is inserted
Before and After node structures after a link is inserted. (大预览)

If we had to do this by ourselves, we'd have to figure out the range of selection and create three new nodes (text, link, text) that replace the original text node. SlateJS has a helper function called Transforms.wrapNodes that does exactly this — wrap nodes at a location into a new container node. We also have a helper available for the reverse of this process — Transforms.unwrapNodes which we use to remove links from selected text and merge that text back into the text nodes around it. With that, toggleLinkAtSelection has the below implementation to insert a new link at an expanded selection.

 # src/utils/EditorUtils.js export function toggleLinkAtSelection(editor) { if (!isLinkNodeAtSelection(editor, editor.selection)) { const isSelectionCollapsed = Range.isCollapsed(editor.selection); if (isSelectionCollapsed) { Transforms.insertNodes( editor, { type: "link", url: '#', children: [{ text: 'link' }], }, { at: editor.selection } ); } else { Transforms.wrapNodes( editor, { type: "link", url: '#', children: [{ text: '' }] }, { split: true, at: editor.selection } ); } } else { Transforms.unwrapNodes(editor, { match: (n) => Element.isElement(n) && n.type === "link", }); } }

If the selection is collapsed, we insert a new node there with Transform.insertNodes that inserts the node at the given location in the document. We wire this function up with the toolbar button and should now have a way to add/remove links from the document with the help of the link button.

 # src/components/Toolbar.js <ToolBarButton ... isActive={isLinkNodeAtSelection(editor, editor.selection)} onMouseDown={() => toggleLinkAtSelection(editor)} /> 

Link Editor Menu

So far, our editor has a way to add and remove links but we don't have a way to update the URLs associated with these links. How about we extend the user experience to allow users to edit it easily with a contextual menu? To enable link editing, we will build a link-editing popover that shows up whenever the user selection is inside a link and lets them edit and apply the URL to that link node. Let's start with building an empty LinkEditor component and rendering it whenever the user selection is inside a link.

# src/components/LinkEditor.js export default function LinkEditor() { return ( <Card className={"link-editor"}> <Card.Body></Card.Body> </Card> ); }
 # src/components/Editor.js <div className="editor"> {isLinkNodeAtSelection(editor, selection) ? <LinkEditor /> : null} <Editable renderElement={renderElement} renderLeaf={renderLeaf} onKeyDown={onKeyDown} /> </div>

由于我们在编辑器之外渲染LinkEditor ,我们需要一种方法来告诉LinkEditor链接在 DOM 树中的位置,以便它可以在编辑器附近渲染自己。 我们这样做的方式是使用 Slate 的 React API 来查找与选择中的链接节点对应的 DOM 节点。 然后我们使用getBoundingClientRect()来查找链接的 DOM 元素的边界和编辑器组件的边界,并计算链接编辑器的topleftEditorLinkEditor的代码更新如下——

 # src/components/Editor.js const editorRef = useRef(null) <div className="editor" ref={editorRef}> {isLinkNodeAtSelection(editor, selection) ? ( <LinkEditor editorOffsets={ editorRef.current != null ? { x: editorRef.current.getBoundingClientRect().x, y: editorRef.current.getBoundingClientRect().y, } : null } /> ) : null} <Editable renderElement={renderElement} ...
 # src/components/LinkEditor.js import { ReactEditor } from "slate-react"; export default function LinkEditor({ editorOffsets }) { const linkEditorRef = useRef(null); const [linkNode, path] = Editor.above(editor, { match: (n) => n.type === "link", }); useEffect(() => { const linkEditorEl = linkEditorRef.current; if (linkEditorEl == null) { return; } const linkDOMNode = ReactEditor.toDOMNode(editor, linkNode); const { x: nodeX, height: nodeHeight, y: nodeY, } = linkDOMNode.getBoundingClientRect(); linkEditorEl.style.display = "block"; linkEditorEl.style.top = `${nodeY + nodeHeight — editorOffsets.y}px`; linkEditorEl.style.left = `${nodeX — editorOffsets.x}px`; }, [editor, editorOffsets.x, editorOffsets.y, node]); if (editorOffsets == null) { return null; } return <Card ref={linkEditorRef} className={"link-editor"}></Card>; }

SlateJS 在内部维护节点映射到它们各自的 DOM 元素。 我们访问该地图并使用ReactEditor.toDOMNode找到链接的 DOM 元素。

链接内的选择显示链接编辑器弹出框。

如上面的视频所示,当插入链接但没有 URL 时,由于选择位于链接内部,它会打开链接编辑器,从而使用户可以为新插入的链接输入 URL,并且因此关闭了那里的用户体验循环。

我们现在向LinkEditor添加一个输入元素和一个按钮,让用户输入一个 URL 并将其应用到链接节点。 我们使用isUrl包进行 URL 验证。

 # src/components/LinkEditor.js import isUrl from "is-url"; export default function LinkEditor({ editorOffsets }) { const [linkURL, setLinkURL] = useState(linkNode.url); // update state if `linkNode` changes useEffect(() => { setLinkURL(linkNode.url); }, [linkNode]); const onLinkURLChange = useCallback( (event) => setLinkURL(event.target.value), [setLinkURL] ); const onApply = useCallback( (event) => { Transforms.setNodes(editor, { url: linkURL }, { at: path }); }, [editor, linkURL, path] ); return ( ... <Form.Control size="sm" type="text" value={linkURL} onChange={onLinkURLChange} /> <Button className={"link-editor-btn"} size="sm" variant="primary" disabled={!isUrl(linkURL)} onClick={onApply} > Apply </Button> ... );

连接好表单元素后,让我们看看链接编辑器是否按预期工作。

编辑器在单击链接编辑器内部时失去选择

正如我们在视频中看到的那样,当用户尝试点击输入时,链接编辑器就会消失。 这是因为当我们在Editable组件之外渲染链接编辑器时,当用户单击输入元素时,SlateJS 认为编辑器失去了焦点并将selection重置为null ,这将删除LinkEditor ,因为isLinkActiveAtSelection不再为true 。 有一个开放的 GitHub 问题讨论了这种 Slate 行为。 解决此问题的一种方法是在用户更改时跟踪用户之前的选择,当编辑器确实失去焦点时,我们可以查看之前的选择,如果之前的选择中有链接,我们仍然会显示链接编辑器菜单。 让我们更新useSelection钩子以记住先前的选择并将其返回给 Editor 组件。

 # src/hooks/useSelection.js export default function useSelection(editor) { const [selection, setSelection] = useState(editor.selection); const previousSelection = useRef(null); const setSelectionOptimized = useCallback( (newSelection) => { if (areEqual(selection, newSelection)) { return; } previousSelection.current = selection; setSelection(newSelection); }, [setSelection, selection] ); return [previousSelection.current, selection, setSelectionOptimized]; }

然后我们更新Editor组件中的逻辑以显示链接菜单,即使之前的选择中有一个链接。

 # src/components/Editor.js const [previousSelection, selection, setSelection] = useSelection(editor); let selectionForLink = null; if (isLinkNodeAtSelection(editor, selection)) { selectionForLink = selection; } else if (selection == null && isLinkNodeAtSelection(editor, previousSelection)) { selectionForLink = previousSelection; } return ( ... <div className="editor" ref={editorRef}> {selectionForLink != null ? ( <LinkEditor selectionForLink={selectionForLink} editorOffsets={..} ... );

然后我们更新LinkEditor以使用selectionForLink来查找链接节点,在它下面渲染并更新它的 URL。

 # src/components/Link.js export default function LinkEditor({ editorOffsets, selectionForLink }) { ... const [node, path] = Editor.above(editor, { at: selectionForLink, match: (n) => n.type === "link", }); ... 
使用 LinkEditor 组件编辑链接。

检测文本中的链接

大多数文字处理应用程序识别文本内的链接并将其转换为链接对象。 在我们开始构建它之前,让我们看看它在编辑器中是如何工作的。

在用户输入链接时检测到链接。

启用此行为的逻辑步骤如下:

  1. 当文档随着用户键入而改变时,找到用户插入的最后一个字符。 如果那个字符是一个空格,我们知道它之前肯定有一个单词。
  2. 如果最后一个字符是空格,我们将其标记为它之前的单词的结束边界。 然后,我们在文本节点内逐个字符地回溯以找到该单词的开始位置。 在这个遍历过程中,我们必须小心不要越过节点开始的边缘进入前一个节点。
  3. 一旦我们之前找到了单词的开始和结束边界,我们就会检查单词的字符串,看看它是否是一个 URL。 如果是,我们将其转换为链接节点。

我们的逻辑存在于一个实用函数identifyLinksInTextIfAny中,该函数存在于EditorUtils中,并在Editor组件的onChange中调用。

 # src/components/Editor.js const onChangeHandler = useCallback( (document) => { ... identifyLinksInTextIfAny(editor); }, [editor, onChange, setSelection] );

这是identifyLinksInTextIfAny与步骤 1 的逻辑实现:

 export function identifyLinksInTextIfAny(editor) { // if selection is not collapsed, we do not proceed with the link // detection if (editor.selection == null || !Range.isCollapsed(editor.selection)) { return; } const [node, _] = Editor.parent(editor, editor.selection); // if we are already inside a link, exit early. if (node.type === "link") { return; } const [currentNode, currentNodePath] = Editor.node(editor, editor.selection); // if we are not inside a text node, exit early. if (!Text.isText(currentNode)) { return; } let [start] = Range.edges(editor.selection); const cursorPoint = start; const startPointOfLastCharacter = Editor.before(editor, editor.selection, { unit: "character", }); const lastCharacter = Editor.string( editor, Editor.range(editor, startPointOfLastCharacter, cursorPoint) ); if(lastCharacter !== ' ') { return; }

有两个 SlateJS 辅助函数可以让事情变得简单。

  • Editor.before给我们某个位置之前的点。 它将unit作为参数,因此我们可以在传入location之前询问字符/单词/块等。
  • Editor.string — 获取范围内的字符串。

作为一个例子,下图解释了当用户插入一个字符“E”并且他们的光标位于它之后时,这些变量的值是什么。

通过示例说明第 1 步之后 cursorPoint 和 startPointOfLastCharacter 指向的位置
步骤 1 之后的cursorPointstartPointOfLastCharacter以及示例文本。 (大预览)

如果文本“ABCDE”是文档第一段的第一个文本节点,我们的点值将是——

 cursorPoint = { path: [0,0], offset: 5} startPointOfLastCharacter = { path: [0,0], offset: 4}

如果最后一个字符是空格,我们就知道它从哪里开始startPointOfLastCharacter. 让我们移动到第 2 步,我们逐个字符地向后移动,直到我们找到另一个空格或文本节点本身的开始。

 ... if (lastCharacter !== " ") { return; } let end = startPointOfLastCharacter; start = Editor.before(editor, end, { unit: "character", }); const startOfTextNode = Editor.point(editor, currentNodePath, { edge: "start", }); while ( Editor.string(editor, Editor.range(editor, start, end)) !== " " && !Point.isBefore(start, startOfTextNode) ) { end = start; start = Editor.before(editor, end, { unit: "character" }); } const lastWordRange = Editor.range(editor, end, startPointOfLastCharacter); const lastWord = Editor.string(editor, lastWordRange);

这是一个图表,显示了当我们发现输入的最后一个单词是ABCDE时这些不同点所指向的位置。

以示例说明链接检测步骤 2 之后不同点所在位置的图表
在链接检测的第 2 步之后,不同的点是一个示例。 (大预览)

请注意, startend是那里的空间之前和之后的点。 同样, startPointOfLastCharactercursorPoint是刚刚插入的空间用户前后的点。 因此[end,startPointOfLastCharacter]给了我们插入的最后一个单词。

我们将lastWord的值记录到控制台并在我们键入时验证这些值。

控制台记录验证用户在步骤 2 中的逻辑之后输入的最后一个单词。

现在我们已经推断出用户输入的最后一个单词是什么,我们验证它确实是一个 URL,并将该范围转换为一个链接对象。 这种转换看起来类似于工具栏链接按钮如何将用户选择的文本转换为链接。

 if (isUrl(lastWord)) { Promise.resolve().then(() => { Transforms.wrapNodes( editor, { type: "link", url: lastWord, children: [{ text: lastWord }] }, { split: true, at: lastWordRange } ); }); }

identifyLinksInTextIfAny在 Slate 的onChange中被调用,所以我们不想更新onChange中的文档结构。 因此,我们使用Promise.resolve().then(..)调用将此更新放在我们的任务队列中。

让我们看看逻辑在行动中的结合! 我们验证是否在文本节点的末尾、中间或开头插入链接。

在用户键入链接时检测到链接。

有了这个,我们已经完成了编辑器上链接的功能,然后转到图像。

处理图像

在本节中,我们专注于添加对渲染图像节点、添加新图像和更新图像标题的支持。 在我们的文档结构中,图像将表示为 Void 节点。 SlateJS 中的 Void 节点(类似于 HTML 规范中的 Void 元素)使得它们的内容不是可编辑的文本。 这允许我们将图像渲染为空洞。 由于 Slate 在渲染方面的灵活性,我们仍然可以在 Void 元素中渲染我们自己的可编辑元素——我们将用于图像标题编辑。 SlateJS 有一个示例演示如何将整个富文本编辑器嵌入到 Void 元素中。

为了渲染图像,我们将编辑器配置为将图像视为 Void 元素,并提供如何渲染图像的渲染实现。 我们将图像添加到我们的 ExampleDocument 并验证它是否与标题一起正确呈现。

 # src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { const { isVoid } = editor; editor.isVoid = (element) => { return ["image"].includes(element.type) || isVoid(element); }; ... } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { case "image": return <Image {...props} />; ... `` `` # src/components/Image.js function Image({ attributes, children, element }) { return ( <div contentEditable={false} {...attributes}> <div className={classNames({ "image-container": true, })} > <img src={String(element.url)} alt={element.caption} className={"image"} /> <div className={"image-caption-read-mode"}>{element.caption}</div> </div> {children} </div> ); }

尝试使用 SlateJS 渲染 void 节点时要记住两件事:

  • 根 DOM 元素应该设置contentEditable={false}以便 SlateJS 如此对待它的内容。 如果没有这个,当您与 void 元素交互时,SlateJS 可能会尝试计算选择等并因此中断。
  • 即使 Void 节点没有任何子节点(例如我们的图像节点),我们仍然需要渲染children节点并提供一个空文本节点作为子节点(参见下面的ExampleDocument ),它被视为 Void 的选择点SlateJS 的元素

我们现在更新ExampleDocument以添加图像并验证它是否在编辑器中显示为标题。

 # src/utils/ExampleDocument.js const ExampleDocument = [ ... { type: "image", url: "/photos/puppy.jpg", caption: "Puppy", // empty text node as child for the Void element. children: [{ text: "" }], }, ]; 
在编辑器中渲染的图像
在编辑器中渲染的图像。 (大预览)

现在让我们专注于字幕编辑。 我们希望这为用户提供无缝体验的方式是,当他们单击标题时,我们会显示一个文本输入,他们可以在其中编辑标题。 如果他们在输入之外单击或按 RETURN 键,我们会将其视为应用标题的确认。 然后我们更新图像节点上的标题并将标题切换回阅读模式。 让我们看看它的实际效果,这样我们就知道我们正在构建什么。

图片说明编辑在行动。

让我们更新我们的 Image 组件以具有标题的读取编辑模式的状态。 我们在用户更新本地字幕状态时更新它,当他们点击退出( onBlur )或点击返回( onKeyDown )时,我们将字幕应用到节点并再次切换到阅读模式。

 const Image = ({ attributes, children, element }) => { const [isEditingCaption, setEditingCaption] = useState(false); const [caption, setCaption] = useState(element.caption); ... const applyCaptionChange = useCallback( (captionInput) => { const imageNodeEntry = Editor.above(editor, { match: (n) => n.type === "image", }); if (imageNodeEntry == null) { return; } if (captionInput != null) { setCaption(captionInput); } Transforms.setNodes( editor, { caption: captionInput }, { at: imageNodeEntry[1] } ); }, [editor, setCaption] ); const onCaptionChange = useCallback( (event) => { setCaption(event.target.value); }, [editor.selection, setCaption] ); const onKeyDown = useCallback( (event) => { if (!isHotkey("enter", event)) { return; } applyCaptionChange(event.target.value); setEditingCaption(false); }, [applyCaptionChange, setEditingCaption] ); const onToggleCaptionEditMode = useCallback( (event) => { const wasEditing = isEditingCaption; setEditingCaption(!isEditingCaption); wasEditing && applyCaptionChange(caption); }, [editor.selection, isEditingCaption, applyCaptionChange, caption] ); return ( ... {isEditingCaption ? ( <Form.Control autoFocus={true} className={"image-caption-input"} size="sm" type="text" defaultValue={element.caption} onKeyDown={onKeyDown} onChange={onCaptionChange} onBlur={onToggleCaptionEditMode} /> ) : ( <div className={"image-caption-read-mode"} onClick={onToggleCaptionEditMode} > {caption} </div> )} </div> ...

这样,字幕编辑功能就完成了。 我们现在开始为用户添加一种将图像上传到编辑器的方法。 让我们添加一个工具栏按钮,让用户选择和上传图像。

 # src/components/Toolbar.js const onImageSelected = useImageUploadHandler(editor, previousSelection); return ( <div className="toolbar"> .... <ToolBarButton isActive={false} as={"label"} htmlFor="image-upload" label={ <> <i className={`bi ${getIconForButton("image")}`} /> <input type="file" className="image-upload-input" accept="image/png, image/jpeg" onChange={onImageSelected} /> </> } /> </div>

当我们处理图像上传时,代码可能会增长很多,所以我们将图像上传处理移动到一个钩子useImageUploadHandler ,它发出一个附加到文件输入元素的回调。 我们将很快讨论为什么它需要previousSelection状态。

在我们实现useImageUploadHandler之前,我们将设置服务器以便能够将图像上传到。 我们设置了一个 Express 服务器并安装了另外两个包corsmulter ,它们为我们处理文件上传。

 yarn add express cors multer

然后,我们添加一个src/server.js脚本,该脚本使用 cors 和 multer 配置 Express 服务器,并公开一个端点/upload ,我们将把图像上传到该端点。

 # src/server.js const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, "./public/photos/"); }, filename: function (req, file, cb) { cb(null, file.originalname); }, }); var upload = multer({ storage: storage }).single("photo"); app.post("/upload", function (req, res) { upload(req, res, function (err) { if (err instanceof multer.MulterError) { return res.status(500).json(err); } else if (err) { return res.status(500).json(err); } return res.status(200).send(req.file); }); }); app.use(cors()); app.listen(port, () => console.log(`Listening on port ${port}`));

现在我们已经设置了服务器,我们可以专注于处理图像上传。 当用户上传图片时,可能需要几秒钟才能上传图片,并且我们有一个 URL。 但是,我们会立即向用户提供图像上传正在进行中的反馈,以便他们知道图像正在被插入到编辑器中。 以下是我们为使这种行为起作用而实施的步骤 -

  1. 一旦用户选择了一个图像,我们在用户的光标位置插入一个图像节点,并在其上设置一个标志isUploading ,以便我们可以向用户显示加载状态。
  2. 我们将请求发送到服务器以上传图像。
  3. 一旦请求完成并且我们有一个图像 URL,我们在图像上设置它并删除加载状态。

让我们从插入图像节点的第一步开始。 现在,这里棘手的部分是我们在选择时遇到了与工具栏中的链接按钮相同的问题。 一旦用户单击工具栏中的 Image 按钮,编辑器就会失去焦点并且选择变为null 。 如果我们尝试插入图像,我们不知道用户的光标在哪里。 跟踪previousSelection为我们提供了该位置,我们使用它来插入节点。

 # src/hooks/useImageUploadHandler.js import { v4 as uuidv4 } from "uuid"; export default function useImageUploadHandler(editor, previousSelection) { return useCallback( (event) => { event.preventDefault(); const files = event.target.files; if (files.length === 0) { return; } const file = files[0]; const fileName = file.name; const formData = new FormData(); formData.append("photo", file); const id = uuidv4(); Transforms.insertNodes( editor, { id, type: "image", caption: fileName, url: null, isUploading: true, children: [{ text: "" }], }, { at: previousSelection, select: true } ); }, [editor, previousSelection] ); }

当我们插入新的图像节点时,我们还使用 uuid 包为其分配一个标识符id 。 我们将在步骤 (3) 的实现中讨论我们为什么需要它。 我们现在更新图像组件以使用isUploading标志来显示加载状态。

 {!element.isUploading && element.url != null ? ( <img src={element.url} alt={caption} className={"image"} /> ) : ( <div className={"image-upload-placeholder"}> <Spinner animation="border" variant="dark" /> </div> )}

这样就完成了第 1 步的实现。让我们验证我们是否能够选择要上传的图像,看到图像节点被插入并带有加载指示器,它被插入到文档中。

图像上传创建具有加载状态的图像节点。

转到步骤 (2),我们将使用 axois 库向服务器发送请求。

 export default function useImageUploadHandler(editor, previousSelection) { return useCallback((event) => { .... Transforms.insertNodes( … {at: previousSelection, select: true} ); axios .post("/upload", formData, { headers: { "content-type": "multipart/form-data", }, }) .then((response) => { // update the image node. }) .catch((error) => { // Fire another Transform.setNodes to set an upload failed state on the image }); }, [...]); }

我们验证图片上传是否有效,并且图片确实显示在应用程序的public/photos文件夹中。 现在图片上传完成了,我们进入第 (3) 步,我们要在 axios promise 的resolve()函数中设置图片的 URL。 我们可以使用Transforms.setNodes更新图像,但我们有一个问题——我们没有新插入的图像节点的路径。 让我们看看我们有什么选择来获得该图像-

  • 我们不能使用editor.selection因为选择必须在新插入的图像节点上吗? 我们不能保证这一点,因为在上传图片时,用户可能点击了其他地方并且选择可能已经改变。
  • 使用previousSelection我们用来插入图像节点的previousSelection怎么样? 出于同样的原因,我们不能使用editor.selection ,我们不能使用previousSelection因为它也可能已经改变了。
  • SlateJS 有一个 History 模块,用于跟踪文档发生的所有更改。 我们可以使用这个模块来搜索历史并找到最后插入的图像节点。 如果上传图片需要更长的时间并且用户在第一次上传完成之前在文档的不同部分插入了更多图片,这也不是完全可靠的。
  • 目前, Transform.insertNodes的 API 不返回有关插入节点的任何信息。 如果它可以返回插入节点的路径,我们可以使用它来找到我们应该更新的精确图像节点。

由于上述方法都不起作用,我们将id应用于插入的图像节点(在步骤(1)中),并在图像上传完成时再次使用相同的id来定位它。 这样,我们的步骤 (3) 的代码如下所示 -

 axios .post("/upload", formData, { headers: { "content-type": "multipart/form-data", }, }) .then((response) => { const newImageEntry = Editor.nodes(editor, { match: (n) => n.id === id, }); if (newImageEntry == null) { return; } Transforms.setNodes( editor, { isUploading: false, url: `/photos/${fileName}` }, { at: newImageEntry[1] } ); }) .catch((error) => { // Fire another Transform.setNodes to set an upload failure state // on the image. });

完成所有三个步骤的实施后,我们就可以测试端到端的图像上传了。

图像上传端到端工作

有了这个,我们已经为我们的编辑打包了图像。 目前,无论图像如何,我们都会显示相同大小的加载状态。 如果在上传完成时加载状态被一个大大减小或更大的图像所取代,这对用户来说可能是一种不和谐的体验。 上传体验的一个很好的后续措施是在上传之前获取图像尺寸并显示该尺寸的占位符,以便无缝过渡。 我们在上面添加的钩子可以扩展为支持其他媒体类型,如视频或文档,并渲染这些类型的节点。

结论

在本文中,我们构建了一个所见即所得编辑器,它具有一组基本功能和一些微用户体验,如链接检测、就地链接编辑和图像标题编辑,帮助我们更深入地了解 SlateJS 和富文本编辑的概念一般的。 如果这个围绕富文本编辑或文字处理的问题空间让您感兴趣,那么需要解决的一些很酷的问题可能是:

  • 合作
  • 更丰富的文本编辑体验,支持文本对齐、内联图像、复制粘贴、更改字体和文本颜色等。
  • 从 Word 文档和 Markdown 等流行格式导入。

如果您想了解更多 SlateJS,这里有一些可能会有所帮助的链接。

  • SlateJS 示例
    许多示例超越了基础知识并构建了通常在编辑器中找到的功能,例如搜索和突出显示、Markdown 预览和提及。
  • API 文档
    对 SlateJS 公开的许多帮助函数的引用,在尝试对 SlateJS 对象执行复杂的查询/转换时,这些函数可能需要方便使用。

最后,SlateJS 的 Slack Channel 是一个非常活跃的 Web 开发人员社区,他们使用 SlateJS 构建富文本编辑应用程序,并且是了解有关该库的更多信息并在需要时获得帮助的好地方。