Dodawanie systemu komentowania do edytora WYSIWYG

Opublikowany: 2022-03-10
Szybkie podsumowanie ↬ W tym artykule ponownie użyjemy podstawowego edytora WYSIWYG, wbudowanego w pierwszym artykule, do zbudowania systemu komentowania dla edytora WYSIWYG, który umożliwia użytkownikom zaznaczanie tekstu w dokumencie i udostępnianie komentarzy na jego temat. Wprowadzimy również RecoilJS do zarządzania stanem w aplikacji UI. (Kod dla systemu, który tutaj budujemy, jest dostępny w repozytorium Github w celach informacyjnych.)

W ostatnich latach widzieliśmy, jak Collaboration przenika do wielu cyfrowych przepływów pracy i przypadków użycia w wielu zawodach. Tylko w społeczności projektantów i inżynierów oprogramowania widzimy, jak projektanci współpracują nad artefaktami projektowymi przy użyciu narzędzi takich jak Figma, zespoły wykonujące sprint i planowanie projektów przy użyciu narzędzi takich jak Mural i wywiady przeprowadzane za pomocą CoderPad. Wszystkie te narzędzia stale mają na celu wypełnienie luki między doświadczeniem online i fizycznym w zakresie wykonywania tych przepływów pracy i uczynienie współpracy tak bogatym i bezproblemowym, jak to tylko możliwe.

W przypadku większości narzędzi do współpracy, takich jak te, możliwość dzielenia się opiniami i prowadzenia dyskusji na temat tych samych treści jest koniecznością. Sednem tej koncepcji jest system komentowania, który umożliwia współpracownikom dodawanie adnotacji do części dokumentu i prowadzenie na ich temat rozmów. Wraz z budowaniem jednego dla tekstu w edytorze WYSIWYG, artykuł próbuje zaangażować czytelników w to, jak próbujemy rozważyć zalety i wady oraz znaleźć równowagę między złożonością aplikacji a doświadczeniem użytkownika, jeśli chodzi o tworzenie funkcji dla edytorów WYSIWYG lub Ogólne procesory tekstu.

Reprezentowanie komentarzy w strukturze dokumentu

Aby znaleźć sposób na reprezentację komentarzy w strukturze danych dokumentu z tekstem sformatowanym, przyjrzyjmy się kilku scenariuszom tworzenia komentarzy w edytorze.

  • Komentarze utworzone nad tekstem, który nie zawiera stylów (scenariusz podstawowy);
  • Komentarze utworzone nad tekstem, które mogą być pogrubione/kursywa/podkreślone itd.;
  • Komentarze, które w jakiś sposób nakładają się na siebie (częściowo nakładają się, gdy dwa komentarze mają tylko kilka słów lub są w pełni zawarte, gdy tekst jednego komentarza jest w pełni zawarty w tekście innego komentarza);
  • Komentarze utworzone nad tekstem wewnątrz linku (szczególnie, ponieważ linki same w sobie są węzłami w naszej strukturze dokumentu);
  • Komentarze, które obejmują wiele akapitów (szczególnie, ponieważ akapity są węzłami w naszej strukturze dokumentu, a komentarze są stosowane do węzłów tekstowych, które są dziećmi akapitu).

Patrząc na powyższe przypadki użycia, wydaje się, że komentarze w sposobie, w jaki mogą pojawiać się w dokumencie z tekstem sformatowanym, są bardzo podobne do stylów znaków (pogrubienie, kursywa itp.). Mogą nakładać się na siebie, przechodzić przez tekst w innych typach węzłów, takich jak łącza, a nawet obejmować wiele węzłów nadrzędnych, takich jak akapity.

Z tego powodu do reprezentowania komentarzy używamy tej samej metody, co w przypadku stylów znaków, tj. „Znaków” (tak nazywa się je w terminologii SlateJS). Znaczniki są zwykłymi właściwościami na węzłach — specjalnością jest to, że API Slate wokół znaczników ( Editor.addMark i Editor.removeMark ) obsługuje zmianę hierarchii węzłów, gdy wiele znaczników jest nakładanych na ten sam zakres tekstu. Jest to dla nas niezwykle przydatne, ponieważ mamy do czynienia z wieloma różnymi kombinacjami nakładających się komentarzy.

Komentuj wątki jako znaki

Za każdym razem, gdy użytkownik wybierze zakres tekstu i spróbuje wstawić komentarz, technicznie rzecz biorąc, rozpoczyna nowy wątek komentarzy dla tego zakresu tekstu. Ponieważ pozwolilibyśmy im na wstawienie komentarza, a później odpowiedzi na ten komentarz, traktujemy to zdarzenie jako nowe wstawienie wątku komentarza do dokumentu.

Sposób, w jaki przedstawiamy wątki komentarzy jako znaczniki, polega na tym, że każdy wątek komentarzy jest reprezentowany przez znacznik o nazwie commentThread_threadID , gdzie threadID jest unikalnym identyfikatorem, który przypisujemy do każdego wątku komentarzy. Tak więc, jeśli ten sam zakres tekstu ma dwa wątki komentarzy, będzie miał dwie właściwości ustawione na wartość truecommentThread_thread1 i commentThread_thread2 . W tym miejscu wątki komentarzy są bardzo podobne do stylów znaków, ponieważ gdyby ten sam tekst był pogrubiony i kursywy, miałby obie właściwości ustawione na truebold i italic .

Zanim przejdziemy do faktycznego konfigurowania tej struktury, warto przyjrzeć się, jak węzły tekstowe zmieniają się po zastosowaniu do nich wątków komentarzy. Sposób, w jaki to działa (jak to ma miejsce w przypadku każdego znaku), polega na tym, że gdy właściwość znacznika jest ustawiana na zaznaczonym tekście, interfejs API Slate Editor.addMark podzieli węzły tekstowe w razie potrzeby tak, że w wynikowej strukturze węzły tekstowe są skonfigurowane w taki sposób, że każdy węzeł tekstowy ma dokładnie taką samą wartość znaku.

Aby lepiej to zrozumieć, spójrz na następujące trzy przykłady, które pokazują stan węzłów tekstu przed i po po wstawieniu wątku komentarza do zaznaczonego tekstu:

Ilustracja przedstawiająca podział węzła tekstowego z podstawowym wstawieniem wątku komentarza
Węzeł tekstowy, który zostaje podzielony na trzy części, gdy znacznik wątku komentarza jest wstawiany w środku tekstu. (duży podgląd)
Ilustracja przedstawiająca podział węzła tekstowego w przypadku częściowego nakładania się wątków komentarzy
Dodanie wątku komentarza nad „text has” tworzy dwa nowe węzły tekstowe. (duży podgląd)
Ilustracja przedstawiająca podział węzła tekstowego w przypadku częściowego nakładania się wątków komentarzy z linkami
Dodanie wątku komentarza nad „has link” dzieli również węzeł tekstowy wewnątrz linku. (duży podgląd)
Więcej po skoku! Kontynuuj czytanie poniżej ↓

Podświetlanie skomentowanego tekstu

