なくてもいいけど、合った方がいいモノ
それが Undo/Redo 機能
この実装は次のように一筋縄でいきません。
- アプリ・システムごとに仕様が違う
- 作り方が意外と複雑で迷う
- 何から実装していいか分からない
僕も Undo/Redo の実装で悩むこと多いです。(個人開発)
でも基本実装さえ分かれば、それほど難しくありません。
そこで記憶の整理のために、
自分なりの JavaScriptでの Undo/Redo 実装手順 をまとめてみます。
このページの目次
手順0.ここで想定する Undo/Redo の実装
ここでは次のようなアプリ(?)を考えます。
- 左側にセレクトボックスがある
- 右にもセレクトボックスがある
- クリックで各アイテムを逆に移動できる
- そのアイテム移動の履歴を記録したい
こういう感じの
そして今回はUIとして、次のようなHTMLを用意しました
▼ アプリのUIとなるHTML例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<select id="leftItems" size="5"> <option value="c">Caupcake</option> <option value="d">Donut</option> <option value="e">Eclair</option> <option value="k">KitKat</option> <option value="o">Oreo</option> </select> <div>⇔</div> <select id="rightItems" size="5"> </select> <button class="redo" onclick="undo();">UNDO</button> <button class="undo" onclick="redo();">REDO</button> |
▼ このアプリの見た目
もし左のセレクトボックスでアイテムがクリックされたら、その逆の右側セレクトボックスにアイテムを移動するようなイメージです。(以下動画参照)
アイテム移動後は移動元からはアイテム消去するというルール
このアプリ(?)で Undo/Redo を実装してみることにします。
手順1.まずUndo/Redo用のスタック(配列)を用意
Undo/Redoを実装するのに大事なのが、、、
履歴記録用のスタックを用意すること
JavaScriptなのでスタックは配列で代用します。
例えば今回の例なら、こういう感じの配列
1 2 3 4 5 |
var undoStack = []; var redoStack = []; var isUndoing = false var isRedoing = false; |
またアンドゥ中かの判定に
isUndoing を、
そしてリドゥ中かの判定に
isRedoing を用意
今回の場合、履歴として次の形のデータを入れていきます。
1 2 3 4 5 6 |
{ /// 移動されたアイテム要素 item: item, /// 移動の方向を表す記号 itemMove: itemMove } |
ここまでで Undo/Redo の準備だけはできました。
次はアプリのメイン機能と履歴記録を実装してきます。
手順2.アプリのメイン機能とUndo/Redo履歴記録の実装
まずアプリのメイン機能から
セレクトボックスでアイテムが押されたら、逆側に移動させます。
▼ そのコードがコチラ
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 28 29 30 31 |
$('#leftItems').on('click', function(){ moveItem($(this).find(':checked'), 'ltor'); }); $('#rightItems').on('click', function(){ moveItem($(this).find(':checked'), 'rtol'); }); function moveItem(item, move){ /// 選択されたoption要素の値 var itemVal = item.val(); /// アイテム移動元のselect要素 var srcSelect = $('#'+(move=='ltor'?'left':'right')+'Items'); /// アイテム移動先のselect要素 var dstSelect = $('#'+(move=='ltor'?'right':'left')+'Items'); /// 移動先に移動元からアイテム移動 dstSelect.append(item.clone(true)); /// UndoまたはRedo用の履歴を記録 if(!isUndoing){ recordUndoAction(item.clone(true), move); }else if(!isRedoing){ recordRedoAction(item.clone(true), move); } /// 履歴操作以外ならRedoスタックを空にする if(!isUndoing && !isRedoing){ redoStack = []; } /// 移動元からアイテム削除 srcSelect.find('[value="'+itemVal+'"]').remove(); } |
全体のコードは最後に載せます。
そしてお次に、 Undo/Redo 履歴を記録する関数を定義
▼ recordUndoAction と recordRedoAction のコード
1 2 3 4 5 6 7 8 9 10 |
function recordUndoAction(item, itemMove){ undoStack.push({ item: item, itemMove: itemMove }); } function recordRedoAction(item, itemMove){ redoStack.push({ item: item, itemMove: itemMove }); } |
先ほど書いたように、履歴データを保存するだけ
この部分は "結構大事" ですね。
- 何がアンドゥ・リドゥに必要か
- どうすれば過去の状態に復元できるか
そういうことを考えて、履歴データを作らないといけないです。
最後に Undo/Redo を行う関数を定義
▼ undo と redo のコード
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 28 29 30 31 32 33 |
function undo(){ if(undoStack.length==0){ return; } /// スタックの上から履歴1つ取り出し var action = undoStack.pop(); var item = action['item']; var itemMove = action['itemMove']; /// パラメーターに応じて操作実行 /// またUndo中フラグを立てておく isUndoing = true; if(itemMove=='ltor') { moveItem(item, 'rtol'); } if(itemMove=='rtol') { moveItem(item, 'ltor'); } isUndoing = false; } function redo(){ if(redoStack.length==0){ return; } /// スタックの上から履歴1つ取り出し var action = redoStack.pop(); var item = action['item']; var itemMove = action['itemMove']; isRedoing = true; if(itemMove=='ltor') { moveItem(item, 'rtol'); } if(itemMove=='rtol') { moveItem(item, 'ltor'); } isRedoing = false; } |
何をしてるかはコメントを参照
これで簡易的だけど Undo/Redo が実装できました。
実装した Undo/Redo を試してみた(動画)
最初に書いたように、今回作ったのはこういうアプリ
- 左右にセレクトボックスがある
- アイテム押下時に逆側に移す
- 移動元からはアイテムを消去
この条件で Undo/Redo を実装したって話でした。
実際に動作させたときの様子がコチラ!
何回か試してみましたが、期待通りに動きました。
まあ簡易的なので穴(バグ)もあるかもですが・・・
ここまでの全コードまとめ
最後にここまでの完全コードを載せときます。
▼ コチラが全てのコード
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 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
var undoStack = []; var redoStack = []; var isUndoing = false var isRedoing = false; $('#leftItems').on('click', function(){ moveItem($(this).find(':checked'), 'ltor'); }); $('#rightItems').on('click', function(){ moveItem($(this).find(':checked'), 'rtol'); }); function moveItem(item, move){ var itemVal = item.val(); var srcSelect = $('#'+(move=='ltor'?'left':'right')+'Items'); var dstSelect = $('#'+(move=='ltor'?'right':'left')+'Items'); dstSelect.append(item.clone(true)); if(!isUndoing){ recordUndoAction(item.clone(true), move); }else if(!isRedoing){ recordRedoAction(item.clone(true), move); } if(!isUndoing && !isRedoing){ redoStack = []; } srcSelect.find('[value="'+itemVal+'"]').remove(); } function recordUndoAction(item, itemMove){ undoStack.push({ item: item, itemMove: itemMove }); } function recordRedoAction(item, itemMove){ redoStack.push({ item: item, itemMove: itemMove }); } function undo(){ if(undoStack.length==0){ return; } var action = undoStack.pop(); var item = action['item']; var itemMove = action['itemMove']; isUndoing = true; if(itemMove=='ltor') { moveItem(item, 'rtol'); } if(itemMove=='rtol') { moveItem(item, 'ltor'); } isUndoing = false; } function redo(){ if(redoStack.length==0){ return; } var action = redoStack.pop(); var item = action['item']; var itemMove = action['itemMove']; isRedoing = true; if(itemMove=='ltor') { moveItem(item, 'rtol'); } if(itemMove=='rtol') { moveItem(item, 'ltor'); } isRedoing = false; } |
もしこのコードを見て、
- このコード間違ってない?
- なんかココおかしい
という部分を見つけた場合は、コメント欄でご指摘ください。ではまた