定義
編集可能領域(contenteditable — ユーザーが直接テキストを編集できる HTML 領域)の中でリッチテキストエンジン(太字・リストなどを扱う編集ライブラリ。ProseMirror や TipTap など)を使うとき、Ctrl+Z(取り消し)を押したのにエディター自身の取り消しが尽きたら、次の取り消しをドキュメント全体の取り消しに引き継ぐというキーボード処理パターンである。
重要な背景: この種のエディターはフォーカス中(入力カーソルが入っている状態)に Mod-Z/Mod-Y(Mac は Cmd、Windows は Ctrl — 取り消し/やり直しのショートカット)を受け取ると、ブラウザの標準 undo が割り込めないよう常に preventDefault(イベントのブラウザ標準動作をキャンセルする呼び出し)を掛ける。そのためエディターの取り消し履歴(undo stack — 取り消せる編集ステップを積み上げたリスト)が空でも、キーイベントはすでに「処理済み」と印が付く。すると上位のコードへそのまま流す(bubble — イベントが親要素へ伝播すること)ことができないので、明示的にドキュメントレベルの取り消しハンドラを呼んで引き継ぐ必要がある。
なぜ必要か
二重履歴(エディター内の取り消し vs ドキュメント全体の取り消し)を設計するとき、よくある誤った仮定がある。「エディターが処理できなければ、イベントはキャンセルされないまま(unprevented)親へ上がってくるだろう」というものだ。
実際のブラウザでは、エディターはブラウザ標準の undo を防ぐために取り消すものが無くても Mod-Z に preventDefault を掛ける。そのため上位ハンドラが if (event.defaultPrevented) return(既に処理済みのイベントなら無視)のようにしか書かれていないと、条件が常に真になり、ドキュメントレベルの取り消しが永遠に実行されない。これを防ぐには「親へ上がってくるか」に頼らず、エディターの取り消しが失敗した瞬間にドキュメントの取り消しを直接呼ぶ。
動作原理
- Guest keymap:
Mod-z→chainCommands(editorUndo, delegateDocumentUndo)。 editorUndoが true ならインライン history を消費。- false なら
delegateDocumentUndoが document history step を実行。 - delegate 前に guest commit + retire でテキスト消失・stale commit を防止。
- Document history は別 stack(例: sidebar move vs 本文)でありうる。
実務での適用
ProseMirror の例:
回帰テストは Playwright で: focus block → structural command(indent/move)→ Mod-Z → DOM order の復元を assert する。jsdom は PM の protective preventDefault を再現しないため、false negative/positive が出る。
デバッグ時は仮説(focus loss、phantom history)を、推測で直す前に document.activeElement、PM eventCount、defaultPrevented を計測して棄却する。
トレードオフ
- explicit chain は
defaultPreventedヒューリスティックより冗長だが、ブラウザ間で一貫する。 - delegate 前の commit は latency を追加する — IME composing gate と一緒に設計する。
- 多層 history(3+)は keymap の優先順位を文書化することが必須。
使ってはいけない場合
- 単一の monolithic document PM が一つだけで、document-level history が無い場合 — PM undo だけで十分。
- global capture listener で Mod-Z を横取りし PM と競合する場合 — 優先順位の衝突を別途解決する必要がある。
よくある間違い
- selection keyboard handler での一律
defaultPreventedguard。 - jsdom green で「undo OK」と判定する。
- delegate without commit — block-level undo 後のテキスト消失。
- inline/document history を一つの stack に混ぜ、sidebar move まで Ctrl+Z で rollback する。
関連概念
- two-stack-inverse-undo — inverse stack モデル
- focused-guest-editor — guest/host history 層
- ime-composition-contenteditable — focus 中の別の入力経路