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
2
3
4
// 編集対象の画像
const [image, setImage] = useState(null); // 確定した操作の履歴
const [ops, setOps] = useState([]); // Undoで取り消した操作(やり直し用)
const [redoStack, setRedoStack] = useState([]);

ops「これまでにやった操作」の履歴で、配列の末尾が一番新しい操作です。
モザイクを1つ置くたびに、opsの末尾に操作オブジェクト({ type, x, y, w, h, ... }のようなデータ)が積まれていきます。

そしてもう1本のredoStack「Undoで取り消した操作」を一時的に退避させておく場所です。
ここがUndo / Redoの実装の肝で、戻したぶんをここに貯めておくからこそ、あとでやり直せるってわけです。

この2本はいわゆるLIFO(後入れ先出し)のスタックとして扱います。
「最後にやったことから順に取り消す」「最後に取り消したことから順にやり直す」。
どちらも末尾から出し入れするだけなので、配列のpushpop相当の操作で完結します。

ちなみに、新しい操作を確定したタイミングではredoStackを空にします。

editor.jsx

1
2
3
// ドラッグでモザイク矩形を確定したとき
setOps([...ops, op]); // 新しい操作を履歴に積む
setRedoStack([]); // 分岐した「やり直し」は破棄する

これは、一度戻してから別の操作をしたら、それ以前の「やり直し」はもう辿れなくするためです。
ブラウザやエディタの履歴と同じ挙動で、ユーザーの直感に合わせています。

② Undo / Redo関数の中身を書く

スタックさえあれば、Undo / Redo本体はこれだけで実装できます!

editor.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function undo() {
if (ops.length === 0) return; // 戻せる操作がなければ何もしない
const popped = ops[ops.length - 1]; // 一番新しい操作を取り出す
setOps(ops.slice(0, -1)); // opsから末尾を削る
setRedoStack([...redoStack, popped]); // 取り出した操作をredoStackへ退避
}

function redo() {
if (redoStack.length === 0) return;
const popped = redoStack[redoStack.length - 1];
setRedoStack(redoStack.slice(0, -1)); // redoStackから末尾を削る
setOps([...ops, popped]); // opsに戻す
}

function clearAll() {
setOps([]);
setRedoStack([]);
}

やっていることは、「片方の配列の末尾を取り出して、もう片方の末尾に積み替える」だけです。
undoopsからredoStackへ、redoredoStackからopsへ、という対称な関係になってます。

