Hinzufügen eines Kommentarsystems zu einem WYSIWYG-Editor

Veröffentlicht: 2022-03-10
Kurze Zusammenfassung ↬ In diesem Artikel werden wir den im ersten Artikel erstellten grundlegenden WYSIWYG-Editor wiederverwenden, um ein Kommentarsystem für einen WYSIWYG-Editor zu erstellen, mit dem Benutzer Text in einem Dokument auswählen und ihre Kommentare dazu freigeben können. Wir werden auch RecoilJS für die Zustandsverwaltung in der UI-Anwendung einführen. (Der Code für das hier erstellte System ist in einem Github-Repository als Referenz verfügbar.)

In den letzten Jahren haben wir gesehen, wie Collaboration viele digitale Workflows und Anwendungsfälle in vielen Berufen durchdringt. Gerade in der Design- und Software-Engineering-Community sehen wir Designer, die mit Tools wie Figma an Designartefakten zusammenarbeiten, Teams, die Sprint- und Projektplanung mit Tools wie Mural durchführen, und Interviews, die mit CoderPad geführt werden. Alle diese Tools zielen ständig darauf ab, die Lücke zwischen einer Online- und einer physischen Welterfahrung bei der Ausführung dieser Workflows zu schließen und die Zusammenarbeit so reichhaltig und nahtlos wie möglich zu gestalten.

Für die meisten Kollaborationstools wie diese ist die Möglichkeit, Meinungen auszutauschen und Diskussionen über dieselben Inhalte zu führen, ein Muss. Ein Kommentarsystem, das es Mitarbeitern ermöglicht, Teile eines Dokuments zu kommentieren und Gespräche darüber zu führen, ist das Herzstück dieses Konzepts. Zusammen mit dem Erstellen eines für Text in einem WYSIWYG-Editor versucht der Artikel, die Leser dazu zu bringen, wie wir versuchen, die Vor- und Nachteile abzuwägen und versuchen, ein Gleichgewicht zwischen Anwendungskomplexität und Benutzererfahrung zu finden, wenn es darum geht, Funktionen für WYSIWYG-Editoren zu erstellen oder Textverarbeitung im Allgemeinen.

Darstellung von Kommentaren in der Dokumentstruktur

Um einen Weg zu finden, Kommentare in der Datenstruktur eines Rich-Text-Dokuments darzustellen, sehen wir uns einige Szenarien an, in denen Kommentare in einem Editor erstellt werden könnten.

  • Kommentare, die über Text erstellt wurden, der keine Stile enthält (Basisszenario);
  • Kommentare, die über Text erstellt wurden, der fett/kursiv/unterstrichen usw. sein kann;
  • Kommentare, die sich in irgendeiner Weise überschneiden (teilweise Überlappung, wenn zwei Kommentare nur wenige Wörter gemeinsam haben, oder vollständig enthalten, wenn der Text eines Kommentars vollständig im Text eines anderen Kommentars enthalten ist);
  • Kommentare, die über Text innerhalb eines Links erstellt wurden (besonders, weil Links selbst Knoten in unserer Dokumentstruktur sind);
  • Kommentare, die sich über mehrere Absätze erstrecken (besonders, weil Absätze Knoten in unserer Dokumentstruktur sind und Kommentare auf Textknoten angewendet werden, die untergeordnete Elemente von Absätzen sind).

Wenn man sich die obigen Anwendungsfälle ansieht, scheint es, als ob Kommentare in der Art und Weise, wie sie in einem Rich-Text-Dokument erscheinen können, Zeichenformaten (fett, kursiv usw.) sehr ähnlich sind. Sie können einander überlappen, Text in anderen Arten von Knoten wie Links durchgehen und sogar mehrere übergeordnete Knoten wie Absätze überspannen.

Aus diesem Grund verwenden wir zur Darstellung von Kommentaren die gleiche Methode wie für Zeichenstile, also „Marks“ (wie sie in der SlateJS-Terminologie so genannt werden). Markierungen sind nur normale Eigenschaften auf Knoten – die Besonderheit besteht darin, dass die API von Slate um Markierungen herum ( Editor.addMark und Editor.removeMark ) die Änderung der Knotenhierarchie handhabt, wenn mehrere Markierungen auf denselben Textbereich angewendet werden. Dies ist für uns äußerst nützlich, da wir es mit vielen verschiedenen Kombinationen sich überschneidender Kommentare zu tun haben.

Kommentieren Sie Threads als Markierungen

Immer wenn ein Benutzer einen Textbereich auswählt und versucht, einen Kommentar einzufügen, startet er technisch gesehen einen neuen Kommentar-Thread für diesen Textbereich. Da wir ihnen erlauben würden, einen Kommentar einzufügen und später auf diesen Kommentar zu antworten, behandeln wir dieses Ereignis als eine neue Kommentar-Thread-Einfügung in das Dokument.

Wir stellen Kommentarthreads als Markierungen dar, indem jeder Kommentarthread durch eine Markierung mit dem Namen commentThread_threadID dargestellt wird, wobei threadID eine eindeutige ID ist, die wir jedem Kommentarthread zuweisen. Wenn derselbe Textbereich also zwei Kommentar-Threads darüber hat, wären zwei Eigenschaften auf true gesetzt – commentThread_thread1 und commentThread_thread2 . Hier sind Kommentar-Threads den Zeichenstilen sehr ähnlich, denn wenn derselbe Text fett und kursiv wäre, wären beide Eigenschaften auf true gesetzt – bold und italic .

Bevor wir uns mit der eigentlichen Einrichtung dieser Struktur befassen, lohnt es sich, einen Blick darauf zu werfen, wie sich die Textknoten ändern, wenn Kommentar-Threads auf sie angewendet werden. Dies funktioniert (wie bei jeder Markierung) folgendermaßen: Wenn eine Markierungseigenschaft für den ausgewählten Text festgelegt wird, teilt die Editor.addMark-API von Slate den/die Textknoten bei Bedarf so auf, dass in der resultierenden Struktur Textknoten entstehen sind so aufgebaut, dass jeder Textknoten den exakt gleichen Wert der Markierung hat.

Um dies besser zu verstehen, werfen Sie einen Blick auf die folgenden drei Beispiele, die den Vorher-Nachher-Zustand der Textknoten zeigen, sobald ein Kommentar-Thread in den ausgewählten Text eingefügt wurde:

Abbildung, die zeigt, wie ein Textknoten mit einer einfachen Kommentar-Thread-Einfügung geteilt wird
In der Mitte des Textes wird ein Textknoten eingefügt, der als Kommentarfadenmarke dreigeteilt wird. (Große Vorschau)
Abbildung, die zeigt, wie Textknoten im Falle einer teilweisen Überlappung von Kommentar-Threads geteilt werden
Durch das Hinzufügen eines Kommentar-Threads über „Text hat“ werden zwei neue Textknoten erstellt. (Große Vorschau)
Abbildung, die zeigt, wie Textknoten im Fall einer teilweisen Überlappung von Kommentar-Threads mit Links geteilt werden
Das Hinzufügen eines Kommentar-Threads über „hat Link“ teilt auch den Textknoten innerhalb des Links. (Große Vorschau)
Mehr nach dem Sprung! Lesen Sie unten weiter ↓

Kommentierten Text hervorheben

Nun, da wir wissen, wie wir Kommentare in der Dokumentstruktur darstellen werden, lasst uns fortfahren und ein paar zum Beispieldokument aus dem ersten Artikel hinzufügen und den Editor so konfigurieren, dass er sie tatsächlich als hervorgehoben anzeigt. Da wir in diesem Artikel viele Dienstprogrammfunktionen zum Umgang mit Kommentaren haben werden, erstellen wir ein EditorCommentUtils -Modul, das all diese Dienstprogramme enthalten wird. Zunächst erstellen wir eine Funktion, die eine Markierung für eine bestimmte Kommentar-Thread-ID erstellt. Wir verwenden das dann, um ein paar Kommentar-Threads in unser ExampleDocument .

 # src/utils/EditorCommentUtils.js const COMMENT_THREAD_PREFIX = "commentThread_"; export function getMarkForCommentThreadID(threadID) { return `${COMMENT_THREAD_PREFIX}${threadID}`; }

