Aggiunta di un sistema di commenti a un editor WYSIWYG
Pubblicato: 2022-03-10Negli ultimi anni, abbiamo visto la collaborazione penetrare in molti flussi di lavoro digitali e casi d'uso in molte professioni. Proprio all'interno della comunità di progettazione e ingegneria del software, vediamo designer collaborare su artefatti di progettazione utilizzando strumenti come Figma, team che eseguono Sprint e pianificazione del progetto utilizzando strumenti come Mural e interviste condotte utilizzando CoderPad. Tutti questi strumenti mirano costantemente a colmare il divario tra l'esperienza online e quella fisica nell'esecuzione di questi flussi di lavoro e nel rendere l'esperienza di collaborazione il più ricca e trasparente possibile.
Per la maggior parte degli strumenti di collaborazione come questi, la capacità di condividere opinioni e discutere sugli stessi contenuti è un must. Un sistema di commenti che consente ai collaboratori di annotare parti di un documento e di avere conversazioni su di esse è al centro di questo concetto. Oltre a crearne uno per il testo in un editor WYSIWYG, l'articolo cerca di coinvolgere i lettori nel modo in cui cerchiamo di valutare i pro e i contro e cerchiamo di trovare un equilibrio tra complessità dell'applicazione ed esperienza utente quando si tratta di creare funzionalità per editor WYSIWYG o Elaboratori di testi in generale.
Rappresentazione dei commenti nella struttura del documento
Per trovare un modo per rappresentare i commenti nella struttura dei dati di un documento RTF, esaminiamo alcuni scenari in cui è possibile creare commenti all'interno di un editor.
- Commenti creati su testo senza stili (scenario di base);
- Commenti creati su testo che può essere grassetto/corsivo/sottolineato e così via;
- Commenti che si sovrappongono in qualche modo (sovrapposizione parziale in cui due commenti condividono solo poche parole o completamente contenuti in cui il testo di un commento è completamente contenuto nel testo di un altro commento);
- Commenti creati sul testo all'interno di un collegamento (speciale perché i collegamenti sono nodi stessi nella nostra struttura del documento);
- Commenti che si estendono su più paragrafi (specialmente perché i paragrafi sono nodi nella nostra struttura del documento e i commenti vengono applicati ai nodi di testo che sono figli del paragrafo).
Osservando i casi d'uso di cui sopra, sembra che i commenti nel modo in cui possono apparire in un documento RTF siano molto simili agli stili dei caratteri (grassetto, corsivo, ecc.). Possono sovrapporsi tra loro, passare sopra il testo in altri tipi di nodi come i collegamenti e persino estendersi su più nodi principali come i paragrafi.
Per questo motivo, per rappresentare i commenti utilizziamo lo stesso metodo che utilizziamo per gli stili di carattere, ovvero i “Segni” (come vengono chiamati nella terminologia di SlateJS). I segni sono solo proprietà regolari sui nodi, la particolarità è che l'API di Slate attorno ai segni ( Editor.addMark
e Editor.removeMark
) gestisce la modifica della gerarchia dei nodi quando più segni vengono applicati allo stesso intervallo di testo. Questo è estremamente utile per noi poiché trattiamo molte diverse combinazioni di commenti sovrapposti.
Commenta le discussioni come segni
Ogni volta che un utente seleziona un intervallo di testo e tenta di inserire un commento, tecnicamente avvia un nuovo thread di commenti per quell'intervallo di testo. Poiché consentiremmo loro di inserire un commento e successivamente di rispondere a tale commento, trattiamo questo evento come un nuovo inserimento di thread di commenti nel documento.
Il modo in cui rappresentiamo i thread di commenti come contrassegni è che ogni thread di commenti è rappresentato da un contrassegno denominato commentThread_threadID
dove threadID
è un ID univoco che assegniamo a ciascun thread di commenti. Quindi, se lo stesso intervallo di testo ha due thread di commenti su di esso, avrebbe due proprietà impostate su true
— commentThread_thread1
e commentThread_thread2
. È qui che i thread dei commenti sono molto simili agli stili di carattere poiché se lo stesso testo fosse in grassetto e corsivo, entrambe le proprietà sarebbero impostate su true
: bold
e italic
.
Prima di addentrarci nell'impostazione effettiva di questa struttura, vale la pena osservare come cambiano i nodi di testo man mano che i thread di commento vengono applicati ad essi. Il modo in cui funziona (come accade con qualsiasi segno) è che quando una proprietà segno viene impostata sul testo selezionato, l'API Editor.addMark di Slate dividerebbe i nodi di testo se necessario in modo tale che nella struttura risultante, i nodi di testo sono impostati in modo che ogni nodo di testo abbia esattamente lo stesso valore del segno.
Per capirlo meglio, dai un'occhiata ai seguenti tre esempi che mostrano lo stato prima e dopo dei nodi di testo una volta inserito un thread di commenti sul testo selezionato:
Evidenziazione del testo commentato
Ora che sappiamo come rappresenteremo i commenti nella struttura del documento, andiamo avanti e aggiungiamo alcuni al documento di esempio del primo articolo e configuriamo l'editor per mostrarli effettivamente come evidenziati. Poiché in questo articolo avremo molte funzioni di utilità per gestire i commenti, creiamo un modulo EditorCommentUtils
che ospiterà tutte queste utilità. Per cominciare, creiamo una funzione che crea un contrassegno per un determinato ID thread di commento. Lo usiamo quindi per inserire alcuni thread di commenti nel nostro ExampleDocument
.
# src/utils/EditorCommentUtils.js const COMMENT_THREAD_PREFIX = "commentThread_"; export function getMarkForCommentThreadID(threadID) { return `${COMMENT_THREAD_PREFIX}${threadID}`; }
Sotto l'immagine sottolinea in rosso gli intervalli di testo che abbiamo come thread di commento di esempio aggiunti nel prossimo frammento di codice. Nota che il testo "Richard McClintock" ha due thread di commenti che si sovrappongono. In particolare, si tratta di un thread di commenti completamente contenuto all'interno di un altro.
# 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, }, ... ];
In questo articolo ci concentriamo sul lato dell'interfaccia utente di un sistema di commenti, quindi assegniamo loro ID nel documento di esempio utilizzando il pacchetto npm uuid. È molto probabile che in una versione di produzione di un editor, questi ID siano creati da un servizio di back-end.
Ora ci concentriamo sulla modifica dell'editor per mostrare questi nodi di testo come evidenziati. Per fare ciò, durante il rendering di nodi di testo, abbiamo bisogno di un modo per sapere se ha thread di commenti su di esso. Aggiungiamo getCommentThreadsOnTextNode
per questo. Ci basiamo sul componente StyledText
che abbiamo creato nel primo articolo per gestire il caso in cui potrebbe tentare di eseguire il rendering di un nodo di testo con commenti. Dal momento che abbiamo altre funzionalità in arrivo che verrebbero aggiunte ai nodi di testo commentato in seguito, creiamo un componente CommentedText
che esegue il rendering del testo commentato. StyledText
verificherà se il nodo di testo che sta tentando di eseguire il rendering ha dei commenti su di esso. Se lo fa, rende CommentedText
. Utilizza getCommentThreadsOnTextNode
per dedurlo.
# 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; }
Il primo articolo ha creato un componente StyledText
che esegue il rendering dei nodi di testo (gestione degli stili di carattere e così via). Estendiamo quel componente per utilizzare l'utility sopra e renderizziamo un componente CommentedText
se il nodo ha commenti su di esso.
# 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>; }
Di seguito è riportata l'implementazione di CommentedText
che esegue il rendering del nodo di testo e allega il CSS che lo mostra come evidenziato.
# 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; }
Con tutto il codice precedente che si unisce, ora vediamo nodi di testo con thread di commenti evidenziati nell'editor.
Nota : gli utenti attualmente non sono in grado di dire se un determinato testo ha commenti sovrapposti su di esso. L'intero intervallo di testo evidenziato sembra un singolo thread di commenti. Ne parleremo più avanti nell'articolo in cui introduciamo il concetto di thread di commenti attivi che consente agli utenti di selezionare uno specifico thread di commenti e di poterne vedere la gamma nell'editor.
Archiviazione dell'interfaccia utente per i commenti
Prima di aggiungere la funzionalità che consente a un utente di inserire nuovi commenti, impostiamo prima uno stato dell'interfaccia utente per contenere i nostri thread di commenti. In questo articolo, utilizziamo RecoilJS come libreria di gestione dello stato per archiviare thread di commenti, commenti contenuti all'interno dei thread e altri metadati come ora di creazione, stato, autore del commento ecc. Aggiungiamo Recoil alla nostra applicazione:
> yarn add recoil
Usiamo Recoil atomi per memorizzare queste due strutture di dati. Se non hai familiarità con Recoil, gli atomi sono ciò che mantiene lo stato dell'applicazione. Per diverse parti dello stato dell'applicazione, di solito vorresti impostare atomi diversi. Atom Family è una raccolta di atomi: si può pensare che sia una Map
da una chiave univoca che identifica l'atomo agli atomi stessi. Vale la pena esaminare i concetti fondamentali di Recoil a questo punto e familiarizzare con essi.
Per il nostro caso d'uso, memorizziamo i thread di commento come una famiglia Atom e quindi avvolgiamo la nostra applicazione in un componente RecoilRoot
. RecoilRoot
viene applicato per fornire il contesto in cui verranno utilizzati i valori degli atomi. Creiamo un modulo separato CommentState
che contiene le nostre definizioni di atomi di rinculo mentre aggiungiamo altre definizioni di atomi più avanti nell'articolo.
# 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([]), });
Vale la pena sottolineare alcune cose su queste definizioni di atomi:
- Ciascuna famiglia di atomi/atomi è identificata in modo univoco da una
key
e può essere impostata con un valore predefinito. - Man mano che sviluppiamo ulteriormente in questo articolo, avremo bisogno di un modo per scorrere tutti i thread di commenti, il che significherebbe sostanzialmente la necessità di un modo per scorrere sulla famiglia di atomi di
commentThreadsState
. Al momento della stesura di questo articolo, il modo per farlo con Recoil è impostare un altro atomo che contenga tutti gli ID della famiglia di atomi. Lo facciamo concommentThreadIDsState
sopra. Entrambi questi atomi dovrebbero essere mantenuti sincronizzati ogni volta che aggiungiamo/eliminiamo thread di commenti.
Aggiungiamo un wrapper RecoilRoot
nel nostro componente App
radice in modo da poter utilizzare questi atomi in seguito. La documentazione di Recoil fornisce anche un utile componente Debugger che prendiamo così com'è e inseriamo nel nostro editor. Questo componente lascerà i log di console.debug
alla nostra console di sviluppo poiché gli atomi di Recoil vengono aggiornati in tempo reale.
# 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. }
Abbiamo anche bisogno di aggiungere codice che inizializzi i nostri atomi con i thread di commenti che già esistono sul documento (quelli che abbiamo aggiunto al nostro documento di esempio nella sezione precedente, per esempio). Lo facciamo in un momento successivo quando creiamo la barra laterale dei commenti che deve leggere tutti i thread di commenti in un documento.
A questo punto, carichiamo la nostra applicazione, ci assicuriamo che non ci siano errori che puntano alla nostra configurazione di Recoil e andiamo avanti.
Aggiunta di nuovi commenti
In questa sezione, aggiungiamo un pulsante alla barra degli strumenti che consente all'utente di aggiungere commenti (ovvero creare un nuovo thread di commenti) per l'intervallo di testo selezionato. Quando l'utente seleziona un intervallo di testo e fa clic su questo pulsante, è necessario eseguire le seguenti operazioni:
- Assegna un ID univoco al nuovo thread di commenti inserito.
- Aggiungi un nuovo segno alla struttura del documento Slate con l'ID in modo che l'utente veda quel testo evidenziato.
- Aggiungi il nuovo thread di commenti agli atomi di Recoil che abbiamo creato nella sezione precedente.
Aggiungiamo una funzione util a EditorCommentUtils
che esegue n. 1 e n. 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; }
Utilizzando il concetto di contrassegno per memorizzare ogni thread di commenti come proprio segno, siamo in grado di utilizzare semplicemente l'API Editor.addMark
per aggiungere un nuovo thread di commenti sull'intervallo di testo selezionato. Questa chiamata da sola gestisce tutti i diversi casi di aggiunta di commenti, alcuni dei quali descritti nella sezione precedente, commenti parzialmente sovrapposti, commenti all'interno/sovrapposizione di collegamenti, commenti su testo in grassetto/corsivo, commenti su paragrafi e così via. Questa chiamata API regola la gerarchia dei nodi per creare tutti i nuovi nodi di testo necessari per gestire questi casi.
addCommentThreadToState
è una funzione di callback che gestisce il passaggio n. 3, aggiungendo il nuovo thread di commenti a Recoil atom . Lo implementiamo successivamente come hook di callback personalizzato in modo che sia riutilizzabile. Questa richiamata deve aggiungere il nuovo thread di commenti a entrambi gli atomi: commentThreadsState
e commentThreadIDsState
. Per poterlo fare, utilizziamo l'hook useRecoilCallback
. Questo hook può essere utilizzato per costruire un callback che ottiene alcune cose che possono essere utilizzate per leggere/impostare i dati dell'atomo. Quella a cui siamo interessati in questo momento è la funzione set
che può essere utilizzata per aggiornare un valore di atomo come 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); }, [] ); }
La prima chiamata a set
aggiunge il nuovo ID al set esistente di ID thread di commento e restituisce il nuovo Set
(che diventa il nuovo valore dell'atomo).
Nella seconda chiamata, otteniamo l'atomo per l'ID dalla famiglia di atomi — commentThreadsState
as commentThreadsState(id)
e quindi impostiamo threadData
come valore. atomFamilyName(atomID)
è il modo in cui Recoil ci consente di accedere a un atomo dalla sua famiglia di atomi usando la chiave univoca. In parole povere, potremmo dire che se commentThreadsState
fosse una mappa javascript, questa chiamata è fondamentalmente — commentThreadsState.set(id, threadData)
.
Ora che abbiamo tutta questa configurazione del codice per gestire l'inserimento di un nuovo thread di commento al documento e Recoil atomi, aggiungiamo un pulsante alla nostra barra degli strumenti e colleghiamolo con la chiamata a queste funzioni.
# 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> ); }
Nota : utilizziamo onMouseDown
e non onClick
che avrebbe fatto perdere l'attenzione all'editor e la selezione sarebbe diventata null
. Ne abbiamo discusso un po' più in dettaglio nella sezione di inserimento dei link del primo articolo.
Nell'esempio seguente, vediamo l'inserimento in azione per un thread di commenti semplice e un thread di commenti sovrapposto con collegamenti. Nota come riceviamo aggiornamenti da Recoil Debugger che confermano che il nostro stato viene aggiornato correttamente. Verifichiamo inoltre che vengano creati nuovi nodi di testo mentre i thread vengono aggiunti al documento.
Commenti sovrapposti
Prima di procedere con l'aggiunta di più funzionalità al nostro sistema di commenti, dobbiamo prendere alcune decisioni su come gestire i commenti sovrapposti e le loro diverse combinazioni nell'editor. Per capire perché ne abbiamo bisogno, diamo un'occhiata a come funziona un Comment Popover, una funzionalità che costruiremo più avanti nell'articolo. Quando un utente fa clic su un determinato testo con thread di commenti su di esso, "selezioniamo" un thread di commenti e mostriamo un popover in cui l'utente può aggiungere commenti a quel thread.
Come puoi vedere dal video sopra, la parola "designer" ora fa parte di tre thread di commenti. Quindi abbiamo due thread di commenti che si sovrappongono l'uno all'altro su una parola. Ed entrambi questi thread di commenti (n. 1 e n. 2) sono completamente contenuti all'interno di un intervallo di testo di thread di commenti più lungo (n. 3). Ciò solleva alcune domande:
- Quale thread di commenti dobbiamo selezionare e mostrare quando l'utente fa clic sulla parola "designer"?
- Sulla base di come decidiamo di affrontare la domanda di cui sopra, avremmo mai un caso di sovrapposizione in cui fare clic su una parola non attiverebbe mai un determinato thread di commenti e il thread non è affatto accessibile?
Ciò implica che, nel caso di commenti sovrapposti, la cosa più importante da considerare è: una volta che l'utente ha inserito un thread di commenti, ci sarebbe un modo per poter selezionare quel thread di commenti in futuro facendo clic su del testo all'interno esso? In caso contrario, probabilmente non vogliamo consentire loro di inserirlo in primo luogo. Per garantire che questo principio sia rispettato il più delle volte nel nostro editor, introduciamo due regole relative ai commenti sovrapposti e le implementiamo nel nostro editor.
Prima di definire queste regole, vale la pena ricordare che editor ed elaboratori di testi diversi hanno approcci diversi quando si tratta di commenti sovrapposti. Per semplificare le cose, alcuni editor non consentono commenti sovrapposti di sorta. Nel nostro caso, cerchiamo di trovare una via di mezzo non consentendo casi di sovrapposizioni troppo complicati ma consentendo comunque commenti sovrapposti in modo che gli utenti possano avere un'esperienza di collaborazione e revisione più ricca.
Regola dell'intervallo di commenti più breve
Questa regola ci aiuta a rispondere alla domanda n. 1 di cui sopra su quale thread di commenti selezionare se un utente fa clic su un nodo di testo che contiene più thread di commenti. La regola è:
"Se l'utente fa clic sul testo che contiene più thread di commenti, troviamo il thread di commenti dell'intervallo di testo più breve e lo selezioniamo".
Intuitivamente, ha senso farlo in modo che l'utente abbia sempre un modo per arrivare al thread di commenti più interno che è completamente contenuto all'interno di un altro thread di commenti. Per altre condizioni (sovrapposizione parziale o nessuna sovrapposizione), dovrebbe esserci del testo con un solo thread di commenti, quindi dovrebbe essere facile usare quel testo per selezionare quel thread di commenti. È il caso di una sovrapposizione completa (o fitta ) di fili e perché abbiamo bisogno di questa regola.
Diamo un'occhiata a un caso piuttosto complesso di sovrapposizione che ci consente di utilizzare questa regola e di "fare la cosa giusta" quando si seleziona il thread di commento.
Nell'esempio sopra, l'utente inserisce i seguenti thread di commenti in quest'ordine:
- Commento Thread #1 sul carattere 'B' (lunghezza = 1).
- Commento Thread #2 su 'AB' (lunghezza = 2).
- Commento Thread #3 su 'BC' (lunghezza = 2).
Alla fine di questi inserimenti, a causa del modo in cui Slate divide i nodi di testo con i segni, avremo tre nodi di testo, uno per ogni carattere. Ora, se l'utente fa clic su 'B', seguendo la regola della lunghezza più breve, selezioniamo il thread n. 1 poiché è il più corto dei tre in lunghezza. Se non lo facciamo, non avremmo un modo per selezionare Comment Thread #1 poiché è lungo solo un carattere e fa anche parte di altri due thread.
Sebbene questa regola renda facile far emergere thread di commenti di lunghezza inferiore, potremmo imbatterci in situazioni in cui thread di commenti più lunghi diventano inaccessibili poiché tutti i caratteri in essi contenuti fanno parte di altri thread di commenti più brevi. Diamo un'occhiata a un esempio per questo.
Supponiamo di avere 100 caratteri (diciamo, il carattere 'A' digitato 100 volte cioè) e l'utente inserisce i thread di commenti nel seguente ordine:
- Commento Thread n. 1 dell'intervallo 20,80
- Commento Thread n. 2 dell'intervallo 0,50
- Commento Thread n. 3 dell'intervallo 51.100
Come puoi vedere nell'esempio sopra, se seguiamo la regola che abbiamo appena descritto qui, facendo clic su qualsiasi carattere compreso tra #20 e #80, selezioneremo sempre i thread #2 o #3 poiché sono più corti di #1 e quindi #1 non sarebbe selezionabile Un altro scenario in cui questa regola può lasciarci indecisi su quale thread di commenti selezionare è quando ci sono più thread di commenti della stessa lunghezza più breve su un nodo di testo.
Per tale combinazione di commenti sovrapposti e molte altre combinazioni simili a cui si potrebbe pensare dove seguire questa regola rende inaccessibile un determinato thread di commenti facendo clic sul testo, costruiamo una barra laterale dei commenti più avanti in questo articolo che offre all'utente una vista di tutti i thread di commenti presenti nel documento in modo che possano fare clic su quei thread nella barra laterale e attivarli nell'editor per vedere l'intervallo del commento. Vorremmo comunque avere questa regola e implementarla in quanto dovrebbe coprire molti scenari di sovrapposizione ad eccezione degli esempi meno probabili che abbiamo citato sopra. Abbiamo fatto tutto questo sforzo attorno a questa regola principalmente perché vedere il testo evidenziato nell'editor e fare clic su di esso per commentare è un modo più intuitivo per accedere a un commento sul testo rispetto al semplice utilizzo di un elenco di commenti nella barra laterale.
Regola di inserimento
La regola è:
"Se l'utente di testo ha selezionato e sta tentando di commentare è già completamente coperto da thread di commenti, non consentire tale inserimento."
Questo perché se consentiamo questo inserimento, ogni carattere in quell'intervallo finirebbe per avere almeno due thread di commenti (uno esistente e un altro quello nuovo che abbiamo appena consentito) rendendo difficile per noi determinare quale selezionare quando il l'utente fa clic su quel carattere in un secondo momento.
Osservando questa regola, ci si potrebbe chiedere perché ne abbiamo bisogno in primo luogo se abbiamo già la regola per l'intervallo di commenti più breve che ci consente di selezionare l'intervallo di testo più piccolo. Perché non consentire tutte le combinazioni di sovrapposizioni se possiamo usare la prima regola per dedurre il thread di commenti giusto da mostrare? Come alcuni degli esempi che abbiamo discusso in precedenza, la prima regola funziona per molti scenari ma non per tutti. Con la regola di inserimento, cerchiamo di ridurre al minimo il numero di scenari in cui la prima regola non può aiutarci e dobbiamo ricorrere alla barra laterale come unico modo per l'utente di accedere a quel thread di commenti. La regola di inserimento previene anche le sovrapposizioni esatte dei thread di commenti. Questa regola è comunemente implementata da molti editor popolari.
Di seguito è riportato un esempio in cui se questa regola non esistesse, consentiremmo il thread di commento n. 3 e quindi, come risultato della prima regola, il n. 3 non sarebbe accessibile poiché diventerebbe il più lungo in lunghezza.
Nota : avere questa regola non significa che non avremmo mai contenuto completamente i commenti sovrapposti. La cosa complicata della sovrapposizione dei commenti è che, nonostante le regole, l'ordine in cui vengono inseriti i commenti può ancora lasciarci in uno stato in cui non volevamo che si trovasse la sovrapposizione. Tornando al nostro esempio dei commenti sulla parola "designer ' in precedenza, il thread di commenti più lungo inserito era l'ultimo da aggiungere, quindi la regola di inserimento lo consentiva e finiamo con una situazione completamente contenuta: #1 e #2 contenuti all'interno di #3. Va bene perché la regola dell'intervallo di commenti più breve ci aiuterebbe là fuori.
Implementeremo la regola per l'intervallo di commenti più breve nella sezione successiva in cui implementeremo la selezione dei thread di commenti. Dato che ora abbiamo un pulsante della barra degli strumenti per inserire commenti, possiamo implementare subito la regola di inserimento controllando la regola quando l'utente ha selezionato del testo. Se la regola non è soddisfatta, disabiliteremmo il pulsante Commento in modo che gli utenti non possano inserire un nuovo thread di commento sul testo selezionato. Iniziamo!
# 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 ); }
La logica in questa funzione è relativamente semplice.
- Se la selezione dell'utente è un punto di inserimento lampeggiante, non è consentito inserire un commento in quanto non è stato selezionato alcun testo.
- Se la selezione dell'utente non è compressa, troviamo tutti i nodi di testo nella selezione. Nota l'uso della
mode: lowest
nella chiamata aEditor.nodes
(una funzione di supporto di SlateJS) che ci aiuta a selezionare tutti i nodi di testo poiché i nodi di testo sono in realtà le foglie dell'albero del documento. - Se c'è almeno un nodo di testo che non ha thread di commento su di esso, possiamo consentire l'inserimento. Usiamo l'util
getCommentThreadsOnTextNode
che abbiamo scritto in precedenza qui.
Ora utilizziamo questa funzione di utilità all'interno della barra degli strumenti per controllare lo stato disabilitato del pulsante.
# 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> );
Testiamo l'implementazione della regola ricreando il nostro esempio sopra.
Un dettaglio dell'esperienza utente da sottolineare qui è che mentre disabilitiamo il pulsante della barra degli strumenti se l'utente ha selezionato l'intera riga di testo qui, non completa l'esperienza per l'utente. L'utente potrebbe non comprendere appieno il motivo per cui il pulsante è disabilitato ed è probabile che si confonda sul fatto che non stiamo rispondendo alla sua intenzione di inserire un thread di commenti lì. Ci occuperemo di questo in seguito poiché i Popover di commento sono costruiti in modo tale che anche se il pulsante della barra degli strumenti è disabilitato, il popover per uno dei thread di commenti verrebbe visualizzato e l'utente sarebbe comunque in grado di lasciare commenti.
Testiamo anche un caso in cui è presente un nodo di testo non commentato e la regola consente di inserire un nuovo thread di commenti.
Selezione dei thread di commenti
In questa sezione, abilitiamo la funzione in cui l'utente fa clic su un nodo di testo commentato e utilizziamo la regola dell'intervallo di commenti più breve per determinare quale thread di commenti deve essere selezionato. I passaggi del processo sono:
- Trova il thread di commenti più breve sul nodo di testo commentato su cui l'utente fa clic.
- Imposta quel thread di commenti come thread di commenti attivo. (Creiamo un nuovo atomo di rinculo che sarà la fonte della verità per questo.)
- I nodi di testo commentati ascolterebbero lo stato Recoil e se fanno parte del thread di commento attivo, si evidenzieranno in modo diverso. In questo modo, quando l'utente fa clic sul thread di commento, l'intero intervallo di testo risalta poiché tutti i nodi di testo aggiorneranno il colore di evidenziazione.
Passaggio 1: implementazione della regola per l'intervallo di commenti più breve
Iniziamo con il passaggio n. 1 che sta sostanzialmente implementando la regola per l'intervallo di commenti più breve. L'obiettivo qui è trovare il thread di commento dell'intervallo più breve nel nodo di testo su cui l'utente ha fatto clic. Per trovare il thread di lunghezza più breve, dobbiamo calcolare la lunghezza di tutti i thread di commento in quel nodo di testo. I passaggi per farlo sono:
- Ottieni tutti i thread di commenti nel nodo di testo in questione.
- Attraversa in entrambe le direzioni da quel nodo di testo e continua ad aggiornare le lunghezze dei fili che vengono tracciate.
- Interrompere la traversata in una direzione quando abbiamo raggiunto uno dei bordi seguenti:
- Un nodo di testo non commentato (che implica che abbiamo raggiunto il bordo iniziale/finale più lontano di tutti i thread di commenti che stiamo monitorando).
- Un nodo di testo in cui tutti i thread di commenti che stiamo monitorando hanno raggiunto un limite (inizio/fine).
- Non ci sono più nodi di testo da attraversare in quella direzione (il che implica che abbiamo raggiunto l'inizio o la fine del documento o un nodo non di testo).
Poiché gli attraversamenti in direzione avanti e indietro sono funzionalmente uguali, scriveremo una funzione di supporto updateCommentThreadLengthMap
che fondamentalmente accetta un iteratore del nodo di testo. Continuerà a chiamare l'iteratore e continuerà ad aggiornare le lunghezze dei thread di rilevamento. Chiameremo questa funzione due volte, una per la direzione avanti e una per la direzione indietro. Let's write our main utility function that will use this helper function.
# src/utils/EditorCommentUtils.js export function getSmallestCommentThreadAtTextNode(editor, textNode) { const commentThreads = getCommentThreadsOnTextNode(textNode); const commentThreadsAsArray = [...commentThreads]; let shortestCommentThreadID = commentThreadsAsArray[0]; const reverseTextNodeIterator = (slateEditor, nodePath) => Editor.previous(slateEditor, { at: nodePath, mode: "lowest", match: Text.isText, }); const forwardTextNodeIterator = (slateEditor, nodePath) => Editor.next(slateEditor, { at: nodePath, mode: "lowest", match: Text.isText, }); if (commentThreads.size > 1) { // The map here tracks the lengths of the comment threads. // We initialize the lengths with length of current text node // since all the comment threads span over the current text node // at the least. const commentThreadsLengthByID = new Map( commentThreadsAsArray.map((id) => [id, textNode.text.length]) ); // traverse in the reverse direction and update the map updateCommentThreadLengthMap( editor, commentThreads, reverseTextNodeIterator, commentThreadsLengthByID ); // traverse in the forward direction and update the map updateCommentThreadLengthMap( editor, commentThreads, forwardTextNodeIterator, commentThreadsLengthByID ); let minLength = Number.POSITIVE_INFINITY; // Find the thread with the shortest length. for (let [threadID, length] of commentThreadsLengthByID) { if (length < minLength) { shortestCommentThreadID = threadID; minLength = length; } } } return shortestCommentThreadID; }
The steps we listed out are all covered in the above code. The comments should help follow how the logic flows there.
One thing worth calling out is how we created the traversal functions. We want to give a traversal function to updateCommentThreadLengthMap
such that it can call it while it is iterating text node's path and easily get the previous/next text node. To do that, Slate's traversal utilities Editor.previous
and Editor.next
(defined in the Editor interface) are very helpful. Our iterators reverseTextNodeIterator
and forwardTextNodeIterator
call these helpers with two options mode: lowest
and the match function Text.isText
so we know we're getting a text node from the traversal, if there is one.
Now we implement updateCommentThreadLengthMap
which traverses using these iterators and updates the lengths we're tracking.
# src/utils/EditorCommentUtils.js function updateCommentThreadLengthMap( editor, commentThreads, nodeIterator, map ) { let nextNodeEntry = nodeIterator(editor); while (nextNodeEntry != null) { const nextNode = nextNodeEntry[0]; const commentThreadsOnNextNode = getCommentThreadsOnTextNode(nextNode); const intersection = [...commentThreadsOnNextNode].filter((x) => commentThreads.has(x) ); // All comment threads we're looking for have already ended meaning // reached an uncommented text node OR a commented text node which // has none of the comment threads we care about. if (intersection.length === 0) { break; } // update thread lengths for comment threads we did find on this // text node. for (let i = 0; i < intersection.length; i++) { map.set(intersection[i], map.get(intersection[i]) + nextNode.text.length); } // call the iterator to get the next text node to consider nextNodeEntry = nodeIterator(editor, nextNodeEntry[1]); } return map; }
One might wonder why do we wait until the intersection
becomes 0
to stop iterating in a certain direction. Why can't we just stop if we're reached the edge of at least one comment thread — that would imply we've reached the shortest length in that direction, right? The reason we can't do that is that we know that a comment thread can span over multiple text nodes and we wouldn't know which of those text nodes did the user click on and we started our traversal from. We wouldn't know the range of all comment threads in question without fully traversing to the farthest edges of the union of the text ranges of the comment threads in both the directions.
Check out the below example where we have two comment threads 'A' and 'B' overlapping each other in some way resulting into three text nodes 1,2 and 3 — #2 being the text node with the overlap.
In this example, let's assume we don't wait for intersection to become 0 and just stop when we reach the edge of a comment thread. Now, if the user clicked on #2 and we start traversal in reverse direction, we'd stop at the start of text node #2 itself since that's the start of the comment thread A. As a result, we might not compute the comment thread lengths correctly for A & B. With the implementation above traversing the farthest edges (text nodes 1,2, and 3), we should get B as the shortest comment thread as expected.
To see the implementation visually, below is a walkthrough with a slideshow of the iterations. We have two comment threads A and B that overlap each other over text node #3 and the user clicks on the overlapping text node #3.
Steps 2 & 3: Maintaining State Of The Selected Comment Thread And Highlighting It
Now that we have the logic for the rule fully implemented, let's update the editor code to use it. For that, we first create a Recoil atom that'll store the active comment thread ID for us. We then update the CommentedText
component to use our rule's implementation.
# src/utils/CommentState.js import { atom } from "recoil"; export const activeCommentThreadIDAtom = atom({ key: "activeCommentThreadID", default: null, }); # src/components/CommentedText.js import { activeCommentThreadIDAtom } from "../utils/CommentState"; import classNames from "classnames"; import { getSmallestCommentThreadAtTextNode } from "../utils/EditorCommentUtils"; import { useRecoilState } from "recoil"; export default function CommentedText(props) { .... const { commentThreads, textNode, ...otherProps } = props; const [activeCommentThreadID, setActiveCommentThreadID] = useRecoilState( activeCommentThreadIDAtom ); const onClick = () => { setActiveCommentThreadID( getSmallestCommentThreadAtTextNode(editor, textNode) ); }; return ( <span {...otherProps} className={classNames({ comment: true, // a different background color treatment if this text node's // comment threads do contain the comment thread active on the // document right now. "is-active": commentThreads.has(activeCommentThreadID), })} onClick={onClick} > {props.children} ≷/span> ); }
Questo componente utilizza useRecoilState
che consente a un componente di iscriversi e anche di essere in grado di impostare il valore di Recoil atom. Abbiamo bisogno che l'abbonato sappia se questo nodo di testo fa parte del thread di commenti attivo in modo che possa modellarsi in modo diverso. Guarda lo screenshot qui sotto in cui è attivo il thread di commenti al centro e possiamo vedere chiaramente il suo intervallo.
Ora che abbiamo tutto il codice per far funzionare la selezione dei thread di commenti, vediamolo in azione. Per testare bene il nostro codice di attraversamento, testiamo alcuni casi semplici di sovrapposizione e alcuni casi limite come:
- Fare clic su un nodo di testo commentato all'inizio/alla fine dell'editor.
- Facendo clic su un nodo di testo commentato con thread di commenti che si estendono su più paragrafi.
- Facendo clic su un nodo di testo commentato subito prima di un nodo immagine.
- Facendo clic su un nodo di testo commentato sui collegamenti sovrapposti.
Poiché ora abbiamo un atomo di Recoil per tenere traccia dell'ID del thread di commenti attivo, un piccolo dettaglio di cui occuparsi è impostare il thread di commenti appena creato come attivo quando l'utente utilizza il pulsante della barra degli strumenti per inserire un nuovo thread di commenti. Ciò ci consente, nella sezione successiva, di mostrare il popover del thread di commento immediatamente all'inserimento in modo che l'utente possa iniziare ad aggiungere commenti immediatamente.
# 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>; };
Nota: l'uso di useSetRecoilState
qui (un hook Recoil che espone un setter per l'atomo ma non sottoscrive il componente al suo valore) è ciò di cui abbiamo bisogno per la barra degli strumenti in questo caso.
Aggiunta di popover di thread di commenti
In questa sezione, costruiamo un Popover di commento che utilizza il concetto di thread di commenti selezionato/attivo e mostra un popover che consente all'utente di aggiungere commenti a quel thread di commenti. Prima di costruirlo, diamo una rapida occhiata a come funziona.
Quando si tenta di eseguire il rendering di un Popover di commento vicino al thread di commenti attivo, ci imbattiamo in alcuni dei problemi che abbiamo riscontrato nel primo articolo con un menu dell'editor di collegamenti. A questo punto, è consigliabile leggere la sezione del primo articolo che costruisce un editor di link e i problemi di selezione che incontriamo con quello.
Per prima cosa lavoriamo sul rendering di un componente popover vuoto nel posto giusto in base al thread di commenti attivo. Il modo in cui il popover funzionerebbe è:
- Comment Thread Popover viene visualizzato solo quando è presente un ID thread di commento attivo. Per ottenere queste informazioni, ascoltiamo l'atomo di rinculo che abbiamo creato nella sezione precedente.
- Quando esegue il rendering, troviamo il nodo di testo nella selezione dell'editor e rendiamo il popover vicino ad esso.
- Quando l'utente fa clic in un punto qualsiasi al di fuori del popover, impostiamo il thread di commento attivo su
null
, disattivando così il thread di commento e facendo anche scomparire il popover.
# 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> ); }
Un paio di cose che dovrebbero essere richiamate per questa implementazione del componente popover:
- Prende l'
editorOffsets
e laselection
dal componenteEditor
dove verrebbe renderizzato.editorOffsets
sono i limiti del componente Editor in modo da poter calcolare la posizione del popover e laselection
potrebbe essere la selezione corrente o precedente nel caso in cui l'utente abbia utilizzato un pulsante della barra degli strumenti rendendo laselection
null
. La sezione sull'editor dei collegamenti del primo articolo collegato sopra li esamina in dettaglio. - Poiché il
LinkEditor
del primo articolo e ilCommentThreadPopover
qui, entrambi rendono un popover attorno a un nodo di testo, abbiamo spostato quella logica comune in un componenteNodePopover
che gestisce il rendering del componente allineato al nodo di testo in questione. I suoi dettagli di implementazione sono ciò che il componenteLinkEditor
aveva nel primo articolo. -
NodePopover
accetta un metodoonClickOutside
come prop che viene chiamato se l'utente fa clic da qualche parte al di fuori del popover. Lo implementiamo allegando il listener di eventimousedown
aldocument
, come spiegato in dettaglio in questo articolo Smashing su questa idea. -
getFirstTextNodeAtSelection
ottiene il primo nodo di testo all'interno della selezione dell'utente che utilizziamo per eseguire il rendering del popover. L'implementazione di questa funzione utilizza gli helper di Slate per trovare il nodo di testo.
# 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; }
Implementiamo il callback onClickOutside
che dovrebbe cancellare il thread di commento attivo. Tuttavia, dobbiamo tenere conto dello scenario in cui il popover del thread di commenti è aperto e un determinato thread è attivo e l'utente fa clic su un altro thread di commenti. In tal caso, non vogliamo che onClickOutside
reimposti il thread di commenti attivo poiché l'evento click sull'altro componente CommentedText
dovrebbe impostare l'altro thread di commenti in modo che diventi attivo. Non vogliamo interferire con quello nel popover.
Il modo in cui lo facciamo è trovare il nodo Slate più vicino al nodo DOM in cui si è verificato l'evento click. Se quel nodo Slate è un nodo di testo e contiene commenti, saltiamo il ripristino del thread di commenti attivo Recoil atom. Mettiamolo in pratica!
# 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 ha un metodo helper toSlateNode
che restituisce il nodo Slate che esegue il mapping a un nodo DOM o al suo predecessore più vicino se esso stesso non è un nodo Slate. L'implementazione corrente di questo helper genera un errore se non riesce a trovare un nodo Slate invece di restituire null
. Lo gestiamo sopra controllando noi stessi il caso null
, che è uno scenario molto probabile se l'utente fa clic da qualche parte al di fuori dell'editor in cui i nodi Slate non esistono.
Ora possiamo aggiornare il componente Editor
per ascoltare activeCommentThreadIDAtom
ed eseguire il rendering del popover solo quando è attivo un thread di commenti.
# 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> ... </> ); }
Verifichiamo che il popover venga caricato nel posto giusto per il thread di commenti corretto e cancelli il thread di commenti attivo quando facciamo clic all'esterno.
Passiamo ora a consentire agli utenti di aggiungere commenti a un thread di commenti e vedere tutti i commenti di quel thread nel popover. Utilizzeremo la famiglia di atomi Recoil — commentThreadsState
che abbiamo creato in precedenza nell'articolo per questo.
I commenti in un thread di commenti vengono archiviati nella matrice dei comments
. Per abilitare l'aggiunta di un nuovo commento, eseguiamo il rendering di un input del modulo che consente all'utente di inserire un nuovo commento. Mentre l'utente digita il commento, lo manteniamo in una variabile di stato locale — commentText
. Al clic del pulsante, aggiungiamo il testo del commento come nuovo commento all'array dei 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> ); }
Nota : anche se eseguiamo il rendering di un input per consentire all'utente di digitare un commento, non lo lasciamo necessariamente a fuoco quando il popover si monta. Questa è una decisione relativa all'esperienza utente che può variare da un editor all'altro. Alcuni editor non consentono agli utenti di modificare il testo mentre il popover del thread di commento è aperto. Nel nostro caso, vogliamo consentire all'utente di modificare il testo commentato quando fa clic su di esso.
Vale la pena richiamare il modo in cui accediamo ai dati del thread di commento specifico dalla famiglia di atomi Recoil — chiamando l'atomo come — commentThreadsState(threadID)
. Questo ci dà il valore dell'atomo e un setter per aggiornare solo quell'atomo nella famiglia. Se i commenti vengono caricati in modo pigro dal server, Recoil fornisce anche un hook useRecoilStateLoadable
che restituisce un oggetto Loadable che ci dice lo stato di caricamento dei dati dell'atomo. Se sta ancora caricando, possiamo scegliere di mostrare uno stato di caricamento nel popover.
Ora accediamo a threadData
e renderizziamo l'elenco dei commenti. Ogni commento viene visualizzato dal componente CommentRow
.
# src/components/CommentThreadPopover.js return ( <NodePopover ... > <div className={"comment-list"}> {threadData.comments.map((comment, index) => ( <CommentRow key={`comment_${index}`} comment={comment} /> ))} </div> ... </NodePopover> );
Di seguito è riportata l'implementazione di CommentRow
che rende il testo del commento e altri metadati come il nome dell'autore e l'ora di creazione. Usiamo il modulo date-fns
per mostrare un'ora di creazione formattata.
# 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> ); }
Abbiamo estratto questo come componente a sé stante, poiché lo riutilizzeremo in seguito quando implementeremo la barra laterale dei commenti.
A questo punto, il nostro Comment Popover ha tutto il codice necessario per consentire l'inserimento di nuovi commenti e l'aggiornamento dello stato Recoil degli stessi. Verifichiamolo. Sulla console del browser, utilizzando Recoil Debug Observer che abbiamo aggiunto in precedenza, siamo in grado di verificare che l'atomo Recoil per il thread di commenti venga aggiornato correttamente mentre aggiungiamo nuovi commenti al thread.
Aggiunta di una barra laterale dei commenti
In precedenza nell'articolo, abbiamo spiegato il motivo per cui occasionalmente può accadere che le regole che abbiamo implementato impediscano a un determinato thread di commenti di non essere accessibile facendo clic solo sui suoi nodi di testo, a seconda della combinazione di sovrapposizione. In questi casi, abbiamo bisogno di una barra laterale dei commenti che consenta all'utente di accedere a tutti i thread di commenti nel documento.
Una barra laterale dei commenti è anche una buona aggiunta che si intreccia in un flusso di lavoro di suggerimenti e recensioni in cui un revisore può navigare attraverso tutti i thread di commenti uno dopo l'altro in un attimo ed essere in grado di lasciare commenti/risposte ovunque ne senta il bisogno. Prima di iniziare a implementare la barra laterale, c'è un compito incompiuto di cui ci occupiamo di seguito.
Inizializzazione dei thread di commento sullo stato di rinculo
Quando il documento viene caricato nell'editor, dobbiamo scansionare il documento per trovare tutti i thread di commenti e aggiungerli agli atomi di Recoil che abbiamo creato sopra come parte del processo di inizializzazione. Scriviamo una funzione di utilità in EditorCommentUtils
che scansiona i nodi di testo, trova tutti i thread di commenti e li aggiunge all'atomo 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", }) ); }
Sincronizzazione con archiviazione back-end e considerazione delle prestazioni
Per il contesto dell'articolo, poiché ci concentriamo esclusivamente sull'implementazione dell'interfaccia utente, li inizializziamo semplicemente con alcuni dati che ci consentono di confermare che il codice di inizializzazione funziona.
Nell'uso reale del Sistema di commento, è probabile che i thread dei commenti vengano archiviati separatamente dai contenuti del documento stesso. In tal caso, il codice precedente dovrebbe essere aggiornato per effettuare una chiamata API che recuperi tutti i metadati e i commenti su tutti gli ID dei thread di commenti in commentThreads
. Una volta caricati i thread di commenti, è probabile che vengano aggiornati poiché più utenti aggiungono più commenti in tempo reale, cambiano il loro stato e così via. La versione di produzione del sistema di commento dovrebbe strutturare l'archiviazione di Recoil in modo da poterla sincronizzare con il server. Se scegli di utilizzare Recoil per la gestione dello stato, ci sono alcuni esempi sull'API Atom Effects (sperimentale al momento della stesura di questo articolo) che fanno qualcosa di simile.
Se un documento è veramente lungo e ha molti utenti che collaborano su di esso su molti thread di commenti, potremmo dover ottimizzare il codice di inizializzazione per caricare solo thread di commenti per le prime pagine del documento. In alternativa, possiamo scegliere di caricare solo i metadati leggeri di tutti i thread di commenti invece dell'intero elenco di commenti che è probabilmente la parte più pesante del payload.
Ora, passiamo a chiamare questa funzione quando il componente Editor
viene montato con il documento in modo che lo stato Recoil sia inizializzato correttamente.
# 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 ( <> ... </> ); }
Usiamo lo stesso hook personalizzato — useAddCommentThreadToState
che abbiamo usato con l'implementazione del pulsante di commento della barra degli strumenti per aggiungere nuovi thread di commenti. Poiché il popover funziona, possiamo fare clic su uno dei thread di commenti preesistenti nel documento e verificare che mostri i dati che abbiamo utilizzato per inizializzare il thread sopra.
Ora che il nostro stato è inizializzato correttamente, possiamo iniziare a implementare la barra laterale. Tutti i nostri thread di commenti nell'interfaccia utente sono archiviati nella famiglia di atomi Recoil — commentThreadsState
. Come evidenziato in precedenza, il modo in cui iteriamo attraverso tutti gli elementi in una famiglia di atomi Recoil è tracciando le chiavi/ID degli atomi in un altro atomo. Lo abbiamo fatto con commentThreadIDsState
. Aggiungiamo il componente CommentSidebar
che scorre l'insieme di ID in questo atom e crea un componente CommentThread
per ciascuno.
# 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> ); }
Ora implementiamo il componente CommentThread
che ascolta l'atomo Recoil nella famiglia corrispondente al thread di commenti che sta eseguendo il rendering. In questo modo, quando l'utente aggiunge più commenti al thread nell'editor o modifica altri metadati, possiamo aggiornare la barra laterale per riflettere ciò.
Poiché la barra laterale potrebbe diventare davvero grande per un documento con molti commenti, nascondiamo tutti i commenti tranne il primo quando eseguiamo il rendering della barra laterale. L'utente può utilizzare il pulsante "Mostra/Nascondi risposte" per mostrare/nascondere l'intero thread di commenti.
# 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> ); }
Abbiamo riutilizzato il componente CommentRow
dal popover anche se abbiamo aggiunto un trattamento di progettazione usando showConnector
prop che fondamentalmente fa sembrare tutti i commenti collegati a un thread nella barra laterale.
Ora, eseguiamo il rendering della CommentSidebar
dei commenti Editor
e verifichiamo che mostri tutti i thread che abbiamo nel documento e si aggiorni correttamente quando aggiungiamo nuovi thread o nuovi commenti ai thread esistenti.
# src/components/Editor.js return ( <> <Slate ... > ..... <div className={"sidebar-wrapper"}> <CommentsSidebar /> </div> </Slate> </> );
Passiamo ora all'implementazione di una popolare interazione con la barra laterale dei commenti che si trova negli editor:
Fare clic su un thread di commenti nella barra laterale dovrebbe selezionare/attivare quel thread di commenti. Aggiungiamo anche un trattamento di progettazione differenziale per evidenziare un thread di commenti nella barra laterale se è attivo nell'editor. Per poterlo fare, utilizziamo Recoil atom — activeCommentThreadIDAtom
. Aggiorniamo il componente CommentThread
per supportare questo.
# 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> );
Se osserviamo da vicino, abbiamo un bug nella nostra implementazione della sincronizzazione del thread di commento attivo con la barra laterale. Quando facciamo clic su diversi thread di commenti nella barra laterale, il thread di commenti corretto viene effettivamente evidenziato nell'editor. Tuttavia, il Comment Popover non si sposta effettivamente nel thread di commento attivo modificato. Rimane dove è stato eseguito il rendering per la prima volta. Se osserviamo l'implementazione del Comment Popover, esso si confronta con il primo nodo di testo nella selezione dell'editor. A quel punto dell'implementazione, l'unico modo per selezionare un thread di commenti era fare clic su un nodo di testo in modo da poter fare comodamente affidamento sulla selezione dell'editor poiché è stato aggiornato da Slate a seguito dell'evento click. Nell'evento onClick
sopra, non aggiorniamo la selezione ma semplicemente aggiorniamo il valore dell'atomo Recoil facendo sì che la selezione di Slate rimanga invariata e quindi il Popover del commento non si muova.
Una soluzione a questo problema è aggiornare la selezione dell'editor insieme all'aggiornamento dell'atomo Recoil quando l'utente fa clic sul thread di commento nella barra laterale. I passaggi per farlo sono:
- Trova tutti i nodi di testo che hanno questo thread di commenti su di essi che imposteremo come nuovo thread attivo.
- Ordina questi nodi di testo nell'ordine in cui appaiono nel documento (usiamo l'API
Path.compare
di Slate per questo). - Calcola un intervallo di selezione che va dall'inizio del primo nodo di testo alla fine dell'ultimo nodo di testo.
- Imposta l'intervallo di selezione in modo che sia la nuova selezione dell'editor (utilizzando l'API
Transforms.select
di Slate).
Se volessimo solo correggere il bug, potremmo semplicemente trovare il primo nodo di testo nel passaggio n. 1 che ha il thread di commenti e impostarlo come selezione dell'editor. Tuttavia, sembra un approccio più pulito per selezionare l'intero intervallo di commenti poiché stiamo davvero selezionando il thread di commenti.
Aggiorniamo l'implementazione della richiamata onClick
per includere i passaggi precedenti.
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]);
Nota : allTextNodePaths
contiene il percorso di tutti i nodi di testo. Usiamo l'API Editor.point
per ottenere i punti di inizio e di fine in quel percorso. Il primo articolo esamina i concetti di posizione di Slate. Sono anche ben documentati sulla documentazione di Slate.
Verifichiamo che questa implementazione risolva il bug e che il Popover di commento si sposti correttamente nel thread di commento attivo. Questa volta, testiamo anche con un caso di fili sovrapposti per assicurarci che non si rompano lì.
Con la correzione del bug, abbiamo abilitato un'altra interazione della barra laterale di cui non abbiamo ancora discusso. Se abbiamo un documento molto lungo e l'utente fa clic su un thread di commenti nella barra laterale che si trova al di fuori del viewport, vorremmo scorrere fino a quella parte del documento in modo che l'utente possa concentrarsi sul thread di commenti nell'editor. Impostando la selezione sopra utilizzando l'API di Slate, lo otteniamo gratuitamente. Vediamolo in azione di seguito.
Con ciò, avvolgiamo la nostra implementazione della barra laterale. Verso la fine dell'articolo, elenchiamo alcune belle funzionalità aggiunte e miglioramenti che possiamo apportare alla barra laterale dei commenti che aiutano a migliorare l'esperienza di creazione di commenti e recensioni nell'editor.
Risoluzione e riapertura dei commenti
In questa sezione, ci concentriamo sul consentire agli utenti di contrassegnare i thread di commenti come "risolti" o essere in grado di riaprirli per la discussione, se necessario. Dal punto di vista dei dettagli dell'implementazione, questi sono i metadati di status
su un thread di commenti che cambiamo quando l'utente esegue questa azione. Dal punto di vista dell'utente, questa è una funzionalità molto utile in quanto offre loro un modo per affermare che la discussione su qualcosa nel documento è conclusa o deve essere riaperta perché ci sono alcuni aggiornamenti/nuove prospettive e così via.
Per abilitare la commutazione dello stato, aggiungiamo un pulsante al CommentPopover
che consente all'utente di alternare tra i due stati: open
e 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> ); }
Prima di testarlo, diamo anche alla barra laterale dei commenti un trattamento di progettazione differenziale per i commenti risolti in modo che l'utente possa rilevare facilmente quali thread di commenti non sono risolti o aperti e concentrarsi su quelli se lo desidera.
# 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> ); }
Conclusione
In questo articolo, abbiamo creato l'infrastruttura dell'interfaccia utente di base per un sistema di commenti su un editor di testo RTF. L'insieme di funzionalità che aggiungiamo qui funge da base per costruire un'esperienza di collaborazione più ricca su un editor in cui i collaboratori possono annotare parti del documento e conversare su di esse. L'aggiunta di una barra laterale dei commenti ci offre uno spazio per avere più funzionalità di conversazione o basate su recensioni da abilitare sul prodotto.
Seguendo queste linee, ecco alcune delle funzionalità che un Rich Text Editor potrebbe considerare di aggiungere in aggiunta a ciò che abbiamo creato in questo articolo:
- Supporto per
@
menzioni in modo che i collaboratori possano taggarsi a vicenda nei commenti; - Supporto per tipi di media come immagini e video da aggiungere ai thread di commenti;
- Modalità di suggerimento a livello di documento che consente ai revisori di apportare modifiche al documento che appaiono come suggerimenti per le modifiche. Si potrebbe fare riferimento a questa funzione in Google Docs o al rilevamento delle modifiche in Microsoft Word come esempi;
- Miglioramenti alla barra laterale per cercare conversazioni per parola chiave, filtrare i thread per stato o autore di commenti e così via.