Adăugarea unui sistem de comentarii la un editor WYSIWYG

Publicat: 2022-03-10
Rezumat rapid ↬ În acest articol, vom reutiliza Editorul WYSIWYG de bază construit în primul articol pentru a construi un sistem de comentarii pentru un Editor WYSIWYG care le permite utilizatorilor să selecteze textul dintr-un document și să-și partajeze comentariile despre acesta. De asemenea, vom introduce RecoilJS pentru managementul statului în aplicația UI. (Codul pentru sistemul pe care îl construim aici este disponibil într-un depozit Github pentru referință.)

În ultimii ani, am văzut colaborarea pătrunzând într-o mulțime de fluxuri de lucru digitale și cazuri de utilizare în multe profesii. Doar în cadrul comunității de proiectare și inginerie software, vedem designeri colaborând la artefacte de design folosind instrumente precum Figma, echipe care efectuează Sprint și planificare de proiect folosind instrumente precum Mural și interviuri realizate folosind CoderPad. Toate aceste instrumente urmăresc în mod constant să reducă diferența dintre o experiență online și cea fizică de a executa aceste fluxuri de lucru și de a face experiența de colaborare cât mai bogată și fără întreruperi.

Pentru majoritatea instrumentelor de colaborare ca acestea, abilitatea de a împărtăși opinii între ele și de a avea discuții despre același conținut este o necesitate. Un sistem de comentarii care le permite colaboratorilor să adnoteze părți ale unui document și să poarte conversații despre acestea se află în centrul acestui concept. Împreună cu crearea unuia pentru text într-un editor WYSIWYG, articolul încearcă să implice cititorii în modul în care încercăm să cântărim argumentele pro și contra și să încercăm să găsim un echilibru între complexitatea aplicației și experiența utilizatorului atunci când vine vorba de crearea de funcții pentru editorii WYSIWYG sau Procesoare de text în general.

Reprezentarea comentariilor în structura documentului

Pentru a găsi o modalitate de a reprezenta comentarii în structura de date a unui document text îmbogățit, să ne uităm la câteva scenarii în care comentariile ar putea fi create în interiorul unui editor.

  • Comentarii create peste text care nu are stiluri pe el (scenariu de bază);
  • Comentarii create peste text care poate fi aldine/cursive/subliniate și așa mai departe;
  • Comentarii care se suprapun într-un fel (suprapunere parțială în cazul în care două comentarii împărtășesc doar câteva cuvinte sau complet conținute în cazul în care textul unui comentariu este complet conținut în textul altui comentariu);
  • Comentarii create peste text din interiorul unui link (special deoarece linkurile sunt ele însele noduri în structura documentului nostru);
  • Comentarii care se întind pe mai multe paragrafe (special pentru că paragrafele sunt noduri în structura documentului nostru, iar comentariile sunt aplicate nodurilor de text care sunt copii ale paragrafului).

Privind cazurile de utilizare de mai sus, se pare că comentariile în modul în care pot apărea într-un document cu text îmbogățit sunt foarte asemănătoare cu stilurile de caractere (aldine, cursive etc). Ele se pot suprapune între ele, pot trece peste text în alte tipuri de noduri, cum ar fi linkurile, și chiar se pot întinde pe mai multe noduri părinte, cum ar fi paragrafele.

Din acest motiv, folosim aceeași metodă pentru a reprezenta comentariile ca și pentru stilurile de caractere, adică „Marcurile” (cum sunt așa numite în terminologia SlateJS). Marcajele sunt doar proprietăți obișnuite pe noduri - specialitatea fiind că API-ul Slate în jurul mărcilor ( Editor.addMark și Editor.removeMark ) se ocupă de schimbarea ierarhiei nodurilor pe măsură ce mai multe mărci sunt aplicate la aceeași gamă de text. Acest lucru ne este extrem de util deoarece ne ocupăm de o mulțime de combinații diferite de comentarii care se suprapun.

Fire de comentarii ca mărci

Ori de câte ori un utilizator selectează o serie de text și încearcă să insereze un comentariu, din punct de vedere tehnic, începe un nou fir de comentarii pentru acel interval de text. Deoarece le-am permite să insereze un comentariu și răspunsuri ulterioare la acel comentariu, tratăm acest eveniment ca pe o nouă inserare a unui fir de comentarii în document.

Modul în care reprezentăm firele de comentarii ca semne este că fiecare fir de comentarii este reprezentat de un marcaj numit commentThread_threadID , unde threadID este un ID unic pe care îl atribuim fiecărui fir de comentarii. Deci, dacă același interval de text are două fire de comentarii peste el, ar avea două proprietăți setate la true - commentThread_thread1 și commentThread_thread2 . Aici firele de comentarii sunt foarte asemănătoare cu stilurile de caractere, deoarece dacă același text ar fi aldin și italic, ar avea ambele proprietăți setate la true - bold și italic .

Înainte de a ne aprofunda în configurarea efectivă a acestei structuri, merită să vedem cum se schimbă nodurile de text pe măsură ce li se aplică firele de comentarii. Modul în care funcționează (la fel cum se întâmplă cu orice marcaj) este că, atunci când o proprietate de marcare este setată pe textul selectat, API-ul Editor.addMark din Slate ar împărți nodurile de text, dacă este necesar, astfel încât în ​​structura rezultată, nodurile de text sunt configurate astfel încât fiecare nod de text să aibă exact aceeași valoare a mărcii.

Pentru a înțelege mai bine acest lucru, aruncați o privire la următoarele trei exemple care arată starea înainte și după a nodurilor de text odată ce un fir de comentarii este inserat pe textul selectat:

Ilustrație care arată modul în care nodul text este împărțit cu o inserare de bază a unui fir de comentarii
Un nod de text care se împarte în trei ca un semn al firului de comentarii este inserat în mijlocul textului. (Previzualizare mare)
Ilustrație care arată modul în care nodul text este împărțit în cazul unei suprapuneri parțiale a firelor de comentarii
Adăugarea unui fir de comentarii peste „text are” creează două noi noduri de text. (Previzualizare mare)
Ilustrație care arată modul în care nodul text este împărțit în cazul unei suprapuneri parțiale a firelor de comentarii cu linkuri
Adăugarea unui fir de comentarii peste „are link” desparte și nodul text din interiorul linkului. (Previzualizare mare)
Mai multe după săritură! Continuați să citiți mai jos ↓

Evidențierea textului comentat

