🕕

7GUIsで学ぶReact状態管理Jotai | Circle Drawer 編 (6/7)

2022/05/17に公開

はじめに

この記事は、「GUIプログラミングのベンチマークとして提案された7つの課題を題材に、React状態管理ライブラリのJotaiを学んでみよう」というテーマのJotai学習記事の第六回Circle Drawer編です。
完成したコードの解説がメインになります。もしご自身で実装してみたい場合はネタバレになってしまうのでご注意ください。

7GUIsとは

言語やライブラリ系のベンチマークといえば計算速度が評価軸にされることが一般的ですが、この7GUIsはいくつかの指標を軸に7つGUIアプリのお題を用意し、それをベンチマークとして提案しています。
2018年頃に話題となったようで、web系だとReact/MobXやSvelteが実装例として掲載されています。
詳しくはこちら。
https://eugenkiss.github.io/7guis/

7GUIs x Jotai の元ネタ

Jotai作者の@dai_shiさんが過去に取り組んでおり、CodeSandboxで既に実装済みなのでそちらを題材として使わせていただきます。
https://blog.axlight.com/posts/learning-react-state-manager-jotai-with-7guis-tasks/

お題:Circle Drawer

(以下DeepL翻訳)

課題:アンドゥ/リドゥ、カスタム描画、ダイアログコントロール*。
課題は、アンドゥとリドゥのボタンと、その下のキャンバスエリアを含むフレームを作ることです。キャンバス内の何もない領域で左クリックすると、左クリックした点を中心とする一定の直径を持つ、塗りつぶされていない円が作成されます。マウスポインタに最も近く、その中心からポインタまでの距離が半径より小さい円は、存在すれば灰色で塗りつぶされます。C を右クリックすると、ポップアップメニューが表示され、「直径を調整...」という項目があります。この項目をクリックすると、C の直径を調整するスライダーがある別のフレームが開きます。このフレームを閉じると、アンドゥ/リドゥ履歴に最後の直径が重要なものとして記録されます。取り消しをクリックすると、最後の重要な変更 (円の作成や直径の調整など) を取り消すことができます。やり直しをクリックすると、その間にユーザーが新しい変更を加えない限り、最後に取り消された変更が再び適用されます。
Circle Drawerの目標は、特に、GUIアプリケーションのための取り消し/やり直し機能の実装という共通の課題をどの程度解決できるかをテストすることです。理想的なソリューションでは、アンドゥ/リドゥ機能は無償で提供され、言語/ツールキット/パラダイムの自然な帰結として現れます。さらに、Circle Drawerは、ダイアログコントロール*、すなわち、複数の連続したGUIインタラクションステップ間で関連するコンテキストを維持することが、ソースコードでどのように達成されるかをテストします。最後に、カスタム描画の容易さがテストされる。

  • ダイアログコントロールについては、Developing GUI Applicationsという論文で詳しく説明されています。Architectural Patterns Revisited(7ページ)で詳しく説明されています。この用語は、連続したGUI操作の間に文脈を保持するという課題を説明しています。

回答コード

解説

今回はGUI実現のためにコンポーネントのコードが多くなっています。
また、コンポーネント内でのatomの使い方はこれまでの記事と同じパターンでしたので、解説はatoms.tsに絞ります。
Circle(円)の持ち方、undo/redoの実装方法に着目したいと思います。

baseCircleListAtomとhistoryAtom

baseCircleListAtomをbase atomとして読み/書きは、分けられています。Circle typeは半径、座標、idを持たせています。描画する円全てを持たせるので配列です。
undo/redoを実現するために履歴をhistoryAtomへ持たせています。historyAtomの使い方は後ほど見ていきます。(listは多重配列ですね、どう使うんでしょう)

export type Circle = { id: string; radius: number; cx: number; cy: number };
const baseCircleListAtom = atom<Circle[]>([]);

export const circleListAtom = atom((get) => get(baseCircleListAtom));

const historyAtom = atom({
  list: [[] as Circle[]],
  index: 0
});

