Fermer

mai 28, 2021

Ajout d'un système de commentaires à un éditeur WYSIWYG


À propos de l'auteur

Shalabh Vyas est un ingénieur front-end avec l'expérience de travailler tout au long du cycle de vie du développement de produits en lançant des applications Web riches. …
En savoir plus sur
Shalabh

Dans cet article, nous réutiliserons l'éditeur WYSIWYG de base intégré dans le premier article pour créer un système de commentaires pour un éditeur WYSIWYG qui permet aux utilisateurs de sélectionner du texte dans un document et de le partager leurs commentaires à ce sujet. Nous allons également intégrer RecoilJS pour la gestion de l'état dans l'application d'interface utilisateur. (Le code du système que nous construisons ici est disponible sur un référentiel Github pour référence.)

Ces dernières années, nous avons vu la collaboration pénétrer de nombreux workflows numériques et cas d'utilisation dans de nombreuses professions . Juste au sein de la communauté de conception et de génie logiciel, nous voyons des concepteurs collaborer sur des artefacts de conception à l'aide d'outils tels que Figma des équipes effectuant des sprints et de la planification de projets à l'aide d'outils tels que Mural et des entretiens menés avec ]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 capacité de partager des opinions les uns avec les autres et 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 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 Traitement de texte en général.

Afin de trouver un moyen de représenter les commentaires dans la structure de données d'un document de texte enrichi, examinons quelques scénarios dans lesquels des commentaires pourraient être créés dans un éditeur.

  • Commentaires créés sur du texte contenant aucun style dessus (scénario de base) ;
  • Commentaires créés sur du texte qui peut être en gras/italique/souligné, et ainsi de suite ;
  • 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 contenus lorsque 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 tha t s'étendent sur plusieurs paragraphes (spécial parce que les paragraphes sont des nœuds dans la structure de notre document et que 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 des commentaires dans la façon dont ils peuvent apparaître un document de texte enrichi est très similaire 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 " Marques »(comme on les appelle dans la terminologie SlateJS). Les marques ne sont que des propriétés normales 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 au même plage de texte. Ceci est extrêmement utile pour nous car nous traitons de nombreuses combinaisons différentes de commentaires qui se chevauchent.

Chaque fois qu'un utilisateur sélectionne une plage de texte et essaie d'insérer un commentaire, techniquement, il démarre un nouveau fil de commentaires pour ce texte intervalle. Parce que nous leur permettions 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 commentaire dans le document.

La façon dont nous représentons les fils de commentaire comme des marques est que chaque fil de commentaire est représenté par un marque nommée commentThread_threadID threadID est un identifiant unique que nous attribuons à chaque fil de commentaire. Ainsi, si la même plage de texte comporte deux fils de commentaires, deux propriétés sont 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 nous plonger dans la configuration de cette structure, il vaut la peine de regarder comment les nœuds de texte changent à mesure que les threads de commentaires leur sont appliqués. La façon dont cela fonctionne (comme pour 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 diviserait 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 que la marque.

Pour mieux comprendre cela, jetez un œil aux trois exemples suivants qui montrent l'état avant-après des nœuds de texte une fois qu'un fil de commentaire est inséré sur le texte sélectionné:

 Illustration montrant comment le nœud de texte est divisé avec une insertion de fil de commentaire de base
Un nœud de texte se scindant en trois comme une marque de fil de commentaire est insérée dans le milieu du texte. ( Grand aperçu )
 Illustration montrant comment le nœud de texte est divisé en cas de chevauchement partiel des fils de commentaires
L’ajout d’un fil de commentaires sur ‘text has’ crée deux nouveaux nœuds de texte. ( Grand aperçu)
Illustration montrant comment le nœud de texte est divisé en cas de chevauchement partiel des fils de commentaires avec des liens
L'ajout d'un fil de commentaire sur « a un lien » divise le nœud de texte à l'intérieur du lien trop. ( Grand aperçu )

] Maintenant que nous savons comment nous allons représenter les commentaires dans la structure du document, allons-y et ajoutons-en quelques-uns au document d'exemple du premier article et configurons l'éditeur pour les afficher comme surlignés. Puisque 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 thread de commentaire donné. Nous utilisons ensuite cela pour insérer quelques fils de commentaires dans notre ExampleDocument .

 # src / utils / EditorCommentUtils.js

const COMMENT_THREAD_PREFIX = "commentThread_";

fonction d'exportation 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» comporte 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.

 Image montrant les plages de texte du document qui seront commentées - l'une d'entre elles étant entièrement contenue dans une autre.
Plages de texte qui seraient être commenté souligné en rouge. ( Grand aperçu )
 # src / utils / ExampleDocument.js
import {getMarkForCommentThreadID} depuis "../utils/EditorCommentUtils";
import {v4 as uuid} depuis "uuid";

const exampleOverlappingCommentThreadID = uuid ();

const ExampleDocument = [
   ...
   {
        text: "Lorem ipsum",
        [getMarkForCommentThreadID(uuid())] : vrai,
   },
   ...
   {
        texte: "Richard McClintock",
        // notez les deux fils de commentaires ici.
        [getMarkForCommentThreadID(uuid())] : vrai,
        [getMarkForCommentThreadID(exampleOverlappingCommentThreadID)] : vrai,
   },
   {
        texte: ", un savant latin",
        [getMarkForCommentThreadID(exampleOverlappingCommentThreadID)]: vrai,
   },
   ...
]; 

Nous nous concentrons sur le côté UI des éléments d'un système de commentaires dans cet article, nous leur attribuons donc des identifiants dans l'exemple de document directement en utilisant le package npm uuid . Il est très probable que dans une version de production d'un éditeur, ces identifiants soient créés par un service backend.