Das folgende Bild unterstreicht rot die Textbereiche, die wir als Beispiel-Kommentar-Threads im nächsten Code-Snippet hinzugefügt haben. Beachten Sie, dass der Text „Richard McClintock“ zwei Kommentar-Threads hat, die sich überschneiden. Insbesondere ist dies ein Fall, in dem ein Kommentar-Thread vollständig in einem anderen enthalten ist.

Bild, das zeigt, welche Textbereiche im Dokument kommentiert werden - einer davon ist vollständig in einem anderen enthalten.
Textbereiche, die kommentiert würden, sind rot unterstrichen. (Große Vorschau)
 # 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, }, ... ];

Wir konzentrieren uns in diesem Artikel auf die UI-Seite der Dinge eines Kommentarsystems, also weisen wir ihnen IDs im Beispieldokument direkt mit der uuid des npm-Pakets zu. Sehr wahrscheinlich werden diese IDs in einer Produktionsversion eines Editors von einem Backend-Dienst erstellt.

Wir konzentrieren uns jetzt darauf, den Editor so anzupassen, dass diese Textknoten hervorgehoben angezeigt werden. Um dies zu tun, müssen wir beim Rendern von Textknoten feststellen, ob Kommentar-Threads darauf vorhanden sind. Dafür fügen wir ein util getCommentThreadsOnTextNode . Wir bauen auf der StyledText Komponente auf, die wir im ersten Artikel erstellt haben, um den Fall zu handhaben, in dem möglicherweise versucht wird, einen Textknoten mit Kommentaren zu rendern. Da wir einige weitere Funktionen haben, die später zu kommentierten Textknoten hinzugefügt werden, erstellen wir eine Komponente CommentedText , die den kommentierten Text rendert. StyledText überprüft, ob der Textknoten, den es zu rendern versucht, Kommentare enthält. Wenn dies der Fall ist, wird CommentedText . Es verwendet ein Dienstprogramm getCommentThreadsOnTextNode , um dies abzuleiten.

 # 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; }

Der erste Artikel erstellte eine Komponente StyledText , die Textknoten darstellt (Handhabung von Zeichenstilen usw.). Wir erweitern diese Komponente, um das obige Dienstprogramm zu verwenden, und rendern eine CommentedText -Komponente, wenn der Knoten Kommentare dazu enthält.

 # 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>; }

Unten sehen Sie die Implementierung von CommentedText , die den Textknoten rendert und das CSS anfügt, das ihn als hervorgehoben anzeigt.

 # 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; }

Wenn der gesamte obige Code zusammenkommt, sehen wir jetzt Textknoten mit Kommentarthreads, die im Editor hervorgehoben sind.

Kommentierte Textknoten werden hervorgehoben, nachdem Kommentar-Threads eingefügt wurden
Kommentierte Textknoten werden hervorgehoben, nachdem Kommentar-Threads eingefügt wurden. (Große Vorschau)

Hinweis : Die Benutzer können derzeit nicht erkennen, ob ein bestimmter Text sich überschneidende Kommentare enthält. Der gesamte hervorgehobene Textbereich sieht aus wie ein einzelner Kommentar-Thread. Wir gehen später in diesem Artikel darauf ein, wo wir das Konzept des aktiven Kommentar-Threads vorstellen, mit dem Benutzer einen bestimmten Kommentar-Thread auswählen und seinen Bereich im Editor sehen können.

UI-Speicher für Kommentare

Bevor wir die Funktionalität hinzufügen, die es einem Benutzer ermöglicht, neue Kommentare einzufügen, richten wir zunächst einen UI-Status ein, um unsere Kommentar-Threads zu halten. In diesem Artikel verwenden wir RecoilJS als unsere Zustandsverwaltungsbibliothek, um Kommentar-Threads, in den Threads enthaltene Kommentare und andere Metadaten wie Erstellungszeit, Status, Kommentarautor usw. zu speichern. Lassen Sie uns Recoil zu unserer Anwendung hinzufügen:

 > yarn add recoil

Wir verwenden Recoil-Atome, um diese beiden Datenstrukturen zu speichern. Wenn Sie mit Recoil nicht vertraut sind, halten Atome den Anwendungsstatus. Für verschiedene Anwendungszustände möchten Sie normalerweise unterschiedliche Atome einrichten. Die Atomfamilie ist eine Sammlung von Atomen – man kann sie sich als Map von einem eindeutigen Schlüssel vorstellen, der das Atom identifiziert, bis hin zu den Atomen selbst. Es lohnt sich, an dieser Stelle die Kernkonzepte von Recoil durchzugehen und uns damit vertraut zu machen.

Für unseren Anwendungsfall speichern wir Kommentar-Threads als Atom-Familie und packen unsere Anwendung dann in eine RecoilRoot Komponente. RecoilRoot wird angewendet, um den Kontext bereitzustellen, in dem die Atomwerte verwendet werden. Wir erstellen ein separates Modul CommentState , das unsere Recoil-Atomdefinitionen enthält, während wir später in diesem Artikel weitere Atomdefinitionen hinzufügen.

 # 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([]), });

Es lohnt sich, einige Dinge über diese Atomdefinitionen hervorzuheben:

  • Jedes Atom/jede Atomfamilie wird eindeutig durch einen key identifiziert und kann mit einem Standardwert eingerichtet werden.
  • Im weiteren Verlauf dieses Artikels benötigen wir eine Möglichkeit, alle Kommentar-Threads zu durchlaufen, was im Grunde bedeuten würde, dass wir eine Möglichkeit zur Iteration über die commentThreadsState benötigen würden. Zum Zeitpunkt des Schreibens dieses Artikels besteht die Möglichkeit, dies mit Recoil zu tun, darin, ein weiteres Atom einzurichten, das alle IDs der Atomfamilie enthält. Das machen wir oben mit commentThreadIDsState . Diese beiden Atome müssten synchron gehalten werden, wenn wir Kommentar-Threads hinzufügen/löschen.

Wir fügen unserer Root- App -Komponente einen RecoilRoot Wrapper hinzu, damit wir diese Atome später verwenden können. Die Dokumentation von Recoil bietet auch eine hilfreiche Debugger-Komponente, die wir so nehmen, wie sie ist, und in unseren Editor einfügen. Diese Komponente hinterlässt console.debug Protokolle in unserer Dev-Konsole, wenn Recoil-Atome in Echtzeit aktualisiert werden.

 # 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. }

Wir müssen auch Code hinzufügen, der unsere Atome mit den Kommentar-Threads initialisiert, die bereits im Dokument vorhanden sind (die wir zum Beispiel unserem Beispieldokument im vorherigen Abschnitt hinzugefügt haben). Wir tun dies zu einem späteren Zeitpunkt, wenn wir die Kommentar-Seitenleiste erstellen, die alle Kommentar-Threads in einem Dokument lesen muss.

An diesem Punkt laden wir unsere Anwendung, stellen sicher, dass keine Fehler auf unser Recoil-Setup hinweisen, und fahren fort.

Neue Kommentare hinzufügen

In diesem Abschnitt fügen wir der Symbolleiste eine Schaltfläche hinzu, mit der der Benutzer Kommentare für den ausgewählten Textbereich hinzufügen (d. h. einen neuen Kommentar-Thread erstellen) kann. Wenn der Benutzer einen Textbereich auswählt und auf diese Schaltfläche klickt, müssen wir Folgendes tun:

  1. Weisen Sie dem neuen Kommentar-Thread, der eingefügt wird, eine eindeutige ID zu.
  2. Fügen Sie eine neue Markierung zur Slate-Dokumentstruktur mit der ID hinzu, damit der Benutzer diesen Text hervorgehoben sieht.
  3. Fügen Sie den neuen Kommentarthread zu Recoil Atoms hinzu, den wir im vorherigen Abschnitt erstellt haben.