Teraz, gdy wiemy, w jaki sposób będziemy reprezentować komentarze w strukturze dokumentu, dodajmy kilka do przykładowego dokumentu z pierwszego artykułu i skonfigurujmy edytor, aby faktycznie wyświetlał je jako podświetlone. Ponieważ w tym artykule będziemy mieli wiele funkcji użytkowych do obsługi komentarzy, utworzymy moduł EditorCommentUtils , który będzie zawierał wszystkie te narzędzia. Na początek tworzymy funkcję, która tworzy oznaczenie dla danego ID wątku komentarza. Następnie używamy tego do wstawienia kilku wątków komentarzy w naszym ExampleDocument .

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

Poniższy obrazek podkreśla na czerwono zakresy tekstu, które mamy jako przykładowe wątki komentarzy dodane w następnym fragmencie kodu. Zauważ, że tekst „Richard McClintock” ma dwa wątki komentarzy, które nakładają się na siebie. W szczególności jest to przypadek, w którym jeden wątek komentarzy jest w pełni zawarty w innym.

Obraz pokazujący, które zakresy tekstu w dokumencie będą komentowane – jeden z nich jest w pełni zawarty w drugim.
Zakresy tekstów, które będą komentowane, podkreślono na czerwono. (duży podgląd)
 # 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, }, ... ];

W tym artykule skupiamy się na stronie interfejsu użytkownika systemu komentowania, więc przypisujemy im identyfikatory w przykładowym dokumencie bezpośrednio za pomocą pakietu npm uuid. Bardzo prawdopodobne, że w produkcyjnej wersji edytora te identyfikatory są tworzone przez usługę backendu.

Skupiamy się teraz na dopracowaniu edytora, aby wyświetlać te węzły tekstowe jako podświetlone. Aby to zrobić, podczas renderowania węzłów tekstowych potrzebujemy sposobu, aby stwierdzić, czy zawiera wątki komentarzy. W tym celu dodajemy narzędzie getCommentThreadsOnTextNode . Opieramy się na komponencie StyledText , który utworzyliśmy w pierwszym artykule, aby obsłużyć przypadek, w którym może próbować renderować węzeł tekstowy z komentarzami. Ponieważ w przyszłości pojawi się więcej funkcji, które zostaną dodane do skomentowanych węzłów tekstowych, tworzymy komponent CommentedText , który renderuje skomentowany tekst. StyledText sprawdzi, czy węzeł tekstowy, który próbuje wyrenderować, ma jakieś komentarze. Jeśli tak, renderuje CommentedText . Aby to wywnioskować, używa narzędzia 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; }

Pierwszy artykuł zbudował składnik StyledText , który renderuje węzły tekstowe (obsługuje style znaków i tak dalej). Rozszerzamy ten komponent, aby używał powyższego narzędzia i renderował komponent CommentedText , jeśli węzeł ma do niego komentarze.

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

Poniżej znajduje się implementacja CommentedText , która renderuje węzeł tekstowy i dołącza CSS, który pokazuje go jako podświetlony.

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

Po połączeniu całego powyższego kodu w edytorze widzimy teraz węzły tekstowe z wątkami komentarzy.

Skomentowane węzły tekstowe pojawiają się jako podświetlone po wstawieniu wątków komentarzy
Skomentowane węzły tekstowe są wyświetlane jako podświetlone po wstawieniu wątków komentarzy. (duży podgląd)

Uwaga : użytkownicy obecnie nie mogą stwierdzić, czy określony tekst zawiera nakładające się komentarze. Cały podświetlony zakres tekstu wygląda jak pojedynczy wątek komentarzy. Odnosimy się do tego w dalszej części artykułu, w którym wprowadzamy koncepcję aktywnego wątku komentarzy, który pozwala użytkownikom wybrać konkretny wątek komentarzy i zobaczyć jego zasięg w edytorze.

Pamięć interfejsu użytkownika na komentarze

Zanim dodamy funkcję, która umożliwia użytkownikowi wstawianie nowych komentarzy, najpierw konfigurujemy stan interfejsu użytkownika, aby przechowywać nasze wątki komentarzy. W tym artykule używamy RecoilJS jako naszej biblioteki zarządzania stanami do przechowywania wątków komentarzy, komentarzy zawartych w wątkach i innych metadanych, takich jak czas utworzenia, status, autor komentarza itp. Dodajmy Recoil do naszej aplikacji:

 > yarn add recoil

Używamy atomów Recoil do przechowywania tych dwóch struktur danych. Jeśli nie znasz Recoil, to atomy przechowują stan aplikacji. Dla różnych części stanu aplikacji, zwykle chcesz ustawić różne atomy. Rodzina atomów to zbiór atomów — można ją uznać za Map z niepowtarzalnego klucza identyfikującego atom do samych atomów. Warto w tym miejscu przejść przez podstawowe koncepcje Recoil i zapoznać się z nimi.

W naszym przypadku użycia przechowujemy wątki komentarzy jako rodzinę Atom, a następnie otaczamy naszą aplikację komponentem RecoilRoot . RecoilRoot jest stosowany w celu zapewnienia kontekstu, w którym będą używane wartości atomów. Tworzymy osobny moduł CommentState , który przechowuje nasze definicje atomów odrzutu, gdy dodajemy więcej definicji atomów w dalszej części artykułu.

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

Warto przywołać kilka rzeczy na temat tych definicji atomów:

  • Każda rodzina atomów/atomów jest jednoznacznie identyfikowana za pomocą key i może być skonfigurowana z wartością domyślną.
  • W dalszej części tego artykułu będziemy potrzebować sposobu na iterację wszystkich wątków komentarzy, co w zasadzie oznaczałoby potrzebę sposobu na iterację rodziny atomów commentThreadsState . W momencie pisania tego artykułu sposobem na zrobienie tego za pomocą Recoil jest skonfigurowanie innego atomu, który będzie zawierał wszystkie identyfikatory rodziny atomów. Robimy to za pomocą commentThreadIDsState powyżej. Oba te atomy musiałyby być zsynchronizowane za każdym razem, gdy dodajemy/usuwamy wątki komentarzy.

Dodajemy opakowanie RecoilRoot w naszym głównym komponencie App , dzięki czemu możemy później użyć tych atomów. Dokumentacja Recoil zawiera również pomocny komponent Debugger, który bierzemy bez zmian i wrzucamy do naszego edytora. Ten składnik pozostawi dzienniki console.debug w naszej konsoli deweloperskiej, ponieważ atomy Recoil są aktualizowane w czasie rzeczywistym.

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

Musimy również dodać kod, który inicjuje nasze atomy za pomocą wątków komentarzy, które już istnieją w dokumencie (na przykład te, które dodaliśmy do naszego przykładowego dokumentu w poprzedniej sekcji). Robimy to później, kiedy tworzymy pasek boczny komentarzy, który musi czytać wszystkie wątki komentarzy w dokumencie.

W tym momencie ładujemy naszą aplikację, upewniamy się, że nie ma błędów wskazujących na naszą konfigurację Recoil i idziemy dalej.

Dodawanie nowych komentarzy

W tej sekcji dodajemy przycisk do paska narzędzi, który pozwala użytkownikowi dodawać komentarze (tzn. tworzyć nowy wątek komentarzy) dla wybranego zakresu tekstu. Gdy użytkownik wybierze zakres tekstu i kliknie ten przycisk, musimy wykonać następujące czynności:

  1. Przypisz unikalny identyfikator do wstawianego nowego wątku komentarzy.
  2. Dodaj nowy znak do struktury dokumentu Slate z identyfikatorem, aby użytkownik widział podświetlony tekst.
  3. Dodaj nowy wątek komentarza do atomów Recoil, które stworzyliśmy w poprzedniej sekcji.