Nous nous concentrons maintenant sur l'ajustement 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 util 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 pourrait essayer de rendre un nœud de texte avec des commentaires. Puisque nous avons d'autres fonctionnalités à venir qui seraient ajoutées aux nœuds de texte commentés plus tard, nous créons un composant CommentedText qui rend le texte commenté. StyledText vérifiera si le nœud de texte qu'il essaie de restituer contient des commentaires. Si c'est le cas, il affiche CommentedText. Il utilise un util getCommentThreadsOnTextNode pour en déduire.

# src/utils/EditorCommentUtils.js

fonction d'exportation getCommentThreadsOnTextNode(textNode) {
  retourner un nouvel ensemble(
     // Parce que les marques ne sont que des propriétés sur les nœuds,
    // nous pouvons simplement utiliser Object.keys() ici.
    Object.keys(textNode)
      .filter(isCommentThreadIDMark)
      .map(getCommentThreadIDFromMark)
  );
}

fonction d'exportation getCommentThreadIDFromMark (marque) {
  if (! isCommentThreadIDMark (marque)) {
    throw new Error("La marque devrait être celle d'un fil de commentaires");
  }
  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 afficher un composant CommentedText si le nœud contient des commentaires.

 # src / components / StyledText.js

importer { getCommentThreadsOnTextNode } de "../utils/EditorCommentUtils" ;

exporter la fonction par défaut StyledText({ attributs, enfants, feuille }) {
  ...

  const commentThreads = getCommentThreadsOnTextNode(leaf);

  if (commentThreads.size> 0) {
    revenir (
      
        {enfants}
      
    );
  }

  retour  {enfants} ;
} 

Vous trouverez ci-dessous l'implémentation de CommentedText qui restitue le nœud de texte et attache le CSS qui le montre comme mis en surbrillance.

 # src / components / CommentedText.js

import "./CommentedText.css";

importer des noms de classe à partir de "noms de classe";

fonction d'exportation par défaut CommentedText (accessoires) {
  const {commentThreads, ... otherProps} = accessoires;
  revenir (
    
      {accessoires.enfants}
    
  );
}

# src/components/CommentedText.css

.commenter {
  couleur d'arrière-plan: # feeab5;
} 

Avec tout le code ci-dessus réuni, nous voyons maintenant les nœuds de texte avec les fils de commentaires mis en surbrillance dans l'éditeur.

 Les nœuds de texte commentés apparaissent comme surlignés après l'insertion des fils de commentaires
Les nœuds de texte commentés apparaissent en surbrillance après l'insertion des fils de commentaires. ( Grand aperçu)

Remarque : Actuellement, les utilisateurs ne peuvent pas savoir si certains textes comportent des commentaires qui se chevauchent. L'ensemble de la plage de texte en surbrillance ressemble à un fil de commentaire unique. Nous abordons cela plus loin dans l'article où nous introduisons le concept de fil de commentaire actif qui permet aux utilisateurs de sélectionner un fil de commentaire spécifique et de voir sa gamme dans l'éditeur.

Avant d'ajouter la fonctionnalité qui permet à un utilisateur pour insérer de nouveaux commentaires, nous avons d'abord configuré un état de l'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 :[19659048]> fil ajouter recoil

Nous utilisons Recoil atomes 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 souhaitez généralement configurer différents atomes. Atom Family est une collection d'atomes — on peut penser qu'il s'agit d'une Map à partir d'une clé unique identifiant l'atome jusqu'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 threads de commentaires dans une famille Atom, puis enveloppons notre application dans un RecoilRoot composant. RecoilRoot est appliqué pour fournir le contexte dans lequel les valeurs d'atome vont être utilisées. Nous créons un module séparé CommentState qui contient nos définitions d'atome Recoil au fur et à mesure que nous ajoutons d'autres définitions d'atome plus loin dans l'article.

 # src / utils / CommentState.js

import {atome, atomFamily} depuis "recoil";

export const commentThreadsState = atomFamily({
  clé: "commentThreads",
  par défaut : [],
});

export const commentThreadIDsState = atom ({
  clé: "commentThreadIDs",
  par défaut: nouvel ensemble ([]),
}); 

Il vaut la peine de rappeler quelques points sur ces définitions d'atome:

  • Chaque famille d'atome / atome est identifiée de manière unique par une clé et peut être configurée avec une valeur par défaut.
  • As nous construisons plus loin dans cet article, nous allons avoir besoin d'un moyen d'itérer sur tous les threads 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 faire cela avec Recoil est de configurer un autre atome contenant tous les ID de la famille des atomes. Nous le faisons avec commentThreadIDsState 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 racine App afin que nous puissions utiliser ces atomes plus tard . La documentation de Recoil fournit également un composant Debugger utile que nous prenons tel quel et que nous laissons tomber dans notre éditeur. Ce composant laissera les journaux console.debug sur notre console de développement car les atomes de Recoil sont mis à jour en temps réel.

# src/components/App.js

importer {RecoilRoot} depuis "recoil";

Exporter la fonction par défaut App () {
  ...

  revenir (
    
      >
         ...
        
    
    
  );
}
# src/components/Editor.js

Exporter l'éditeur de fonction par défaut ({...}): JSX.Element {
  .....

  revenir (
    <>
      
         .....
      
      
   >
);

function DebugObserver (): React.Node {
   // voir le lien API ci-dessus pour la mise en œuvre.
} 

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 plus tard lorsque nous construisons la barre latérale Commentaires qui doit lire tous les fils de commentaires dans un document.

À ce stade, nous chargeons notre application, nous nous assurons qu'il n'y a pas d'erreurs pointant vers notre configuration Recoil et nous nous déplaçons

