Implémenter facilement un éditeur de texte collaboratif avec tiptap et ProseMirror11 min read

Il y a un mois, j’ai écris un article d’introduction à l’édition collaborative avec tiptap. Depuis, j’en ai fait deux packages qui sont accessibles en open-source. Bien que ça ne soit pas obligatoire, je vous recommande chaudement la lecture de ce premier article.

Le but de cet article ci est de vous montrer comment utiliser ces deux packages ensemble pour ajouter un éditeur collaboratif plein de belles fonctionnalités à votre application VueJS.


Point sur le vocabulaire

Avant d’aller plus loin, voici les définitions de quelques termes utilisés dans cet article.

Serveur de socket
Un serveur de socket est un serveur qui permet une connexion bidirectionnelle avec des clients. Les échanges entre le client et le serveur sont à base d’événements. Ce système est très utilisé en informatique pour gérer des notifications en temps réel entre plusieurs clients distants sur un réseau.

Fonction de hook
Hook signifie littéralement « crochet » et c’est bien dans ce sens qu’il est utilisé car il permet de faire faire des crochets à droite et à gauche pendant l’exécution d’un programme.


Le serveur de socket ProseMirror

Pour synchroniser plusieurs éditeurs, chacun d’eux doit se connecter à un serveur de socket. Le serveur rassemble les modifications apportées par chaque éditeur et les renvoie à tous les éditeurs. De cette manière, les éditeurs sont toujours synchronisés entre eux.

Le package tiptap-collab-server peut se charger de plusieurs documents différents en même temps, et pour chacun d’eux, elle gère les curseurs et les sélections en plus des modifications de texte. Elle fournit également des fonctions de hook à destination des programmeurs pour implémenter des gardes de connexion (afin de sécuriser l’accès aux documents) ou de réaliser des actions lorsqu’un utilisateur se connecte par exemple.

Ce package est fournie avec un exemple fonctionnel, alors ouvrez votre terminal et rendez-vous dans votre répertoire de projets, puis :

# Clonez le dépôt
git clone git@github.com:naept/tiptap-collab-server.git

# Installez les dépendances
npm install

# Compilez la bibliothèque
npm run build

# Et lancez le serveur d’exemple
npm run serve-example

Félicitations ! Vous avez fait la moitié du travail.

Avant d’aller plus loin, jetons un œil au code source de cet exemple et à ce que ce package a à nous offrir.

L’objet CollabServer

La première chose à faire est d’importer le package :

import CollabServer from 'tiptap-collab-server'

Puis de créer un objet CollabServer.

new CollabServer({
  port: 6002,
  namespaceFilter: /^\/[a-zA-Z0-9_/-]+$/,
  lockDelay: 1000,
  lockRetries: 10,
})

Différents paramètres sont disponibles. Vous pouvez spécifier un port sur lequel vous voulez servir les sockets. J’ai découvert que le port 6000 était bloqué par mon navigateur, donc j’ai choisi le port 6002 pour l’exemple.

Le namespaceFilter est une expression régulière utilisée pour extraire de l’URL le nom du namespace que l’utilisateur veut rejoindre. Donc si un utilisateur se connecte au serveur de socket en utilisant l’URL http://localhost:6002/awesome-namespace, il se connectera au namespace nommé awesome-namespace.

Pour cette première publication, le package tiptap-collab-server utilise des fichiers pour base de données, comme dans l’exemple fourni par tiptap. Ces fichiers ont besoin d’être verrouillés pour éviter que deux utilisateurs (ou plus) appliquent des modifications au document en même temps. Lorsqu’un fichier est verrouillé, le serveur réessaiera après un temps donnée (lockDelay), et un nombre maximal de fois (lockRetries) avant d’abandonner et de retourner une erreur.

A part le numéro de port, il n’est pas obligatoire de configurer ces paramètres.

L’objet CollabServer fournit quelques fonctions de hook. Toutes fonctionnent comme des promesses (au sens JavaScript). Le premier paramètre est un objet contenant quelques arguments utilisables à l’intérieur de la fonction. Le second paramètre est une fonction nommée resolve, qui résout la promesse lorsqu’elle est appelée. Et le troisième paramètre est une fonction nommée reject, qui rejette la promesse lorsqu’elle est appelée. Descendons un peu plus profondément dans ces fonctions.

Le garde de connexion