Lassen Sie uns eine util-Funktion zu EditorCommentUtils hinzufügen, die #1 und #2 erledigt.

 # 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; }

Indem wir das Konzept von Markierungen verwenden, um jeden Kommentar-Thread als eigene Markierung zu speichern, können wir einfach die Editor.addMark API verwenden, um einen neuen Kommentar-Thread zu dem ausgewählten Textbereich hinzuzufügen. Dieser Aufruf allein behandelt all die verschiedenen Fälle des Hinzufügens von Kommentaren – von denen wir einige im vorherigen Abschnitt beschrieben haben – teilweise überlappende Kommentare, Kommentare innerhalb/überlappende Links, Kommentare über fettem/kursivem Text, Kommentare über Absätze und so weiter. Dieser API-Aufruf passt die Knotenhierarchie an, um so viele neue Textknoten wie nötig zu erstellen, um diese Fälle zu handhaben.

addCommentThreadToState ist eine Callback-Funktion, die Schritt 3 behandelt – das Hinzufügen des neuen Kommentar-Threads zum Recoil-Atom . Wir implementieren das als nächstes als benutzerdefinierten Callback-Hook, damit es wiederverwendbar ist. Dieser Rückruf muss den neuen Kommentar-Thread zu beiden Atomen hinzufügen – commentThreadsState und commentThreadIDsState . Um dies tun zu können, verwenden wir den Hook useRecoilCallback . Dieser Hook kann verwendet werden, um einen Rückruf zu erstellen, der einige Dinge erhält, die zum Lesen/Setzen von Atomdaten verwendet werden können. Diejenige, an der wir uns gerade interessieren, ist die set Funktion, die verwendet werden kann, um einen Atomwert als set(atom, newValueOrUpdaterFunction) zu aktualisieren.

 # 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); }, [] ); }

Der erste Aufruf von set fügt die neue ID dem vorhandenen Satz von Kommentar-Thread-IDs hinzu und gibt den neuen Set zurück (der zum neuen Wert des Atoms wird).

Beim zweiten Aufruf holen wir das Atom für die ID aus der Atom-Familie – commentThreadsState als commentThreadsState(id) und setzen dann threadData auf seinen Wert. atomFamilyName(atomID) ermöglicht uns Recoil den Zugriff auf ein Atom aus seiner Atomfamilie unter Verwendung des eindeutigen Schlüssels. Grob gesagt könnten wir sagen, dass, wenn commentThreadsState eine Javascript-Map wäre, dieser Aufruf im Grunde — commentThreadsState.set(id, threadData) ist.

Nachdem wir nun all diesen Code eingerichtet haben, um das Einfügen eines neuen Kommentar-Threads in das Dokument und die Recoil-Atome zu handhaben, fügen wir unserer Symbolleiste eine Schaltfläche hinzu und verbinden sie mit dem Aufruf dieser Funktionen.

 # 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> ); }

Hinweis : Wir verwenden onMouseDown und nicht onClick , was dazu führen würde, dass der Editor den Fokus verliert und die Auswahl null wird. Wir haben das etwas ausführlicher im Abschnitt zum Einfügen von Links im ersten Artikel besprochen.

Im folgenden Beispiel sehen wir das Einfügen in Aktion für einen einfachen Kommentar-Thread und einen überlappenden Kommentar-Thread mit Links. Beachten Sie, wie wir Updates vom Recoil Debugger erhalten, die bestätigen, dass unser Status korrekt aktualisiert wird. Wir überprüfen auch, ob neue Textknoten erstellt werden, wenn dem Dokument Threads hinzugefügt werden.

Durch das Einfügen eines Kommentar-Threads wird der Textknoten geteilt, wodurch der kommentierte Text zu einem eigenen Knoten wird.
Weitere Textknoten werden erstellt, wenn wir überlappende Kommentare hinzufügen.

Überlappende Kommentare

Bevor wir mit dem Hinzufügen weiterer Funktionen zu unserem Kommentarsystem fortfahren, müssen wir einige Entscheidungen darüber treffen, wie wir mit sich überschneidenden Kommentaren und ihren unterschiedlichen Kombinationen im Editor umgehen werden. Um zu sehen, warum wir das brauchen, werfen wir einen kurzen Blick darauf, wie ein Kommentar-Popover funktioniert – eine Funktion, die wir später in diesem Artikel bauen werden. Wenn ein Benutzer auf einen bestimmten Text mit Kommentar-Threads darauf klickt, „wählen“ wir einen Kommentar-Thread aus und zeigen ein Popover, wo der Benutzer Kommentare zu diesem Thread hinzufügen kann.

Wenn der Benutzer auf einen Textknoten mit überlappenden Kommentaren klickt, muss der Redakteur entscheiden, welcher Kommentar-Thread ausgewählt werden soll.

Wie Sie dem obigen Video entnehmen können, ist das Wort „Designer“ jetzt Teil von drei Kommentarthreads. Wir haben also zwei Kommentarthreads, die sich bei einem Wort überschneiden. Und diese beiden Kommentar-Threads (Nr. 1 und Nr. 2) sind vollständig in einem längeren Kommentar-Thread-Textbereich (Nr. 3) enthalten. Dies wirft einige Fragen auf:

  1. Welchen Kommentarthread sollen wir auswählen und anzeigen, wenn der Nutzer auf das Wort „Designer“ klickt?
  2. Würden wir je nach unserer Entscheidung, die obige Frage anzugehen, jemals einen Fall von Überschneidungen haben, bei dem das Klicken auf ein beliebiges Wort niemals einen bestimmten Kommentar-Thread aktivieren würde und auf den Thread überhaupt nicht zugegriffen werden kann?

Dies bedeutet, dass im Fall von sich überschneidenden Kommentaren vor allem Folgendes zu berücksichtigen ist: Wenn der Benutzer einen Kommentarthread eingefügt hat, gibt es eine Möglichkeit für ihn, diesen Kommentarthread in Zukunft auszuwählen, indem er auf einen Text darin klickt? es? Wenn nicht, wollen wir ihnen wahrscheinlich erst gar nicht erlauben, es einzufügen. Um sicherzustellen, dass dieses Prinzip in unserem Editor die meiste Zeit eingehalten wird, führen wir zwei Regeln bezüglich sich überschneidender Kommentare ein und implementieren sie in unserem Editor.

Bevor wir diese Regeln definieren, lohnt es sich, darauf hinzuweisen, dass verschiedene Editoren und Textverarbeitungsprogramme unterschiedliche Ansätze haben, wenn es um überlappende Kommentare geht. Der Einfachheit halber erlauben einige Editoren überhaupt keine überlappenden Kommentare. In unserem Fall versuchen wir, einen Mittelweg zu finden, indem wir keine zu komplizierten Fälle von Überschneidungen zulassen, aber dennoch überlappende Kommentare zulassen, damit Benutzer eine reichhaltigere Zusammenarbeits- und Überprüfungserfahrung haben.

Regel für den kürzesten Kommentarbereich

Diese Regel hilft uns bei der Beantwortung der Frage Nr. 1 von oben, welcher Kommentar-Thread ausgewählt werden soll, wenn ein Benutzer auf einen Textknoten klickt, der mehrere Kommentar-Threads enthält. Die Regel lautet:

„Wenn der Benutzer auf Text klickt, der mehrere Kommentar-Threads enthält, finden wir den Kommentar-Thread mit dem kürzesten Textbereich und wählen diesen aus.“

Intuitiv ist es sinnvoll, dies zu tun, damit der Benutzer immer eine Möglichkeit hat, zum innersten Kommentar-Thread zu gelangen, der vollständig in einem anderen Kommentar-Thread enthalten ist. Für andere Bedingungen (teilweise Überlappung oder keine Überlappung) sollte es einen Text geben, der nur einen Kommentar-Thread enthält, sodass es einfach sein sollte, diesen Text zu verwenden, um diesen Kommentar-Thread auszuwählen. Es ist der Fall einer vollständigen (oder dichten ) Überlappung von Fäden und warum wir diese Regel brauchen.