Dodajmy funkcję util do EditorCommentUtils , która wykonuje #1 i #2.

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

Używając koncepcji znaczników do przechowywania każdego wątku komentarzy jako własnego znacznika, możemy po prostu użyć interfejsu API Editor.addMark , aby dodać nowy wątek komentarzy w wybranym zakresie tekstu. Samo to wywołanie obsługuje wszystkie różne przypadki dodawania komentarzy — niektóre z nich opisaliśmy we wcześniejszej sekcji — częściowo nakładające się komentarze, komentarze wewnątrz/nakładające się linki, komentarze nad tekstem pogrubionym/kursywą, komentarze obejmujące akapity i tak dalej. To wywołanie interfejsu API dostosowuje hierarchię węzłów, aby utworzyć tyle nowych węzłów tekstowych, ile potrzeba do obsługi tych przypadków.

addCommentThreadToState to funkcja zwrotna, która obsługuje krok 3 — dodanie nowego wątku komentarza do atomu Recoil . Zaimplementujemy to następnie jako niestandardowy hak wywołania zwrotnego, aby można go było ponownie wykorzystać. To wywołanie zwrotne musi dodać nowy wątek komentarza do obu atomów — commentThreadsState i commentThreadIDsState . Aby móc to zrobić, używamy useRecoilCallback . Ten zaczep może być użyty do skonstruowania wywołania zwrotnego, które pobiera kilka rzeczy, które można wykorzystać do odczytu/ustawienia danych atomu. Obecnie interesuje nas funkcja set , której można użyć do aktualizacji wartości atomu jako set(atom, newValueOrUpdaterFunction) .

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

Pierwsze wywołanie set dodaje nowy identyfikator do istniejącego zestawu identyfikatorów wątków komentarzy i zwraca nowy Set (który staje się nową wartością atomu).

W drugim wywołaniu otrzymujemy atom dla identyfikatora z rodziny atomów — commentThreadsState jako commentThreadsState(id) , a następnie ustawiamy threadData na jego wartość. atomFamilyName(atomID) to sposób, w jaki Recoil umożliwia nam dostęp do atomu z jego rodziny atomów za pomocą unikalnego klucza. Krótko mówiąc, moglibyśmy powiedzieć, że jeśli commentThreadsState był mapą javascript, to wywołanie to w zasadzie — commentThreadsState.set(id, threadData) .

Teraz, gdy mamy już wszystkie ustawienia kodu do obsługi wstawiania nowego wątku komentarza do dokumentu i atomów Recoil, dodajmy przycisk do naszego paska narzędzi i połączmy go z wywołaniem tych funkcji.

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

Uwaga : Używamy onMouseDown , a nie onClick , co spowodowałoby utratę ostrości i zaznaczenia edytora , by stać się null . Omówiliśmy to bardziej szczegółowo w sekcji wstawiania linków w pierwszym artykule.

W poniższym przykładzie widzimy działanie wstawiania prostego wątku komentarzy i nakładającego się wątku komentarzy z linkami. Zwróć uwagę, jak otrzymujemy aktualizacje z Recoil Debugger potwierdzające, że nasz stan jest aktualizowany poprawnie. Sprawdzamy również, czy nowe węzły tekstowe są tworzone podczas dodawania wątków do dokumentu.

Wstawienie wątku komentarza dzieli węzeł tekstu, czyniąc go własnym węzłem.
Więcej węzłów tekstowych jest tworzonych, gdy dodajemy nakładające się komentarze.

Nakładające się komentarze

Zanim przejdziemy do dodawania kolejnych funkcji do naszego systemu komentowania, musimy podjąć pewne decyzje dotyczące tego, jak mamy sobie radzić z nakładającymi się komentarzami i ich różnymi kombinacjami w edytorze. Aby zobaczyć, dlaczego tego potrzebujemy, przyjrzyjmy się, jak działa wyskakujące okienko komentarzy — funkcja, którą zbudujemy w dalszej części artykułu. Gdy użytkownik kliknie określony tekst z wątkami komentarzy, „wybieramy” wątek komentarzy i wyświetlamy okienko, w którym użytkownik może dodawać komentarze do tego wątku.

Gdy użytkownik kliknie węzeł tekstowy z nakładającymi się komentarzami, edytor musi zdecydować, który wątek komentarzy wybrać.

