← Toda la documentación

Motor CRDT de Yjs por nodo

Referencia de arquitectura e integración del modo textSync: "crdt" incluido en la v2.2. Para ver el uso de extremo a extremo, consulta la demo de colaboración.

Qué es este modo

editor.collab.attach({ doc, provider, textSync: "crdt" }) activa un binding de Yjs por párrafo que refleja el DOM editable en un Y.XmlFragment de tu Y.Doc compartido. Dos usuarios que editan el mismo párrafo a la vez producen operaciones de Yjs sin conflictos: cada rango de inserción, eliminación o formato lleva un reloj vectorial de Yjs, y el CRDT los combina de forma determinista. Sin «gana la última escritura», sin saltos del cursor con una carga normal y sin necesidad de un servidor proxy: el motor se ejecuta por completo en el navegador y funciona con cualquier proveedor de Yjs (y-websocket, Hocuspocus, y-webrtc, Liveblocks o cualquier otro transporte compatible con CRDT). Para saber por qué esta elección apenas se nota en una demo y tanto importa en producción, consulta CRDT frente a OT: cómo combinan las ediciones los editores colaborativos.

Matriz de modos

textSyncSe dirige aComportamiento
false (predeterminado)handle sin operación, solo de presenciaSolo presencia y cursores; sin sincronización del documento. Úsalo cuando el contenido esté en otro sitio (p. ej., HTML guardado en el servidor).
true (heredado)MVP en modo de instantáneasUn Y.Text compartido contiene el HTML del editor como una única cadena opaca. Combinación tosca: gana la última escritura en los conflictos dentro del mismo párrafo. Se mantiene por compatibilidad con los clientes de la v2.0; obsoleto en 2027.
"crdt" (recomendado)motor por nodo (esta página)CRDT completo a nivel de carácter. La escritura simultánea en el mismo párrafo se combina; cursores de presencia mediante Y.RelativePosition; Y.UndoManager por autor.

Arquitectura

El motor se distribuye como un script de plugin aparte (richtexteditor/plugins/crdt-engine.min.js, ~88 KB minificado) que expone window.RichTextEditorCrdt. El método collab.attach del plugin yjscollab comprueba si existe esa variable global; si existe, delega en el motor la sincronización del contenido y conserva la gestión de la capa de presencia, el panel de presencia y el puente con el registro de revisión.

   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

De qué se encarga el motor

  • Sincronización bidireccional entre Y.XmlFragment y el DOM. Los elementos de bloque, los elementos hoja y las marcas en línea hacen el viaje de ida y vuelta. Los cambios de childList en mitad del árbol (listas, tablas) provocan reconstrucciones precisas del subárbol en lugar de volver a renderizar todo el documento.
  • Conservación de la selección. Captura basada en rutas antes de cada nuevo renderizado provocado por un cambio remoto, restaurada después: el cursor del usuario se queda en su sitio cuando los colaboradores escriben en otra parte.
  • Prevención del eco. Las ediciones locales se etiquetan con LOCAL_ORIGIN; el controlador remoto las ignora. MutationObserver.takeRecords() se vacía de forma síncrona después de cada renderizado remoto, para que el callback asíncrono del observador no pueda devolver en bucle la emisión de la edición local al Y.Doc.
  • Presencia de cursores. Las posiciones de la selección se codifican como bytes de Y.RelativePosition (identificadores estables que sobreviven a inserciones y eliminaciones simultáneas) y se publican en el canal de presencia; los pares las decodifican al recibirlas para proyectar los cursores remotos sobre su DOM local.
  • Deshacer por autor. Y.UndoManager con trackedOrigins: { LOCAL_ORIGIN }: Ctrl-Z solo deshace las ediciones del usuario local, nunca las de su colaborador.

De qué se encarga la aplicación anfitriona

  • Barra de herramientas, menús, cuadros de diálogo y gestión del pegado.
  • La conexión y la autenticación con el proveedor de Yjs (el motor no se comunica con la red).
  • La composición de la información de presencia (identidad, color y nombre del usuario).
  • El dibujo de la capa de cursores: el motor devuelve los datos de posición y la aplicación los dibuja.
  • La experiencia ante conflictos (mostrar los nombres de los colaboradores al pasar el ratón, resaltar los rectángulos de su selección, etc.).

Código de integración

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>

Verifica el modo conectado

Las integraciones de producción deben comprobar el modo después de conectar. getStatus().yjsBridge es true cuando tu Y.Doc viene de una instancia de Yjs distinta de la que incluye el motor y el plugin está retransmitiendo las actualizaciones entre ellas (pasa Y a attach para activarlo). requireCrdt: true mantiene disponibles la presencia y el registro de revisión cuando el motor no puede arrancar, pero no cambia sin avisar las ediciones del documento al modo heredado de instantáneas. Usa onCollabStatus para la telemetría central o para un mensaje de recuperación 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 }}
        />
    );
}

Migrar desde textSync: true

Dos caminos, según quieras un cambio de golpe o un despliegue en paralelo.

Camino A: desde cero (recomendado)

Genera un Y.Doc nuevo con otra clave (p. ej., añade "-crdt" al id de tu documento actual). En la primera conexión de cada documento, ejecuta la función de migración del motor para rellenar el documento nuevo a partir de tu HTML guardado:

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",
});

Camino B: cambio en paralelo

Ejecuta ambos modos en paralelo durante un despliegue con feature flag. Importante: todos los colaboradores de un mismo documento deben usar el mismo modo; mezclar el modo heredado de instantáneas y el modo CRDT en el mismo Y.Doc produce un estado incoherente. Limita el flag por inquilino.

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

Calendario de obsolescencia (provisional)

FechaEvento
Q2 2026textSync: "crdt" se publica tras un flag de activación explícita; textSync: true sigue siendo el valor predeterminado (sin cambios incompatibles).
Q4 2026textSync: "crdt" pasa a ser el valor predeterminado; textSync: true sigue funcionando mediante la capa de compatibilidad heredada.
Q2 2027textSync: true emite una advertencia de obsolescencia en tiempo de ejecución.
Q4 2027Se elimina textSync: true. Los clientes deben usar "crdt".

Detalles de ingeniería

La estructura del código fuente, el corpus de pruebas y la documentación para colaboradores están junto al motor en el espacio de trabajo interno RichTextEditorCloud/CrdtEngine/. La API pública se exporta mediante window.RichTextEditorCrdt.*:

  • attachCollab(opts): el distribuidor; enruta según el modo textSync
  • attachCrdtBinding(opts): entrada directa al binding (sin pasar por el distribuidor)
  • attachAwareness(opts): conexión de la presencia de cursores
  • attachUndoManager(opts): wrapper de Y.UndoManager por autor
  • htmlToYFragment(opts): función de migración de una sola vez de HTML a Y por nodo
  • LOCAL_ORIGIN: el símbolo que se usa para etiquetar las transacciones de Yjs de origen local y evitar el eco