Implémenter un éditeur collaboratif multi-document avec Tiptap et Socket.io12 min read

Deux femmes travaillent sur le même ordinateur

Dans cet article, nous partons ensemble à la découverte de la fonction “Édition collaborative” de Tiptap. Nous allons mettre en place notre propre serveur socker.io.


Point sur le vocabulaire

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

tiptap
tiptap est un éditeur de texte riche (avec de la mise en forme), sans rendu. Cela signifie que tiptap n’impose pas d’interface utilisateur pour les menus ni la typographie par exemple. Il est basé sur ProseMirror, qui est une boite à outils pour la création d’éditeurs de texte formaté utilisée par beaucoup d’entreprises bien connues telles que Atlassian ou le New York Times.


Tiptap fourni un exemple simple mais limité d’édition collaborative. Pour commencer, il ne permet d’éditer qu’un seule document. Naept gère de nombreux éléments qui contiennent du texte mis en forme. Jusqu’à maintenant, la modification de ces éléments se faisait via des boîtes de dialogue, qui envoyaient la mise à jour au back-end. Cela fonctionne bien, mais l’expérience utilisateur pourrait être améliorée. Nous voulons améliorer le côté collaboratif de naept en fournissant une solution d’édition collaborative pour chaque zone de texte de l’interface utilisateur. Pour cela nous devons mettre à jour à la fois le côté client et le côté serveur de l’exemple de tiptap afin de pouvoir gérer plusieurs documents.

Reproduire l’exemple d’éditeur collaboratif de tiptap localement

Faire fonctionner le code client de l’exemple de tiptap est plutôt aisé. Tout est décrit sur leur page github :

# install dependencies
yarn install
# serve examples at localhost:3000
yarn start

Mais ce démonstrateur se connecte toujours au serveur de socket qu’ils ont hébergé sur glitch.com. Ce que nous voulons, c’est nous connecter à un serveur hébergé sur notre machine locale.

Pour créer un serveur socket.io, commençons par installer NodeJS. C’est assez facile pour que je vous laisse gérer cette étape seul.

Maintenant, sur notre machine, créons un dossier pour notre projet de serveur, et copions les fichiers de l’exemple de tiptap hébergés sur glitch : server.js, schema.js, package.json et db_locked.json doivent être copiés tels quels, db_steps.json peut contenir un tableau vide [], et db.json peut être omis.

Avant d’aller plus loin, apportons une légère modification au fichier server.js. Comme le port 3000 est déjà utilisé par l’application client, remplaçons l’instruction http.listen(3000) ligne 9 par http.listen(6000). Ou n’importe quel autre port si le 6000 ne vous convient pas ou n’est pas libre non plus.

Nous pouvons maintenant lancer le serveur de socket :

# Dans le dossier du projet de serveur
npm start

Nous devons maintenant changer l’adresse du serveur de socket dans le ficher d’exemple de tiptap examples/Components/Collaboration/index.vue, ligne 88 :

this.socket = io('wss://tiptap-sockets.glitch.me')

devient :

this.socket = io('http://localhost:6000')

Rafraichissons la page http://localhost:3000/collaboration dans notre navigateur web et on peut voir qu’on est maintenant connectés à notre serveur de socket local :

Capture d'écran de l'exemple d'éditeur collaboratif de tiptap connecté au serveur de socket locale

Utiliser les namespaces pour créer plusieurs documents

Ce qu’on veut faire maintenant, c’est créer ce qui ressemble à des canaux de manière à pouvoir utiliser tiptap pour éditer plusieurs documents différents.

Il y a deux manière de diviser un socket avec socket.io : les “namespaces”, et les “rooms”. En fait, un socket peut être divisé en namespaces, et les namespaces peuvent être divisés en “rooms”.

Les clients du serveur de socket peuvent choisir de se connecter à un namespace particulier, mais n’ont pas directement la main sur quelle “room” ils veulent rejoindre. Les namespaces peuvent être protégés par un système d’autorisation, alors que ce n’est pas prévu nativement pour les “rooms”.

Puisque mon application est pensée pour gérer plusieurs documents regroupés en projets, j’utiliserai – et je pense que c’est une belle opportunité pour explorer les deux dans cet article – une “room” pour chaque document, et un namespace pour chaque projet.

Dans ce cas particulier, le serveur ne peut pas avoir connaissance de tous les namespaces. Ils sont donnés par le client lorsqu’il se connecte. Comme expliqué dans la documentation, nous utilisons une expression régulière pour gérer le namespace de connexion.

io.on('connection', socket => {...})

devient :

const namespaces = io.of(/^\/[a-zA-Z0-9_\/-]+$/)

namespaces.on('connection', socket => {
  const namespace = socket.nsp;
  [...]
})

