定義
段落・画像・表のような「ブロック」が複数積み重なったエディタで、ユーザーが複数ブロックをまたいでドラッグ選択し、その範囲を削除・太字化・タイピングで置換する動作を作りたいときに使うパターンである。厄介なのは、リッチテキスト編集エンジンが今カーソルのある1ブロックだけに付いていて、ブロック境界を越える選択を処理する主体がいないためである。
正確に言えば、ブロックをまたぐテキスト範囲操作は、文書をブロックツリーとしてhost(文書全体の状態を所有する上位コンポーネント)が所有し、リッチテキストエンジンはフォーカスされた1ブロックだけにマウントされるハイブリッドエディタで、ユーザーが複数ブロックにまたがって作ったnativeテキスト選択(ブラウザが標準で管理する、ドラッグで生じた選択領域)に対して削除・マーク(太字・斜体などの書式)・入力を適用するパターンである。範囲をReact stateに保存せず、操作時点でwindow.getSelection()(現在の選択領域を返すブラウザAPI)から無状態で派生させ、hostパッチ(状態を部分的に変える変更片)で反映する。
なぜ必要か
単一のProseMirror/TipTap(代表的なリッチテキストエディタエンジン)documentはselection owner(選択状態を管理する主体)が1つである。guest(1ブロックだけに一時的にマウントされる編集エンジンインスタンス)が1ブロックだけを編集すると、ブロック境界を越える選択に対してowning editor instanceがない。ところがブラウザのSelection APIはすでにクロスノード範囲(複数DOMノードにまたがる選択)を表現している。範囲をミラーリング(React stateへ別途複製)するとnative selectionとdrift(値が互いにずれる)し、IME(韓国語・日本語などを組み合わせて入力する方式)の変換・リレンダー時に壊れやすい。
動作原理
- ユーザーがstaticブロック・guestブロックをまたいでドラッグ選択する。
selectionchange(選択領域が変わるたびに発生するブラウザイベント)またはkeydown時点でanchor/focus DOM node(選択の始点・終点)を(blockId, inlineOffset)に変換する。isCrossBlockなら、guestのonKeyDownではなくdocument-levelなhandlerが実行される。- 範囲操作前のブロック境界でのinline content split(選択境界でテキストを2片に分けること)ロジックを1モジュールに置く — delete·addMark·removeMark·slice(範囲だけ切り出してコピー)で再利用される。
- 削除(Backspace)はしばしばマージ: 最初のブロックのprefix + 最後のブロックのsuffixを最初のブロックに合わせ、中間ブロックを除去する。
- パッチ適用後にstatic DOMがリレンダーされるとnative selectionが消えることがある → offsetをDOM positionに逆マッピングして
requestAnimationFrame(次の画面描画直前にコールバックを実行するブラウザAPI)で再選択する。 - 単一ブロック内部の選択は既存のguestが処理する — クロスのときだけ横取りする。
実務での適用
- DOM position ↔ logical offset変換ヘルパーを双方向に保つ。
blockInlineContent(block)のようにtextだけを持つブロックの派生contentを読む — rawcontentundefinedの罠。- フローティング書式ツールバーは単一ブロックUIを再利用し、クロスのときだけコールバックをpatch操作へルーティングする。
- Enter on cross-block selection: splitせずマージのみとするポリシーなど、製品ルールを明示する。
- copy/cut/pasteはsliceのシリアライズ + 既存clipboardパイプライン再利用が自然な次のステップである。
トレードオフ
- グローバルなselectionモデルを新規に作らないため同期負担が少ない。
- 代わりにブロックごとのpatch·境界split·selection復元ロジックが複雑になる。
- guestトランザクションとpatch経路が共存する — 単一ブロックの回帰をガードで厳密に分離する。
使ってはいけない場合
- 文書全体が単一contenteditable / 単一PM doc — 既定のselection·commandsで十分。
- ブロックが常にplain text 1行でマークがない — 範囲操作を単純化できる。
- 協調編集のOT/CRDT(複数ユーザーの同時編集を衝突なく統合するアルゴリズム)がselection stateをサーバー権威として持つ場合 — 別プロトコルが必要。
よくある間違い
- ドラッグ中にfrom/toをReact stateに保存してnativeとdriftする。
- 境界splitの数学をdelete·markごとに複製する。
- クロスブロックでguest keymapとdocument handlerの両方を実行する。
- マーク適用後にselection復元なしでツールバーが消える。
for...ofなどチームのlintと衝突する範囲走査(reduceなどで統一)。
関連概念
- focused-guest-editor — guestの1ブロックlease
- selectionchange-floating-ui — ツールバーのトリガー·所有権
- two-stack-inverse-undo — パッチ逆演算のundo
- fixed-position-floating-ui-scroll — ツールバーの位置