Schauen wir uns einen ziemlich komplexen Fall von Überlappung an, der es uns ermöglicht, diese Regel zu verwenden und bei der Auswahl des Kommentar-Threads „das Richtige zu tun“.

Beispiel, das drei Kommentar-Threads zeigt, die sich so überlappen, dass die einzige Möglichkeit, einen Kommentar-Thread auszuwählen, die Verwendung der Regel der kürzesten Länge ist.
Gemäß der Regel für den kürzesten Kommentarthread wählt ein Klick auf „B“ den Kommentarthread Nr. 1 aus. (Große Vorschau)

Im obigen Beispiel fügt der Benutzer die folgenden Kommentar-Threads in dieser Reihenfolge ein:

  1. Kommentar Thread #1 über Zeichen 'B' (Länge = 1).
  2. Kommentar Thread #2 über 'AB' (Länge = 2).
  3. Kommentieren Sie Thread #3 über 'BC' (Länge = 2).

Am Ende dieser Einfügungen haben wir aufgrund der Art und Weise, wie Slate die Textknoten mit Markierungen teilt, drei Textknoten – einen für jedes Zeichen. Wenn der Benutzer nun auf „B“ klickt und sich an die Regel der kürzesten Länge hält, wählen wir Thread Nr. 1 aus, da er der kürzeste der drei in der Länge ist. Wenn wir das nicht tun, hätten wir keine Möglichkeit, Kommentar-Thread Nr. 1 auszuwählen, da er nur ein Zeichen lang ist und auch Teil von zwei anderen Threads ist.

Obwohl diese Regel es einfach macht, kürzere Kommentar-Threads aufzutauchen, könnten wir in Situationen geraten, in denen längere Kommentar-Threads unzugänglich werden, da alle darin enthaltenen Zeichen Teil eines anderen kürzeren Kommentar-Threads sind. Schauen wir uns dazu ein Beispiel an.

Nehmen wir an, wir haben 100 Zeichen (sagen wir, das Zeichen 'A' wurde 100 Mal eingegeben) und der Benutzer fügt Kommentar-Threads in der folgenden Reihenfolge ein:

  1. Kommentar Thread Nr. 1 im Bereich 20,80
  2. Kommentar Thread Nr. 2 im Bereich 0,50
  3. Kommentar Thread Nr. 3 des Bereichs 51.100
Beispiel, das die Regel der kürzesten Länge zeigt, die einen Kommentar-Thread nicht auswählbar macht, da sein gesamter Text von kürzeren Kommentar-Threads verdeckt wird.
Der gesamte Text unter Kommentar-Thread Nr. 1 ist auch Teil eines anderen Kommentar-Threads, der kürzer als Nr. 1 ist. (Große Vorschau)

Wie Sie im obigen Beispiel sehen können, würden, wenn wir die gerade hier beschriebene Regel befolgen, durch Klicken auf ein beliebiges Zeichen zwischen #20 und #80 immer die Threads #2 oder #3 ausgewählt werden, da sie kürzer als #1 und daher #1 sind wäre nicht wählbar. Ein weiteres Szenario, in dem uns diese Regel unentschlossen lassen kann, welcher Kommentar-Thread ausgewählt werden soll, ist, wenn es mehr als einen Kommentar-Thread mit der gleichen kürzesten Länge auf einem Textknoten gibt.

Für eine solche Kombination von sich überschneidenden Kommentaren und viele andere solche Kombinationen, die man sich vorstellen könnte, wo das Befolgen dieser Regel einen bestimmten Kommentar-Thread durch Klicken auf Text unzugänglich macht, bauen wir später in diesem Artikel eine Kommentar-Seitenleiste, die dem Benutzer eine Ansicht aller Kommentar-Threads gibt im Dokument vorhanden sein, damit sie auf diese Threads in der Seitenleiste klicken und sie im Editor aktivieren können, um den Bereich des Kommentars anzuzeigen. Wir würden diese Regel trotzdem haben und implementieren wollen, da sie viele Überschneidungsszenarien abdecken sollte, mit Ausnahme der weniger wahrscheinlichen Beispiele, die wir oben zitiert haben. Wir haben all diesen Aufwand in diese Regel gesteckt, hauptsächlich weil das Anzeigen von hervorgehobenem Text im Editor und das Anklicken zum Kommentieren eine intuitivere Möglichkeit ist, auf einen Textkommentar zuzugreifen, als nur eine Liste mit Kommentaren in der Seitenleiste zu verwenden.

Einfügungsregel

Die Regel lautet:

„Wenn der Text, den der Benutzer ausgewählt hat und zu kommentieren versucht, bereits vollständig von Kommentar-Threads bedeckt ist, lassen Sie dieses Einfügen nicht zu.“

Dies liegt daran, dass, wenn wir diese Einfügung zulassen würden, jedes Zeichen in diesem Bereich am Ende mindestens zwei Kommentar-Threads hätte (einen bestehenden und einen den neuen, den wir gerade zugelassen haben), was es für uns schwierig macht, zu bestimmen, welcher wann ausgewählt werden soll Benutzer klickt später auf dieses Zeichen.

Wenn man sich diese Regel ansieht, könnte man sich fragen, warum wir sie überhaupt brauchen, wenn wir bereits die Regel für den kürzesten Kommentarbereich haben, mit der wir den kleinsten Textbereich auswählen können. Warum nicht alle Kombinationen von Überschneidungen zulassen, wenn wir die erste Regel verwenden können, um den richtigen Kommentar-Thread abzuleiten, der angezeigt werden soll? Wie einige der Beispiele, die wir bereits besprochen haben, funktioniert die erste Regel für viele Szenarien, aber nicht für alle. Mit der Einfügungsregel versuchen wir, die Anzahl der Szenarien zu minimieren, in denen uns die erste Regel nicht helfen kann und wir auf die Seitenleiste als einzige Möglichkeit für den Benutzer zurückgreifen müssen, auf diesen Kommentarthread zuzugreifen. Die Einfügungsregel verhindert auch exakte Überschneidungen von Kommentar-Threads. Diese Regel wird üblicherweise von vielen beliebten Editoren implementiert.

Unten ist ein Beispiel, wo wir, wenn diese Regel nicht existierte, den Kommentar-Thread Nr. 3 zulassen würden und dann als Ergebnis der ersten Regel Nr. 3 nicht zugänglich wäre, da er am längsten wäre.

Einfügungsregel, die keinen dritten Kommentar-Thread zulässt, dessen gesamter Textbereich von zwei anderen Kommentar-Threads abgedeckt wird.

Hinweis : Das Vorhandensein dieser Regel bedeutet nicht, dass wir nie vollständig überlappende Kommentare enthalten hätten. Das Schwierige an überlappenden Kommentaren ist, dass trotz der Regeln die Reihenfolge, in der Kommentare eingefügt werden, uns immer noch in einem Zustand zurücklassen kann, in dem wir die Überlappung nicht haben wollten. Um auf unser Beispiel der Kommentare zum Wort „Designer“ zurückzukommen ' Früher war der längste Kommentar-Thread, der dort eingefügt wurde, der letzte, der hinzugefügt wurde, also würde die Einfügungsregel dies zulassen, und wir enden mit einer vollständig eingeschlossenen Situation – #1 und #2, die in #3 enthalten sind. Das ist in Ordnung, denn die Shortest Comment Range Rule würde uns da draußen helfen.

Wir implementieren die Regel für den kürzesten Kommentarbereich im nächsten Abschnitt, in dem wir die Auswahl von Kommentar-Threads implementieren. Da wir jetzt eine Symbolleistenschaltfläche zum Einfügen von Kommentaren haben, können wir die Einfügeregel sofort implementieren, indem wir die Regel aktivieren, wenn der Benutzer Text ausgewählt hat. Wenn die Regel nicht erfüllt ist, würden wir die Schaltfläche „Kommentar“ deaktivieren, sodass Benutzer keinen neuen Kommentar-Thread zu dem ausgewählten Text einfügen können. Lass uns anfangen!

 # 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 ); }