L’expression régulière accepte les slash (barre oblique). Cela va nous permettre de gérer des sous-domaines. Alors que ça n’aura aucune signification pour socket.io, cela nous aidera à organiser nos différents documents.

Le nom du namespace courant est récupérable avec l’instruction socket.nsp. Nous l’enregistrons dans une constante nommée namespace et chaque io.emit() ou io.sockets.emit() doit être remplacé par namespace.emit().

Maintenant, notre serveur de socket gère les connexions de clients utilisant des URLs comme http://localhost:6002/doc1 ou même http://localhost:6002/projectA/doc3. Nous n’avons rien changé au niveau de la gestion des fichiers par le serveur, donc nous ne verrons aucune différence en utilisant différentes URLs. En effet, l’emplacement des fichiers db.json, db_locked.json et db_steps.json est toujours défini par des constantes alors qu’on le voudrait défini dynamiquement en utilisant le namespace courant.

Donc à chaque fonction d’écriture ou de lecture d’un fichier, on ajoute un paramètre pour spécifier le dossier dans lequel le fichier doit être enregistré.

Voici donc à quoi ressemble le fichier server.js avec toutes ces modifications :

import fs from 'fs'
import { Step } from 'prosemirror-transform'
import schema from './schema.js'

// setup socket server
const app = require('express')()
const http = require('http').Server(app)
const io = require('socket.io')(http)
http.listen(6002)

// options
const simulateSlowServerDelay = 0 // milliseconds
const dbPath = './db'
const docPath = '/db.json'
const lockedPath = '/db_locked.json'
const stepsPath = '/db_steps.json'
const maxStoredSteps = 1000
const defaultData = {
  "version": 0,
  "doc": { "type": "doc", "content": [{ "type": "paragraph", "content":[{ "type": "text", "text": "Let's start collaborating. Yeah!" }] }] }
}

const sleep = (ms) => (new Promise(resolve => setTimeout(resolve, ms)));

function initProjectDir(namespaceDir) {
  if (!fs.existsSync(dbPath + namespaceDir)){
    fs.mkdirSync(dbPath + namespaceDir, { recursive: true })
  }
}

function storeDoc(data, namespaceDir) {
  fs.writeFileSync(dbPath + namespaceDir + docPath, JSON.stringify(data, null, 2))
}

function storeSteps({steps, version}, namespaceDir) {
  let limitedOldData = []
  try {
    const oldData = JSON.parse(fs.readFileSync(dbPath + namespaceDir + stepsPath, 'utf8'))
    limitedOldData = oldData.slice(Math.max(oldData.length - maxStoredSteps))
  } catch(e) {
  }

  const newData = [
    ...limitedOldData,
    ...steps.map((step, index) => {
      return {
        step: JSON.parse(JSON.stringify(step)),
        version: version + index + 1,
        clientID: step.clientID,
      }
    })
  ]

  fs.writeFileSync(dbPath + namespaceDir + stepsPath, JSON.stringify(newData))
}

function storeLocked(locked, namespaceDir) {
  fs.writeFileSync(dbPath + namespaceDir + lockedPath, locked.toString())
}

function getDoc(namespaceDir) {
  try {
    return JSON.parse(fs.readFileSync(dbPath + namespaceDir + docPath, 'utf8'))
  } catch(e) {
    return defaultData
  }
}

function getLocked(namespaceDir) {
  try {
    return JSON.parse(fs.readFileSync(dbPath + namespaceDir + lockedPath, 'utf8'))
  } catch(e) {
    return false
  }
}

function getSteps(version, namespaceDir) {
  try {
    const steps = JSON.parse(fs.readFileSync(dbPath + namespaceDir + stepsPath, 'utf8'))
    return steps.filter(step => step.version > version)
  } catch(e) {
    return []
  }
}

const namespaces = io.of(/^\/[a-zA-Z0-9_\/-]+$/)

