この記事で扱うこと
ブロックベースのリッチテキストエディタを作っていると、順序、同時編集、undo、IME、テスト、scoped UI が一日に重なる。見かけのテーマは違うが、共通の目標は一つだ: 編集の意図を安定的に記録・巻き戻し・検証すること。各作業がどんな状況でなぜ必要になり、何を選び、何を学んだかを作業ごとに整理する。
一日のまとめ
| 作業 | 何をしたかったか | やったこと | 結果 |
|---|---|---|---|
| 順序 | concurrent insert でも決定的な順序 — renumber なしで | fractional position key + neighbor anchor(after/before) + (position, clientId, opId) tie-break | 中間・同時挿入で renumber 衝突なく整列を維持 |
| 協業 | optimistic + remote 到着時に rollback → re-apply | outbox に inverse patch を保存、merge の権威はサーバー seq | remote 到着時に rebase 可能、再適用失敗は silent drop しない |
| undo | guest が処理できないとき document undo へ明示委譲 | chainCommands(inlineUndo, delegateDocumentUndo) + Playwright 検証 | preventDefault で塞がれていた undo が document へ委譲された |
| IME | composing 中に commit を defer | composing フラグ + guest mount-once | 組み合わせ中の CJK 崩れを解消 |
| Portal | scoped CSS 維持 — 小さなメニューは inline render を検討 | 小さな toolbar/image control は Portal off の inline | Portal 外へ漏れていた scoped スタイルを維持 |
| Jest | ESM-only 依存を CJS Jest でロード | vendor TS shim + moduleNameMapper | nested install path でも安定して import |
1. Fractional ordering
背景知識(概念)
- ブロックエディタ: 段落・見出し・リストがそれぞれ「ブロック」であるドキュメント UI(Notion・Google Docs スタイル)。
どんな状況だったか
兄弟ブロックの順序を配列 index で保存すると、中間挿入・同時挿入・offline replay のとき renumber 衝突が頻発する。
主な作業
整列可能な文字列 position を storage の SoT とし、patch は「何番目」ではなく**隣接 anchor(after/before)**で意図を表現した。同時 insert は (position, clientId, opId) の tie-break で決定的な順序を作る。subtree move/promote 時の position reuse は sibling sort を壊すので、bounds 基準の再割り当てが必要だ。結果として、中間・同時挿入で renumber 衝突なく整列が維持された。
教訓
- ordering は結局 anchor・origin のような契約の問題だ。
- subtree move 時の position reuse は sibling sort を壊すので bounds 基準の再割り当てが必要だ。
2. Optimistic outbox rebase
背景知識(概念)
- Optimistic UI: サーバー応答の前にローカルに先に反映して速く見せるパターン。
どんな状況だったか
ローカルに先に見せる optimistic apply の後、サーバー/他クライアントの変更が到着すると**巻き戻してから再適用(rebase)**しなければならない。
主な作業
outbox の各 pending entry は inverse patch を一緒に保存してこそ remote 到着時に rollback→re-apply の rebase が可能だ。merge の権威は wall-clock ではなくサーバー seq に置く方が良い。再適用失敗は silent drop しない。結果として、remote 到着時に rebase が可能になり、再適用失敗を静かに捨てないようになった。
教訓
- undo・collab はどちらも inverse・origin のような契約の問題だ。
- 再適用失敗は silent drop しない。
3. Keyboard undo delegation
背景知識(概念)
- contenteditable: ブラウザ標準の編集可能領域。guest リッチテキストエンジンが focus 中に Ctrl/Cmd+Z に preventDefault をかけて native undo を塞ぐ。
どんな状況だったか
guest リッチテキストエンジンが focus 中に Ctrl/Cmd+Z に preventDefault をかけて native undo を塞ぐ。インライン履歴が空でもイベントは「消費された」ように振る舞うので、上位 handler の defaultPrevented ガードだけでは document undo に到達できない。
主な作業
chainCommands(inlineUndo, delegateDocumentUndo) で明示的に fallback し、Playwright で検証した。結果として、preventDefault で塞がれていた undo が document へ委譲された。
教訓
- undo は inverse・origin のような契約の問題だ。
- jsdom の green は preventDefault の根拠にはなれないので、実ブラウザ E2E が必要だ。
→ Contenteditable keyboard history delegation · Two-stack inverse undo
4. IME composition
背景知識(概念)
- IME: ハングル・日本語などの組み合わせ入力。
compositionstart~compositionendの間は commit を遅らせなければならない。
どんな状況だったか
組み合わせ中に commit・serialize・re-render が割り込むとハングルが崩れる。jsdom テストは composition を忠実に再現しない。
主な作業
カスタムの composition イベントより、composing フラグで commit を defer + guest の mount-once が先だ。結果として、組み合わせ中の CJK 崩れが解消された。
教訓
- jsdom は composition を忠実に再現しないので、IME の根拠は実ブラウザ E2E で確認する。
→ IME composition in contenteditable
5. Radix Portal vs scoped CSS
背景知識(概念)
- Radix Popover Portal: ポップオーバー content を DOM ツリー外にレンダーするパターン。scoped CSS の ancestor を外れる。
どんな状況だったか
[data-scope] .popover スタイルは Portal content には適用されない。
主な作業
小さな toolbar/image control popover は Portal off の inline render で scope を維持し、テストは trigger を open した後に内部の control を query した。結果として、Portal 外へ漏れていた scoped スタイルが維持された。
教訓
- scoped CSS を使う小さなエディタメニューは inline レンダーをまず検討する。
6. Jest + ESM-only dependency
背景知識(概念)
- ESM-only パッケージ: CommonJS なしで ES Module だけを提供する npm パッケージ。CJS Jest で import エラーを出す。
どんな状況だったか
ESM-only の npm パッケージが CJS Jest で import エラーを出す。
主な作業
vendor TS shim + moduleNameMapper が transformIgnorePatterns より nested install path で安定しうる。結果として、nested install path でも安定して import された。
教訓
- test の green と tsc の green は別物だ。