WebサイトにCtrl+Z(Undo / Redo)を実装する方法(Reactのkeydown + 履歴スタック)
概要
今回はWebサイト(Webアプリ)にCtrl+ZによるUndo(元に戻す)とRedo(やり直し)を実装する方法について紹介していきます。
お絵描きツールや画像編集系のUIを作っていると、「うっかり1個前の操作を消したい」場面って必ず出てきますよね。
そういうときにCtrl+Zが効くだけで、UXはかなり良くなる気がします(^^
実は先日作った画像マスクサイト「もざいくにゃん」に、この機能を後付けで入れたんですが、思っていたよりシンプルなコードで実現できました。
ポイントは「操作の履歴を2本のスタックで持つ」という考え方だけです。
本記事では、実際に動いているReact / JavaScriptのコードを抜粋しながら、履歴スタックの設計 → keydownの拾い方 → 巻き戻しの実装という順で解説していきます。
Mac/Windows両対応や入力欄でのショートカット無効化など、地味に忘れがちな実装上の注意もあわせてまとめました。
WebアプリにCtrl+Z(Undo / Redo)を入れたいけど「どこから手を付ければいいの?」と迷っている方は、是非見ていってください。
それではやっていきましょう(^^!
目次
Undo / Redoで実現するゴール
今回ゴールにするショートカットは、下記の3つです。
| ショートカット | 機能 | Mac |
|---|---|---|
| Ctrl+Z | Undo(1つ戻す) | ⌘+Z |
| Ctrl+Y | Redo(1つ進む) | ⌘+Y |
| Ctrl+Shift+Z | Redo(1つ進む) | ⌘+Shift+Z |
RedoにCtrl+YとCtrl+Shift+Zの両方を割り当てているのは、アプリごとに「やり直し」のキーが違って、ユーザーの手癖が分かれるからです。
Officeに慣れた人はCtrl+Y、Photoshopやエディタに慣れた人はCtrl+Shift+Zを押しがちなので、両対応にしておくと親切な設計だと思ってます(^^
実際にもざいくにゃんでは、画像にモザイクを何個か置いたあと、Ctrl+Zで1個ずつ取り消せるようになっています。
Undo / Redo実装の設計(履歴スタック方式)
Undo / Redoは難しそうに見えますが、分解すると下記の3要素だけです。
- ① 履歴スタックで状態を持つ
- 「やった操作」と「取り消した操作」を2本の配列で管理する
- ② keydownでショートカットを拾う
windowへのキーイベントでCtrl+Zなどを検出し、対応する関数を呼ぶ
- ③ スタックを巻き戻す / 進める
- 配列の末尾を片方からもう片方へ移し替える
ここで大事なのは、「画像そのものを毎回コピーして保存する」のではなく、「操作(オペレーション)の履歴を持つ」という考え方です。
画像のピクセルを丸ごとスナップショットすると重くなりますが、「どこにどんなモザイクをかけたか」という操作データだけなら配列1本だけで済みます。
それぞれ順番に見ていきましょう。
① Reactで履歴スタックを用意する(ops / redoStack)
まずは状態の定義からです。
ReactのuseStateで、操作の配列を2本持ちます。
editor.jsx
1 | // 編集対象の画像 |
opsが「これまでにやった操作」の履歴で、配列の末尾が一番新しい操作です。
モザイクを1つ置くたびに、opsの末尾に操作オブジェクト({ type, x, y, w, h, ... }のようなデータ)が積まれていきます。
そしてもう1本のredoStackが「Undoで取り消した操作」を一時的に退避させておく場所です。
ここがUndo / Redoの実装の肝で、戻したぶんをここに貯めておくからこそ、あとでやり直せるってわけです。
この2本はいわゆるLIFO(後入れ先出し)のスタックとして扱います。
「最後にやったことから順に取り消す」「最後に取り消したことから順にやり直す」。
どちらも末尾から出し入れするだけなので、配列のpushとpop相当の操作で完結します。
ちなみに、新しい操作を確定したタイミングではredoStackを空にします。
editor.jsx
1 | // ドラッグでモザイク矩形を確定したとき |
これは、一度戻してから別の操作をしたら、それ以前の「やり直し」はもう辿れなくするためです。
ブラウザやエディタの履歴と同じ挙動で、ユーザーの直感に合わせています。
② Undo / Redo関数の中身を書く
スタックさえあれば、Undo / Redo本体はこれだけで実装できます!
editor.jsx
1 | function undo() { |
やっていることは、「片方の配列の末尾を取り出して、もう片方の末尾に積み替える」だけです。undoはopsからredoStackへ、redoはredoStackからopsへ、という対称な関係になってます。
ここでのポイントは、sliceとスプレッド構文(...)を使って、必ず新しい配列を作っていることです。
Reactでは既存の配列をpushで直接書き換えると再描画が走らないことがあるので、setOps(ops.slice(0, -1))のように「新しい配列を渡す」形にしています。
このイミュータブルな書き方を守っておくと、わかりやすくてよいです(^^b
あとは、opsが変わるたびにcanvasを再描画する処理を別のuseEffectで書いておけば、UIが履歴と自動的に同期します。
③ Ctrl+Zをkeydownイベントで拾う
さていよいよ本題のキーボードショートカットです。useEffectの中でwindowにkeydownリスナーを登録します。
editor.jsx
1 | // Ctrl+Z = undo, Ctrl+Y / Ctrl+Shift+Z = redo (MacはCommand) |
短いですが、1つずつ見ていきましょう。
MacとWindowsの両対応
まず押さえておきたいのが、修飾キーの判定です。
editor.jsx
1 | const ctrl = e.ctrlKey || e.metaKey; |
WindowsのCtrlはe.ctrlKey、Macの⌘(Command)はe.metaKeyとして飛んできます。
MacユーザーはCtrl+Zではなく⌘+Zを押すので、この||が無いとMacで全く効かない、という大事故になります。
両方をctrlという1つの変数にまとめておけば、以降の分岐をプラットフォーム共通で書けるので楽です。
入力欄ではショートカットを無効化する
次に、入力欄の中ではショートカットを発動させないようにしています。
editor.jsx
1 | const t = e.target; |
もしフォームのinputやテキストエリアにフォーカスがある状態でCtrl+Zを奪ってしまうと、ユーザーが文字入力を取り消そうとしたのに、アプリ側のUndoが動いてしまうという気持ち悪い挙動になります。
入力欄にいるときはブラウザ標準のテキスト編集に任せたいので、INPUT / TEXTAREA / contentEditableなら早期returnで素通りさせています。
ここは忘れがちなので要注意です(/.;
preventDefaultでブラウザ標準動作を抑える
ショートカットが有効に発動するときだけ、e.preventDefault()を呼んでいます。
editor.jsx
1 | if (ops.length === 0) return; // 戻せないなら preventDefault しない |
ブラウザによってはCtrl+Zに独自の動作(フォームのリセットなど)が割り当たっていることがあり、放っておくと自分のUndoと二重に効いてしまう恐れがあります。
そこで「このアプリのショートカットを処理する」と決めた瞬間だけpreventDefaultを呼び、ブラウザ標準の動作を止めるようにしています。
逆に、戻せる操作が無い(ops.length === 0)ときはpreventDefaultせずにそのまま抜けています。
何もしないのにブラウザの動作だけ潰す、という余計な副作用を避けるためですね。
クリーンアップと依存配列
最後に、useEffectの後始末です。
editor.jsx
1 | window.addEventListener('keydown', onKeyDown); |
useEffectが返す関数(クリーンアップ関数)で、登録したリスナーを必ず外しています。
これをやらないと、再レンダリングのたびにリスナーが二重三重に積み重なって、Undoが2回走るなどの不具合につながります。
[image, result, ops, redoStack] を指定しているのもポイントです。
onKeyDown の中で参照しているstateが変わるたびにリスナーを貼り直すことで、ハンドラが常に最新の ops / redoStack を見るようにしています。
ボタンからもUndo / Redoを呼べるようにする
キーボードショートカットだけだと、スマホやタブレットのユーザーが使えませんよね。
ここまでに作ったundo関数とredo関数は、そのまま画面上のボタンにも割り当てられます。
editor.jsx
1 | <button onClick={undo} disabled={ops.length === 0}>元に戻す</button> |
ロジック(履歴スタックの積み替え)と入力手段(keydown / クリック)を分けて書いておくと、同じundo / redoを両方から呼び回せるのが嬉しいところです。disabledにops.length === 0を渡しておけば、戻せないときはボタンがグレーアウトするので、UI的にも親切になります(^^b
Undo / Redo実装ではまりやすいポイント
最後に、実装中に踏みやすい落とし穴を3つだけ共有しておきます。
- 依存配列を空([])にすると state が古いまま固まる
- リスナーは1度しか貼られないのに、その中の
opsは初期値の空配列を見続けるため、何個操作しても「戻せる操作がない」と判定されてUndoが効かなくなります。
- e.key の大文字小文字
- Shiftを押すと
e.keyが'Z'(大文字)になります。toLowerCase()で正規化してから比較しないと、Ctrl+Shift+Zの判定を取りこぼします。
- Shift+Z を Redo に割り当てるときの分岐順序
- 先にRedo(Ctrl+Shift+Z)を判定し、そのあとUndo側を
!e.shiftKey && k === 'z'で絞ること。順序を逆にすると、Ctrl+Shift+ZがUndoに吸われてRedoが動きません。
どれも「動かない!」となりがちですが、原因が分かってしまえば対処は簡単です。
特に1番目の依存配列は、Reactのkeydown実装で一番ハマるところなので頭の片隅に置いておいてください(^^
まとめ
いかがでしたか?
Webサイトへのキーボードショートカット(Ctrl+Z / Redo)の実装を振り返ると、やることは下記の3つだけでした。
- ① 履歴スタックを2本持つ
ops(やった操作)とredoStack(取り消した操作)をLIFOで管理する
- ② keydownでショートカットを拾う
windowにkeydownを登録し、ctrlKey || metaKeyでMac/Win両対応にする
- ③ スタックを末尾で積み替える
sliceとスプレッドでイミュータブルに移し替えるだけ
画像そのものではなく「操作の履歴」を持つことを意識するだけで、コードも軽さも両立できます。
入力欄での無効化やpreventDefault、依存配列といった細かな配慮を入れておけば、ユーザーにとって「効いて当たり前」な自然なショートカットになります。
自作のWebアプリにCtrl+Zを入れてみたい方は、まずは小さなops配列から試してみるのがお勧めです。
以上となります。
やっぱりショートカット、ショートカットが生産性を支える!
それではお疲れさまでした!