Dans cette section, nous ajoutons un bouton à la barre d'outils qui permet à l'utilisateur d'ajouter des commentaires (c'est-à-dire 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 :

  1. Attribuez un identifiant unique au nouveau fil de commentaire en cours d'insertion.
  2. Ajoutez une nouvelle marque à la structure du document Slate avec l'identifiant ainsi l'utilisateur voit ce texte en surbrillance.
  3. Ajoutez le nouveau fil de commentaire 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.[19659069]# src/utils/EditorCommentUtils.js

importer { Éditeur } de "slate" ;
import {v4 as uuidv4} depuis "uuid";

fonction d'exportation insertCommentThread (éditeur, addCommentThreadToState) {
const threadID = uuidv4();
const newCommentThread = {
// les commentaires ajoutés seraient ajoutés au fil de discussion ici.
commentaires : [],
creationTime: nouvelle date (),
// Les fils de commentaires nouvellement créés sont OUVERTS. Nous traitons les statuts
// plus loin dans l'article.
statut: "ouvert",
};
addCommentThreadToState (threadID, newCommentThread);
Editor.addMark (éditeur, 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 à lui seul gère tous les différents cas d'ajout de commentaires – dont certains ont été décrits dans la section précédente – commentaires se chevauchant partiellement, commentaires à l'intérieur de liens / chevauchant, 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 – l'ajout du 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 hook useRecoilCallback . Ce hook peut être utilisé pour construire un callback qui obtient quelques éléments qui peuvent être utilisés pour lire / définir des données atom. Celle qui nous intéresse actuellement est la fonction set qui peut être utilisée pour mettre à jour une valeur d'atome en tant que set(atom, newValueOrUpdaterFunction).

# src/hooks/ useAddCommentThreadToState.js

importer {
  commentThreadIDsState,
  commentThreadsState,
} de "../utils/CommentState" ;

import {useRecoilCallback} depuis "recoil";

fonction d'exportation par défaut useAddCommentThreadToState () {
  return useRecoilCallback(
    ({ définir }) => (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 thread de commentaire et renvoie le nouveau Set(qui devient la nouvelle valeur de l'atome).[19659006]Dans le deuxième appel, nous obtenons l'atome pour l'ID de la famille d'atomes — commentThreadsState as commentThreadsState(id) puis définissons le threadData 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 setup pour gérer l'insertion d'un nouveau fil de commentaire dans le document et les atomes de recul, ajoutons un bouton à notre barre d'outils et connectons-le avec l'appel à ces fonctions.

 # src / components / Toolbar.js

import {insertCommentThread} de "../utils/EditorCommentUtils";
import useAddCommentThreadToState de "../hooks/useAddCommentThreadToState";

Exporter la barre d'outils de la fonction par défaut ({selection, previousSelection}) {
  éditeur const = useEditor();
  ...

  const addCommentThread = useAddCommentThreadToState ();

  const onInsertComment = useCallback(() => {
    const newCommentThreadID = insertCommentThread (éditeur, addCommentThread);
  }, [editor, addCommentThread]);
 
revenir (
    
... <ToolBarButton isActive = {false} étiquette={} onMouseDown={onInsertComment} />
); }

Note : Nous utilisons onMouseDown et non onClick ce 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 simple fil de commentaire et un fil de commentaires qui se chevauchent avec des liens. Remarquez comment nous obtenons des mises à jour de Recoil Debugger confirmant que notre état est mis à jour correctement. Nous vérifions également que de nouveaux nœuds de texte sont créés lorsque des fils sont ajoutés au document.

L'insertion d'un fil de commentaire divise le nœud de texte en faisant du texte commenté son propre nœud.
D'autres nœuds de texte sont créés lorsque nous ajoutons des commentaires qui se chevauchent .

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 voir 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 un ou plusieurs 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.

Lorsque l'utilisateur clique sur un nœud de texte avec les commentaires qui se chevauchent, l'éditeur doit décider quel fil de commentaires sélectionner.

Comme vous pouvez le voir dans la vidéo ci-dessus, le mot «concepteurs» 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 (n ° 1 et n ° 2) sont entièrement contenus dans une plage de texte de fil de commentaire plus longue (n ° 3). Cela soulève quelques questions :

  1. Quel fil de commentaires devons-nous sélectionner et afficher lorsque l'utilisateur clique sur le mot « designers » ?
  2. Selon la façon 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 de discussion n'est pas accessible du tout?

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 eux de pouvoir sélectionner ce fil de commentaire à l'avenir en cliquant sur un texte à l'intérieur ? Sinon, nous ne voulons probablement pas leur permettre de l'insérer en premier lieu. Pour nous assurer que ce principe est 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 vaut la peine de signaler que différents éditeurs et les traitements de texte ont des approches différentes en ce qui concerne les commentaires qui se chevauchent. Pour simplifier les choses, certains éditeurs n'autorisent pas le chevauchement des commentaires. Dans notre cas, nous essayons de trouver un terrain d'entente en n'autorisant pas les cas trop compliqués de chevauchements 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.

Cette règle nous aide à répondre à la question n° 1 ci-dessus quant au fil de commentaires à sélectionner si un utilisateur clique sur un nœud de texte contenant plusieurs fils de commentaires. La règle est la suivante:

"Si l'utilisateur clique sur du texte contenant plusieurs fils de commentaires, nous trouvons le fil de commentaire de la plage de texte la plus courte et le sélectionnons."

Intuitivement, il est logique de le faire pour que l'utilisateur a 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 d'autres conditions (chevauchement partiel ou non-chevauchement), il devrait y avoir du texte qui n'a qu'un seul fil de commentaire dessus, il devrait donc être facile d'utiliser ce texte pour sélectionner ce fil de commentaire. C'est le cas d'un chevauchement complet (ou d'un dense) de threads et pourquoi nous avons besoin de cette règle.

Regardons un cas de chevauchement assez complexe qui nous permet d'utiliser cette règle et de "faire le bonne chose 'lors de la sélection du fil de commentaire.

 Exemple montrant trois fils de commentaires se chevauchant de manière à ce que la seule façon de sélectionner un fil de commentaire soit d'utiliser la règle de longueur la plus courte.
En suivant la règle de fil de commentaire le plus court, cliquez sur sur 'B' sélectionne le fil de commentaire n ° 1. ( Grand aperçu )

Dans l'exemple ci-dessus, l'utilisateur insère les fils de commentaires suivants dans cet ordre:

  1. Discussion de commentaire n ° 1 sur le caractère 'B' (longueur = 1).
  2. Fil de commentaire n° 2 sur 'AB' (longueur = 2).
  3. Thread de commentaire n° 3 sur 'BC' (longueur = 2).

À la fin de ces insertions, à cause de la façon dont Slate divise les nœuds de texte avec les 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 longueur la plus courte, nous sélectionnons le fil de discussion n ° 1 car il est le plus court des trois. Si nous ne le faisons pas, nous n'aurions pas le moyen de sélectionner le fil de discussion n ° 1 puisqu'il ne comporte qu'un seul caractère et fait également partie de deux autres threads.

Bien que cette règle facilite le surface des fils de commentaires de plus courte longueur, nous pourrions nous heurter à des situations où les 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. Regardons 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:

  1. Discussion de commentaire n ° 1 de la plage 20,80
  2. Discussion de commentaire n ° 2 de plage 0,50
  3. Discussion de commentaire n ° 3 de plage 51,100
 Exemple montrant la règle de longueur la plus courte rendant un fil de commentaire non sélectionnable car tout son texte est couvert par un commentaire plus court threads.
Tout le texte sous le fil de commentaire #1 fait également partie d'un autre fil de commentaire plus court que #1. ( Grand aperçu)

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 car 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, on pourrait penser à l'endroit 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 dans la barre latérale et activez-les 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 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 du texte que d'utiliser simplement une liste de commentaires dans la barre latérale.

Règle d'insertion.

La règle est la suivante:

"Si le texte que l'utilisateur a sélectionné et essaie de commenter est déjà entièrement couvert par le (s) fil (s) de commentaires, n'autorisez pas cette insertion."

En effet, si nous avons autorisé 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 l'utilisateur clique sur ce caractère

En regardant cette règle, on pourrait 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 l'on peut utiliser la première règle pour en 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 replier 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 implémentée par de nombreux éditeurs populaires.

Vous trouverez ci-dessous un exemple où si cette règle n'existait pas, nous autoriserions le fil de commentaire n ° 3, puis en raison de la première règle, n ° 3 ne

Règle d'insertion n'autorisant pas un troisième fil de commentaires dont la plage de texte entière est couverte par deux autres fils de commentaires.

Note : Cette règle ne permet pas ' Cela veut dire que nous n’aurions jamais complètement contenu des 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 soit. En revenant à notre exemple des commentaires sur le mot « designers » 'plus tôt, le fil de discussion le plus long inséré était le dernier à être ajouté, donc la règle d'insertion le permettrait et nous nous retrouvons avec une situation entièrement contenue – # 1 et # 2 contenues dans # 3. C'est très bien car la règle de la plage de commentaires la plus courte nous aiderait là-bas.

Nous allons mettre en œuvre la règle de plage de commentaires la plus courte dans la prochaine section 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 tout de suite 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

la fonction d'exportation devraitAllowNewCommentThreadAtSelection(éditeur, sélection) {
  if (sélection == null || Range.isCollapsed (sélection)) {
    retourne faux;
  }

  const textNodeIterator = Editor.nodes(éditeur, {
    à : sélection,
    mode: "le plus bas",
  });

  let nextTextNodeEntry = textNodeIterator.next (). value;
  const textNodeEntriesInSelection = [];
  while (nextTextNodeEntry! = null) {
    textNodeEntriesInSelection.push (nextTextNodeEntry);
    nextTextNodeEntry = textNodeIterator.next (). value;
  }

  if (textNodeEntriesInSelection.length === 0) {
    retourne faux;
  }

  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 curseur clignotant, nous ne permettons pas d'y insérer un commentaire car aucun texte n'a été sélectionné.
  • Si la sélection de l'utilisateur n'est pas un réduit, on retrouve tous les nœuds de texte dans la sélection. Note the use of the mode: lowest in the call to Editor.nodes (a helper function by SlateJS) that helps us select all the text nodes since text nodes are really the leaves of the document tree.
  • If there is at least one text node that has no comment threads on it, we may allow the insertion. We use the util getCommentThreadsOnTextNode we wrote earlier here.

We now use this util function inside the toolbar to control the disabled state of the button.

# src/components/Toolbar.js

export default function Toolbar({ selection, previousSelection }) {
  const editor = useEditor();
  ....

  return (
   
.... <ToolBarButton isActive={false} disabled={!shouldAllowNewCommentThreadAtSelection( editor, selection )} label={} onMouseDown={onInsertComment} />
);

Let’s test the implementation of the rule by recreating our example above.

Insertion button in the toolbar disabled as user tries to insert comment over text range already fully covered by other comments.

A fine user experience detail to call out here is that while we disable the toolbar button if the user has selected the entire line of text here, it doesn’t complete the experience for the user. The user may not fully understand why the button is disabled and is likely to get confused that we’re not responding to their intent to insert a comment thread there. We address this later as Comment Popovers are built such that even if the toolbar button is disabled, the popover for one of the comment threads would show up and the user would still be able to leave comments.

Let’s also test a case where there is some uncommented text node and the rule allows inserting a new comment thread.

Insertion Rule allowing insertion of comment thread when there is some uncommented text within user’s selection.

In this section, we enable the feature where the user clicks on a commented text node and we use the Shortest Comment Range Rule to determine which comment thread should be selected. The steps in the process are:

  1. Find the shortest comment thread on the commented text node that user clicks on.
  2. Set that comment thread to be the active comment thread. (We create a new Recoil atom which will be the source of truth for this.)
  3. The commented text nodes would listen to the Recoil state and if they are part of the active comment thread, they’d highlight themselves differently. That way, when the user clicks on the comment thread, the entire text range stands out as all the text nodes will update their highlight color.

Let’s start with Step #1 which is basically implementing the Shortest Comment Range Rule. The goal here is to find the comment thread of the shortest range at the text node on which the user clicked. To find the shortest length thread, we need to compute the length of all the comment threads at that text node. Steps to do this are:

  1. Get all the comment threads at the text node in question.
  2. Traverse in either direction from that text node and keep updating the thread lengths being tracked.
  3. Stop the traversal in a direction when we’ve reached one of the below edges:
    • An uncommented text node (implying we’ve reached furthermost start/end edge of all the comment threads we’re tracking).
    • A text node where all the comment threads we are tracking have reached an edge (start/end).
    • There are no more text nodes to traverse in that direction (implying we’ve either reached the start or the end of the document or a non-text node).

Since the traversals in forward and reverse direction are functionally the same, we’re going to write a helper function updateCommentThreadLengthMap that basically takes a text node iterator. It will keep calling the iterator and keep updating the tracking thread lengths. We’ll call this function twice — once for forward and once for backward direction. Let’s write our main utility function that will use this helper function.

# src/utils/EditorCommentUtils.js

export function getSmallestCommentThreadAtTextNode(editor, textNode) {

  const commentThreads = getCommentThreadsOnTextNode(textNode);
  const commentThreadsAsArray = [...commentThreads];

  let shortestCommentThreadID = commentThreadsAsArray[0];

  const reverseTextNodeIterator = (slateEditor, nodePath) =>
    Editor.previous(slateEditor, {
      at: nodePath,
      mode: "lowest",
      match: Text.isText,
    });

  const forwardTextNodeIterator = (slateEditor, nodePath) =>
    Editor.next(slateEditor, {
      at: nodePath,
      mode: "lowest",
      match: Text.isText,
    });

  if (commentThreads.size > 1) {

    // The map here tracks the lengths of the comment threads.
    // We initialize the lengths with length of current text node
    // since all the comment threads span over the current text node
    // at the least.
    const commentThreadsLengthByID = new Map(
      commentThreadsAsArray.map((id) => [id, textNode.text.length])
    );


    // traverse in the reverse direction and update the map
    updateCommentThreadLengthMap(
      editor,
      commentThreads,
      reverseTextNodeIterator,
      commentThreadsLengthByID
    );

    // traverse in the forward direction and update the map
    updateCommentThreadLengthMap(
      editor,
      commentThreads,
      forwardTextNodeIterator,
      commentThreadsLengthByID
    );

    let minLength = Number.POSITIVE_INFINITY;


    // Find the thread with the shortest length.
    for (let [threadID, length] of commentThreadsLengthByID) {
      if (length < minLength) {
        shortestCommentThreadID = threadID;
        minLength = length;
      }
    }
  }

  return shortestCommentThreadID;
}

The steps we listed out are all covered in the above code. The comments should help follow how the logic flows there.

One thing worth calling out is how we created the traversal functions. We want to give a traversal function to updateCommentThreadLengthMap such that it can call it while it is iterating text node’s path and easily get the previous/next text node. To do that, Slate’s traversal utilities Editor.previous and Editor.next (defined in the Editor interface) are very helpful. Our iterators reverseTextNodeIterator and forwardTextNodeIterator call these helpers with two options mode: lowest and the match function Text.isText so we know we’re getting a text node from the traversal, if there is one.

Now we implement updateCommentThreadLengthMap which traverses using these iterators and updates the lengths we’re tracking.

# src/utils/EditorCommentUtils.js

function updateCommentThreadLengthMap(
  editor,
  commentThreads,
  nodeIterator,
  map
) {
  let nextNodeEntry = nodeIterator(editor);

  while (nextNodeEntry != null) {
    const nextNode = nextNodeEntry[0];
    const commentThreadsOnNextNode = getCommentThreadsOnTextNode(nextNode);

    const intersection = [...commentThreadsOnNextNode].filter((x) =>
      commentThreads.has(x)
    );

     // All comment threads we're looking for have already ended meaning
    // reached an uncommented text node OR a commented text node which
    // has none of the comment threads we care about.
    if (intersection.length === 0) {
      break;
    }


    // update thread lengths for comment threads we did find on this
    // text node.
    for (let i = 0; i < intersection.length; i++) {
      map.set(intersection[i]map.get(intersection[i]) + nextNode.text.length);
    }


    // call the iterator to get the next text node to consider
    nextNodeEntry = nodeIterator(editor, nextNodeEntry[1]);
  }

  return map;
}

One might wonder why do we wait until the intersection becomes 0 to stop iterating in a certain direction. Why can’t we just stop if we’re reached the edge of at least one comment thread — that would imply we’ve reached the shortest length in that direction, right? The reason we can’t do that is that we know that a comment thread can span over multiple text nodes and we wouldn’t know which of those text nodes did the user click on and we started our traversal from. We wouldn’t know the range of all comment threads in question without fully traversing to the farthest edges of the union of the text ranges of the comment threads in both the directions.

Check out the below example where we have two comment threads ‘A’ and ‘B’ overlapping each other in some way resulting into three text nodes 1,2 and 3 — #2 being the text node with the overlap.

Example of multiple comment threads overlapping on a text node.
Two comment threads overlapping over the word ‘text’. (Large preview)

In this example, let’s assume we don’t wait for intersection to become 0 and just stop when we reach the edge of a comment thread. Now, if the user clicked on #2 and we start traversal in reverse direction, we’d stop at the start of text node #2 itself since that’s the start of the comment thread A. As a result, we might not compute the comment thread lengths correctly for A & B. With the implementation above traversing the farthest edges (text nodes 1,2, and 3), we should get B as the shortest comment thread as expected.

To see the implementation visually, below is a walkthrough with a slideshow of the iterations. We have two comment threads A and B that overlap each other over text node #3 and the user clicks on the overlapping text node #3.

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

Steps 2 & 3: Maintaining State Of The Selected Comment Thread And Highlighting It

Now that we have the logic for the rule fully implemented, let’s update the editor code to use it. For that, we first create a Recoil atom that’ll store the active comment thread ID for us. We then update the CommentedText component to use our rule’s implementation.

# src/utils/CommentState.js

import { atom } from "recoil";

export const activeCommentThreadIDAtom = atom({
  key: "activeCommentThreadID",
  default: null,
});


# src/components/CommentedText.js

import { activeCommentThreadIDAtom } from "../utils/CommentState";
import classNames from "classnames";
import { getSmallestCommentThreadAtTextNode } from "../utils/EditorCommentUtils";
import { useRecoilState } from "recoil";

export default function CommentedText(props) {
 ....
const { commentThreads, textNode, ...otherProps } = props;
const [activeCommentThreadID, setActiveCommentThreadID] = useRecoilState(
    activeCommentThreadIDAtom
  );

  const onClick = () => {
    setActiveCommentThreadID(
      getSmallestCommentThreadAtTextNode(editor, textNode)
    );
  };

  return (
    
      {props.children}
    &gl;/span>
  );
}

This component uses useRecoilState that allows a component to subscribe to and also be able to set the value of Recoil atom. We need the subscriber to know if this text node is part of the active comment thread so it can style itself differently. Check out the screenshot below where the comment thread in the middle is active and we can see its range clearly.

Example showing how text node(s) under selected comment thread jump out.
Text node(s) under selected comment thread change in style and jump out. (Large preview)

Now that we have all the code in to make selection of comment threads work, let’s see it in action. To test our traversal code well, we test some straightforward cases of overlap and some edge cases like:

  • Clicking on a commented text node at the start/end of the editor.
  • Clicking on a commented text node with comment threads spanning multiple paragraphs.
  • Clicking on a commented text node right before an image node.
  • Clicking on a commented text node overlapping links.
Selecting shortest comment thread for different overlap combinations.

As we now have a Recoil atom to track the active comment thread ID, one tiny detail to take care of is setting the newly created comment thread to be the active one when the user uses the toolbar button to insert a new comment thread. This enables us, in the next section, to show the comment thread popover immediately on insertion so the user can start adding comments right away.

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

Note: The use of useSetRecoilState here (a Recoil hook that exposes a setter for the atom but doesn’t subscribe the component to its value) is what we need for the toolbar in this case.

In this section, we build a Comment Popover that makes use of the concept of selected/active comment thread and shows a popover that lets the user add comments to that comment thread. Before we build it, let’s take a quick look at how it functions.

Preview of the Comment Popover Feature.

When trying to render a Comment Popover close to the comment thread that is active, we run into some of the problems that we did in the first article with a Link Editor Menu. At this point, it is encouraged to read through the section in the first article that builds a Link Editor and the selection issues we run into with that.

Let’s first work on rendering an empty popover component in the right place based on the what active comment thread is. The way popover would work is:

  • Comment Thread Popover is rendered only when there is an active comment thread ID. To get that information, we listen to the Recoil atom we created in the previous section.
  • When it does render, we find the text node at the editor’s selection and render the popover close to it.
  • When the user clicks anywhere outside the popover, we set the active comment thread to be null thereby de-activating the comment thread and also making the popover disappear.
# 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 (
    
      {`Comment Thread Popover for threadID:${threadID}`}
    
  );
}

Couple of things that should be called out for this implementation of the popover component:

  • It takes the editorOffsets and the selection from the Editor component where it would be rendered. editorOffsets are the bounds of the Editor component so we could compute the position of the popover and selection could be current or previous selection in case the user used a toolbar button causing selection to become null. The section on the Link Editor from the first article linked above goes through these in detail.
  • Since the LinkEditor from the first article and the CommentThreadPopover here, both render a popover around a text node, we’ve moved that common logic into a component NodePopover that handles rendering of the component aligned to the text node in question. Its implementation details are what LinkEditor component had in the first article.
  • NodePopover takes a onClickOutside method as a prop that is called if the user clicks somewhere outside the popover. We implement this by attaching mousedown event listener to the document — as explained in detail in this Smashing article on this idea.
  • getFirstTextNodeAtSelection gets the first text node inside the user’s selection which we use to render the popover against. The implementation of this function uses Slate’s helpers to find the text node.
# 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;
}

