この記事で扱うこと
Notion・Google Docs のようにブロックが積み重なったドキュメントをウェブで編集するとき、「エディタ一つにドキュメント全体」の代わりに host(ドキュメントの主)/ guest(フォーカスブロックの客) に分ける パターンと、その上で生じた4つの問題を整理する。
最も単純な実装は「ドキュメント全体 = リッチテキストエディタ一つ」だ。タイピングは楽だがすぐに詰まる — 権限・購読・保存が「エディタが持つツリー」に縛られ、ブロックごとに異なる UI(埋め込み、ウィジェット)を付けにくく、ブロックが数百個あってもエディタ一つが全部生きていなければならない。だから役割を分ける: Host はブロックリスト・パッチ・権限・保存を握るドキュメントの本当の主(SoT)、Guest は今カーソルがある一つのブロックだけを編集する客エディタだ。フォーカスがないブロックは読み取り用の HTML で描き、クリックしてフォーカスが入ったときだけ guest がマウントされ、フォーカスが外れると guest が内容を host に返し(commit)消える。
一日のまとめ
| 作業 | 何をしたかったか | やったこと | 結果 |
|---|---|---|---|
| Host/Guest | ドキュメント変更をパッチ関門でのみ — guest はフォーカスブロックだけ | inject → commit → keymap 委譲; 構造演算の前に commit-before-delegate | stale commit が変わった host 状態を上書きしない |
| External store | React 外の状態を UI に付けるときflake なしに再レンダー | スナップショットに単調な revision(iso#n)を付与 | 同一 ms の mutation でも再レンダーが落ちない |
| Undo | 全体スナップショットのコピーではなく do/inverse 2スタック | { undo, redo } 2スタック; 再生中は record・normalizer を相互排除 | メモリ節約 + redo 崩壊を防止 |
| DnD·コピー | guest の1ブロックの限界をブラウザネイティブで補完 | HTML5 DnD + host パッチ; ハンドルグリフは ::before pseudo でコピー経路から除外 | クロスブロック移動が可能、装飾グリフがコピーに混ざらない |
1. Host / Guest — ドキュメントはパッチが所有する
背景知識(概念)
- Source of Truth (SoT): 「本当のデータがどこにあるか」。ドキュメント構造・パッチは host が所有する。
- Guest エディタ: ProseMirror/TipTap などのリッチテキストエンジンが今フォーカスされた一つのブロックだけを編集する。
どんな状況だったか
Guest(リッチテキストエンジン)が「ドキュメント全体」のように振る舞うと、ブロック分割・タイプ変更・権限チェックが guest 内部状態に従属する。後で「このブロックだけ読み取り専用」「このブロックだけリアルタイム購読」のような要求が来るとエディタ API と戦うことになる。ドキュメント変更をパッチ関門でのみ受けたかった。
主な作業
- Host にパッチ apply 関門を置く。ドキュメント変更はすべて
[{ op: "update", id, content }, …]のようなパッチとしてのみ入る。 - Guest はフォーカスされたブロックの
contentだけを受け取る: inject(フォーカス時に host が現在の内容を guest に入れる)→ commit(フォーカス解除・退去時に guest が内容を host に返す)→ keymap 委譲(「見出しに変える」のような構造キーは guest が処理せず host コールバックへ渡す)。 - キーも層に分ける — テキスト・マーク(太字、文字入力)は Guest、構造(ブロックタイプ変更・移動)は Host、無フォーカス(複数ブロック選択モード)は Host の selection モード。
- stale commit 防止: 構造演算の直後に古い guest が遅れて
blurcommit を出すと、すでに変わった host 状態を古い内容で上書きする。だから構造演算の前には常に今の guest 内容を先に commit した上で(commit-before-delegate)host パッチを適用し、その lease は破棄(retire)する。
教訓
- ドキュメントは host パッチが SoT であり、エディタはフォーカスブロックの guest にすぎない。
2. useSyncExternalStore — 「変わったのに同じと判断する」罠
背景知識(概念)
useSyncExternalStore: React 外のストアを UI に付ける公式フック。
どんな状況だったか
フォーカス中のブロック id、レイアウト状態のように React 外にあるストアを UI に付けるとき useSyncExternalStore を使う。React はストアが「変わった」と知らせても、getSnapshot() が返した値が以前と Object.is で同じなら再レンダーしない。スナップショットを new Date().toISOString() のようにミリ秒タイムスタンプだけ入れると、同じ 1ms 内に「マウント + すぐに update」が連続するとき、二つのスナップショット文字列が同一で、React が「変更なし」と見て UI を描かない。flake なしに再レンダーしたかった。
主な作業
スナップショットに常に増加する revision を付ける。
SSR を通るコンポーネントは getServerSnapshot も必ず渡す。
教訓
subscribeが呼ばれた ≠ スナップショット値が変わった — timestamp だけを使わず revision を使う。
→ useSyncExternalStore スナップショットの同一性 · SSR hydration mismatch
3. Undo/Redo — スナップショットの代わりに「前へ/後ろへ」のペア
背景知識(概念)
- do/inverse 2スタック: 編集がすでに「パッチ」で表現されるなら、そのパッチの反対(inverse)さえあれば元に戻せる。undo・redo の二つのスタックで管理する。
どんな状況だったか
ドキュメント全体をスタックにコピーしておくとメモリが大きく、構造共有・メモ化も壊れやすい。全体スナップショットのコピーではなく do/inverse 2スタックで行きたかった。
主な作業
エントリ一つ = { undo: inverse パッチ, redo: 元のパッチ }。
- ユーザーが編集 → apply 関門で do と inverse を一緒に計算 → undo スタックに push、redo スタックは空にする。
- Undo → undo スタック pop →
undoを適用 → redo スタックに push。 - Redo → redo スタック pop →
redoを適用 → undo スタックに push。
undo/redo の再生中は再び履歴に記録せず、自動 normalizer もオフにする — 不変式が redo 状態とずれて redo が崩壊するからだ。
教訓
- Undo UI より先に単一の apply 関門があってこそ inverse を信じられる。
4. ドラッグ・コピー — ブラウザと戦わない
背景知識(概念)
- HTML5 Drag and Drop: guest がフォーカスされた一つのブロックにだけ生きているとき、ブロックを跨ぐ移動をブラウザネイティブの DnD で解く。
- Pseudo content: 装飾グリフを DOM テキストではなく
::before { content }に置くとSelection.toString()・コピーに混ざらない。
どんな状況だったか
Guest はフォーカスされた一つのブロックにだけ生きているので、ブロックを跨いでテキストを移すのが難しい(問題 A)。また、ハンドルグリフ ⠿ を DOM テキストに置くとコピーに混ざる(問題 B)。guest の1ブロックの限界をブラウザネイティブで補完したかった。
主な作業
- 問題 A(クロスブロック移動): コンテナ全体で HTML5 Drag and Drop を使い、ドロップ結果は host パッチで反映する。
dragoverでpreventDefault()必須、ドラッグ中に DOM を差し替えるとセッションが切れるのでドラッグ開始時に guest を整理、E2E はDragEventを直接ディスパッチして経路を検証。 - 問題 B(ハンドルグリフ):
⠿を::before { content: "⠿" }pseudo content に置いてコピー経路から除外する。
教訓
- ブラウザが得意なこと(選択・ドラッグ)を再実装せず、足りない穴だけを host パッチで埋める。
- 装飾グリフは DOM テキストではなく pseudo content —
Selection.toString()に混ざらないように。
→ HTML5 drag and drop · Selection.toString() and pseudo content