Lorsqu’un utilisateur rejoint une “room”, ce qui arrivera lorsqu’un nouvel éditeur est créé côté client (on verra ça un peu plus loin), la fonction connectionGuard est appelée. Elle fournit quelques arguments qu’on peut utiliser pour connecter l’utilisateur au backend par exemple :

  • namespaceName: C’est une chaine de caractères contenant le nom du namespace (celui qui a été extrait de l’URL)
  • roomName: C’est une chaine de caractères contenant le nom de la “room”, également extrait de l’URL (c’est la partie après le slash qui suit le namespace).
  • clientID: C’est une chaine de caractères contenant l’ID du client connecté au serveur. Cet ID est défini côté client. On en reparlera.
  • requestHeader: C’est un pointeur sur les headers de la requête faite au socket serveur (par le client). On pourrait passer ces headers au backend, pour identifier l’utilisateur par exemple.
  • options: C’est un objet qu’on peut définir côté client. Des options peuvent ainsi être passées au serveur depuis le coté client.

Le hook de connexion d’un client

Une fois le garde de connexion passé, la fonction onClientConnect est appelée. Les arguments qu’elle fournit sont similaires à ceux fournis par la fonction connectionGuard :

  • namespaceName
  • roomName
  • clientID
  • requestHeaders
  • clientsCount

Le dernier est de type Number et représente le nombre de clients actuellement connectés à ce document précis.

Le hook d’initialisation du document

Ensuite, la fonction initDocument est appelée. A ce moment, le contenu du document et sa version ont été récupérés dans la base de données et sont passés en tant qu’arguments à cette fonction, avec les arguments maintenant habituels :

  • namespaceName
  • roomName
  • clientID
  • requestHeaders
  • clientsCount
  • version: C’est le numéro de version du document collaboratif (voir la documentation de ProseMirror)
  • doc: C’est un objet de la classe prosemirror-model Node, représentant le contenu du document courant.

Lorsque la fonction resolve est appelée ici, on peut lui passer un objet avec les attributs version et doc. Si c’est le cas, le document courant et la version de la base de données seront remplacés par ceux-ci.

Personnellement, j’utilise ce hook pour récupérer le document depuis le backend lorsque le premier utilisateur s’y connecte. Dans l’exemple du package tiptap-collab-server, il est utilisé pour crée un document non-vide lorsqu’un premier utilisateur s’y connecte.

Le hook de départ d’un document

Lorsqu’un client se déconnecte d’un document collaboratif, la fonction leaveDocument est appelée, avec un argument de plus que pour la fonction initDocument :

  • namespaceName
  • roomName
  • clientID
  • requestHeaders
  • clientsCount
  • version
  • doc
  • deleteDatabase

deleteDatabase est une fonction qui efface le document courant de la base de donnée.

Elle est utilisée dans l’exemple pour supprimer tous les fichiers de la base de données, liés à ce document, lorsque le dernier utilisateur connecté se déconnecte d’un document collaboratif. J’utilise personnellement ce hook pour sauvegarder la version modifiée du document en l’envoyant au backend, avant de supprimer les fichiers de la base de données. De cette manière, le dossier de base de données sur le serveur de socket n’est pas rempli de fichiers inutilisés.

Le hook de déconnexion d’un client

Enfin, la fonction onClientDisconnect est appelée avec les mêmes arguments que onClientConnect.

Toutes ces fonctions retournent l’objet CollabServer, donc les appels sont chaînables.

Lancer le serveur collaboratif

La dernière chose qu’il reste à faire est d’appeler la fonction serve qui lancera effectivement le serveur.

Bim ! Tout est paramétré, et vous savez tout… sur le côté serveur de cet éditeur collaboratif basé sur tiptap. Passons maintenant du côté client.

L’extension de collaboration pour tiptap

Le package tiptap-extension-collaboration représente l’autre côté du miroir. Elle est inspirée de celle fournie par tiptap, avec quelques améliorations.

Tout d’abord, la gestion des sockets est internalisée. Plus besoin de s’inquiéter d’ouvrir et de fermer la connexion, ou de gérer les événements. Tout ce que vous avez besoin de faire est de fournir quelques paramètres que je vais détailler dans la suite de cet article.

Là aussi un exemple est fourni avec le package tiptap-extension-collaboration, donc une fois encore, ouvrez votre terminal et rendez-vous dans votre répertoire de projets, puis :

# Clonez le dépôt
git clone git@github.com:naept/tiptap-extension-collaboration.git

# Installez les dependances
yarn install

# Compilez la bibliothèque
npm run build

# Et lancez l'exemple
npm run serve-example

Cela ouvrira notre navigateur web à l’adresse http://localhost:8080. Et puisque c’est un éditeur collaboratif, pourquoi ne pas dupliquer notre navigateur ?

Essayons de taper du texte dans l’un des éditeurs. On voit tous les autres éditeurs rester synchronisés et même afficher notre curseur. Si on sélectionne du texte dans d’un des éditeurs, on voit la sélection apparaître dans tous les autres éditeurs. C’est pas génial ?!

Maintenant voyons comment utiliser ce package dans notre propre projet.

Mise en place de l’extension de collaboration