Acum că știm cum vom reprezenta comentariile în structura documentului, să continuăm și să adăugăm câteva la documentul exemplu din primul articol și să configurați editorul pentru a le afișa efectiv așa cum sunt evidențiate. Deoarece vom avea o mulțime de funcții utilitare pentru a face față comentariilor în acest articol, creăm un modul EditorCommentUtils care va găzdui toate aceste utilitare. Pentru început, creăm o funcție care creează un marcaj pentru un ID de fir de comentarii dat. Apoi îl folosim pentru a insera câteva fire de comentarii în ExampleDocument .

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

Imaginea de mai jos subliniază cu roșu intervalele de text pe care le avem ca exemple de fire de comentarii adăugate în următorul fragment de cod. Rețineți că textul „Richard McClintock” are două fire de comentarii care se suprapun. Mai exact, acesta este cazul în care un fir de comentarii este complet conținut în altul.

Imagine care arată ce intervale de text din document vor fi comentate - unul dintre ele fiind complet cuprins în altul.
Intervalele de text care ar fi comentate subliniate cu roșu. (Previzualizare mare)
 # 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, }, ... ];

Ne concentrăm pe partea UI a lucrurilor unui sistem de comentarii în acest articol, așa că le atribuim ID-uri în documentul exemplu direct folosind pachetul npm uuid. Foarte probabil, într-o versiune de producție a unui editor, aceste ID-uri sunt create de un serviciu backend.

Acum ne concentrăm pe ajustarea editorului pentru a afișa aceste noduri de text așa cum sunt evidențiate. Pentru a face asta, atunci când redăm noduri de text, avem nevoie de o modalitate de a spune dacă are fire de comentarii pe el. Adăugăm un util getCommentThreadsOnTextNode pentru asta. Ne bazăm pe componenta StyledText pe care am creat-o în primul articol pentru a gestiona cazul în care ar putea încerca să redea un nod text cu comentarii. Deoarece avem mai multe funcționalități care urmează să fie adăugate la nodurile de text comentat mai târziu, creăm o componentă CommentedText care redă textul comentat. StyledText va verifica dacă nodul de text pe care încearcă să îl redeze are comentarii la el. Dacă o face, redă CommentedText . Utilizează un util getCommentThreadsOnTextNode pentru a deduce asta.

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

Primul articol a construit o componentă StyledText care redă noduri de text (manevrând stilurile de caractere și așa mai departe). Extindem acea componentă pentru a folosi utilitarul de mai sus și redăm o componentă CommentedText dacă nodul are comentarii la ea.

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

Mai jos este implementarea CommentedText care redă nodul text și atașează CSS-ul care îl arată ca evidențiat.

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

Cu toate codurile de mai sus reunite, acum vedem noduri de text cu fire de comentarii evidențiate în editor.

Nodurile de text comentate apar ca evidențiate după ce au fost inserate firele de comentarii
Nodurile de text comentate apar ca evidențiate după ce au fost inserate firele de comentarii. (Previzualizare mare)

Notă : în prezent, utilizatorii nu pot spune dacă un anumit text are comentarii care se suprapun. Întregul interval de text evidențiat arată ca un singur fir de comentarii. Abordăm acest lucru mai târziu în articol, unde introducem conceptul de fir de comentarii activ, care le permite utilizatorilor să selecteze un anumit fir de comentarii și să poată vedea gama acestuia în editor.

Stocare UI pentru comentarii

Înainte de a adăuga funcționalitatea care permite unui utilizator să insereze comentarii noi, mai întâi setăm o stare de interfață pentru a păstra firele de comentarii. În acest articol, folosim RecoilJS ca bibliotecă de management de stat pentru a stoca fire de comentarii, comentarii conținute în fire și alte metadate precum timpul de creare, starea, autorul comentariului etc. Să adăugăm Recoil la aplicația noastră:

 > yarn add recoil

Folosim atomi Reil pentru a stoca aceste două structuri de date. Dacă nu sunteți familiarizat cu Recoil, atomii sunt cei care dețin starea aplicației. Pentru diferite piese de stare de aplicare, de obicei ați dori să configurați diferiți atomi. Familia Atom este o colecție de atomi - se poate crede că este o Map de la o cheie unică care identifică atomul până la atomii înșiși. Merită să parcurgem conceptele de bază ale Recoil în acest moment și să ne familiarizăm cu ele.

Pentru cazul nostru de utilizare, stocăm firele de comentarii ca o familie Atom și apoi împachetăm aplicația noastră într-o componentă RecoilRoot . RecoilRoot este aplicat pentru a furniza contextul în care vor fi utilizate valorile atomilor. Creăm un modul separat CommentState care conține definițiile noastre de atom Reil, pe măsură ce adăugăm mai multe definiții de atom mai târziu în articol.

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

Merită menționat câteva lucruri despre aceste definiții de atom:

  • Fiecare familie de atom/atomi este identificată în mod unic printr-o key și poate fi configurată cu o valoare implicită.
  • Pe măsură ce construim mai departe în acest articol, vom avea nevoie de o modalitate de a repeta peste toate firele de comentarii, ceea ce ar însemna, practic, a avea nevoie de o modalitate de a itera peste familia de atomi commentThreadsState . La momentul scrierii acestui articol, modalitatea de a face asta cu Recoil este să configurați un alt atom care să dețină toate ID-urile familiei de atom. Facem asta cu commentThreadIDsState de mai sus. Ambii acești atomi ar trebui să fie menținuți sincronizați ori de câte ori adăugăm/ștergem fire de comentarii.

Adăugăm un înveliș RecoilRoot în componenta App noastre rădăcină, astfel încât să putem folosi acești atomi mai târziu. Documentația Recoil oferă, de asemenea, o componentă utilă Debugger pe care o luăm așa cum este și o introducem în editorul nostru. Această componentă va lăsa jurnalele console.debug în consola noastră Dev, deoarece atomii Recoil sunt actualizați în timp real.

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

De asemenea, trebuie să adăugăm cod care ne inițializează atomii cu firele de comentarii care există deja pe document (cele pe care le-am adăugat la documentul nostru exemplu în secțiunea anterioară, de exemplu). Facem asta la un moment ulterior, când construim bara laterală de comentarii, care trebuie să citească toate firele de comentarii dintr-un document.

În acest moment, încărcăm aplicația noastră, ne asigurăm că nu există erori care indică configurarea Recoil și mergem mai departe.

Adăugarea de comentarii noi

În această secțiune, adăugăm un buton la bara de instrumente care permite utilizatorului să adauge comentarii (adică să creeze un fir de comentarii nou) pentru intervalul de text selectat. Când utilizatorul selectează un interval de text și face clic pe acest buton, trebuie să facem următoarele:

  1. Atribuiți un ID unic noului fir de comentarii care este inserat.
  2. Adăugați un nou marcaj la structura documentului Slate cu ID-ul, astfel încât utilizatorul să vadă acel text evidențiat.
  3. Adăugați noul fir de comentarii la Atomii Recoil pe care i-am creat în secțiunea anterioară.

