Ajout d'un système de commentaires à un éditeur WYSIWYG
Publié: 2022-03-10Ces dernières années, nous avons vu la collaboration pénétrer de nombreux flux de travail numériques et cas d'utilisation dans de nombreuses professions. Au sein même de la communauté de la conception et de l'ingénierie logicielle, nous voyons des concepteurs collaborer sur des artefacts de conception à l'aide d'outils tels que Figma, des équipes effectuant des sprints et des planifications de projets à l'aide d'outils tels que Mural et des entretiens menés à l'aide de CoderPad. Tous ces outils visent constamment à combler le fossé entre une expérience en ligne et une expérience du monde physique en exécutant ces flux de travail et en rendant l'expérience de collaboration aussi riche et transparente que possible.
Pour la majorité des outils de collaboration comme ceux-ci, la possibilité de partager des opinions les uns avec les autres et d'avoir des discussions sur le même contenu est un must. Un système de commentaires qui permet aux collaborateurs d'annoter des parties d'un document et d'avoir des conversations à leur sujet est au cœur de ce concept. En plus d'en créer un pour le texte dans un éditeur WYSIWYG, l'article tente d'impliquer les lecteurs dans la façon dont nous essayons de peser le pour et le contre et tentons de trouver un équilibre entre la complexité de l'application et l'expérience utilisateur lorsqu'il s'agit de créer des fonctionnalités pour les éditeurs WYSIWYG ou Traitements de texte en général.
Représentation des commentaires dans la structure du document
Afin de trouver un moyen de représenter les commentaires dans la structure de données d'un document en texte enrichi, examinons quelques scénarios dans lesquels des commentaires peuvent être créés dans un éditeur.
- Commentaires créés sur du texte sans style (scénario de base) ;
- Commentaires créés sur du texte qui peut être en gras/italique/souligné, etc. ;
- Commentaires qui se chevauchent d'une manière ou d'une autre (chevauchement partiel où deux commentaires ne partagent que quelques mots ou entièrement contenu où le texte d'un commentaire est entièrement contenu dans le texte d'un autre commentaire) ;
- Commentaires créés sur du texte à l'intérieur d'un lien (spécial car les liens sont eux-mêmes des nœuds dans notre structure de document);
- Commentaires qui s'étendent sur plusieurs paragraphes (spéciaux car les paragraphes sont des nœuds dans notre structure de document et les commentaires sont appliqués aux nœuds de texte qui sont les enfants du paragraphe).
En regardant les cas d'utilisation ci-dessus, il semble que les commentaires dans la façon dont ils peuvent apparaître dans un document de texte enrichi sont très similaires aux styles de caractères (gras, italique, etc.). Ils peuvent se chevaucher, parcourir du texte dans d'autres types de nœuds comme des liens et même s'étendre sur plusieurs nœuds parents comme des paragraphes.
Pour cette raison, nous utilisons la même méthode pour représenter les commentaires que pour les styles de caractères, c'est-à-dire les "marques" (comme on les appelle ainsi dans la terminologie SlateJS). Les marques ne sont que des propriétés régulières sur les nœuds - la spécialité étant que l'API de Slate autour des marques ( Editor.addMark
et Editor.removeMark
) gère le changement de la hiérarchie des nœuds lorsque plusieurs marques sont appliquées à la même plage de texte. Cela nous est extrêmement utile car nous traitons de nombreuses combinaisons différentes de commentaires qui se chevauchent.
Discussions de commentaires en tant que marques
Chaque fois qu'un utilisateur sélectionne une plage de texte et tente d'insérer un commentaire, techniquement, il démarre un nouveau fil de commentaires pour cette plage de texte. Comme nous leur permettrions d'insérer un commentaire et des réponses ultérieures à ce commentaire, nous traitons cet événement comme une nouvelle insertion de fil de commentaires dans le document.
La façon dont nous représentons les fils de commentaires en tant que marques est que chaque fil de commentaires est représenté par une marque nommée commentThread_threadID
où threadID
est un identifiant unique que nous attribuons à chaque fil de commentaires. Ainsi, si la même plage de texte comporte deux fils de commentaires, elle aurait deux propriétés définies sur true
— commentThread_thread1
et commentThread_thread2
. C'est là que les fils de commentaires sont très similaires aux styles de caractères puisque si le même texte était en gras et en italique, il aurait les deux propriétés définies sur true
— bold
et italic
.
Avant de plonger dans la configuration de cette structure, il vaut la peine de regarder comment les nœuds de texte changent lorsque les fils de commentaires leur sont appliqués. La façon dont cela fonctionne (comme avec n'importe quelle marque) est que lorsqu'une propriété de marque est définie sur le texte sélectionné, l'API Editor.addMark de Slate divise le ou les nœuds de texte si nécessaire de sorte que dans la structure résultante, les nœuds de texte sont configurés de manière à ce que chaque nœud de texte ait exactement la même valeur de la marque.
Pour mieux comprendre cela, jetez un œil aux trois exemples suivants qui montrent l'état avant et après des nœuds de texte une fois qu'un fil de commentaires est inséré sur le texte sélectionné :
Surlignage du texte commenté
Maintenant que nous savons comment nous allons représenter les commentaires dans la structure du document, ajoutons-en quelques-uns à l'exemple de document du premier article et configurons l'éditeur pour qu'il les affiche en surbrillance. Comme nous aurons beaucoup de fonctions utilitaires pour traiter les commentaires dans cet article, nous créons un module EditorCommentUtils
qui hébergera tous ces utilitaires. Pour commencer, nous créons une fonction qui crée une marque pour un ID de fil de commentaire donné. Nous l'utilisons ensuite pour insérer quelques fils de commentaires dans notre ExampleDocument
.
# src/utils/EditorCommentUtils.js const COMMENT_THREAD_PREFIX = "commentThread_"; export function getMarkForCommentThreadID(threadID) { return `${COMMENT_THREAD_PREFIX}${threadID}`; }
L'image ci-dessous souligne en rouge les plages de texte que nous avons comme exemples de fils de commentaires ajoutés dans l'extrait de code suivant. Notez que le texte "Richard McClintock" a deux fils de commentaires qui se chevauchent. Plus précisément, il s'agit d'un cas où un fil de commentaires est entièrement contenu dans un autre.
# 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, }, ... ];
Nous nous concentrons sur le côté interface utilisateur d'un système de commentaires dans cet article, nous leur attribuons donc des ID dans l'exemple de document directement à l'aide du package npm uuid. Il est fort probable que dans une version de production d'un éditeur, ces identifiants soient créés par un service backend.
Nous nous concentrons maintenant sur le peaufinage de l'éditeur pour afficher ces nœuds de texte en surbrillance. Pour ce faire, lors du rendu des nœuds de texte, nous avons besoin d'un moyen de savoir s'il contient des fils de commentaires. Nous ajoutons un getCommentThreadsOnTextNode
pour cela. Nous nous appuyons sur le composant StyledText
que nous avons créé dans le premier article pour gérer le cas où il essaie de rendre un nœud de texte avec des commentaires. Comme nous avons d'autres fonctionnalités à venir qui seront ajoutées ultérieurement aux nœuds de texte commenté, nous créons un composant CommentedText
qui restitue le texte commenté. StyledText
vérifiera si le nœud de texte qu'il essaie de rendre contient des commentaires. Si c'est le cas, il affiche CommentedText
. Il utilise un getCommentThreadsOnTextNode
pour en déduire cela.
# 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; }
Le premier article a construit un composant StyledText
qui rend les nœuds de texte (gestion des styles de caractères, etc.). Nous étendons ce composant pour utiliser l'utilitaire ci-dessus et rendons un composant CommentedText
si le nœud contient des commentaires.
# 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>; }
Vous trouverez ci-dessous l'implémentation de CommentedText
qui rend le nœud de texte et attache le CSS qui le montre en surbrillance.
# 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; }
Avec tout le code ci-dessus réuni, nous voyons maintenant des nœuds de texte avec des fils de commentaires mis en évidence dans l'éditeur.
Remarque : Les utilisateurs ne peuvent actuellement pas savoir si certains textes comportent des commentaires qui se chevauchent. L'ensemble de la plage de texte en surbrillance ressemble à un seul fil de commentaires. Nous abordons cela plus loin dans l'article où nous introduisons le concept de fil de commentaires actif qui permet aux utilisateurs de sélectionner un fil de commentaires spécifique et de voir sa plage dans l'éditeur.
Stockage de l'interface utilisateur pour les commentaires
Avant d'ajouter la fonctionnalité qui permet à un utilisateur d'insérer de nouveaux commentaires, nous configurons d'abord un état d'interface utilisateur pour contenir nos fils de commentaires. Dans cet article, nous utilisons RecoilJS comme bibliothèque de gestion d'état pour stocker les fils de commentaires, les commentaires contenus dans les fils et d'autres métadonnées comme l'heure de création, le statut, l'auteur du commentaire, etc. Ajoutons Recoil à notre application :
> yarn add recoil
Nous utilisons des atomes de recul pour stocker ces deux structures de données. Si vous n'êtes pas familier avec Recoil, les atomes sont ce qui maintient l'état de l'application. Pour différents états d'application, vous souhaiterez généralement configurer différents atomes. La famille d'atomes est une collection d'atomes - on peut penser qu'il s'agit d'une Map
à partir d'une clé unique identifiant l'atome aux atomes eux-mêmes. Cela vaut la peine de passer en revue les concepts de base de Recoil à ce stade et de nous familiariser avec eux.
Pour notre cas d'utilisation, nous stockons les fils de commentaires en tant que famille Atom, puis encapsulons notre application dans un composant RecoilRoot
. RecoilRoot
est appliqué pour fournir le contexte dans lequel les valeurs atomiques vont être utilisées. Nous créons un module séparé CommentState
qui contient nos définitions d'atomes Recoil à mesure que nous ajoutons d'autres définitions d'atomes plus loin dans l'article.
# 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([]), });
Cela vaut la peine d'appeler quelques choses sur ces définitions d'atomes :
- Chaque atome/famille d'atomes est identifié de manière unique par une
key
et peut être configuré avec une valeur par défaut. - Au fur et à mesure que nous approfondirons cet article, nous aurons besoin d'un moyen d'itérer sur tous les fils de commentaires, ce qui signifierait essentiellement avoir besoin d'un moyen d'itérer sur la famille d'atomes
commentThreadsState
. Au moment de la rédaction de cet article, la façon de le faire avec Recoil est de configurer un autre atome qui contient tous les identifiants de la famille d'atomes. Nous le faisons aveccommentThreadIDsState
ci-dessus. Ces deux atomes devraient être synchronisés chaque fois que nous ajoutons/supprimons des fils de commentaires.
Nous ajoutons un wrapper RecoilRoot
dans notre composant App
racine afin de pouvoir utiliser ces atomes plus tard. La documentation de Recoil fournit également un composant Debugger utile que nous prenons tel quel et que nous déposons dans notre éditeur. Ce composant laissera les journaux console.debug
à notre console de développement au fur et à mesure que les atomes de recul seront mis à jour en temps réel.
# 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. }
Nous devons également ajouter du code qui initialise nos atomes avec les fils de commentaires qui existent déjà sur le document (ceux que nous avons ajoutés à notre exemple de document dans la section précédente, par exemple). Nous le faisons ultérieurement lorsque nous construisons la barre latérale des commentaires qui doit lire tous les fils de commentaires d'un document.
À ce stade, nous chargeons notre application, nous nous assurons qu'il n'y a pas d'erreurs pointant vers notre configuration de recul et avançons.
Ajout de nouveaux commentaires
Dans cette section, nous ajoutons un bouton à la barre d'outils qui permet à l'utilisateur d'ajouter des commentaires (c'est-à-dire de créer un nouveau fil de commentaires) pour la plage de texte sélectionnée. Lorsque l'utilisateur sélectionne une plage de texte et clique sur ce bouton, nous devons procéder comme suit :
- Attribuez un ID unique au nouveau fil de commentaires en cours d'insertion.
- Ajoutez une nouvelle marque à la structure du document Slate avec l'ID afin que l'utilisateur voie ce texte en surbrillance.
- Ajoutez le nouveau fil de commentaires aux atomes de recul que nous avons créés dans la section précédente.
Ajoutons une fonction util à EditorCommentUtils
qui fait #1 et #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; }
En utilisant le concept de marques pour stocker chaque fil de commentaire comme sa propre marque, nous pouvons simplement utiliser l'API Editor.addMark
pour ajouter un nouveau fil de commentaire sur la plage de texte sélectionnée. Cet appel gère à lui seul tous les différents cas d'ajout de commentaires - dont certains ont été décrits dans la section précédente - commentaires qui se chevauchent partiellement, commentaires à l'intérieur/qui se chevauchent, commentaires sur du texte en gras/italique, commentaires couvrant des paragraphes, etc. Cet appel d'API ajuste la hiérarchie des nœuds pour créer autant de nouveaux nœuds de texte que nécessaire pour gérer ces cas.
addCommentThreadToState
est une fonction de rappel qui gère l'étape 3 - ajouter le nouveau fil de commentaires à Recoil atom . Nous l'implémentons ensuite en tant que crochet de rappel personnalisé afin qu'il soit réutilisable. Ce rappel doit ajouter le nouveau fil de commentaires aux deux atomes — commentThreadsState
et commentThreadIDsState
. Pour ce faire, nous utilisons le crochet useRecoilCallback
. Ce crochet peut être utilisé pour construire un rappel qui obtient quelques éléments pouvant être utilisés pour lire/définir des données atomiques. Celle qui nous intéresse en ce moment est la fonction set
qui peut être utilisée pour mettre à jour une valeur d'atome comme 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); }, [] ); }
Le premier appel à set
ajoute le nouvel ID à l'ensemble existant d'ID de fils de commentaires et renvoie le nouvel Set
(qui devient la nouvelle valeur de l'atome).
Dans le deuxième appel, nous obtenons l'atome pour l'ID de la famille d'atomes - commentThreadsState
en tant que commentThreadsState(id)
puis définissons le threadData
comme étant sa valeur. atomFamilyName(atomID)
est la façon dont Recoil nous permet d'accéder à un atome de sa famille d'atomes en utilisant la clé unique. En gros, nous pourrions dire que si commentThreadsState
était une carte javascript, cet appel est essentiellement — commentThreadsState.set(id, threadData)
.
Maintenant que nous avons tout ce code configuré pour gérer l'insertion d'un nouveau fil de commentaires dans le document et les atomes Recoil, ajoutons un bouton à notre barre d'outils et connectons-le avec l'appel à ces fonctions.
# 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> ); }
Note : Nous utilisons onMouseDown
et non onClick
qui aurait fait perdre le focus et la sélection à l'éditeur pour devenir null
. Nous en avons discuté un peu plus en détail dans la section d'insertion de lien du premier article.
Dans l'exemple ci-dessous, nous voyons l'insertion en action pour un fil de commentaires simple et un fil de commentaires superposé avec des liens. Remarquez comment nous obtenons des mises à jour de Recoil Debugger confirmant que notre état est correctement mis à jour. Nous vérifions également que de nouveaux nœuds de texte sont créés au fur et à mesure que des threads sont ajoutés au document.
Chevauchement de commentaires
Avant de procéder à l'ajout de fonctionnalités supplémentaires à notre système de commentaires, nous devons prendre des décisions sur la manière dont nous allons traiter les commentaires qui se chevauchent et leurs différentes combinaisons dans l'éditeur. Pour comprendre pourquoi nous en avons besoin, jetons un coup d'œil au fonctionnement d'un Comment Popover - une fonctionnalité que nous développerons plus tard dans l'article. Lorsqu'un utilisateur clique sur un certain texte contenant des fils de commentaires, nous "sélectionnons" un fil de commentaires et affichons une fenêtre contextuelle où l'utilisateur peut ajouter des commentaires à ce fil.
Comme vous pouvez le voir dans la vidéo ci-dessus, le mot "designers" fait désormais partie de trois fils de commentaires. Nous avons donc deux fils de commentaires qui se chevauchent sur un mot. Et ces deux fils de commentaires (#1 et #2) sont entièrement contenus dans une plage de texte de fil de commentaires plus longue (#3). Cela soulève quelques questions :
- Quel fil de commentaires devons-nous sélectionner et afficher lorsque l'utilisateur clique sur le mot "designers" ?
- En fonction de la manière dont nous décidons d'aborder la question ci-dessus, aurions-nous jamais un cas de chevauchement où cliquer sur un mot n'activerait jamais un certain fil de commentaires et le fil n'est pas du tout accessible ?
Cela implique que dans le cas de commentaires qui se chevauchent, la chose la plus importante à considérer est - une fois que l'utilisateur a inséré un fil de commentaires, y aurait-il un moyen pour lui de pouvoir sélectionner ce fil de commentaires à l'avenir en cliquant sur du texte à l'intérieur ce? Sinon, nous ne voulons probablement pas leur permettre de l'insérer en premier lieu. Pour garantir que ce principe soit respecté la plupart du temps dans notre éditeur, nous introduisons deux règles concernant les commentaires qui se chevauchent et les implémentons dans notre éditeur.
Avant de définir ces règles, il convient de rappeler que différents éditeurs et traitements de texte ont des approches différentes en ce qui concerne les commentaires qui se chevauchent. Pour garder les choses simples, certains éditeurs n'autorisent pas les commentaires qui se chevauchent. Dans notre cas, nous essayons de trouver un terrain d'entente en n'autorisant pas les cas de chevauchement trop compliqués, mais en autorisant toujours les commentaires qui se chevauchent afin que les utilisateurs puissent avoir une expérience de collaboration et de révision plus riche.
Règle de plage de commentaires la plus courte
Cette règle nous aide à répondre à la question n° 1 ci-dessus concernant le fil de commentaires à sélectionner si un utilisateur clique sur un nœud de texte contenant plusieurs fils de commentaires. La règle est :
"Si l'utilisateur clique sur un texte contenant plusieurs fils de commentaires, nous trouvons le fil de commentaires de la plage de texte la plus courte et le sélectionnons."
Intuitivement, il est logique de le faire pour que l'utilisateur ait toujours un moyen d'accéder au fil de commentaires le plus interne qui est entièrement contenu dans un autre fil de commentaires. Pour les autres conditions (chevauchement partiel ou pas de chevauchement), il devrait y avoir du texte qui n'a qu'un seul fil de commentaires, il devrait donc être facile d'utiliser ce texte pour sélectionner ce fil de commentaires. C'est le cas d'un chevauchement complet (ou dense ) de threads et c'est pourquoi nous avons besoin de cette règle.
Examinons un cas de chevauchement assez complexe qui nous permet d'utiliser cette règle et de "faire ce qu'il faut" lors de la sélection du fil de commentaires.
Dans l'exemple ci-dessus, l'utilisateur insère les fils de commentaires suivants dans cet ordre :
- Commentaire Thread #1 sur le caractère 'B' (longueur = 1).
- Commentez le fil #2 sur 'AB' (longueur = 2).
- Commentez le fil #3 sur 'BC' (longueur = 2).
À la fin de ces insertions, en raison de la façon dont Slate divise les nœuds de texte avec des marques, nous aurons trois nœuds de texte - un pour chaque caractère. Maintenant, si l'utilisateur clique sur 'B', en suivant la règle de la longueur la plus courte, nous sélectionnons le fil #1 car c'est le plus court des trois. Si nous ne le faisons pas, nous n'aurions aucun moyen de sélectionner le fil de commentaire n ° 1 puisqu'il ne contient qu'un seul caractère et fait également partie de deux autres fils.
Bien que cette règle facilite la mise en évidence de fils de commentaires plus courts, nous pourrions nous retrouver dans des situations où des fils de commentaires plus longs deviennent inaccessibles puisque tous les caractères qu'ils contiennent font partie d'un autre fil de commentaires plus court. Prenons un exemple pour cela.
Supposons que nous ayons 100 caractères (par exemple, le caractère "A" tapé 100 fois) et que l'utilisateur insère les fils de commentaires dans l'ordre suivant :
- Commentaire Thread #1 de la gamme 20,80
- Commentaire Filet #2 de la gamme 0,50
- Commentaire Thread # 3 de la gamme 51 100
Comme vous pouvez le voir dans l'exemple ci-dessus, si nous suivons la règle que nous venons de décrire ici, cliquer sur n'importe quel caractère entre #20 et #80, sélectionnerait toujours les fils #2 ou #3 puisqu'ils sont plus courts que #1 et donc #1 ne serait pas sélectionnable. Un autre scénario où cette règle peut nous laisser indécis quant au fil de commentaires à sélectionner est lorsqu'il y a plus d'un fil de commentaires de la même longueur la plus courte sur un nœud de texte.
Pour une telle combinaison de commentaires qui se chevauchent et de nombreuses autres combinaisons de ce type auxquelles on pourrait penser où suivre cette règle rend un certain fil de commentaires inaccessible en cliquant sur le texte, nous construisons une barre latérale de commentaires plus loin dans cet article qui donne à l'utilisateur une vue de tous les fils de commentaires présents dans le document afin qu'ils puissent cliquer sur ces fils de discussion dans la barre latérale et les activer dans l'éditeur pour voir la portée du commentaire. Nous voudrions toujours avoir cette règle et la mettre en œuvre car elle devrait couvrir de nombreux scénarios de chevauchement, à l'exception des exemples les moins probables que nous avons cités ci-dessus. Nous avons déployé tous ces efforts autour de cette règle principalement parce que voir le texte en surbrillance dans l'éditeur et cliquer dessus pour commenter est un moyen plus intuitif d'accéder à un commentaire sur le texte que d'utiliser simplement une liste de commentaires dans la barre latérale.
Règle d'insertion
La règle est :
"Si le texte que l'utilisateur a sélectionné et essaie de commenter est déjà entièrement couvert par le ou les fils de commentaires, n'autorisez pas cette insertion."
En effet, si nous autorisions cette insertion, chaque caractère de cette plage finirait par avoir au moins deux fils de commentaires (un existant et un autre le nouveau que nous venons d'autoriser), ce qui nous rend difficile de déterminer lequel sélectionner lorsque le l'utilisateur clique sur ce caractère plus tard.
En regardant cette règle, on peut se demander pourquoi nous en avons besoin en premier lieu si nous avons déjà la règle de plage de commentaires la plus courte qui nous permet de sélectionner la plus petite plage de texte. Pourquoi ne pas autoriser toutes les combinaisons de chevauchements si nous pouvons utiliser la première règle pour déduire le bon fil de commentaires à afficher ? Comme certains des exemples dont nous avons parlé précédemment, la première règle fonctionne pour de nombreux scénarios, mais pas pour tous. Avec la règle d'insertion, nous essayons de minimiser le nombre de scénarios où la première règle ne peut pas nous aider et nous devons nous rabattre sur la barre latérale comme seul moyen pour l'utilisateur d'accéder à ce fil de commentaires. La règle d'insertion empêche également les chevauchements exacts des fils de commentaires. Cette règle est couramment mise en œuvre par de nombreux éditeurs populaires.
Vous trouverez ci-dessous un exemple où si cette règle n'existait pas, nous autoriserions le fil de commentaires n° 3, puis à la suite de la première règle, le n° 3 ne serait pas accessible car il deviendrait le plus long.
Remarque : cette règle ne signifie pas que nous n'aurions jamais entièrement contenu les commentaires qui se chevauchent. Le problème avec les commentaires qui se chevauchent est que malgré les règles, l'ordre dans lequel les commentaires sont insérés peut toujours nous laisser dans un état dans lequel nous ne voulions pas que le chevauchement se produise. ' plus tôt, le fil de commentaires le plus long inséré était le dernier à être ajouté afin que la règle d'insertion le permette et nous nous retrouvons avec une situation entièrement contenue - # 1 et # 2 contenus dans # 3. C'est bien parce que la règle de la plage de commentaires la plus courte nous aiderait là-bas.
Nous implémenterons la règle de la plage de commentaires la plus courte dans la section suivante où nous implémenterons la sélection des fils de commentaires. Puisque nous avons maintenant un bouton de barre d'outils pour insérer des commentaires, nous pouvons implémenter la règle d'insertion immédiatement en vérifiant la règle lorsque l'utilisateur a sélectionné du texte. Si la règle n'est pas satisfaite, nous désactiverons le bouton Commentaire afin que les utilisateurs ne puissent pas insérer un nouveau fil de commentaires sur le texte sélectionné. Commençons!
# 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 logique de cette fonction est relativement simple.
- Si la sélection de l'utilisateur est un signe d'insertion clignotant, nous n'autorisons pas l'insertion d'un commentaire car aucun texte n'a été sélectionné.
- Si la sélection de l'utilisateur n'est pas réduite, nous trouvons tous les nœuds de texte dans la sélection. Notez l'utilisation du
mode: lowest
dans l'appel àEditor.nodes
(une fonction d'assistance de SlateJS) qui nous aide à sélectionner tous les nœuds de texte puisque les nœuds de texte sont vraiment les feuilles de l'arborescence du document. - S'il y a au moins un nœud de texte qui n'a pas de fils de commentaires, nous pouvons autoriser l'insertion. Nous utilisons l'
getCommentThreadsOnTextNode
que nous avons écrit plus tôt ici.
Nous utilisons maintenant cette fonction utilitaire dans la barre d'outils pour contrôler l'état désactivé du bouton.
# 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> );
Testons l'implémentation de la règle en recréant notre exemple ci-dessus.
Un détail précis de l'expérience utilisateur à souligner ici est que même si nous désactivons le bouton de la barre d'outils si l'utilisateur a sélectionné la ligne entière de texte ici, cela ne complète pas l'expérience pour l'utilisateur. L'utilisateur peut ne pas bien comprendre pourquoi le bouton est désactivé et est susceptible d'être confus car nous ne répondons pas à son intention d'y insérer un fil de commentaires. Nous aborderons ce problème plus tard, car les popovers de commentaires sont construits de telle sorte que même si le bouton de la barre d'outils est désactivé, le popover de l'un des fils de commentaires s'affichera et l'utilisateur pourra toujours laisser des commentaires.
Testons également un cas où il y a un nœud de texte non commenté et la règle permet d'insérer un nouveau fil de commentaire.
Sélection des fils de commentaires
Dans cette section, nous activons la fonctionnalité où l'utilisateur clique sur un nœud de texte commenté et nous utilisons la règle de la plage de commentaires la plus courte pour déterminer quel fil de commentaire doit être sélectionné. Les étapes du processus sont :
- Trouvez le fil de commentaires le plus court sur le nœud de texte commenté sur lequel l'utilisateur clique.
- Définissez ce fil de commentaires comme fil de commentaires actif. (Nous créons un nouvel atome de recul qui sera la source de vérité pour cela.)
- Les nœuds de texte commentés écouteraient l'état Recoil et s'ils faisaient partie du fil de commentaires actif, ils se mettraient en évidence différemment. De cette façon, lorsque l'utilisateur clique sur le fil de commentaires, toute la plage de texte se démarque car tous les nœuds de texte mettront à jour leur couleur de surbrillance.
Étape 1 : mise en œuvre de la règle de la plage de commentaires la plus courte
Commençons par l'étape 1 qui implémente essentiellement la règle de la plage de commentaires la plus courte. Le but ici est de trouver le fil de commentaires de la plage la plus courte au nœud de texte sur lequel l'utilisateur a cliqué. Pour trouver le fil le plus court, nous devons calculer la longueur de tous les fils de commentaires à ce nœud de texte. Les étapes pour ce faire sont :
- Obtenez tous les fils de commentaires au nœud de texte en question.
- Traversez dans les deux sens à partir de ce nœud de texte et continuez à mettre à jour les longueurs de fil suivies.
- Arrêtez la traversée dans une direction lorsque nous avons atteint l'un des bords ci-dessous :
- Un nœud de texte non commenté (ce qui implique que nous avons atteint le bord de début/fin le plus éloigné de tous les fils de commentaires que nous suivons).
- Un nœud de texte où tous les fils de commentaires que nous suivons ont atteint un bord (début/fin).
- Il n'y a plus de nœuds de texte à traverser dans cette direction (ce qui implique que nous avons atteint le début ou la fin du document ou un nœud non textuel).
Étant donné que les traversées dans les sens avant et arrière sont fonctionnellement les mêmes, nous allons écrire une fonction d'assistance updateCommentThreadLengthMap
qui prend essentiellement un itérateur de nœud de texte. Il continuera d'appeler l'itérateur et de mettre à jour les longueurs de thread de suivi. Nous appellerons cette fonction deux fois - une fois pour la direction avant et une fois pour la direction arrière. 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> ); }
Ce composant utilise useRecoilState
qui permet à un composant de s'abonner et également de définir la valeur de Recoil atom. Nous avons besoin que l'abonné sache si ce nœud de texte fait partie du fil de commentaires actif afin qu'il puisse se styler différemment. Regardez la capture d'écran ci-dessous où le fil de commentaires au milieu est actif et nous pouvons voir clairement sa portée.
Maintenant que nous avons tout le code pour faire fonctionner la sélection des fils de commentaires, voyons-le en action. Pour bien tester notre code de traversée, nous testons quelques cas simples de chevauchement et quelques cas limites comme :
- Cliquer sur un nœud de texte commenté au début/à la fin de l'éditeur.
- Cliquer sur un nœud de texte commenté avec des fils de commentaires couvrant plusieurs paragraphes.
- Cliquer sur un nœud de texte commenté juste avant un nœud d'image.
- Cliquer sur un nœud de texte commenté chevauchant des liens.
Comme nous avons maintenant un atome Recoil pour suivre l'ID du fil de commentaire actif, un petit détail à prendre en compte est de définir le fil de commentaire nouvellement créé comme actif lorsque l'utilisateur utilise le bouton de la barre d'outils pour insérer un nouveau fil de commentaire. Cela nous permet, dans la section suivante, d'afficher la fenêtre contextuelle du fil de commentaires immédiatement après l'insertion afin que l'utilisateur puisse commencer à ajouter des commentaires immédiatement.
# 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>; };
Remarque : L'utilisation de useSetRecoilState
ici (un hook Recoil qui expose un setter pour l'atome mais n'abonne pas le composant à sa valeur) est ce dont nous avons besoin pour la barre d'outils dans ce cas.
Ajout de popovers de fils de commentaires
Dans cette section, nous construisons une fenêtre contextuelle de commentaires qui utilise le concept de fil de commentaires sélectionné/actif et affiche une fenêtre contextuelle qui permet à l'utilisateur d'ajouter des commentaires à ce fil de commentaires. Avant de le construire, jetons un coup d'œil à son fonctionnement.
Lorsque vous essayez de rendre un Comment Popover près du fil de commentaires qui est actif, nous rencontrons certains des problèmes que nous avons rencontrés dans le premier article avec un Link Editor Menu. À ce stade, nous vous encourageons à lire la section du premier article qui construit un éditeur de liens et les problèmes de sélection que nous rencontrons avec cela.
Travaillons d'abord sur le rendu d'un composant popover vide au bon endroit en fonction du fil de commentaire actif. La façon dont le popover fonctionnerait est:
- Commentaire Thread Popover est affiché uniquement lorsqu'il existe un ID de fil de commentaire actif. Pour obtenir cette information, nous écoutons l'atome Recoil que nous avons créé dans la section précédente.
- Lorsqu'il est rendu, nous trouvons le nœud de texte à la sélection de l'éditeur et rendons le popover à proximité.
- Lorsque l'utilisateur clique n'importe où en dehors du popover, nous définissons le fil de commentaires actif sur
null
, ce qui désactive le fil de commentaires et fait également disparaître le 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> ); }
Deux choses qui devraient être appelées pour cette implémentation du composant popover :
- Il prend le
editorOffsets
et laselection
du composantEditor
où il serait rendu.editorOffsets
sont les limites du composant Editor afin que nous puissions calculer la position du popover et que laselection
puisse être la sélection actuelle ou précédente au cas où l'utilisateur utiliserait un bouton de la barre d'outils provoquant lanull
de laselection
. La section sur l'éditeur de liens du premier article lié ci-dessus les décrit en détail. - Depuis le
LinkEditor
du premier article et leCommentThreadPopover
ici, tous deux rendent un popover autour d'un nœud de texte, nous avons déplacé cette logique commune dans un composantNodePopover
qui gère le rendu du composant aligné sur le nœud de texte en question. Ses détails d'implémentation sont ce que le composantLinkEditor
avait dans le premier article. -
NodePopover
prend une méthodeonClickOutside
comme prop qui est appelée si l'utilisateur clique quelque part en dehors du popover. Nous implémentons cela en attachant un écouteur d'événementmousedown
audocument
- comme expliqué en détail dans cet article Smashing sur cette idée. -
getFirstTextNodeAtSelection
obtient le premier nœud de texte dans la sélection de l'utilisateur que nous utilisons pour afficher le popover. L'implémentation de cette fonction utilise les assistants de Slate pour trouver le nœud de texte.
# 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; }
Implémentons le rappel onClickOutside
qui devrait effacer le fil de commentaires actif. Cependant, nous devons tenir compte du scénario lorsque le popover du fil de commentaires est ouvert et qu'un certain fil est actif et que l'utilisateur clique sur un autre fil de commentaires. Dans ce cas, nous ne voulons pas que onClickOutside
réinitialise le fil de commentaires actif puisque l'événement click sur l'autre composant CommentedText
doit activer l'autre fil de commentaires. Nous ne voulons pas interférer avec cela dans le popover.
Pour ce faire, nous trouvons le nœud Slate le plus proche du nœud DOM où l'événement de clic s'est produit. Si ce nœud Slate est un nœud de texte et contient des commentaires, nous sautons la réinitialisation de l'atome Recoil du fil de commentaires actif. Mettons-le en œuvre !
# 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 a une méthode d'assistance toSlateNode
qui renvoie le nœud Slate qui correspond à un nœud DOM ou à son ancêtre le plus proche si lui-même n'est pas un nœud Slate. L'implémentation actuelle de cet assistant génère une erreur si elle ne trouve pas de nœud Slate au lieu de renvoyer null
. Nous gérons cela ci-dessus en vérifiant nous-mêmes le cas null
, ce qui est un scénario très probable si l'utilisateur clique quelque part en dehors de l'éditeur où les nœuds Slate n'existent pas.
Nous pouvons maintenant mettre à jour le composant Editor
pour écouter l' activeCommentThreadIDAtom
et afficher le popover uniquement lorsqu'un fil de commentaires est actif.
# 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> ... </> ); }
Vérifions que le popover se charge au bon endroit pour le bon fil de commentaires et efface le fil de commentaires actif lorsque nous cliquons à l'extérieur.
Nous allons maintenant permettre aux utilisateurs d'ajouter des commentaires à un fil de commentaires et de voir tous les commentaires de ce fil dans le popover. Nous allons utiliser la famille d'atomes Recoil - commentThreadsState
que nous avons créé plus tôt dans l'article pour cela.
Les commentaires d'un fil de commentaires sont stockés dans le tableau comments
. Pour permettre l'ajout d'un nouveau commentaire, nous rendons une entrée de formulaire qui permet à l'utilisateur d'entrer un nouveau commentaire. Pendant que l'utilisateur tape le commentaire, nous le maintenons dans une variable d'état locale — commentText
. En cliquant sur le bouton, nous ajoutons le texte du commentaire en tant que nouveau commentaire au tableau des 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> ); }
Remarque : Bien que nous rendions une entrée pour que l'utilisateur puisse saisir un commentaire, nous ne la laissons pas nécessairement prendre le focus lorsque le popover monte. Il s'agit d'une décision d'expérience utilisateur qui peut varier d'un éditeur à l'autre. Certains éditeurs ne permettent pas aux utilisateurs de modifier le texte lorsque la fenêtre contextuelle du fil de commentaires est ouverte. Dans notre cas, nous voulons pouvoir laisser l'utilisateur modifier le texte commenté lorsqu'il clique dessus.
Cela vaut la peine d'expliquer comment nous accédons aux données du fil de commentaire spécifique de la famille d'atomes Recoil - en appelant l'atome comme - commentThreadsState(threadID)
. Cela nous donne la valeur de l'atome et un setter pour mettre à jour uniquement cet atome dans la famille. Si les commentaires sont chargés paresseusement depuis le serveur, Recoil fournit également un crochet useRecoilStateLoadable
qui renvoie un objet Loadable qui nous informe sur l'état de chargement des données de l'atome. S'il est toujours en cours de chargement, nous pouvons choisir d'afficher un état de chargement dans le popover.
Maintenant, nous accédons au threadData
et rendons la liste des commentaires. Chaque commentaire est rendu par le composant CommentRow
.
# src/components/CommentThreadPopover.js return ( <NodePopover ... > <div className={"comment-list"}> {threadData.comments.map((comment, index) => ( <CommentRow key={`comment_${index}`} comment={comment} /> ))} </div> ... </NodePopover> );
Vous trouverez ci-dessous l'implémentation de CommentRow
qui restitue le texte du commentaire et d'autres métadonnées telles que le nom de l'auteur et l'heure de création. Nous utilisons le module date-fns
pour afficher une heure de création formatée.
# 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> ); }
Nous l'avons extrait pour en faire son propre composant car nous le réutiliserons plus tard lorsque nous implémenterons la barre latérale de commentaires.
À ce stade, notre Comment Popover a tout le code dont il a besoin pour permettre l'insertion de nouveaux commentaires et la mise à jour de l'état Recoil pour le même. Vérifions cela. Sur la console du navigateur, à l'aide de l'observateur de débogage de recul que nous avons ajouté précédemment, nous sommes en mesure de vérifier que l'atome Recoil du fil de commentaires est correctement mis à jour lorsque nous ajoutons de nouveaux commentaires au fil.
Ajout d'une barre latérale de commentaires
Plus tôt dans l'article, nous avons expliqué pourquoi parfois, il peut arriver que les règles que nous avons implémentées empêchent un certain fil de commentaires de ne pas être accessible en cliquant uniquement sur son ou ses nœuds de texte - en fonction de la combinaison de chevauchement. Dans de tels cas, nous avons besoin d'une barre latérale de commentaires qui permet à l'utilisateur d'accéder à tous les fils de commentaires du document.
Une barre latérale de commentaires est également un bon ajout qui s'intègre dans un flux de travail de suggestion et de révision où un réviseur peut parcourir tous les fils de commentaires les uns après les autres et être en mesure de laisser des commentaires/réponses là où il en ressent le besoin. Avant de commencer à implémenter la barre latérale, il y a une tâche inachevée dont nous nous occupons ci-dessous.
Initialisation de l'état de recul des fils de commentaires
Lorsque le document est chargé dans l'éditeur, nous devons scanner le document pour trouver tous les fils de commentaires et les ajouter aux atomes Recoil que nous avons créés ci-dessus dans le cadre du processus d'initialisation. Écrivons une fonction utilitaire dans EditorCommentUtils
qui analyse les nœuds de texte, trouve tous les fils de commentaires et les ajoute à l'atome 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", }) ); }
Synchronisation avec le stockage principal et considérations de performances
Pour le contexte de l'article, comme nous nous concentrons uniquement sur l'implémentation de l'interface utilisateur, nous les initialisons simplement avec des données qui nous permettent de confirmer que le code d'initialisation fonctionne.
Dans l'utilisation réelle du système de commentaires, les fils de commentaires sont susceptibles d'être stockés séparément du contenu du document lui-même. Dans un tel cas, le code ci-dessus devrait être mis à jour pour effectuer un appel d'API qui récupère toutes les métadonnées et les commentaires sur tous les ID de fil de commentaire dans commentThreads
. Une fois les fils de commentaires chargés, ils sont susceptibles d'être mis à jour au fur et à mesure que plusieurs utilisateurs y ajoutent des commentaires en temps réel, modifient leur statut, etc. La version de production du système de commentaires devrait structurer le stockage Recoil de manière à ce que nous puissions continuer à le synchroniser avec le serveur. Si vous choisissez d'utiliser Recoil pour la gestion de l'état, il existe quelques exemples sur l'API Atom Effects (expérimental au moment de la rédaction de cet article) qui font quelque chose de similaire.
Si un document est très long et que de nombreux utilisateurs y collaborent sur de nombreux fils de commentaires, nous devrons peut-être optimiser le code d'initialisation pour ne charger les fils de commentaires que pour les premières pages du document. Alternativement, nous pouvons choisir de ne charger que les métadonnées légères de tous les fils de commentaires au lieu de la liste complète des commentaires qui est probablement la partie la plus lourde de la charge utile.
Passons maintenant à l'appel de cette fonction lorsque le composant Editor
est monté avec le document afin que l'état Recoil soit correctement initialisé.
# 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 ( <> ... </> ); }
Nous utilisons le même crochet personnalisé - useAddCommentThreadToState
que nous avons utilisé avec l'implémentation du bouton de commentaire de la barre d'outils pour ajouter de nouveaux fils de commentaires. Puisque le popover fonctionne, nous pouvons cliquer sur l'un des fils de commentaires préexistants dans le document et vérifier qu'il affiche les données que nous avons utilisées pour initialiser le fil ci-dessus.
Maintenant que notre état est correctement initialisé, nous pouvons commencer à implémenter la barre latérale. Tous nos fils de commentaires dans l'interface utilisateur sont stockés dans la famille d'atomes Recoil — commentThreadsState
. Comme souligné précédemment, la façon dont nous parcourons tous les éléments d'une famille d'atomes Recoil consiste à suivre les clés/identifiants d'atome dans un autre atome. Nous avons fait cela avec commentThreadIDsState
. Ajoutons le composant CommentSidebar
qui parcourt l'ensemble des identifiants de cet atome et affiche un composant CommentThread
pour chacun.
# 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> ); }
Maintenant, nous implémentons le composant CommentThread
qui écoute l'atome Recoil dans la famille correspondant au fil de commentaires qu'il rend. De cette façon, lorsque l'utilisateur ajoute plus de commentaires sur le fil dans l'éditeur ou modifie d'autres métadonnées, nous pouvons mettre à jour la barre latérale pour refléter cela.
Comme la barre latérale peut devenir très grande pour un document avec beaucoup de commentaires, nous masquons tous les commentaires sauf le premier lorsque nous rendons la barre latérale. L'utilisateur peut utiliser le bouton "Afficher/Masquer les réponses" pour afficher/masquer l'intégralité du fil de commentaires.
# 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> ); }
Nous avons réutilisé le composant CommentRow
du popover, bien que nous ayons ajouté un traitement de conception à l'aide de la showConnector
qui fait en sorte que tous les commentaires semblent connectés à un fil dans la barre latérale.
Maintenant, nous rendons le CommentSidebar
dans l' Editor
et vérifions qu'il affiche tous les fils de discussion que nous avons dans le document et qu'il est correctement mis à jour lorsque nous ajoutons de nouveaux fils de discussion ou de nouveaux commentaires aux fils de discussion existants.
# src/components/Editor.js return ( <> <Slate ... > ..... <div className={"sidebar-wrapper"}> <CommentsSidebar /> </div> </Slate> </> );
Nous passons maintenant à la mise en œuvre d'une interaction populaire de la barre latérale de commentaires trouvée dans les éditeurs :
Cliquer sur un fil de commentaires dans la barre latérale devrait sélectionner/activer ce fil de commentaires. Nous ajoutons également un traitement de conception différentiel pour mettre en évidence un fil de commentaires dans la barre latérale s'il est actif dans l'éditeur. Pour ce faire, nous utilisons l'atome Recoil - activeCommentThreadIDAtom
. Mettons à jour le composant CommentThread
pour prendre en charge cela.
# 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> );
Si nous regardons attentivement, nous avons un bogue dans notre implémentation de la synchronisation du fil de commentaires actif avec la barre latérale. Lorsque nous cliquons sur différents fils de commentaires dans la barre latérale, le bon fil de commentaires est en effet mis en surbrillance dans l'éditeur. Cependant, le Comment Popover ne se déplace pas réellement vers le fil de commentaires actif modifié. Il reste là où il a été rendu pour la première fois. Si nous regardons l'implémentation du Comment Popover, il s'affiche par rapport au premier nœud de texte dans la sélection de l'éditeur. À ce stade de l'implémentation, la seule façon de sélectionner un fil de commentaires était de cliquer sur un nœud de texte afin que nous puissions facilement nous fier à la sélection de l'éditeur puisqu'elle a été mise à jour par Slate à la suite de l'événement click. Dans l'événement onClick
ci-dessus, nous ne mettons pas à jour la sélection, mais nous mettons simplement à jour la valeur de l'atome Recoil, ce qui fait que la sélection de Slate reste inchangée et, par conséquent, le Comment Popover ne bouge pas.
Une solution à ce problème consiste à mettre à jour la sélection de l'éditeur avec la mise à jour de l'atome Recoil lorsque l'utilisateur clique sur le fil de commentaires dans la barre latérale. Les étapes à suivre sont :
- Trouvez tous les nœuds de texte contenant ce fil de commentaires que nous allons définir comme nouveau fil actif.
- Triez ces nœuds de texte dans l'ordre dans lequel ils apparaissent dans le document (nous utilisons l'API
Path.compare
de Slate pour cela). - Calculez une plage de sélection qui s'étend du début du premier nœud de texte à la fin du dernier nœud de texte.
- Définissez la plage de sélection comme étant la nouvelle sélection de l'éditeur (à l'aide de l'API
Transforms.select
de Slate).
Si nous voulions simplement corriger le bogue, nous pourrions simplement trouver le premier nœud de texte à l'étape 1 qui contient le fil de commentaires et le définir comme étant la sélection de l'éditeur. Cependant, cela ressemble à une approche plus propre pour sélectionner toute la plage de commentaires, car nous sélectionnons vraiment le fil de commentaires.
Mettons à jour l'implémentation du rappel onClick
pour inclure les étapes ci-dessus.
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]);
Remarque : allTextNodePaths
contient le chemin vers tous les nœuds de texte. Nous utilisons l'API Editor.point
pour obtenir les points de début et de fin de ce chemin. Le premier article passe en revue les concepts de localisation de Slate. Ils sont également bien documentés dans la documentation de Slate.
Vérifions que cette implémentation corrige le bogue et que le Comment Popover se déplace correctement vers le fil de commentaires actif. Cette fois, nous testons également avec un cas de fils qui se chevauchent pour nous assurer qu'il ne se casse pas là.
Avec le correctif de bogue, nous avons activé une autre interaction de la barre latérale dont nous n'avons pas encore parlé. Si nous avons un document très long et que l'utilisateur clique sur un fil de commentaires dans la barre latérale qui se trouve en dehors de la fenêtre d'affichage, nous voudrions faire défiler jusqu'à cette partie du document afin que l'utilisateur puisse se concentrer sur le fil de commentaires dans l'éditeur. En définissant la sélection ci-dessus à l'aide de l'API de Slate, nous l'obtenons gratuitement. Voyons-le en action ci-dessous.
Avec cela, nous terminons notre implémentation de la barre latérale. Vers la fin de l'article, nous énumérons quelques ajouts et améliorations de fonctionnalités intéressantes que nous pouvons apporter à la barre latérale des commentaires qui aident à améliorer l'expérience de commentaire et de révision sur l'éditeur.
Résoudre et rouvrir les commentaires
Dans cette section, nous nous concentrons sur la possibilité pour les utilisateurs de marquer les fils de commentaires comme "résolus" ou de pouvoir les rouvrir pour discussion si nécessaire. Du point de vue des détails de l'implémentation, il s'agit des métadonnées d' status
d'un fil de commentaires que nous modifions lorsque l'utilisateur effectue cette action. Du point de vue de l'utilisateur, il s'agit d'une fonctionnalité très utile car elle leur donne un moyen d'affirmer que la discussion sur quelque chose sur le document est terminée ou doit être rouverte car il y a des mises à jour/nouvelles perspectives, etc.
Pour activer le basculement du statut, nous ajoutons un bouton au CommentPopover
qui permet à l'utilisateur de basculer entre les deux statuts : open
et 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> ); }
Avant de tester cela, donnons également à la barre latérale des commentaires un traitement de conception différentiel pour les commentaires résolus afin que l'utilisateur puisse facilement détecter les fils de commentaires non résolus ou ouverts et se concentrer sur ceux-ci s'il le souhaite.
# 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> ); }
Conclusion
Dans cet article, nous avons construit l'infrastructure d'interface utilisateur de base pour un système de commentaires sur un éditeur de texte enrichi. L'ensemble de fonctionnalités que nous ajoutons ici sert de base pour créer une expérience de collaboration plus riche sur un éditeur où les collaborateurs peuvent annoter des parties du document et avoir des conversations à leur sujet. L'ajout d'une barre latérale de commentaires nous donne un espace pour avoir plus de fonctionnalités de conversation ou de révision à activer sur le produit.
Dans ce sens, voici quelques-unes des fonctionnalités qu'un éditeur de texte enrichi pourrait envisager d'ajouter en plus de ce que nous avons construit dans cet article :
- Prise en charge des mentions
@
afin que les collaborateurs puissent se taguer les uns les autres dans les commentaires ; - Prise en charge des types de médias tels que les images et les vidéos à ajouter aux fils de commentaires ;
- Mode de suggestion au niveau du document qui permet aux réviseurs d'apporter des modifications au document qui apparaissent sous forme de suggestions de modifications. On pourrait se référer à cette fonctionnalité dans Google Docs ou Change Tracking dans Microsoft Word à titre d'exemples ;
- Améliorations apportées à la barre latérale pour rechercher des conversations par mot-clé, filtrer les discussions par statut ou auteur(s) de commentaires, etc.