본문 바로가기
React.js

Jotai - 7GUIs 튜토리얼 - 3

by Zih0 2021. 10. 20.

Task 6 : Circle Drawer

정해진 곳을 마우스로 클릭하면 원이 생기고, 원을 다시 누르면 크기를 조절 할 수 있습니다.

Atom 정의

import { atom } from "jotai";
import { nanoid } from "nanoid";

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

// 원 리스트 반환하는 Atom
export const circleListAtom = atom((get) => get(baseCircleListAtom));

// 생성된 원의 순서를 기록하는 Atom 
const historyAtom = atom({
  list: [[] as Circle[]],
  index: 0
});

// 가장 최근에 생성한 원을 제거하는 Atom
export const undoAtom = atom(
    // 원이 존재할 때에만 제거할 수 있도록 boolean 반환
  (get) => {
    const { index } = get(historyAtom);
    const canUndo = index > 0;
    return canUndo;
  },
    // 제거하는 set 작업
  (get, set) => {
    const { list, index } = get(historyAtom);
    if (index > 0) {
      set(baseCircleListAtom, list[index - 1]);
      set(historyAtom, { list, index: index - 1 });
    }
  }
);

// 지운 원을 다시 되돌리는 Atom
export const redoAtom = atom(
    // 가장 최근에 생성한 원이 존재할 때에만 다시 되돌릴 수 있도록 boolean 반환
  (get) => {
    const { list, index } = get(historyAtom);
    const canRedo = index < list.length - 1;
    return canRedo;
  },
    // 되돌리는 set 작업
  (get, set) => {
    const { list, index } = get(historyAtom);
    if (index < list.length - 1) {
      set(baseCircleListAtom, list[index + 1]);
      set(historyAtom, { list, index: index + 1 });
    }
  }
);

// historyAtom에 원 리스트를 넣는 작업 Atom
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
  });
});

// 원을 추가하는 Atom
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);
  }
);

// 원의 크기를 수정하는 Atom
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))
    );
  }
);

// 모달에 사용할 Atom
export const circleForDialogAtom = atom<Circle | null>(null);

Atom 적용

import { useState, useEffect, useRef } from "react";
import { useAtom } from "jotai";
import "./styles.css";
import {
  circleListAtom,
  undoAtom,
  redoAtom,
  saveAtom,
  addCircleAtom,
  changeCircleRadiusAtom,
  circleForDialogAtom
} from "./atoms";

// Undo가 불가능하면 disabled, 가능할 때 버튼 클릭 시, undo set 동작 실행
const Undobutton = () => {
  const [enabled, undo] = useAtom(undoAtom);
  return (
    <button disabled={!enabled} onClick={undo}>
      Undo
    </button>
  );
};

// Redo가 불가능하면 disabled, 가능할 때 버튼 클릭 시, redo set 동작 실행
const Redobutton = () => {
  const [enabled, redo] = useAtom(redoAtom);
  return (
    <button disabled={!enabled} onClick={redo}>
      Redo
    </button>
  );
};