Să adăugăm o funcție util la EditorCommentUtils care face #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; }

Folosind conceptul de semne pentru a stoca fiecare fir de comentarii ca marca sa proprie, putem folosi pur și simplu API-ul Editor.addMark pentru a adăuga un nou fir de comentarii în intervalul de text selectat. Numai acest apel se ocupă de toate cazurile diferite de adăugare de comentarii - dintre care unele le-am descris în secțiunea anterioară - comentarii parțial suprapuse, comentarii în interiorul/legături suprapuse, comentarii peste text aldine/italic, comentarii care se întind pe paragrafe și așa mai departe. Acest apel API ajustează ierarhia nodurilor pentru a crea cât mai multe noduri text noi este necesar pentru a gestiona aceste cazuri.

addCommentThreadToState este o funcție de apel invers care se ocupă de pasul #3 — adăugarea noului fir de comentarii la Recoil atom. Îl implementăm în continuare ca un cârlig personalizat de apel invers, astfel încât să fie reutilizabil. Acest apel invers trebuie să adauge noul fir de comentarii la ambii atomi - commentThreadsState și commentThreadIDsState . Pentru a putea face acest lucru, folosim cârligul useRecoilCallback . Acest cârlig poate fi folosit pentru a construi un apel invers care primește câteva lucruri care pot fi folosite pentru a citi/seta datele atomului. Cea care ne interesează acum este funcția set care poate fi folosită pentru a actualiza o valoare a atomului ca 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); }, [] ); }

Primul apel de set adaugă noul ID la setul existent de ID-uri fir de comentarii și returnează noul Set (care devine noua valoare a atomului).

În cel de-al doilea apel, obținem atomul pentru ID-ul din familia atomului - commentThreadsState ca commentThreadsState(id) și apoi setăm threadData să fie valoarea sa. atomFamilyName(atomID) este modul în care Recoil ne permite să accesăm un atom din familia sa de atomi folosind cheia unică. Vorbind, am putea spune că, dacă commentThreadsState a fost o hartă javascript, acest apel este practic — commentThreadsState.set(id, threadData) .

Acum că avem toate configurațiile de cod pentru a gestiona inserarea unui nou fir de comentarii în document și atomii Recoil, să adăugăm un buton în bara noastră de instrumente și să-l conectăm cu apelul la aceste funcții.

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

Notă : Folosim onMouseDown și nu onClick , ceea ce ar fi făcut ca editorul să piardă focalizarea și selecția să devină null . Am discutat despre asta mai detaliat în secțiunea de inserare a linkurilor din primul articol.

În exemplul de mai jos, vedem inserarea în acțiune pentru un fir de comentarii simplu și un fir de comentarii suprapus cu link-uri. Observați cum primim actualizări de la Recoil Debugger care confirmă că starea noastră este actualizată corect. De asemenea, verificăm că sunt create noi noduri de text pe măsură ce firele de execuție sunt adăugate în document.

Inserarea unui fir de comentarii desparte nodul de text, făcând textul comentat propriul nod.
Mai multe noduri de text sunt create pe măsură ce adăugăm comentarii care se suprapun.

Comentarii suprapuse

Înainte de a continua cu adăugarea mai multor funcții la sistemul nostru de comentarii, trebuie să luăm câteva decizii cu privire la modul în care vom trata comentariile care se suprapun și diferitele combinații ale acestora în editor. Pentru a vedea de ce avem nevoie de asta, haideți să aruncăm o privire asupra modului în care funcționează un comentariu popover - o funcționalitate pe care o vom construi mai târziu în articol. Când un utilizator face clic pe un anumit text cu fire de comentarii pe acesta, „selectăm” un fir de comentarii și afișăm un popover în care utilizatorul poate adăuga comentarii la acel fir.

Când utilizatorul face clic pe un nod de text cu comentarii suprapuse, editorul trebuie să decidă ce fir de comentarii să selecteze.