Die Logik in dieser Funktion ist relativ einfach.

  • Wenn die Auswahl des Benutzers ein blinkendes Caret ist, erlauben wir das Einfügen eines Kommentars dort nicht, da kein Text ausgewählt wurde.
  • Wenn die Auswahl des Benutzers nicht reduziert ist, finden wir alle Textknoten in der Auswahl. Beachten Sie die Verwendung des mode: lowest im Aufruf von Editor.nodes (einer Hilfsfunktion von SlateJS), die uns hilft, alle Textknoten auszuwählen, da Textknoten eigentlich die Blätter des Dokumentbaums sind.
  • Wenn es mindestens einen Textknoten gibt, der keine Kommentar-Threads enthält, können wir das Einfügen zulassen. Wir verwenden das Dienstprogramm getCommentThreadsOnTextNode , das wir zuvor hier geschrieben haben.

Wir verwenden jetzt diese util-Funktion innerhalb der Symbolleiste, um den deaktivierten Zustand der Schaltfläche zu steuern.

 # 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> );

Lassen Sie uns die Implementierung der Regel testen, indem wir unser obiges Beispiel neu erstellen.

Schaltfläche zum Einfügen in der Symbolleiste deaktiviert, da der Benutzer versucht, einen Kommentar über einen Textbereich einzufügen, der bereits vollständig von anderen Kommentaren abgedeckt ist.

Ein nettes Detail zur Benutzererfahrung, das hier hervorgehoben werden sollte, ist, dass wir zwar die Symbolleistenschaltfläche deaktivieren, wenn der Benutzer die gesamte Textzeile hier ausgewählt hat, dies aber die Erfahrung für den Benutzer nicht vervollständigt. Der Benutzer versteht möglicherweise nicht ganz, warum die Schaltfläche deaktiviert ist, und ist wahrscheinlich verwirrt, dass wir nicht auf seine Absicht reagieren, dort einen Kommentar-Thread einzufügen. Wir sprechen dies später an, da Kommentar-Popover so aufgebaut sind, dass selbst wenn die Symbolleistenschaltfläche deaktiviert ist, das Popover für einen der Kommentar-Threads angezeigt wird und der Benutzer weiterhin Kommentare hinterlassen kann.

Lassen Sie uns auch einen Fall testen, in dem ein unkommentierter Textknoten vorhanden ist und die Regel das Einfügen eines neuen Kommentarthreads zulässt.

Einfügungsregel, die das Einfügen eines Kommentar-Threads ermöglicht, wenn sich unkommentierter Text in der Auswahl des Benutzers befindet.

Auswahl von Kommentar-Threads

In diesem Abschnitt aktivieren wir die Funktion, bei der der Benutzer auf einen kommentierten Textknoten klickt, und wir verwenden die Regel für den kürzesten Kommentarbereich, um zu bestimmen, welcher Kommentar-Thread ausgewählt werden soll. Die Schritte im Prozess sind:

  1. Finden Sie den kürzesten Kommentar-Thread auf dem kommentierten Textknoten, auf den der Benutzer klickt.
  2. Legen Sie diesen Kommentarthread als aktiven Kommentarthread fest. (Wir erschaffen ein neues Recoil-Atom, das die Quelle der Wahrheit dafür sein wird.)
  3. Die kommentierten Textknoten würden auf den Recoil-Zustand hören, und wenn sie Teil des aktiven Kommentar-Threads sind, würden sie sich selbst anders hervorheben. Auf diese Weise wird der gesamte Textbereich hervorgehoben, wenn der Benutzer auf den Kommentar-Thread klickt, da alle Textknoten ihre Hervorhebungsfarbe aktualisieren.

Schritt 1: Implementieren der Regel für den kürzesten Kommentarbereich

Beginnen wir mit Schritt 1, der im Wesentlichen die Regel für den kürzesten Kommentarbereich implementiert. Das Ziel ist hier, den Kommentarthread der kürzesten Reichweite an dem Textknoten zu finden, auf den der Benutzer geklickt hat. Um den Thread mit der kürzesten Länge zu finden, müssen wir die Länge aller Kommentar-Threads an diesem Textknoten berechnen. Schritte dazu sind:

  1. Holen Sie sich alle Kommentar-Threads am betreffenden Textknoten.
  2. Gehen Sie von diesem Textknoten aus in beide Richtungen und aktualisieren Sie weiterhin die verfolgten Threadlängen.
  3. Stoppen Sie die Traversierung in einer Richtung, wenn wir eine der folgenden Kanten erreicht haben:
    • Ein unkommentierter Textknoten (was bedeutet, dass wir den äußersten Start-/Endrand aller von uns verfolgten Kommentar-Threads erreicht haben).
    • Ein Textknoten, an dem alle Kommentar-Threads, die wir verfolgen, einen Rand (Start/Ende) erreicht haben.
    • Es gibt keine weiteren Textknoten, die in dieser Richtung durchlaufen werden müssen (was bedeutet, dass wir entweder den Anfang oder das Ende des Dokuments oder einen Nicht-Text-Knoten erreicht haben).

Da die Durchläufe in Vorwärts- und Rückwärtsrichtung funktional gleich sind, werden wir eine updateCommentThreadLengthMap schreiben, die im Grunde einen Textknoten-Iterator verwendet. Es wird weiterhin den Iterator aufrufen und die Tracking-Thread-Längen aktualisieren. Wir rufen diese Funktion zweimal auf – einmal für Vorwärts- und einmal für Rückwärtsrichtung. 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.

Example of multiple comment threads overlapping on a text node.
Two comment threads overlapping over the word 'text'. (Große Vorschau)

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.

Slideshow showing iterations in the implementation of Shortest Comment Thread Rule.

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> ); }

Diese Komponente verwendet useRecoilState , das es einer Komponente ermöglicht, den Wert des Recoil-Atoms zu abonnieren und auch festzulegen. Der Abonnent muss wissen, ob dieser Textknoten Teil des aktiven Kommentar-Threads ist, damit er sich anders gestalten kann. Schauen Sie sich den Screenshot unten an, wo der Kommentar-Thread in der Mitte aktiv ist und wir seine Reichweite deutlich sehen können.

Beispiel, das zeigt, wie Textknoten unter dem ausgewählten Kommentar-Thread herausspringen.
Textknoten unter dem ausgewählten Kommentar-Thread ändern ihren Stil und springen heraus. (Große Vorschau)

Jetzt, da wir den gesamten Code haben, damit die Auswahl von Kommentar-Threads funktioniert, sehen wir ihn uns in Aktion an. Um unseren Traversalcode gut zu testen, testen wir einige einfache Überschneidungsfälle und einige Randfälle wie:

  • Klicken auf einen kommentierten Textknoten am Anfang/Ende des Editors.
  • Klicken auf einen kommentierten Textknoten mit Kommentarthreads, die sich über mehrere Absätze erstrecken.
  • Klicken auf einen kommentierten Textknoten direkt vor einem Bildknoten.
  • Durch Klicken auf einen kommentierten Textknoten überlappende Links.
Auswählen des kürzesten Kommentarfadens für verschiedene Überlappungskombinationen.

Da wir jetzt ein Recoil-Atom haben, um die ID des aktiven Kommentar-Threads zu verfolgen, ist ein kleines Detail, um das wir uns kümmern müssen, das Festlegen des neu erstellten Kommentar-Threads als aktiv, wenn der Benutzer die Schaltfläche in der Symbolleiste verwendet, um einen neuen Kommentar-Thread einzufügen. Dadurch können wir im nächsten Abschnitt das Kommentar-Thread-Popover sofort nach dem Einfügen anzeigen, sodass der Benutzer sofort mit dem Hinzufügen von Kommentaren beginnen kann.

 # 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>; };