L’extension de collaboration doit être déclarée comme toute autre extension tiptap :

import { Collaboration } from 'tiptap-extension-collaboration'

new Editor({
  extensions: [
    new Collaboration({
      socketServerBaseURL: 'http://localhost:6002',
      namespace: 'Directory-A',
      room: 'Document-1',

      clientID: String(Math.floor(Math.random() * 0xFFFFFFFF)),
      joinOptions: {},

      debounce: 250,
      keepFocusOnBlur: false,

      onConnected: () => {},
      onConnectedFailed: (error) => {},
      onDisconnected: () => {},
      onClientsUpdate: ({clientsIDs, clientID}) => {},
      onSaving: () => {},
      onSaved: () => {},
    }),
  ],
})

Vous devez seulement fournir quelques paramètres.

socketServerURL est l’URL et le port de l’instance du tiptap-collab-server. Dans notre cas, c’est http://localhost:6002.

namesapce et room sont les noms du namespace et de la “room” que vous voulez que votre éditeur rejoigne sur le serveur.

clientID est une chaîne de caractères qui doit être unique à chaque instance d’éditeur. Si on ne spécifie rien, un nombre aléatoire sera généré. Personnellement, j’y ajoute l’ID de l’utilisateur connecté, ce qui me permet de retrouver et d’afficher le nom des utilisateurs connectés dans toutes les instances de l’éditeur.

Vous-vous souvenez de la fonction connectionGuard ? Et de ses paramètres ? Et bien joinOptions est l’objet qui sera disponible dans la fonction connectionGuard sur le serveur.

Afin que le serveur de socket ne soit pas surchargé de requêtes, les clients n’envoient des données au serveur que si l’utilisateur n’a rien tapé pendant un certain temps. La valeur par défaut est de 250 ms et est modifiable en utilisant le paramètre debounce.

Si l’éditeur perd le focus, par défaut le curseur ne sera plus affiché dans les autres éditeurs connectés. Si on veut le laisser affiché, on doit paramétrer keepFocusOnBlur à true.

Dès que l’extension de collaboration est créée, elle essayera de se connecter au serveur. Et, en fonction des évènements qui vont arriver ensuite, les fonctions de callback suivantes seront appelées ou non :

  • onConnected est appelée une fois que la connexion a été acceptée par le serveur (le garde de connexion est passé)
  • onConnectionFailed est appelée si la connexion a été refusée par le serveur (le garde de connexion à rejeté la connexion)
  • onClientsUpdate est appelée une fois que le client est connecté avec succès au serveur, puis à chaque fois qu’un nouvel utilisateur se connecte au même document collaboratif (même namespace, même room). Elle fournit 2 paramètres :
    • clientsIDs est la liste des IDs des éditeurs connectés;
    • clientID est l’ID de cette instance de l’éditeur.
  • onSaving est appelée chaque fois que cette instance envoie des données de mise à jour du contenu au serveur.
  • onSaved est appelée chaque fois que l’éditeur reçoit des données du serveur.
  • Et enfin onDisconnected est appelée quand l’éditeur se déconnecte du serveur (que se soit de son initiative ou non).

Afficher les curseurs des autres clients

Le package tiptap-extension-collaboration fournit également une extension tiptap pour afficher les curseurs et les sélections. Elle ajoute des décorations au texte, une span avec la classe cursor à l’emplacement du curseur, et une span avec la classe selection autour de la selection. Les deux spans se voient également attribuées une classe nommée client-suivi du clientID.

La mise en place du CSS pour effectivement afficher les curseurs et les sélections à l’écran est laissée au programmeur. Une manière de faire est proposée dans le projet d’exemple, mais nous n’allons pas nous y plonger ici, c’est un peu hors-sujet.

Cette extension est optionnelle et, une fois encore, doit être déclarée comme toute autre extension tiptap :

import { Collaboration, Cursors } from 'tiptap-extension-collaboration'

new Editor({
  extensions: [
    new Cursors(),
    new Collaboration({
      socketServerBaseURL: 'http://localhost:6002',
      namespace: 'Directory-A',
      room: 'Document-1',

      clientID: String(Math.floor(Math.random() * 0xFFFFFFFF)),
      joinOptions: {},

      debounce: 250,
      keepFocusOnBlur: false,

      onConnected: () => {},
      onConnectedFailed: (error) => {},
      onDisconnected: () => {},
      onClientsUpdate: ({clientsIDs, clientID}) => {},
      onSaving: () => {},
      onSaved: () => {},
    }),
  ],
})

Amusez-vous bien !

J’espère que ces deux packages vous seront utiles, et que cet article vous poussera à implémenter des éditeurs collaboratifs en utilisant tiptap.

N’hésitez pas à commenter et à partager cet article.

Bonne journée !

Related Posts

Leave a Reply