După cum puteți vedea din videoclipul de mai sus, cuvântul „designeri” face acum parte din trei fire de comentarii. Deci avem două fire de comentarii care se suprapun între ele peste un cuvânt. Și ambele fire de comentarii (#1 și #2) sunt complet conținute într-un interval mai lung de text de fir de comentarii (#3). Acest lucru ridică câteva întrebări:

  1. Ce fir de comentarii ar trebui să selectăm și să arătăm când utilizatorul face clic pe cuvântul „designeri”?
  2. Pe baza modului în care decidem să abordăm întrebarea de mai sus, am avea vreodată un caz de suprapunere în care făcând clic pe orice cuvânt nu ar activa niciodată un anumit fir de comentarii și firul nu poate fi accesat deloc?

Acest lucru implică, în cazul comentariilor care se suprapun, cel mai important lucru de luat în considerare este: odată ce utilizatorul a inserat un fir de comentarii, ar exista o modalitate prin care acesta să poată selecta acel fir de comentarii în viitor făcând clic pe un text din interior aceasta? Dacă nu, probabil că nu vrem să le permitem să-l introducă în primul rând. Pentru a ne asigura că acest principiu este respectat de cele mai multe ori în editorul nostru, introducem două reguli privind suprapunerea comentariilor și le implementăm în editorul nostru.

Înainte de a defini aceste reguli, merită să menționăm că diferiți editori și procesoare de text au abordări diferite atunci când vine vorba de comentarii care se suprapun. Pentru a menține lucrurile simple, unii editori nu permit comentarii care se suprapun. În cazul nostru, încercăm să găsim o cale de mijloc, fără a permite cazuri prea complicate de suprapuneri, dar totuși permițând comentarii care se suprapun, astfel încât utilizatorii să poată avea o experiență mai bogată de colaborare și revizuire.

Regulă pentru cel mai scurt interval de comentarii

Această regulă ne ajută să răspundem la întrebarea #1 de mai sus cu privire la ce fir de comentarii să selectăm dacă un utilizator dă clic pe un nod de text care are mai multe fire de comentarii pe el. Regula este:

„Dacă utilizatorul face clic pe text care are mai multe fire de comentarii, găsim firul de comentarii din cel mai scurt interval de text și îl selectăm.”

În mod intuitiv, este logic să faceți acest lucru, astfel încât utilizatorul să aibă întotdeauna o modalitate de a ajunge la cel mai interior fir de comentarii care este complet conținut în alt fir de comentarii. Pentru alte condiții (suprapunere parțială sau fără suprapunere), ar trebui să existe un text care să aibă un singur fir de comentarii, așa că ar trebui să fie ușor de utilizat acel text pentru a selecta acel fir de comentarii. Este cazul unei suprapuneri pline (sau dense ) de fire și de ce avem nevoie de această regulă.

Să ne uităm la un caz destul de complex de suprapunere care ne permite să folosim această regulă și să „facem ceea ce trebuie” atunci când selectăm firul de comentarii.

Exemplu care arată trei fire de comentarii care se suprapun într-un mod în care singura modalitate de a selecta un fir de comentarii este utilizarea regulii celei mai mici.
Urmând regula cea mai scurtă fir de comentarii, făcând clic pe „B” selectează firul de comentarii #1. (Previzualizare mare)

În exemplul de mai sus, utilizatorul introduce următoarele fire de comentarii în această ordine:

  1. Subiectul de comentarii #1 peste caracterul „B” (lungime = 1).
  2. Subiectul de comentarii #2 peste „AB” (lungime = 2).
  3. Subiectul de comentarii #3 peste „BC” (lungime = 2).

La sfârșitul acestor inserții, din cauza modului în care Slate împarte nodurile de text cu semne, vom avea trei noduri de text - câte unul pentru fiecare caracter. Acum, dacă utilizatorul face clic pe „B”, urmând regula cea mai scurtă lungime, selectăm firul #1 deoarece este cel mai scurt dintre cele trei lungime. Dacă nu facem asta, nu am avea o modalitate de a selecta Filul de comentarii #1, deoarece are o lungime de doar un caracter și, de asemenea, face parte din alte două fire.

Deși această regulă face ușor să apară fire de comentarii de lungime mai scurtă, am putea întâlni situații în care firele de comentarii mai lungi devin inaccesibile, deoarece toate caracterele conținute în ele fac parte dintr-un alt fir de comentarii mai scurt. Să ne uităm la un exemplu pentru asta.

Să presupunem că avem 100 de caractere (să zicem, caracterul „A” tastat de 100 de ori) și utilizatorul introduce fire de comentarii în următoarea ordine:

  1. Subiectul de comentarii # 1 din intervalul 20,80
  2. Subiectul de comentarii # 2 din intervalul 0,50
  3. Subiectul de comentarii # 3 din intervalul 51.100
Exemplu care arată regula de cea mai scurtă lungime care face ca un fir de comentarii să nu fie selectabil, deoarece tot textul său este acoperit de fire de comentarii mai scurte.
Tot textul din Subiectul de comentarii #1 face, de asemenea, parte dintr-un alt fir de comentarii mai scurt decât #1. (Previzualizare mare)

După cum puteți vedea în exemplul de mai sus, dacă respectăm regula pe care tocmai am descris-o aici, făcând clic pe orice caracter între #20 și #80, ar selecta întotdeauna firele #2 sau #3, deoarece acestea sunt mai scurte decât #1 și, prin urmare, #1 nu ar fi selectabil. Un alt scenariu în care această regulă ne poate lăsa nehotărâți cu privire la ce fir de comentarii să selectăm este atunci când există mai multe fire de comentarii de aceeași lungime cea mai scurtă pe un nod de text.

Pentru o astfel de combinație de comentarii suprapuse și multe alte astfel de combinații la care s-ar putea gândi unde respectarea acestei reguli face ca un anumit fir de comentarii să fie inaccesibil făcând clic pe text, construim o bară laterală de comentarii mai târziu în acest articol, care oferă utilizatorului o vizualizare a tuturor firelor de comentarii. prezente în document, astfel încât să poată face clic pe acele fire din bara laterală și să le activeze în editor pentru a vedea intervalul comentariului. Am dori totuși să avem această regulă și să o implementăm, deoarece ar trebui să acopere o mulțime de scenarii de suprapunere, cu excepția exemplelor mai puțin probabile pe care le-am citat mai sus. Depunem tot acest efort în jurul acestei reguli în primul rând pentru că a vedea textul evidențiat în editor și a face clic pe el pentru a comenta este o modalitate mai intuitivă de a accesa un comentariu pe text decât simpla utilizare a unei liste de comentarii în bara laterală.

Regula de inserare

Regula este:

„Dacă utilizatorul text a selectat și încearcă să comenteze este deja acoperit în întregime de firele de comentarii, nu permiteți acea inserare.”

Acest lucru se datorează faptului că, dacă am permite această inserare, fiecare caracter din acel interval ar ajunge să aibă cel puțin două fire de comentarii (unul existent și altul cel nou pe care tocmai l-am permis), ceea ce ne face dificil să stabilim pe care să îl selectăm atunci când utilizatorul face clic pe acel caracter mai târziu.

Privind această regulă, cineva s-ar putea întreba de ce avem nevoie de ea în primul rând dacă avem deja Regulă pentru cel mai scurt interval de comentarii care ne permite să selectăm cel mai mic interval de text. De ce să nu permitem toate combinațiile de suprapuneri dacă putem folosi prima regulă pentru a deduce firul de comentarii potrivit de afișat? Ca unele dintre exemplele pe care le-am discutat mai devreme, prima regulă funcționează pentru o mulțime de scenarii, dar nu pentru toate. Cu regula de inserare, încercăm să minimizăm numărul de scenarii în care prima regulă nu ne poate ajuta și trebuie să ne întoarcem pe Sidebar ca singura modalitate prin care utilizatorul poate accesa acel fir de comentarii. Regula de inserare previne, de asemenea, suprapunerile exacte ale firelor de comentarii. Această regulă este implementată în mod obișnuit de o mulțime de editori populari.

Mai jos este un exemplu în care, dacă această regulă nu ar exista, am permite thread-ul de comentarii #3 și apoi, ca urmare a primei reguli, #3 nu ar fi accesibil deoarece ar deveni cel mai lung ca lungime.

Regula de inserare nu permite un al treilea fir de comentarii al cărui întreg interval de text este acoperit de alte două fire de comentarii.

Notă : A avea această regulă nu înseamnă că nu am fi conținut niciodată pe deplin comentariile care se suprapun. Lucrul complicat cu suprapunerea comentariilor este că, în ciuda regulilor, ordinea în care sunt inserate comentariile ne poate lăsa în continuare într-o stare în care nu am dorit să fie suprapunerea. Revenind la exemplul nostru de comentarii la cuvântul „designeri”. ' mai devreme, cel mai lung fir de comentarii inserat acolo a fost ultimul care a fost adăugat, astfel încât regula de inserare să o permită și ajungem la o situație complet limitată - #1 și #2 conținute în interiorul #3. Este în regulă, deoarece regula pentru cel mai scurt interval de comentarii ne-ar ajuta.

Vom implementa regula cel mai scurt interval de comentarii în secțiunea următoare în care implementăm selectarea firelor de comentarii. Deoarece acum avem un buton din bara de instrumente pentru a insera comentarii, putem implementa regula de inserare imediat verificând regula atunci când utilizatorul are un text selectat. Dacă regula nu este îndeplinită, vom dezactiva butonul Comentariu, astfel încât utilizatorii să nu poată insera un nou fir de comentarii pe textul selectat. Să începem!

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

Logica în această funcție este relativ simplă.

  • Dacă selectarea utilizatorului este o semnătură care clipește, nu permitem inserarea unui comentariu acolo, deoarece nu a fost selectat niciun text.
  • Dacă selecția utilizatorului nu este una restrânsă, găsim toate nodurile de text din selecție. Rețineți utilizarea mode: lowest în apelul către Editor.nodes (o funcție de ajutor de la SlateJS) care ne ajută să selectăm toate nodurile de text, deoarece nodurile de text sunt de fapt frunzele arborelui documentului.
  • Dacă există cel puțin un nod de text care nu are fire de comentarii, este posibil să permitem inserarea. Folosim getCommentThreadsOnTextNode pe care l-am scris mai devreme aici.

Acum folosim această funcție util în interiorul barei de instrumente pentru a controla starea dezactivată a butonului.

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

Să testăm implementarea regulii recreând exemplul nostru de mai sus.

Butonul de inserare din bara de instrumente a fost dezactivat deoarece utilizatorul încearcă să insereze un comentariu peste intervalul de text deja acoperit în totalitate de alte comentarii.

Un detaliu bun despre experiența utilizatorului de menționat aici este că, deși dezactivăm butonul din bara de instrumente dacă utilizatorul a selectat întreaga linie de text aici, acesta nu completează experiența pentru utilizator. Este posibil ca utilizatorul să nu înțeleagă pe deplin de ce butonul este dezactivat și este posibil să se confuze că nu răspundem la intenția sa de a introduce un fir de comentarii acolo. Abordăm acest lucru mai târziu, deoarece popover-urile de comentarii sunt create astfel încât, chiar dacă butonul din bara de instrumente este dezactivat, popover-ul pentru unul dintre firele de comentarii va apărea și utilizatorul ar putea în continuare să lase comentarii.

Să testăm și un caz în care există un nod de text necomentat și regula permite inserarea unui fir de comentarii nou.

Regula de inserare care permite inserarea firului de comentarii atunci când există un text necomentat în selecția utilizatorului.

Selectarea subiectelor de comentarii

În această secțiune, activăm caracteristica în care utilizatorul face clic pe un nod de text comentat și folosim Regulă pentru cel mai scurt interval de comentarii pentru a determina ce fir de comentarii trebuie selectat. Etapele procesului sunt:

  1. Găsiți cel mai scurt fir de comentarii pe nodul de text comentat pe care face clic utilizatorul.
  2. Setați acel fir de comentarii să fie firul de comentarii activ. (Creăm un nou atom Recul care va fi sursa adevărului pentru aceasta.)
  3. Nodurile de text comentate ar asculta starea Recul și, dacă fac parte din firul de comentarii activ, s-ar evidenția diferit. În acest fel, atunci când utilizatorul face clic pe firul de comentarii, întregul interval de text iese în evidență, deoarece toate nodurile de text își vor actualiza culoarea de evidențiere.

Pasul 1: implementarea regulii pentru cel mai scurt interval de comentarii

Să începem cu Pasul #1, care în esență implementează regula celui mai scurt interval de comentarii. Scopul aici este de a găsi firul de comentarii din cel mai scurt interval la nodul de text pe care a făcut clic utilizatorul. Pentru a găsi firul de cea mai scurtă lungime, trebuie să calculăm lungimea tuturor firelor de comentarii la acel nod de text. Pașii pentru a face acest lucru sunt:

  1. Obțineți toate firele de comentarii la nodul text în cauză.
  2. Traversați în orice direcție de la acel nod de text și continuați să actualizați lungimile firelor urmărite.
  3. Opriți traversarea într-o direcție când ajungem la una dintre marginile de mai jos:
    • Un nod text necomentat (care implică că am ajuns la cea mai îndepărtată margine de început/sfârșit a tuturor firelor de comentarii pe care le urmărim).
    • Un nod text în care toate firele de comentarii pe care le urmărim au atins un avantaj (început/sfârșit).
    • Nu mai există noduri text de parcurs în acea direcție (ceea ce înseamnă că am ajuns fie la începutul sau la sfârșitul documentului, fie la un nod non-text).

Deoarece traversările în direcția înainte și înapoi sunt aceleași din punct de vedere funcțional, vom scrie o funcție de ajutor updateCommentThreadLengthMap care, practic, ia un iterator de nod text. Acesta va continua apelarea iteratorului și va continua să actualizeze lungimile firelor de urmărire. Vom apela această funcție de două ori - o dată pentru direcția înainte și o dată pentru direcția înapoi. 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'. (Previzualizare mare)

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

Această componentă folosește useRecoilState care permite unei componente să se aboneze și, de asemenea, să poată seta valoarea atomului Recoil. Avem nevoie de abonat să știe dacă acest nod de text face parte din firul de comentarii activ, astfel încât să se poată stila diferit. Consultați captura de ecran de mai jos, unde firul de comentarii din mijloc este activ și îi putem vedea clar intervalul.

Exemplu care arată modul în care nodurile de text din firul de comentarii selectat ies.
Nodurile de text din firul de comentarii selectat se schimbă în stil și ies. (Previzualizare mare)

Acum că avem tot codul pentru a face selecția firelor de comentarii să funcționeze, să-l vedem în acțiune. Pentru a testa bine codul nostru de traversare, testăm câteva cazuri simple de suprapunere și unele cazuri marginale, cum ar fi:

  • Făcând clic pe un nod de text comentat la începutul/sfârșitul editorului.
  • Făcând clic pe un nod de text comentat cu fire de comentarii care se întind pe mai multe paragrafe.
  • Făcând clic pe un nod de text comentat chiar înaintea unui nod de imagine.
  • Făcând clic pe un nod de text comentat care se suprapun pe linkuri.
Selectarea celui mai scurt fir de comentarii pentru diferite combinații de suprapunere.

Deoarece acum avem un atom Recoil pentru a urmări ID-ul firului de comentarii activ, un mic detaliu de care trebuie să avem grijă este setarea firului de comentarii nou creat să fie cel activ atunci când utilizatorul folosește butonul din bara de instrumente pentru a insera un nou fir de comentarii. Acest lucru ne permite, în secțiunea următoare, să afișăm popoverul firului de comentarii imediat după inserare, astfel încât utilizatorul să poată începe să adauge comentarii imediat.

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

Notă: Utilizarea useSetRecoilState aici (un cârlig Recoil care expune un setter pentru atom, dar nu subscrie componenta la valoarea sa) este ceea ce avem nevoie pentru bara de instrumente în acest caz.

Adăugarea popover-urilor de subiecte de comentarii

În această secțiune, construim un popover de comentarii care folosește conceptul de fir de comentarii selectat/activ și afișează un popover care permite utilizatorului să adauge comentarii la acel fir de comentarii. Înainte de a-l construi, să aruncăm o privire rapidă asupra modului în care funcționează.

Previzualizare a funcției Popover de comentarii.

Când încercăm să redăm un comentariu Popover aproape de firul de comentarii care este activ, ne confruntăm cu unele dintre problemele pe care le-am făcut în primul articol cu ​​un meniu Editor de linkuri. În acest moment, este încurajat să citiți secțiunea din primul articol care creează un Editor de linkuri și problemele de selecție cu care ne confruntăm cu acesta.

Să lucrăm mai întâi la redarea unei componente popover goale în locul potrivit, pe baza firului de comentarii activ. Modul în care ar funcționa popover-ul este:

  • Popoverul firului de comentarii este redat numai atunci când există un ID activ al firului de comentarii. Pentru a obține aceste informații, ascultăm atomul Recoil pe care l-am creat în secțiunea anterioară.
  • Când se redă, găsim nodul de text la selecția editorului și redăm popover-ul aproape de acesta.
  • Când utilizatorul face clic oriunde în afara popover-ului, setăm firul de comentarii activ să fie null , dezactivând astfel firul de comentarii și, de asemenea, făcând popover-ul să dispară.
 # 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> ); }

Câteva lucruri care ar trebui menționate pentru această implementare a componentei popover:

  • Este nevoie de editorOffsets și de selection din componenta Editor unde ar fi redat. editorOffsets -urile sunt limitele componentei Editor, astfel încât să putem calcula poziția popover-ului și selection ar putea fi o selecție curentă sau anterioară, în cazul în care utilizatorul a folosit un buton din bara de instrumente care determină selection să devină null . Secțiunea din Editorul de linkuri din primul articol legat mai sus le parcurge în detaliu.
  • Deoarece LinkEditor din primul articol și CommentThreadPopover de aici, ambele redă un popover în jurul unui nod text, am mutat acea logică comună într-o componentă NodePopover care se ocupă de redarea componentei aliniate la nodul text în cauză. Detaliile sale de implementare sunt ceea ce avea componenta LinkEditor în primul articol.
  • NodePopover ia o metodă onClickOutside ca prop, care este numită dacă utilizatorul face clic undeva în afara popover-ului. Implementăm acest lucru prin atașarea unui ascultător de evenimente mousedown la document - așa cum este explicat în detaliu în acest articol Smashing despre această idee.
  • getFirstTextNodeAtSelection primește primul nod text din selecția utilizatorului, pe care îl folosim pentru a afișa popover-ul. Implementarea acestei funcții folosește ajutoarele lui Slate pentru a găsi nodul text.
 # 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; }

Să implementăm apel invers onClickOutside care ar trebui să șterge firul de comentarii activ. Cu toate acestea, trebuie să luăm în considerare scenariul în care popover-ul firului de comentarii este deschis și un anumit thread este activ și utilizatorul face clic pe un alt fir de comentarii. În acest caz, nu dorim ca onClickOutside să reseta firul de comentarii activ, deoarece evenimentul de clic pe cealaltă componentă CommentedText ar trebui să seteze celălalt fir de comentarii să devină activ. Nu vrem să interferăm cu asta în popover.

Modul în care facem asta este că găsim nodul Slate cel mai aproape de nodul DOM unde a avut loc evenimentul clic. Dacă acel nod Slate este un nod text și are comentarii despre el, omitem resetarea firului de comentarii activ Recoil atom. Să-l punem în aplicare!

 # 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 are o metodă de ajutor pentru toSlateNode care returnează nodul Slate care se mapează la un nod DOM sau cel mai apropiat strămoș al său dacă el însuși nu este un Nod Slate. Implementarea curentă a acestui ajutor aruncă o eroare dacă nu poate găsi un nod Slate în loc să returneze null . Ne ocupăm de asta mai sus verificând noi înșine cazul null , care este un scenariu foarte probabil dacă utilizatorul face clic undeva în afara editorului, unde nodurile Slate nu există.

Acum putem actualiza componenta Editor pentru a asculta activeCommentThreadIDAtom și a reda popover-ul numai atunci când un fir de comentarii este activ.

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

Să verificăm că popover-ul se încarcă în locul potrivit pentru firul de comentarii potrivit și șterge firul de comentarii activ atunci când facem clic afară.

Popoverul firului de comentarii se încarcă corect pentru firul de comentarii selectat.

Acum trecem la a permite utilizatorilor să adauge comentarii la un fir de comentarii și să vedem toate comentariile acelui fir în popover. Vom folosi familia de atomi Recoil — commentThreadsState pe care am creat-o mai devreme în articol pentru aceasta.

Comentariile dintr-un fir de comentarii sunt stocate în matricea de comments . Pentru a permite adăugarea unui nou comentariu, redăm o intrare de formular care permite utilizatorului să introducă un nou comentariu. În timp ce utilizatorul scrie comentariul, menținem că într-o variabilă de stat locală - commentText . Făcând clic pe butonul, anexăm textul comentariului ca noul comentariu la matricea de 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> ); }

