定義
エディタで Ctrl+Z(実行取り消し)と Ctrl+Shift+Z(やり直し)を作りたいときに使う履歴(編集記録)モデルだ。編集一つ一つを丸ごと保存せず、「前に進む演算」と「それを戻す反対の演算」のペアだけを記録しておけば、2 つのスタック(取り消し用・やり直し用)を行き来しながら状態を往復できる。
正確に言えば、2 スタック逆演算 undo/redo は編集を**前に進む演算(do)と後ろに戻る演算(undo/inverse)**のペアで記録し、undo スタックと redo スタックの 2 つにそのペアを移し替える履歴モデルだ。inverse(逆演算)とは、ある編集を正確に戻す反対の編集を意味する — 例:「3 番の位置に 'a' を挿入」の inverse は「3 番の位置の 'a' を削除」。全状態スナップショット(その瞬間の文書全体を丸ごとコピーしたもの)を積まず、命令(イベント)の逆で状態を往復する。GoF Command パターン(編集を実行/取り消しが可能な一つの「命令オブジェクト」として扱う古典的設計パターン)の execute / unexecute と同じ系列だ。
なぜ必要か
文書・キャンバス・フォームのように状態が大きいと、編集ごとに文書全体をコピーしてスタックに積むコストが大きく、構造共有・メモ化(値をキャッシュして再計算を避ける最適化)の契約を壊しやすい。すべての編集がすでに「パッチ(patch、状態を部分的に変える変更のかけら)/コマンド」で表現されているなら、その単一の apply 関門(すべての編集が必ず通る一つの適用関数)で inverse だけを一緒に計算すれば undo はほぼタダだ。関門がなければ、undo UI よりも記録の関門を先に作る必要がある。
動作原理
- ユーザー編集が apply 関門を通ると、
doとinverseを同時に計算する。 - エントリ
{ undo: inverse, redo: do }を undo スタックに push し、redo スタックは空にする(新しい分岐が未来を無効化)。 - Undo: undo スタックから pop →
undoを適用 → 同じエントリを redo スタックに push。 - Redo: redo スタックから pop →
redoを適用 → 再び undo スタックに push。 - 履歴の再生中は再び record せず、trailing node(文書の末尾を常に空の段落に保つなどのルール)のような自動 normalizer(編集後に文書をルールに合わせて自動補正するロジック)も切る。守らないとスタックが自分自身を汚染したり、undo 後の正規化が状態を変えて redo が食い違う。
| 項目 | 説明 | 注意点 |
|---|---|---|
エントリ = {undo, redo} | undo 時は inverse、redo 時は原本を再適用 | inverse の inverse を毎回再計算しない |
| record 時に redo クリア | 新しい編集が分岐すると未来は無効 | スタック分岐の意味論 |
| 履歴実行 = raw | record off + normalizer off | 相互排他 |
| 多層履歴 | 下位(インライン)スタックが空なら上位(文書)へバブル | 下位が false を返してこそバブル |
実務適用
インラインエディタ(セッション履歴)と文書ツリー履歴が共存するときは、下位の keymap(キー入力を特定の動作に結びつけたマッピング)が処理するものがなければ false を返して、上位ハンドラへイベントをバブル(イベントが下位から上位へ伝播すること)させる。
トレードオフ
- スナップショットスタックよりメモリが小さく速いが、すべての演算に正しい inverse が必要だ。inverse が間違っていると静かに状態が壊れる。
- 不変式(常に空の段落で終わる、など)と履歴は相互排他にすべきだ。不変式を履歴経路でも回すと redo が計算された状態と食い違う。
使ってはいけない場合
- 編集が関門なしで状態を直接ミューテーションするとき — inverse を信頼できない。
- OT/CRDT(複数ユーザーの同時編集を衝突なく統合する協業アルゴリズム)など多人数の同時編集が本体の場合 — ローカルの 2 スタックだけでは、リモート演算と合成ルールが異なる。
- 状態が小さくスナップショットの方がシンプルな場合(設定トグル数個)— スナップショットスタックで十分だ。
よくある間違い
- undo 経路でも record が付いていて、スタックが自分自身を再び積む。
- undo 後に trailing-block のような normalizer が状態を変えて redo が崩壊する。
- 新しい編集の後も redo スタックを空にせず、「未来」が残る。
- インラインエディタと文書履歴が同じキー(例:Mod-z)を巡って互いに上書きする。
関連概念
- focused-guest-editor — インライン guest のセッション履歴と文書 host の履歴を層に分ける典型的な背景。