Katashin .info

操作データから逆操作を生成しUndo(元に戻す)機能を実装するパターン

リッチなアプリを開発していると、Undo(元に戻す) 機能を自分で実装する必要が出てきます。canvas を使った図形の描画などはブラウザデフォルトの Undo 機能が使えず、自分で実装しなければならない代表例です。Undo の実装にはパターンがあり、それを理解することで様々なアプリへの Undo の実装がしやすくなります。

この記事では、JavaScript による簡単なデザインツールのデモを通して、Undo の実装パターンと、具体的な実装例を解説します。

Undo の実装パターン #

Undo の頻出実装パターンは、操作をデータで表現することです。ユーザーの各操作をデータで表し、それを打ち消す操作(逆操作)を保存します。Undo を行うときは逆操作を取り出し、それを実行します。

ユーザーの行った操作に対する逆操作を Undo の処理として使うことを表した図

デザインツールに Undo を実装 #

以下の単純なデザインツールに Undo を実装します。簡単のためにこのデザインツールで追加できる図形は四角のみとします。

ドラッグで図形を追加でき、図形をクリックすると選択状態になります。選択状態の図形はドラッグで移動でき、図形の右上のボタンをクリックすると削除できます。

実装には Vue.js を使用していますが、以降の Undo 実装の考え方は Vue.js 以外の UI ライブラリを使用していても活用できます。Undo 実装前の全体のコードは以下から確認できます。長いのと、Undo の実装はこれを見なくても理解できるので、読み飛ばしてもらっても構いません。

Undo 実装前のデザインツールのコード
<script setup>
import { ref, computed } from 'vue'

// 追加する図形の色がこの中からランダムに選択される
const colors = ['#f93529', '#536eff', '#09eb10']

// 図形データの配列
const shapes = ref([
  {
    id: 1,
    top: 50,
    left: 50,
    width: 100,
    height: 100,
    color: colors[0],
  },
  {
    id: 2,
    top: 100,
    left: 75,
    width: 100,
    height: 100,
    color: colors[1],
  },
  {
    id: 3,
    top: 25,
    left: 125,
    width: 100,
    height: 100,
    color: colors[2],
  },
])

// ドラッグで追加中の図形データ
const shapeAdding = ref()

// 選択中の図形 ID
const selectedShapeId = ref()

// ドラッグで図形を移動しているときの座標データ
const movingContext = ref()

// ドラッグで移動している図形の移動後のデータ
const shapeMoving = computed(() => {
  if (!movingContext.value) {
    return
  }

  const shape = shapes.value.find((shape) => shape.id === movingContext.value.id)

  // movingContext の座標データを元に移動後の座標にした図形データを返す
  return {
    ...shape,
    left: shape.left + movingContext.value.deltaLeft,
    top: shape.top + movingContext.value.deltaTop,
  }
})

// 追加中の図形と移動中の図形を反映させた図形データの配列
const renderedShapes = computed(() => {
  return shapes.value
    .map((shape) => {
      // 移動中の図形があるときは移動後の座標にする
      if (shape.id === shapeMoving.value?.id) {
        return shapeMoving.value
      }
      return shape
    })
    // 追加中の図形があるときは配列に入れる
    .concat(shapeAdding.value ? [shapeAdding.value] : [])
})

let maxId = 3

// なにもないところでドラッグを開始した時
function onPointerDown(event) {
  event.currentTarget.setPointerCapture(event.pointerId)
  selectedShapeId.value = undefined

  const shape = {
    id: ++maxId,
    top: event.clientY,
    left: event.clientX,
    width: 0,
    height: 0,
    color: colors[Math.floor(Math.random() * colors.length)],
  }

  // 図形の追加を始める
  shapeAdding.value = shape
}

// なにもないところでドラッグ中
function onPointerMove(event) {
  if (!shapeAdding.value) {
    return
  }

  // ドラッグに応じて追加中の図形の大きさを変える
  const { left, top } = shapeAdding.value
  shapeAdding.value.width = Math.max(0, event.clientX - left)
  shapeAdding.value.height = Math.max(0, event.clientY - top)
}