Notă : Deși redăm o intrare pentru ca utilizatorul să introducă un comentariu, nu o lăsăm neapărat să se concentreze atunci când popover-ul se montează. Aceasta este o decizie privind Experiența utilizatorului care poate varia de la un editor la altul. Unii editori nu permit utilizatorilor să editeze textul în timp ce popover-ul firului de comentarii este deschis. În cazul nostru, dorim să putem permite utilizatorului să editeze textul comentat atunci când dă clic pe el.

Merită să știm cum accesăm datele specifice firului de comentarii din familia de atom Recoil - prin apelarea atomului ca - commentThreadsState(threadID) . Acest lucru ne oferă valoarea atomului și un setter pentru a actualiza doar acel atom din familie. Dacă comentariile sunt încărcate lene de pe server, Recoil oferă și un cârlig useRecoilStateLoadable care returnează un obiect Loadable care ne spune despre starea de încărcare a datelor atomului. Dacă încă se încarcă, putem alege să afișăm o stare de încărcare în popover.

Acum, accesăm threadData și redăm lista de comentarii. Fiecare comentariu este redat de componenta CommentRow .

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

Mai jos este implementarea CommentRow care redă textul comentariului și alte metadate, cum ar fi numele autorului și momentul creării. Folosim modulul date-fns pentru a arăta ora de creare formatată.

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

