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
textSync | Se dirige a | Comportamiento |
|---|---|---|
false (predeterminado) | handle sin operación, solo de presencia | Solo 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áneas | Un 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 updatesDe 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.UndoManagercontrackedOrigins: { 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)
| Fecha | Evento |
|---|---|
| Q2 2026 | textSync: "crdt" se publica tras un flag de activación explícita; textSync: true sigue siendo el valor predeterminado (sin cambios incompatibles). |
| Q4 2026 | textSync: "crdt" pasa a ser el valor predeterminado; textSync: true sigue funcionando mediante la capa de compatibilidad heredada. |
| Q2 2027 | textSync: true emite una advertencia de obsolescencia en tiempo de ejecución. |
| Q4 2027 | Se 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 modotextSyncattachCrdtBinding(opts): entrada directa al binding (sin pasar por el distribuidor)attachAwareness(opts): conexión de la presencia de cursoresattachUndoManager(opts): wrapper de Y.UndoManager por autorhtmlToYFragment(opts): función de migración de una sola vez de HTML a Y por nodoLOCAL_ORIGIN: el símbolo que se usa para etiquetar las transacciones de Yjs de origen local y evitar el eco