ここでのポイントは、sliceとスプレッド構文(...)を使って、必ず新しい配列を作っていることです。
Reactでは既存の配列をpushで直接書き換えると再描画が走らないことがあるので、setOps(ops.slice(0, -1))のように「新しい配列を渡す」形にしています。
このイミュータブルな書き方を守っておくと、わかりやすくてよいです(^^b

あとは、opsが変わるたびにcanvasを再描画する処理を別のuseEffectで書いておけば、UIが履歴と自動的に同期します。

③ Ctrl+Zをkeydownイベントで拾う

さていよいよ本題のキーボードショートカットです。
useEffectの中でwindowkeydownリスナーを登録します。

editor.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Ctrl+Z = undo, Ctrl+Y / Ctrl+Shift+Z = redo (MacはCommand)
useEffect(() => {
function onKeyDown(e) {
if (!image || result) return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
const ctrl = e.ctrlKey || e.metaKey;
if (!ctrl) return;
const k = e.key.toLowerCase();

// Redo: Ctrl+Y もしくは Ctrl+Shift+Z
if (k === 'y' || (e.shiftKey && k === 'z')) {
if (redoStack.length === 0) return;
e.preventDefault();
redo();
return;
}
// Undo: Ctrl+Z(Shiftが押されていないとき)
if (!e.shiftKey && k === 'z') {
if (ops.length === 0) return;
e.preventDefault();
undo();
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [image, result, ops, redoStack]);

短いですが、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
2
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;

もしフォームのinputやテキストエリアにフォーカスがある状態でCtrl+Zを奪ってしまうと、ユーザーが文字入力を取り消そうとしたのに、アプリ側のUndoが動いてしまうという気持ち悪い挙動になります。
入力欄にいるときはブラウザ標準のテキスト編集に任せたいので、INPUT / TEXTAREA / contentEditableなら早期returnで素通りさせています。
ここは忘れがちなので要注意です(/.;

preventDefaultでブラウザ標準動作を抑える

ショートカットが有効に発動するときだけ、e.preventDefault()を呼んでいます。

editor.jsx

1
2
3
if (ops.length === 0) return;   // 戻せないなら preventDefault しない
e.preventDefault();
undo();

ブラウザによってはCtrl+Zに独自の動作(フォームのリセットなど)が割り当たっていることがあり、放っておくと自分のUndoと二重に効いてしまう恐れがあります。
そこで「このアプリのショートカットを処理する」と決めた瞬間だけpreventDefaultを呼び、ブラウザ標準の動作を止めるようにしています。

逆に、戻せる操作が無い(ops.length === 0)ときはpreventDefaultせずにそのまま抜けています。
何もしないのにブラウザの動作だけ潰す、という余計な副作用を避けるためですね。

クリーンアップと依存配列

最後に、useEffectの後始末です。

editor.jsx

1
2
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);

useEffectが返す関数(クリーンアップ関数)で、登録したリスナーを必ず外しています。
これをやらないと、再レンダリングのたびにリスナーが二重三重に積み重なって、Undoが2回走るなどの不具合につながります。

依存配列に [image, result, ops, redoStack] を指定しているのもポイントです。 onKeyDown の中で参照しているstateが変わるたびにリスナーを貼り直すことで、ハンドラが常に最新の ops / redoStack を見るようにしています。

ボタンからもUndo / Redoを呼べるようにする

キーボードショートカットだけだと、スマホやタブレットのユーザーが使えませんよね。
ここまでに作ったundo関数とredo関数は、そのまま画面上のボタンにも割り当てられます。

editor.jsx

1
2
<button onClick={undo} disabled={ops.length === 0}>元に戻す</button>
<button onClick={redo} disabled={redoStack.length === 0}>やり直す</button>

ロジック(履歴スタックの積み替え)と入力手段(keydown / クリック)を分けて書いておくと、同じundo / redoを両方から呼び回せるのが嬉しいところです。
disabledops.length === 0を渡しておけば、戻せないときはボタンがグレーアウトするので、UI的にも親切になります(^^b

Undo / Redo実装ではまりやすいポイント

最後に、実装中に踏みやすい落とし穴を3つだけ共有しておきます。

    1. 依存配列を空([])にすると state が古いまま固まる
    • リスナーは1度しか貼られないのに、その中の ops は初期値の空配列を見続けるため、何個操作しても「戻せる操作がない」と判定されてUndoが効かなくなります。
    1. e.key の大文字小文字
    • Shiftを押すと e.key'Z'(大文字)になります。toLowerCase() で正規化してから比較しないと、Ctrl+Shift+Zの判定を取りこぼします。
    1. 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でショートカットを拾う
    • windowkeydownを登録し、ctrlKey || metaKeyでMac/Win両対応にする
  • ③ スタックを末尾で積み替える
    • sliceとスプレッドでイミュータブルに移し替えるだけ

画像そのものではなく「操作の履歴」を持つことを意識するだけで、コードも軽さも両立できます。
入力欄での無効化やpreventDefault、依存配列といった細かな配慮を入れておけば、ユーザーにとって「効いて当たり前」な自然なショートカットになります。

自作のWebアプリにCtrl+Zを入れてみたい方は、まずは小さなops配列から試してみるのがお勧めです。

以上となります。
やっぱりショートカット、ショートカットが生産性を支える!
それではお疲れさまでした!