🤖

VueUseのuseManualRefHistory composableでリアクティブ変数のundo redoを実現する

2023/03/29に公開

概要

useManualRefHistory | VueUseを使って、リアクティブ変数のundoとredoを実現します

また、おまけでundo redoのUIをボタンとショートカット(コントロール+Z、シフト+コントロール+Z)で操作できるようにしました。

useManualRefHistory

今回メインで使うライブラリは、useManualRefHistoryです。

これは、VueUse | VueUseに含まれるコンポーザブルの一つです。(コンポーザブルについてはVue公式のコンポーザブル | Vue.jsを参照ください)

公式によるとuseManualRefHistoryは、以下の通りです。

Manually track the change history of a ref when the using calls commit(), also provides undo and redo functionality

つまり、リアクティブな変数に対して、commitを実行して履歴を保存し、履歴を対象にundoとredoをする事ができるとのことです。

まさしく今回の目的にぴったり。

デモ動画

どんなことができるかを示すために、デモコードを書きました。
デモコードの動きは以下の通りです。

増加を押すと数値が増加します。保存を押すとその時の数値を履歴として保存します。

戻るを押すと履歴を一つ前に戻ります。この操作は、ショートカットcontrol + zでも実行できます。

進むを押すと戻った履歴を一つ先に進めます。この操作は、ショートカット shift + control + zでも実行できます。

useManualRefHistoryコードリーディング

commitを実行した時に何が起きるかuseManualRefHistoryのコードを読みます。

commit

commitは、リアクティブ変数を配列に保存する関数です。

リアクティブ変数は、useManualRefHistoryを初期するときにわたします。

const count = ref(0)
const { comit } = useManualRefHistory(count)

一つ前のリアクティブ変数の状態は last.valueです。

一つ前の状態を履歴用の配列に保存し、現在の状態をlast.valueに保存するというのが、主なcommitの処理です。

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L149-L157

配列は、undoStackとredoStackです。undo用とredo用それぞれ履歴を保存する配列です。

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L141-L142

履歴の保存処理です。

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L151

last.valueに保存する、現在の状態を用意します。

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L132-L137

source.valueは、現在のリアクティブな変数です。markRawを通すことで、immutableなオブジェクトとして保管する事ができます。

もうちょっと見ると、markRawはVueの関数です。

markRaw()
プロキシに変換されないようにオブジェクトをマークします。オブジェクト自体を返します。

プロキシに変換されないことの価値については調べていません。文脈から見ると、commit保存時点でのスナップショットをimmutableな値として扱いたいとおもいますので、オブジェクトがリアクティブな変数として扱われないように加工して返しているのだろうと解釈しました。

リアクティブな変数は、以下で変換処理をして保存します。

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L134-L135

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L127

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L106-L108

sourceには、リアクティブな変数が入っています。
cloneに関数を渡していればその関数でリアクティブな変数を変換します。cloneがtrueなら、cloneFnJSONでJSONに変換します。そうじゃなければ、何もしない・・・ようです。

ニーズはわかりませんが、履歴保存時に履歴に手を加えたい場合はこのcloneに関数をわたしてやれば良いと思います。

last.valueには、現在のリアクティブな変数の状態が入っています。

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L139

commit時は、現在のリアクティブな状態を履歴に保存します。

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L150

その後、現在のリアクティブな状態を更新します。

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L151

options.capscityは、履歴の最大保存回数が入っています。よって、下記で最大保存回数を超える履歴を削除しています。

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L153-L156

上記により、履歴の保存と、現在の状態の保存をすることができます。

undo

undo時の処理です。こちらはとっても素直です。
undo履歴から最後に保存した値を取り出し、再度進む用に一つ前の状態をredo履歴に保存しています。

履歴を取り出すのはいかです。履歴が入ったundoStack配列から、shiftで取り出しています。

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L165

再度進む(redo)ができるように、redoStackに値を格納するのは以下です。

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L168

取り出した履歴で現在のリアクティブな変数を更新します。

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L169

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L144-L147

ここで、出てくるsetSourceについてです。基本ただ、リアクティブな変数を指定の履歴に置換するだけです。

特に指定しなければ、第1引数に第2引数を代入することで実現します。

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L102

きっと、cloneの時と同様に履歴を取り出すときに何らかの変換をしたときは、このsetSourceに処理を差し込むのかなと思います。(使いどころはわからない)

redo

redoは、undoと考え方は全く同じです。前述のundoの説明をundoとredoを入れ替えたものです。

https://github.com/vueuse/vueuse/blob/e8dfb1f35e3559fd8774fdcd3e6865ae53cf6dcd/packages/core/useManualRefHistory/index.ts#L173-L180

コード

先に示したでも動画のコードです。

<template>
  <div>
    <button @click="onIncrement">増加</button>
    <div>{{ count }}</div>
    <button @click="onCommit">保存</button>
    <div v-for="item in history" :key="item.timestamp">
      <div>{{ item }}</div>
    </div>
    <button @click="onUndo">戻る(control + z)</button>
    <button @click="onRedo">進む(shift + control + z)</button>
  </div>
</template>
<script setup>
import { useManualRefHistory, useKeyModifier, onKeyStroke } from "@vueuse/core";
import { ref } from "vue";
const count = ref(0);
const { commit, undo, redo, history } = useManualRefHistory(count);

const control = useKeyModifier("Control");
const shift = useKeyModifier("Shift");
onKeyStroke(["z", "Z"], (e) => {
  if (!shift.value && control.value) {
    undo();
    e.preventDefault();
  } else if (shift.value && control.value) {
    redo();
    e.preventDefault();
  }
});
const onIncrement = () => {
  count.value++;
};

const onCommit = () => {
  commit();
};

const onUndo = () => {
  undo();
};

const onRedo = () => {
  redo();
};
</script>

下記で履歴を管理したいリアクティブな変数を用意します。

const count = ref(0);

リアクティブな変数を履歴管理できようにします。と、いっても関数を取得してそれぞれ実行するだけです。

const { commit, undo, redo, history } = useManualRefHistory(count);

今回は、buttonタグに応じて、履歴保管、undo, redo時にそれぞれcommit,undo,redoを呼び出すようにしました。

const onCommit = () => {
  commit();
};

const onUndo = () => {
  undo();
};

const onRedo = () => {
  redo();
};

ショートカットは、こちらもVueUseのモジュール onKeyStroke | VueUseをつかいました。shift キーと コントロールキーは、useKeyModifier | VueUseで押されているかどうかを取得しています。
zキーが押されたかどうかもあわせて、いかのコードでショートカットを実現しています。

const control = useKeyModifier("Control");
const shift = useKeyModifier("Shift");
onKeyStroke(["z", "Z"], (e) => {
  if (!shift.value && control.value) {
    undo();
    e.preventDefault();
  } else if (shift.value && control.value) {
    redo();
    e.preventDefault();
  }
});

以上です。

Discussion