Jak widać na powyższym filmie, słowo „projektanci” jest teraz częścią trzech wątków komentarzy. Mamy więc dwa wątki komentarzy, które nakładają się na siebie na jednym słowie. Oba te wątki komentarzy (#1 i #2) są w pełni zawarte w dłuższym zakresie tekstowym wątku komentarzy (#3). Rodzi to kilka pytań:

  1. Który wątek komentarzy powinniśmy wybrać i pokazać, gdy użytkownik kliknie słowo „projektanci”?
  2. W oparciu o to, w jaki sposób postanowimy poradzić sobie z powyższym pytaniem, czy kiedykolwiek mielibyśmy przypadek nakładania się, w którym kliknięcie dowolnego słowa nigdy nie aktywowałoby określonego wątku komentarza, a wątek nie byłby w ogóle dostępny?

Oznacza to, że w przypadku nakładających się komentarzy, najważniejszą rzeczą do rozważenia jest — czy po wstawieniu przez użytkownika wątku komentarza istniałby sposób, aby w przyszłości mógł wybrać ten wątek komentarzy, klikając jakiś tekst w środku to? Jeśli nie, prawdopodobnie nie chcemy pozwolić im na wstawienie go w pierwszej kolejności. Aby ta zasada była przestrzegana przez większość czasu w naszym edytorze, wprowadzamy dwie zasady dotyczące nakładania się komentarzy i wdrażamy je w naszym edytorze.

Zanim zdefiniujemy te reguły, warto zauważyć, że różni redaktorzy i edytory tekstu mają różne podejście do nakładających się komentarzy. Aby uprościć sprawę, niektórzy redaktorzy w ogóle nie zezwalają na nakładanie się komentarzy. W naszym przypadku staramy się znaleźć środek, nie dopuszczając zbyt skomplikowanych przypadków nakładania się, ale nadal zezwalając na nakładanie się komentarzy, aby użytkownicy mogli mieć bogatsze środowisko współpracy i recenzji.

Zasada najkrótszego zakresu komentarzy

Ta reguła pomaga nam odpowiedzieć na pytanie nr 1 z góry, który wątek komentarzy wybrać, jeśli użytkownik kliknie węzeł tekstowy, na którym znajduje się wiele wątków komentarzy. Zasadą jest:

„Jeśli użytkownik kliknie tekst, który zawiera wiele wątków komentarzy, znajdujemy wątek komentarzy o najkrótszym zakresie tekstu i wybieramy go”.

Intuicyjnie ma to sens, aby użytkownik zawsze miał sposób na dotarcie do najbardziej wewnętrznego wątku komentarzy, który jest w pełni zawarty w innym wątku komentarzy. W przypadku innych warunków (częściowe nakładanie się lub brak nakładania się) powinien istnieć tekst, który zawiera tylko jeden wątek komentarzy, więc użycie tego tekstu w celu wybrania tego wątku komentarzy powinno być łatwe. To przypadek pełnego (lub gęstego ) nakładania się wątków i dlaczego potrzebujemy tej reguły.

Przyjrzyjmy się dość złożonemu przypadkowi nakładania się, który pozwala nam wykorzystać tę regułę i „postępować właściwie” przy wyborze wątku komentarzy.

Przykład pokazujący trzy wątki komentarzy nakładające się na siebie w taki sposób, że jedynym sposobem wybrania wątku komentarzy jest użycie reguły najkrótszej długości.
Zgodnie z regułą najkrótszego wątku komentarzy, kliknięcie „B” wybiera wątek komentarzy nr 1. (duży podgląd)

W powyższym przykładzie użytkownik wstawia następujące wątki komentarzy w tej kolejności:

  1. Komentarz Wątek nr 1 nad znakiem „B” (długość = 1).
  2. Skomentuj wątek nr 2 nad „AB” (długość = 2).
  3. Komentarz Wątek nr 3 nad „BC” (długość = 2).

Na końcu tych wstawek, ze względu na sposób, w jaki Łupek dzieli węzły tekstowe za pomocą znaków, będziemy mieli trzy węzły tekstowe — po jednym dla każdego znaku. Teraz, jeśli użytkownik kliknie „B”, zgodnie z regułą najkrótszej długości, wybieramy wątek nr 1, ponieważ jest on najkrótszy z trzech. Jeśli tego nie zrobimy, nie mielibyśmy możliwości wybrania wątku komentarza nr 1, ponieważ ma on tylko jeden znak długości i jest częścią dwóch innych wątków.

Chociaż ta reguła ułatwia ujawnianie krótszych wątków komentarzy, możemy napotkać sytuacje, w których dłuższe wątki komentarzy staną się niedostępne, ponieważ wszystkie zawarte w nich znaki są częścią innego, krótszego wątku komentarzy. Spójrzmy na przykład.

Załóżmy, że mamy 100 znaków (powiedzmy, że znak „A” został wpisany 100 razy), a użytkownik wstawia wątki komentarzy w następującej kolejności:

  1. Komentarz Wątek nr 1 z zakresu 20,80
  2. Komentarz Wątek nr 2 z zakresu 0,50
  3. Komentarz Wątek nr 3 z zakresu 51,100
Przykład pokazujący regułę najkrótszej długości, uniemożliwiającą wybór wątku komentarzy, ponieważ cały jego tekst jest pokryty krótszymi wątkami komentarzy.
Cały tekst w wątku komentarzy #1 jest również częścią innego wątku komentarzy, krótszego niż #1. (duży podgląd)

Jak widać w powyższym przykładzie, jeśli zastosujemy się do zasady, którą właśnie tu opisaliśmy, kliknięcie dowolnego znaku między #20 a #80 zawsze spowoduje wybranie wątków nr 2 lub nr 3, ponieważ są one krótsze niż nr 1, a zatem nr 1 nie byłoby możliwe do wybrania. Innym scenariuszem, w którym ta reguła może pozostawić nas niezdecydowanych, który wątek komentarzy wybrać, jest sytuacja, gdy w węźle tekstowym znajduje się więcej niż jeden wątki komentarzy o tej samej najkrótszej długości.

Dla takiej kombinacji nakładających się komentarzy i wielu innych takich kombinacji, które można by pomyśleć, gdzie przestrzeganie tej zasady powoduje, że dany wątek komentarzy jest niedostępny po kliknięciu tekstu, w dalszej części tego artykułu zbudujemy pasek boczny komentarzy, który daje użytkownikowi widok wszystkich wątków komentarzy obecne w dokumencie, aby mogli kliknąć te wątki na pasku bocznym i aktywować je w edytorze, aby zobaczyć zakres komentarza. Nadal chcielibyśmy mieć tę regułę i wdrożyć ją, ponieważ powinna obejmować wiele scenariuszy nakładania się, z wyjątkiem mniej prawdopodobnych przykładów, które przytoczyliśmy powyżej. Włożyliśmy cały ten wysiłek w tę zasadę przede wszystkim dlatego, że zobaczenie podświetlonego tekstu w edytorze i kliknięcie go w celu skomentowania jest bardziej intuicyjnym sposobem uzyskania dostępu do komentarza do tekstu niż zwykłe korzystanie z listy komentarzy na pasku bocznym.

Reguła wstawiania

Zasadą jest:

„Jeśli użytkownik tekstu wybrał i próbuje skomentować, jest już w pełni objęty wątkami komentarzy, nie zezwalaj na to wstawianie”.

Dzieje się tak, ponieważ gdybyśmy zezwolili na to wstawienie, każdy znak w tym zakresie miałby co najmniej dwa wątki komentarzy (jeden istniejący, a drugi nowy, na który właśnie zezwoliliśmy), co utrudniałoby nam określenie, który z nich wybrać, gdy użytkownik kliknie ten znak później.

Patrząc na tę regułę, można by się zastanawiać, po co nam jej w ogóle, skoro mamy już Regułę Najkrótszego Zakresu Komentarzy, która pozwala nam wybrać najmniejszy zakres tekstu. Dlaczego nie zezwolić na wszystkie kombinacje nakładania się, jeśli możemy użyć pierwszej reguły do ​​wydedukowania właściwego wątku komentarza do wyświetlenia? Jak niektóre z przykładów, które omówiliśmy wcześniej, pierwsza reguła działa w wielu scenariuszach, ale nie we wszystkich. Dzięki regule wstawiania staramy się zminimalizować liczbę scenariuszy, w których pierwsza reguła nie może nam pomóc i musimy skorzystać z paska bocznego jako jedynego sposobu, w jaki użytkownik może uzyskać dostęp do tego wątku komentarza. Reguła wstawiania zapobiega również dokładnemu nakładaniu się wątków komentarzy. Ta zasada jest powszechnie stosowana przez wielu popularnych redaktorów.

Poniżej znajduje się przykład, w którym, gdyby ta reguła nie istniała, zezwolilibyśmy na wątek komentarzy #3, a następnie w wyniku pierwszej reguły #3 nie byłby dostępny, ponieważ stałby się najdłuższy.

Reguła wstawiania nie zezwala na trzeci wątek komentarzy, którego cały zakres tekstu obejmuje dwa inne wątki komentarzy.

Uwaga : ta reguła nie oznacza, że ​​nigdy nie zawarlibyśmy w pełni nakładających się komentarzy. Trudną rzeczą w nakładających się komentarzach jest to, że pomimo reguł kolejność wstawiania komentarzy może nadal pozostawiać nas w stanie, w którym nie chcieliśmy, aby nakładały się na siebie. Wracając do naszego przykładu komentarzy dotyczących słowa „projektanci” ' wcześniej najdłuższy wstawiony wątek komentarzy był ostatnim, który został dodany, więc reguła wstawiania pozwoli na to, a my otrzymamy w pełni zamkniętą sytuację — #1 i #2 znajdującą się wewnątrz #3. To dobrze, ponieważ reguła najkrótszego zakresu komentarzy może nam pomóc.

Zaimplementujemy regułę najkrótszego zakresu komentarzy w następnej sekcji, w której zaimplementujemy wybór wątków komentarzy. Ponieważ mamy teraz przycisk paska narzędzi do wstawiania komentarzy, możemy od razu zaimplementować regułę wstawiania, sprawdzając regułę, gdy użytkownik zaznaczy jakiś tekst. Jeśli reguła nie jest spełniona, wyłączymy przycisk Komentarz, aby użytkownicy nie mogli wstawić nowego wątku komentarza do zaznaczonego tekstu. Zacznijmy!

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

Logika tej funkcji jest stosunkowo prosta.

  • Jeśli zaznaczeniem użytkownika jest migająca karetka, nie zezwalamy na wstawianie tam komentarza, ponieważ żaden tekst nie został zaznaczony.
  • Jeśli wybór użytkownika nie jest zwinięty, znajdziemy w nim wszystkie węzły tekstowe. Zwróć uwagę na użycie mode: lowest w wywołaniu Editor.nodes (funkcji pomocniczej SlateJS), który pomaga nam wybrać wszystkie węzły tekstowe, ponieważ węzły tekstowe są w rzeczywistości liśćmi drzewa dokumentu.
  • Jeśli istnieje co najmniej jeden węzeł tekstowy, na którym nie ma wątków komentarzy, możemy zezwolić na wstawienie. Używamy narzędzia getCommentThreadsOnTextNode , które napisaliśmy tutaj wcześniej.

Teraz używamy tej funkcji narzędzia na pasku narzędzi, aby kontrolować stan wyłączenia przycisku.

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

Przetestujmy implementację reguły, odtwarzając powyższy przykład.

Przycisk wstawiania na pasku narzędzi jest wyłączony, ponieważ użytkownik próbuje wstawić komentarz w zakresie tekstu, który jest już w pełni objęty innymi komentarzami.

Drobnym szczegółem dotyczącym obsługi użytkownika jest to, że chociaż wyłączamy przycisk paska narzędzi, jeśli użytkownik zaznaczył tutaj całą linię tekstu, nie kończy to obsługi użytkownika. Użytkownik może nie w pełni zrozumieć, dlaczego przycisk jest wyłączony i może się pomylić, że nie odpowiadamy na jego zamiar wstawienia tam wątku komentarza. Zajmiemy się tym później, ponieważ wyskakujące okienka komentarzy są budowane w taki sposób, że nawet jeśli przycisk paska narzędzi jest wyłączony, wyskakujące okienko dla jednego z wątków komentarzy będzie się pojawiać, a użytkownik nadal będzie mógł zostawiać komentarze.

Przetestujmy też przypadek, w którym istnieje jakiś niezakomentowany węzeł tekstowy, a reguła pozwala na wstawienie nowego wątku komentarza.

Reguła wstawiania umożliwiająca wstawianie wątku komentarza, gdy w zaznaczeniu użytkownika znajduje się jakiś nieskomentowany tekst.

Wybieranie wątków komentarzy

W tej sekcji włączamy funkcję, w której użytkownik klika skomentowany węzeł tekstowy i używamy reguły najkrótszego zakresu komentarzy, aby określić, który wątek komentarza powinien zostać wybrany. Etapy procesu to:

  1. Znajdź najkrótszy wątek komentarzy w skomentowanym węźle tekstowym, który klika użytkownik.
  2. Ustaw ten wątek komentarzy jako aktywny wątek komentarzy. (Tworzymy nowy atom Recoil, który będzie źródłem prawdy.)
  3. Komentowane węzły tekstowe będą nasłuchiwać stanu odrzutu i jeśli są częścią aktywnego wątku komentarzy, będą się wyróżniać w inny sposób. W ten sposób, gdy użytkownik kliknie wątek komentarza, cały zakres tekstu zostanie wyróżniony, ponieważ wszystkie węzły tekstu zaktualizują swój kolor podświetlenia.

Krok 1: Implementuj regułę najkrótszego zakresu komentarzy

Zacznijmy od kroku nr 1, który jest w zasadzie implementacją reguły najkrótszego zakresu komentarzy. Celem jest tutaj znalezienie wątku komentarza o najkrótszym zakresie w węźle tekstowym, na który kliknął użytkownik. Aby znaleźć wątek o najkrótszej długości, musimy obliczyć długość wszystkich wątków komentarzy w tym węźle tekstowym. Kroki, aby to zrobić, to:

  1. Pobierz wszystkie wątki komentarzy w danym węźle tekstowym.
  2. Przechodź w dowolnym kierunku od tego węzła tekstowego i aktualizuj śledzone długości wątków.
  3. Zatrzymaj przemierzanie w kierunku, gdy dotrzemy do jednej z poniższych krawędzi:
    • Niezakomentowany węzeł tekstowy (co oznacza, że ​​osiągnęliśmy najdalszą krawędź początku/końca wszystkich śledzonych wątków komentarzy).
    • Węzeł tekstowy, w którym wszystkie śledzone przez nas wątki komentarzy osiągnęły krawędź (początek/koniec).
    • Nie ma więcej węzłów tekstowych do przejścia w tym kierunku (co oznacza, że ​​dotarliśmy do początku lub końca dokumentu lub węzła nietekstowego).

Ponieważ przechodzenie w przód i w tył są funkcjonalnie takie same, napiszemy funkcję pomocniczą updateCommentThreadLengthMap , która zasadniczo przyjmuje iterator węzła tekstowego. Będzie nadal wywoływał iterator i aktualizował długość wątków śledzenia. Wywołamy tę funkcję dwukrotnie — raz dla kierunku do przodu i raz dla kierunku wstecznego. 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'. (duży podgląd)

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

Ten komponent używa useRecoilState , który pozwala komponentowi subskrybować, a także może ustawić wartość atomu Recoil. Potrzebujemy, aby subskrybent wiedział, czy ten węzeł tekstowy jest częścią aktywnego wątku komentarzy, aby mógł się zmienić styl. Sprawdź zrzut ekranu poniżej, na którym wątek komentarzy pośrodku jest aktywny i wyraźnie widzimy jego zasięg.

Przykład pokazujący, jak wyskakują węzły tekstowe w wybranym wątku komentarzy.
Węzły tekstowe pod wybranym wątkiem komentarzy zmieniają styl i wyskakują. (duży podgląd)

Teraz, gdy mamy już cały kod, aby wybór wątków komentarzy działał, zobaczmy, jak działa. Aby dobrze przetestować nasz kod przechodzenia, testujemy kilka prostych przypadków nakładania się i niektóre przypadki brzegowe, takie jak:

  • Kliknięcie komentowanego węzła tekstowego na początku/końcu edytora.
  • Kliknięcie komentowanego węzła tekstowego z wątkami komentarzy obejmującymi wiele akapitów.
  • Kliknięcie komentowanego węzła tekstowego tuż przed węzłem obrazu.
  • Kliknięcie skomentowanego węzła tekstowego nakładającego się na linki.
Wybieranie najkrótszego wątku komentarza dla różnych kombinacji nakładania się.

Ponieważ mamy teraz atom Recoil do śledzenia identyfikatora aktywnego wątku komentarzy, jednym drobnym szczegółem, którym należy się zająć, jest ustawienie nowo utworzonego wątku komentarzy jako aktywnego, gdy użytkownik użyje przycisku paska narzędzi, aby wstawić nowy wątek komentarzy. Dzięki temu w następnej sekcji możemy wyświetlić okno popover wątku komentarza natychmiast po wstawieniu, dzięki czemu użytkownik może od razu rozpocząć dodawanie komentarzy.

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

Uwaga: Użycie tutaj useSetRecoilState (hak Recoil, który odsłania element ustawiający dla atomu, ale nie subskrybuje komponentu jego wartości) jest tym, czego potrzebujemy w tym przypadku dla paska narzędzi.

Dodawanie wyskakujących okienek z komentarzami

W tej sekcji tworzymy okno wyskakujące komentarzy, które wykorzystuje koncepcję wybranego/aktywnego wątku komentarzy i wyświetla okno wyskakujące, które pozwala użytkownikowi dodawać komentarze do tego wątku komentarzy. Zanim go zbudujemy, rzućmy okiem na jego działanie.

Podgląd funkcji wyskakującego komentarza.

Podczas próby renderowania wyskakującego okienka komentarza w pobliżu aktywnego wątku komentarzy, napotkaliśmy pewne problemy, które zrobiliśmy w pierwszym artykule z menu edytora linków. W tym momencie zachęcamy do zapoznania się z sekcją pierwszego artykułu, która tworzy edytor linków i problemów z selekcją, z którymi się z tym spotykamy.

Najpierw popracujmy nad renderowaniem pustego komponentu popover we właściwym miejscu na podstawie aktywnego wątku komentarza. Sposób działania popover to:

  • Popover wątku komentarza jest renderowany tylko wtedy, gdy istnieje aktywny identyfikator wątku komentarza. Aby uzyskać te informacje, słuchamy atomu Recoil, który stworzyliśmy w poprzedniej sekcji.
  • Kiedy to się renderuje, znajdujemy węzeł tekstowy w miejscu zaznaczenia edytora i renderujemy okienko popover blisko niego.
  • Gdy użytkownik kliknie gdziekolwiek poza popoverem, ustawiamy aktywny wątek komentarza na null , tym samym dezaktywując wątek komentarza, a także sprawiając, że popover znika.
 # 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> ); }

Kilka rzeczy, na które należy zwrócić uwagę przy tej implementacji komponentu popover:

  • Pobiera editorOffsets i selection ze składnika Editor , w którym byłby renderowany. editorOffsets to granice komponentu Editor, dzięki czemu możemy obliczyć pozycję popovera, a selection może być bieżącym lub poprzednim zaznaczeniem w przypadku, gdy użytkownik użyje przycisku paska narzędzi powodującego, że selection staje się null . Sekcja dotycząca edytora linków z pierwszego artykułu, do którego link znajduje się powyżej, szczegółowo je omawia.
  • Ponieważ LinkEditor z pierwszego artykułu i CommentThreadPopover tutaj renderują popover wokół węzła tekstowego, przenieśliśmy tę wspólną logikę do komponentu NodePopover , który obsługuje renderowanie komponentu dopasowane do danego węzła tekstowego. Jego szczegóły implementacyjne są tym, co komponent LinkEditor miał w pierwszym artykule.
  • NodePopover przyjmuje metodę onClickOutside jako właściwość, która jest wywoływana, jeśli użytkownik kliknie gdzieś poza popoverem. Realizujemy to, dołączając do document detektor zdarzeń mousedown — jak wyjaśniono szczegółowo w tym artykule dotyczącym tego pomysłu.
  • getFirstTextNodeAtSelection pobiera pierwszy węzeł tekstowy wewnątrz zaznaczenia użytkownika, którego używamy do renderowania popovera. Implementacja tej funkcji używa pomocników Slate do znalezienia węzła tekstowego.
 # 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; }

Zaimplementujmy wywołanie zwrotne onClickOutside , które powinno wyczyścić aktywny wątek komentarza. Musimy jednak wziąć pod uwagę scenariusz, w którym popover wątku komentarzy jest otwarty, a określony wątek jest aktywny, a użytkownik klika inny wątek komentarzy. W takim przypadku nie chcemy, aby onClickOutside zresetował aktywny wątek komentarza, ponieważ zdarzenie kliknięcia w innym komponencie CommentedText powinno ustawić inny wątek komentarza jako aktywny. Nie chcemy ingerować w to w popover.

Sposób, w jaki to robimy, polega na tym, że znajdujemy węzeł Slate najbliżej węzła DOM, w którym nastąpiło zdarzenie kliknięcia. Jeśli ten węzeł Slate jest węzłem tekstowym i ma do niego komentarze, pomijamy resetowanie aktywnego wątku komentarza Recoil atom. Zaimplementujmy to!

 # 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 ma metodę pomocniczą toSlateNode , która zwraca węzeł Slate mapowany na węzeł DOM lub jego najbliższego przodka, jeśli sam nie jest węzłem Slate. Bieżąca implementacja tego pomocnika zgłasza błąd, jeśli nie może znaleźć węzła Slate zamiast zwracać null . Poradzimy sobie z tym powyżej, sami sprawdzając przypadek null , co jest bardzo prawdopodobnym scenariuszem, jeśli użytkownik kliknie gdzieś poza edytorem, gdzie węzły Slate nie istnieją.

Możemy teraz zaktualizować komponent Editor , aby nasłuchiwał activeCommentThreadIDAtom i renderował popover tylko wtedy, gdy aktywny jest wątek komentarza.

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

Sprawdźmy, czy okienko popover ładuje się we właściwym miejscu dla właściwego wątku komentarza i czyści aktywny wątek komentarza, gdy klikniemy na zewnątrz.

Popover wątku komentarzy poprawnie ładuje się dla wybranego wątku komentarzy.

Przechodzimy teraz do umożliwienia użytkownikom dodawania komentarzy do wątku komentarzy i przeglądania wszystkich komentarzy tego wątku w popoverze. W tym celu użyjemy rodziny atomów Recoil — commentThreadsState , który stworzyliśmy wcześniej w artykule.

Komentarze w wątku komentarzy są przechowywane w tablicy comments . Aby umożliwić dodanie nowego komentarza, renderujemy dane wejściowe formularza, które umożliwiają użytkownikowi wprowadzenie nowego komentarza. Podczas gdy użytkownik wpisuje komentarz, utrzymujemy to w lokalnej zmiennej stanu — commentText . Po kliknięciu przycisku dołączamy tekst komentarza jako nowy komentarz do tablicy 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> ); }