Let’s implement the onClickOutside callback that should clear the active comment thread. However, we have to account for the scenario when the comment thread popover is open and a certain thread is active and the user happens to click on another comment thread. In that case, we don’t want the onClickOutside to reset the active comment thread since the click event on the other CommentedText component should set the other comment thread to become active. We don’t want to interfere with that in the popover.

The way we do that is that is we find the Slate Node closest to the DOM node where the click event happened. If that Slate node is a text node and has comments on it, we skip resetting the active comment thread Recoil atom. Let’s implement it!

# 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 has a helper method toSlateNode that returns the Slate node that maps to a DOM node or its closest ancestor if itself isn’t a Slate Node. The current implementation of this helper throws an error if it can’t find a Slate node instead of returning null. We handle that above by checking the null case ourselves which is a very likely scenario if the user clicks somewhere outside the editor where Slate nodes don’t exist.

We can now update the Editor component to listen to the activeCommentThreadIDAtom and render the popover only when a comment thread is active.

# 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 (
    <>
               ...
              
... {activeCommentThreadID != null ? ( ) : null}
... > ); }

Let’s verify that the popover loads at the right place for the right comment thread and does clear the active comment thread when we click outside.

Comment Thread Popover correctly loads for the selected comment thread.

We now move on to enabling users to add comments to a comment thread and seeing all the comments of that thread in the popover. We are going to use the Recoil atom family — commentThreadsState we created earlier in the article for this.

