← Toute la documentation

Moteur CRDT Yjs par nœud

Référence d’architecture et d’intégration du mode textSync: "crdt", livré en v2.2. Pour une utilisation de bout en bout, consultez la démo de collaboration.

Ce qu’est ce mode

editor.collab.attach({ doc, provider, textSync: "crdt" }) active une liaison Yjs par paragraphe qui reflète le DOM modifiable dans un Y.XmlFragment de votre Y.Doc partagé. Deux utilisateurs qui modifient le même paragraphe au même moment produisent des opérations Yjs sans conflit : chaque plage d’insertion, de suppression ou de mise en forme porte une horloge vectorielle Yjs, et le CRDT les fusionne de façon déterministe. Pas de « la dernière écriture l’emporte », pas de sauts de curseur en charge normale, pas de serveur proxy nécessaire — le moteur s’exécute entièrement dans le navigateur et fonctionne avec n’importe quel provider Yjs (y-websocket, Hocuspocus, y-webrtc, Liveblocks, ou tout autre transport compatible CRDT). Pour comprendre pourquoi ce choix se voit à peine dans une démo et compte énormément en production, consultez CRDT ou OT : comment les éditeurs collaboratifs fusionnent les modifications.

Tableau des modes

textSyncAboutit àComportement
false (par défaut)poignée sans effet, présence uniquementAwareness et curseurs uniquement ; pas de synchronisation du document. À utiliser lorsque le contenu vit ailleurs (par exemple du HTML stocké sur le serveur).
true (ancien)MVP en mode instantanéUn Y.Text partagé contient le HTML de l’éditeur sous forme d’une seule chaîne opaque. Fusion grossière — la dernière écriture l’emporte en cas de conflit sur un même paragraphe. Conservé pour la rétrocompatibilité avec les clients de la v2.0 ; obsolète en 2027.
"crdt" (recommandé)moteur par nœud (cette page)CRDT complet au niveau du caractère. La saisie simultanée dans un même paragraphe fusionne ; curseurs de présence via Y.RelativePosition ; Y.UndoManager par auteur.

Architecture

Le moteur est livré sous forme de script de plugin séparé (richtexteditor/plugins/crdt-engine.min.js, ~88 KB minifié) qui expose window.RichTextEditorCrdt. La méthode collab.attach du plugin yjscollab détecte cette variable globale ; si elle est présente, elle délègue la synchronisation du contenu au moteur tout en gardant la maîtrise de la surcouche d’awareness, du panneau de présence et de la passerelle vers le registre de relecture.

   Browser editable (rte.js)
                          ↓ MutationObserver
                    [crdt-engine.js]
                          ↓ Y.Doc transactions (LOCAL_ORIGIN)
                       Y.XmlFragment
                          ↓ Yjs provider (y-websocket / Hocuspocus / etc.)
                        Network
                          ↓
                       Other peers' Y.Docs

   Other peer's Y.Doc updates
                          ↓ ydoc.on("update", origin !== LOCAL_ORIGIN)
                     [crdt-engine.js renders to DOM,
                      preserves selection via path-based capture]
                          ↓
                  Browser editable updates

Ce dont le moteur est responsable

  • Synchronisation bidirectionnelle Y.XmlFragment ↔ DOM. Les éléments de bloc, les éléments terminaux et les marques en ligne font tous l’aller-retour. Les modifications de childList au milieu de l’arbre (listes, tableaux) déclenchent des reconstructions ciblées de sous-arbres plutôt qu’un nouveau rendu grossier de tout le document.
  • Préservation de la sélection. Capture fondée sur les chemins avant chaque nouveau rendu provoqué par une modification distante, puis restauration — le curseur de l’utilisateur reste en place lorsque des collaborateurs tapent ailleurs.
  • Prévention de l’écho. Les modifications locales sont étiquetées LOCAL_ORIGIN ; le gestionnaire distant les ignore. MutationObserver.takeRecords() est vidé de façon synchrone après chaque rendu distant, pour que le callback asynchrone de l’observateur ne puisse pas renvoyer en boucle l’émission de la modification locale dans le Y.Doc.
  • Présence des curseurs. Les positions de sélection sont encodées sous forme d’octets Y.RelativePosition — des identifiants stables qui survivent aux insertions et suppressions simultanées — et publiées sur le canal d’awareness ; les pairs les décodent à la réception pour projeter les curseurs distants sur leur DOM local.
  • Annulation par auteur. Y.UndoManager avec trackedOrigins: { LOCAL_ORIGIN } — Ctrl-Z n’annule que les modifications de l’utilisateur local, jamais celles de ses collaborateurs.

Ce dont l’hôte est responsable

  • Barre d’outils, menus, boîtes de dialogue, gestion du collage.
  • Connexion et authentification du provider Yjs (le moteur ne communique pas avec le réseau).
  • Composition de la charge utile d’awareness (identité, couleur et nom de l’utilisateur).
  • Dessin de la surcouche des curseurs — le moteur renvoie les données de position, l’hôte dessine.
  • Expérience utilisateur des conflits (affichage du nom des collaborateurs au survol, mise en évidence des rectangles de leur sélection, etc.).

Code d’intégration

JavaScript simple

