S.F. Page

Programming,Music,etc...

ポケミク・シーケンサーを作る(7) undo/redoの実装

はじめに

縦ピアノロールエディタという奇怪なUIを実装し始めたがまだまだ道のりは長い。

実装方法をある程度理解できていたはずのundo/redo機能にてこずっていた。ようやくできたけれども忘れたとき用にまとめておくことにする。

Undo/Redo機能の仕様など

まずは仕様についてまとめておく。私が実装したい機能はメモリの制限が許す限りの無制限にundo/redoができる機能である。ちなみにundo/redoの定義だが

  • undoとは直前の実行済み操作を取り消す操作のことである。
  • redoとは直前のundoを取り消す操作のことである。実施済みコマンドを繰り返し実行ではない。

である。

続いて機能仕様を列挙する。

  1. ファイルを開く、保存、新規作成の操作をUndo/Redoの起点となる。ファイルを開く、保存、新規作成自体はundoできない。
  2. 実行した操作を記憶する。
  3. undoボタンを押すと一つ前の操作を取り消す。
  4. undoは起点にまで遡って逐次実行することができる。
  5. 1回以上undoを行うとredo可能となる。
  6. redoを行うと直前に行ったundo操作を取り消す。
  7. redoはundoが最初に行われた時点にまで遡って逐次実行することができる。
  8. undo実施後にundo/redo以外の操作を行うと、undo以降に行った操作は無効となりredo出来ない。

こんなところだろうか。

実装について

機能仕様に基づき、どのように実装するかを考えた結果を書いておく。

実装仕様はViViエディタの「Doc3 undo/redo」を参考にしている。

大まかには、アプリ上における操作をコマンドオブジェクトとして定義する。そのコマンドオブジェクトをUndoManagerで管理する。アプリの操作はすべてUndoManagerを経由して実行される。

  • アプリの操作をコマンドオブジェクトとして実装する。コマンドオブジェクトは以下のメソッドを持つ。
    exec()
    1. 操作を実行する。undo()やredo()に必要な情報も保存する。
    undo()
    execで実行した機能を取り消し、必要であればredoに必要な情報を保存する。
    redo()
    undoで取り消した処理を再実行する。
  • コマンドオブジェクトを管理するためにUndoManagerというものを作る。UndoManagerは以下のプロパティとメソッドを持つ
    buffer
    コマンドオブジェクトを記憶するためのArrayオブジェクトである。
    index
    bufferの位置を保存する。
    clear()
    bufferをクリアし、indexを-1にする。
    exec(command)
    1. indexがさすコマンド以降の配列データを削除する。
    2. 引数commandのexec()を呼び出し、bufferにcommandをpush()する。
    3. indexを+1する。
    4. executedイベントをtriggerする。
    undo()
    bufferに一つ以上のコマンドオブジェクトが格納されている場合以下を実行する。
    1. bufferのindexの位置にあるコマンドオブジェクトのundo()を実行する。
    2. undidイベントをトリガーする。
    3. indexを-1する。
    4. indexが-1になったらundoEmptyイベントをトリガーする。
    redo()
    indexが-1以上(配列の長さ-2)以下のとき、以下を実行する。
    1. indexを+1する。
    2. bufferのindexの位置にあるコマンドオブジェクトのredo()を実行する。
    3. redidイベントをトリガーする。
    4. (index+1)の値が配列の長さと等しくなった場合redoEmptyイベントをトリガーする。

実装結果

実装結果は以下の通り。

function UndoManager()
{
  this.buffer = [];
  this.index = -1;
}

UndoManager.prototype =
{
  clear: function ()
  {
    this.buffer.length = 0;
    this.index = -1;
    $(this).trigger('cleared');
  },
  exec: function (command)
  {
    command.exec();
    if ((this.index + 1) < this.buffer.length)
    {
      this.buffer.length = this.index + 1;
    }
    this.buffer.push(command);
    ++this.index;
    $(this).trigger('executed');
  },
  redo: function ()
  {
    if ((this.index + 1) < (this.buffer.length))
    {
      ++this.index;
      var command = this.buffer[this.index];
      command.redo();
      $(this).trigger('redid');
      if ((this.index  + 1) == this.buffer.length)
      {
        $(this).trigger('redoEmpty');
      }
    }
  },
  undo: function ()
  {
    if (this.buffer.length > 0 && this.index >= 0)
    {
      var command = this.buffer[this.index];
      command.undo();
      --this.index;
      $(this).trigger('undid');
      if (this.index < 0)
      {
        this.index = -1;
        $(this).trigger('undoEmpty');
      }
    }
  }
}

var undoManager = new UndoManager();