namespaces.on('connection', socket => {  
  const namespace = socket.nsp;
  const namespaceDir = namespace.name

  initProjectDir(namespaceDir)

  socket.on('update', async ({ version, clientID, steps }) => {
    // we need to check if there is another update processed
    // so we store a "locked" state
    const locked = getLocked(namespaceDir)

    if (locked) {
      // we will do nothing and wait for another client update
      return
    }

    storeLocked(true, namespaceDir)

    const storedData = getDoc(namespaceDir)

    await sleep(simulateSlowServerDelay)

    // version mismatch: the stored version is newer
    // so we send all steps of this version back to the user
    if (storedData.version !== version) {
      namespace.emit('update', {
        version,
        steps: getSteps(version, namespaceDir),
      })
      storeLocked(false, namespaceDir)
      return
    }

    let doc = schema.nodeFromJSON(storedData.doc)

    await sleep(simulateSlowServerDelay)

    let newSteps = steps.map(step => {
      const newStep = Step.fromJSON(schema, step)
      newStep.clientID = clientID

      // apply step to document
      let result = newStep.apply(doc)
      doc = result.doc

      return newStep
    })

    await sleep(simulateSlowServerDelay)

    // calculating a new version number is easy
    const newVersion = version + newSteps.length

    // store data
    storeSteps({ version, steps: newSteps }, namespaceDir)
    storeDoc({ version: newVersion, doc }, namespaceDir)

    await sleep(simulateSlowServerDelay)

    // send update to everyone (me and others)
    namespace.emit('update', {
      version: newVersion,
      steps: getSteps(version, namespaceDir),
    })

    storeLocked(false, namespaceDir)
  })
  
  // send latest document
  namespace.emit('init', getDoc(namespaceDir))
  
  // send client count
  namespace.emit('getCount', io.engine.clientsCount)
  socket.on('disconnect', () => {
    namespace.emit('getCount', io.engine.clientsCount)
  })
})

