JavaScriptでUndoRedoするためのなにか

方法論?

ふとJavaScriptでUndoRedo機構がエレガントにつくれないかと思ってなんかごさごさやっていたんですがそれっぽいものができたのでアプローチをポスト。UndoRedo機構つくる為の方法論としていくつか考えてみました。

  1. 前やったことの逆算。次やることの計算(右に3px動かす。左に3px動かす。)
  2. DOMのプロパティtop:50pxとかleft:50pxとか保存して頑張る。(prop1 = { top: 50, left :50px}, prop2 = {top: 25, left : 25})
  3. 現在の状態のDOMを保存(キャッシュ)しておき状態を復元する。

とまぁ。すぐ思い浮かぶのは1,2くらい(1と2のパターンはやれなくもないけど凄いめんどう。)今回は3のアプローチ。

element.cloneNode(flag)

JavaScriptでアニメーションするにしろ新しくHTMLに文字列追加するにしろ全てDOM処理。なので現在のDOMの状態と次のDOMの状態。次の次のDOMの状態を保存していけば前の処理に戻せる。

ただ単純に以下のようにやっても保存できない…というよりも値が変わることにはJavaScriptな人には説明する必要はないけど。barはfooの参照なのでプロパティが変われば一緒に変わるということ。

var foo = document.getElementById('foo');
foo.style.backgroundColor = 'red';
var bar = foo;
alert(bar.style.backgroundColor); // red
foo.style.backgroundColor = 'navy';
alert(bar.style.backgroundColor); // navy

参照ではなく値がbarに保存されれば。fooが変化してもbarは変わらない。参照でなくする方法はDOMではこう。element.cloneNode()を使うとparentNodeはnullになるので注意。

// true → 子ノード含む
// false → 子ノード含まず
var bar = foo.cloneNode(true);
foo.style.backgroundColor = 'navy';
alert(bar.style.backgroundColor); // red
alert(foo.style.backgroundColor); // navy
alert(bar.parentNode); // null

というわけで

element.cloneNode(flag)を使えばなんとかなりそう。ということで各種のDOMの状態をcloneNodeで保存してみる。

var cache  = [];
var signal = document.getElementById('signal');
//↓親ノードを利用できるようにしておくと便利。というか必須。
var parent = signal.parentNode;
var id = signal.getAttribute('id');

// 信号機は青だよ。
signal.innerHTML = 'blue';
//もしID属性がない場合は設定しておくと便利。
//signal.setAttribute('id', 'signal');
cache.push(signal.cloneNode(true));

// 信号機は黄色になります。
signal.innerHTML = 'yellow';
cache.push(signal.cloneNode(true));

// 信号機は赤になりました。
signal.innerHTML = 'red';
cache.push(signal.cloneNode(true));

// 信号機が故障した。
signal.innerHTML = 'orz...';
chace.push(signal.cloneNode(true));

上記工程で信号機が青→黄→赤→故障という状況で今にも交通事故が起きそうな予感。というわけで青の状態に戻す。

// cache[0]は青色のときの信号機
parent.replaceChild(cache[0], document.getElementById('signal'));
// cache[2]は赤色のときの信号機
parent.replaceChild(cache[2], document.getElementById('signal'));
// 黄色にしてみます。
parent.replaceChild(cache[1], document.getElementById('signal'));
// なおったみたいだから青に
parent.replaceChild(cache[0], document.getElementById('signal'));

RedoUndo機能付のdndできるdivをつくる。

dndの実装についてはここでやるのもあれなのでid:clonedさん作のDraggable.jsを活用させていただきました。多謝。以下はサンプルコードなのでメモリリーク対策などは考慮せず。あとのこと気にしない。

Array.prototype

簡単の為にArray.prototypeにsetメソッドと双方向iteratorメソッドを加えておく。配列の追加はsetメソッドを使う。双方向iteratorがUndoRedoをする為に使われる。

Array.prototype.set = function(v)
{
    this.push(v);
    this.index = this.length - 1;
    return this;
};
Array.prototype.iterator = function()
{
    var self = this;
    var length = this.length;

    return new function(){
        this.next = function()
        {
            var i = self.index++;
            return self[i + 1];
        };
        this.prev = function()
        {
            var i = self.index--;
            return self[i - 1];
        };
        this.hasNext = function(){
            return (-1 < self.index && self.index < length - 1);
        };
        this.hasPrev = function(){
            return (0 < self.index && self.index < length);
        };
    };
};
Array.prototype.index = -1;
Recordable
/**
 * @param {Element} d Draggableされたエレメント
 * @param {Element} u Undoする為のボタン
 * @param {Element} r Redoする為のボタン
 */
function Recordable(d, u, r)
{
    var record = [];
    var id = d.element.id;
    var it = record.iterator();
    var parent = d.element.parentNode;

    // 初期状態をキャッシュしておく。
    record.set(d.element.cloneNode(true));

    // -------------------------------------------------
    // cloneNodeされたノードはイベントは引き継がれないの
    // でdndできるようにしておく【重要】
    // ------------------------------------------------
    new Draggable(record[record.length - 1]);
  //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    // 保存のタイミングはマウスが離れた状態
    d.observe(d.element, 'mouseup', function(e)
    {
        record.set(d.element.cloneNode(true));
        new Draggable(record[record.length - 1]);
        //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ イベントの再割当
        it = record.iterator();
    });

    u.onclick = function()
    {
        if(it.hasPrev()){
            parent.replaceChild(it.prev(), document.getElementById(id));
        };
    };

    r.onclick = function()
    {
        if(it.hasNext()){
            parent.replaceChild(it.next(), document.getElementById(id));
        };
    };
};
htmlはこんな具合
<body>
   <div id="dnd" style="position:absolute;background:navy;">dnd</div>
   <input type="button" id="undo">
   <input type="button" id="redo">
   <script type="text/javascript">
   var undo = document.getElementById('undo');
   var redo = document.getElementById('redo');
   // 上の実装では new はいらないね....。
   new Recordable(new Draggable("dnd"), undo, redo);
   </script>
</body>

注意点

  • cloneNode()したものはオリジナルのイベントは引き継いでくれない。
  • cloneNode()したノードは parentNode が null