// 원을 그리는 캔버스 컴포넌트
const Canvas = () => {
  const [circleForDialog, setCircleForDialog] = useAtom(circleForDialogAtom);
  const [popupPos, setPopupPos] = useState<[number, number] | null>(null);
  const [selected, setSelected] = useState<string | null>(null);
  const [list] = useAtom(circleListAtom);
  const [, addCircle] = useAtom(addCircleAtom);
  return (
    <svg width="300" height="200">
      <rect
        width="100%"
        height="100%"
        stroke="darkgray"
        strokeWidth="4"
        fill="#eee"
        onClick={(e) => {
          if (circleForDialog) {
            return;
          }
          if (popupPos) {
            setPopupPos(null);
            return;
          }
          const { x, y } = e.currentTarget.getBoundingClientRect();
          addCircle({
            radius: 20,
            cx: e.clientX - x,
            cy: e.clientY - y
          });
        }}
      />
      {list.map((circle) => (
        <circle
          key={String(circle.id)}
          r={circle.radius}
          cx={circle.cx}
          cy={circle.cy}
          onMouseEnter={() => {
            if (circleForDialog || popupPos) {
              return;
            }
            setSelected(circle.id);
          }}
          onMouseLeave={() => {
            if (circleForDialog || popupPos) {
              return;
            }
            setSelected(null);
          }}
          onClick={(e) => {
            if (circleForDialog) {
              return;
            }
            if (popupPos) {
              setPopupPos(null);
              setSelected(circle.id);
              return;
            }
            const { x, y } = (e.currentTarget
              .parentNode as any).getBoundingClientRect();
            setPopupPos([e.clientX - x, e.clientY - y]);
          }}
          stroke="black"
          fill={circle.id === selected ? "gray" : "transparent"}
        />
      ))}
      {popupPos && (
        <>
          <rect
            width="130"
            height="20"
            x={popupPos[0]}
            y={popupPos[1]}
            stroke="black"
            fill="#ddd"
            onClick={() => {
              setPopupPos(null);
              setCircleForDialog(
                list.find((circle) => circle.id === selected) || null
              );
            }}
          />
          <text
            x={popupPos[0] + 3}
            y={popupPos[1] + 15}
            fill="black"
            style={{
              pointerEvents: "none"
            }}
          >
            Adjust diameter...
          </text>
        </>
      )}
    </svg>
  );
};

// 원을 클릭하면 사이즈를 조절하는 모달 컴포넌트
const Dialog = () => {
  const [circleForDialog, setCircleForDialog] = useAtom(circleForDialogAtom);
  const [, change] = useAtom(changeCircleRadiusAtom);
  const [, save] = useAtom(saveAtom);
  const ref = useRef<any>();
  const lastRadius = useRef(0);
  const [radius, setRadius] = useState(0);
  useEffect(() => {
    if (circleForDialog) {
      setRadius(circleForDialog.radius);
      lastRadius.current = circleForDialog.radius;
    }
  }, [circleForDialog]);
  const onChange = (e: any) => {
    const r: number = Number(e.target.value);
    setRadius(r);
    if (circleForDialog) {
      change({ id: circleForDialog.id, radius: r });
      lastRadius.current = r;
    }
  };
  const onClose = () => {
    if (circleForDialog && circleForDialog.radius !== lastRadius.current) {
      save();
    }
    setCircleForDialog(null);
  };
  return (
    <div ref={ref} style={{ position: "absolute", left: "20px", top: "200px" }}>
      {circleForDialog && (
        <div
          style={{
            backgroundColor: "#fff",
            border: "1px solid gray",
            borderRadius: "4px",
            width: "240px"
          }}
        >
          <div
            style={{
              backgroundColor: "#ccc",
              height: "22px",
              marginBottom: "8px",
              borderRadius: "4px 4px 0 0",
              textAlign: "left"
            }}
            onMouseDown={(e) => {
              const startX = e.clientX;
              const startY = e.clientY;
              const posX = Number(ref.current.style.left.slice(0, -2)) || 20;
              const posY = Number(ref.current.style.top.slice(0, -2)) || 200;
              const onMouseMove = (e: any) => {
                ref.current.style.left = `${posX + e.clientX - startX}px`;
                ref.current.style.top = `${posY + e.clientY - startY}px`;
              };
              const onMouseUp = () => {
                document.removeEventListener("mousemove", onMouseMove);
                document.removeEventListener("mouseup", onMouseUp);
              };
              document.addEventListener("mousemove", onMouseMove);
              document.addEventListener("mouseup", onMouseUp);
            }}
          >
            <span
              style={{
                margin: "4px",
                fontSize: "18px",
                cursor: "pointer"
              }}
              onClick={onClose}
            >
              &#10006;
            </span>
          </div>
          <div style={{ fontSize: "small" }}>
            Adjust diameter of circle at ({circleForDialog.cx},{" "}
            {circleForDialog.cy}
            ).
          </div>
          <input
            type="range"
            value={radius}
            min={5}
            max={80}
            onChange={onChange}
          />
        </div>
      )}
    </div>
  );
};