Am extras aceasta pentru a fi propria sa componentă, pe măsură ce o reutilizam mai târziu, când implementăm bara laterală de comentarii.

În acest moment, Comment Popover are tot codul de care are nevoie pentru a permite inserarea de noi comentarii și actualizarea stării Recoil pentru aceeași. Să verificăm asta. Pe consola browserului, folosind Recoil Debug Observer pe care l-am adăugat mai devreme, putem verifica dacă atomul Recoil pentru firul de comentarii este actualizat corect pe măsură ce adăugăm noi comentarii în fir.

Popoverul pentru firul de comentarii se încarcă la selectarea unui fir de comentarii.

Adăugarea unei bare laterale de comentarii

Mai devreme în articol, am spus de ce, ocazional, se poate întâmpla ca regulile pe care le-am implementat să împiedice un anumit fir de comentarii să nu fie accesibil făcând clic numai pe nodul(ele) textului său - în funcție de combinația de suprapunere. Pentru astfel de cazuri, avem nevoie de o bară laterală de comentarii care să permită utilizatorului să ajungă la toate firele de comentarii din document.

O bară laterală de comentarii este, de asemenea, un plus bun care se integrează într-un flux de lucru de sugestii și recenzii, în care un recenzent poate naviga prin toate firele de comentarii unul după altul și poate lăsa comentarii/răspunsuri oriunde simte nevoia. Înainte de a începe implementarea barei laterale, există o sarcină neterminată de care ne ocupăm mai jos.