Du côté client, voici une capture d’écran d’un exemple où j’ai simplement dupliqué l’éditeur, en donnant à chacun une URL de connexion au serveur de socket différente (http://localhost:6002/doc1 et http://localhost:6002/project1/doc1) :

Capture d'écran de plusieurs éditeurs collaboratifs connectés au serveur local.

Vous trouverez le code source ici : https://gist.github.com/Julien1138/b480927caf65f65c09ed1629591a9505

Utiliser les rooms pour créer plusieurs documents

Maintenant nous avons un éditeur collaboratif multi-document utilisant tiptap qui fonctionne. Et pour certain d’entre vous, l’utilisation des namespaces pourrait être suffisante. Ou alors, peut-être que vous ne souhaitez pas utiliser les namespaces, seulement les “rooms”. Les lignes qui suivent détaillent l’utilisation des “rooms” en complément des namespaces, mais le principe reste le même si on utilise pas les namespaces.

Ce chapitre est un peu plus complexe, parce que contrairement aux namespaces, pour lesquels le nom est défini dans l’URL, rien n’indique au server quelle “room” le client veut rejoindre au moment de la connexion. Nous devons donc implémenter la gestion d’un nouvel événement sur notre serveur afin de permettre au client de rejoindre une “room” spécifique.

Allons-y, appelons-le joinRoom :

socket.on('joinRoom', async (room) => {
  socket.join(room);
  [...]
}

La première chose à faire dans la callback est de rejoindre la “room” en utilisant la fonction socket.join()à laquelle on passe le nom de la “room”.

Et pour que nos autres gestionnaires d’événements soient bien assignés aux événements de la “room” courante, nous déplaçons également leur code à l’intérieur de cette callback.

Comme pour les namespaces, nous ajoutons un nouveau paramètre aux fonctions de lecture et d’écriture pour modifier le nom des fichiers en fonction du nom de la “room” auxquels ils sont associés.

Voici le code du serveur après ces nouvelles modifications :

import fs from 'fs'
import { Step } from 'prosemirror-transform'
import schema from './schema.js'
import { join } from 'path'
import { disconnect } from 'process'

// setup socket server
const app = require('express')()
const http = require('http').Server(app)
const io = require('socket.io')(http)
http.listen(6002)

// options
const simulateSlowServerDelay = 0 // milliseconds
const dbPath = './db'
const docTrailer = '-db.json'
const lockedTrailer = '-db_locked.json'
const stepsTrailer = '-db_steps.json'
const maxStoredSteps = 1000
const defaultData = {
  "version": 0,
  "doc": { "type": "doc", "content": [{ "type": "paragraph", "content":[{ "type": "text", "text": "Let's start collaborating. Yeah!" }] }] }
}

const sleep = (ms) => (new Promise(resolve => setTimeout(resolve, ms)));

function initProjectDir(namespaceDir) {
  if (!fs.existsSync(dbPath + namespaceDir)){
    fs.mkdirSync(dbPath + namespaceDir, { recursive: true })
  }
}

function storeDoc(data, namespaceDir, roomName) {
  fs.writeFileSync(dbPath + namespaceDir + '/' + roomName + docTrailer, JSON.stringify(data, null, 2))
}

function storeSteps({steps, version}, namespaceDir, roomName) {
  let limitedOldData = []
  try {
    const oldData = JSON.parse(fs.readFileSync(dbPath + namespaceDir + '/' + roomName + stepsTrailer, 'utf8'))
    limitedOldData = oldData.slice(Math.max(oldData.length - maxStoredSteps))
  } catch(e) {
  }

  const newData = [
    ...limitedOldData,
    ...steps.map((step, index) => {
      return {
        step: JSON.parse(JSON.stringify(step)),
        version: version + index + 1,
        clientID: step.clientID,
      }
    })
  ]

  fs.writeFileSync(dbPath + namespaceDir + '/' + roomName + stepsTrailer, JSON.stringify(newData))
}

function storeLocked(locked, namespaceDir, roomName) {
  fs.writeFileSync(dbPath + namespaceDir + '/' + roomName + lockedTrailer, locked.toString())
}

function getDoc(namespaceDir, roomName) {
  try {
    return JSON.parse(fs.readFileSync(dbPath + namespaceDir + '/' + roomName + docTrailer, 'utf8'))
  } catch(e) {
    return defaultData
  }
}

function getLocked(namespaceDir, roomName) {
  try {
    return JSON.parse(fs.readFileSync(dbPath + namespaceDir + '/' + roomName + lockedTrailer, 'utf8'))
  } catch(e) {
    return false
  }
}

function getSteps(version, namespaceDir, roomName) {
  try {
    const steps = JSON.parse(fs.readFileSync(dbPath + namespaceDir + '/' + roomName + stepsTrailer, 'utf8'))
    return steps.filter(step => step.version > version)
  } catch(e) {
    return []
  }
}

const namespaces = io.of(/^\/[a-zA-Z0-9_\/-]+$/)

namespaces.on('connection', socket => {  
  const namespace = socket.nsp;
  const namespaceDir = namespace.name

  initProjectDir(namespaceDir)

  socket.on('joinRoom', async (room) => {
    socket.join(room);

    socket.on('update', async ({ version, clientID, steps }) => {
      // we need to check if there is another update processed
      // so we store a "locked" state
      const locked = getLocked(namespaceDir, room)

      if (locked) {
        // we will do nothing and wait for another client update
        return
      }

      storeLocked(true, namespaceDir, room)

      const storedData = getDoc(namespaceDir, room)

      await sleep(simulateSlowServerDelay)

      // version mismatch: the stored version is newer
      // so we send all steps of this version back to the user
      if (storedData.version !== version) {
        namespace.in(room).emit('update', {
          version,
          steps: getSteps(version, namespaceDir, room),
        })
        storeLocked(false, namespaceDir, room)
        return
      }

      let doc = schema.nodeFromJSON(storedData.doc)

      await sleep(simulateSlowServerDelay)

      let newSteps = steps.map(step => {
        const newStep = Step.fromJSON(schema, step)
        newStep.clientID = clientID

        // apply step to document
        let result = newStep.apply(doc)
        doc = result.doc

        return newStep
      })

      await sleep(simulateSlowServerDelay)

      // calculating a new version number is easy
      const newVersion = version + newSteps.length

      // store data
      storeSteps({ version, steps: newSteps }, namespaceDir, room)
      storeDoc({ version: newVersion, doc }, namespaceDir, room)

      await sleep(simulateSlowServerDelay)

      // send update to everyone (me and others)
      namespace.in(room).emit('update', {
        version: newVersion,
        steps: getSteps(version, namespaceDir, room),
      })

      storeLocked(false, namespaceDir, room)
    })
    
    // send latest document
    namespace.in(room).emit('init', getDoc(namespaceDir, room))
    
    // send client count
    namespace.in(room).emit('getCount', namespace.adapter.rooms[room].length)
    socket.on('disconnect', () => {
      if (namespace.adapter.rooms[room]) {
        namespace.in(room).emit('getCount', namespace.adapter.rooms[room].length)
      }
    })
  })
})

Notez bien que la méthode qui permet de recupérer le nombre d’utilisateurs connectés a dû être modifiée afin de ne récupérer que les utilisateurs connectés à une “room” en particulier.

Du côté client, nous avons juste à envoyer un événement joinRoom juste après la création du socket et voilà :

Capture d'écran de plusieurs éditeurs collaboratifs connectés au serveur local, utilisant les namespaces et les rooms.

Le code source est disponible ici : https://gist.github.com/Julien1138/fd6b80dcc2d9cbc0172763167adceaa6

Aller plus loin

Cet article apporte des modifications simples à l’exemple fourni par tiptap. Il peut évidement être encore amélioré. Les fonctions de gestion de la base de données peuvent être déplacées dans une classe séparée par exemple. D’autres fonctionnalités comme l’affichage des curseurs et les sélections de texte des autres utilisateurs connectés peuvent être ajoutées.


J’espère que vous avez apprécié cet article de découverte et qu’il a aidé certains d’entre vous à comprendre comment un éditeur collaboratif multi-document pouvait être mis en place avec tiptap.

N’hésitez pas à le commenter et à le partager.

Bonne journée !

Related Posts

Leave a Reply