The comments in a comment thread are stored on the comments array. To enable adding a new comment, we render a Form input that allows the user to enter a new comment. While the user is typing out the comment, we maintain that in a local state variable — commentText. On the click of the button, we append the comment text as the new comment to the comments array.

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

Note: Although we render an input for the user to type in comment, we don’t necessarily let it take focus when the popover mounts. This is a User Experience decision that could vary from one editor to another. Some editors do not let users edit the text while the comment thread popover is open. In our case, we want to be able to let the user edit the commented text when they click on it.

Worth calling out how we access the specific comment thread’s data from the Recoil atom family — by calling out the atom as — commentThreadsState(threadID). This gives us the value of the atom and a setter to update just that atom in the family. If the comments are being lazy loaded from the server, Recoil also provides a useRecoilStateLoadable hook that returns a Loadable object which tells us about the loading state of the atom’s data. If it is still loading, we can choose to show a loading state in the popover.

Now, we access the threadData and render the list of comments. Each comment is rendered by the CommentRow component.

# src/components/CommentThreadPopover.js

return (
    
      
{threadData.comments.map((comment, index) => ( ))}
...
);

Below is the implementation of CommentRow that renders the comment text and other metadata like author name and creation time. We use the date-fns module to show a formatted creation time.

# src/components/CommentRow.js