Inițializarea stării de recul a firelor de comentarii

Când documentul este încărcat în editor, trebuie să scanăm documentul pentru a găsi toate firele de comentarii și să le adăugăm la atomii Recoil pe care i-am creat mai sus ca parte a procesului de inițializare. Să scriem o funcție de utilitate în EditorCommentUtils care scanează nodurile de text, găsește toate firele de comentarii și le adaugă la atomul 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", }) ); }

Sincronizarea cu stocarea backend și luarea în considerare a performanței

Pentru contextul articolului, deoarece ne concentrăm exclusiv pe implementarea UI, le inițializam doar cu câteva date care ne permit să confirmăm că codul de inițializare funcționează.

În utilizarea în lumea reală a sistemului de comentarii, firele de comentarii pot fi stocate separat de conținutul documentului în sine. Într-un astfel de caz, codul de mai sus ar trebui actualizat pentru a efectua un apel API care preia toate metadatele și comentariile la toate ID-urile firelor de comentarii din commentThreads . Odată ce firele de comentarii sunt încărcate, este probabil să fie actualizate, deoarece mai mulți utilizatori le adaugă mai multe comentarii în timp real, își schimbă starea și așa mai departe. Versiunea de producție a sistemului de comentarii ar trebui să structureze stocarea Recoil într-un mod în care să putem continua să o sincronizăm cu serverul. Dacă alegeți să utilizați Recoil pentru managementul de stat, există câteva exemple pe API-ul Atom Effects (experimental la momentul scrierii acestui articol) care fac ceva similar.

Dacă un document este foarte lung și are o mulțime de utilizatori care colaborează la el pe o mulțime de fire de comentarii, ar putea fi necesar să optimizăm codul de inițializare pentru a încărca fire de comentarii doar pentru primele câteva pagini ale documentului. Alternativ, putem alege să încărcăm doar metadatele ușoare ale tuturor firelor de comentarii în loc de întreaga listă de comentarii, care este probabil partea mai grea a sarcinii utile.

Acum, să trecem la apelarea acestei funcții atunci când componenta Editor se montează cu documentul, astfel încât starea Recoil să fie inițializată corect.

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

Folosim același cârlig personalizat - useAddCommentThreadToState pe care l-am folosit cu implementarea Butonului de comentarii din Bara de instrumente pentru a adăuga fire de comentarii noi. Deoarece avem popover-ul în funcțiune, putem face clic pe unul dintre firele de comentarii preexistente din document și putem verifica dacă arată datele pe care le-am folosit pentru a inițializa firul de mai sus.

Făcând clic pe un fir de comentarii preexistent, se încarcă corect popover-ul cu comentariile lor.
Făcând clic pe un fir de comentarii preexistent, se încarcă corect popover-ul cu comentariile lor. (Previzualizare mare)

Acum că starea noastră este inițializată corect, putem începe implementarea barei laterale. Toate firele noastre de comentarii din interfața de utilizare sunt stocate în familia de atom Recoil — commentThreadsState . După cum sa evidențiat mai devreme, modul în care repetăm ​​toate elementele dintr-o familie de atomi Recoil este urmărirea cheilor/id-urilor atomului dintr-un alt atom. Am făcut asta cu commentThreadIDsState . Să adăugăm componenta CommentSidebar care iterează prin setul de id-uri din acest atom și redă o componentă CommentThread pentru fiecare.

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

Acum, implementăm componenta CommentThread care ascultă atomul Recoil din familia corespunzătoare firului de comentarii pe care îl redă. În acest fel, pe măsură ce utilizatorul adaugă mai multe comentarii pe fir în editor sau modifică orice alte metadate, putem actualiza bara laterală pentru a reflecta acest lucru.

Deoarece bara laterală ar putea deveni foarte mare pentru un document cu multe comentarii, ascundem toate comentariile, cu excepția primei, atunci când redăm bara laterală. Utilizatorul poate folosi butonul „Afișare/Ascunde răspunsurile” pentru a afișa/ascunde întregul fir de comentarii.

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

Am refolosit componenta CommentRow din popover, deși am adăugat un tratament de design folosind showConnector prop care practic face ca toate comentariile să pară conectate cu un fir din bara laterală.

Acum, redăm CommentSidebar în Editor și verificăm că arată toate firele pe care le avem în document și se actualizează corect pe măsură ce adăugăm fire noi sau comentarii noi la firele existente.

 # src/components/Editor.js return ( <> <Slate ... > ..... <div className={"sidebar-wrapper"}> <CommentsSidebar /> </div> </Slate> </> );
Comentarii Bara laterală cu toate firele de comentarii din document.

Acum trecem la implementarea unei interacțiuni populare din bara laterală de comentarii, găsită în editori:

Făcând clic pe un fir de comentarii din bara laterală ar trebui să selecteze/activa acel fir de comentarii. Adăugăm, de asemenea, un tratament de design diferențial pentru a evidenția un fir de comentarii în bara laterală dacă este activ în editor. Pentru a putea face acest lucru, folosim atomul Recoil — activeCommentThreadIDAtom . Să actualizăm componenta CommentThread pentru a sprijini acest lucru.

 # 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> );
Făcând clic pe un fir de comentarii din bara laterală Comentarii, îl selectează în editor și îi evidențiază intervalul.

Dacă ne uităm cu atenție, avem o eroare în implementarea noastră de sincronizare a firului de comentarii activ cu bara laterală. Pe măsură ce facem clic pe diferite fire de comentarii din bara laterală, firul de comentarii corect este într-adevăr evidențiat în editor. Cu toate acestea, Comentariul Popover nu se mută de fapt în firul de comentarii activ modificat. Rămâne acolo unde a fost redat prima dată. Dacă ne uităm la implementarea Comment Popover, acesta se redă pe primul nod de text din selecția editorului. În acel moment al implementării, singura modalitate de a selecta un fir de comentarii era să facem clic pe un nod de text, astfel încât să ne putem baza în mod convenabil pe selecția editorului, deoarece acesta a fost actualizat de Slate ca urmare a evenimentului de clic. În evenimentul onClick de mai sus, nu actualizăm selecția, ci doar actualizăm valoarea atomului Recoil, ceea ce face ca selecția lui Slate să rămână neschimbată și, prin urmare, Comentariul Popover nu se mișcă.

O soluție la această problemă este actualizarea selecției editorului împreună cu actualizarea atomului Recoil atunci când utilizatorul face clic pe firul de comentarii din bara laterală. Pașii fac acest lucru sunt:

  1. Găsiți toate nodurile de text care au acest fir de comentarii pe ele pe care le vom seta ca noul fir activ.
  2. Sortați aceste noduri de text în ordinea în care apar în document (folosim API-ul Slate Path.compare pentru aceasta).
  3. Calculați un interval de selecție care se întinde de la începutul primului nod text până la sfârșitul ultimului nod text.
  4. Setați intervalul de selecție să fie noua selecție a editorului (folosind API-ul Transforms.select de la Slate).

Dacă am vrut doar să remediem eroarea, am putea găsi primul nod de text în Pasul #1 care are firul de comentarii și să setăm ca acesta să fie selecția editorului. Cu toate acestea, se pare o abordare mai curată de a selecta întreaga gamă de comentarii, deoarece chiar selectăm firul de comentarii.

Să actualizăm implementarea onClick callback pentru a include pașii de mai sus.

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

Notă : allTextNodePaths conține calea către toate nodurile text. Folosim API-ul Editor.point pentru a obține punctele de început și de sfârșit la acea cale. Primul articol trece prin conceptele de locație ale lui Slate. De asemenea, sunt bine documentate în documentația lui Slate.

Să verificăm că această implementare remediază eroarea, iar popover-ul de comentarii se mută corect în firul de comentarii activ. De data aceasta, testăm și cu un caz de fire suprapuse pentru a ne asigura că nu se rupe acolo.

Făcând clic pe un fir de comentarii din bara laterală de comentarii, îl selectează și se încarcă popoverul firului de comentarii.

Odată cu remedierea erorilor, am activat o altă interacțiune cu bara laterală despre care nu am discutat încă. Dacă avem un document foarte lung și utilizatorul face clic pe un fir de comentarii din bara laterală care se află în afara ferestrei de vizualizare, am dori să defilăm la acea parte a documentului, astfel încât utilizatorul să se poată concentra pe firul de comentarii din editor. Setând selecția de mai sus folosind API-ul Slate, o obținem gratuit. Să-l vedem în acțiune mai jos.

Documentul defilează corect la firul de comentarii atunci când se dă clic pe în bara laterală de comentarii.

Cu asta, încheiem implementarea barei laterale. Spre sfârșitul articolului, enumeram câteva completări și îmbunătățiri frumoase pe care le putem face barei laterale de comentarii, care ajută la creșterea experienței de comentarii și recenzii în editor.

Rezolvarea și redeschiderea comentariilor

În această secțiune, ne concentrăm pe a permite utilizatorilor să marcheze firele de comentarii ca „Rezolvate” sau să le poată redeschide pentru discuții, dacă este necesar. Din perspectiva detaliilor implementării, acestea sunt metadatele de status dintr-un fir de comentarii pe care le modificăm pe măsură ce utilizatorul efectuează această acțiune. Din perspectiva unui utilizator, aceasta este o caracteristică foarte utilă, deoarece îi oferă o modalitate de a afirma că discuția despre ceva de pe document s-a încheiat sau trebuie redeschisă pentru că există unele actualizări/perspective noi și așa mai departe.

Pentru a activa comutarea stării, adăugăm un buton la CommentPopover care permite utilizatorului să comute între cele două stări: 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> ); }

Înainte de a testa acest lucru, să dăm și Barei laterale comentarii un tratament de design diferențial pentru comentariile rezolvate, astfel încât utilizatorul să poată detecta cu ușurință care fire de comentarii sunt nerezolvate sau deschise și să se concentreze pe acelea dacă dorește.

 # 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> ); }
Starea firului de comentarii este comutată din popover și reflectată în bara laterală.

Concluzie

În acest articol, am construit infrastructura de bază a UI pentru un sistem de comentarii pe un editor de text îmbogățit. Setul de funcționalități pe care le adăugăm aici acționează ca o bază pentru a construi o experiență de colaborare mai bogată pe un editor în care colaboratorii ar putea adnota părți ale documentului și pot avea conversații despre acestea. Adăugarea unei bare laterale de comentarii ne oferă un spațiu pentru a avea mai multe funcționalități conversaționale sau bazate pe recenzii pentru a fi activate pe produs.

În acest sens, iată câteva dintre funcțiile pe care un editor de text îmbogățit le-ar putea lua în considerare adăugarea pe lângă ceea ce am construit în acest articol:

  • Suport pentru mențiunile @ , astfel încât colaboratorii să se poată eticheta unul pe altul în comentarii;
  • Suport pentru tipurile media, cum ar fi imagini și videoclipuri, care urmează să fie adăugate în firele de comentarii;
  • Modul de sugestie la nivel de document, care permite recenzenților să efectueze modificări documentului care apar ca sugestii de modificări. S-ar putea referi la această caracteristică în Google Docs sau Urmărirea modificărilor în Microsoft Word ca exemple;
  • Îmbunătățiri ale barei laterale pentru a căuta conversații după cuvânt cheie, a filtra firele după stare sau autor(i) de comentariu și așa mai departe.