From 31d870b978ac078a74da152dc3dd06e1bde63a87 Mon Sep 17 00:00:00 2001 From: Hogyun Jeon Date: Thu, 28 Nov 2024 11:11:18 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20space=EC=97=90=EC=84=9C=20Subspace=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20Subspace=20=EB=85=B8=EB=93=9C?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: subspace node 추가 * feat: context 변화에 따라 재렌더링하도록 개선 * refactor: autofit 로직 별도 훅으로 분리 * chore: 더 이상 사용되지 않는 mock 데이터 삭제 --- packages/frontend/src/components/Node.tsx | 24 +++- packages/frontend/src/components/mock.ts | 12 -- .../src/components/space/SpaceView.tsx | 103 ++++++++++++------ packages/frontend/src/hooks/useAutofit.ts | 39 +++++++ packages/frontend/src/hooks/useYjsSpace.tsx | 48 +++----- packages/frontend/src/hooks/yjs/useY.ts | 4 +- packages/shared/types/index.ts | 1 + 7 files changed, 151 insertions(+), 80 deletions(-) delete mode 100644 packages/frontend/src/components/mock.ts create mode 100644 packages/frontend/src/hooks/useAutofit.ts diff --git a/packages/frontend/src/components/Node.tsx b/packages/frontend/src/components/Node.tsx index d74b97b1..6c93e4ef 100644 --- a/packages/frontend/src/components/Node.tsx +++ b/packages/frontend/src/components/Node.tsx @@ -1,5 +1,6 @@ import { ReactNode, useEffect, useRef, useState } from "react"; -import { Circle, Group, Text } from "react-konva"; +import { Circle, Group, KonvaNodeEvents, Text } from "react-konva"; +import { useNavigate } from "react-router-dom"; import Konva from "konva"; import { KonvaEventObject } from "konva/lib/Node"; @@ -10,7 +11,8 @@ type NodeProps = { y: number; draggable?: boolean; children?: ReactNode; -} & Konva.GroupConfig; +} & Konva.GroupConfig & + KonvaNodeEvents; type NodeHandlers = { onDragStart: () => void; @@ -117,3 +119,21 @@ export function NoteNode({ x, y, name, ...rest }: NoteNodeProps) { ); } + +export type SubspaceNodeProps = { + x: number; + y: number; + name: string; + src: string; +} & NodeHandlers; + +export function SubspaceNode({ x, y, name, src, ...rest }: SubspaceNodeProps) { + const navigate = useNavigate(); + + return ( + navigate(`/space/${src}`)} {...rest}> + + + + ); +} diff --git a/packages/frontend/src/components/mock.ts b/packages/frontend/src/components/mock.ts deleted file mode 100644 index a4cf81ad..00000000 --- a/packages/frontend/src/components/mock.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Edge, Node } from "shared/types"; - -export const nodeSample: Node[] = [ - { id: "0", x: 0, y: 0, type: "head", name: "Hello World" }, - { id: "1", x: 100, y: 100, type: "note", name: "first" }, - { id: "2", x: -100, y: 100, type: "note", name: "second" }, -]; - -export const edgeSample: Edge[] = [ - { from: nodeSample[0], to: nodeSample[1] }, - { from: nodeSample[0], to: nodeSample[2] }, -]; diff --git a/packages/frontend/src/components/space/SpaceView.tsx b/packages/frontend/src/components/space/SpaceView.tsx index 225571cf..afe90c37 100644 --- a/packages/frontend/src/components/space/SpaceView.tsx +++ b/packages/frontend/src/components/space/SpaceView.tsx @@ -1,12 +1,14 @@ -import React, { useEffect, useState } from "react"; +import React from "react"; import { Layer, Stage } from "react-konva"; import { Html } from "react-konva-utils"; import Konva from "konva"; import type { Node } from "shared/types"; +import { createSpace } from "@/api/space"; import Edge from "@/components/Edge"; -import { HeadNode, NoteNode } from "@/components/Node"; +import { HeadNode, NoteNode, SubspaceNode } from "@/components/Node"; +import useAutofit from "@/hooks/useAutofit"; import useDragNode from "@/hooks/useDragNode"; import useYjsSpace from "@/hooks/useYjsSpace"; import { useZoomSpace } from "@/hooks/useZoomSpace.ts"; @@ -17,6 +19,7 @@ import { MemoizedNearIndicator } from "./NearNodeIndicator"; import PaletteMenu from "./PaletteMenu"; interface SpaceViewProps { + spaceId: string; autofitTo?: Element | React.RefObject; } @@ -24,8 +27,7 @@ const dragBoundFunc = function (this: Konva.Node) { return this.absolutePosition(); }; -export default function SpaceView({ autofitTo }: SpaceViewProps) { - const [stageSize, setStageSize] = useState({ width: 0, height: 0 }); +export default function SpaceView({ spaceId, autofitTo }: SpaceViewProps) { const stageRef = React.useRef(null); const { zoomSpace } = useZoomSpace({ stageRef }); @@ -35,42 +37,64 @@ export default function SpaceView({ autofitTo }: SpaceViewProps) { const { drag, dropPosition, handlePaletteSelect } = useDragNode(nodesArray, { createNode: (type, parentNode, position, name = "New Note") => { - defineNode({ type, x: position.x, y: position.y, name }, parentNode.id); - }, - createEdge: (fromNode, toNode) => { - defineEdge(fromNode.id, toNode.id); - }, - }); - const { startNode, handlers } = drag; - - useEffect(() => { - if (!autofitTo) { - return undefined; - } + if (type === "note") { + let src = ""; + // FIXME: note 생성 후 id 입력 + defineNode( + { + type, + x: position.x, + y: position.y, + name, + src, + }, + parentNode.id, + ); - const containerRef = - "current" in autofitTo ? autofitTo : { current: autofitTo }; + return; + } - function resizeStage() { - const container = containerRef.current; + if (type === "subspace") { + createSpace({ + spaceName: name, + userId: "honeyflow", + parentContextNodeId: spaceId, + }).then((res) => { + const [urlPath] = res.urlPath; + defineNode( + { + type, + x: position.x, + y: position.y, + name, + src: urlPath, + }, + parentNode.id, + ); + }); - if (!container) { return; } - const width = container.clientWidth; - const height = container.clientHeight; - - setStageSize({ width, height }); - } + defineNode( + { + type, + x: position.x, + y: position.y, + name, + src: "", + }, + parentNode.id, + ); + }, - resizeStage(); + createEdge: (fromNode, toNode) => { + defineEdge(fromNode.id, toNode.id); + }, + }); + const { startNode, handlers } = drag; - window.addEventListener("resize", resizeStage); - return () => { - window.removeEventListener("resize", resizeStage); - }; - }, [autofitTo]); + const stageSize = useAutofit(autofitTo); const nodeComponents = { head: (node: Node) => ( @@ -96,6 +120,19 @@ export default function SpaceView({ autofitTo }: SpaceViewProps) { dragBoundFunc={dragBoundFunc} /> ), + subspace: (node: Node) => ( + handlers.onDragStart(node)} + onDragMove={handlers.onDragMove} + onDragEnd={handlers.onDragEnd} + dragBoundFunc={dragBoundFunc} + /> + ), }; return ( @@ -145,7 +182,7 @@ export default function SpaceView({ autofitTo }: SpaceViewProps) { }} > diff --git a/packages/frontend/src/hooks/useAutofit.ts b/packages/frontend/src/hooks/useAutofit.ts new file mode 100644 index 00000000..e40e7b8e --- /dev/null +++ b/packages/frontend/src/hooks/useAutofit.ts @@ -0,0 +1,39 @@ +import { RefObject, useEffect, useState } from "react"; + +export default function useAutofit( + container: RefObject | T | undefined, +) { + const [size, setSize] = useState({ width: 0, height: 0 }); + + useEffect(() => { + if (!container) { + return undefined; + } + + const containerRef = + "current" in container ? container : { current: container }; + + function resizeStage() { + const container = containerRef.current; + + if (!container) { + return; + } + + const width = container.clientWidth; + const height = container.clientHeight; + + setSize({ width, height }); + } + + resizeStage(); + + // NOTE: ResizeObserver를 도입할 것을 고려할 것 + window.addEventListener("resize", resizeStage); + return () => { + window.removeEventListener("resize", resizeStage); + }; + }, [container]); + + return size; +} diff --git a/packages/frontend/src/hooks/useYjsSpace.tsx b/packages/frontend/src/hooks/useYjsSpace.tsx index 1519b45a..b7ce489e 100644 --- a/packages/frontend/src/hooks/useYjsSpace.tsx +++ b/packages/frontend/src/hooks/useYjsSpace.tsx @@ -22,28 +22,22 @@ const MOCK_DATA = { }; export default function useYjsSpace() { - const { yDoc, yProvider } = useYjsStore(); - const [yContext, setYContext] = useState>(); + const { yDoc } = useYjsStore(); - useEffect(() => { - if (!yDoc) return; - const context = yDoc.getMap("context"); - setYContext(context); - }, [yDoc]); - - // TODO 코드 개선 - const yNodes = yContext?.get("nodes") as Y.Map | undefined; - const yEdges = yContext?.get("edges") as Y.Map | undefined; - const nodes = useY(yNodes) as SpaceData["nodes"] | undefined; - const edgesRaw = useY(yEdges) as - | Record - | undefined; + const yContext = yDoc?.getMap("context") as Y.Map> | undefined; + + const context = useY(yContext) as SpaceData | undefined; + const nodes = context?.nodes; + const edgesRaw = context?.edges as EdgeWithId[] | undefined; const [edges, setEdges] = useState(); // update functions const defineNode = (node: Omit, parentNodeId?: Node["id"]) => { + const yNodes = yContext?.get("nodes") as Y.Map | undefined; + const yEdges = yContext?.get("edges") as Y.Map | undefined; + if (!yDoc || !yNodes || !yEdges) { return; } @@ -61,6 +55,9 @@ export default function useYjsSpace() { }; const defineEdge = (fromNodeId: string, toNodeId: string) => { + const yNodes = yContext?.get("nodes") as Y.Map | undefined; + const yEdges = yContext?.get("edges") as Y.Map | undefined; + if (!yDoc || !yNodes || !yEdges) { return; } @@ -76,6 +73,9 @@ export default function useYjsSpace() { }; const deleteNode = (nodeId: Node["id"]) => { + const yNodes = yContext?.get("nodes") as Y.Map | undefined; + const yEdges = yContext?.get("edges") as Y.Map | undefined; + if (!yDoc || !yNodes || !yEdges) { return; } @@ -96,6 +96,8 @@ export default function useYjsSpace() { }; const updateNode = (nodeId: Node["id"], patch: Partial>) => { + const yNodes = yContext?.get("nodes") as Y.Map | undefined; + const prev = yNodes?.get(nodeId); if (!yNodes || !prev) { @@ -124,24 +126,8 @@ export default function useYjsSpace() { }, {}); setEdges(edgesData); - - // NOTE nodes는 포함되지 않아도 될 것 같긴 하다 -> 이후 이를 고려해 로직 수정 }, [edgesRaw, nodes]); - useEffect(() => { - if (!yDoc || !yProvider) { - return undefined; - } - - const handleOnSync = (/* isSynced: boolean */) => { - const yContext = yDoc.getMap("context"); - setYContext(yContext); - }; - - yProvider.on("sync", handleOnSync); - return () => yProvider.off("sync", handleOnSync); - }, [yDoc, yProvider]); - /* NOTE - 개발 단계에서 프론트엔드 Space 개발을 위한 Mock 데이터 임의 설정 */ if (import.meta.env.VITE_MOCK) { if (!yDoc || !nodes || Object.keys(nodes || {}).length === 0) { diff --git a/packages/frontend/src/hooks/yjs/useY.ts b/packages/frontend/src/hooks/yjs/useY.ts index 9eb0fed3..91ba8018 100644 --- a/packages/frontend/src/hooks/yjs/useY.ts +++ b/packages/frontend/src/hooks/yjs/useY.ts @@ -41,8 +41,8 @@ export default function useY | undefined>( }; if (yData) { - yData.observe(callback); - return () => yData.unobserve(callback); + yData.observeDeep(callback); + return () => yData.unobserveDeep(callback); } return () => {}; diff --git a/packages/shared/types/index.ts b/packages/shared/types/index.ts index 4a25f853..f81945fa 100644 --- a/packages/shared/types/index.ts +++ b/packages/shared/types/index.ts @@ -4,6 +4,7 @@ export type Node = { x: number; y: number; type: "head" | "note" | "url" | "image" | "subspace"; + src: string; }; export type Edge = {