Uwaga : Chociaż renderujemy dane wejściowe, które użytkownik może wpisać w komentarzu, niekoniecznie pozwalamy, aby był on aktywny, gdy montuje się popover. To jest decyzja dotycząca User Experience, która może się różnić w zależności od edytora. Niektórzy redaktorzy nie pozwalają użytkownikom edytować tekstu, gdy okno podręczne wątku komentarzy jest otwarte. W naszym przypadku chcemy umożliwić użytkownikowi edycję komentowanego tekstu po jego kliknięciu.

Warto przypomnieć, w jaki sposób uzyskujemy dostęp do danych konkretnego wątku komentarzy z rodziny atomów Recoil — przez wywołanie atomu jako — commentThreadsState(threadID) . Daje nam to wartość atomu i ustawiacz do aktualizacji tylko tego atomu w rodzinie. Jeśli komentarze są ładowane z serwera z opóźnieniem, Recoil udostępnia również hak useRecoilStateLoadable , który zwraca obiekt Loadable, który informuje nas o stanie ładowania danych atomu. Jeśli nadal się ładuje, możemy wybrać wyświetlanie stanu ładowania w popoverze.

Teraz uzyskujemy dostęp do threadData i renderujemy listę komentarzy. Każdy komentarz jest renderowany przez składnik CommentRow .

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