import { format } from "date-fns";

export default function CommentRow({
  comment: { author, text, creationTime },
}) {
  return (
    
{author} {format(creationTime, "eee MM/dd H:mm")}
{text}
); }

We’ve extracted this to be its own component as we re-use it later when we implement the Comment Sidebar.

At this point, our Comment Popover has all the code it needs to allow inserting new comments and updating the Recoil state for the same. Let’s verify that. On the browser console, using the Recoil Debug Observer we added earlier, we’re able to verify that the Recoil atom for the comment thread is getting updated correctly as we add new comments to the thread.

Comment Thread Popover loads on selecting a comment thread.

Earlier in the article, we’ve called out why occasionally, it may so happen that the rules we implemented prevent a certain comment thread to not be accessible by clicking on its text node(s) alone — depending upon the combination of overlap. For such cases, we need a Comments Sidebar that lets the user get to any and all comment threads in the document.

A Comments Sidebar is also a good addition that weaves into a Suggestion & Review workflow where a reviewer can navigate through all the comment threads one after the other in a sweep and be able to leave comments/replies wherever they feel the need to. Before we start implementing the sidebar, there is one unfinished task we take care of below.

When the document is loaded in the editor, we need to scan the document to find all the comment threads and add them to the Recoil atoms we created above as part of the initialization process. Let’s write a utility function in EditorCommentUtils that scans the text nodes, finds all the comment threads and adds them to the Recoil atom.