Hinweis: Die Verwendung von useSetRecoilState hier (ein Recoil-Hook, der einen Setter für das Atom verfügbar macht, aber die Komponente nicht auf seinen Wert abonniert) ist das, was wir in diesem Fall für die Symbolleiste benötigen.

Kommentar-Thread-Popovers hinzufügen

In diesem Abschnitt erstellen wir ein Kommentar-Popover, das das Konzept des ausgewählten/aktiven Kommentar-Threads nutzt und ein Popover anzeigt, mit dem der Benutzer Kommentare zu diesem Kommentar-Thread hinzufügen kann. Bevor wir es bauen, werfen wir einen kurzen Blick darauf, wie es funktioniert.

Vorschau der Kommentar-Popover-Funktion.

Beim Versuch, ein Kommentar-Popover in der Nähe des aktiven Kommentar-Threads zu rendern, stoßen wir auf einige der Probleme, die wir im ersten Artikel mit einem Link-Editor-Menü hatten. An dieser Stelle wird empfohlen, den Abschnitt im ersten Artikel durchzulesen, in dem ein Link-Editor erstellt wird, und die Auswahlprobleme, auf die wir dabei stoßen.

Lassen Sie uns zunächst daran arbeiten, eine leere Popover-Komponente an der richtigen Stelle zu rendern, basierend auf dem, was der aktive Kommentar-Thread ist. So würde Popover funktionieren:

  • Kommentar-Thread-Popover wird nur gerendert, wenn es eine aktive Kommentar-Thread-ID gibt. Um diese Informationen zu erhalten, hören wir uns das Recoil-Atom an, das wir im vorherigen Abschnitt erstellt haben.
  • Wenn es gerendert wird, finden wir den Textknoten in der Auswahl des Editors und rendern das Popover in seiner Nähe.
  • Wenn der Benutzer irgendwo außerhalb des Popovers klickt, setzen wir den aktiven Kommentar-Thread auf null , wodurch der Kommentar-Thread deaktiviert wird und auch das Popover verschwindet.
 # 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> ); }

Einige Dinge, die für diese Implementierung der Popover-Komponente genannt werden sollten:

  • Es nimmt die editorOffsets und die selection aus der Editor -Komponente, wo es gerendert würde. editorOffsets sind die Grenzen der Editor-Komponente, sodass wir die Position des Popovers berechnen könnten, und die selection könnte die aktuelle oder vorherige Auswahl sein, falls der Benutzer eine Symbolleistenschaltfläche verwendet hat, die bewirkt, dass die selection null wird. Der Abschnitt über den Link-Editor aus dem ersten oben verlinkten Artikel geht diese im Detail durch.
  • Da der LinkEditor aus dem ersten Artikel und das CommentThreadPopover hier beide ein Popover um einen Textknoten rendern, haben wir diese gemeinsame Logik in eine Komponente NodePopover , die das Rendern der Komponente übernimmt, die an dem betreffenden Textknoten ausgerichtet ist. Die Implementierungsdetails sind die, die die LinkEditor Komponente im ersten Artikel hatte.
  • NodePopover eine onClickOutside Methode als Requisite, die aufgerufen wird, wenn der Benutzer irgendwo außerhalb des Popovers klickt. Wir implementieren dies, indem wir den mousedown Ereignis-Listener an das document anhängen – wie in diesem Smashing-Artikel zu dieser Idee ausführlich erläutert.
  • getFirstTextNodeAtSelection den ersten Textknoten innerhalb der Auswahl des Benutzers ab, den wir zum Rendern des Popovers verwenden. Die Implementierung dieser Funktion verwendet die Helfer von Slate, um den Textknoten zu finden.
 # 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; }

Implementieren wir den onClickOutside -Callback, der den aktiven Kommentar-Thread löschen soll. Wir müssen jedoch das Szenario berücksichtigen, in dem das Kommentar-Thread-Popover geöffnet ist und ein bestimmter Thread aktiv ist und der Benutzer zufällig auf einen anderen Kommentar-Thread klickt. In diesem Fall möchten wir nicht, dass onClickOutside den aktiven Kommentar-Thread zurücksetzt, da das Click-Ereignis auf der anderen CommentedText -Komponente den anderen Kommentar-Thread so einstellen sollte, dass er aktiv wird. Das wollen wir im Popover nicht stören.

Dazu finden wir den Slate-Knoten, der dem DOM-Knoten am nächsten liegt, an dem das Klickereignis stattfand. Wenn dieser Slate-Knoten ein Textknoten ist und Kommentare enthält, überspringen wir das Zurücksetzen des aktiven Kommentar-Threads Recoil-Atom. Setzen wir es um!

 # 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 hat eine toSlateNode , die den Slate-Knoten zurückgibt, der einem DOM-Knoten oder seinem nächsten Vorfahren zugeordnet ist, wenn er selbst kein Slate-Knoten ist. Die aktuelle Implementierung dieses Helfers löst einen Fehler aus, wenn kein Slate-Knoten gefunden werden kann, anstatt null zurückzugeben. Wir behandeln das oben, indem wir den null selbst überprüfen, was ein sehr wahrscheinliches Szenario ist, wenn der Benutzer irgendwo außerhalb des Editors klickt, wo keine Slate-Knoten vorhanden sind.

Wir können jetzt die Editor -Komponente aktualisieren, um das activeCommentThreadIDAtom und das Popover nur zu rendern, wenn ein Kommentar-Thread aktiv ist.

 # 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> ... </> ); }

Lassen Sie uns überprüfen, ob das Popover an der richtigen Stelle für den richtigen Kommentar-Thread geladen wird und den aktiven Kommentar-Thread löscht, wenn wir nach draußen klicken.

Kommentar-Thread-Popover wird für den ausgewählten Kommentar-Thread korrekt geladen.

Wir fahren nun damit fort, Benutzern zu ermöglichen, Kommentare zu einem Kommentar-Thread hinzuzufügen und alle Kommentare dieses Threads im Popover anzuzeigen. Wir werden die Recoil-Atomfamilie verwenden – commentThreadsState , die wir zuvor in diesem Artikel dafür erstellt haben.

Die Kommentare in einem Kommentar-Thread werden im comments -Array gespeichert. Um das Hinzufügen eines neuen Kommentars zu ermöglichen, rendern wir eine Formulareingabe, die es dem Benutzer ermöglicht, einen neuen Kommentar einzugeben. Während der Benutzer den Kommentar eintippt, behalten wir diesen in einer lokalen Zustandsvariablen bei – commentText . Beim Klicken auf die Schaltfläche fügen wir den Kommentartext als neuen Kommentar an das comments -Array an.

 # 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> ); }

Hinweis : Obwohl wir eine Eingabe rendern, damit der Benutzer einen Kommentar eingeben kann, lassen wir ihn nicht unbedingt den Fokus übernehmen, wenn das Popover bereitgestellt wird. Dies ist eine Entscheidung der Benutzererfahrung, die von einem Redakteur zum anderen variieren kann. Einige Editoren lassen Benutzer den Text nicht bearbeiten, während das Kommentar-Thread-Popover geöffnet ist. In unserem Fall möchten wir den Benutzer den kommentierten Text bearbeiten lassen, wenn er darauf klickt.

Es lohnt sich, hervorzuheben, wie wir auf die Daten des spezifischen Kommentarthreads aus der Recoil-Atomfamilie zugreifen – indem wir das Atom als – commentThreadsState(threadID) . Dies gibt uns den Wert des Atoms und einen Setter, um genau dieses Atom in der Familie zu aktualisieren. Wenn die Kommentare faul vom Server geladen werden, stellt Recoil auch einen useRecoilStateLoadable Hook bereit, der ein Loadable-Objekt zurückgibt, das uns über den Ladezustand der Daten des Atoms informiert. Wenn es noch geladen wird, können wir im Popover einen Ladestatus anzeigen.

Jetzt greifen wir auf die threadData und rendern die Liste der Kommentare. Jeder Kommentar wird von der CommentRow Komponente gerendert.

 # src/components/CommentThreadPopover.js return ( <NodePopover ... > <div className={"comment-list"}> {threadData.comments.map((comment, index) => ( <CommentRow key={`comment_${index}`} comment={comment} /> ))} </div> ... </NodePopover> );

