ノード単位の Yjs CRDT エンジン
v2.2 で提供された textSync: "crdt" モードのアーキテクチャと組み込みのリファレンスです。エンドツーエンドの使い方については、共同編集のデモを参照してください。
このモードとは
editor.collab.attach({ doc, provider, textSync: "crdt" }) は、段落単位の Yjs バインディングを有効にし、編集可能な DOM を共有の Y.Doc 内の Y.XmlFragment に反映します。2 人のユーザーが同時に同じ段落を編集しても、競合しない Yjs の操作になります。挿入/削除/書式設定の各範囲は Yjs のベクタークロックを持ち、CRDT がそれらを決定的にマージします。後勝ち(last-write-wins)はなく、通常の負荷ではキャレットが飛ぶこともなく、プロキシサーバーも不要です。エンジンは完全にブラウザー内で動作し、任意の Yjs プロバイダー(y-websocket、Hocuspocus、y-webrtc、Liveblocks、その他の CRDT 互換の通信手段)と組み合わせられます。この選択がデモではほとんど目立たないのに、本番環境では非常に重要になる理由については、CRDT と OT:共同編集エディターが編集をマージする方法を参照してください。
モードの一覧
textSync | 処理先 | 動作 |
|---|---|---|
false(既定) | プレゼンスのみの、何もしないハンドル | アウェアネス/カーソルのみで、文書は同期しません。コンテンツが別の場所にある場合(サーバーに保存した HTML など)に使います。 |
true(従来) | スナップショットモードの MVP | 共有の Y.Text が、エディターの HTML を 1 つの不透明な文字列として保持します。マージは大まかで、同じ段落での競合は後勝ちになります。v2.0 の顧客との後方互換性のために残しており、2027 年に非推奨になります。 |
"crdt"(推奨) | ノード単位のエンジン(このページ) | 完全な文字単位の CRDT。同じ段落での同時入力はマージされ、プレゼンスのカーソルは Y.RelativePosition で表し、作成者ごとの Y.UndoManager を使います。 |
アーキテクチャ
エンジンは、window.RichTextEditorCrdt を公開する独立したプラグインのスクリプト(richtexteditor/plugins/crdt-engine.min.js、縮小後約 88 KB)として提供されます。yjscollab プラグインの collab.attach メソッドはこのグローバル変数を調べ、存在する場合は、アウェアネスのオーバーレイ、プレゼンスのパネル、review-ledger のブリッジは自分で管理したまま、コンテンツの同期をエンジンに任せます。
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エンジンが担うもの
- Y.XmlFragment と DOM の双方向の同期。ブロック要素、末端の要素、インラインのマークは、すべて往復で変換されます。ツリーの途中での childList の変更(リスト、表)では、文書全体を大まかに再描画するのではなく、該当するサブツリーだけを的確に再構築します。
- 選択範囲の保持。リモートの変更による再描画の前にパスベースで選択範囲を記録し、再描画の後に復元します。共同編集者が別の場所で入力しても、ユーザーのキャレットはその場にとどまります。
- エコーの防止。ローカルの編集には
LOCAL_ORIGINのタグが付き、リモートのハンドラーはそれを無視します。リモートの描画のたびにMutationObserver.takeRecords()を同期的に取り出すので、オブザーバーの非同期のコールバックが、ローカルの編集の送出を Y.Doc に戻してループさせることはありません。 - カーソルのプレゼンス。選択範囲の位置は
Y.RelativePositionのバイト列(同時の挿入/削除があっても変わらない安定した識別子)としてエンコードされ、アウェアネスのチャネルで公開されます。相手側は受信時にそれをデコードし、リモートのカーソルを自分のローカルの DOM に投影します。 - 作成者ごとの元に戻す操作。
trackedOrigins: { LOCAL_ORIGIN }を指定したY.UndoManagerにより、Ctrl-Z で元に戻るのはローカルのユーザーの編集だけで、共同編集者の編集が元に戻ることはありません。
ホストが担うもの
- ツールバー、メニュー、ダイアログ、貼り付けの処理。
- Yjs プロバイダーへの接続と認証(エンジンはネットワークと通信しません)。
- アウェアネスのペイロードの構成(ユーザーの識別情報、色、名前)。
- カーソルのオーバーレイの描画。エンジンは位置のデータを返し、ホストが描画します。
- 競合時の UX(ホバー時に共同編集者の名前を表示する、その選択範囲を強調表示するなど)。
組み込みのコード
Vanilla JS
<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>アタッチされたモードを確認する
本番環境の組み込みでは、アタッチした後にモードを確認してください。getStatus().yjsBridge は、Y.Doc がエンジンに同梱されているものとは別の Yjs インスタンスから作られており、プラグインがその間で更新を中継している場合に true になります(これを有効にするには attach に Y を渡します)。requireCrdt: true を指定すると、エンジンを開始できない場合でもプレゼンスとレビューの記録は使えるままになりますが、文書の編集が黙って従来のスナップショットモードに切り替わることはありません。集中的な監視や、目に見える回復のメッセージには onCollabStatus を使ってください。
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 }}
/>
);
}textSync: true からの移行
一気に切り替えるか、並行して展開するかによって、2 つの方法があります。
方法 A — 新規に作成する(推奨)
別のキーで新しい Y.Doc を作成します(たとえば、既存の文書 ID に "-crdt" を付け加えます)。ある文書を初めてアタッチするときに、エンジンの移行ヘルパーを実行し、保存済みの HTML から新しい文書にデータを入れます。
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",
});方法 B — 並行して切り替える
機能フラグによる展開の間、両方のモードを並行して実行します。重要:ある文書の共同編集者は全員、同じモードを使わなければなりません。同じ Y.Doc で従来のスナップショットモードと CRDT モードを混在させると、状態に不整合が生じます。フラグはテナント単位で設定してください。
const mode = featureFlag("rte_crdt_for_tenant_" + tenantId) ? "crdt" : true;
editor.collab.attach({ doc: ydoc, provider, textSync: mode });非推奨化のスケジュール(暫定)
| 時期 | 内容 |
|---|---|
| 2026 年第 2 四半期 | textSync: "crdt" をオプトインのフラグ付きで提供。textSync: true は既定のまま(破壊的変更なし)。 |
| 2026 年第 4 四半期 | textSync: "crdt" が既定になる。textSync: true は従来の後方互換用の shim によって引き続き動作。 |
| 2027 年第 2 四半期 | textSync: true は実行時に非推奨の警告を出す。 |
| 2027 年第 4 四半期 | textSync: true を削除。顧客は "crdt" を使う必要がある。 |
技術的な詳細
ソースの構成、テストのコーパス、コントリビューター向けのドキュメントは、内部の RichTextEditorCloud/CrdtEngine/ ワークスペースにエンジンと一緒に置かれています。公開 API は window.RichTextEditorCrdt.* としてエクスポートされています。
attachCollab(opts)— ディスパッチャー。textSyncのモードによって振り分けますattachCrdtBinding(opts)— バインディングを直接使う入り口(ディスパッチャーを経由しません)attachAwareness(opts)— カーソルのプレゼンスの接続attachUndoManager(opts)— 作成者ごとの Y.UndoManager のラッパーhtmlToYFragment(opts)— HTML からノード単位の Y への一括移行ヘルパーLOCAL_ORIGIN— エコー防止のため、ローカルで発生した Yjs のトランザクションにタグを付けるシンボル