# 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",
    })
  );
}
Syncing with Backend Storage and Performance Consideration

For the context of the article, as we’re purely focused on the UI implementation, we just initialize them with some data that lets us confirm the initialization code is working.

In the real-world usage of the Commenting System, comment threads are likely to be stored separately from the document contents themselves. In such a case, the above code would need to be updated to make an API call that fetches all the metadata and comments on all the comment thread IDs in commentThreads. Once the comment threads are loaded, they are likely to be updated as multiple users add more comments to them in real time, change their status and so on. The production version of the Commenting System would need to structure the Recoil storage in a way that we can keep syncing it with the server. If you choose to use Recoil for state management, there are some examples on the Atom Effects API (experimental as of writing this article) that do something similar.

If a document is really long and has a lot of users collaborating on it on a lot of comment threads, we might have to optimize the initialization code to only load comment threads for the first few pages of the document. Alternatively, we may choose to only load the light-weight metadata of all the comment threads instead of the entire list of comments which is likely the heavier part of the payload.

Now, let’s move on to calling this function when the Editor component mounts with the document so the Recoil state is correctly initialized.

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

We use the same custom hook — useAddCommentThreadToState that we used with the Toolbar Comment Button implementation to add new comment threads. Since we have the popover working, we can click on one of pre-existing comment threads in the document and verify that it shows the data we used to initialize the thread above.

Clicking on a pre-existing comment thread loads the popover with their comments correctly.
Clicking on a pre-existing comment thread loads the popover with their comments correctly. (Large preview)

Now that our state is correctly initialized, we can start implementing the sidebar. All our comment threads in the UI are stored in the Recoil atom family — commentThreadsState. As highlighted earlier, the way we iterate through all the items in a Recoil atom family is by tracking the atom keys/ids in another atom. We’ve been doing that with commentThreadIDsState. Let’s add the CommentSidebar component that iterates through the set of ids in this atom and renders a CommentThread component for each.

# 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 (
    
      Comments
      
        {Array.from(allCommentThreadIDs).map((id) => (
          
            
              
            
          
        ))}
      
    
  );
}

Now, we implement the CommentThread component that listens to the Recoil atom in the family corresponding to the comment thread it is rendering. This way, as the user adds more comments on the thread in the editor or changes any other metadata, we can update the sidebar to reflect that.

As the sidebar could grow to be really big for a document with a lot of comments, we hide all comments but the first one when we render the sidebar. The user can use the ‘Show/Hide Replies’ button to show/hide the entire thread of comments.

# 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 (
    
      
      {shouldShowReplies
        ? otherComments.map((comment, index) => (
            
          ))
        : null}
      {comments.length > 1 ? (
        
      ) : null}
    
  );
}