<link rel="stylesheet" href="/richtexteditor/rte_theme_default.css">
<script src="/richtexteditor/rte.js"></script>
<script src="/richtexteditor/plugins/all_plugins.js"></script>
<script src="/richtexteditor/plugins/crdt-engine.min.js"></script>

<script type="module">
    import * as Y from "https://esm.sh/yjs";
    import { WebsocketProvider } from "https://esm.sh/y-websocket";

    const editor = new RichTextEditor("#editor", {
        commentsEnabled: true,
        trackChangesEnabled: true,
    });

    const ydoc = new Y.Doc();
    const provider = new WebsocketProvider("wss://your-yjs-server", "doc-1", ydoc);

    editor.collab.attach({
        doc: ydoc,
        provider: provider,
        Y: Y,                                 // your Yjs module — lets the engine bridge a doc built by it
        textSync: "crdt",                    // ← per-node CRDT
        requireCrdt: true,                    // fail closed if the engine is missing
        user: { id: "u-1", name: "Alice", color: "#2563eb" }
    });
</script>

Vérifier le mode attaché

Les intégrations de production doivent vérifier le mode après l’attachement. getStatus().yjsBridge vaut true lorsque votre Y.Doc provient d’une autre instance de Yjs que celle intégrée au moteur et que le plugin relaie les mises à jour entre les deux (transmettez Y à attach pour l’activer). requireCrdt: true garde la présence et le registre de relecture disponibles lorsque le moteur ne peut pas démarrer, mais ne bascule pas silencieusement les modifications du document vers l’ancien mode instantané. Utilisez onCollabStatus pour une télémétrie centralisée ou un message de récupération visible.

const editor = new RichTextEditor(host, {
  onCollabStatus(status) {
    if (status.fallbackReason) reportCollaborationIssue(status.fallbackReason);
  }
});

const status = editor.collab.getStatus();
if (status.textSyncMode !== "crdt") {
  reportCollaborationIssue(status.fallbackReason);
}

React

import { useEffect, useRef } from "react";
import { RichTextEditorComponent } from "@richscripts/richtexteditor/react";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";

export function CollabEditor({ docId, user }) {
    const editorRef = useRef();

    useEffect(() => {
        const editor = editorRef.current.getEditor();
        const ydoc = new Y.Doc();
        const provider = new WebsocketProvider(
            "wss://your-yjs-server", docId, ydoc,
        );
        editor.collab.attach({
            doc: ydoc, provider, Y, textSync: "crdt", user,
        });
        return () => { provider.destroy(); ydoc.destroy(); };
    }, [docId]);

    return (
        <RichTextEditorComponent
            ref={editorRef}
            config={{ commentsEnabled: true, trackChangesEnabled: true }}
        />
    );
}

Migrer depuis textSync: true

Deux voies, selon que vous voulez une bascule nette ou un déploiement en parallèle.

Voie A — nouveau départ (recommandée)

Générez un nouveau Y.Doc avec une clé différente (par exemple en ajoutant "-crdt" à l’identifiant de votre document existant). Au premier attachement pour un document donné, exécutez la fonction de migration du moteur pour remplir le nouveau document à partir de votre HTML stocké :

const newDoc = new Y.Doc();
window.RichTextEditorCrdt.htmlToYFragment({
    ydoc: newDoc,
    html: legacyHtmlString,
    parserDocument: document,
});
editor.collab.attach({
    doc: newDoc,
    provider: new WebsocketProvider(url, docId + "-crdt", newDoc),
    Y,
    textSync: "crdt",
});

Voie B — bascule en parallèle

Exécutez les deux modes en parallèle pendant un déploiement contrôlé par feature flag. Important : tous les collaborateurs d’un document donné doivent utiliser le même mode ; mélanger l’ancien mode instantané et le mode CRDT sur le même Y.Doc produit un état incohérent. Limitez le flag au niveau du locataire.

const mode = featureFlag("rte_crdt_for_tenant_" + tenantId) ? "crdt" : true;
editor.collab.attach({ doc: ydoc, provider, textSync: mode });

Calendrier d’obsolescence (provisoire)

DateÉvénement
Q2 2026textSync: "crdt" est livré derrière un flag à activer explicitement ; textSync: true reste la valeur par défaut (pas de changement incompatible).
Q4 2026textSync: "crdt" devient la valeur par défaut ; textSync: true fonctionne toujours via la couche de rétrocompatibilité.
Q2 2027textSync: true émet un avertissement d’obsolescence à l’exécution.
Q4 2027textSync: true est supprimé. Les clients doivent être passés à "crdt".

Détails techniques

L’organisation des sources, le corpus de tests et la documentation des contributeurs se trouvent à côté du moteur, dans l’espace de travail interne RichTextEditorCloud/CrdtEngine/. L’API publique est exportée via window.RichTextEditorCrdt.* :

  • attachCollab(opts) — le dispatcher ; aiguille selon le mode textSync
  • attachCrdtBinding(opts) — point d’entrée direct de la liaison (sans passer par le dispatcher)
  • attachAwareness(opts) — câblage de la présence des curseurs
  • attachUndoManager(opts) — wrapper de Y.UndoManager par auteur
  • htmlToYFragment(opts) — fonction de migration ponctuelle HTML → Y par nœud
  • LOCAL_ORIGIN — symbole utilisé pour étiqueter les transactions Yjs d’origine locale afin d’éviter l’écho