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}
>
✖
</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;
댓글