Unten sehen Sie die Implementierung von CommentRow , die den Kommentartext und andere Metadaten wie den Namen des Autors und die Erstellungszeit rendert. Wir verwenden das Modul date-fns , um eine formatierte Erstellungszeit anzuzeigen.

 # 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> ); }

Wir haben dies als eigene Komponente extrahiert, da wir es später wiederverwenden, wenn wir die Kommentar-Seitenleiste implementieren.

An diesem Punkt verfügt unser Kommentar-Popover über den gesamten Code, den es benötigt, um das Einfügen neuer Kommentare und das Aktualisieren des Recoil-Status für dieselben zu ermöglichen. Lassen Sie uns das überprüfen. Auf der Browserkonsole können wir mit dem zuvor hinzugefügten Recoil Debug Observer überprüfen, ob das Recoil-Atom für den Kommentar-Thread korrekt aktualisiert wird, wenn wir dem Thread neue Kommentare hinzufügen.

Kommentar-Thread-Popover wird geladen, wenn ein Kommentar-Thread ausgewählt wird.

Hinzufügen einer Seitenleiste für Kommentare

Weiter oben in diesem Artikel haben wir darauf hingewiesen, warum es gelegentlich vorkommen kann, dass die von uns implementierten Regeln verhindern, dass auf einen bestimmten Kommentar-Thread nicht zugegriffen werden kann, indem nur auf seine Textknoten geklickt wird – abhängig von der Kombination der Überschneidungen. Für solche Fälle benötigen wir eine Kommentar-Seitenleiste, mit der der Benutzer zu allen Kommentar-Threads im Dokument gelangen kann.

Eine Kommentar-Seitenleiste ist auch eine gute Ergänzung, die sich in einen Vorschlags- und Überprüfungs-Workflow einfügt, in dem ein Prüfer nacheinander durch alle Kommentar-Threads navigieren und Kommentare/Antworten hinterlassen kann, wo immer er dies für erforderlich hält. Bevor wir mit der Implementierung der Seitenleiste beginnen, gibt es eine unerledigte Aufgabe, um die wir uns unten kümmern.

Initialisieren des Rückstoßstatus von Kommentar-Threads

Wenn das Dokument in den Editor geladen wird, müssen wir das Dokument scannen, um alle Kommentar-Threads zu finden, und sie den Recoil-Atomen hinzufügen, die wir oben als Teil des Initialisierungsprozesses erstellt haben. Lassen Sie uns eine Utility-Funktion in EditorCommentUtils schreiben, die die Textknoten scannt, alle Kommentar-Threads findet und sie dem Recoil-Atom hinzufügt.

 # 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", }) ); }

Synchronisierung mit Back-End-Speicher und Leistungsbetrachtung

Für den Kontext des Artikels, da wir uns ausschließlich auf die UI-Implementierung konzentrieren, initialisieren wir sie einfach mit einigen Daten, mit denen wir bestätigen können, dass der Initialisierungscode funktioniert.

Bei der realen Verwendung des Kommentarsystems werden Kommentar-Threads wahrscheinlich getrennt von den Dokumentinhalten selbst gespeichert. In einem solchen Fall müsste der obige Code aktualisiert werden, um einen API-Aufruf durchzuführen, der alle Metadaten und Kommentare zu allen Kommentar-Thread-IDs in commentThreads . Sobald die Kommentar-Threads geladen sind, werden sie wahrscheinlich aktualisiert, wenn mehrere Benutzer in Echtzeit weitere Kommentare hinzufügen, ihren Status ändern und so weiter. Die Produktionsversion des Kommentarsystems müsste den Recoil-Speicher so strukturieren, dass wir ihn weiterhin mit dem Server synchronisieren können. Wenn Sie Recoil für die Zustandsverwaltung verwenden möchten, gibt es einige Beispiele für die Atom Effects-API (experimentell zum Zeitpunkt des Schreibens dieses Artikels), die etwas Ähnliches tun.

Wenn ein Dokument sehr lang ist und viele Benutzer in vielen Kommentar-Threads daran zusammenarbeiten, müssen wir möglicherweise den Initialisierungscode optimieren, um nur Kommentar-Threads für die ersten paar Seiten des Dokuments zu laden. Alternativ können wir uns dafür entscheiden, nur die leichtgewichtigen Metadaten aller Kommentar-Threads zu laden, anstatt die gesamte Kommentarliste, die wahrscheinlich den schwereren Teil der Nutzlast darstellt.

Fahren wir nun mit dem Aufrufen dieser Funktion fort, wenn die Editor -Komponente mit dem Dokument gemountet wird, damit der Recoil-Zustand korrekt initialisiert wird.

 # 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 ( <> ... </> ); }

Wir verwenden denselben benutzerdefinierten Hook – useAddCommentThreadToState – den wir bei der Implementierung der Toolbar-Kommentarschaltfläche verwendet haben, um neue Kommentar-Threads hinzuzufügen. Da das Popover funktioniert, können wir auf einen der bereits vorhandenen Kommentar-Threads im Dokument klicken und überprüfen, ob er die Daten anzeigt, die wir zum Initialisieren des obigen Threads verwendet haben.

Durch Klicken auf einen bereits vorhandenen Kommentar-Thread wird das Popover mit seinen Kommentaren korrekt geladen.
Durch Klicken auf einen bereits vorhandenen Kommentar-Thread wird das Popover mit seinen Kommentaren korrekt geladen. (Große Vorschau)

Nachdem unser Zustand nun korrekt initialisiert ist, können wir mit der Implementierung der Seitenleiste beginnen. Alle unsere Kommentar-Threads in der Benutzeroberfläche werden in der Recoil-Atomfamilie gespeichert — commentThreadsState . Wie bereits erwähnt, durchlaufen wir alle Elemente in einer Recoil-Atomfamilie, indem wir die Atomschlüssel/-ids in einem anderen Atom verfolgen. Wir haben das mit commentThreadIDsState . Lassen Sie uns die CommentSidebar -Komponente hinzufügen, die den Satz von IDs in diesem Atom durchläuft und für jede eine CommentThread Komponente rendert.

 # 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> ); }

Jetzt implementieren wir die CommentThread Komponente, die auf das Recoil-Atom in der Familie lauscht, die dem Kommentar-Thread entspricht, den es darstellt. Wenn der Benutzer weitere Kommentare zum Thread im Editor hinzufügt oder andere Metadaten ändert, können wir auf diese Weise die Seitenleiste aktualisieren, um dies widerzuspiegeln.

Da die Seitenleiste für ein Dokument mit vielen Kommentaren sehr groß werden kann, blenden wir beim Rendern der Seitenleiste alle Kommentare bis auf den ersten aus. Der Benutzer kann die Schaltfläche „Antworten anzeigen/ausblenden“ verwenden, um den gesamten Kommentar-Thread anzuzeigen/auszublenden.

 # 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> ); }

Wir haben die CommentRow Komponente aus dem Popover wiederverwendet, obwohl wir eine Designbehandlung mit der showConnector Prop hinzugefügt haben, die im Grunde alle Kommentare mit einem Thread in der Seitenleiste verbunden erscheinen lässt.

Jetzt rendern wir die CommentSidebar im Editor und überprüfen, ob sie alle Threads im Dokument anzeigt und korrekt aktualisiert wird, wenn wir neue Threads oder neue Kommentare zu bestehenden Threads hinzufügen.

 # src/components/Editor.js return ( <> <Slate ... > ..... <div className={"sidebar-wrapper"}> <CommentsSidebar /> </div> </Slate> </> );
Kommentar-Seitenleiste mit allen Kommentar-Threads im Dokument.

Wir fahren nun mit der Implementierung einer beliebten Interaktion in der Kommentar-Seitenleiste fort, die in Editoren zu finden ist:

