← Toda a documentação

Engine CRDT Yjs por nó

Referência de arquitetura e integração do modo textSync: "crdt", lançado na v2.2. Para o uso completo, de ponta a ponta, veja a demonstração de colaboração.

O que é este modo

editor.collab.attach({ doc, provider, textSync: "crdt" }) ativa um binding Yjs por parágrafo que espelha o DOM editável em um Y.XmlFragment dentro do seu Y.Doc compartilhado. Dois usuários editando o mesmo parágrafo ao mesmo tempo geram operações Yjs sem conflito: cada intervalo de inserção / exclusão / formatação carrega um relógio vetorial Yjs, e o CRDT os mescla de forma determinística. Nada de last-write-wins, nada de saltos do cursor em carga normal, nenhum servidor proxy necessário — o engine roda inteiramente no navegador e funciona com qualquer provedor Yjs (y-websocket, Hocuspocus, y-webrtc, Liveblocks ou qualquer outro transporte compatível com CRDT). Para entender por que essa escolha quase não aparece em uma demonstração e faz muita diferença em produção, veja CRDT vs OT: como editores colaborativos mesclam edições.

Matriz de modos

textSyncDireciona paraComportamento
false (padrão)handle no-op somente de presençaApenas awareness / cursores; sem sincronização do documento. Use quando o conteúdo fica em outro lugar (por exemplo, HTML armazenado no servidor).
true (legado)MVP do modo snapshotUm Y.Text compartilhado guarda o HTML do editor como uma única string opaca. Mesclagem grosseira — last-write-wins em conflitos no mesmo parágrafo. Mantido para compatibilidade com clientes da v2.0; obsoleto em 2027.
"crdt" (recomendado)engine por nó (esta página)CRDT completo em nível de caractere. Digitação simultânea no mesmo parágrafo é mesclada; cursores de presença via Y.RelativePosition; Y.UndoManager por autor.

Arquitetura

O engine é distribuído como um script de plugin separado (richtexteditor/plugins/crdt-engine.min.js, ~88 KB minificado) que expõe window.RichTextEditorCrdt. O método collab.attach do plugin yjscollab verifica se esse global existe; se existir, delega a sincronização do conteúdo ao engine e continua responsável pela sobreposição de awareness, pelo painel de presença e pela ponte com o registro de revisão.

   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

Do que o engine cuida

  • Sincronização bidirecional Y.XmlFragment ↔ DOM. Elementos de bloco, elementos folha e marcas inline fazem o caminho de ida e volta. Alterações de childList no meio da árvore (listas, tabelas) disparam reconstruções cirúrgicas da subárvore, em vez de renderizações grosseiras do documento inteiro.
  • Preservação da seleção. Captura baseada em caminho antes de cada nova renderização causada por alterações remotas, restaurada depois — o cursor do usuário fica no lugar quando os colaboradores digitam em outro ponto.
  • Prevenção de eco. Edições locais recebem a marca LOCAL_ORIGIN; o handler remoto as ignora. MutationObserver.takeRecords() é esvaziado de forma síncrona após cada renderização remota, para que o callback assíncrono do observer não devolva a emissão da edição local ao Y.Doc em loop.
  • Presença de cursores. As posições de seleção são codificadas como bytes Y.RelativePosition — identificadores estáveis que sobrevivem a inserções / exclusões simultâneas — e publicadas no canal de awareness; os pares as decodificam ao recebê-las para projetar os cursores remotos no DOM local.
  • Desfazer por autor. Y.UndoManager com trackedOrigins: { LOCAL_ORIGIN } — Ctrl-Z desfaz apenas as edições do usuário local, nunca as do colaborador.

Do que a aplicação host cuida

  • Barra de ferramentas, menus, caixas de diálogo, tratamento de colagem.
  • Conexão / autenticação do provedor Yjs (o engine não se comunica com a rede).
  • Composição do payload de awareness (identidade do usuário, cor, nome).
  • Desenho da sobreposição de cursores — o engine retorna os dados de posição, o host desenha.
  • UX de conflitos (mostrar o nome dos colaboradores ao passar o mouse, destacar os retângulos de seleção deles etc.).

Código de integração

JavaScript puro

<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>

Verifique o modo conectado

Integrações em produção devem verificar o modo após o attach. getStatus().yjsBridge é true quando o seu Y.Doc veio de uma instância do Yjs diferente da que o engine inclui e o plugin está repassando as atualizações entre elas (passe Y para attach para habilitar isso). requireCrdt: true mantém a presença e o registro de revisão disponíveis quando o engine não consegue iniciar, mas não passa silenciosamente as edições do documento para o modo snapshot legado. Use onCollabStatus para telemetria centralizada ou para uma mensagem de recuperação visível.

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 }}
        />
    );
}

Migração a partir de textSync: true

Há dois caminhos, conforme você queira uma troca definitiva ou uma implantação lado a lado.

Caminho A — do zero (recomendado)

Gere um novo Y.Doc com uma chave diferente (por exemplo, acrescente "-crdt" ao id do documento existente). No primeiro attach de um determinado documento, execute o helper de migração do engine para preencher o novo documento a partir do HTML armazenado:

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

Caminho B — troca lado a lado

Execute os dois modos em paralelo durante uma implantação controlada por feature flag. Importante: todos os colaboradores de um mesmo documento precisam usar o mesmo modo; misturar os modos snapshot legado e CRDT no mesmo Y.Doc produz um estado inconsistente. Defina o escopo da flag por tenant.

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

Cronograma de descontinuação (provisório)

DataEvento
Q2 2026textSync: "crdt" é lançado atrás de uma flag opcional (opt-in); textSync: true continua sendo o padrão (sem alteração incompatível).
Q4 2026textSync: "crdt" passa a ser o padrão; textSync: true continua funcionando por meio do shim legado de compatibilidade.
Q2 2027textSync: true emite um aviso de descontinuação em tempo de execução.
Q4 2027textSync: true é removido. Os clientes precisam estar em "crdt".

Detalhes de engenharia

A estrutura do código-fonte, o corpus de testes e a documentação para contribuidores ficam junto ao engine, no workspace interno RichTextEditorCloud/CrdtEngine/. A API pública é exportada via window.RichTextEditorCrdt.*:

  • attachCollab(opts) — o dispatcher; direciona conforme o modo textSync
  • attachCrdtBinding(opts) — entrada direta do binding (dispensa o dispatcher)
  • attachAwareness(opts) — conexão da presença de cursores
  • attachUndoManager(opts) — wrapper do Y.UndoManager por autor
  • htmlToYFragment(opts) — helper de migração única de HTML → Y por nó
  • LOCAL_ORIGIN — símbolo usado para marcar transações Yjs de origem local, para prevenção de eco