We’ve reused the CommentRow component from the popover although we added a design treatment using showConnector prop that basically makes all the comments look connected with a thread in the sidebar.

Now, we render the CommentSidebar in the Editor and verify that it shows all the threads we have in the document and correctly updates as we add new threads or new comments to existing threads.

# src/components/Editor.js

return (
    <>
      
       .....
        
> );
Comments Sidebar with all the comment threads in the document.

We now move on to implementing a popular Comments Sidebar interaction found in editors:

Clicking on a comment thread in the sidebar should select/activate that comment thread. We also add a differential design treatment to highlight a comment thread in the sidebar if it’s active in the editor. To be able to do so, we use the Recoil atom — activeCommentThreadIDAtom. Let’s update the CommentThread component to support this.

# src/components/CommentsSidebar.js

function CommentThread({ id }) {
 
const [activeCommentThreadID, setActiveCommentThreadID] = useRecoilState(
    activeCommentThreadIDAtom
  );

const onClick = useCallback(() => {   
    setActiveCommentThreadID(id);
  }, [id, setActiveCommentThreadID]);

  ...

  return (
    
    ....
   
);
Clicking on a comment thread in Comments Sidebar selects it in the editor and highlights its range.

If we look closely, we have a bug in our implementation of sync-ing the active comment thread with the sidebar. As we click on different comment threads in the sidebar, the correct comment thread is indeed highlighted in the editor. However, the Comment Popover doesn’t actually move to the changed active comment thread. It stays where it was first rendered. If we look at the implementation of the Comment Popover, it renders itself against the first text node in the editor’s selection. At that point in the implementation, the only way to select a comment thread was to click on a text node so we could conveniently rely on the editor’s selection since it was updated by Slate as a result of the click event. In the above onClick event, we don’t update the selection but merely update the Recoil atom value causing Slate’s selection to remain unchanged and hence the Comment Popover doesn’t move.

A solution to this problem is to update the editor’s selection along with updating the Recoil atom when the user clicks on the comment thread in the sidebar. The steps do this are:

  1. Find all text nodes that have this comment thread on them that we are going to set as the new active thread.
  2. Sort these text nodes in the order in which they appear in the document (We use Slate’s Path.compare API for this).
  3. Compute a selection range that spans from the start of the first text node to the end of the last text node.
  4. Set the selection range to be the editor’s new selection (using Slate’s Transforms.select API).

If we just wanted to fix the bug, we could just find the first text node in Step #1 that has the comment thread and set that to be the editor’s selection. However, it feels like a cleaner approach to select the entire comment range as we really are selecting the comment thread.

Let’s update the onClick callback implementation to include the steps above.

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

Note: allTextNodePaths contains the path to all the text nodes. We use the Editor.point API to get the start and end points at that path. The first article goes through Slate’s Location concepts. They’re also well-documented on Slate’s documentation.

Let’s verify that this implementation does fix the bug and the Comment Popover moves to the active comment thread correctly. This time, we also test with a case of overlapping threads to make sure it doesn’t break there.

Clicking on a comment thread in Comments Sidebar selects it and loads Comment Thread Popover.

With the bug fix, we’ve enabled another sidebar interaction that we haven’t discussed yet. If we have a really long document and the user clicks on a comment thread in the sidebar that’s outside the viewport, we’d want to scroll to that part of the document so the user can focus on the comment thread in the editor. By setting the selection above using Slate’s API, we get that for free. Let’s see it in action below.

Document scrolls to the comment thread correctly when clicked on in the Comments Sidebar.

With that, we wrap our implementation of the sidebar. Towards the end of the article, we list out some nice feature additions and enhancements we can do to the Comments Sidebar that help elevate the Commenting and Review experience on the editor.

Resolving And Re-Opening Comments

In this section, we focus on enabling users to mark comment threads as ‘Resolved’ or be able to re-open them for discussion if needed. From an implementation detail perspective, this is the status metadata on a comment thread that we change as the user performs this action. From a user’s perspective, this is a very useful feature as it gives them a way to affirm that the discussion about something on the document has concluded or needs to be re-opened because there are some updates/new perspectives, and so on.

To enable toggling the status, we add a button to the CommentPopover that allows the user to toggle between the two statuses: open and 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={
        
0} onToggleStatus={onToggleStatus} /> } >
...
); } function Header({ onToggleStatus, shouldAllowStatusChange, status }) { return (
{shouldAllowStatusChange && status != null ? ( ) : null}
); }

Before we test this, let’s also give the Comments Sidebar a differential design treatment for resolved comments so that the user can easily detect which comment threads are un-resolved or open and focus on those if they want to.

# src/components/CommentsSidebar.js

function CommentThread({ id }) {
  ...
  const { comments, status } = useRecoilValue(commentThreadsState(id));
 
 ...
  return (
    
       ...  
   
  );
}
Comment Thread Status being toggled from the popover and reflected in the sidebar.

Conclusion

In this article, we built the core UI infrastructure for a Commenting System on a Rich Text Editor. The set of functionalities we add here act as a foundation to build a richer Collaboration Experience on an editor where collaborators could annotate parts of the document and have conversations about them. Adding a Comments Sidebar gives us a space to have more conversational or review-based functionalities to be enabled on the product.

Along those lines, here are some of features that a Rich Text Editor could consider adding on top of what we built in this article:

  • Support for @ mentions so collaborators could tag one another in comments;
  • Support for media types like images and videos to be added to comment threads;
  • Suggestion Mode at the document level that allows reviewers to make edits to the document that appear as suggestions for changes. One could refer to this feature in Google Docs or Change Tracking in Microsoft Word as examples;
  • Enhancements to the sidebar to search conversations by keyword, filter threads by status or comment author(s), and so on.
Smashing Editorial" width="35" height="46" loading="lazy" decoding="async(vf, yk, il)




Source link