Durch Klicken auf einen Kommentarthread in der Seitenleiste sollte dieser Kommentarthread ausgewählt/aktiviert werden. Wir fügen auch eine differenzielle Designbehandlung hinzu, um einen Kommentar-Thread in der Seitenleiste hervorzuheben, wenn er im Editor aktiv ist. Dazu verwenden wir das Recoil-Atom — activeCommentThreadIDAtom . Lassen Sie uns die Komponente CommentThread aktualisieren, um dies zu unterstützen.

 # 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> );
Durch Klicken auf einen Kommentar-Thread in der Kommentar-Seitenleiste wird dieser im Editor ausgewählt und sein Bereich hervorgehoben.

Wenn wir genau hinsehen, haben wir einen Fehler in unserer Implementierung der Synchronisierung des aktiven Kommentar-Threads mit der Seitenleiste. Wenn wir in der Seitenleiste auf verschiedene Kommentarthreads klicken, wird der richtige Kommentarthread tatsächlich im Editor hervorgehoben. Das Kommentar-Popover verschiebt sich jedoch nicht wirklich in den geänderten aktiven Kommentar-Thread. Es bleibt dort, wo es zuerst gerendert wurde. Wenn wir uns die Implementierung des Kommentar-Popovers ansehen, wird es gegen den ersten Textknoten in der Auswahl des Editors gerendert. Zu diesem Zeitpunkt in der Implementierung bestand die einzige Möglichkeit, einen Kommentar-Thread auszuwählen, darin, auf einen Textknoten zu klicken, sodass wir uns bequem auf die Auswahl des Editors verlassen konnten, da sie von Slate als Ergebnis des Klickereignisses aktualisiert wurde. Im obigen onClick Ereignis aktualisieren wir nicht die Auswahl, sondern lediglich den Recoil-Atomwert, wodurch die Auswahl von Slate unverändert bleibt und sich das Kommentar-Popover daher nicht bewegt.

Eine Lösung für dieses Problem besteht darin, die Auswahl des Editors zusammen mit dem Recoil-Atom zu aktualisieren, wenn der Benutzer auf den Kommentar-Thread in der Seitenleiste klickt. Die Schritte dazu sind:

  1. Finden Sie alle Textknoten, die diesen Kommentar-Thread enthalten, den wir als neuen aktiven Thread festlegen werden.
  2. Sortieren Sie diese Textknoten in der Reihenfolge, in der sie im Dokument erscheinen (Wir verwenden dafür die Path.compare -API von Slate).
  3. Berechnen Sie einen Auswahlbereich, der sich vom Beginn des ersten Textknotens bis zum Ende des letzten Textknotens erstreckt.
  4. Legen Sie den Auswahlbereich auf die neue Auswahl des Editors fest (unter Verwendung der Transforms.select -API von Slate).

Wenn wir nur den Fehler beheben wollten, könnten wir einfach den ersten Textknoten in Schritt #1 finden, der den Kommentar-Thread enthält, und ihn als die Auswahl des Editors festlegen. Es scheint jedoch ein saubererer Ansatz zu sein, den gesamten Kommentarbereich auszuwählen, da wir wirklich den Kommentar-Thread auswählen.

Lassen Sie uns die onClick -Callback-Implementierung so aktualisieren, dass sie die obigen Schritte enthält.

 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]);

Hinweis : allTextNodePaths enthält den Pfad zu allen Textknoten. Wir verwenden die Editor.point API, um die Start- und Endpunkte an diesem Pfad zu erhalten. Der erste Artikel geht auf die Standortkonzepte von Slate ein. Sie sind auch in der Slate-Dokumentation gut dokumentiert.

Lassen Sie uns überprüfen, ob diese Implementierung den Fehler behebt und das Kommentar-Popover korrekt in den aktiven Kommentar-Thread verschoben wird. Diesmal testen wir auch mit einem Fall von überlappenden Fäden, um sicherzustellen, dass es dort nicht bricht.

Durch Klicken auf einen Kommentar-Thread in der Kommentar-Seitenleiste wird dieser ausgewählt und das Kommentar-Thread-Popover geladen.

Mit der Fehlerbehebung haben wir eine weitere Sidebar-Interaktion aktiviert, die wir noch nicht besprochen haben. Wenn wir ein wirklich langes Dokument haben und der Benutzer auf einen Kommentar-Thread in der Seitenleiste klickt, der sich außerhalb des Ansichtsfensters befindet, möchten wir zu diesem Teil des Dokuments scrollen, damit sich der Benutzer im Editor auf den Kommentar-Thread konzentrieren kann. Indem wir die obige Auswahl mithilfe der API von Slate festlegen, erhalten wir diese kostenlos. Sehen wir es uns unten in Aktion an.

Das Dokument scrollt korrekt zum Kommentar-Thread, wenn es in der Kommentar-Seitenleiste angeklickt wird.

Damit schließen wir unsere Implementierung der Seitenleiste ab. Gegen Ende des Artikels listen wir einige nette Feature-Ergänzungen und Verbesserungen auf, die wir an der Kommentare-Seitenleiste vornehmen können, die dazu beitragen, das Kommentieren und Überprüfen im Editor zu verbessern.

Kommentare auflösen und wieder öffnen

In diesem Abschnitt konzentrieren wir uns darauf, Benutzern zu ermöglichen, Kommentar-Threads als „Gelöst“ zu markieren oder sie bei Bedarf erneut zur Diskussion zu öffnen. Aus Sicht der Implementierungsdetails sind dies die status in einem Kommentarthread, die wir ändern, wenn der Benutzer diese Aktion ausführt. Aus der Sicht eines Benutzers ist dies eine sehr nützliche Funktion, da es ihnen die Möglichkeit gibt, zu bestätigen, dass die Diskussion über etwas in dem Dokument abgeschlossen ist oder erneut eröffnet werden muss, weil es einige Aktualisierungen/neue Perspektiven gibt und so weiter.

Um das Umschalten des Status zu ermöglichen, fügen wir dem CommentPopover eine Schaltfläche hinzu, die es dem Benutzer ermöglicht, zwischen den beiden Status umzuschalten: open und 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> ); }

Bevor wir dies testen, lassen Sie uns auch der Kommentare-Seitenleiste eine differenzierte Designbehandlung für aufgelöste Kommentare geben, damit der Benutzer leicht erkennen kann, welche Kommentar-Threads nicht aufgelöst oder offen sind, und sich auf diese konzentrieren kann, wenn er möchte.

 # 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> ); }
Der Kommentar-Thread-Status wird im Popover umgeschaltet und in der Seitenleiste angezeigt.

Fazit

In diesem Artikel haben wir die zentrale UI-Infrastruktur für ein Kommentarsystem in einem Rich-Text-Editor erstellt. Die hier hinzugefügten Funktionen dienen als Grundlage für den Aufbau einer umfassenderen Zusammenarbeitserfahrung in einem Editor, in dem Mitarbeiter Teile des Dokuments mit Anmerkungen versehen und Gespräche darüber führen können. Durch das Hinzufügen einer Kommentar-Seitenleiste können wir mehr Konversations- oder bewertungsbasierte Funktionen für das Produkt aktivieren.

In diesem Sinne sind hier einige Funktionen, die ein Rich-Text-Editor zusätzlich zu dem, was wir in diesem Artikel erstellt haben, hinzufügen könnte:

  • Unterstützung für @ -Erwähnungen, damit Mitarbeiter sich gegenseitig in Kommentaren markieren können;
  • Unterstützung für Medientypen wie Bilder und Videos, die Kommentar-Threads hinzugefügt werden können;
  • Vorschlagsmodus auf Dokumentebene, der es Prüfern ermöglicht, Änderungen am Dokument vorzunehmen, die als Änderungsvorschläge angezeigt werden. Als Beispiele könnte man auf diese Funktion in Google Docs oder die Änderungsverfolgung in Microsoft Word verweisen;
  • Verbesserungen an der Seitenleiste, um Konversationen nach Schlüsselwörtern zu durchsuchen, Threads nach Status oder Kommentarautor(en) zu filtern und so weiter.