const App = () => (
  <div className="App">
    <Undobutton />
    <Redobutton />
    <Canvas />
    <Dialog />
  </div>
);

export default App;

Task 7: Cells

Atom 정의

이 예제를 진행하기에 앞서 사용하는 atomFamily 에 대한 이해가 필요했습니다.

atomFamily를 사용하면 파라미터를 입력받아 atom을 반환하는 함수를 만들 수 있습니다.

예를 들면, 객체 Atom을 생성해서 key 값을 파라미터로 해당 key의 val을 불러오는 작업이 가능해집니다.

ref:

https://github.com/pmndrs/jotai/issues/23

https://github.com/pmndrs/jotai/pull/45

(라이브러리를 사용하면서 라이브러리의 기능이 어떻게 만들어졌는지 이번에 처음 찾아보았습니다.

개발자들이 어떤 고민을 했는지, 어떻게 구현했는지 알 수 있어서 뜻 깊었습니다.)

import { atom, Getter } from "jotai";
import { atomFamily } from "jotai/utils";

const baseCellFamily = atomFamily(() => atom(""));

const evalCell = (exp: string, getCellVal: (cellId: string) => unknown) => {
  if (!exp.startsWith("=")) {
    return exp;
  }
  try {
    // eslint-disable-next-line no-new-func
    const fn = Function(
      "get",
      `
      return ${exp
        .slice(1)
        .replace(/\b([A-Z]\d{1,2})\b/g, (m) => `get('${m}')`)};
      `
    );
    return fn((cellId: string) => {
      const val = getCellVal(cellId);
      const num = Number(val);
      return Number.isFinite(num) ? num : val;
    });
  } catch (e) {
    return `#ERROR ${e}`;
  }
};

export const cellFamily = atomFamily((cellId: string) =>
  atom(
    (get) => {
      const exp = get(baseCellFamily(cellId));
      const val = evalCell(exp, (cellId) => get(cellFamily(cellId)).val);
      return { exp, val };
    },
    (_get, set, exp: string) => {
      set(baseCellFamily(cellId), exp);
    }
  )
);

Atom 적용

import { useState } from "react";
import { useAtom } from "jotai";
import "./styles.css";
import { cellFamily } from "./atoms";

const Cell = ({ id }: { id: string }) => {
  const [editing, setEditing] = useState(false);
  const [{ exp, val }, setExp] = useAtom(cellFamily(id));
  const onDone = (e: any) => {
    setExp(e.target.value);
    setEditing(false);
  };
  const onKeyPress = (e: any) => {
    if (e.key === "Enter") {
      onDone(e);
    }
  };
  return (
    <td onClick={() => setEditing(true)}>
      {editing ? (
        <input
          defaultValue={exp}
          autoFocus
          onBlur={onDone}
          onKeyPress={onKeyPress}
        />
      ) : (
        val
      )}
    </td>
  );
};

const COLUMNS = Array.from(Array(26).keys()).map((i) =>
  String.fromCharCode("A".charCodeAt(0) + i)
);

const ROWS = Array.from(Array(100).keys()).map((i) => String(i));

const Cells = () => {
  return (
    <table>
      <thead>
        <tr>
          <th></th>
          {COLUMNS.map((c) => (
            <th key={c}>{c}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {ROWS.map((r) => (
          <tr key={r}>
            <th>{r}</th>
            {COLUMNS.map((c) => (
              <Cell key={`${c}${r}`} id={`${c}${r}`} />
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
};

const App = () => (
  <div className="App">
    <Cells />
  </div>
);

export default App;

댓글