// なにもないところでドラッグを終了した時
function onPointerUp() {
  if (!shapeAdding.value) {
    return
  }

  const delta = Math.sqrt(
    shapeAdding.value.width ** 2 + shapeAdding.value.height ** 2,
  )

  // 10px以上の距離ドラッグしたら、図形の追加を行う
  if (delta >= 10) {
    addShape(shapeAdding.value)
  }

  shapeAdding.value = undefined
}

// 図形上でクリック、ドラッグの開始をした時
function onSelectShape(event, shape) {
  event.currentTarget.setPointerCapture(event.pointerId)

  // 図形を選択する
  selectedShapeId.value = shape.id

  // 図形のドラッグのための座標データを準備する
  movingContext.value = {
    id: shape.id,
    startX: event.clientX,
    startY: event.clientY,
    deltaTop: 0,
    deltaLeft: 0,
  }
}

// 図形をドラッグ中
function onMoveShape(event, shape) {
  if (movingContext.value?.id !== shape.id) {
    return
  }

  // ドラッグに応じて座標データを更新する
  movingContext.value.deltaLeft = event.clientX - movingContext.value.startX
  movingContext.value.deltaTop = event.clientY - movingContext.value.startY
}

// 図形のドラッグを終了した時
function onMoveEndShape(shape) {
  if (movingContext.value?.id !== shape.id) {
    return
  }

  const delta = Math.sqrt(
    movingContext.value.deltaLeft ** 2 + movingContext.value.deltaTop ** 2,
  )

  // 10px以上の距離ドラッグしたら、図形の移動を行う
  if (delta > 10) {
    updateShape(shapeMoving.value)
  }

  movingContext.value = undefined
}

// 図形の削除ボタンをクリックした時
function onClickRemove(shape) {
  removeShape(shape)
  selectedShapeId.value = undefined
}

// 図形の位置とサイズ、色を View に反映
function shapeStyle(shape) {
  return {
    top: shape.top + 'px',
    left: shape.left + 'px',
    width: shape.width + 'px',
    height: shape.height + 'px',
    backgroundColor: shape.color,
  }
}

function addShape(shape) {
  // 図形を末尾に追加
  shapes.value = shapes.value.concat(shape)
}

function updateShape(updatedShape) {
  // 渡された図形を図形の配列に反映
  shapes.value = shapes.value.map((shape) => {
    if (shape.id === updatedShape.id) {
      return updatedShape
    }
    return shape
  })
}

function removeShape(shape) {
  // 渡された図形を図形の配列から削除
  shapes.value = shapes.value.filter((s) => s.id !== shape.id)
}

</script>

<template>
  <div
    class="canvas"
    @touchstart.prevent
    @pointerdown="onPointerDown"
    @pointermove="onPointerMove"
    @pointerup="onPointerUp"
  >
    <div
      v-for="shape of renderedShapes"
      :key="shape.id"
      class="shape"
      :class="{ selected: selectedShapeId === shape.id }"
      :style="shapeStyle(shape)"
      @pointerdown.stop="onSelectShape($event, shape)"
      @pointermove.stop="onMoveShape($event, shape)"
      @pointerup.stop="onMoveEndShape(shape)"
    >
      <button
        v-if="selectedShapeId === shape.id"
        type="button"
        class="remove"
        @touchstart.stop
        @pointerdown.stop
        @click="onClickRemove(shape)"
      ></button>
    </div>
  </div>
</template>

<style scoped>
.canvas {
  position: absolute;
  inset: 0;
}

.shape {
  position: absolute;
  box-shadow: 0 0 10px 5px rgba(0, 0, 0, 0.05);
}

.shape.selected {
  outline: 2px dashed #333;
}

.remove {
  position: absolute;
  top: -10px;
  right: -10px;
  width: 20px;
  height: 20px;
  background-color: #fff;
  border: none;
  border-radius: 50%;
  cursor: pointer;
  box-shadow: 0 0 10px 5px rgba(0, 0, 0, 0.1);
}