Poniżej znajduje się implementacja CommentRow , która renderuje tekst komentarza i inne metadane, takie jak nazwisko autora i czas utworzenia. Używamy modułu date-fns , aby pokazać sformatowany czas utworzenia.

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

Wyodrębniliśmy to jako osobny składnik, ponieważ użyjemy go później, gdy zaimplementujemy pasek boczny komentarzy.

W tym momencie nasz komentarz Popover ma cały kod, którego potrzebuje, aby umożliwić wstawianie nowych komentarzy i aktualizowanie stanu odrzutu dla tego samego. Sprawdźmy to. W konsoli przeglądarki, za pomocą dodanego wcześniej Recoil Debug Observer, jesteśmy w stanie sprawdzić, czy atom Recoil dla wątku komentarza jest poprawnie aktualizowany, gdy dodajemy nowe komentarze do wątku.

Popover wątku komentarzy ładuje się po wybraniu wątku komentarza.

Dodawanie paska bocznego komentarzy

Wcześniej w artykule wyjaśniliśmy, dlaczego od czasu do czasu może się zdarzyć, że wdrożone przez nas reguły uniemożliwiają dostęp do określonego wątku komentarza, klikając jego węzły tekstowe — w zależności od kombinacji nakładania się. W takich przypadkach potrzebujemy paska bocznego komentarzy, który umożliwia użytkownikowi dostęp do wszystkich wątków komentarzy w dokumencie.

