Chapter 47

Undo / Redo

miku
miku
2021.11.15に更新

この章では、一度描いたものを取り消したり、リプレイのように再生したりする機能を実装する方法を扱う。

Undo / Redo

操作を記録しておき、直前の操作までの状態に戻すことを Undo と呼ぶ。一般的に、Undoした状態から更にUndoをすることも可能で、最初の状態まで戻すことができる。
逆に、Undoした状態から、先の状態に戻すことを Redo と呼ぶ。

Undo / Redoを実装するために、ペイントソフトであるような、描いたものをUndo / Redoする機能を作成する。

描画をもとに戻したり、戻したものを先にすすめるには、描画の状態を配列に入れて管理する必要がある。では具体的に何を管理する必要があるのかを考える。

レイヤーを管理する

function mouseClicked() {
  const layer = createGraphics(width, height);
  layer.circle(mouseX, mouseY, 100);
  hist.push(layer);
}

描画をするたびに、描画先として新しいレイヤーを作り、そのレイヤーを配列に入れて管理する場合。

単純な方法だが、一操作につき、画面全体のピクセル情報を保持することになるので、かなりのメモリ使用量になることが予想される。

命令を管理する

function mouseClicked() {
  hist.push({ type: "circle", x: mouseX, y: mouseY, d: 100 });
}

circle(mouseX, mouseY, 100) のような命令を分解して、一つ一つをプロパティして追加して管理する場合。

レイヤーを管理する場合と比べてメモリ量はかなり削減されるが、命令によって引数の数などが異なるので、場合分けのコードを書くのが大変になる。

関数を管理する

function mouseClicked() {
  const f = (x, y) => {
    return () => {
      circle(x, y, 100);
    };
  };
  hist.push(f(mouseX, mouseY));
}

JavaScriptでは関数を変数に格納することができるので、circle(mouseX, mouseY, 100) のような命令を関数に包んで、それを配列に入れて管理すればいい。

ただし、書き方に工夫をしないと、うまく管理ができない場合がある。

function f() {
  circle(mouseX, mouseY, 100);
}

たとえば上記のように関数を管理すると、(mouseX, mouseY) は、この関数が実行された時点でのマウス座標を返すという意味になってしまう。

実際はマウスをクリックした時点でのマウス座標を保持しておかなければならないのでクロージャを利用する必要がある。

const f = (x, y) => {
  return () => {
    circle(x, y, 100);
  };
};

hist.push(f(mouseX, mouseY));

クロージャ、つまり、関数が関数を返す作りにする。

外側の関数で位置を受けとって、戻り値として新しい関数を返す。その内側の関数で外側で受け取った位置を参照すると、引数として渡された位置にしかならない。

あとは外側の関数を実行する際に (mouseX, mouseY) を渡して座標をバインドさせて、戻り値である関数を配列で管理すればいい。

関数を管理する作例

let hist;

function setup() {
  createCanvas(windowWidth, windowHeight);

  hist = [];
}

function draw() {
  clear();

  hist.forEach((f) => {
    f();
  });
}

function mouseClicked() {
  const f = (x, y) => {
    return () => {
      circle(x, y, 100);
    };
  };
  hist.push(f(mouseX, mouseY));
}

マウスをクリックするたびに、マウス座標に円を描く関数を配列で保持し、毎フレーム、配列の関数をすべて前から実行して円をすべて描く作例。

これで描画の状態を管理することができたので、このコードをもとにUndo / Redoの実装を行う。

Undo

Undoをすると、画面に描画されているもので、一番直近に描いたものだけを取り除く、というのは原理的に不可能である。なので、一度画面をクリアして、直近の描画までの描画を全て前から順番に描き直すしかない。

配列から直近の状態を取り除いてしまうと、この後に扱うRedoが実装できなくなるので、どこまで描くかという位置情報を新たに追加する。

let histIndex;

function undo() {
  histIndex = max(histIndex - 1, 0);
}

位置情報 histIndex を用意し、undo() が呼ばれるたびに値が1つ減るが、0未満にはならないようにする。

function draw() {
  clear();

  for (let i = 0; i < histIndex; i++) {
    hist[i]();
  }
}

histIndex の数だけ描画をする。

function mouseClicked() {
  // 省略

  hist.length = histIndex;
  hist.push(f(mouseX, mouseY));
  histIndex++;
}

Undoをしている状態で、配列に状態を追加すると、Undoされた状態はすべて削除する必要がある。なので lengthhistIndex を代入して、長さを histIndex に合わせた後に、状態の追加をする。

<body>
  <button class="undo">Undo</button>
</body>
let hist, histIndex;

function setup() {
  createCanvas(windowWidth, windowHeight);

  const undoButton = document.getElementsByClassName("undo")[0];
  undoButton.onclick = undo;

  hist = [];
  histIndex = 0;
}

function draw() {
  clear();

  for (let i = 0; i < histIndex; i++) {
    hist[i]();
  }
}

function mouseClicked() {
  if (mouseX < 0 || mouseX >= width) return;
  if (mouseY < 0 || mouseY >= height) return;

  const f = (x, y) => {
    return () => {
      circle(x, y, 100);
    };
  };
  hist.length = histIndex;
  hist.push(f(mouseX, mouseY));
  histIndex++;
}

function undo() {
  histIndex = max(histIndex - 1, 0);
}

Redo

function redo() {
  histIndex = min(histIndex + 1, hist.length);
}

RedoはUndoで戻した histIndex を再び増やす処理なので、redo() が呼ばれるたびに histIndex を1増やす。ただし、配列の要素数を超えないように min() を取る必要がある。

<body>
  <button class="undo">Undo</button>
  <button class="redo">Redo</button>
</body>
let hist, histIndex;

function setup() {
  createCanvas(windowWidth, windowHeight);

  const undoButton = document.getElementsByClassName("undo")[0];
  undoButton.onclick = undo;

  const redoButton = document.getElementsByClassName("redo")[0];
  redoButton.onclick = redo;

  hist = [];
  histIndex = 0;
}

function draw() {
  clear();

  for (let i = 0; i < histIndex; i++) {
    hist[i]();
  }
}

function mouseClicked() {
  if (mouseX < 0 || mouseX >= width) return;
  if (mouseY < 0 || mouseY >= height) return;

  const f = (x, y) => {
    return () => {
      circle(x, y, 100);
    };
  };
  hist.length = histIndex;
  hist.push(f(mouseX, mouseY));
  histIndex++;
}

function undo() {
  histIndex = max(histIndex - 1, 0);
}

function redo() {
  histIndex = min(histIndex + 1, hist.length);
}