.remove::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 20%;
  margin-top: -1px;
  height: 2px;
  width: 60%;
  background-color: #666;
}

.buttons {
  display: flex;
  position: absolute;
  left: 0;
  top: 0;
}

.undo-redo {
  display: flex;
  padding: 0;
  background: none;
  border: none;
  cursor: pointer;
}

.undo-redo:disabled {
  opacity: 0.3;
  cursor: not-allowed;
}
</style>

操作をデータにし、Undo 用に保存 #

Undo 実装前のコードでは、ユーザーの追加、更新、削除操作に対する関数内では以下のように、図形データの配列 shapes を直接更新しています。

function addShape(shape) {
  // 図形を末尾に追加
  shapes.value = shapes.value.concat(shape)
}

function updateShape(updatedShape) {
  // 渡された図形を図形の配列に反映
  shapes.value = shapes.value.map((shape) => {
    if (shape.id === updatedShape.id) {
      return updatedShape
    }
    return shape
  })
}

function removeShape(shape) {
  // 渡された図形を図形の配列から削除
  shapes.value = shapes.value.filter((s) => s.id !== shape.id)
}

これを以下のように書きかえます。それぞれの操作に対応する操作データを作成し、操作データを実行する関数 doOperation を呼び出します。

function addShape(shape) {
  // index 番目に新しい図形 shape を追加する操作データを作成
  const operation = {
    type: 'add',
    shape,
    index: shapes.value.length,
  }

  doOperation(operation)
}

function updateShape(updatedShape) {
  // update を元に図形を更新する操作データを作成
  const operation = {
    type: 'update',
    update: updatedShape,
  }

  doOperation(operation)
}

function removeShape(shape) {
  // 図形 shape を削除する操作データを作成
  const operation = {
    type: 'remove',
    shape,
  }

  doOperation(operation)
}

doOperation の中では以下の2つの処理を行っています:

undoStack、redoStack に追加される操作が実際の Undo、Redo 処理に対応しています。

// undo する操作データのスタック
const undoStack = ref([])

// redo する操作データのスタック
const redoStack = ref([])

function doOperation(operation) {
  // Undo 用に渡された操作の逆操作を生成
  const reversedOperation = reverseOperation(operation, shapes.value)

  // 操作を適用して、shapes データを更新
  shapes.value = applyOperation(shapes.value, operation)

  // 逆操作を undo スタックに追加
  undoStack.value.push(reversedOperation)

  // ユーザーによる操作が行われたので redo スタックをクリア
  redoStack.value = []
}

操作の適用と逆変換 #

applyOperationreverseOperation は Undo 実装の要となる関数です。applyOperation は図形データの配列 shapes と操作データ operation を受け取り、操作を実行した結果を返します。例えば、type: 'add' を持つ operation が与えられた時、shapes に図形を追加した結果が返ります。

変更前のコードで addShape、updateShape、removeShape に書かれていたロジックがこの関数に移動すると考えれば理解しやすいかもしれません。

// shapes に operation 操作を適用した結果を返す
function applyOperation(shapes, operation) {
  switch (operation.type) {
    // 追加操作
    case 'add': {
      // index 番目に新しい図形 shape を追加
      return [
        ...shapes.slice(0, operation.index),
        operation.shape,
        ...shapes.slice(operation.index),
      ]
    }

    // 削除操作
    case 'remove':
      // shape.id と一致する図形を削除
      return shapes.filter((s) => s.id !== operation.shape.id)

    // 更新操作
    case 'update':
      // update で指定された id を持つ図形を更新
      return shapes.map((s) => {
        if (s.id === operation.update.id) {
          return {
            ...s,
            ...operation.update,
          }
        }
        return s
      })
  }
}

reverseOperation は与えられた操作データ operation の逆操作を返す関数です。追加操作であれば追加された図形を削除する操作、削除操作であれば削除された図形を同じ場所に追加する操作、更新操作であれば更新される前の状態に戻す操作を返します。