Pasek boczny komentarzy jest również dobrym dodatkiem, który wplata się w przepływ pracy sugestii i recenzji, w którym recenzent może poruszać się po wszystkich wątkach komentarzy jeden po drugim i pozostawiać komentarze/odpowiedzi wszędzie tam, gdzie czuje taką potrzebę. Zanim zaczniemy implementować pasek boczny, poniżej zajmiemy się jednym niedokończonym zadaniem.

Inicjowanie stanu odrzutu wątków komentarza

Po załadowaniu dokumentu do edytora musimy go zeskanować, aby znaleźć wszystkie wątki komentarzy i dodać je do atomów Recoil, które stworzyliśmy powyżej w ramach procesu inicjalizacji. Napiszmy funkcję narzędziową w EditorCommentUtils , która skanuje węzły tekstowe, znajduje wszystkie wątki komentarzy i dodaje je do atomu 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", }) ); }

Synchronizacja z pamięcią masową zaplecza i rozważania dotyczące wydajności

W kontekście artykułu, ponieważ koncentrujemy się wyłącznie na implementacji interfejsu użytkownika, po prostu inicjujemy je pewnymi danymi, które pozwalają nam potwierdzić, że kod inicjujący działa.

W rzeczywistych zastosowaniach systemu komentowania wątki komentarzy są prawdopodobnie przechowywane oddzielnie od samej treści dokumentu. W takim przypadku powyższy kod musiałby zostać zaktualizowany, aby wykonać wywołanie API, które pobiera wszystkie metadane i komentarze dotyczące wszystkich identyfikatorów wątków komentarzy w commentThreads . Po załadowaniu wątków komentarzy prawdopodobnie zostaną one zaktualizowane, ponieważ wielu użytkowników dodaje do nich więcej komentarzy w czasie rzeczywistym, zmienia ich status i tak dalej. Wersja produkcyjna systemu komentowania musiałaby ustrukturyzować pamięć Recoil w taki sposób, abyśmy mogli nadal synchronizować ją z serwerem. Jeśli zdecydujesz się na użycie Recoil do zarządzania stanem, istnieje kilka przykładów w interfejsie API Atom Effects (eksperymentalne w momencie pisania tego artykułu), które robią coś podobnego.

Jeśli dokument jest naprawdę długi i wielu użytkowników współpracuje nad nim w wielu wątkach komentarzy, być może będziemy musieli zoptymalizować kod inicjujący, aby ładować wątki komentarzy tylko dla pierwszych kilku stron dokumentu. Alternatywnie możemy zdecydować się na załadowanie tylko lekkich metadanych wszystkich wątków komentarzy zamiast całej listy komentarzy, która prawdopodobnie jest cięższą częścią ładunku.

Przejdźmy teraz do wywoływania tej funkcji, gdy składnik Editor montuje się z dokumentem, aby stan Odrzutu był prawidłowo zainicjowany.

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

Używamy tego samego niestandardowego zaczepu — useAddCommentThreadToState , którego użyliśmy z implementacją przycisku komentarza paska narzędzi Toolbar, aby dodać nowe wątki komentarzy. Ponieważ popover działa, możemy kliknąć jeden z istniejących wcześniej wątków komentarzy w dokumencie i sprawdzić, czy pokazuje on dane, których użyliśmy do zainicjowania powyższego wątku.

Kliknięcie istniejącego wcześniej wątku komentarzy wczytuje okienko popover z ich komentarzami poprawnie.
Kliknięcie istniejącego wcześniej wątku komentarzy wczytuje okienko popover z ich komentarzami poprawnie. (duży podgląd)

Teraz, gdy nasz stan jest poprawnie zainicjowany, możemy rozpocząć implementację paska bocznego. Wszystkie nasze wątki komentarzy w interfejsie użytkownika są przechowywane w rodzinie atomów Recoil — commentThreadsState . Jak podkreślono wcześniej, sposób, w jaki przechodzimy przez wszystkie elementy w rodzinie atomów Recoil, polega na śledzeniu kluczy/identyfikatorów atomu w innym atomie. Robiliśmy to za pomocą commentThreadIDsState . Dodajmy składnik CommentSidebar , który iteruje przez zestaw identyfikatorów w tym atomie i renderuje składnik CommentThread dla każdego z nich.

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

Teraz zaimplementujemy komponent CommentThread , który nasłuchuje atomu Recoil w rodzinie odpowiadającej renderowanemu wątkowi komentarza. W ten sposób, gdy użytkownik dodaje więcej komentarzy do wątku w edytorze lub zmienia inne metadane, możemy zaktualizować pasek boczny, aby to odzwierciedlić.

Ponieważ pasek boczny może stać się naprawdę duży dla dokumentu z dużą ilością komentarzy, podczas renderowania paska bocznego ukrywamy wszystkie komentarze oprócz pierwszego. Użytkownik może użyć przycisku „Pokaż/Ukryj odpowiedzi”, aby pokazać/ukryć cały wątek komentarzy.

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

Ponownie wykorzystaliśmy komponent CommentRow z popovera, chociaż dodaliśmy obróbkę projektową za pomocą showConnector , która zasadniczo sprawia, że ​​wszystkie komentarze wyglądają na połączone z wątkiem na pasku bocznym.

Teraz renderujemy CommentSidebar w Editor i sprawdzamy, czy pokazuje on wszystkie wątki, które mamy w dokumencie, i prawidłowo aktualizujemy, gdy dodajemy nowe wątki lub nowe komentarze do istniejących wątków.

 # src/components/Editor.js return ( <> <Slate ... > ..... <div className={"sidebar-wrapper"}> <CommentsSidebar /> </div> </Slate> </> );
Pasek boczny komentarzy ze wszystkimi wątkami komentarzy w dokumencie.

Przechodzimy teraz do implementacji popularnej interakcji paska bocznego komentarzy, którą można znaleźć w edytorach:

Kliknięcie wątku komentarzy na pasku bocznym powinno wybrać/aktywować ten wątek komentarzy. Dodaliśmy również zróżnicowane traktowanie projektu, aby wyróżnić wątek komentarzy na pasku bocznym, jeśli jest aktywny w edytorze. Aby to zrobić, używamy atomu Recoil — activeCommentThreadIDAtom . Zaktualizujmy komponent CommentThread , aby to obsługiwać.

 # 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> );
Kliknięcie wątku komentarzy na pasku bocznym komentarzy zaznacza go w edytorze i podświetla jego zakres.

Jeśli przyjrzymy się uważnie, mamy błąd w naszej implementacji synchronizacji aktywnego wątku komentarzy z paskiem bocznym. Gdy klikamy różne wątki komentarzy na pasku bocznym, właściwy wątek komentarzy jest rzeczywiście podświetlony w edytorze. Jednak okno dialogowe komentarza nie jest w rzeczywistości przenoszone do zmienionego aktywnego wątku komentarzy. Pozostaje tam, gdzie został wyrenderowany po raz pierwszy. Jeśli spojrzymy na implementację komentarza Popover, renderuje się on względem pierwszego węzła tekstowego w zaznaczeniu edytora. W tym momencie implementacji jedynym sposobem wybrania wątku komentarza było kliknięcie węzła tekstowego, abyśmy mogli wygodnie polegać na wyborze edytora, ponieważ został on zaktualizowany przez Slate w wyniku zdarzenia kliknięcia. W powyższym zdarzeniu onClick nie aktualizujemy zaznaczenia, a jedynie aktualizujemy wartość atomu Recoil, powodując, że zaznaczenie Slate pozostaje niezmienione, a zatem okienko komentarza nie porusza się.

Rozwiązaniem tego problemu jest aktualizacja wyboru edytora wraz z aktualizacją atomu Recoil, gdy użytkownik kliknie wątek komentarza na pasku bocznym. Kroki to:

  1. Znajdź wszystkie węzły tekstowe, na których znajduje się ten wątek komentarzy, który ustawimy jako nowy aktywny wątek.
  2. Posortuj te węzły tekstowe w kolejności, w jakiej pojawiają się w dokumencie (używamy do tego Slate's Path.compare API).
  3. Oblicz zakres zaznaczenia, który rozciąga się od początku pierwszego węzła tekstowego do końca ostatniego węzła tekstowego.
  4. Ustaw zakres wyboru jako nowy wybór edytora (przy użyciu interfejsu API Transforms.select Slate).

Gdybyśmy tylko chcieli naprawić błąd, moglibyśmy po prostu znaleźć pierwszy węzeł tekstowy w kroku 1, który ma wątek komentarzy i ustawić go jako wybór redaktora. Jednak wybór całego zakresu komentarzy wydaje się bardziej przejrzystym podejściem, ponieważ tak naprawdę wybieramy wątek komentarzy.

Zaktualizujmy implementację wywołania zwrotnego onClick , aby uwzględnić powyższe kroki.

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

Uwaga : allTextNodePaths zawiera ścieżkę do wszystkich węzłów tekstowych. Używamy API Editor.point , aby uzyskać punkty początkowe i końcowe na tej ścieżce. Pierwszy artykuł omawia koncepcje Slate's Location. Są również dobrze udokumentowane w dokumentacji Slate.

Sprawdźmy, czy ta implementacja naprawi błąd, a okienko komentarza zostanie poprawnie przeniesione do aktywnego wątku komentarza. Tym razem testujemy również w przypadku nakładających się wątków, aby upewnić się, że tam się nie zerwie.

Kliknięcie wątku komentarzy na pasku bocznym komentarzy powoduje jego zaznaczenie i wczytanie okna wyskakującego wątku komentarzy.

Dzięki naprawie błędu włączyliśmy inną interakcję z paskiem bocznym, o której jeszcze nie rozmawialiśmy. Jeśli mamy naprawdę długi dokument, a użytkownik kliknie wątek komentarzy na pasku bocznym, który znajduje się poza obszarem wyświetlania, chcielibyśmy przewinąć do tej części dokumentu, aby użytkownik mógł skupić się na wątku komentarzy w edytorze. Ustawiając powyższą selekcję za pomocą API Slate, otrzymujemy to za darmo. Zobaczmy to w akcji poniżej.

Dokument jest prawidłowo przewijany do wątku komentarzy po kliknięciu na pasku bocznym komentarzy.

Tym otoczymy naszą implementację paska bocznego. Pod koniec artykułu wymieniamy kilka fajnych dodatków i ulepszeń, które możemy wprowadzić na pasku bocznym komentarzy, które pomogą ulepszyć środowisko komentowania i recenzowania w edytorze.

Rozwiązywanie i ponowne otwieranie komentarzy

W tej sekcji koncentrujemy się na umożliwieniu użytkownikom oznaczania wątków komentarzy jako „rozwiązanych” lub możliwości ponownego otwarcia ich w celu dyskusji, jeśli zajdzie taka potrzeba. Z perspektywy szczegółów implementacji są to metadane status w wątku komentarzy, które zmieniamy, gdy użytkownik wykonuje tę akcję. Z punktu widzenia użytkownika jest to bardzo przydatna funkcja, ponieważ daje mu możliwość potwierdzenia, że ​​dyskusja o czymś w dokumencie zakończyła się lub musi zostać ponownie otwarta, ponieważ pojawiły się pewne aktualizacje/nowe perspektywy i tak dalej.

Aby umożliwić przełączanie statusu, do CommentPopover przycisk, który pozwala użytkownikowi przełączać się między dwoma statusami: open i 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> ); }

Zanim to przetestujemy, nadajmy również pasek boczny komentarzy zróżnicowaną obsługę rozwiązanych komentarzy, aby użytkownik mógł łatwo wykryć, które wątki komentarzy są nierozwiązane lub otwarte i skoncentrować się na tych, jeśli chce.

 # 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> ); }
Komentarz Status wątku jest przełączany z wyskakującego okienka i odzwierciedlany na pasku bocznym.

Wniosek

W tym artykule zbudowaliśmy podstawową infrastrukturę interfejsu użytkownika dla systemu komentowania w edytorze tekstu sformatowanego. Zestaw funkcji, które tutaj dodajemy, stanowi podstawę do zbudowania bogatszego środowiska współpracy w edytorze, w którym współpracownicy mogliby dodawać adnotacje do części dokumentu i prowadzić rozmowy na ich temat. Dodanie paska bocznego komentarzy daje nam miejsce na włączenie w produkcie większej liczby funkcji konwersacyjnych lub opartych na recenzjach.

Poniżej przedstawiamy kilka funkcji, które edytor tekstu sformatowanego mógłby rozważyć jako uzupełnienie tego, co zbudowaliśmy w tym artykule:

  • Obsługa wzmianek @ , aby współpracownicy mogli oznaczać się nawzajem w komentarzach;
  • Obsługa typów mediów, takich jak obrazy i filmy, które mają być dodawane do wątków komentarzy;
  • Tryb sugestii na poziomie dokumentu, który umożliwia recenzentom wprowadzanie zmian w dokumencie, które pojawiają się jako sugestie zmian. Można odnieść się do tej funkcji w Dokumentach Google lub Śledzeniu zmian w programie Microsoft Word jako przykłady;
  • Ulepszenia paska bocznego umożliwiające wyszukiwanie wątków według słów kluczowych, filtrowanie wątków według statusu lub autorów komentarzy itd.