リッチテキストエディタの構築(WYSIWYG)
公開: 2022-03-10近年、デジタルプラットフォームでのコンテンツの作成と表現の分野は大規模な混乱を経験しています。 Quip、Google Docs、Dropbox Paperなどの製品が広く成功していることは、企業がエンタープライズドメインのコンテンツ作成者に最高のエクスペリエンスを構築するために競争し、コンテンツの共有と消費の従来の型を打ち破る革新的な方法を見つけようとしていることを示しています。 ソーシャルメディアプラットフォームの大規模なアウトリーチを利用して、Mediumなどのプラットフォームを使用してコンテンツを作成し、視聴者と共有する独立したコンテンツクリエーターの新しい波があります。
さまざまな職業やバックグラウンドの多くの人々がこれらの製品でコンテンツを作成しようとしているため、これらの製品がコンテンツ作成のパフォーマンスとシームレスなエクスペリエンスを提供し、この分野で一定レベルのドメイン専門知識を開発するデザイナーとエンジニアのチームを持つことが重要です。 。 この記事では、エディターを構築するための基礎を築くだけでなく、機能の小さな塊がコンテンツ作成者にとって優れたユーザーエクスペリエンスをどのように生み出すことができるかを読者に垣間見せようとしています。
ドキュメント構造を理解する
エディターの構築に取り掛かる前に、リッチテキストエディター用にドキュメントがどのように構造化されているか、および関連するさまざまなタイプのデータ構造について見ていきましょう。
ドキュメントノード
ドキュメントノードは、ドキュメントのコンテンツを表すために使用されます。 リッチテキストドキュメントに含まれる可能性のあるノードの一般的なタイプは、段落、見出し、画像、ビデオ、コードブロック、および引用符です。 これらの一部には、子として他のノードが含まれている場合があります(たとえば、段落ノードにはテキストノードが含まれています)。 ノードは、エディター内でそれらのノードをレンダリングするために必要な、ノードが表すオブジェクトに固有のプロパティも保持します。 (たとえば、画像ノードには画像src
プロパティが含まれ、コードブロックにはlanguage
プロパティが含まれる場合があります)。
レンダリング方法を表すノードには、主に2つのタイプがあります-
- それぞれが新しい行にレンダリングされ、使用可能な幅を占めるブロックノード(ブロックレベル要素のHTML概念に類似)。 ブロックノードには、他のブロックノードまたはその中にインラインノードを含めることができます。 ここでの観察は、ドキュメントの最上位ノードは常にブロックノードであるということです。
- 前のノードと同じ行でレンダリングを開始するインラインノード(インライン要素のHTML概念に類似)。 インライン要素がさまざまな編集ライブラリでどのように表現されるかには、いくつかの違いがあります。 SlateJSを使用すると、インライン要素をノード自体にすることができます。 もう1つの人気のあるリッチテキスト編集ライブラリであるDraftJSを使用すると、エンティティの概念を使用してインライン要素をレンダリングできます。 リンクとインライン画像は、インラインノードの例です。
- ボイドノード— SlateJSでは、この記事の後半でメディアをレンダリングするために使用する、この3番目のカテゴリのノードも使用できます。
これらのカテゴリについて詳しく知りたい場合は、ノードに関するSlateJSのドキュメントから始めることをお勧めします。
属性
HTMLの属性の概念と同様に、リッチテキストドキュメントの属性は、ノードまたはその子の非コンテンツプロパティを表すために使用されます。 たとえば、テキストノードは、テキストが太字/斜体/下線付きであるかどうかを示す文字スタイルの属性を持つことができます。 この記事では見出しをノード自体として表現していますが、見出しを表現する別の方法として、ノードの属性として段落スタイル( paragraph
& h1-h6
)を使用することもできます。
以下の画像は、左側の構造内のいくつかの要素を強調表示するノードと属性を使用して、ドキュメントの構造(JSON)がより詳細なレベルでどのように記述されているかの例を示しています。
ここで構造を使用して呼び出す価値のあるもののいくつかは次のとおりです。
- テキストノードは
{text: 'text content'}
として表されます - ノードのプロパティはノードに直接保存されます(リンクの
url
や画像のcaption
など) - SlateJS固有のテキスト属性の表現は、文字スタイルが変更された場合、テキストノードを独自のノードに分割します。 したがって、テキスト「 Duis aute irure dolor 」は、
bold: true
が設定された独自のテキストノードです。 このドキュメントの斜体、下線、およびコードスタイルのテキストの場合も同様です。
場所と選択
リッチテキストエディタを作成するときは、ドキュメントの最も細かい部分(文字など)をある種の座標で表現する方法を理解することが重要です。 これは、実行時にドキュメント構造をナビゲートして、ドキュメント階層のどこにいるかを理解するのに役立ちます。 最も重要なことは、ロケーションオブジェクトは、ユーザーの選択を表す方法を提供します。これは、エディターのユーザーエクスペリエンスをリアルタイムで調整するために非常に広く使用されています。 この記事の後半で、選択を使用してツールバーを作成します。 これらの例は次のとおりです。
- ユーザーのカーソルは現在リンク内にありますか?リンクを編集/削除するためのメニューを表示する必要がありますか?
- ユーザーは画像を選択しましたか? たぶん、画像のサイズを変更するためのメニューを提供します。
- ユーザーが特定のテキストを選択して[削除]ボタンを押すと、ユーザーが選択したテキストが何であるかが判別され、ドキュメントから削除されます。
Locationに関するSlateJSのドキュメントでは、これらのデータ構造について詳しく説明していますが、記事のさまざまなインスタンスでこれらの用語を使用し、次の図に例を示すため、ここで簡単に説明します。
- 道
数字の配列で表されるパスは、ドキュメント内のノードに到達するための方法です。 たとえば、パス[2,3]
は、ドキュメント内の2番目のノードの3番目の子ノードを表します。 - 点
パス+オフセットで表されるコンテンツのより詳細な場所。 たとえば、{path: [2,3], offset: 14}
のポイントは、ドキュメントの2番目のノード内の3番目の子ノードの14番目の文字を表します。 - 範囲
ドキュメント内のテキストの範囲を表す1対のポイント(anchor
とfocus
と呼ばれます)。 この概念は、anchor
がユーザーの選択の開始点であり、focus
が終了点であるWebのSelectionAPIに由来します。 折りたたまれた範囲/選択は、アンカーポイントとフォーカスポイントが同じ場所を示します(たとえば、テキスト入力でカーソルが点滅していると考えてください)。
例として、上記のドキュメント例でのユーザーの選択が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
コンポーネントを使用してアプリケーションのUIを構築しています。 始めましょう!react-bootstrap
wysiwyg-editor
というフォルダーを作成し、ディレクトリ内から以下のコマンドを実行して、reactアプリをセットアップします。 次に、 yarn start
コマンドを実行して、ローカルWebサーバー(ポートのデフォルトは3000)を起動し、Reactのウェルカム画面を表示します。
npx create-react-app . yarn start
次に、SlateJSの依存関係をアプリケーションに追加します。
yarn add slate slate-react
slate
はSlateJSのコアパッケージであり、 slate-react
には、Slateエディターのレンダリングに使用するReactコンポーネントのセットが含まれています。 SlateJSは、エディターに追加することを検討する可能性のある機能別に編成された、さらにいくつかのパッケージを公開します。
まず、このアプリケーションで作成したユーティリティモジュールを保持するutils
フォルダーを作成します。 まず、テキストを含む段落を含む基本的なドキュメント構造を返すExampleDocument.js
を作成します。 このモジュールは次のようになります。
const ExampleDocument = [ { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, ], }, ]; export default ExampleDocument;
ここで、すべてのReactコンポーネントを保持するcomponents
というフォルダーを追加し、次のことを行います。
- 最初のReactコンポーネント
Editor.js
をそれに追加します。 今のところdiv
を返すだけです。 -
App.js
コンポーネントを更新して、上記のExampleDocument
に初期化された状態でドキュメントを保持します。 - アプリ内でエディターをレンダリングし、ドキュメントの状態と
onChange
ハンドラーをエディターに渡して、ユーザーがドキュメントの状態を更新するとドキュメントの状態が更新されるようにします。 - Reactブートストラップの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は、ReactとDOMの動作をエディターオブジェクトに追加するSlateJSプラグインです。 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がドキュメントツリーをトラバースして各ノードをレンダリングしようとしたときに呼び出されます。 renderElement関数は3つのパラメーターを取得します—
-
attributes
この関数から返される最上位のDOM要素に適用する必要があるSlateJS固有。 -
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
をチェックするだけではないよりカスタマイズされたレンダリングを実装できます。 たとえば、ブロック画像に対してインライン画像をレンダリングするのに役立つ別のDOM構造を返すために使用できるisInline
プロパティを持つ画像ノードを持つことができます。
次に、このフックを使用するように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の例に従って、 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
を選択し、ここでそのleafプロパティをチェックして、それぞれのスタイルをアタッチできるようにする方法があるかもしれません。
ここで、上記の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 }, ], },
ツールバーの追加
まず、新しいコンポーネント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
コンポーネントのラッパーであるToolbarButtonコンポーネントに抽象化します。 次に、 Editor
Editable
コンポーネントの上にツールバーをレンダリングし、ツールバーがアプリケーションに表示されることを確認します。
ツールバーがサポートする必要がある3つの主要な機能は次のとおりです。
- ユーザーのカーソルがドキュメント内の特定の場所にあり、ユーザーが文字スタイルボタンのいずれかをクリックした場合、次に入力する可能性のあるテキストのスタイルを切り替える必要があります。
- ユーザーがテキストの範囲を選択して文字スタイルボタンの1つをクリックすると、その特定のセクションのスタイルを切り替える必要があります。
- ユーザーがテキストの範囲を選択すると、選択範囲の段落タイプを反映するように段落スタイルのドロップダウンが更新されます。 選択範囲から異なる値を選択した場合は、選択範囲全体の段落スタイルを更新して、選択した値に変更します。
実装を開始する前に、これらの機能がエディターでどのように機能するかを見てみましょう。
選択を聞く
ツールバーが上記の機能を実行できるようにするために必要な最も重要なことは、ドキュメントの選択状態です。 この記事の執筆時点では、SlateJSは、ドキュメントの最新の選択状態を提供する可能性のあるonSelectionChange
メソッドを公開していません。 ただし、エディターで選択が変更されると、ドキュメントの内容が変更されていない場合でも、 onChange
はonChangeメソッドを呼び出します。 これを選択の変更を通知し、 Editor
コンポーネントの状態で保存する方法として使用します。 これをフックuseSelection
に抽象化し、選択状態のより最適な更新を実行できるようにします。 選択はWYSIWYGEditorインスタンスで頻繁に変更されるプロパティであるため、これは重要です。
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
コンポーネント内でこのフックを使用し、選択範囲をツールバーコンポーネントに渡します。
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} /> ...
パフォーマンスに関する考慮事項
より多くの機能を備えたはるかに大きなエディターコードベースがあるアプリケーションでは、選択の変更をリッスンするコンポーネントもレンダリングする可能性があるため、パフォーマンスの高い方法で選択の変更を保存してリッスンすることが重要です(状態管理ライブラリの使用など)。頻繁。 これを行う1つの方法は、特定の選択情報を保持する選択状態の上に最適化されたセレクターを配置することです。 たとえば、編集者は、画像が選択されたときに画像のサイズ変更メニューをレンダリングしたい場合があります。 このような場合、エディターの選択状態からセレクターisImageSelected
を計算すると、このセレクターの値が変更された場合にのみ[画像]メニューが再レンダリングされると便利な場合があります。 ReduxのReselectは、セレクターの構築を可能にするそのようなライブラリの1つです。
ツールバー内のselection
は後で使用しますが、小道具として渡すと、エディターで選択が変更されるたびにツールバーが再レンダリングされます。 これを行うのは、ドキュメントコンテンツの変更だけに依存して、階層( App -> Editor -> Toolbar
])で再レンダリングをトリガーすることはできないためです。ユーザーはドキュメントをクリックし続けるだけで選択が変更される可能性がありますが、実際にはドキュメントコンテンツは変更されません。自体。
キャラクタースタイルの切り替え
次に、SlateJSからアクティブな文字スタイルを取得し、エディター内でそれらを使用することに移ります。 SlateJSで何かを取得/実行するために今後構築するすべてのutil関数をホストする新しいJSモジュールEditorUtils
を追加しましょう。 モジュールの最初の関数は、エディターでアクティブなスタイルのSet
を提供するgetActiveStyles
です。 また、エディター関数でスタイルを切り替える関数を追加します— 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
オブジェクトをパラメーターとして受け取ります。これは、この記事の後半で追加する多くのutil関数と同様です。Slateの用語では、書式設定スタイルはMarksと呼ばれ、Editorインターフェイスでヘルパーメソッドを使用して取得、追加します。これらのマークを削除します。これらの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>
コンポーネントによってアタッチされたコンテキストからSlateインスタンスにアクセスできるようにするSlateフックです。
ここでonClick
の代わりにonMouseDown
を使用するのはなぜだろうか。 エディターが何らかの方法でフォーカスを失ったときにSlateがselection
をnull
に変える方法については、未解決のGithubの問題があります。 したがって、 onClick
ハンドラーをツールバーボタンにアタッチすると、 selection
範囲がnull
になり、ユーザーはスタイルを切り替えようとしてカーソル位置を失いますが、これはすばらしいエクスペリエンスではありません。 代わりに、選択がリセットされないようにするonMouseDown
イベントをアタッチして、スタイルを切り替えます。 これを行う別の方法は、選択を自分で追跡して、最後の選択が何であったかを把握し、それを使用してスタイルを切り替えることです。 この記事の後半でpreviousSelection
の概念を紹介しますが、別の問題を解決するためです。
SlateJSを使用すると、エディターでイベントハンドラーを構成できます。 これを使用して、キーボードショートカットを接続し、文字スタイルを切り替えます。 これを行うには、 useEditorConfig
内にKeyBindings
オブジェクトを追加し、 Editable
コンポーネントにアタッチされたonKeyDown
イベントハンドラーを公開します。 is-hotkey
utilを使用してキーの組み合わせを決定し、対応するスタイルを切り替えます。
# 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} />
段落スタイルのドロップダウンを機能させる
段落スタイルのドロップダウンを機能させることに移りましょう。 MSWordやGoogleDocsなどの一般的なワードプロセッシングアプリケーションで段落スタイルのドロップダウンがどのように機能するかと同様に、ユーザーが選択した最上位のブロックのスタイルがドロップダウンに反映されるようにします。 選択範囲全体に一貫したスタイルが1つある場合は、ドロップダウン値をそのように更新します。 それらが複数ある場合は、ドロップダウン値を「複数」に設定します。 この動作は、折りたたまれた選択と展開された選択の両方で機能する必要があります。
この動作を実装するには、ユーザーの選択にまたがる最上位のブロックを見つけることができる必要があります。 そのために、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>
ヘルパー関数は、エディターインスタンスとoptions
オブジェクトを受け取ります。これは、ツリーを通過するときにツリー内のノードをフィルター処理する方法です。 この関数は、 NodeEntry
のジェネレーターを返します。 スレート用語のNodeEntry
は、ノードとそのノードへのパスのタプルです— [node, pathToNode]
。 ここにあるオプションは、ほとんどのスレートヘルパー関数で使用できます。 それらのそれぞれが何を意味するのかを見てみましょう:
-
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
paramの範囲内にあるすべてのレベルにわたってツリー全体のすべてのノードを検索し、その上で一致フィルターを実行します( 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ドキュメントやMSWordと非常によく似ています。 存在する場合、それらはリンクオブジェクトに変換されるため、ユーザーはツールバーボタンを使用してそれを行う必要がありません。
リンクのレンダリング
このエディターでは、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 }, ], }, ... }
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> );
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:
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
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.Transform.insertNodes
# 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
をエディターの外部でレンダリングしているため、リンクがDOMツリーのどこにあるかをLinkEditor
に通知して、エディターの近くでレンダリングできるようにする方法が必要です。 これを行う方法は、SlateのReact APIを使用して、選択中のリンクノードに対応するDOMノードを見つけることです。 次に、 getBoundingClientRect()
を使用して、リンクのDOM要素の境界とエディターコンポーネントの境界を検索し、リンクエディターのtop
とleft
を計算します。 Editor
とLinkEditor
のコード更新は次のとおりです—
# 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を入力することができます。したがって、そこでのユーザーエクスペリエンスのループを閉じます。
次に、ユーザーがURLを入力してリンクノードに適用できるようにする入力要素とボタンをLinkEditor
に追加します。 URL検証にはisUrl
パッケージを使用します。
# 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
にリセットして、 isLinkActiveAtSelection
がtrue
ではなくなったためにLinkEditor
を削除するためです。 このスレートの動作について説明している未解決のGitHubの問題があります。 これを解決する1つの方法は、ユーザーの以前の選択が変更されたときにそれを追跡することです。エディターがフォーカスを失った場合でも、以前の選択を確認し、以前の選択にリンクが含まれている場合はリンクエディターメニューを表示できます。 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={..} ... );
次に、 selectionForLink
を使用してLinkEditor
を更新し、リンクノードを検索し、その下にレンダリングして、そのURLを更新します。
# src/components/Link.js export default function LinkEditor({ editorOffsets, selectionForLink }) { ... const [node, path] = Editor.above(editor, { at: selectionForLink, match: (n) => n.type === "link", }); ...
テキスト内のリンクの検出
ほとんどのワードプロセッシングアプリケーションは、テキスト内のリンクを識別してリンクオブジェクトに変換します。 ビルドを開始する前に、エディターでそれがどのように機能するかを見てみましょう。
この動作を有効にするロジックの手順は次のとおりです。
- ユーザーが入力するとドキュメントが変わるので、ユーザーが挿入した最後の文字を見つけます。 その文字がスペースである場合、その前に単語があった可能性があることを私たちは知っています。
- 最後の文字がスペースの場合、その前にある単語の最後の境界としてマークします。 次に、テキストノード内で文字ごとに逆方向にトラバースして、その単語がどこから始まったかを見つけます。 このトラバーサルの間、ノードの開始の端を超えて前のノードに入らないように注意する必要があります。
- 前に単語の開始境界と終了境界を見つけたら、単語の文字列をチェックして、それがURLであるかどうかを確認します。 もしそうなら、それをリンクノードに変換します。
私たちのロジックは、 EditorUtils
にあり、 Editor
コンポーネントのonChange
内で呼び出されるutil関数identifyLinksInTextIfAny
にあります。
# src/components/Editor.js const onChangeHandler = useCallback( (document) => { ... identifyLinksInTextIfAny(editor); }, [editor, onChange, setSelection] );
ステップ1のロジックが実装されたidentifyLinksInTextIfAny
は次のとおりです。
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; }
ここで物事を簡単にする2つのSlateJSヘルパー関数があります。
-
Editor.before
—特定の場所の前のポイントを示します。 パラメータとしてunit
を使用するため、location
が渡される前に文字/単語/ブロックなどを要求できます。 -
Editor.string
—範囲内の文字列を取得します。
例として、次の図は、ユーザーが文字「E」を挿入し、カーソルがその後ろにある場合のこれらの変数の値を示しています。
テキスト「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
であることがわかったら、これらのさまざまなポイントがどこを指しているかを示す図です。
start
とend
は、スペースの前後のポイントであることに注意してください。 同様に、 startPointOfLastCharacter
とcursorPoint
は、スペースユーザーが挿入された前後のポイントです。 したがって[end,startPointOfLastCharacter]
は、最後に挿入された単語を示します。
lastWord
の値をコンソールに記録し、入力時に値を確認します。
ユーザーが入力した最後の単語が何であるかを推測したので、それが実際に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ノードをレンダリングしようとするときに覚えておくべき2つのこと:
- SlateJSがその内容を処理できるように、ルートDOM要素には
contentEditable={false}
を設定する必要があります。 これがないと、void要素を操作すると、SlateJSが選択範囲などを計算しようとして、結果として中断する可能性があります。 - Voidノードに子ノードがない場合でも(例として画像ノードのように)、
children
をレンダリングし、Voidの選択ポイントとして扱われる空のテキストノードを子として提供する必要があります(以下のExampleDocument
を参照)。 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
)またはRETURN( 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
に移動します。このフックは、file-input要素にアタッチされたコールバックを提供します。 previousSelection
状態が必要な理由については簡単に説明します。
useImageUploadHandler
を実装する前に、画像をアップロードできるようにサーバーを設定します。 Expressサーバーをセットアップし、ファイルのアップロードを処理するcors
とmulter
の2つのパッケージをインストールします。
yarn add express cors multer
次に、corsとmulterを使用してExpressサーバーを構成し、イメージをアップロードするエンドポイント/upload
を公開するsrc/server.js
スクリプトを追加します。
# 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が表示されるまでに数秒かかる場合があります。 ただし、画像がエディターに挿入されていることをユーザーが認識できるように、画像のアップロードが進行中であることをユーザーに即座にフィードバックするようにしています。 この動作を機能させるために実装する手順は次のとおりです-
- ユーザーが画像を選択したら、フラグ
isUploading
が設定された画像ノードをユーザーのカーソル位置に挿入して、ユーザーに読み込み状態を表示できるようにします。 - 画像をアップロードするリクエストをサーバーに送信します。
- リクエストが完了し、画像のURLを取得したら、それを画像に設定し、読み込み状態を削除します。
画像ノードを挿入する最初のステップから始めましょう。 ここで注意が必要なのは、ツールバーのリンクボタンと同じ選択の問題が発生することです。 ユーザーがツールバーの[画像]ボタンをクリックするとすぐに、エディターはフォーカスを失い、選択範囲が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
を使用してみませんか? 同じ理由で、editor.selection
を使用できません。また、previousSelection
も変更されている可能性があるため、使用できません。 - SlateJSには、ドキュメントに発生するすべての変更を追跡する履歴モジュールがあります。 このモジュールを使用して、履歴を検索し、最後に挿入された画像ノードを見つけることができます。 また、画像のアップロードに時間がかかり、ユーザーが最初のアップロードが完了する前にドキュメントのさまざまな部分にさらに多くの画像を挿入した場合、これは完全に信頼できるものではありません。
- 現在、
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. });
3つのステップすべての実装が完了したら、画像のアップロードをエンドツーエンドでテストする準備が整いました。
これで、エディター用の画像をまとめました。 現在、画像に関係なく同じサイズの読み込み状態を示しています。 アップロードが完了したときに読み込み状態が大幅に小さいまたは大きい画像に置き換えられた場合、これはユーザーにとって不快な体験になる可能性があります。 アップロードエクスペリエンスの適切なフォローアップは、アップロード前に画像のサイズを取得し、そのサイズのプレースホルダーを表示して、移行がシームレスになるようにすることです。 上記で追加したフックは、ビデオやドキュメントなどの他のメディアタイプをサポートし、それらのタイプのノードもレンダリングするように拡張できます。
結論
この記事では、基本的な機能セットと、リンク検出、インプレースリンク編集、画像キャプション編集などのマイクロユーザーエクスペリエンスを備えたWYSIWYGエディターを構築しました。これにより、SlateJSとリッチテキスト編集の概念をさらに深く理解することができます。全般的。 リッチテキスト編集またはワードプロセッシングを取り巻くこの問題領域に関心がある場合は、次のような問題が発生する可能性があります。
- コラボレーション
- テキストの配置、インライン画像、コピー&ペースト、フォントやテキストの色の変更などをサポートする、よりリッチなテキスト編集エクスペリエンス。
- Word文書やMarkdownなどの一般的な形式からのインポート。
SlateJSについて詳しく知りたい場合は、役立つリンクをいくつか紹介します。
- SlateJSの例
基本を超えて、検索とハイライト、マークダウンプレビュー、メンションなどのエディターに通常見られる機能を構築する多くの例。 - APIドキュメント
SlateJSオブジェクトに対して複雑なクエリ/変換を実行しようとするときに便利な、SlateJSによって公開されている多くのヘルパー関数への参照。
最後に、SlateJSのSlack Channelは、SlateJSを使用してリッチテキスト編集アプリケーションを構築するWeb開発者の非常に活発なコミュニティであり、ライブラリの詳細を学び、必要に応じてヘルプを得るのに最適な場所です。