// operation の逆操作を返す
function reverseOperation(operation, shapes) {
  switch (operation.type) {
    // 追加操作の逆操作は削除操作
    case 'add':
      return {
        type: 'remove',
        shape: operation.shape,
      }

    // 削除操作の逆操作は追加操作
    case 'remove': {
      // 削除操作によって図形が削除される場所を確認
      const index = shapes.findIndex((s) => s.id === operation.shape.id)
      return {
        type: 'add',
        shape: shapes[index],
        index,
      }
    }

    // 更新操作の逆操作は更新前の状態に戻す操作
    case 'update': {
      // 更新操作によって更新される前の図形を取得
      const prevShape = shapes.find((s) => s.id === operation.update.id)
      return {
        type: 'update',
        update: prevShape,
      }
    }
  }
}

保存した操作を取り出し Undo #

最後に Undo、および Redo 処理を実装します。ボタンがクリックされたら以下の undo, redo 関数が実行されるようにします。どちらの関数も処理の流れは同じで、対応する stack から操作を取り出し、それを実行することで Undo(Redo)を行います。Undo を行った後はその逆操作を redoStack に、Redo を行った後はその逆操作を undoStack に追加します。

function undo() {
  // undo スタックから操作データを取り出す
  const undoOperation = undoStack.value.pop()
  if (undoOperation) {
    // redo 用に逆操作を生成し、スタックに追加
    const redoOperation = reverseOperation(undoOperation, shapes.value)
    redoStack.value.push(redoOperation)

    // undo 操作を適用して、shapes データを更新
    shapes.value = applyOperation(shapes.value, undoOperation)
  }
}

function redo() {
  // redo スタックから操作データを取り出す
  const redoOperation = redoStack.value.pop()
  if (redoOperation) {
    // undo 用に逆操作を生成し、スタックに追加
    const undoOperation = reverseOperation(redoOperation, shapes.value)
    undoStack.value.push(undoOperation)

    // redo 操作を適用して、shapes データを更新
    shapes.value = applyOperation(shapes.value, redoOperation)
  }
}

これまでの実装をすべて反映したデモは以下のとおりです。左上に Undo および Redo ボタンが追加され、クリックすると Undo(Redo)処理を実行します。

Undo の応用 #

この記事で実装した Undo は最も単純なケースを想定しており、実際のアプリ開発ではより応用的な実装が必要になる場合があります。よくあるケースが、このデザインツールを複数人で利用する場合です。

複数人が同時に利用するツールで Undo を行う場合、Undo 操作を実行する前に他のユーザーの操作が行われた時、Undo の結果が意図しないものとなることがあります。例えば、Undo が1番目の図形を削除する時を考えます。

Undo 操作が1番目の図形を削除するケースを図で表したもの

この時、他のユーザーが1番目に図形を追加していた場合、Undo によってその図形を削除してしまいます(幸い本記事の削除処理では id を使っているためこれは発生しませんが、Undo が追加操作のときは似たような問題が起きます)。

他のユーザーの操作が Undo 操作と競合し、意図しない挙動を引き起こすケースを図で表したもの

このような問題を解決するために、Operational Transformation (OT) という手法が用いられます。OT では他のユーザーが行った操作を元に自分が実行しようとする操作を変換していき、上記の問題が起きないようにします。また、似たような手法として Selective Undo というものもあり、こちらは以前に記事を書いているので、興味のある方はご覧ください。

テキスト編集における Selective Undo を実装した

おわりに #

本記事では JavaScript による簡単なデザインツールのデモを通して、Undo の実装パターンと、具体的な実装例を解説しました。Undo を実装するために使った、操作をデータとして表現するパターンは、Undo 以外にも様々な実装に応用できます。興味がある方は色々と試してみてください。

参考文献 #

Google Wave Operational Transformation
https://svn.apache.org/repos/asf/incubator/wave/whitepapers/operational-transform/operational-transform.html