構建富文本編輯器 (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 構建富文本編輯應用程序,並且是了解有關該庫的更多信息並在需要時獲得幫助的好地方。