円の編集(円追加と半径変更)

円の操作は、「円の追加」と「円の半径変更」です。これらは以下のWrite-only atom内でbaseCircleListAtomに対して行われます。

export const addCircleAtom = atom(
  null,
  (_get, set, data: { radius: number; cx: number; cy: number }) => {
    const id = nanoid();
    set(baseCircleListAtom, (prev) => [...prev, { id, ...data }]);
    set(saveAtom);
  }
);

export const changeCircleRadiusAtom = atom(
  null,
  (_get, set, { id, radius }: { id: string; radius: number }) => {
    set(baseCircleListAtom, (prev) =>
      prev.map((circle) => (circle.id === id ? { ...circle, radius } : circle))
    );
  }
);

addCircleAtomのwrite関数でset(saveAtom)されていたのでsaveAtomを見てみます。
新たな円が追加される毎に、listの末尾にget(baseCircleListAtom)(円の"配列すべて")を追加しています。

export const saveAtom = atom(null, (get, set) => {
  const { list, index } = get(historyAtom);
  set(historyAtom, {
    list: [...list.slice(0, index + 1), get(baseCircleListAtom)],
    index: index + 1
  });
});

undoの実装

undoAtomを見てみましょう。
Read関数はindexを元にundoのenabled/disabledを決めています。
Write関数では、履歴のlist[index - 1]をbaseCircleListAtomにsetしています。上記で見たように、listの要素は円の配列なのでこの様にできます。
historyAtomには、listはそのままにindex - 1でsetしています。これでredoしたとき(+1したとき)に最新の変更に変えることができますね。

export const undoAtom = atom(
  (get) => {
    const { index } = get(historyAtom);
    const canUndo = index > 0;
    return canUndo;
  },
  (get, set) => {
    const { list, index } = get(historyAtom);
    if (index > 0) {
      set(baseCircleListAtom, list[index - 1]);
      set(historyAtom, { list, index: index - 1 });
    }
  }
);

コンポーネント側ではこうですね。

const Undobutton = () => {
  const [enabled, undo] = useAtom(undoAtom);
  return (
    <button disabled={!enabled} onClick={undo}>
      Undo
    </button>
  );
};

redoの実装

undoとやっていることは同じですね。 -1ではなく+1です。

export const redoAtom = atom(
  (get) => {
    const { list, index } = get(historyAtom);
    const canRedo = index < list.length - 1;
    return canRedo;
  },
  (get, set) => {
    const { list, index } = get(historyAtom);
    if (index < list.length - 1) {
      set(baseCircleListAtom, list[index + 1]);
      set(historyAtom, { list, index: index + 1 });
    }
  }
);

半径変更時のundo/redo

atomsの定義だけを眺めると、円の追加だけを対象に考えれば問題なさそうでした。では、半径変更時にもundo/redoできるか見てみます。
履歴の保存はsaveAtomが担っているのでApp.tsxで使っている箇所を見てみます。
circleForDialog.radius !== lastRadius.currentの時 = 半径編集が確定したときのみsave()されています。onCloseは半径編集ダイアログを閉じる時に呼ばれるイベントです。
半径が変更されたbaseCircleListAtomが履歴に保存されるということなので、問題なさそうです。

const Dialog = () => {
  ...
  const [, save] = useAtom(saveAtom);
  const onClose = () => {
    if (circleForDialog && circleForDialog.radius !== lastRadius.current) {
      save();
    }
  ...

おわりに

今回はatoms.tsに絞って解説しました。ロジックを全てatomに押し込めてコンポーネントは描画に注力できるので、何をやっているかはatomの定義を見れば済むのが嬉しいですね。
undo/redoの実装方法も、素直な感じで書かれていました。

次回はいよいよ7GUIs最後のお題です。

Jotai Friendsとは

いちJotaiファンとして、エンジニアの皆さんにもっとJotaiを知ってもらって使ってもらいたい、そんな思いから立ち上げたのがJotai Friendsです。

https://jotaifriends.dev/

Jotai Friends

Discussion