WYSIWYGエディターへのコメントシステムの追加
公開: 2022-03-10近年、コラボレーションは多くの職業にわたる多くのデジタルワークフローとユースケースに浸透しています。 デザインとソフトウェアエンジニアリングのコミュニティ内では、デザイナーがFigmaなどのツールを使用してデザインアーティファクトに協力し、チームがMuralなどのツールを使用してスプリントとプロジェクトプランニングを行い、インタビューがCoderPadを使用して行われているのを目にします。 これらのツールはすべて、これらのワークフローを実行し、コラボレーションエクスペリエンスを可能な限りリッチでシームレスにするという、オンラインと物理的な世界のエクスペリエンスの間のギャップを埋めることを常に目指しています。
このようなコラボレーションツールの大部分では、お互いに意見を共有し、同じコンテンツについて話し合う機能が必須です。 共同編集者がドキュメントの一部に注釈を付け、それらについて会話できるようにするコメントシステムは、この概念の中心です。 この記事では、WYSIWYGエディターでテキスト用に作成するとともに、読者を引き付けて、長所と短所を比較検討し、WYSIWYGエディターまたは一般的なワードプロセッサ。
ドキュメント構造でのコメントの表現
リッチテキストドキュメントのデータ構造でコメントを表す方法を見つけるために、エディタ内でコメントを作成できるいくつかのシナリオを見てみましょう。
- スタイルのないテキストに対して作成されたコメント(基本シナリオ)。
- 太字/斜体/下線付きなどのテキスト上に作成されたコメント。
- 何らかの方法で互いに重複するコメント(2つのコメントが数語しか共有しない部分的な重複、または1つのコメントのテキストが別のコメントのテキスト内に完全に含まれる完全に含まれる)。
- リンク内のテキスト上に作成されたコメント(リンクはドキュメント構造内のノード自体であるため、特別です)。
- 複数の段落にまたがるコメント(段落はドキュメント構造のノードであり、コメントは段落の子であるテキストノードに適用されるため特別です)。
上記のユースケースを見ると、リッチテキストドキュメントで表示されるコメントは、文字スタイル(太字、斜体など)と非常に似ているようです。 それらは互いに重なり合ったり、リンクなどの他のタイプのノードのテキストを調べたり、段落などの複数の親ノードにまたがったりする可能性があります。
このため、コメントを表すために、文字スタイル、つまり「マーク」(SlateJSの用語ではそのように呼ばれる)の場合と同じ方法を使用します。 マークはノード上の単なる通常のプロパティです。特に、マークに関するSlateのAPI( Editor.addMark
およびEditor.removeMark
)は、同じ範囲のテキストに複数のマークが適用されるときにノード階層の変更を処理します。 これは、重複するコメントのさまざまな組み合わせを処理するため、非常に役立ちます。
マークとしてのコメントスレッド
ユーザーがテキストの範囲を選択してコメントを挿入しようとすると、技術的には、そのテキスト範囲の新しいコメントスレッドが開始されます。 コメントを挿入し、後でそのコメントに返信できるようにするため、このイベントはドキュメントへの新しいコメントスレッドの挿入として扱われます。
コメントスレッドをマークとして表す方法は、各コメントスレッドがcommentThread_threadID
という名前のマークで表されることです。ここで、 threadID
は、各コメントスレッドに割り当てる一意のIDです。 したがって、同じ範囲のテキストに2つのコメントスレッドがある場合、2つのプロパティ( commentThread_thread1
とcommentThread_thread2
がtrue
に設定されます。 同じテキストが太字と斜体の場合、両方のプロパティがtrue
( bold
とitalic
)に設定されるため、コメントスレッドは文字スタイルと非常によく似ています。
この構造を実際に設定する前に、コメントスレッドが適用されるときにテキストノードがどのように変化するかを確認する価値があります。 これが機能する方法(他のマークの場合と同様)は、選択したテキストにマークプロパティが設定されている場合、SlateのEditor.addMark APIが必要に応じてテキストノードを分割し、結果の構造でテキストノードが作成されるようにします。各テキストノードがマークとまったく同じ値を持つように設定されます。
これをよりよく理解するために、コメントスレッドが選択されたテキストに挿入された後のテキストノードの前後の状態を示す次の3つの例を見てください。
コメント付きテキストの強調表示
ドキュメント構造でコメントを表現する方法がわかったので、先に進んで、最初の記事のサンプルドキュメントにいくつか追加し、実際に強調表示されるようにエディターを構成します。 この記事ではコメントを処理するためのユーティリティ関数がたくさんあるので、これらすべてのユーティリティを格納するEditorCommentUtils
モジュールを作成します。 まず、指定されたコメントスレッドIDのマークを作成する関数を作成します。 次に、それを使用して、 ExampleDocument
にいくつかのコメントスレッドを挿入します。
# src/utils/EditorCommentUtils.js const COMMENT_THREAD_PREFIX = "commentThread_"; export function getMarkForCommentThreadID(threadID) { return `${COMMENT_THREAD_PREFIX}${threadID}`; }
下の画像は、次のコードスニペットで追加されたコメントスレッドの例としてあるテキストの範囲を赤で下線で示しています。 テキスト「RichardMcClintock」には、互いに重複する2つのコメントスレッドがあることに注意してください。 具体的には、これは1つのコメントスレッドが別のコメントスレッド内に完全に含まれている場合です。
# src/utils/ExampleDocument.js import { getMarkForCommentThreadID } from "../utils/EditorCommentUtils"; import { v4 as uuid } from "uuid"; const exampleOverlappingCommentThreadID = uuid(); const ExampleDocument = [ ... { text: "Lorem ipsum", [getMarkForCommentThreadID(uuid())]: true, }, ... { text: "Richard McClintock", // note the two comment threads here. [getMarkForCommentThreadID(uuid())]: true, [getMarkForCommentThreadID(exampleOverlappingCommentThreadID)]: true, }, { text: ", a Latin scholar", [getMarkForCommentThreadID(exampleOverlappingCommentThreadID)]: true, }, ... ];
この記事では、コメントシステムのUI側に焦点を当てているため、サンプルドキュメントでnpmパッケージuuidを使用して直接IDを割り当てます。 エディターの製品版では、これらのIDはバックエンドサービスによって作成される可能性が非常に高くなります。
ここでは、これらのテキストノードを強調表示して表示するようにエディターを調整することに焦点を当てます。 これを行うには、テキストノードをレンダリングするときに、コメントスレッドがあるかどうかを確認する方法が必要です。 そのためにgetCommentThreadsOnTextNode
を追加します。 最初の記事で作成したStyledText
コンポーネントに基づいて、コメント付きのテキストノードをレンダリングしようとしている可能性がある場合を処理します。 後でコメント付きテキストノードに追加される機能がさらに追加されるため、コメント付きテキストをレンダリングするコンポーネントCommentedText
を作成します。 StyledText
は、レンダリングしようとしているテキストノードにコメントがあるかどうかを確認します。 含まれている場合は、 CommentedText
をレンダリングします。 getCommentThreadsOnTextNode
を使用してそれを推測します。
# src/utils/EditorCommentUtils.js export function getCommentThreadsOnTextNode(textNode) { return new Set( // Because marks are just properties on nodes, // we can simply use Object.keys() here. Object.keys(textNode) .filter(isCommentThreadIDMark) .map(getCommentThreadIDFromMark) ); } export function getCommentThreadIDFromMark(mark) { if (!isCommentThreadIDMark(mark)) { throw new Error("Expected mark to be of a comment thread"); } return mark.replace(COMMENT_THREAD_PREFIX, ""); } function isCommentThreadIDMark(mayBeCommentThread) { return mayBeCommentThread.indexOf(COMMENT_THREAD_PREFIX) === 0; }
最初の記事では、テキストノードをレンダリングする(文字スタイルの処理など)コンポーネントStyledText
を作成しました。 そのコンポーネントを拡張して上記のutilを使用し、ノードにコメントがある場合はCommentedText
コンポーネントをレンダリングします。
# src/components/StyledText.js import { getCommentThreadsOnTextNode } from "../utils/EditorCommentUtils"; export default function StyledText({ attributes, children, leaf }) { ... const commentThreads = getCommentThreadsOnTextNode(leaf); if (commentThreads.size > 0) { return ( <CommentedText {...attributes} // We use commentThreads and textNode props later in the article. commentThreads={commentThreads} textNode={leaf} > {children} </CommentedText> ); } return <span {...attributes}>{children}</span>; }
以下は、テキストノードをレンダリングし、それを強調表示するCSSを添付するCommentedText
の実装です。
# src/components/CommentedText.js import "./CommentedText.css"; import classNames from "classnames"; export default function CommentedText(props) { const { commentThreads, ...otherProps } = props; return ( <span {...otherProps} className={classNames({ comment: true, })} > {props.children} </span> ); } # src/components/CommentedText.css .comment { background-color: #feeab5; }
上記のすべてのコードをまとめると、エディターでコメントスレッドが強調表示されたテキストノードが表示されます。
注:ユーザーは現在、特定のテキストに重複するコメントがあるかどうかを判断できません。 ハイライトされたテキスト範囲全体は、単一のコメントスレッドのように見えます。 これについては、ユーザーが特定のコメントスレッドを選択し、エディターでその範囲を確認できるアクティブコメントスレッドの概念を紹介する記事の後半で説明します。
コメント用のUIストレージ
ユーザーが新しいコメントを挿入できる機能を追加する前に、まずコメントスレッドを保持するためのUI状態を設定します。 この記事では、状態管理ライブラリとしてRecoilJSを使用して、コメントスレッド、スレッド内に含まれるコメント、および作成時間、ステータス、コメント作成者などの他のメタデータを保存します。アプリケーションにRecoilを追加しましょう。
> yarn add recoil
Recoilアトムを使用して、これら2つのデータ構造を格納します。 Recoilに慣れていない場合は、アトムがアプリケーションの状態を保持します。 アプリケーションの状態が異なる場合は、通常、異なるアトムを設定する必要があります。 アトムファミリーはアトムのコレクションです—アトムを識別する一意のキーからアトム自体へのMap
であると考えることができます。 この時点でリコイルのコアコンセプトを理解し、それらに精通することは価値があります。
このユースケースでは、コメントスレッドをAtomファミリーとして保存してから、アプリケーションをRecoilRoot
コンポーネントでラップします。 RecoilRoot
は、アトム値が使用されるコンテキストを提供するために適用されます。 この記事の後半でさらにアトム定義を追加するときに、Recoilアトム定義を保持する別のモジュールCommentState
を作成します。
# src/utils/CommentState.js import { atom, atomFamily } from "recoil"; export const commentThreadsState = atomFamily({ key: "commentThreads", default: [], }); export const commentThreadIDsState = atom({ key: "commentThreadIDs", default: new Set([]), });
これらの原子の定義についていくつかのことを指摘する価値があります。
- 各原子/原子ファミリーは
key
によって一意に識別され、デフォルト値で設定できます。 - この記事でさらに構築するとき、すべてのコメントスレッドを反復処理する方法が必要になります。これは、基本的に、
commentThreadsState
アトムファミリーを反復処理する方法が必要であることを意味します。 この記事を書いている時点で、Recoilを使用してこれを行う方法は、アトムファミリーのすべてのIDを保持する別のアトムを設定することです。 上記のcommentThreadIDsState
を使用してこれを行います。 コメントスレッドを追加/削除するときは常に、これらのアトムの両方を同期させる必要があります。
ルートApp
コンポーネントにRecoilRoot
ラッパーを追加して、後でこれらのアトムを使用できるようにします。 Recoilのドキュメントには、そのまま使用してエディターにドロップする便利なデバッガーコンポーネントも含まれています。 Recoilアトムはリアルタイムで更新されるため、このコンポーネントはconsole.debug
ログをDevコンソールに残します。
# src/components/App.js import { RecoilRoot } from "recoil"; export default function App() { ... return ( <RecoilRoot> > ... <Editor document={document} onChange={updateDocument} /> </RecoilRoot> ); }
# src/components/Editor.js export default function Editor({ ... }): JSX.Element { ..... return ( <> <Slate> ..... </Slate> <DebugObserver /> </> ); function DebugObserver(): React.Node { // see API link above for implementation. }
また、ドキュメントにすでに存在するコメントスレッド(たとえば、前のセクションでサンプルドキュメントに追加したもの)でアトムを初期化するコードを追加する必要があります。 これは、後でドキュメント内のすべてのコメントスレッドを読み取る必要があるコメントサイドバーを作成するときに行います。
この時点で、アプリケーションをロードし、リコイルのセットアップを示すエラーがないことを確認して、先に進みます。
新しいコメントの追加
このセクションでは、ツールバーにボタンを追加して、ユーザーが選択したテキスト範囲にコメントを追加できるようにします(つまり、新しいコメントスレッドを作成します)。 ユーザーがテキスト範囲を選択してこのボタンをクリックすると、以下を実行する必要があります。
- 挿入される新しいコメントスレッドに一意のIDを割り当てます。
- IDを使用してスレートドキュメント構造に新しいマークを追加し、ユーザーにそのテキストが強調表示されていることを確認します。
- 前のセクションで作成した原子反跳に新しいコメントスレッドを追加します。
#1と#2を実行するutil関数をEditorCommentUtils
に追加しましょう。
# src/utils/EditorCommentUtils.js import { Editor } from "slate"; import { v4 as uuidv4 } from "uuid"; export function insertCommentThread(editor, addCommentThreadToState) { const threadID = uuidv4(); const newCommentThread = { // comments as added would be appended to the thread here. comments: [], creationTime: new Date(), // Newly created comment threads are OPEN. We deal with statuses // later in the article. status: "open", }; addCommentThreadToState(threadID, newCommentThread); Editor.addMark(editor, getMarkForCommentThreadID(threadID), true); return threadID; }
マークの概念を使用して各コメントスレッドを独自のマークとして保存することにより、 Editor.addMark
APIを使用して、選択したテキスト範囲に新しいコメントスレッドを追加することができます。 この呼び出しだけで、コメントを追加するさまざまなケース(前のセクションで説明したものの一部)、部分的に重複するコメント、リンク内/重複するコメント、太字/斜体のテキスト上のコメント、段落にまたがるコメントなどをすべて処理します。 このAPI呼び出しは、ノード階層を調整して、これらのケースを処理するために必要な数の新しいテキストノードを作成します。
addCommentThreadToState
は、ステップ3を処理するコールバック関数です—新しいコメントスレッドをRecoilアトムに追加します。 次に、それをカスタムコールバックフックとして実装して、再利用できるようにします。 このコールバックは、新しいコメントスレッドを両方のアトム( commentThreadsState
とcommentThreadIDsState
に追加する必要があります。 これを実行できるようにするには、 useRecoilCallback
フックを使用します。 このフックは、アトムデータの読み取り/設定に使用できるいくつかのものを取得するコールバックを構築するために使用できます。 現在関心があるのは、アトム値をset(atom, newValueOrUpdaterFunction)
として更新するために使用できるset
関数です。
# src/hooks/useAddCommentThreadToState.js import { commentThreadIDsState, commentThreadsState, } from "../utils/CommentState"; import { useRecoilCallback } from "recoil"; export default function useAddCommentThreadToState() { return useRecoilCallback( ({ set }) => (id, threadData) => { set(commentThreadIDsState, (ids) => new Set([...Array.from(ids), id])); set(commentThreadsState(id), threadData); }, [] ); }
set
への最初の呼び出しは、コメントスレッドIDの既存のセットに新しいIDを追加し、新しいSet
(アトムの新しい値になります)を返します。
2番目の呼び出しでは、アトムファミリーからIDのアトムを取得しますcommentThreadsState
をcommentThreadsState(id)
として取得し、 threadData
をその値に設定します。 atomFamilyName(atomID)
は、Recoilが一意のキーを使用してそのアトムファミリーのアトムにアクセスできるようにする方法です。 大まかに言えば、 commentThreadsState
がjavascript Mapの場合、この呼び出しは基本的に— commentThreadsState.set(id, threadData)
ます。
ドキュメントへの新しいコメントスレッドの挿入とRecoilアトムを処理するためのすべてのコード設定が完了したので、ツールバーにボタンを追加して、これらの関数の呼び出しに接続します。
# src/components/Toolbar.js import { insertCommentThread } from "../utils/EditorCommentUtils"; import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState"; export default function Toolbar({ selection, previousSelection }) { const editor = useEditor(); ... const addCommentThread = useAddCommentThreadToState(); const onInsertComment = useCallback(() => { const newCommentThreadID = insertCommentThread(editor, addCommentThread); }, [editor, addCommentThread]); return ( <div className="toolbar"> ... <ToolBarButton isActive={false} label={<i className={`bi ${getIconForButton("comment")}`} />} onMouseDown={onInsertComment} /> </div> ); }
注: onClick
ではなくonMouseDown
を使用しているため、エディターはフォーカスと選択を失い、 null
になります。 これについては、最初の記事のリンク挿入セクションでもう少し詳しく説明しました。
次の例では、単純なコメントスレッドと、リンクを含む重複するコメントスレッドの挿入が実際に行われていることを示しています。 状態が正しく更新されていることを確認するRecoilDebuggerから更新を取得する方法に注目してください。 また、スレッドがドキュメントに追加されるときに、新しいテキストノードが作成されることを確認します。
重複するコメント
コメントシステムに機能を追加する前に、エディターで重複するコメントとそれらのさまざまな組み合わせをどのように処理するかについて、いくつかの決定を行う必要があります。 なぜそれが必要なのかを理解するために、コメントポップオーバーがどのように機能するかを少し覗いてみましょう。これはこの記事の後半で構築する機能です。 ユーザーがコメントスレッドのある特定のテキストをクリックすると、コメントスレッドが「選択」され、ユーザーがそのスレッドにコメントを追加できるポップオーバーが表示されます。
上記のビデオからわかるように、「デザイナー」という単語は3つのコメントスレッドの一部になっています。 したがって、1つの単語で互いに重複する2つのコメントスレッドがあります。 そして、これらのコメントスレッド(#1と#2)は両方とも、より長いコメントスレッドのテキスト範囲(#3)内に完全に含まれています。 これはいくつかの質問を提起します:
- ユーザーが「デザイナー」という単語をクリックしたときに、どのコメントスレッドを選択して表示する必要がありますか?
- 上記の質問にどのように取り組むかを考えると、単語をクリックしても特定のコメントスレッドがアクティブにならず、スレッドにまったくアクセスできないという重複のケースが発生する可能性はありますか?
これは、コメントが重複している場合、考慮すべき最も重要なことは、ユーザーがコメントスレッドを挿入した後、内部のテキストをクリックして、将来そのコメントスレッドを選択できるようにする方法があることを意味します。それ? そうでない場合は、そもそも挿入を許可したくないでしょう。 この原則がエディターでほとんどの場合尊重されるようにするために、重複するコメントに関する2つのルールを導入し、エディターに実装します。
これらのルールを定義する前に、コメントの重複に関しては、エディターとワードプロセッサーが異なればアプローチも異なることを指摘しておく価値があります。 物事を単純にするために、一部の編集者はコメントの重複を一切許可していません。 私たちの場合、重複の複雑なケースを許可せずに、ユーザーがより豊かなコラボレーションとレビューのエクスペリエンスを得ることができるように、重複するコメントを許可することによって、中間点を見つけようとします。
最短コメント範囲ルール
このルールは、ユーザーが複数のコメントスレッドを持つテキストノードをクリックした場合に選択するコメントスレッドに関する上記の質問1に答えるのに役立ちます。 ルールは次のとおりです。
「ユーザーが複数のコメントスレッドを含むテキストをクリックすると、テキスト範囲が最も短いコメントスレッドが見つかり、それを選択します。」
直感的には、これを行うことは理にかなっています。これにより、ユーザーは常に、別のコメントスレッド内に完全に含まれている最も内側のコメントスレッドにアクセスできます。 他の条件(部分的なオーバーラップまたはオーバーラップなし)の場合、コメントスレッドが1つしかないテキストがあるはずなので、そのテキストを使用してそのコメントスレッドを選択するのは簡単です。 これは、スレッドが完全に(または密に)オーバーラップしている場合であり、このルールが必要な理由です。
コメントスレッドを選択するときに、このルールを使用して「正しいことを行う」ことができる、かなり複雑なオーバーラップのケースを見てみましょう。
上記の例では、ユーザーは次のコメントスレッドをこの順序で挿入します。
- スレッド#1を文字「B」(長さ= 1)にコメントします。
- スレッド#2を「AB」(長さ= 2)にコメントします。
- 'BC'(長さ= 2)のスレッド#3にコメントします。
これらの挿入の最後に、Slateがテキストノードをマークで分割する方法のために、3つのテキストノードがあります(文字ごとに1つ)。 ここで、ユーザーが「B」をクリックした場合、最短の長さのルールに従って、3つの長さの中で最も短いスレッド#1を選択します。 そうしないと、コメントスレッド#1を選択する方法がありません。これは、長さが1文字であり、他の2つのスレッドの一部でもあるためです。
このルールを使用すると、短いコメントスレッドを簡単に表示できますが、長いコメントスレッドに含まれるすべての文字が他の短いコメントスレッドの一部であるため、長いコメントスレッドにアクセスできなくなる可能性があります。 その例を見てみましょう。
100文字(たとえば、文字「A」を100回入力)があり、ユーザーが次の順序でコメントスレッドを挿入するとします。
- コメント範囲20,80のスレッド#1
- コメント範囲0,50のスレッド#2
- コメント範囲51,100のスレッド#3
上記の例でわかるように、ここで説明したルールに従うと、#20と#80の間の任意の文字をクリックすると、スレッド#2または#3が常に選択されます。これは、スレッドが#1よりも短いため#1であるためです。選択できません。 このルールでどのコメントスレッドを選択するかを決定できない別のシナリオは、テキストノードに同じ最短の長さのコメントスレッドが複数ある場合です。
重複するコメントのこのような組み合わせや、このルールに従うとテキストをクリックして特定のコメントスレッドにアクセスできなくなる可能性がある他の多くの組み合わせについては、この記事の後半でコメントサイドバーを作成し、ユーザーにすべてのコメントスレッドを表示します。ドキュメントに表示されるので、サイドバーでそれらのスレッドをクリックし、エディターでアクティブにしてコメントの範囲を確認できます。 上で引用した可能性の低い例を除いて、多くの重複シナリオをカバーする必要があるため、このルールを使用して実装する必要があります。 主に、エディターで強調表示されたテキストを表示し、それをクリックしてコメントする方が、サイドバーのコメントのリストを使用するよりも直感的にテキストのコメントにアクセスできるため、このルールにすべての努力を払いました。
挿入規則
ルールは次のとおりです。
「テキストユーザーが選択してコメントしようとしている場合、コメントスレッドですでに完全にカバーされている場合は、その挿入を許可しないでください。」
これは、この挿入を許可した場合、その範囲内の各文字に少なくとも2つのコメントスレッド(1つは既存のスレッド、もう1つは許可したばかりの新しいスレッド)が含まれることになり、どちらを選択するかを決定するのが困難になるためです。ユーザーは後でその文字をクリックします。
このルールを見ると、最小のテキスト範囲を選択できる最短コメント範囲ルールがすでにあるのに、そもそもなぜそれが必要なのか不思議に思うかもしれません。 最初のルールを使用して、表示する適切なコメントスレッドを推測できる場合は、重複のすべての組み合わせを許可しないのはなぜですか? 前に説明した例のいくつかとして、最初のルールは多くのシナリオで機能しますが、すべてではありません。 挿入ルールを使用すると、最初のルールが役に立たないシナリオの数を最小限に抑え、ユーザーがそのコメントスレッドにアクセスする唯一の方法としてサイドバーにフォールバックする必要があります。 挿入ルールは、コメントスレッドの正確な重複も防ぎます。 このルールは、多くの人気のある編集者によって一般的に実装されています。
以下は、このルールが存在しない場合、コメントスレッド#3を許可し、最初のルールの結果として、長さが最も長くなるため、#3にアクセスできない例です。
注:このルールがあるからといって、重複するコメントが完全に含まれることはありません。 コメントの重複について注意が必要なのは、ルールにもかかわらず、コメントが挿入される順序によって、重複を望まない状態のままになる可能性があることです。「デザイナー」という単語のコメントの例に戻ります。 '以前は、そこに挿入された最長のコメントスレッドが最後に追加されたため、挿入ルールで許可され、完全に封じ込められた状況になりました—#1と#2は#3に含まれていました。 最短コメント範囲ルールが役立つので、それで問題ありません。
コメントスレッドの選択を実装する次のセクションで、最短コメント範囲ルールを実装します。 コメントを挿入するためのツールバーボタンができたので、ユーザーがテキストを選択したときにルールをチェックすることで、挿入ルールをすぐに実装できます。 ルールが満たされない場合は、[コメント]ボタンを無効にして、ユーザーが選択したテキストに新しいコメントスレッドを挿入できないようにします。 始めましょう!
# src/utils/EditorCommentUtils.js export function shouldAllowNewCommentThreadAtSelection(editor, selection) { if (selection == null || Range.isCollapsed(selection)) { return false; } const textNodeIterator = Editor.nodes(editor, { at: selection, mode: "lowest", }); let nextTextNodeEntry = textNodeIterator.next().value; const textNodeEntriesInSelection = []; while (nextTextNodeEntry != null) { textNodeEntriesInSelection.push(nextTextNodeEntry); nextTextNodeEntry = textNodeIterator.next().value; } if (textNodeEntriesInSelection.length === 0) { return false; } return textNodeEntriesInSelection.some( ([textNode]) => getCommentThreadsOnTextNode(textNode).size === 0 ); }
この関数のロジックは比較的単純です。
- ユーザーの選択が点滅するキャレットの場合、テキストが選択されていないため、コメントを挿入することはできません。
- ユーザーの選択が折りたたまれていない場合は、選択範囲内のすべてのテキストノードが見つかります。
mode: lowest
の使用に注意してください。テキストノードは実際にはドキュメントツリーのリーフであるため、Editor.nodes
(SlateJSによるヘルパー関数)の呼び出しで最も低く、すべてのテキストノードを選択するのに役立ちます。 - コメントスレッドがないテキストノードが少なくとも1つある場合は、挿入を許可することがあります。 ここで前に書いた
getCommentThreadsOnTextNode
を使用します。
ここで、ツールバー内でこのutil関数を使用して、ボタンの無効状態を制御します。
# src/components/Toolbar.js export default function Toolbar({ selection, previousSelection }) { const editor = useEditor(); .... return ( <div className="toolbar"> .... <ToolBarButton isActive={false} disabled={!shouldAllowNewCommentThreadAtSelection( editor, selection )} label={<i className={`bi ${getIconForButton("comment")}`} />} onMouseDown={onInsertComment} /> </div> );
上記の例を再作成して、ルールの実装をテストしてみましょう。
ここで呼び出すユーザーエクスペリエンスの詳細は、ユーザーがここでテキストの行全体を選択した場合はツールバーボタンを無効にしますが、ユーザーエクスペリエンスは完了しないということです。 ユーザーは、ボタンが無効になっている理由を完全に理解していない可能性があり、コメントスレッドをそこに挿入するというユーザーの意図に応答していないことに混乱する可能性があります。 コメントポップオーバーは、ツールバーボタンが無効になっている場合でも、コメントスレッドの1つのポップオーバーが表示され、ユーザーがコメントを残すことができるように構築されているため、後でこれに対処します。
コメントされていないテキストノードがあり、ルールで新しいコメントスレッドの挿入が許可されている場合もテストしてみましょう。
コメントスレッドの選択
このセクションでは、ユーザーがコメント付きのテキストノードをクリックする機能を有効にし、最短コメント範囲ルールを使用して、選択するコメントスレッドを決定します。 プロセスのステップは次のとおりです。
- ユーザーがクリックしたコメント付きテキストノードで最短のコメントスレッドを見つけます。
- そのコメントスレッドをアクティブなコメントスレッドに設定します。 (これの真実の源となる新しい反跳原子を作成します。)
- コメントされたテキストノードはリコイル状態をリッスンし、それらがアクティブなコメントスレッドの一部である場合、それらは異なる方法でハイライトされます。 そうすれば、ユーザーがコメントスレッドをクリックすると、すべてのテキストノードがハイライトの色を更新するため、テキスト範囲全体が目立ちます。
ステップ1:最短コメント範囲ルールの実装
基本的に最短コメント範囲ルールを実装しているステップ#1から始めましょう。 ここでの目標は、ユーザーがクリックしたテキストノードで最短範囲のコメントスレッドを見つけることです。 最短の長さのスレッドを見つけるには、そのテキストノードのすべてのコメントスレッドの長さを計算する必要があります。 これを行う手順は次のとおりです。
- 問題のテキストノードですべてのコメントスレッドを取得します。
- そのテキストノードからいずれかの方向にトラバースし、追跡されているスレッドの長さを更新し続けます。
- 以下のエッジの1つに到達したら、ある方向へのトラバーサルを停止します。
- コメントされていないテキストノード(追跡しているすべてのコメントスレッドの最も遠い開始/終了エッジに到達したことを意味します)。
- 追跡しているすべてのコメントスレッドがエッジ(開始/終了)に到達したテキストノード。
- その方向にトラバースするテキストノードはもうありません(ドキュメントの最初または最後、あるいは非テキストノードに到達したことを意味します)。
順方向と逆方向の走査は機能的に同じであるため、基本的にテキストノードのイテレータを使用するヘルパー関数updateCommentThreadLengthMap
を記述します。 イテレータを呼び出し続け、トラッキングスレッドの長さを更新し続けます。 この関数を2回呼び出します。1回は順方向、もう1回は逆方向です。 Let's write our main utility function that will use this helper function.
# src/utils/EditorCommentUtils.js export function getSmallestCommentThreadAtTextNode(editor, textNode) { const commentThreads = getCommentThreadsOnTextNode(textNode); const commentThreadsAsArray = [...commentThreads]; let shortestCommentThreadID = commentThreadsAsArray[0]; const reverseTextNodeIterator = (slateEditor, nodePath) => Editor.previous(slateEditor, { at: nodePath, mode: "lowest", match: Text.isText, }); const forwardTextNodeIterator = (slateEditor, nodePath) => Editor.next(slateEditor, { at: nodePath, mode: "lowest", match: Text.isText, }); if (commentThreads.size > 1) { // The map here tracks the lengths of the comment threads. // We initialize the lengths with length of current text node // since all the comment threads span over the current text node // at the least. const commentThreadsLengthByID = new Map( commentThreadsAsArray.map((id) => [id, textNode.text.length]) ); // traverse in the reverse direction and update the map updateCommentThreadLengthMap( editor, commentThreads, reverseTextNodeIterator, commentThreadsLengthByID ); // traverse in the forward direction and update the map updateCommentThreadLengthMap( editor, commentThreads, forwardTextNodeIterator, commentThreadsLengthByID ); let minLength = Number.POSITIVE_INFINITY; // Find the thread with the shortest length. for (let [threadID, length] of commentThreadsLengthByID) { if (length < minLength) { shortestCommentThreadID = threadID; minLength = length; } } } return shortestCommentThreadID; }
The steps we listed out are all covered in the above code. The comments should help follow how the logic flows there.
One thing worth calling out is how we created the traversal functions. We want to give a traversal function to updateCommentThreadLengthMap
such that it can call it while it is iterating text node's path and easily get the previous/next text node. To do that, Slate's traversal utilities Editor.previous
and Editor.next
(defined in the Editor interface) are very helpful. Our iterators reverseTextNodeIterator
and forwardTextNodeIterator
call these helpers with two options mode: lowest
and the match function Text.isText
so we know we're getting a text node from the traversal, if there is one.
Now we implement updateCommentThreadLengthMap
which traverses using these iterators and updates the lengths we're tracking.
# src/utils/EditorCommentUtils.js function updateCommentThreadLengthMap( editor, commentThreads, nodeIterator, map ) { let nextNodeEntry = nodeIterator(editor); while (nextNodeEntry != null) { const nextNode = nextNodeEntry[0]; const commentThreadsOnNextNode = getCommentThreadsOnTextNode(nextNode); const intersection = [...commentThreadsOnNextNode].filter((x) => commentThreads.has(x) ); // All comment threads we're looking for have already ended meaning // reached an uncommented text node OR a commented text node which // has none of the comment threads we care about. if (intersection.length === 0) { break; } // update thread lengths for comment threads we did find on this // text node. for (let i = 0; i < intersection.length; i++) { map.set(intersection[i], map.get(intersection[i]) + nextNode.text.length); } // call the iterator to get the next text node to consider nextNodeEntry = nodeIterator(editor, nextNodeEntry[1]); } return map; }
One might wonder why do we wait until the intersection
becomes 0
to stop iterating in a certain direction. Why can't we just stop if we're reached the edge of at least one comment thread — that would imply we've reached the shortest length in that direction, right? The reason we can't do that is that we know that a comment thread can span over multiple text nodes and we wouldn't know which of those text nodes did the user click on and we started our traversal from. We wouldn't know the range of all comment threads in question without fully traversing to the farthest edges of the union of the text ranges of the comment threads in both the directions.
Check out the below example where we have two comment threads 'A' and 'B' overlapping each other in some way resulting into three text nodes 1,2 and 3 — #2 being the text node with the overlap.
In this example, let's assume we don't wait for intersection to become 0 and just stop when we reach the edge of a comment thread. Now, if the user clicked on #2 and we start traversal in reverse direction, we'd stop at the start of text node #2 itself since that's the start of the comment thread A. As a result, we might not compute the comment thread lengths correctly for A & B. With the implementation above traversing the farthest edges (text nodes 1,2, and 3), we should get B as the shortest comment thread as expected.
To see the implementation visually, below is a walkthrough with a slideshow of the iterations. We have two comment threads A and B that overlap each other over text node #3 and the user clicks on the overlapping text node #3.
Steps 2 & 3: Maintaining State Of The Selected Comment Thread And Highlighting It
Now that we have the logic for the rule fully implemented, let's update the editor code to use it. For that, we first create a Recoil atom that'll store the active comment thread ID for us. We then update the CommentedText
component to use our rule's implementation.
# src/utils/CommentState.js import { atom } from "recoil"; export const activeCommentThreadIDAtom = atom({ key: "activeCommentThreadID", default: null, }); # src/components/CommentedText.js import { activeCommentThreadIDAtom } from "../utils/CommentState"; import classNames from "classnames"; import { getSmallestCommentThreadAtTextNode } from "../utils/EditorCommentUtils"; import { useRecoilState } from "recoil"; export default function CommentedText(props) { .... const { commentThreads, textNode, ...otherProps } = props; const [activeCommentThreadID, setActiveCommentThreadID] = useRecoilState( activeCommentThreadIDAtom ); const onClick = () => { setActiveCommentThreadID( getSmallestCommentThreadAtTextNode(editor, textNode) ); }; return ( <span {...otherProps} className={classNames({ comment: true, // a different background color treatment if this text node's // comment threads do contain the comment thread active on the // document right now. "is-active": commentThreads.has(activeCommentThreadID), })} onClick={onClick} > {props.children} ≷/span> ); }
このコンポーネントはuseRecoilState
を使用して、コンポーネントがサブスクライブできるようにし、Recoilアトムの値を設定できるようにします。 サブスクライバーは、このテキストノードがアクティブなコメントスレッドの一部であるかどうかを知って、スタイルを変えることができるようにする必要があります。 真ん中のコメントスレッドがアクティブになっている下のスクリーンショットを確認してください。その範囲がはっきりとわかります。
コメントスレッドの選択を機能させるためのすべてのコードが揃ったので、実際の動作を見てみましょう。 トラバーサルコードを適切にテストするために、オーバーラップのいくつかの単純なケースと、次のようないくつかのエッジケースをテストします。
- エディターの開始/終了でコメント化されたテキストノードをクリックします。
- 複数の段落にまたがるコメントスレッドを持つコメント付きテキストノードをクリックします。
- 画像ノードの直前にあるコメント付きのテキストノードをクリックします。
- リンクが重なっているコメント付きのテキストノードをクリックします。
アクティブなコメントスレッドIDを追跡するためのRecoilアトムがあるので、ユーザーがツールバーボタンを使用して新しいコメントスレッドを挿入するときに、新しく作成されたコメントスレッドをアクティブなものに設定することが重要です。 これにより、次のセクションで、挿入直後にコメントスレッドのポップオーバーを表示できるため、ユーザーはすぐにコメントの追加を開始できます。
# src/components/Toolbar.js import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState"; import { useSetRecoilState } from "recoil"; export default function Toolbar({ selection, previousSelection }) { ... const setActiveCommentThreadID = useSetRecoilState(activeCommentThreadIDAtom); ..... const onInsertComment = useCallback(() => { const newCommentThreadID = insertCommentThread(editor, addCommentThread); setActiveCommentThreadID(newCommentThreadID); }, [editor, addCommentThread, setActiveCommentThreadID]); return <div className='toolbar'> .... </div>; };
注:ここでのuseSetRecoilState
の使用(アトムのセッターを公開するが、コンポーネントをその値にサブスクライブしないリコイルフック)は、この場合のツールバーに必要なものです。
コメントスレッドポップオーバーの追加
このセクションでは、選択された/アクティブなコメントスレッドの概念を利用し、ユーザーがそのコメントスレッドにコメントを追加できるようにするポップオーバーを表示するコメントポップオーバーを作成します。 ビルドする前に、それがどのように機能するかを簡単に見てみましょう。
アクティブなコメントスレッドの近くにコメントポップオーバーをレンダリングしようとすると、リンクエディタメニューを使用した最初の記事で行った問題のいくつかに遭遇します。 この時点で、リンクエディタを構築する最初の記事のセクションと、それに遭遇する選択の問題を一読することをお勧めします。
まず、アクティブなコメントスレッドに基づいて、空のポップオーバーコンポーネントを適切な場所にレンダリングする作業を始めましょう。 ポップオーバーが機能する方法は次のとおりです。
- コメントスレッドポップオーバーは、アクティブなコメントスレッドIDがある場合にのみレンダリングされます。 その情報を取得するために、前のセクションで作成した反跳原子を聞きます。
- レンダリングすると、エディターが選択したテキストノードが見つかり、それに近いポップオーバーがレンダリングされます。
- ユーザーがポップオーバーの外側をクリックすると、アクティブなコメントスレッドが
null
に設定され、コメントスレッドが非アクティブ化され、ポップオーバーが非表示になります。
# src/components/CommentThreadPopover.js import NodePopover from "./NodePopover"; import { getFirstTextNodeAtSelection } from "../utils/EditorUtils"; import { useEditor } from "slate-react"; import { useSetRecoilState} from "recoil"; import {activeCommentThreadIDAtom} from "../utils/CommentState"; export default function CommentThreadPopover({ editorOffsets, selection, threadID }) { const editor = useEditor(); const textNode = getFirstTextNodeAtSelection(editor, selection); const setActiveCommentThreadID = useSetRecoilState( activeCommentThreadIDAtom ); const onClickOutside = useCallback( () => {}, [] ); return ( <NodePopover editorOffsets={editorOffsets} isBodyFullWidth={true} node={textNode} className={"comment-thread-popover"} onClickOutside={onClickOutside} > {`Comment Thread Popover for threadID:${threadID}`} </NodePopover> ); }
ポップオーバーコンポーネントのこの実装のために呼び出されるべきいくつかの事柄:
- これは、
editorOffsets
と、それがレンダリングされるEditor
コンポーネントからのselection
を取ります。editorOffsets
はEditorコンポーネントの境界であるため、ポップオーバーの位置を計算でき、ユーザーがツールバーボタンを使用して選択がnull
になった場合に備えて、selection
は現在または以前のselection
になります。 上記のリンク先の最初の記事のリンクエディタのセクションでは、これらについて詳しく説明しています。 - 最初の記事の
LinkEditor
とここのCommentThreadPopover
はどちらもテキストノードの周りにポップオーバーをレンダリングするため、その共通ロジックを、問題のテキストノードに位置合わせされたコンポーネントのレンダリングを処理するコンポーネントNodePopover
に移動しました。 その実装の詳細は、LinkEditor
コンポーネントが最初の記事で持っていたものです。 -
NodePopover
は、ユーザーがポップオーバーの外側をクリックした場合に呼び出される小道具としてonClickOutside
メソッドを取ります。 このアイデアに関するこのSmashingの記事で詳細に説明されているように、document
にmousedown
イベントリスナーをアタッチすることでこれを実装します。 -
getFirstTextNodeAtSelection
は、ポップオーバーのレンダリングに使用するユーザーの選択範囲内の最初のテキストノードを取得します。 この関数の実装では、Slateのヘルパーを使用してテキストノードを検索します。
# src/utils/EditorUtils.js export function getFirstTextNodeAtSelection(editor, selection) { const selectionForNode = selection ?? editor.selection; if (selectionForNode == null) { return null; } const textNodeEntry = Editor.nodes(editor, { at: selectionForNode, mode: "lowest", match: Text.isText, }).next().value; return textNodeEntry != null ? textNodeEntry[0] : null; }
アクティブなコメントスレッドをクリアするonClickOutside
コールバックを実装しましょう。 ただし、コメントスレッドのポップオーバーが開いていて、特定のスレッドがアクティブであり、ユーザーがたまたま別のコメントスレッドをクリックした場合のシナリオを考慮する必要があります。 その場合、他のCommentedText
コンポーネントのクリックイベントによって他のコメントスレッドがアクティブになるように設定されるため、 onClickOutside
がアクティブなコメントスレッドをリセットすることは望ましくありません。 ポップオーバーでそれを妨害したくありません。
これを行う方法は、クリックイベントが発生したDOMノードに最も近いスレートノードを見つけることです。 そのスレートノードがテキストノードであり、コメントがある場合は、アクティブなコメントスレッドのリコイルアトムのリセットをスキップします。 実装しましょう!
# src/components/CommentThreadPopover.js const setActiveCommentThreadID = useSetRecoilState(activeCommentThreadIDAtom); const onClickOutside = useCallback( (event) => { const slateDOMNode = event.target.hasAttribute("data-slate-node") ? event.target : event.target.closest('[data-slate-node]'); // The click event was somewhere outside the Slate hierarchy. if (slateDOMNode == null) { setActiveCommentThreadID(null); return; } const slateNode = ReactEditor.toSlateNode(editor, slateDOMNode); // Click is on another commented text node => do nothing. if ( Text.isText(slateNode) && getCommentThreadsOnTextNode(slateNode).size > 0 ) { return; } setActiveCommentThreadID(null); }, [editor, setActiveCommentThreadID] );
SlateにはヘルパーメソッドtoSlateNode
があり、DOMノードまたはそれ自体がSlateノードでない場合は最も近い祖先にマップするSlateノードを返します。 このヘルパーの現在の実装は、 null
を返す代わりに、Slateノードが見つからない場合にエラーをスローします。 上記の処理では、 null
ケースを自分でチェックします。これは、ユーザーがエディターの外部でスレートノードが存在しない場所をクリックした場合に発生する可能性が非常に高いシナリオです。
これで、 Editor
コンポーネントを更新して、 activeCommentThreadIDAtom
をリッスンし、コメントスレッドがアクティブな場合にのみポップオーバーをレンダリングできるようになりました。
# src/components/Editor.js import { useRecoilValue } from "recoil"; import { activeCommentThreadIDAtom } from "../utils/CommentState"; export default function Editor({ document, onChange }): JSX.Element { const activeCommentThreadID = useRecoilValue(activeCommentThreadIDAtom); // This hook is described in detail in the first article const [previousSelection, selection, setSelection] = useSelection(editor); return ( <> ... <div className="editor" ref={editorRef}> ... {activeCommentThreadID != null ? ( <CommentThreadPopover editorOffsets={editorOffsets} selection={selection ?? previousSelection} threadID={activeCommentThreadID} /> ) : null} </div> ... </> ); }
ポップオーバーが適切なコメントスレッドの適切な場所に読み込まれ、外側をクリックするとアクティブなコメントスレッドがクリアされることを確認しましょう。
次に、ユーザーがコメントスレッドにコメントを追加できるようにし、そのスレッドのすべてのコメントをポップオーバーに表示できるようにします。 この記事の前半で作成したRecoilアトムファミリーcommentThreadsState
を使用します。
コメントスレッドのコメントは、 comments
配列に格納されます。 新しいコメントの追加を有効にするために、ユーザーが新しいコメントを入力できるようにするフォーム入力をレンダリングします。 ユーザーがコメントを入力している間、ローカル状態変数( commentText
)でコメントを維持します。 ボタンをクリックすると、 comments
配列に新しいコメントとしてコメントテキストが追加されます。
# src/components/CommentThreadPopover.js import { commentThreadsState } from "../utils/CommentState"; import { useRecoilState } from "recoil"; import Button from "react-bootstrap/Button"; import Form from "react-bootstrap/Form"; export default function CommentThreadPopover({ editorOffsets, selection, threadID, }) { const [threadData, setCommentThreadData] = useRecoilState( commentThreadsState(threadID) ); const [commentText, setCommentText] = useState(""); const onClick = useCallback(() => { setCommentThreadData((threadData) => ({ ...threadData, comments: [ ...threadData.comments, // append comment to the comments on the thread. { text: commentText, author: "Jane Doe", creationTime: new Date() }, ], })); // clear the input setCommentText(""); }, [commentText, setCommentThreadData]); const onCommentTextChange = useCallback( (event) => setCommentText(event.target.value), [setCommentText] ); return ( <NodePopover ... > <div className={"comment-input-wrapper"}> <Form.Control bsPrefix={"comment-input form-control"} placeholder={"Type a comment"} type="text" value={commentText} onChange={onCommentTextChange} /> <Button size="sm" variant="primary" disabled={commentText.length === 0} onClick={onClick} > Comment </Button> </div> </NodePopover> ); }
注:ユーザーがコメントを入力できるように入力をレンダリングしますが、ポップオーバーがマウントされたときに必ずしもフォーカスを合わせるとは限りません。 これはユーザーエクスペリエンスの決定であり、編集者ごとに異なる可能性があります。 一部のエディターでは、コメントスレッドのポップオーバーが開いている間はユーザーがテキストを編集できません。 この例では、ユーザーがコメント付きのテキストをクリックしたときに編集できるようにする必要があります。
Recoilアトムファミリーから特定のコメントスレッドのデータにアクセスする方法を呼び出す価値があります—アトムをcommentThreadsState(threadID)
として呼び出すことによって。 これにより、アトムの値と、ファミリ内のそのアトムだけを更新するためのセッターが得られます。 コメントがサーバーから遅延ロードされている場合、RecoilはuseRecoilStateLoadable
フックも提供します。このフックは、アトムのデータのロード状態を通知するLoadableオブジェクトを返します。 まだロード中の場合は、ポップオーバーにロード状態を表示するように選択できます。
次に、 threadData
にアクセスして、コメントのリストをレンダリングします。 各コメントは、 CommentRow
コンポーネントによってレンダリングされます。
# src/components/CommentThreadPopover.js return ( <NodePopover ... > <div className={"comment-list"}> {threadData.comments.map((comment, index) => ( <CommentRow key={`comment_${index}`} comment={comment} /> ))} </div> ... </NodePopover> );
以下は、コメントテキストと、作成者名や作成時間などの他のメタデータをレンダリングするCommentRow
の実装です。 date-fns
モジュールを使用して、フォーマットされた作成時刻を表示します。
# src/components/CommentRow.js import { format } from "date-fns"; export default function CommentRow({ comment: { author, text, creationTime }, }) { return ( <div className={"comment-row"}> <div className="comment-author-photo"> <i className="bi bi-person-circle comment-author-photo"></i> </div> <div> <span className="comment-author-name">{author}</span> <span className="comment-creation-time"> {format(creationTime, "eee MM/dd H:mm")} </span> <div className="comment-text">{text}</div> </div> </div> ); }
後でコメントサイドバーを実装するときに再利用するため、これを独自のコンポーネントとして抽出しました。
この時点で、Comment Popoverには、新しいコメントの挿入とリコイル状態の更新を可能にするために必要なすべてのコードが含まれています。 それを確認しましょう。 ブラウザコンソールで、前に追加したRecoil Debug Observerを使用して、スレッドに新しいコメントを追加するときに、コメントスレッドのRecoilアトムが正しく更新されていることを確認できます。
コメントサイドバーの追加
記事の前半で、重複の組み合わせによっては、実装したルールによって、テキストノードだけをクリックしても特定のコメントスレッドにアクセスできない場合がある理由を説明しました。 このような場合、ユーザーがドキュメント内のすべてのコメントスレッドにアクセスできるコメントサイドバーが必要です。
コメントサイドバーは、提案とレビューのワークフローに組み込まれた優れた追加機能でもあり、レビュー担当者はすべてのコメントスレッドをスイープで次々にナビゲートし、必要に応じてコメント/返信を残すことができます。 サイドバーの実装を開始する前に、以下で処理する未完了のタスクが1つあります。
コメントスレッドの反動状態の初期化
ドキュメントがエディターにロードされたら、ドキュメントをスキャンしてすべてのコメントスレッドを見つけ、初期化プロセスの一部として上記で作成したRecoilアトムに追加する必要があります。 EditorCommentUtils
で、テキストノードをスキャンし、すべてのコメントスレッドを検索して、それらをRecoilアトムに追加するユーティリティ関数を記述してみましょう。
# src/utils/EditorCommentUtils.js export async function initializeStateWithAllCommentThreads( editor, addCommentThread ) { const textNodesWithComments = Editor.nodes(editor, { at: [], mode: "lowest", match: (n) => Text.isText(n) && getCommentThreadsOnTextNode(n).size > 0, }); const commentThreads = new Set(); let textNodeEntry = textNodesWithComments.next().value; while (textNodeEntry != null) { [...getCommentThreadsOnTextNode(textNodeEntry[0])].forEach((threadID) => { commentThreads.add(threadID); }); textNodeEntry = textNodesWithComments.next().value; } Array.from(commentThreads).forEach((id) => addCommentThread(id, { comments: [ { author: "Jane Doe", text: "Comment Thread Loaded from Server", creationTime: new Date(), }, ], status: "open", }) ); }
バックエンドストレージとの同期とパフォーマンスに関する考慮事項
この記事のコンテキストでは、UIの実装に純粋に焦点を当てているため、初期化コードが機能していることを確認できるデータを使用して初期化するだけです。
コメントシステムの実際の使用法では、コメントスレッドはドキュメントの内容自体とは別に保存される可能性があります。 このような場合、 commentThreads
内のすべてのコメントスレッドIDに関するすべてのメタデータとコメントをフェッチするAPI呼び出しを行うために、上記のコードを更新する必要があります。 コメントスレッドが読み込まれると、複数のユーザーがリアルタイムでコメントを追加したり、ステータスを変更したりするため、コメントスレッドが更新される可能性があります。 コメントシステムの製品版では、サーバーとの同期を維持できるようにRecoilストレージを構成する必要があります。 状態管理にRecoilを使用することを選択した場合、Atom Effects API(この記事の執筆時点では実験的)に同様のことを行う例がいくつかあります。
ドキュメントが非常に長く、多くのユーザーが多くのコメントスレッドで共同作業を行っている場合は、初期化コードを最適化して、ドキュメントの最初の数ページのコメントスレッドのみを読み込む必要があります。 または、ペイロードの重い部分である可能性が高いコメントのリスト全体ではなく、すべてのコメントスレッドの軽量メタデータのみをロードすることを選択することもできます。
それでは、 Editor
コンポーネントがドキュメントとともにマウントされ、反動状態が正しく初期化されるときに、この関数の呼び出しに移りましょう。
# src/components/Editor.js import { initializeStateWithAllCommentThreads } from "../utils/EditorCommentUtils"; import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState"; export default function Editor({ document, onChange }): JSX.Element { ... const addCommentThread = useAddCommentThreadToState(); useEffect(() => { initializeStateWithAllCommentThreads(editor, addCommentThread); }, [editor, addCommentThread]); return ( <> ... </> ); }
同じカスタムフックを使用します—ツールバーのコメントボタンの実装で使用したuseAddCommentThreadToState
を使用して、新しいコメントスレッドを追加します。 ポップオーバーが機能しているので、ドキュメント内の既存のコメントスレッドのいずれかをクリックして、上記のスレッドを初期化するために使用したデータが表示されていることを確認できます。
状態が正しく初期化されたので、サイドバーの実装を開始できます。 UIのすべてのコメントスレッドは、RecoilアトムファミリーcommentThreadsState
に格納されます。 前に強調したように、Recoilアトムファミリーのすべてのアイテムを反復処理する方法は、別のアトムのアトムキー/IDを追跡することです。 私たちはcommentThreadIDsState
でそれを行ってきました。 このアトムのIDのセットを反復処理し、それぞれにCommentThread
CommentSidebar
を追加しましょう。
# src/components/CommentsSidebar.js import "./CommentSidebar.css"; import {commentThreadIDsState,} from "../utils/CommentState"; import { useRecoilValue } from "recoil"; export default function CommentsSidebar(params) { const allCommentThreadIDs = useRecoilValue(commentThreadIDsState); return ( <Card className={"comments-sidebar"}> <Card.Header>Comments</Card.Header> <Card.Body> {Array.from(allCommentThreadIDs).map((id) => ( <Row key={id}> <Col> <CommentThread id={id} /> </Col> </Row> ))} </Card.Body> </Card> ); }
ここで、レンダリングしているコメントスレッドに対応するファミリのRecoilアトムをリッスンするCommentThread
コンポーネントを実装します。 このように、ユーザーがエディターのスレッドにコメントを追加したり、他のメタデータを変更したりすると、それを反映するようにサイドバーを更新できます。
コメントの多いドキュメントではサイドバーが非常に大きくなる可能性があるため、サイドバーをレンダリングするときに最初のコメントを除くすべてのコメントを非表示にします。 ユーザーは、[返信の表示/非表示]ボタンを使用して、コメントのスレッド全体を表示/非表示にすることができます。
# src/components/CommentSidebar.js function CommentThread({ id }) { const { comments } = useRecoilValue(commentThreadsState(id)); const [shouldShowReplies, setShouldShowReplies] = useState(false); const onBtnClick = useCallback(() => { setShouldShowReplies(!shouldShowReplies); }, [shouldShowReplies, setShouldShowReplies]); if (comments.length === 0) { return null; } const [firstComment, ...otherComments] = comments; return ( <Card body={true} className={classNames({ "comment-thread-container": true, })} > <CommentRow comment={firstComment} showConnector={false} /> {shouldShowReplies ? otherComments.map((comment, index) => ( <CommentRow key={`comment-${index}`} comment={comment} showConnector={true} /> )) : null} {comments.length > 1 ? ( <Button className={"show-replies-btn"} size="sm" variant="outline-primary" onClick={onBtnClick} > {shouldShowReplies ? "Hide Replies" : "Show Replies"} </Button> ) : null} </Card> ); }
ポップオーバーのCommentRow
コンポーネントを再利用しましたが、基本的にすべてのコメントがサイドバーのスレッドに接続されているように見えるshowConnector
を使用したデザイン処理を追加しました。
ここで、 Editor
でCommentSidebar
をレンダリングし、ドキュメント内にあるすべてのスレッドが表示され、既存のスレッドに新しいスレッドまたは新しいコメントを追加するときに正しく更新されることを確認します。
# src/components/Editor.js return ( <> <Slate ... > ..... <div className={"sidebar-wrapper"}> <CommentsSidebar /> </div> </Slate> </> );
次に、エディターで見られる人気のあるコメントサイドバーインタラクションの実装に移ります。
サイドバーのコメントスレッドをクリックすると、そのコメントスレッドが選択/アクティブ化されます。 また、エディターでアクティブになっている場合は、サイドバーのコメントスレッドを強調表示するための差分デザイン処理を追加します。 これを可能にするために、RecoilアトムactiveCommentThreadIDAtom
を使用します。 これをサポートするためにCommentThread
コンポーネントを更新しましょう。
# src/components/CommentsSidebar.js function CommentThread({ id }) { const [activeCommentThreadID, setActiveCommentThreadID] = useRecoilState( activeCommentThreadIDAtom ); const onClick = useCallback(() => { setActiveCommentThreadID(id); }, [id, setActiveCommentThreadID]); ... return ( <Card body={true} className={classNames({ "comment-thread-container": true, "is-active": activeCommentThreadID === id, })} onClick={onClick} > .... </Card> );
よく見ると、アクティブなコメントスレッドをサイドバーと同期する実装にバグがあります。 サイドバーでさまざまなコメントスレッドをクリックすると、正しいコメントスレッドが実際にエディターで強調表示されます。 ただし、コメントポップオーバーは、実際には変更されたアクティブなコメントスレッドに移動しません。 最初にレンダリングされた場所にとどまります。 コメントポップオーバーの実装を見ると、エディターの選択範囲の最初のテキストノードに対してそれ自体がレンダリングされます。 実装のその時点で、コメントスレッドを選択する唯一の方法は、テキストノードをクリックすることでした。これにより、クリックイベントの結果としてSlateによって更新されたため、編集者の選択に簡単に頼ることができました。 上記のonClick
イベントでは、選択を更新せず、Recoilアトム値を更新するだけで、Slateの選択が変更されないままになるため、CommentPopoverは移動しません。
この問題の解決策は、ユーザーがサイドバーのコメントスレッドをクリックしたときに、リコイルアトムを更新するとともに、エディターの選択を更新することです。 これを行う手順は次のとおりです。
- 新しいアクティブスレッドとして設定する、このコメントスレッドが含まれているすべてのテキストノードを検索します。
- これらのテキストノードをドキュメントに表示される順序で並べ替えます(これにはSlateの
Path.compare
APIを使用します)。 - 最初のテキストノードの開始から最後のテキストノードの終了までの選択範囲を計算します。
- 選択範囲をエディターの新しい選択になるように設定します(Slateの
Transforms.select
APIを使用)。
バグを修正したいだけの場合は、ステップ1でコメントスレッドを持つ最初のテキストノードを見つけて、それを編集者の選択に設定することができます。 ただし、実際にはコメントスレッドを選択しているため、コメント範囲全体を選択する方がクリーンなアプローチのように感じます。
onClick
コールバックの実装を更新して、上記の手順を含めましょう。
const onClick = useCallback(() => { const textNodesWithThread = Editor.nodes(editor, { at: [], mode: "lowest", match: (n) => Text.isText(n) && getCommentThreadsOnTextNode(n).has(id), }); let textNodeEntry = textNodesWithThread.next().value; const allTextNodePaths = []; while (textNodeEntry != null) { allTextNodePaths.push(textNodeEntry[1]); textNodeEntry = textNodesWithThread.next().value; } // sort the text nodes allTextNodePaths.sort((p1, p2) => Path.compare(p1, p2)); // set the selection on the editor Transforms.select(editor, { anchor: Editor.point(editor, allTextNodePaths[0], { edge: "start" }), focus: Editor.point( editor, allTextNodePaths[allTextNodePaths.length - 1], { edge: "end" } ), }); // Update the Recoil atom value. setActiveCommentThreadID(id); }, [editor, id, setActiveCommentThreadID]);
注: allTextNodePaths
には、すべてのテキストノードへのパスが含まれています。 Editor.point
APIを使用して、そのパスの開始点と終了点を取得します。 最初の記事では、スレートのロケーションの概念について説明します。 それらは、Slateのドキュメントにも十分に文書化されています。
この実装でバグが修正され、コメントポップオーバーがアクティブなコメントスレッドに正しく移動することを確認しましょう。 今回は、重複するスレッドの場合もテストして、そこで壊れないことを確認します。
バグ修正により、まだ説明していない別のサイドバーインタラクションが有効になりました。 非常に長いドキュメントがあり、ユーザーがビューポートの外側にあるサイドバーのコメントスレッドをクリックした場合、ユーザーがエディターのコメントスレッドに集中できるように、ドキュメントのその部分までスクロールします。 SlateのAPIを使用して上記の選択を設定することにより、無料で取得できます。 以下で実際の動作を見てみましょう。
これで、サイドバーの実装をラップします。 記事の終わりに向けて、エディターでのコメントとレビューのエクスペリエンスを向上させるのに役立つ、コメントサイドバーに実行できるいくつかの優れた機能の追加と拡張機能をリストします。
コメントの解決と再開
このセクションでは、ユーザーがコメントスレッドを「解決済み」としてマークしたり、必要に応じてディスカッションのために再度開いたりできるようにすることに重点を置いています。 実装の詳細の観点から、これは、ユーザーがこのアクションを実行するときに変更するコメントスレッドのstatus
メタデータです。 ユーザーの観点からは、これは非常に便利な機能であり、ドキュメント上の何かについてのディスカッションが終了したか、更新や新しい観点があるために再度開く必要があることなどを確認する方法を提供します。
ステータスの切り替えを有効にするために、 CommentPopover
にボタンを追加して、ユーザーが2つのステータス( open
とresolved
済み)を切り替えることができるようにします。
# src/components/CommentThreadPopover.js export default function CommentThreadPopover({ editorOffsets, selection, threadID, }) { … const [threadData, setCommentThreadData] = useRecoilState( commentThreadsState(threadID) ); ... const onToggleStatus = useCallback(() => { const currentStatus = threadData.status; setCommentThreadData((threadData) => ({ ...threadData, status: currentStatus === "open" ? "resolved" : "open", })); }, [setCommentThreadData, threadData.status]); return ( <NodePopover ... header={ <Header status={threadData.status} shouldAllowStatusChange={threadData.comments.length > 0} onToggleStatus={onToggleStatus} /> } > <div className={"comment-list"}> ... </div> </NodePopover> ); } function Header({ onToggleStatus, shouldAllowStatusChange, status }) { return ( <div className={"comment-thread-popover-header"}> {shouldAllowStatusChange && status != null ? ( <Button size="sm" variant="primary" onClick={onToggleStatus}> {status === "open" ? "Resolve" : "Re-Open"} </Button> ) : null} </div> ); }
これをテストする前に、コメントサイドバーに解決済みのコメントの異なるデザイン処理を適用して、ユーザーが未解決または開いているコメントスレッドを簡単に検出し、必要に応じてそれらに焦点を当てられるようにします。
# src/components/CommentsSidebar.js function CommentThread({ id }) { ... const { comments, status } = useRecoilValue(commentThreadsState(id)); ... return ( <Card body={true} className={classNames({ "comment-thread-container": true, "is-resolved": status === "resolved", "is-active": activeCommentThreadID === id, })} onClick={onClick} > ... </Card> ); }
結論
この記事では、リッチテキストエディターのコメントシステムのコアUIインフラストラクチャを構築しました。 ここで追加する一連の機能は、共同編集者がドキュメントの一部に注釈を付けたり、それらについて会話したりできる、エディター上でより豊かなコラボレーションエクスペリエンスを構築するための基盤として機能します。 コメントサイドバーを追加すると、製品で有効にする会話またはレビューベースの機能を追加するためのスペースが提供されます。
これらの方針に沿って、リッチテキストエディターがこの記事で構築したものに追加することを検討できる機能のいくつかを次に示します。
-
@
メンションのサポートにより、共同編集者はコメントで互いにタグ付けできます。 - コメントスレッドに追加される画像やビデオなどのメディアタイプのサポート。
- レビュー担当者が変更の提案として表示されるドキュメントを編集できるようにする、ドキュメントレベルの提案モード。 例として、Googleドキュメントのこの機能またはMicrosoftWordの変更追跡を参照できます。
- キーワードで会話を検索したり、ステータスやコメントの作成者でスレッドをフィルタリングしたりするためのサイドバーの機能強化。