From b4ebf12c42f0a39422509b07cd1ffede4eddba89 Mon Sep 17 00:00:00 2001 From: pakxe Date: Wed, 11 Feb 2026 18:37:54 +0900 Subject: [PATCH 01/36] =?UTF-8?q?feat:=20ws=EB=A1=9C=20ydoc=EC=97=90=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EB=90=9C=20=EB=A7=88=EC=9D=B8=EB=93=9C?= =?UTF-8?q?=EB=A7=B5=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EA=B3=A0=20=EA=B0=B1=EC=8B=A0=ED=95=98?= =?UTF-8?q?=EB=8A=94=20useSharedMindmap=20=ED=9B=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mindmap/hooks/useSharedMindmap.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 frontend/src/features/mindmap/hooks/useSharedMindmap.ts diff --git a/frontend/src/features/mindmap/hooks/useSharedMindmap.ts b/frontend/src/features/mindmap/hooks/useSharedMindmap.ts new file mode 100644 index 000000000..f8557a51d --- /dev/null +++ b/frontend/src/features/mindmap/hooks/useSharedMindmap.ts @@ -0,0 +1,88 @@ +import { useEffect, useMemo, useState } from "react"; +// import { IndexeddbPersistence } from "y-indexeddb"; +import { WebsocketProvider } from "y-websocket"; +import * as Y from "yjs"; + +import { ENV } from "@/constants/env"; +import { NodeElement, NodeId } from "@/features/mindmap/types/mindmapType"; +import SharedMindmapLayoutManager from "@/features/mindmap/utils/SharedMindmapLayoutManager"; +import SharedTreeContainer, { ROOT_NODE_ID } from "@/features/mindmap/utils/SharedTreeContainer"; +import { EventBroker } from "@/utils/EventBroker"; + +type CONNECTION_STATUS = "disconnected" | "connected" | "connecting"; + +type Props = { + roomId: string; +}; + +const useSharedMindmap = ({ roomId }: Props) => { + const [connectionStatus, setConnectedStatus] = useState("disconnected"); + + const { provider, doc, container, layoutManager, ...rest } = useMemo(() => { + const doc = new Y.Doc(); + + const provider = new WebsocketProvider(ENV.WS_BASE_URL, roomId, doc); + // const localProvider = new IndexeddbPersistence(roomId, doc); + + // localProvider.on("synced", () => { + // console.log("기존에 저장된 데이터를 로컬 DB에서 모두 불러왔습니다!"); + // }); + + const broker = new EventBroker(); + const container = new SharedTreeContainer({ + doc, + broker, + quadTreeManager: undefined, + name: "Test Mindmap", + }); + const layoutManager = new SharedMindmapLayoutManager({ + treeContainer: container, + config: { xGap: 140, yGap: 60 }, + }); + return { doc, provider, container, layoutManager, broker }; + }, []); + + useEffect(() => { + if (!provider.shouldConnect) { + provider.connect(); + } + + provider.on("status", (e) => { + setConnectedStatus(e.status); + }); + + return () => { + provider.disconnect(); + }; + }, [provider]); + + useEffect(() => { + const observer = (e: Y.YMapEvent) => { + e.keysChanged.forEach((key) => layoutManager.invalidate(key)); + + // 자신이 일으킨 변화가 아닌 경우 + if (!e.transaction.local) { + return; + } + + layoutManager.updateLayout({ + rootId: ROOT_NODE_ID, + rootCenterX: 0, + rootCenterY: 0, + }); + }; + + container.yNodes.observe(observer); + }, [doc, provider, container, layoutManager]); + + return { + connectionStatus, + provider, + doc, + container, + layoutManager, + ...rest, + }; +}; + +export default useSharedMindmap; From cf0015faa9220a0a588393825801bc3d250f0575 Mon Sep 17 00:00:00 2001 From: pakxe Date: Wed, 11 Feb 2026 18:38:14 +0900 Subject: [PATCH 02/36] =?UTF-8?q?chore:=20=EB=8F=99=EC=8B=9C=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EC=9D=84=20=EC=9C=9F=ED=95=98=EC=97=AC=20y=20js?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 12 +++++--- frontend/pnpm-lock.yaml | 65 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index a152a59aa..a6fcbda7e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,8 +7,9 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "preview": "vite preview --config vite.production.ts", - "dev": "vite --config vite.development.ts", - "build": "tsc && vite build --config vite.production.ts" + "dev": "vite --config vite.development.ts --host", + "build": "tsc && vite build --config vite.production.ts", + "yjs:server": "HOST=0.0.0.0 PORT=1234 npx y-websocket" }, "dependencies": { "@tailwindcss/vite": "^4.1.18", @@ -16,16 +17,19 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", + "nanoid": "^5.1.6", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", - "nanoid": "^5.1.6", "react": "^19.2.0", "react-dom": "^19.2.0", "react-error-boundary": "^6.1.0", "react-router": "^7.13.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.1.18" + "tailwindcss": "^4.1.18", + "y-indexeddb": "^9.0.12", + "y-websocket": "^3.0.0", + "yjs": "^13.6.29" }, "devDependencies": { "@eslint/js": "^9.39.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index ddb508bf7..a09357e28 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -53,6 +53,15 @@ importers: tailwindcss: specifier: ^4.1.18 version: 4.1.18 + y-indexeddb: + specifier: ^9.0.12 + version: 9.0.12(yjs@13.6.29) + y-websocket: + specifier: ^3.0.0 + version: 3.0.0(yjs@13.6.29) + yjs: + specifier: ^13.6.29 + version: 13.6.29 devDependencies: '@eslint/js': specifier: ^9.39.2 @@ -2103,6 +2112,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -2151,6 +2163,11 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -2764,9 +2781,31 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + y-indexeddb@9.0.12: + resolution: {integrity: sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + + y-protocols@1.0.7: + resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + + y-websocket@3.0.0: + resolution: {integrity: sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.5.6 + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yjs@13.6.29: + resolution: {integrity: sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -4917,6 +4956,8 @@ snapshots: isexe@2.0.0: {} + isomorphic.js@0.2.5: {} + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -4962,6 +5003,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + lightningcss-android-arm64@1.30.2: optional: true @@ -5676,8 +5721,28 @@ snapshots: word-wrap@1.2.5: {} + y-indexeddb@9.0.12(yjs@13.6.29): + dependencies: + lib0: 0.2.117 + yjs: 13.6.29 + + y-protocols@1.0.7(yjs@13.6.29): + dependencies: + lib0: 0.2.117 + yjs: 13.6.29 + + y-websocket@3.0.0(yjs@13.6.29): + dependencies: + lib0: 0.2.117 + y-protocols: 1.0.7(yjs@13.6.29) + yjs: 13.6.29 + yallist@3.1.1: {} + yjs@13.6.29: + dependencies: + lib0: 0.2.117 + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@4.3.5): From 6ce3024768d6b7c90b9c2c31f4ab833c7774ba54 Mon Sep 17 00:00:00 2001 From: pakxe Date: Wed, 11 Feb 2026 18:38:28 +0900 Subject: [PATCH 03/36] =?UTF-8?q?feat:=20env=20=ED=97=AC=ED=8D=BC=20?= =?UTF-8?q?=EC=83=81=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/constants/env.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 frontend/src/constants/env.ts diff --git a/frontend/src/constants/env.ts b/frontend/src/constants/env.ts new file mode 100644 index 000000000..490333893 --- /dev/null +++ b/frontend/src/constants/env.ts @@ -0,0 +1,11 @@ +interface AppEnv { + VITE_API_BASE_URL: string; + VITE_WS_BASE_URL: string; +} + +const env = import.meta.env as unknown as AppEnv; + +export const ENV = { + API_BASE_URL: env.VITE_API_BASE_URL || `invalid`, // 에러나게 아무 뻥값 넣었습니다. + WS_BASE_URL: env.VITE_WS_BASE_URL || `invalid`, +} as const; From 09e43f2ffc9f6e39aafa89032d1b8d940ce37de4 Mon Sep 17 00:00:00 2001 From: pakxe Date: Wed, 11 Feb 2026 18:45:48 +0900 Subject: [PATCH 04/36] =?UTF-8?q?design:=20=EB=8B=A4=EB=A5=B8=20type?= =?UTF-8?q?=EC=9D=98=20toast=EB=8A=94=20=EB=8B=A4=EB=A5=B8=20=EC=83=89?= =?UTF-8?q?=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/shared/components/ui/sonner.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/shared/components/ui/sonner.tsx b/frontend/src/shared/components/ui/sonner.tsx index b0d040726..7e922eacb 100644 --- a/frontend/src/shared/components/ui/sonner.tsx +++ b/frontend/src/shared/components/ui/sonner.tsx @@ -12,14 +12,16 @@ const Toaster = ({ ...props }: ToasterProps) => { icons={{ success: , info: , - warning: , + warning: , error: , loading: , }} style={ { - "--normal-bg": "var(--popover)", - "--normal-text": "var(--popover-foreground)", + "--normal-bg": "var(--popover-foreground)", + "--normal-text": "var(--color-text-main2)", + "--alert-bg": "var(--popover)", + "--alert-text": "var(--popover-foreground)", "--normal-border": "var(--border)", "--border-radius": "var(--radius)", } as React.CSSProperties From 4f09a4f49c05898ffbb09833e15ae79782afe9f5 Mon Sep 17 00:00:00 2001 From: pakxe Date: Wed, 11 Feb 2026 18:52:16 +0900 Subject: [PATCH 05/36] =?UTF-8?q?chore:=20import=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/mindmap/providers/MindmapProvider.tsx | 2 +- frontend/src/features/mindmap/types/mindmap_interaction_type.ts | 2 +- .../features/mindmap/types/{mindmapType.ts => mindmap_type.ts} | 0 .../src/features/mindmap/utils/MindmapInteractionManager.ts | 2 +- frontend/src/features/mindmap/utils/MindmapLayoutManager.ts | 2 +- frontend/src/features/mindmap/utils/Renderer.ts | 2 +- frontend/src/features/mindmap/utils/TreeContainer.ts | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename frontend/src/features/mindmap/types/{mindmapType.ts => mindmap_type.ts} (100%) diff --git a/frontend/src/features/mindmap/providers/MindmapProvider.tsx b/frontend/src/features/mindmap/providers/MindmapProvider.tsx index 3f8f74b04..0bcc10542 100644 --- a/frontend/src/features/mindmap/providers/MindmapProvider.tsx +++ b/frontend/src/features/mindmap/providers/MindmapProvider.tsx @@ -5,7 +5,7 @@ */ import { createContext, useCallback, useContext, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { NodeId } from "@/features/mindmap/types/mindmapType"; +import { NodeId } from "@/features/mindmap/types/mindmap_type"; import MindmapLayoutManager from "@/features/mindmap/utils/MindmapLayoutManager"; import TreeContainer from "@/features/mindmap/utils/TreeContainer"; import { EventBroker } from "@/utils/EventBroker"; diff --git a/frontend/src/features/mindmap/types/mindmap_interaction_type.ts b/frontend/src/features/mindmap/types/mindmap_interaction_type.ts index ac23394e7..54a3dd977 100644 --- a/frontend/src/features/mindmap/types/mindmap_interaction_type.ts +++ b/frontend/src/features/mindmap/types/mindmap_interaction_type.ts @@ -1,4 +1,4 @@ -import { NodeId } from "@/features/mindmap/types/mindmapType"; +import { NodeId } from "@/features/mindmap/types/mindmap_type"; export type InteractionMode = "idle" | "potential_drag" | "dragging" | "panning"; diff --git a/frontend/src/features/mindmap/types/mindmapType.ts b/frontend/src/features/mindmap/types/mindmap_type.ts similarity index 100% rename from frontend/src/features/mindmap/types/mindmapType.ts rename to frontend/src/features/mindmap/types/mindmap_type.ts diff --git a/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts b/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts index 7cba9c18a..9f7d6a19a 100644 --- a/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts +++ b/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts @@ -1,7 +1,7 @@ import { MOUSE_DOWN } from "@/constants/mouse"; import { ATTRIBUTE_NAME_OF_NODE_ID } from "@/features/mindmap/constants/node"; import { BaseNodeInfo, InteractionMode, ViewportTransform } from "@/features/mindmap/types/mindmap_interaction_type"; -import { NodeId } from "@/features/mindmap/types/mindmapType"; +import { NodeId } from "@/features/mindmap/types/mindmap_type"; import TreeContainer from "@/features/mindmap/utils/TreeContainer"; import { calcDistance } from "@/utils/calc_distance"; diff --git a/frontend/src/features/mindmap/utils/MindmapLayoutManager.ts b/frontend/src/features/mindmap/utils/MindmapLayoutManager.ts index 152a49367..98cb7f1ec 100644 --- a/frontend/src/features/mindmap/utils/MindmapLayoutManager.ts +++ b/frontend/src/features/mindmap/utils/MindmapLayoutManager.ts @@ -1,4 +1,4 @@ -import { NodeElement, NodeId } from "@/features/mindmap/types/mindmapType"; +import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap_type"; import TreeContainer from "@/features/mindmap/utils/TreeContainer"; import { CacheMap } from "@/utils/CacheMap"; import { calcPartitionIndex } from "@/utils/calc_partition"; diff --git a/frontend/src/features/mindmap/utils/Renderer.ts b/frontend/src/features/mindmap/utils/Renderer.ts index 8fd2a7a42..dc53a2ced 100644 --- a/frontend/src/features/mindmap/utils/Renderer.ts +++ b/frontend/src/features/mindmap/utils/Renderer.ts @@ -1,4 +1,4 @@ -import { NodeElement } from "@/features/mindmap/types/mindmapType"; +import { NodeElement } from "@/features/mindmap/types/mindmap_type"; import { Rect } from "@/features/quad_tree/types/rect"; import QuadTree from "@/features/quad_tree/utils/QuadTree"; diff --git a/frontend/src/features/mindmap/utils/TreeContainer.ts b/frontend/src/features/mindmap/utils/TreeContainer.ts index 457b0eaec..dc3e6ae63 100644 --- a/frontend/src/features/mindmap/utils/TreeContainer.ts +++ b/frontend/src/features/mindmap/utils/TreeContainer.ts @@ -1,4 +1,4 @@ -import { NodeData, NodeElement, NodeId, NodeType } from "@/features/mindmap/types/mindmapType"; +import { NodeData, NodeElement, NodeId, NodeType } from "@/features/mindmap/types/mindmap_type"; import { EventBroker } from "@/utils/EventBroker"; import { exhaustiveCheck } from "@/utils/exhaustive_check"; import generateId from "@/utils/generate_id"; From 710f1048fd631f561d1b1d33cfde8ad5381cc742 Mon Sep 17 00:00:00 2001 From: pakxe Date: Wed, 11 Feb 2026 18:54:50 +0900 Subject: [PATCH 06/36] =?UTF-8?q?feat:=20=EC=98=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=EC=9D=BC=20=EB=95=8C=EC=97=90=EB=8F=84=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B8=EB=93=9C=EB=A7=B5=20=EB=B3=80=EA=B2=BD=20=EC=82=AC?= =?UTF-8?q?=ED=95=AD=EC=9D=84=20=EB=B3=B4=EC=A1=B4=ED=95=A0=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EB=8F=84=EB=A1=9D=20y-indexeddb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/mindmap/hooks/useSharedMindmap.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/src/features/mindmap/hooks/useSharedMindmap.ts b/frontend/src/features/mindmap/hooks/useSharedMindmap.ts index f8557a51d..390a46d96 100644 --- a/frontend/src/features/mindmap/hooks/useSharedMindmap.ts +++ b/frontend/src/features/mindmap/hooks/useSharedMindmap.ts @@ -1,10 +1,11 @@ import { useEffect, useMemo, useState } from "react"; -// import { IndexeddbPersistence } from "y-indexeddb"; +import { toast } from "sonner"; +import { IndexeddbPersistence } from "y-indexeddb"; import { WebsocketProvider } from "y-websocket"; import * as Y from "yjs"; import { ENV } from "@/constants/env"; -import { NodeElement, NodeId } from "@/features/mindmap/types/mindmapType"; +import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap_type"; import SharedMindmapLayoutManager from "@/features/mindmap/utils/SharedMindmapLayoutManager"; import SharedTreeContainer, { ROOT_NODE_ID } from "@/features/mindmap/utils/SharedTreeContainer"; import { EventBroker } from "@/utils/EventBroker"; @@ -22,11 +23,11 @@ const useSharedMindmap = ({ roomId }: Props) => { const doc = new Y.Doc(); const provider = new WebsocketProvider(ENV.WS_BASE_URL, roomId, doc); - // const localProvider = new IndexeddbPersistence(roomId, doc); + const localProvider = new IndexeddbPersistence(roomId, doc); - // localProvider.on("synced", () => { - // console.log("기존에 저장된 데이터를 로컬 DB에서 모두 불러왔습니다!"); - // }); + localProvider.on("synced", () => { + toast.success("오프라인 상태에서 수정한 작업 내역을 반영하였습니다."); + }); const broker = new EventBroker(); const container = new SharedTreeContainer({ From 37cd3ab09725b0a9d5113c18e13ee464823f4d67 Mon Sep 17 00:00:00 2001 From: pakxe Date: Thu, 12 Feb 2026 15:41:10 +0900 Subject: [PATCH 07/36] =?UTF-8?q?chore:=20import=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/mindmap/providers/MindmapProvider.tsx | 2 +- .../src/features/mindmap/utils/MindmapInteractionManager.ts | 4 ++-- frontend/src/features/mindmap/utils/MindmapLayoutManager.ts | 3 ++- frontend/src/features/mindmap/utils/Renderer.ts | 2 +- frontend/src/features/mindmap/utils/TreeContainer.ts | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/src/features/mindmap/providers/MindmapProvider.tsx b/frontend/src/features/mindmap/providers/MindmapProvider.tsx index 0bcc10542..b54fc5f3f 100644 --- a/frontend/src/features/mindmap/providers/MindmapProvider.tsx +++ b/frontend/src/features/mindmap/providers/MindmapProvider.tsx @@ -5,7 +5,7 @@ */ import { createContext, useCallback, useContext, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { NodeId } from "@/features/mindmap/types/mindmap_type"; +import { NodeId } from "@/features/mindmap/types/mindmap"; import MindmapLayoutManager from "@/features/mindmap/utils/MindmapLayoutManager"; import TreeContainer from "@/features/mindmap/utils/TreeContainer"; import { EventBroker } from "@/utils/EventBroker"; diff --git a/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts b/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts index 9f7d6a19a..50ebf6597 100644 --- a/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts +++ b/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts @@ -1,7 +1,7 @@ import { MOUSE_DOWN } from "@/constants/mouse"; import { ATTRIBUTE_NAME_OF_NODE_ID } from "@/features/mindmap/constants/node"; -import { BaseNodeInfo, InteractionMode, ViewportTransform } from "@/features/mindmap/types/mindmap_interaction_type"; -import { NodeId } from "@/features/mindmap/types/mindmap_type"; +import { NodeId } from "@/features/mindmap/types/mindmap"; +import { BaseNodeInfo, InteractionMode, ViewportTransform } from "@/features/mindmap/types/mindmap_interaction"; import TreeContainer from "@/features/mindmap/utils/TreeContainer"; import { calcDistance } from "@/utils/calc_distance"; diff --git a/frontend/src/features/mindmap/utils/MindmapLayoutManager.ts b/frontend/src/features/mindmap/utils/MindmapLayoutManager.ts index 98cb7f1ec..0ccbc9073 100644 --- a/frontend/src/features/mindmap/utils/MindmapLayoutManager.ts +++ b/frontend/src/features/mindmap/utils/MindmapLayoutManager.ts @@ -1,4 +1,4 @@ -import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap_type"; +import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap"; import TreeContainer from "@/features/mindmap/utils/TreeContainer"; import { CacheMap } from "@/utils/CacheMap"; import { calcPartitionIndex } from "@/utils/calc_partition"; @@ -146,6 +146,7 @@ export default class MindmapLayoutManager { ? parentRealX + parentNode.width + this.config.xGap : parentRealX - childNode.width - this.config.xGap; + console.log(realX, childNode); this.layoutSubtree({ curNode: childNode, x: realX, startY: currentY, direction }); currentY += this.getSubTreeHeight(childNode) + this.config.yGap; diff --git a/frontend/src/features/mindmap/utils/Renderer.ts b/frontend/src/features/mindmap/utils/Renderer.ts index dc53a2ced..60f6f44be 100644 --- a/frontend/src/features/mindmap/utils/Renderer.ts +++ b/frontend/src/features/mindmap/utils/Renderer.ts @@ -1,4 +1,4 @@ -import { NodeElement } from "@/features/mindmap/types/mindmap_type"; +import { NodeElement } from "@/features/mindmap/types/mindmap"; import { Rect } from "@/features/quad_tree/types/rect"; import QuadTree from "@/features/quad_tree/utils/QuadTree"; diff --git a/frontend/src/features/mindmap/utils/TreeContainer.ts b/frontend/src/features/mindmap/utils/TreeContainer.ts index dc3e6ae63..c66a7d45f 100644 --- a/frontend/src/features/mindmap/utils/TreeContainer.ts +++ b/frontend/src/features/mindmap/utils/TreeContainer.ts @@ -1,4 +1,4 @@ -import { NodeData, NodeElement, NodeId, NodeType } from "@/features/mindmap/types/mindmap_type"; +import { NodeData, NodeElement, NodeId, NodeType } from "@/features/mindmap/types/mindmap"; import { EventBroker } from "@/utils/EventBroker"; import { exhaustiveCheck } from "@/utils/exhaustive_check"; import generateId from "@/utils/generate_id"; From 659429f1eb8ec4823f40704fba8a546d70843af1 Mon Sep 17 00:00:00 2001 From: pakxe Date: Thu, 12 Feb 2026 15:41:24 +0900 Subject: [PATCH 08/36] =?UTF-8?q?feat:=20useNode=20=ED=9B=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=98=EC=97=AC=20nodeId=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=8B=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20rerendering?= =?UTF-8?q?=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mindmap/shared_mindmap/hooks/useNode.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 frontend/src/features/mindmap/shared_mindmap/hooks/useNode.ts diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useNode.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useNode.ts new file mode 100644 index 000000000..50ae1e443 --- /dev/null +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useNode.ts @@ -0,0 +1,22 @@ +import { useSyncExternalStore } from "react"; + +import SharedMindMapController from "@/features/mindmap/shared_mindmap/utils/SharedMindmapController"; +import { NodeId } from "@/features/mindmap/types/mindmap"; + +/** + * Node UI 컴포넌트 안에서 사용하는 훅입니다. + * nodeId에 해당하는 데이터가 변경되면 해당 컴포넌트만 rerendering을 트리거 해줍니다. + */ +export function useNode(nodeId: NodeId, controller: SharedMindMapController) { + const { container } = controller; + + const subscribe = (callback: () => void) => { + return container.broker.subscribe({ key: nodeId, callback }); + }; + + const getSnapshot = () => { + return container.safeGetNode(nodeId); + }; + + return useSyncExternalStore(subscribe, getSnapshot); +} From aa2e9c7a7276667fbcd0351bf18bf9531b40054f Mon Sep 17 00:00:00 2001 From: pakxe Date: Thu, 12 Feb 2026 15:41:42 +0900 Subject: [PATCH 09/36] =?UTF-8?q?feat:=20useNodeResizeObserver=20=ED=9B=85?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EB=85=B8=EB=93=9C=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EC=BD=9C?= =?UTF-8?q?=EB=B0=B1=20=EC=8B=A4=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/useNodeResizeObserver.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts new file mode 100644 index 000000000..bd2452a66 --- /dev/null +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts @@ -0,0 +1,34 @@ +import { useLayoutEffect, useRef } from "react"; + +import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap"; +import { isSame } from "@/utils/is_same"; + +type Props = { + nodeId: NodeId; + node?: NodeElement; + onResize: ({ width, height }: { width: number; height: number }) => void; +}; +/** + * 타겟 노드의 크기가 변경되면 콜백을 실행합니다. + */ +export function useNodeResizeObserver({ nodeId, node, onResize }: Props) { + const nodeRef = useRef(null); + + useLayoutEffect(() => { + if (!nodeRef.current || !node) { + return; + } + + const rect = nodeRef.current.getBoundingClientRect(); + + // 안잘리게 ceil + const newWidth = Math.ceil(rect.width); + const newHeight = Math.ceil(rect.height); + + if (!isSame(node.width, newWidth) || !isSame(node.height, newHeight)) { + onResize({ height: newHeight, width: newWidth }); + } + }, [nodeId]); + + return nodeRef; +} From a24c8e7cdc44b4aefe759b6e7fbfa0e8c76c1a87 Mon Sep 17 00:00:00 2001 From: pakxe Date: Thu, 12 Feb 2026 15:41:48 +0900 Subject: [PATCH 10/36] =?UTF-8?q?feat:=20useSharedMindmap=20=ED=9B=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=9B=B9?= =?UTF-8?q?=EC=86=8C=EC=BC=93=20=EC=97=B0=EA=B2=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared_mindmap/hooks/useSharedMindmap.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts new file mode 100644 index 000000000..f5c657bcd --- /dev/null +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts @@ -0,0 +1,58 @@ +import { useEffect, useMemo, useState } from "react"; +import { WebsocketProvider } from "y-websocket"; +import * as Y from "yjs"; + +import { ENV } from "@/constants/env"; +import SharedMindMapController from "@/features/mindmap/shared_mindmap/utils/SharedMindmapController"; +import { NodeId } from "@/features/mindmap/types/mindmap"; +import { MindmapRoomId } from "@/features/mindmap/types/mindmap_room"; +import { EventBroker } from "@/utils/EventBroker"; + +type ConnectionStatus = "disconnected" | "connecting" | "connected"; + +type UseSharedMindmapProps = { + roomId: MindmapRoomId; +}; + +/** + * 동시편집을 위해 웹소켓을 연결합니다. + */ +export const useSharedMindmap = ({ roomId }: UseSharedMindmapProps) => { + const [connectionStatus, setConnectionStatus] = useState("disconnected"); + + const { controller, provider } = useMemo(() => { + const doc = new Y.Doc(); + + const provider = new WebsocketProvider(ENV.WS_BASE_URL, roomId, doc); + + const broker = new EventBroker(); + const controller = new SharedMindMapController(doc, broker); + + return { controller, provider }; + }, [roomId]); + + useEffect(() => { + if (!provider.shouldConnect) { + provider.connect(); + } + + const handleStatus = (event: { status: ConnectionStatus }) => { + setConnectionStatus(event.status); + }; + + provider.on("status", handleStatus); + + return () => { + provider.off("status", handleStatus); + provider.disconnect(); + }; + }, [provider]); + + return { + controller, + + container: controller.container, + + connectionStatus, + }; +}; From 11e255aabc51e73e6d5155eea06c84c90d5d2262 Mon Sep 17 00:00:00 2001 From: pakxe Date: Thu, 12 Feb 2026 15:42:00 +0900 Subject: [PATCH 11/36] =?UTF-8?q?feat:=20useOfflineMindmap=20=ED=9B=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EC=98=A4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=83=81=ED=83=9C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EB=82=B4=EC=9A=A9=EC=9D=84=20indexeddb?= =?UTF-8?q?=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared_mindmap/hooks/useOfflineMindmap.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 frontend/src/features/mindmap/shared_mindmap/hooks/useOfflineMindmap.ts diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useOfflineMindmap.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useOfflineMindmap.ts new file mode 100644 index 000000000..5feb6c861 --- /dev/null +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useOfflineMindmap.ts @@ -0,0 +1,31 @@ +import { useEffect } from "react"; +import { toast } from "sonner"; +import { IndexeddbPersistence } from "y-indexeddb"; +import * as Y from "yjs"; + +import { MindmapRoomId } from "@/features/mindmap/types/mindmap_room"; + +type Props = { + roomId: MindmapRoomId; + doc: Y.Doc; +}; + +/** + * 오프라인, 온라인 상황에서 indexeddb에 저장된 작업 내용을 불러와 doc에 반영합니다. + */ +export const useOfflineMindmap = ({ roomId, doc }: Props) => { + useEffect(() => { + const persistence = new IndexeddbPersistence(roomId, doc); + + const handleSynced = () => { + toast.success("오프라인 상태에서 작업한 내용이 성공적으로 반영되었습니다."); + }; + + persistence.on("synced", handleSynced); + + return () => { + persistence.off("synced", handleSynced); + persistence.destroy(); + }; + }, [roomId, doc]); +}; From c5dec3334c112af227c087e88c5550437d0ed15b Mon Sep 17 00:00:00 2001 From: pakxe Date: Thu, 12 Feb 2026 15:42:19 +0900 Subject: [PATCH 12/36] =?UTF-8?q?feat:=20SharedMindMapController=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/SharedMindmapController.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts new file mode 100644 index 000000000..af5589855 --- /dev/null +++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts @@ -0,0 +1,88 @@ +import * as Y from "yjs"; + +import SharedMindmapLayoutManager from "@/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager"; +import SharedTreeContainer, { TransactionOrigin } from "@/features/mindmap/shared_mindmap/utils/SharedTreeContainer"; +import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap"; +import { EventBroker } from "@/utils/EventBroker"; + +export default class SharedMindMapController { + public container: SharedTreeContainer; + private layoutManager: SharedMindmapLayoutManager; + + constructor(doc: Y.Doc, broker: EventBroker) { + this.container = new SharedTreeContainer({ doc, broker }); + this.layoutManager = new SharedMindmapLayoutManager({ xGap: 140, yGap: 60 }); + + this.container.onTransaction = (event, origin) => { + this.handleTransaction(event, origin); + }; + } + + private handleTransaction(event: Y.YMapEvent, origin: TransactionOrigin) { + if (origin === "layout") { + return; + } + + if (!event.transaction.local) { + return; + } + + if (origin === "user_action") { + event.keysChanged.forEach((nodeId) => { + this.layoutManager.invalidate(nodeId, this.container); + }); + + this.refreshLayout(); + } + } + + private refreshLayout() { + const updates = this.layoutManager.calculateLayout(this.container); + + if (updates.size > 0) { + this.container.doc.transact(() => { + updates.forEach((pos, nodeId) => { + this.container.updateNode(nodeId, { x: pos.x, y: pos.y }, "layout"); + }); + console.log(updates); + }, "layout"); + } + } + + public addChildNode(parentId: NodeId) { + const parent = this.container.safeGetNode(parentId); + + if (!parent) { + console.error("Parent node not found"); + return; + } + + this.container.appendChild({ parentNodeId: parentId }, "user_action"); + } + + public resetMindMap() { + if (confirm("정말로 모든 내용을 삭제하고 초기화하시겠습니까?")) { + this.container.clear("user_action"); + } + } + + public deleteNode(nodeId: NodeId) { + this.container.delete({ nodeId }); + } + + public updateNodeSize({ nodeId, width, height }: { nodeId: NodeId; width: number; height: number }) { + this.container.updateNode(nodeId, { width, height }, "user_action"); + } + + public updateNodeContents(nodeId: NodeId, contents: string) { + this.container.updateNode(nodeId, { data: { contents } }, "user_action"); + } + + public undo() { + this.container.undoManager.undo(); + } + + public redo() { + this.container.undoManager.redo(); + } +} From 7149efda93602dcbcfacf2336c5d04e2dab36cd0 Mon Sep 17 00:00:00 2001 From: pakxe Date: Thu, 12 Feb 2026 15:42:25 +0900 Subject: [PATCH 13/36] =?UTF-8?q?feat:=20SharedMindmapLayoutManager=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/SharedMindmapLayoutManager.ts | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts new file mode 100644 index 000000000..0ba858fff --- /dev/null +++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts @@ -0,0 +1,232 @@ +import SharedTreeContainer from "@/features/mindmap/shared_mindmap/utils/SharedTreeContainer"; +import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap"; +import { calcPartitionIndex } from "@/utils/calc_partition"; // 기존 유틸 사용 +import { isSame } from "@/utils/is_same"; // 기존 유틸 사용 + +type LayoutConfig = { + xGap: number; + yGap: number; +}; + +type PartitionDirection = "right" | "left"; + +export type LayoutResult = Map; + +export default class SharedMindmapLayoutManager { + private config: LayoutConfig; + private subtreeHeightCache: Map; + + constructor(config?: Partial) { + const defaultConfig: LayoutConfig = { + xGap: 200, + yGap: 16, + }; + this.config = { ...defaultConfig, ...config }; + this.subtreeHeightCache = new Map(); + } + + public invalidate(nodeId: NodeId, container: SharedTreeContainer) { + let currentId: NodeId | undefined = nodeId; + + while (currentId) { + this.subtreeHeightCache.delete(currentId); + + const parentNode = container.safeGetParentNode(currentId); + if (!parentNode) { + break; + } + + currentId = parentNode.id; + } + } + + public calculateLayout( + container: SharedTreeContainer, + rootCenterX: number = 0, + rootCenterY: number = 0, + ): LayoutResult { + const rootId = container.getRootId(); + const rootNode = container.safeGetNode(rootId); + + const updates: LayoutResult = new Map(); + + if (!rootNode) { + return updates; + } + + const realX = rootCenterX - rootNode.width / 2; + const realY = rootCenterY - rootNode.height / 2; + + if (!isSame(rootNode.x, realX) || !isSame(rootNode.y, realY)) { + updates.set(rootId, { x: realX, y: realY }); + } + + const childNodes = container.getChildNodes(rootId); + if (childNodes.length === 0) { + return updates; + } + + const { leftGroup, rightGroup } = this.getPartition(childNodes, container); + + this.layoutPartition({ + container, + parentNode: rootNode, + partition: rightGroup, + parentRealX: realX, + parentRealY: realY, + direction: "right", + updates, + }); + + this.layoutPartition({ + container, + parentNode: rootNode, + partition: leftGroup, + parentRealX: realX, + parentRealY: realY, + direction: "left", + updates, + }); + + return updates; + } + + private getPartition(childNodes: NodeElement[], container: SharedTreeContainer) { + const heightArr = childNodes.map((node) => this.getSubTreeHeight(node, container)); + const partitionIndex = calcPartitionIndex(heightArr); + + return { + rightGroup: childNodes.slice(0, partitionIndex), + leftGroup: childNodes.slice(partitionIndex), + }; + } + + private getSubTreeHeight(node: NodeElement, container: SharedTreeContainer): number { + if (this.subtreeHeightCache.has(node.id)) { + return this.subtreeHeightCache.get(node.id)!; + } + + const childNodes = container.getChildNodes(node.id); + let calculatedHeight = 0; + + if (childNodes.length === 0) { + calculatedHeight = node.height; + } else { + const gapHeight = (childNodes.length - 1) * this.config.yGap; + const childrenHeightSum = childNodes.reduce( + (acc, child) => acc + this.getSubTreeHeight(child, container), + gapHeight, + ); + + calculatedHeight = Math.max(node.height, childrenHeightSum); + } + + this.subtreeHeightCache.set(node.id, calculatedHeight); + + return calculatedHeight; + } + + private layoutPartition({ + container, + parentNode, + partition, + parentRealX, + parentRealY, + direction, + updates, + }: { + container: SharedTreeContainer; + parentNode: NodeElement; + partition: NodeElement[]; + parentRealX: number; + parentRealY: number; + direction: PartitionDirection; + updates: LayoutResult; + }) { + if (partition.length === 0) { + return; + } + + const partitionHeight = this.calcPartitionHeightWithGap(partition, container); + let currentY = parentRealY + parentNode.height / 2 - partitionHeight / 2; + + partition.forEach((childNode) => { + const realX = + direction === "right" + ? parentRealX + parentNode.width + this.config.xGap + : parentRealX - childNode.width - this.config.xGap; + + this.layoutSubtree({ + container, + curNode: childNode, + x: realX, + startY: currentY, + direction, + updates, + }); + + currentY += this.getSubTreeHeight(childNode, container) + this.config.yGap; + }); + } + + private layoutSubtree({ + container, + curNode, + x, + startY, + direction, + updates, + }: { + container: SharedTreeContainer; + curNode: NodeElement; + x: number; + startY: number; + direction: PartitionDirection; + updates: LayoutResult; + }) { + const subtreeHeight = this.getSubTreeHeight(curNode, container); + const newNodeY = startY - curNode.height / 2 + subtreeHeight / 2; + + if (!isSame(curNode.x, x) || !isSame(curNode.y, newNodeY)) { + updates.set(curNode.id, { x, y: newNodeY }); + } + + const childNodes = container.getChildNodes(curNode.id); + if (childNodes.length === 0) { + return; + } + + const childGroupHeight = this.calcPartitionHeightWithGap(childNodes, container); + let currentChildY = newNodeY + curNode.height / 2 - childGroupHeight / 2; + + childNodes.forEach((childNode) => { + const nextX = + direction === "right" ? x + curNode.width + this.config.xGap : x - childNode.width - this.config.xGap; + + this.layoutSubtree({ + container, + curNode: childNode, + x: nextX, + startY: currentChildY, + direction, + updates, + }); + + currentChildY += this.getSubTreeHeight(childNode, container) + this.config.yGap; + }); + } + + private calcPartitionHeightWithGap(partition: NodeElement[], container: SharedTreeContainer) { + if (partition.length === 0) { + return 0; + } + + const totalGap = this.config.yGap * (partition.length - 1); + const totalNodesHeight = partition.reduce( + (acc, node) => acc + this.getSubTreeHeight(node, container), + totalGap, + ); + + return totalNodesHeight; + } +} From 5407f3add8e590804a34ac44d13dae4503bcae33 Mon Sep 17 00:00:00 2001 From: pakxe Date: Thu, 12 Feb 2026 15:42:30 +0900 Subject: [PATCH 14/36] =?UTF-8?q?feat:=20SharedTreeContainer=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B8=EB=93=9C=EB=A7=B5=20=EB=85=B8=EB=93=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/SharedTreeContainer.ts | 530 ++++++++++++++++++ 1 file changed, 530 insertions(+) create mode 100644 frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts b/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts new file mode 100644 index 000000000..043edd34f --- /dev/null +++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts @@ -0,0 +1,530 @@ +import * as Y from "yjs"; + +import { NodeData, NodeElement, NodeId, NodeType } from "@/features/mindmap/types/mindmap"; +import { EventBroker } from "@/utils/EventBroker"; +import { exhaustiveCheck } from "@/utils/exhaustive_check"; +import generateId from "@/utils/generate_id"; + +// TODO: quadtree 준비되면 의존성 주입 + +const TRANSACTION_ORIGINS = { + USER_ACTION: "user_action", + LAYOUT: "layout", + REMOTE: "remote", +} as const; + +export type TransactionOrigin = (typeof TRANSACTION_ORIGINS)[keyof typeof TRANSACTION_ORIGINS]; + +const ROOT_NODE_PARENT_ID = "empty"; +const ROOT_NODE_CONTENTS = "김현대의 마인드맵"; +export const ROOT_NODE_ID = "root"; + +const DETACHED_NODE_PARENT_ID = "detached"; +const ROOM_NAME = "mindmap-nodes"; + +export default class SharedTreeContainer { + public undoManager: Y.UndoManager; + public doc: Y.Doc; + public yNodes: Y.Map; + private cachedNodes: Map; + + public broker: EventBroker; + private isThrowError: boolean; + private rootNodeId: NodeId = ROOT_NODE_ID; + + public onTransaction?: (event: Y.YMapEvent, origin: TransactionOrigin) => void; + + constructor({ + isThrowError = true, + name = ROOT_NODE_CONTENTS, + broker, + doc, + }: { + broker: EventBroker; + name?: string; + isThrowError?: boolean; + doc: Y.Doc; + }) { + // initialization + this.doc = doc; + this.broker = broker; + this.yNodes = this.doc.getMap(ROOM_NAME); + + this.cachedNodes = new Map(); + this.yNodes.forEach((value, key) => { + this.cachedNodes.set(key, value); + }); + + if (this.yNodes.size === 0) { + this.initRootNode(name); + } + + this.undoManager = new Y.UndoManager(this.yNodes, { + captureTimeout: 500, + trackedOrigins: new Set(["user-action"]), + }); + + this.isThrowError = isThrowError; + + this.yNodes.observe((event) => { + const origin = event.transaction.origin as TransactionOrigin; + + event.keysChanged.forEach((nodeId) => { + const newValue = this.yNodes.get(nodeId); + + if (newValue === undefined) { + this.cachedNodes.delete(nodeId); + } else { + this.cachedNodes.set(nodeId, newValue); + } + + this.broker.publish(nodeId); + }); + + if (this.onTransaction) { + this.onTransaction(event, origin); + } + }); + } + + private initRootNode(contents: string) { + this.doc.transact(() => { + this.generateNewNodeElement({ + nodeData: { contents }, + id: this.rootNodeId, + type: "root", + }); + }, TRANSACTION_ORIGINS.USER_ACTION); + } + + public getDoc() { + return this.doc; + } + + appendChild( + { parentNodeId, childNodeId }: { parentNodeId: NodeId; childNodeId?: NodeId }, + origin: TransactionOrigin = "user_action", + ) { + this.doc.transact(() => { + try { + let childNode: NodeElement; + if (childNodeId) { + childNode = this._getNode(childNodeId); + } else { + childNode = this.generateNewNodeElement(); + } + + const parentNode = this._getNode(parentNodeId); + + this._updateNode(childNode.id, { parentId: parentNodeId }); + + if (parentNode.lastChildId) { + const lastNode = this._getNode(parentNode.lastChildId); + + this._updateNode(lastNode.id, { nextId: childNode.id }); + this._updateNode(childNode.id, { + prevId: lastNode.id, + nextId: null, + }); + this._updateNode(parentNode.id, { lastChildId: childNode.id }); + } else { + this._updateNode(parentNode.id, { + firstChildId: childNode.id, + lastChildId: childNode.id, + }); + } + } catch (e) { + this.handleError(e); + } + }, origin); + } + + attachTo({ baseNodeId, direction }: { baseNodeId: NodeId; direction: "prev" | "next" | "child" }) { + this.doc.transact(() => { + try { + const baseNode = this._getNode(baseNodeId); + + if (baseNode.type === "root") { + throw new Error("루트 노드의 형제로는 노드를 추가할 수 없습니다."); + } + + const newNode = this.generateNewNodeElement(); + + switch (direction) { + case "next": + this.attachNext({ baseNode, movingNode: newNode }); + break; + case "prev": + this.attachPrev({ baseNode, movingNode: newNode }); + break; + case "child": + this.appendChild({ parentNodeId: baseNode.id, childNodeId: newNode.id }); + break; + default: + exhaustiveCheck(`${direction} 방향은 불가능합니다.`); + } + } catch (e) { + this.handleError(e); + } + }, "user-action"); + } + + private attachNext({ baseNode, movingNode }: { baseNode: NodeElement; movingNode: NodeElement }) { + this._updateNode(movingNode.id, { + parentId: baseNode.parentId, + prevId: baseNode.id, + nextId: baseNode.nextId, + }); + + if (baseNode.nextId) { + this._updateNode(baseNode.nextId, { + prevId: movingNode.id, + }); + } + + this._updateNode(baseNode.id, { + nextId: movingNode.id, + }); + + const parentNode = this._getNode(baseNode.parentId); + if (parentNode.lastChildId === baseNode.id) { + this._updateNode(parentNode.id, { + lastChildId: movingNode.id, + }); + } + } + + private attachPrev({ baseNode, movingNode }: { baseNode: NodeElement; movingNode: NodeElement }) { + this._updateNode(movingNode.id, { + parentId: baseNode.parentId, + prevId: baseNode.prevId, + nextId: baseNode.id, + }); + + if (baseNode.prevId) { + this._updateNode(baseNode.prevId, { + nextId: movingNode.id, + }); + } + + this._updateNode(baseNode.id, { + prevId: movingNode.id, + }); + + const parentNode = this._getNode(baseNode.parentId); + if (parentNode.firstChildId === baseNode.id) { + this._updateNode(parentNode.id, { + firstChildId: movingNode.id, + }); + } + } + + delete({ nodeId, origin = "user_action" }: { nodeId: NodeId; origin?: TransactionOrigin }) { + this.doc.transact(() => { + try { + const node = this._getNode(nodeId); + if (node.type === "root") { + throw new Error("루트 노드는 삭제할 수 없습니다."); + } + + const parentNode = this._getNode(node.parentId); + + if (parentNode.firstChildId === node.id) { + this._updateNode(parentNode.id, { firstChildId: node.nextId }); + } + + if (parentNode.lastChildId === node.id) { + this._updateNode(parentNode.id, { lastChildId: node.prevId }); + } + + if (node.prevId) { + this._updateNode(node.prevId, { nextId: node.nextId }); + } + + if (node.nextId) { + this._updateNode(node.nextId, { prevId: node.prevId }); + } + + this._deleteTraverse({ nodeId }); + } catch (e) { + this.handleError(e); + } + }, origin); + } + + private _deleteTraverse({ nodeId }: { nodeId: NodeId }) { + const node = this.safeGetNode(nodeId); + if (!node) return; + + let childId = node.firstChildId; + + while (childId) { + const child = this.safeGetNode(childId); + if (!child) break; + + const nextChildId = child.nextId; + this._deleteTraverse({ nodeId: childId }); + childId = nextChildId; + } + + this.deleteNode(nodeId); + } + + moveTo({ + baseNodeId, + movingNodeId, + direction, + }: { + baseNodeId: NodeId; + movingNodeId: NodeId; + direction: "prev" | "next" | "child"; + }) { + if (direction === "child" && baseNodeId === movingNodeId) return; + if (baseNodeId === movingNodeId) return; + + this.doc.transact(() => { + try { + const baseNode = this._getNode(baseNodeId); + const movingNode = this._getNode(movingNodeId); + + const checkNodeId = direction !== "child" ? baseNode.parentId : baseNode.id; + + let tempParent = this.safeGetNode(checkNodeId); + while (tempParent) { + if (tempParent.id === movingNodeId) { + throw new Error("자손 밑으로 이동 불가"); + } + if (tempParent.type === "root") break; + tempParent = this.safeGetNode(tempParent.parentId); + } + + this.detach({ node: movingNode }); + + switch (direction) { + case "next": + this.attachNext({ baseNode, movingNode }); + break; + case "prev": + this.attachPrev({ baseNode, movingNode }); + break; + case "child": + this.appendChild({ parentNodeId: baseNode.id, childNodeId: movingNode.id }); + break; + default: + exhaustiveCheck(`${direction} 방향은 불가능합니다.`); + } + } catch (e) { + this.handleError(e); + } + }, "user-action"); + } + + private detach({ node }: { node: NodeElement }) { + if (node.type === "root") { + throw new Error("루트 노드는 뗄 수 없습니다."); + } + + const parentNode = this._getNode(node.parentId); + + if (parentNode.firstChildId === node.id) { + this._updateNode(parentNode.id, { firstChildId: node.nextId }); + } + + if (parentNode.lastChildId === node.id) { + this._updateNode(parentNode.id, { lastChildId: node.prevId }); + } + + if (node.prevId) { + this._updateNode(node.prevId, { nextId: node.nextId }); + } + + if (node.nextId) { + this._updateNode(node.nextId, { prevId: node.prevId }); + } + + this._updateNode(node.id, { + prevId: null, + nextId: null, + parentId: DETACHED_NODE_PARENT_ID, + }); + } + + update({ nodeId, newNodeData }: { nodeId: NodeId; newNodeData: Partial> }) { + try { + // 유효성 검사를 위해 get + this._getNode(nodeId); + // 업데이트 수행 + this._updateNode(nodeId, newNodeData); + } catch (e) { + this.handleError(e); + } + } + + private _getNode(nodeId: NodeId): NodeElement { + const node = this.cachedNodes.get(nodeId); + + if (!node) { + throw new Error(`일치하는 Node가 없습니다. (node_id: ${nodeId})`); + } + return node; + } + + safeGetNode(nodeId: NodeId): NodeElement | undefined { + return this.cachedNodes.get(nodeId); + } + + private _updateNode(nodeId: NodeId, patch: Partial) { + const prev = this.cachedNodes.get(nodeId); + + if (!prev) return; + + this.yNodes.set(nodeId, { ...prev, ...patch }); + this.cachedNodes.set(nodeId, { ...prev, ...patch }); + } + + public clear(origin: TransactionOrigin = "user_action") { + this.doc.transact(() => { + this.yNodes.clear(); + this.cachedNodes.clear(); + + this.generateNewNodeElement({ + nodeData: { contents: "새로운 마인드맵" }, + id: this.rootNodeId, + type: "root", + }); + }, origin); + } + + public updateNode(nodeId: NodeId, patch: Partial, origin: TransactionOrigin = "user_action") { + this.doc.transact(() => { + const prev = this.cachedNodes.get(nodeId); + if (!prev) return; + this.yNodes.set(nodeId, { ...prev, ...patch }); + this.cachedNodes.set(nodeId, { ...prev, ...patch }); + }, origin); + } + + public setNode(nodeId: NodeId, node: NodeElement, origin: TransactionOrigin = "user_action") { + this.doc.transact(() => { + this.yNodes.set(nodeId, node); + this.cachedNodes.set(nodeId, node); + }, origin); + } + + public deleteNode(nodeId: NodeId, origin: TransactionOrigin = "user_action") { + this.doc.transact(() => { + this.yNodes.delete(nodeId); + this.cachedNodes.delete(nodeId); + }, origin); + } + + private generateNewNodeElement({ + nodeData = { contents: "" }, + type = "normal", + id, + }: { nodeData?: NodeData; type?: NodeType; id?: NodeId } = {}) { + const newNodeId = id ?? generateId(); + + const node: NodeElement = { + id: newNodeId, + x: 0, + y: 0, + width: 0, + height: 0, + parentId: ROOT_NODE_PARENT_ID, + firstChildId: null, + lastChildId: null, + nextId: null, + prevId: null, + data: nodeData, + type, + }; + + this.yNodes.set(newNodeId, node); + this.cachedNodes.set(newNodeId, node); + + return node; + } + + getChildIds(nodeId: NodeId): NodeId[] { + const node = this.safeGetNode(nodeId); + if (!node) return []; + + const childIds: NodeId[] = []; + let currentChildId = node.firstChildId; + + while (currentChildId) { + childIds.push(currentChildId); + const childNode = this.safeGetNode(currentChildId); + if (!childNode) break; + currentChildId = childNode.nextId; + } + return childIds; + } + + safeGetParentNode(nodeId: NodeId) { + const node = this.safeGetNode(nodeId); + if (!node) return undefined; + return this.safeGetNode(node.parentId); + } + + getRootId() { + return this.rootNodeId; + } + + getParentId(nodeId: NodeId): NodeId | undefined { + const node = this.safeGetNode(nodeId); + if (!node) return undefined; + return node.parentId; + } + + getAllDescendantIds(nodeId: NodeId): Set { + const descendants = new Set(); + descendants.add(nodeId); + + const traverse = (currentId: NodeId) => { + const children = this.getChildIds(currentId); + children.forEach((childId) => { + descendants.add(childId); + traverse(childId); + }); + }; + + traverse(nodeId); + return descendants; + } + + getChildNodes(parentNodeId: NodeId): NodeElement[] { + const node = this.safeGetNode(parentNodeId); + if (!node) return []; + + const childNodes: NodeElement[] = []; + let currentChildId = node.firstChildId; + + while (currentChildId) { + const childNode = this.safeGetNode(currentChildId); + if (!childNode) { + console.error("유효하지 않은 childNode가 존재하므로 빈 배열을 반환합니다."); + return []; + } + childNodes.push(childNode); + currentChildId = childNode.nextId; + } + return childNodes; + } + + getRootNode() { + return this._getNode(this.rootNodeId); + } + + private handleError(e: unknown) { + if (e instanceof Error) { + console.error(e.message); + } else { + console.error(String(e)); + } + if (this.isThrowError) { + throw e; + } + } +} From acc59e35e258f2980438cbb6dc0e88f177fb15a7 Mon Sep 17 00:00:00 2001 From: pakxe Date: Thu, 12 Feb 2026 15:43:03 +0900 Subject: [PATCH 15/36] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B8=EB=93=9C?= =?UTF-8?q?=EB=A7=B5=20=EA=B4=80=EB=A0=A8=20=ED=83=80=EC=9E=85=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20=EC=B6=94=EA=B0=80,=20=ED=8C=8C=EC=9D=BC=EB=AA=85?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/mindmap/types/{mindmap_type.ts => mindmap.ts} | 0 .../{mindmap_interaction_type.ts => mindmap_interaction.ts} | 2 +- frontend/src/features/mindmap/types/mindmap_room.ts | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) rename frontend/src/features/mindmap/types/{mindmap_type.ts => mindmap.ts} (100%) rename frontend/src/features/mindmap/types/{mindmap_interaction_type.ts => mindmap_interaction.ts} (78%) create mode 100644 frontend/src/features/mindmap/types/mindmap_room.ts diff --git a/frontend/src/features/mindmap/types/mindmap_type.ts b/frontend/src/features/mindmap/types/mindmap.ts similarity index 100% rename from frontend/src/features/mindmap/types/mindmap_type.ts rename to frontend/src/features/mindmap/types/mindmap.ts diff --git a/frontend/src/features/mindmap/types/mindmap_interaction_type.ts b/frontend/src/features/mindmap/types/mindmap_interaction.ts similarity index 78% rename from frontend/src/features/mindmap/types/mindmap_interaction_type.ts rename to frontend/src/features/mindmap/types/mindmap_interaction.ts index 54a3dd977..9b03fb1db 100644 --- a/frontend/src/features/mindmap/types/mindmap_interaction_type.ts +++ b/frontend/src/features/mindmap/types/mindmap_interaction.ts @@ -1,4 +1,4 @@ -import { NodeId } from "@/features/mindmap/types/mindmap_type"; +import { NodeId } from "@/features/mindmap/types/mindmap"; export type InteractionMode = "idle" | "potential_drag" | "dragging" | "panning"; diff --git a/frontend/src/features/mindmap/types/mindmap_room.ts b/frontend/src/features/mindmap/types/mindmap_room.ts new file mode 100644 index 000000000..3ba6e0732 --- /dev/null +++ b/frontend/src/features/mindmap/types/mindmap_room.ts @@ -0,0 +1 @@ +export type MindmapRoomId = string; From 524761e0e02c706ddc130d0b72e650bad8cf22c5 Mon Sep 17 00:00:00 2001 From: pakxe Date: Thu, 12 Feb 2026 15:59:40 +0900 Subject: [PATCH 16/36] chore: SharedMindmapShowCase --- .../components/SharedMindmapShowCase.tsx | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 frontend/src/features/mindmap/shared_mindmap/components/SharedMindmapShowCase.tsx diff --git a/frontend/src/features/mindmap/shared_mindmap/components/SharedMindmapShowCase.tsx b/frontend/src/features/mindmap/shared_mindmap/components/SharedMindmapShowCase.tsx new file mode 100644 index 000000000..4cf00e0de --- /dev/null +++ b/frontend/src/features/mindmap/shared_mindmap/components/SharedMindmapShowCase.tsx @@ -0,0 +1,233 @@ +import { useEffect, useRef, useState } from "react"; + +import { useNode } from "@/features/mindmap/shared_mindmap/hooks/useNode"; +import { useNodeResizeObserver } from "@/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver"; +import { useSharedMindmap } from "@/features/mindmap/shared_mindmap/hooks/useSharedMindmap"; +import SharedMindMapController from "@/features/mindmap/shared_mindmap/utils/SharedMindmapController"; +import { NodeId } from "@/features/mindmap/types/mindmap"; + +// [Read Hook] 특정 노드의 데이터 변경을 구독 + +// [View Logic] ResizeObserver 대신 getBoundingClientRect 사용 + +type NodeItemProps = { + nodeId: NodeId; + controller: SharedMindMapController; +}; + +const NodeItem = ({ nodeId, controller }: NodeItemProps) => { + const node = useNode(nodeId, controller); + + const nodeRef = useNodeResizeObserver({ + nodeId, + node, + onResize: (args) => controller.updateNodeSize({ ...args, nodeId }), + }); + + if (!node) return null; + + const handleAddChild = (e: React.MouseEvent) => { + e.stopPropagation(); // 캔버스 Pan 방지 + controller.addChildNode(nodeId); + }; + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + if (node.type !== "root") { + controller.deleteNode(nodeId); + } + }; + + // --- Style --- + const style: React.CSSProperties = { + position: "absolute", + left: node.x, // Controller가 계산해준 좌표 사용 + top: node.y, + + // 스타일링 + minWidth: "100px", + minHeight: "40px", + backgroundColor: node.type === "root" ? "#FFB74D" : "#FFFFFF", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + transition: "left 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), top 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)", // 부드러운 애니메이션 + zIndex: node.type === "root" ? 10 : 1, + whiteSpace: "nowrap", + padding: "20px", + }; + + return ( + <> +
e.stopPropagation()} // 드래그 방지 + > + {/* 노드 내용 */} +
+ {node.id} +
+ {node.parentId} +
({node.x}, {node.y}) +
+ + {/* 컨트롤 버튼 */} +
+ + {node.type !== "root" && ( + + )} +
+
+ + {/* 자식 노드 재귀 렌더링 */} + {controller.container.getChildIds(nodeId).map((childId) => ( + + ))} + + ); +}; + +const btnStyle: React.CSSProperties = { + cursor: "pointer", + padding: "2px 6px", + fontSize: "12px", + border: "1px solid #ccc", + borderRadius: "4px", + background: "#f9f9f9", +}; + +export default function MindmapShowcaseV3() { + const { controller, connectionStatus } = useSharedMindmap({ roomId: "" }); + + // 2. Canvas Panning State + const [pan, setPan] = useState({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); + const [isPanning, setIsPanning] = useState(false); + const lastPos = useRef({ x: 0, y: 0 }); + + // 3. Keyboard Shortcuts (Undo/Redo) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (["INPUT", "TEXTAREA"].includes((e.target as HTMLElement).tagName)) return; + + const isCtrl = e.ctrlKey || e.metaKey; + + if (isCtrl && e.key === "z" && !e.shiftKey) { + e.preventDefault(); + controller.undo(); // Controller에게 위임 + } + if ((isCtrl && e.key === "y") || (isCtrl && e.key === "z" && e.shiftKey)) { + e.preventDefault(); + controller.redo(); // Controller에게 위임 + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [controller]); + + // --- Panning Handlers --- + const handlePointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + setIsPanning(true); + lastPos.current = { x: e.clientX, y: e.clientY }; + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!isPanning) return; + e.preventDefault(); + const dx = e.clientX - lastPos.current.x; + const dy = e.clientY - lastPos.current.y; + setPan((prev) => ({ x: prev.x + dx, y: prev.y + dy })); + lastPos.current = { x: e.clientX, y: e.clientY }; + }; + + const handlePointerUp = (e: React.PointerEvent) => { + setIsPanning(false); + (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId); + }; + + return ( + <> + +
+ {/* Status Indicator */} +
+
+ + + Status: {connectionStatus} + +
+ + {/* Canvas Content */} +
+ {/* Root Node 렌더링 시작 */} + +
+
+ + ); +} From 9edc63f7131fdeafc1469efc04c0ecc2ce20ac1b Mon Sep 17 00:00:00 2001 From: pakxe Date: Thu, 12 Feb 2026 16:05:47 +0900 Subject: [PATCH 17/36] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mindmap/hooks/useSharedMindmap.ts | 89 ------- .../components/SharedMindmapShowCase.tsx | 233 ------------------ 2 files changed, 322 deletions(-) delete mode 100644 frontend/src/features/mindmap/hooks/useSharedMindmap.ts delete mode 100644 frontend/src/features/mindmap/shared_mindmap/components/SharedMindmapShowCase.tsx diff --git a/frontend/src/features/mindmap/hooks/useSharedMindmap.ts b/frontend/src/features/mindmap/hooks/useSharedMindmap.ts deleted file mode 100644 index 390a46d96..000000000 --- a/frontend/src/features/mindmap/hooks/useSharedMindmap.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { IndexeddbPersistence } from "y-indexeddb"; -import { WebsocketProvider } from "y-websocket"; -import * as Y from "yjs"; - -import { ENV } from "@/constants/env"; -import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap_type"; -import SharedMindmapLayoutManager from "@/features/mindmap/utils/SharedMindmapLayoutManager"; -import SharedTreeContainer, { ROOT_NODE_ID } from "@/features/mindmap/utils/SharedTreeContainer"; -import { EventBroker } from "@/utils/EventBroker"; - -type CONNECTION_STATUS = "disconnected" | "connected" | "connecting"; - -type Props = { - roomId: string; -}; - -const useSharedMindmap = ({ roomId }: Props) => { - const [connectionStatus, setConnectedStatus] = useState("disconnected"); - - const { provider, doc, container, layoutManager, ...rest } = useMemo(() => { - const doc = new Y.Doc(); - - const provider = new WebsocketProvider(ENV.WS_BASE_URL, roomId, doc); - const localProvider = new IndexeddbPersistence(roomId, doc); - - localProvider.on("synced", () => { - toast.success("오프라인 상태에서 수정한 작업 내역을 반영하였습니다."); - }); - - const broker = new EventBroker(); - const container = new SharedTreeContainer({ - doc, - broker, - quadTreeManager: undefined, - name: "Test Mindmap", - }); - const layoutManager = new SharedMindmapLayoutManager({ - treeContainer: container, - config: { xGap: 140, yGap: 60 }, - }); - return { doc, provider, container, layoutManager, broker }; - }, []); - - useEffect(() => { - if (!provider.shouldConnect) { - provider.connect(); - } - - provider.on("status", (e) => { - setConnectedStatus(e.status); - }); - - return () => { - provider.disconnect(); - }; - }, [provider]); - - useEffect(() => { - const observer = (e: Y.YMapEvent) => { - e.keysChanged.forEach((key) => layoutManager.invalidate(key)); - - // 자신이 일으킨 변화가 아닌 경우 - if (!e.transaction.local) { - return; - } - - layoutManager.updateLayout({ - rootId: ROOT_NODE_ID, - rootCenterX: 0, - rootCenterY: 0, - }); - }; - - container.yNodes.observe(observer); - }, [doc, provider, container, layoutManager]); - - return { - connectionStatus, - provider, - doc, - container, - layoutManager, - ...rest, - }; -}; - -export default useSharedMindmap; diff --git a/frontend/src/features/mindmap/shared_mindmap/components/SharedMindmapShowCase.tsx b/frontend/src/features/mindmap/shared_mindmap/components/SharedMindmapShowCase.tsx deleted file mode 100644 index 4cf00e0de..000000000 --- a/frontend/src/features/mindmap/shared_mindmap/components/SharedMindmapShowCase.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { useEffect, useRef, useState } from "react"; - -import { useNode } from "@/features/mindmap/shared_mindmap/hooks/useNode"; -import { useNodeResizeObserver } from "@/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver"; -import { useSharedMindmap } from "@/features/mindmap/shared_mindmap/hooks/useSharedMindmap"; -import SharedMindMapController from "@/features/mindmap/shared_mindmap/utils/SharedMindmapController"; -import { NodeId } from "@/features/mindmap/types/mindmap"; - -// [Read Hook] 특정 노드의 데이터 변경을 구독 - -// [View Logic] ResizeObserver 대신 getBoundingClientRect 사용 - -type NodeItemProps = { - nodeId: NodeId; - controller: SharedMindMapController; -}; - -const NodeItem = ({ nodeId, controller }: NodeItemProps) => { - const node = useNode(nodeId, controller); - - const nodeRef = useNodeResizeObserver({ - nodeId, - node, - onResize: (args) => controller.updateNodeSize({ ...args, nodeId }), - }); - - if (!node) return null; - - const handleAddChild = (e: React.MouseEvent) => { - e.stopPropagation(); // 캔버스 Pan 방지 - controller.addChildNode(nodeId); - }; - - const handleDelete = (e: React.MouseEvent) => { - e.stopPropagation(); - if (node.type !== "root") { - controller.deleteNode(nodeId); - } - }; - - // --- Style --- - const style: React.CSSProperties = { - position: "absolute", - left: node.x, // Controller가 계산해준 좌표 사용 - top: node.y, - - // 스타일링 - minWidth: "100px", - minHeight: "40px", - backgroundColor: node.type === "root" ? "#FFB74D" : "#FFFFFF", - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - transition: "left 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), top 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)", // 부드러운 애니메이션 - zIndex: node.type === "root" ? 10 : 1, - whiteSpace: "nowrap", - padding: "20px", - }; - - return ( - <> -
e.stopPropagation()} // 드래그 방지 - > - {/* 노드 내용 */} -
- {node.id} -
- {node.parentId} -
({node.x}, {node.y}) -
- - {/* 컨트롤 버튼 */} -
- - {node.type !== "root" && ( - - )} -
-
- - {/* 자식 노드 재귀 렌더링 */} - {controller.container.getChildIds(nodeId).map((childId) => ( - - ))} - - ); -}; - -const btnStyle: React.CSSProperties = { - cursor: "pointer", - padding: "2px 6px", - fontSize: "12px", - border: "1px solid #ccc", - borderRadius: "4px", - background: "#f9f9f9", -}; - -export default function MindmapShowcaseV3() { - const { controller, connectionStatus } = useSharedMindmap({ roomId: "" }); - - // 2. Canvas Panning State - const [pan, setPan] = useState({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); - const [isPanning, setIsPanning] = useState(false); - const lastPos = useRef({ x: 0, y: 0 }); - - // 3. Keyboard Shortcuts (Undo/Redo) - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (["INPUT", "TEXTAREA"].includes((e.target as HTMLElement).tagName)) return; - - const isCtrl = e.ctrlKey || e.metaKey; - - if (isCtrl && e.key === "z" && !e.shiftKey) { - e.preventDefault(); - controller.undo(); // Controller에게 위임 - } - if ((isCtrl && e.key === "y") || (isCtrl && e.key === "z" && e.shiftKey)) { - e.preventDefault(); - controller.redo(); // Controller에게 위임 - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [controller]); - - // --- Panning Handlers --- - const handlePointerDown = (e: React.PointerEvent) => { - if (e.button !== 0) return; - (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); - setIsPanning(true); - lastPos.current = { x: e.clientX, y: e.clientY }; - }; - - const handlePointerMove = (e: React.PointerEvent) => { - if (!isPanning) return; - e.preventDefault(); - const dx = e.clientX - lastPos.current.x; - const dy = e.clientY - lastPos.current.y; - setPan((prev) => ({ x: prev.x + dx, y: prev.y + dy })); - lastPos.current = { x: e.clientX, y: e.clientY }; - }; - - const handlePointerUp = (e: React.PointerEvent) => { - setIsPanning(false); - (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId); - }; - - return ( - <> - -
- {/* Status Indicator */} -
-
- - - Status: {connectionStatus} - -
- - {/* Canvas Content */} -
- {/* Root Node 렌더링 시작 */} - -
-
- - ); -} From 4fa7ddb8147dc062dc5de61b17247086815460cb Mon Sep 17 00:00:00 2001 From: pakxe Date: Thu, 12 Feb 2026 16:07:00 +0900 Subject: [PATCH 18/36] =?UTF-8?q?refactor:=20=EC=97=B0=EC=82=B0=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EB=B3=80=EC=88=98=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts index bd2452a66..b8de823e4 100644 --- a/frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts @@ -25,7 +25,8 @@ export function useNodeResizeObserver({ nodeId, node, onResize }: Props) { const newWidth = Math.ceil(rect.width); const newHeight = Math.ceil(rect.height); - if (!isSame(node.width, newWidth) || !isSame(node.height, newHeight)) { + const isChanged = !isSame(node.width, newWidth) || !isSame(node.height, newHeight); + if (isChanged) { onResize({ height: newHeight, width: newWidth }); } }, [nodeId]); From 6fbb2bc88d4d87576fe01d1022464571bac2fd06 Mon Sep 17 00:00:00 2001 From: pakxe Date: Thu, 12 Feb 2026 17:11:31 +0900 Subject: [PATCH 19/36] =?UTF-8?q?fix:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=EC=99=B8=EB=B6=80=EC=97=90=EC=84=9C=20roomId=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mindmap/shared_mindmap/utils/SharedTreeContainer.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts b/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts index 043edd34f..31ac8f21f 100644 --- a/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts +++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts @@ -1,6 +1,7 @@ import * as Y from "yjs"; import { NodeData, NodeElement, NodeId, NodeType } from "@/features/mindmap/types/mindmap"; +import { MindmapRoomId } from "@/features/mindmap/types/mindmap_room"; import { EventBroker } from "@/utils/EventBroker"; import { exhaustiveCheck } from "@/utils/exhaustive_check"; import generateId from "@/utils/generate_id"; @@ -20,7 +21,6 @@ const ROOT_NODE_CONTENTS = "김현대의 마인드맵"; export const ROOT_NODE_ID = "root"; const DETACHED_NODE_PARENT_ID = "detached"; -const ROOM_NAME = "mindmap-nodes"; export default class SharedTreeContainer { public undoManager: Y.UndoManager; @@ -39,16 +39,18 @@ export default class SharedTreeContainer { name = ROOT_NODE_CONTENTS, broker, doc, + roomId, }: { broker: EventBroker; name?: string; isThrowError?: boolean; doc: Y.Doc; + roomId: MindmapRoomId; }) { // initialization this.doc = doc; this.broker = broker; - this.yNodes = this.doc.getMap(ROOM_NAME); + this.yNodes = this.doc.getMap(roomId); this.cachedNodes = new Map(); this.yNodes.forEach((value, key) => { @@ -61,7 +63,7 @@ export default class SharedTreeContainer { this.undoManager = new Y.UndoManager(this.yNodes, { captureTimeout: 500, - trackedOrigins: new Set(["user-action"]), + trackedOrigins: new Set([TRANSACTION_ORIGINS.USER_ACTION]), }); this.isThrowError = isThrowError; From 40493747008b4bca770931f61c8c279c99c54cfb Mon Sep 17 00:00:00 2001 From: pakxe Date: Thu, 12 Feb 2026 17:13:23 +0900 Subject: [PATCH 20/36] =?UTF-8?q?feat:=20roomId=EC=99=B8=EB=B6=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mindmap/shared_mindmap/hooks/useSharedMindmap.ts | 2 +- .../shared_mindmap/utils/SharedMindmapController.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts index f5c657bcd..2542e254e 100644 --- a/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts @@ -26,7 +26,7 @@ export const useSharedMindmap = ({ roomId }: UseSharedMindmapProps) => { const provider = new WebsocketProvider(ENV.WS_BASE_URL, roomId, doc); const broker = new EventBroker(); - const controller = new SharedMindMapController(doc, broker); + const controller = new SharedMindMapController(doc, broker, roomId); return { controller, provider }; }, [roomId]); diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts index af5589855..59046fb93 100644 --- a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts +++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts @@ -3,14 +3,15 @@ import * as Y from "yjs"; import SharedMindmapLayoutManager from "@/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager"; import SharedTreeContainer, { TransactionOrigin } from "@/features/mindmap/shared_mindmap/utils/SharedTreeContainer"; import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap"; +import { MindmapRoomId } from "@/features/mindmap/types/mindmap_room"; import { EventBroker } from "@/utils/EventBroker"; export default class SharedMindMapController { public container: SharedTreeContainer; private layoutManager: SharedMindmapLayoutManager; - constructor(doc: Y.Doc, broker: EventBroker) { - this.container = new SharedTreeContainer({ doc, broker }); + constructor(doc: Y.Doc, broker: EventBroker, roomId: MindmapRoomId) { + this.container = new SharedTreeContainer({ doc, broker, roomId }); this.layoutManager = new SharedMindmapLayoutManager({ xGap: 140, yGap: 60 }); this.container.onTransaction = (event, origin) => { @@ -44,7 +45,6 @@ export default class SharedMindMapController { updates.forEach((pos, nodeId) => { this.container.updateNode(nodeId, { x: pos.x, y: pos.y }, "layout"); }); - console.log(updates); }, "layout"); } } @@ -78,11 +78,12 @@ export default class SharedMindMapController { this.container.updateNode(nodeId, { data: { contents } }, "user_action"); } + // 지금 지원 안함 public undo() { - this.container.undoManager.undo(); + // this.container.undoManager.undo(); } public redo() { - this.container.undoManager.redo(); + // this.container.undoManager.redo(); } } From 15b626b32c54d4bdf674301e438ed45fa71d8f0e Mon Sep 17 00:00:00 2001 From: pakxe Date: Thu, 12 Feb 2026 17:14:11 +0900 Subject: [PATCH 21/36] chore: showCase --- .../shared_mindmap/show_cases/ShowCase.tsx | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx diff --git a/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx b/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx new file mode 100644 index 000000000..cb7261a88 --- /dev/null +++ b/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx @@ -0,0 +1,234 @@ +import { useEffect, useRef, useState } from "react"; + +import { useNode } from "@/features/mindmap/shared_mindmap/hooks/useNode"; +import { useNodeResizeObserver } from "@/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver"; +import { useSharedMindmap } from "@/features/mindmap/shared_mindmap/hooks/useSharedMindmap"; +import SharedMindMapController from "@/features/mindmap/shared_mindmap/utils/SharedMindmapController"; +import { NodeId } from "@/features/mindmap/types/mindmap"; + +// [Read Hook] 특정 노드의 데이터 변경을 구독 + +// [View Logic] ResizeObserver 대신 getBoundingClientRect 사용 + +type NodeItemProps = { + nodeId: NodeId; + controller: SharedMindMapController; +}; + +const NodeItem = ({ nodeId, controller }: NodeItemProps) => { + const node = useNode(nodeId, controller); + + const nodeRef = useNodeResizeObserver({ + nodeId, + node, + onResize: (args) => controller.updateNodeSize({ ...args, nodeId }), + }); + + if (!node) return null; + + const handleAddChild = (e: React.MouseEvent) => { + e.stopPropagation(); // 캔버스 Pan 방지 + controller.addChildNode(nodeId); + }; + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + if (node.type !== "root") { + controller.deleteNode(nodeId); + } + }; + + // --- Style --- + const style: React.CSSProperties = { + position: "absolute", + left: node.x, // Controller가 계산해준 좌표 사용 + top: node.y, + + // 스타일링 + minWidth: "100px", + minHeight: "40px", + backgroundColor: node.type === "root" ? "#FFB74D" : "#FFFFFF", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + transition: "left 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), top 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)", // 부드러운 애니메이션 + zIndex: node.type === "root" ? 10 : 1, + whiteSpace: "nowrap", + padding: "20px", + }; + + return ( + <> +
e.stopPropagation()} // 드래그 방지 + > + {/* 노드 내용 */} +
+ {node.id} +
+ {node.parentId} +
({node.x}, {node.y}) +
+ + {/* 컨트롤 버튼 */} +
+ + {node.type !== "root" && ( + + )} +
+
+ + {/* 자식 노드 재귀 렌더링 */} + {controller.container.getChildIds(nodeId).map((childId) => ( + + ))} + + ); +}; + +const btnStyle: React.CSSProperties = { + cursor: "pointer", + padding: "2px 6px", + fontSize: "12px", + border: "1px solid #ccc", + borderRadius: "4px", + background: "#f9f9f9", +}; + +const DUMMY_ROOM_ID = "ㅁ"; +export default function MindmapShowcaseV3() { + const { controller, connectionStatus } = useSharedMindmap({ roomId: DUMMY_ROOM_ID }); + + // 2. Canvas Panning State + const [pan, setPan] = useState({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); + const [isPanning, setIsPanning] = useState(false); + const lastPos = useRef({ x: 0, y: 0 }); + + // 3. Keyboard Shortcuts (Undo/Redo) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (["INPUT", "TEXTAREA"].includes((e.target as HTMLElement).tagName)) return; + + const isCtrl = e.ctrlKey || e.metaKey; + + if (isCtrl && e.key === "z" && !e.shiftKey) { + e.preventDefault(); + controller.undo(); // Controller에게 위임 + } + if ((isCtrl && e.key === "y") || (isCtrl && e.key === "z" && e.shiftKey)) { + e.preventDefault(); + controller.redo(); // Controller에게 위임 + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [controller]); + + // --- Panning Handlers --- + const handlePointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + setIsPanning(true); + lastPos.current = { x: e.clientX, y: e.clientY }; + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!isPanning) return; + e.preventDefault(); + const dx = e.clientX - lastPos.current.x; + const dy = e.clientY - lastPos.current.y; + setPan((prev) => ({ x: prev.x + dx, y: prev.y + dy })); + lastPos.current = { x: e.clientX, y: e.clientY }; + }; + + const handlePointerUp = (e: React.PointerEvent) => { + setIsPanning(false); + (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId); + }; + + return ( + <> + +
+ {/* Status Indicator */} +
+
+ + + Status: {connectionStatus} + +
+ + {/* Canvas Content */} +
+ {/* Root Node 렌더링 시작 */} + +
+
+ + ); +} From ea4a64a91c7fcb5186f1aab603a8730fc1e46b0f Mon Sep 17 00:00:00 2001 From: pakxe Date: Thu, 12 Feb 2026 17:31:47 +0900 Subject: [PATCH 22/36] =?UTF-8?q?feat:=20broker=20=EB=93=B1=20public?= =?UTF-8?q?=EC=9D=B8=EC=9E=90=EB=A5=BC=20private=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=ED=95=98=EA=B3=A0=20=EC=83=9D=EC=84=B1=EC=9E=90?= =?UTF-8?q?=EC=9D=98=20=EC=9D=B8=EC=9E=90=EB=A1=9C=20=EC=A3=BC=EC=9E=85?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mindmap/shared_mindmap/hooks/useNode.ts | 5 ++-- .../shared_mindmap/hooks/useSharedMindmap.ts | 7 +++--- .../shared_mindmap/show_cases/ShowCase.tsx | 12 ++++++---- .../utils/SharedMindmapController.ts | 23 ++++++++----------- .../utils/SharedMindmapLayoutManager.ts | 15 ++++++------ .../utils/SharedTreeContainer.ts | 21 +++++++++++++---- 6 files changed, 47 insertions(+), 36 deletions(-) diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useNode.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useNode.ts index 50ae1e443..f5326a4e2 100644 --- a/frontend/src/features/mindmap/shared_mindmap/hooks/useNode.ts +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useNode.ts @@ -2,16 +2,17 @@ import { useSyncExternalStore } from "react"; import SharedMindMapController from "@/features/mindmap/shared_mindmap/utils/SharedMindmapController"; import { NodeId } from "@/features/mindmap/types/mindmap"; +import { EventBroker } from "@/utils/EventBroker"; /** * Node UI 컴포넌트 안에서 사용하는 훅입니다. * nodeId에 해당하는 데이터가 변경되면 해당 컴포넌트만 rerendering을 트리거 해줍니다. */ -export function useNode(nodeId: NodeId, controller: SharedMindMapController) { +export function useNode(nodeId: NodeId, controller: SharedMindMapController, broker: EventBroker) { const { container } = controller; const subscribe = (callback: () => void) => { - return container.broker.subscribe({ key: nodeId, callback }); + return broker.subscribe({ key: nodeId, callback }); }; const getSnapshot = () => { diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts index 2542e254e..1fb14ca2f 100644 --- a/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts @@ -20,15 +20,14 @@ type UseSharedMindmapProps = { export const useSharedMindmap = ({ roomId }: UseSharedMindmapProps) => { const [connectionStatus, setConnectionStatus] = useState("disconnected"); - const { controller, provider } = useMemo(() => { + const { controller, provider, broker } = useMemo(() => { const doc = new Y.Doc(); const provider = new WebsocketProvider(ENV.WS_BASE_URL, roomId, doc); - const broker = new EventBroker(); const controller = new SharedMindMapController(doc, broker, roomId); - return { controller, provider }; + return { controller, provider, broker }; }, [roomId]); useEffect(() => { @@ -53,6 +52,8 @@ export const useSharedMindmap = ({ roomId }: UseSharedMindmapProps) => { container: controller.container, + broker, + connectionStatus, }; }; diff --git a/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx b/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx index cb7261a88..7d5d9f565 100644 --- a/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx +++ b/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx @@ -5,6 +5,7 @@ import { useNodeResizeObserver } from "@/features/mindmap/shared_mindmap/hooks/u import { useSharedMindmap } from "@/features/mindmap/shared_mindmap/hooks/useSharedMindmap"; import SharedMindMapController from "@/features/mindmap/shared_mindmap/utils/SharedMindmapController"; import { NodeId } from "@/features/mindmap/types/mindmap"; +import { EventBroker } from "@/utils/EventBroker"; // [Read Hook] 특정 노드의 데이터 변경을 구독 @@ -13,10 +14,11 @@ import { NodeId } from "@/features/mindmap/types/mindmap"; type NodeItemProps = { nodeId: NodeId; controller: SharedMindMapController; + broker: EventBroker; }; -const NodeItem = ({ nodeId, controller }: NodeItemProps) => { - const node = useNode(nodeId, controller); +const NodeItem = ({ nodeId, controller, broker }: NodeItemProps) => { + const node = useNode(nodeId, controller, broker); const nodeRef = useNodeResizeObserver({ nodeId, @@ -88,7 +90,7 @@ const NodeItem = ({ nodeId, controller }: NodeItemProps) => { {/* 자식 노드 재귀 렌더링 */} {controller.container.getChildIds(nodeId).map((childId) => ( - + ))} ); @@ -105,7 +107,7 @@ const btnStyle: React.CSSProperties = { const DUMMY_ROOM_ID = "ㅁ"; export default function MindmapShowcaseV3() { - const { controller, connectionStatus } = useSharedMindmap({ roomId: DUMMY_ROOM_ID }); + const { controller, connectionStatus, broker } = useSharedMindmap({ roomId: DUMMY_ROOM_ID }); // 2. Canvas Panning State const [pan, setPan] = useState({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); @@ -226,7 +228,7 @@ export default function MindmapShowcaseV3() { }} > {/* Root Node 렌더링 시작 */} - +
diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts index 59046fb93..14b64911e 100644 --- a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts +++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts @@ -11,12 +11,15 @@ export default class SharedMindMapController { private layoutManager: SharedMindmapLayoutManager; constructor(doc: Y.Doc, broker: EventBroker, roomId: MindmapRoomId) { - this.container = new SharedTreeContainer({ doc, broker, roomId }); + this.container = new SharedTreeContainer({ + doc, + broker, + roomId, + onTransaction: (event, origin) => { + this.handleTransaction(event, origin); + }, + }); this.layoutManager = new SharedMindmapLayoutManager({ xGap: 140, yGap: 60 }); - - this.container.onTransaction = (event, origin) => { - this.handleTransaction(event, origin); - }; } private handleTransaction(event: Y.YMapEvent, origin: TransactionOrigin) { @@ -40,13 +43,7 @@ export default class SharedMindMapController { private refreshLayout() { const updates = this.layoutManager.calculateLayout(this.container); - if (updates.size > 0) { - this.container.doc.transact(() => { - updates.forEach((pos, nodeId) => { - this.container.updateNode(nodeId, { x: pos.x, y: pos.y }, "layout"); - }); - }, "layout"); - } + this.container.updateNodes(updates, "layout"); } public addChildNode(parentId: NodeId) { @@ -78,7 +75,7 @@ export default class SharedMindMapController { this.container.updateNode(nodeId, { data: { contents } }, "user_action"); } - // 지금 지원 안함 + // TODO: 이후 추가할예정. 지금 지원 안함 public undo() { // this.container.undoManager.undo(); } diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts index 0ba858fff..f54c1c4a4 100644 --- a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts +++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts @@ -1,7 +1,7 @@ import SharedTreeContainer from "@/features/mindmap/shared_mindmap/utils/SharedTreeContainer"; import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap"; -import { calcPartitionIndex } from "@/utils/calc_partition"; // 기존 유틸 사용 -import { isSame } from "@/utils/is_same"; // 기존 유틸 사용 +import { calcPartitionIndex } from "@/utils/calc_partition"; +import { isSame } from "@/utils/is_same"; type LayoutConfig = { xGap: number; @@ -10,8 +10,6 @@ type LayoutConfig = { type PartitionDirection = "right" | "left"; -export type LayoutResult = Map; - export default class SharedMindmapLayoutManager { private config: LayoutConfig; private subtreeHeightCache: Map; @@ -21,6 +19,7 @@ export default class SharedMindmapLayoutManager { xGap: 200, yGap: 16, }; + this.config = { ...defaultConfig, ...config }; this.subtreeHeightCache = new Map(); } @@ -44,11 +43,11 @@ export default class SharedMindmapLayoutManager { container: SharedTreeContainer, rootCenterX: number = 0, rootCenterY: number = 0, - ): LayoutResult { + ): Map> { const rootId = container.getRootId(); const rootNode = container.safeGetNode(rootId); - const updates: LayoutResult = new Map(); + const updates: Map> = new Map(); if (!rootNode) { return updates; @@ -141,7 +140,7 @@ export default class SharedMindmapLayoutManager { parentRealX: number; parentRealY: number; direction: PartitionDirection; - updates: LayoutResult; + updates: Map>; }) { if (partition.length === 0) { return; @@ -182,7 +181,7 @@ export default class SharedMindmapLayoutManager { x: number; startY: number; direction: PartitionDirection; - updates: LayoutResult; + updates: Map>; }) { const subtreeHeight = this.getSubTreeHeight(curNode, container); const newNodeY = startY - curNode.height / 2 + subtreeHeight / 2; diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts b/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts index 31ac8f21f..05f86261c 100644 --- a/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts +++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts @@ -23,16 +23,16 @@ export const ROOT_NODE_ID = "root"; const DETACHED_NODE_PARENT_ID = "detached"; export default class SharedTreeContainer { - public undoManager: Y.UndoManager; - public doc: Y.Doc; - public yNodes: Y.Map; + private undoManager: Y.UndoManager; + private doc: Y.Doc; + private yNodes: Y.Map; private cachedNodes: Map; - public broker: EventBroker; + private broker: EventBroker; private isThrowError: boolean; private rootNodeId: NodeId = ROOT_NODE_ID; - public onTransaction?: (event: Y.YMapEvent, origin: TransactionOrigin) => void; + private onTransaction: (event: Y.YMapEvent, origin: TransactionOrigin) => void; constructor({ isThrowError = true, @@ -40,17 +40,20 @@ export default class SharedTreeContainer { broker, doc, roomId, + onTransaction, }: { broker: EventBroker; name?: string; isThrowError?: boolean; doc: Y.Doc; roomId: MindmapRoomId; + onTransaction: (event: Y.YMapEvent, origin: TransactionOrigin) => void; }) { // initialization this.doc = doc; this.broker = broker; this.yNodes = this.doc.getMap(roomId); + this.onTransaction = onTransaction; this.cachedNodes = new Map(); this.yNodes.forEach((value, key) => { @@ -406,6 +409,14 @@ export default class SharedTreeContainer { }, origin); } + public updateNodes(nodes: Map>, origin: TransactionOrigin = "user_action") { + this.doc.transact(() => { + nodes.forEach((value, key) => { + this.updateNode(key, value, "layout"); + }); + }, origin); + } + public setNode(nodeId: NodeId, node: NodeElement, origin: TransactionOrigin = "user_action") { this.doc.transact(() => { this.yNodes.set(nodeId, node); From e35f002252182d8003e64e0f216ddc3fea6d0e91 Mon Sep 17 00:00:00 2001 From: pakxe Date: Fri, 13 Feb 2026 02:08:08 +0900 Subject: [PATCH 23/36] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20origin=20param=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/useNodeResizeObserver.ts | 3 +- .../shared_mindmap/show_cases/ShowCase.tsx | 2 +- .../utils/SharedMindmapController.ts | 71 ++++++++++------ .../utils/SharedMindmapLayoutManager.ts | 2 + .../utils/SharedTreeContainer.ts | 85 ++++++++----------- 5 files changed, 83 insertions(+), 80 deletions(-) diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts index b8de823e4..13c2dda82 100644 --- a/frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts @@ -21,7 +21,6 @@ export function useNodeResizeObserver({ nodeId, node, onResize }: Props) { const rect = nodeRef.current.getBoundingClientRect(); - // 안잘리게 ceil const newWidth = Math.ceil(rect.width); const newHeight = Math.ceil(rect.height); @@ -29,7 +28,7 @@ export function useNodeResizeObserver({ nodeId, node, onResize }: Props) { if (isChanged) { onResize({ height: newHeight, width: newWidth }); } - }, [nodeId]); + }, [nodeId, node?.x, node?.y]); return nodeRef; } diff --git a/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx b/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx index 7d5d9f565..ddf9d1d3b 100644 --- a/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx +++ b/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx @@ -68,7 +68,7 @@ const NodeItem = ({ nodeId, controller, broker }: NodeItemProps) => { onPointerDown={(e) => e.stopPropagation()} // 드래그 방지 > {/* 노드 내용 */} -
+
{node.id}
{node.parentId} diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts index 14b64911e..84659e75d 100644 --- a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts +++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts @@ -1,7 +1,7 @@ import * as Y from "yjs"; import SharedMindmapLayoutManager from "@/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager"; -import SharedTreeContainer, { TransactionOrigin } from "@/features/mindmap/shared_mindmap/utils/SharedTreeContainer"; +import SharedTreeContainer from "@/features/mindmap/shared_mindmap/utils/SharedTreeContainer"; import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap"; import { MindmapRoomId } from "@/features/mindmap/types/mindmap_room"; import { EventBroker } from "@/utils/EventBroker"; @@ -15,64 +15,79 @@ export default class SharedMindMapController { doc, broker, roomId, - onTransaction: (event, origin) => { - this.handleTransaction(event, origin); + onTransaction: (event) => { + this.handleTransaction(event); }, }); this.layoutManager = new SharedMindmapLayoutManager({ xGap: 140, yGap: 60 }); } - private handleTransaction(event: Y.YMapEvent, origin: TransactionOrigin) { - if (origin === "layout") { + private handleTransaction(event: Y.YMapEvent) { + if (event.transaction.local) { return; } - if (!event.transaction.local) { - return; - } - - if (origin === "user_action") { - event.keysChanged.forEach((nodeId) => { - this.layoutManager.invalidate(nodeId, this.container); - }); + event.keysChanged.forEach((nodeId) => { + this.layoutManager.invalidate(nodeId, this.container); + }); - this.refreshLayout(); - } + this.refreshLayout(); } private refreshLayout() { const updates = this.layoutManager.calculateLayout(this.container); - this.container.updateNodes(updates, "layout"); + this.container.updateNodes(updates); } public addChildNode(parentId: NodeId) { - const parent = this.container.safeGetNode(parentId); + this.container.getDoc().transact(() => { + const parent = this.container.safeGetNode(parentId); - if (!parent) { - console.error("Parent node not found"); - return; - } + if (!parent) { + console.error("Parent node not found"); + return; + } - this.container.appendChild({ parentNodeId: parentId }, "user_action"); + this.container.appendChild({ parentNodeId: parentId }); + this.refreshLayout(); + }); } public resetMindMap() { - if (confirm("정말로 모든 내용을 삭제하고 초기화하시겠습니까?")) { - this.container.clear("user_action"); - } + this.container.getDoc().transact(() => { + if (confirm("정말로 모든 내용을 삭제하고 초기화하시겠습니까?")) { + this.container.clear(); + + this.refreshLayout(); + } + }); } public deleteNode(nodeId: NodeId) { - this.container.delete({ nodeId }); + this.container.getDoc().transact(() => { + this.container.delete({ nodeId }); + }); } public updateNodeSize({ nodeId, width, height }: { nodeId: NodeId; width: number; height: number }) { - this.container.updateNode(nodeId, { width, height }, "user_action"); + this.container.getDoc().transact(() => { + this.layoutManager.invalidate(nodeId, this.container); + + this.container.updateNode(nodeId, { width, height }); + + this.refreshLayout(); + }); } public updateNodeContents(nodeId: NodeId, contents: string) { - this.container.updateNode(nodeId, { data: { contents } }, "user_action"); + this.container.getDoc().transact(() => { + this.layoutManager.invalidate(nodeId, this.container); + + this.container.updateNode(nodeId, { data: { contents } }); + + this.refreshLayout(); + }); } // TODO: 이후 추가할예정. 지금 지원 안함 diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts index f54c1c4a4..f020dfb65 100644 --- a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts +++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts @@ -220,6 +220,8 @@ export default class SharedMindmapLayoutManager { return 0; } + console.log(partition); + const totalGap = this.config.yGap * (partition.length - 1); const totalNodesHeight = partition.reduce( (acc, node) => acc + this.getSubTreeHeight(node, container), diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts b/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts index 05f86261c..2e2918259 100644 --- a/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts +++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts @@ -8,13 +8,13 @@ import generateId from "@/utils/generate_id"; // TODO: quadtree 준비되면 의존성 주입 -const TRANSACTION_ORIGINS = { - USER_ACTION: "user_action", - LAYOUT: "layout", - REMOTE: "remote", -} as const; +// const TRANSACTION_ORIGINS = { +// USER_ACTION: "user_action", +// LAYOUT: "layout", +// REMOTE: "remote", +// } as const; -export type TransactionOrigin = (typeof TRANSACTION_ORIGINS)[keyof typeof TRANSACTION_ORIGINS]; +// export type TransactionOrigin = (typeof TRANSACTION_ORIGINS)[keyof typeof TRANSACTION_ORIGINS]; const ROOT_NODE_PARENT_ID = "empty"; const ROOT_NODE_CONTENTS = "김현대의 마인드맵"; @@ -23,7 +23,7 @@ export const ROOT_NODE_ID = "root"; const DETACHED_NODE_PARENT_ID = "detached"; export default class SharedTreeContainer { - private undoManager: Y.UndoManager; + // private undoManager: Y.UndoManager; private doc: Y.Doc; private yNodes: Y.Map; private cachedNodes: Map; @@ -32,7 +32,7 @@ export default class SharedTreeContainer { private isThrowError: boolean; private rootNodeId: NodeId = ROOT_NODE_ID; - private onTransaction: (event: Y.YMapEvent, origin: TransactionOrigin) => void; + private onTransaction: (event: Y.YMapEvent) => void; constructor({ isThrowError = true, @@ -47,7 +47,7 @@ export default class SharedTreeContainer { isThrowError?: boolean; doc: Y.Doc; roomId: MindmapRoomId; - onTransaction: (event: Y.YMapEvent, origin: TransactionOrigin) => void; + onTransaction: (event: Y.YMapEvent) => void; }) { // initialization this.doc = doc; @@ -64,16 +64,14 @@ export default class SharedTreeContainer { this.initRootNode(name); } - this.undoManager = new Y.UndoManager(this.yNodes, { - captureTimeout: 500, - trackedOrigins: new Set([TRANSACTION_ORIGINS.USER_ACTION]), - }); + // this.undoManager = new Y.UndoManager(this.yNodes, { + // captureTimeout: 500, + // trackedOrigins: new Set([TRANSACTION_ORIGINS.USER_ACTION]), + // }); this.isThrowError = isThrowError; this.yNodes.observe((event) => { - const origin = event.transaction.origin as TransactionOrigin; - event.keysChanged.forEach((nodeId) => { const newValue = this.yNodes.get(nodeId); @@ -87,29 +85,24 @@ export default class SharedTreeContainer { }); if (this.onTransaction) { - this.onTransaction(event, origin); + this.onTransaction(event); } }); } private initRootNode(contents: string) { - this.doc.transact(() => { - this.generateNewNodeElement({ - nodeData: { contents }, - id: this.rootNodeId, - type: "root", - }); - }, TRANSACTION_ORIGINS.USER_ACTION); + this.generateNewNodeElement({ + nodeData: { contents }, + id: this.rootNodeId, + type: "root", + }); } public getDoc() { return this.doc; } - appendChild( - { parentNodeId, childNodeId }: { parentNodeId: NodeId; childNodeId?: NodeId }, - origin: TransactionOrigin = "user_action", - ) { + appendChild({ parentNodeId, childNodeId }: { parentNodeId: NodeId; childNodeId?: NodeId }) { this.doc.transact(() => { try { let childNode: NodeElement; @@ -141,7 +134,7 @@ export default class SharedTreeContainer { } catch (e) { this.handleError(e); } - }, origin); + }); } attachTo({ baseNodeId, direction }: { baseNodeId: NodeId; direction: "prev" | "next" | "child" }) { @@ -224,7 +217,7 @@ export default class SharedTreeContainer { } } - delete({ nodeId, origin = "user_action" }: { nodeId: NodeId; origin?: TransactionOrigin }) { + delete({ nodeId }: { nodeId: NodeId }) { this.doc.transact(() => { try { const node = this._getNode(nodeId); @@ -254,7 +247,7 @@ export default class SharedTreeContainer { } catch (e) { this.handleError(e); } - }, origin); + }); } private _deleteTraverse({ nodeId }: { nodeId: NodeId }) { @@ -387,48 +380,42 @@ export default class SharedTreeContainer { this.cachedNodes.set(nodeId, { ...prev, ...patch }); } - public clear(origin: TransactionOrigin = "user_action") { - this.doc.transact(() => { - this.yNodes.clear(); - this.cachedNodes.clear(); + public clear() { + this.yNodes.clear(); + this.cachedNodes.clear(); - this.generateNewNodeElement({ - nodeData: { contents: "새로운 마인드맵" }, - id: this.rootNodeId, - type: "root", - }); - }, origin); + this.initRootNode(""); } - public updateNode(nodeId: NodeId, patch: Partial, origin: TransactionOrigin = "user_action") { + public updateNode(nodeId: NodeId, patch: Partial) { this.doc.transact(() => { const prev = this.cachedNodes.get(nodeId); if (!prev) return; this.yNodes.set(nodeId, { ...prev, ...patch }); this.cachedNodes.set(nodeId, { ...prev, ...patch }); - }, origin); + }); } - public updateNodes(nodes: Map>, origin: TransactionOrigin = "user_action") { + public updateNodes(nodes: Map>) { this.doc.transact(() => { nodes.forEach((value, key) => { - this.updateNode(key, value, "layout"); + this.updateNode(key, value); }); - }, origin); + }); } - public setNode(nodeId: NodeId, node: NodeElement, origin: TransactionOrigin = "user_action") { + public setNode(nodeId: NodeId, node: NodeElement) { this.doc.transact(() => { this.yNodes.set(nodeId, node); this.cachedNodes.set(nodeId, node); - }, origin); + }); } - public deleteNode(nodeId: NodeId, origin: TransactionOrigin = "user_action") { + public deleteNode(nodeId: NodeId) { this.doc.transact(() => { this.yNodes.delete(nodeId); this.cachedNodes.delete(nodeId); - }, origin); + }); } private generateNewNodeElement({ From c2ddbc16c01ce96538731c79228e6ec63492e2f5 Mon Sep 17 00:00:00 2001 From: pakxe Date: Fri, 13 Feb 2026 03:01:59 +0900 Subject: [PATCH 24/36] =?UTF-8?q?chore:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EA=B3=B5=EC=9C=A0=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=9C=84=ED=95=9C=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 4 ++++ frontend/pnpm-lock.yaml | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/frontend/package.json b/frontend/package.json index a6fcbda7e..fb223816f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,9 +16,11 @@ "@tanstack/react-query": "^5.90.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "lodash-es": "^4.17.23", "lucide-react": "^0.563.0", "nanoid": "^5.1.6", "next-themes": "^0.4.6", + "perfect-cursors": "^1.0.5", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -28,11 +30,13 @@ "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "y-indexeddb": "^9.0.12", + "y-protocols": "^1.0.7", "y-websocket": "^3.0.0", "yjs": "^13.6.29" }, "devDependencies": { "@eslint/js": "^9.39.2", + "@types/lodash-es": "^4.17.12", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a09357e28..d78b9e4b8 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + lodash-es: + specifier: ^4.17.23 + version: 4.17.23 lucide-react: specifier: ^0.563.0 version: 0.563.0(react@19.2.3) @@ -29,6 +32,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + perfect-cursors: + specifier: ^1.0.5 + version: 1.0.5 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -56,6 +62,9 @@ importers: y-indexeddb: specifier: ^9.0.12 version: 9.0.12(yjs@13.6.29) + y-protocols: + specifier: ^1.0.7 + version: 1.0.7(yjs@13.6.29) y-websocket: specifier: ^3.0.0 version: 3.0.0(yjs@13.6.29) @@ -66,6 +75,9 @@ importers: '@eslint/js': specifier: ^9.39.2 version: 9.39.2 + '@types/lodash-es': + specifier: ^4.17.12 + version: 4.17.12 '@types/node': specifier: ^24.10.1 version: 24.10.9 @@ -1437,6 +1449,9 @@ packages: peerDependencies: react: ^18 || ^19 + '@tldraw/vec@1.9.2': + resolution: {integrity: sha512-k9vH52MRpJHjVcaahWu6VqvhLeE9h1qL5Z2gLobS9zTMpUJ59kBQPNo0VPzPlDYBpXdS4GxuB4jYQMnKvuPAZg==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1455,6 +1470,12 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.23': + resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + '@types/node@24.10.9': resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==} @@ -2245,6 +2266,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -2372,6 +2396,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + perfect-cursors@1.0.5: + resolution: {integrity: sha512-LgQJj6QtF6VzYODurlhF9Ayt2liiadJZBocK98brcCC6D/dRtZlSU/r0mXWDoCdGPiO24oJB1+PZKz4p9hblWg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4081,6 +4108,8 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 19.2.3 + '@tldraw/vec@1.9.2': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.6 @@ -4106,6 +4135,12 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.23 + + '@types/lodash@4.17.23': {} + '@types/node@24.10.9': dependencies: undici-types: 7.16.0 @@ -5062,6 +5097,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.17.23: {} + lodash.merge@4.6.2: {} loose-envify@1.4.0: @@ -5192,6 +5229,10 @@ snapshots: path-type@4.0.0: {} + perfect-cursors@1.0.5: + dependencies: + '@tldraw/vec': 1.9.2 + picocolors@1.1.1: {} picomatch@4.0.3: {} From 3537b3a7d896c2df3624c92ddc6a858cb0e5fe37 Mon Sep 17 00:00:00 2001 From: pakxe Date: Fri, 13 Feb 2026 03:02:19 +0900 Subject: [PATCH 25/36] =?UTF-8?q?feat:=20=EB=8F=99=EC=8B=9C=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EC=A4=91=EC=9D=B8=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EC=9D=98=20=EC=A0=95=EB=B3=B4,=20=EC=BB=A4=EC=84=9C=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/CollaboratorList.tsx | 19 +++ .../components/CursorsOverlay.tsx | 121 ++++++++++++++++++ .../shared_mindmap/hooks/useCollaborators.ts | 9 ++ .../shared_mindmap/hooks/useCursors.ts | 9 ++ .../shared_mindmap/hooks/useSharedMindmap.ts | 13 +- .../shared_mindmap/show_cases/ShowCase.tsx | 14 +- .../shared_mindmap/types/collaborator.ts | 12 ++ .../utils/CollaboratorManager.ts | 95 ++++++++++++++ 8 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 frontend/src/features/mindmap/shared_mindmap/components/CollaboratorList.tsx create mode 100644 frontend/src/features/mindmap/shared_mindmap/components/CursorsOverlay.tsx create mode 100644 frontend/src/features/mindmap/shared_mindmap/hooks/useCollaborators.ts create mode 100644 frontend/src/features/mindmap/shared_mindmap/hooks/useCursors.ts create mode 100644 frontend/src/features/mindmap/shared_mindmap/types/collaborator.ts create mode 100644 frontend/src/features/mindmap/shared_mindmap/utils/CollaboratorManager.ts diff --git a/frontend/src/features/mindmap/shared_mindmap/components/CollaboratorList.tsx b/frontend/src/features/mindmap/shared_mindmap/components/CollaboratorList.tsx new file mode 100644 index 000000000..cc172a0eb --- /dev/null +++ b/frontend/src/features/mindmap/shared_mindmap/components/CollaboratorList.tsx @@ -0,0 +1,19 @@ +import { useCollaborators } from "@/features/mindmap/shared_mindmap/hooks/useCollaborators"; +import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; + +type Props = { + manager: CollaboratorsManager; +}; +// 1. 목록 컴포넌트 (조용함) +export function CollaboratorList({ manager }: Props) { + const users = useCollaborators(manager); + return ( +
+ {users.map((u) => ( +
+ {u.name.slice(0, 6)} +
+ ))} +
+ ); +} diff --git a/frontend/src/features/mindmap/shared_mindmap/components/CursorsOverlay.tsx b/frontend/src/features/mindmap/shared_mindmap/components/CursorsOverlay.tsx new file mode 100644 index 000000000..42ccd31d6 --- /dev/null +++ b/frontend/src/features/mindmap/shared_mindmap/components/CursorsOverlay.tsx @@ -0,0 +1,121 @@ +import { PerfectCursor } from "perfect-cursors"; +import React, { useLayoutEffect, useRef, useState } from "react"; + +import { useCursors } from "@/features/mindmap/shared_mindmap/hooks/useCursors"; +import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; + +// --- [Sub Component] 개별 커서 (Perfect Cursor 적용) --- +const OtherUserCursor = ({ + point, + color, + name, + pan, +}: { + point: { x: number; y: number }; // World 좌표 + color: string; + name: string; + pan: { x: number; y: number }; +}) => { + const rCursor = useRef(null); + const [pc] = useState( + () => + new PerfectCursor((point) => { + // PerfectCursor가 계산해준 매 프레임의 좌표 (point) + if (rCursor.current) { + // [중요] 여기서 Pan 값을 더해서 화면 좌표로 변환 + // Hooks 안에서 pan 값을 실시간으로 참조하기 위해 ref나 closure 주의 필요 + // 하지만 performant한 애니메이션을 위해 style 직접 수정 + rCursor.current.style.setProperty("transform", `translate(${point[0]}px, ${point[1]}px)`); + } + }), + ); + + // 1. World 좌표가 업데이트되면 PerfectCursor에게 "여기로 가!"라고 알려줌 + useLayoutEffect(() => { + // Pan은 CSS transform 단계에서 더해지므로, PC에는 World 좌표만 줌? + // -> 아님. PC는 궤적 계산기임. + // 화면에 그릴 때 (Pan + PC값)을 해야 함. + // 하지만 PC 콜백 안에서 Pan state를 가져오기 까다로우므로, + // PC에게는 [화면 좌표]를 줘버리는게 가장 부드러움. + + const screenX = point.x + pan.x; + const screenY = point.y + pan.y; + pc.addPoint([screenX, screenY]); + }, [point, pc, pan]); // pan이 바뀌어도 목표 지점이 바뀌는 것이므로 addPoint + + // 2. 컴포넌트 언마운트 시 정리 + React.useEffect(() => { + return () => pc.dispose(); + }, [pc]); + + return ( +
+ + + +
+ {name} +
+
+ ); +}; + +// --- [Main Component] Overlay --- +type Props = { + manager: CollaboratorsManager; + pan: { x: number; y: number }; +}; + +export function CursorOverlay({ manager, pan }: Props) { + const cursors = useCursors(manager); + + return ( +
+ {Array.from(cursors.entries()).map(([userId, cursor]) => ( + + ))} +
+ ); +} diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useCollaborators.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useCollaborators.ts new file mode 100644 index 000000000..6a865d68b --- /dev/null +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useCollaborators.ts @@ -0,0 +1,9 @@ +import { useSyncExternalStore } from "react"; + +import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; + +// hooks/useCollaborators.ts (기존) +export function useCollaborators(manager: CollaboratorsManager) { + // collaboratorsCache 참조가 바뀔 때만 리렌더링 발생 + return useSyncExternalStore(manager.subscribe, manager.getCollaboratorsSnapshot); +} diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useCursors.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useCursors.ts new file mode 100644 index 000000000..23b81eeeb --- /dev/null +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useCursors.ts @@ -0,0 +1,9 @@ +import { useSyncExternalStore } from "react"; + +import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; + +// hooks/useCursors.ts (신규) +export function useCursors(manager: CollaboratorsManager) { + // cursorsCache 참조가 바뀔 때(마우스 움직일 때)마다 리렌더링 발생 + return useSyncExternalStore(manager.subscribe, manager.getCursorsSnapshot); +} diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts index 1fb14ca2f..f3b6c7b51 100644 --- a/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts @@ -3,10 +3,12 @@ import { WebsocketProvider } from "y-websocket"; import * as Y from "yjs"; import { ENV } from "@/constants/env"; +import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; import SharedMindMapController from "@/features/mindmap/shared_mindmap/utils/SharedMindmapController"; import { NodeId } from "@/features/mindmap/types/mindmap"; import { MindmapRoomId } from "@/features/mindmap/types/mindmap_room"; import { EventBroker } from "@/utils/EventBroker"; +import generateId from "@/utils/generate_id"; type ConnectionStatus = "disconnected" | "connecting" | "connected"; @@ -20,14 +22,19 @@ type UseSharedMindmapProps = { export const useSharedMindmap = ({ roomId }: UseSharedMindmapProps) => { const [connectionStatus, setConnectionStatus] = useState("disconnected"); - const { controller, provider, broker } = useMemo(() => { + const { controller, provider, broker, collaboratorsManager } = useMemo(() => { const doc = new Y.Doc(); const provider = new WebsocketProvider(ENV.WS_BASE_URL, roomId, doc); const broker = new EventBroker(); const controller = new SharedMindMapController(doc, broker, roomId); + const n = generateId(); + const collaboratorsManager = new CollaboratorsManager({ + provider, + userInfo: { id: n, name: n, color: "#ff3421" }, + }); - return { controller, provider, broker }; + return { controller, provider, broker, collaboratorsManager }; }, [roomId]); useEffect(() => { @@ -54,6 +61,8 @@ export const useSharedMindmap = ({ roomId }: UseSharedMindmapProps) => { broker, + collaboratorsManager, + connectionStatus, }; }; diff --git a/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx b/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx index ddf9d1d3b..432a2df57 100644 --- a/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx +++ b/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx @@ -1,8 +1,11 @@ import { useEffect, useRef, useState } from "react"; +import { CollaboratorList } from "@/features/mindmap/shared_mindmap/components/CollaboratorList"; +import { CursorOverlay } from "@/features/mindmap/shared_mindmap/components/CursorsOverlay"; import { useNode } from "@/features/mindmap/shared_mindmap/hooks/useNode"; import { useNodeResizeObserver } from "@/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver"; import { useSharedMindmap } from "@/features/mindmap/shared_mindmap/hooks/useSharedMindmap"; +import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; import SharedMindMapController from "@/features/mindmap/shared_mindmap/utils/SharedMindmapController"; import { NodeId } from "@/features/mindmap/types/mindmap"; import { EventBroker } from "@/utils/EventBroker"; @@ -107,7 +110,7 @@ const btnStyle: React.CSSProperties = { const DUMMY_ROOM_ID = "ㅁ"; export default function MindmapShowcaseV3() { - const { controller, connectionStatus, broker } = useSharedMindmap({ roomId: DUMMY_ROOM_ID }); + const { controller, connectionStatus, broker, collaboratorsManager } = useSharedMindmap({ roomId: DUMMY_ROOM_ID }); // 2. Canvas Panning State const [pan, setPan] = useState({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); @@ -143,6 +146,13 @@ export default function MindmapShowcaseV3() { }; const handlePointerMove = (e: React.PointerEvent) => { + // [핵심 수정] 화면 좌표에서 Pan을 빼서 "World 좌표"로 변환하여 전송 + const worldX = e.clientX - pan.x; + const worldY = e.clientY - pan.y; + + collaboratorsManager.updateCursor(worldX, worldY); + + // 2. Panning 로직 (기존 동일) if (!isPanning) return; e.preventDefault(); const dx = e.clientX - lastPos.current.x; @@ -173,6 +183,8 @@ export default function MindmapShowcaseV3() { > 초기화 (Reset) + +
; diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/CollaboratorManager.ts b/frontend/src/features/mindmap/shared_mindmap/utils/CollaboratorManager.ts new file mode 100644 index 000000000..1f247ac42 --- /dev/null +++ b/frontend/src/features/mindmap/shared_mindmap/utils/CollaboratorManager.ts @@ -0,0 +1,95 @@ +import { throttle } from "lodash-es"; +import { Awareness } from "y-protocols/awareness"; +import { WebsocketProvider } from "y-websocket"; + +import { CursorMap, UserProfile } from "@/features/mindmap/shared_mindmap/types/collaborator"; + +export default class CollaboratorsManager { + private awareness: Awareness; + private localUser: UserProfile; + + private collaboratorsCache: UserProfile[] = []; + private cursorsCache: CursorMap = new Map(); + + constructor({ provider, userInfo }: { provider: WebsocketProvider; userInfo: UserProfile }) { + this.awareness = provider.awareness; + this.localUser = userInfo; + + this.setLocalState(); + + if (typeof window !== "undefined") { + window.addEventListener("beforeunload", () => this.destroy()); + } + + // 초기화 + this.processAwarenessData(); + } + + private setLocalState() { + this.awareness.setLocalState({ + user: this.localUser, + cursor: null, + }); + } + + public updateCursor = throttle((x: number, y: number) => { + this.awareness.setLocalStateField("cursor", { x, y }); + }, 80); + + private processAwarenessData() { + const states = this.awareness.getStates(); + + const newCollaborators: UserProfile[] = []; + const newCursors: CursorMap = new Map(); + + const isCollaboratorsChanged = false; + + states.forEach((state: any, clientId: number) => { + if (!state.user) return; + + newCollaborators.push(state.user); + + if (clientId !== this.awareness.clientID && state.cursor) { + newCursors.set(state.user.id, { + x: state.cursor.x, + y: state.cursor.y, + color: state.user.color, + name: state.user.name, + }); + } + }); + + // [최적화 핵심 1] 유저 목록이 실제로 변했을 때만 캐시 교체 (깊은 비교 또는 길이/ID 비교) + // 여기서는 간단히 JSON 문자열 비교 예시 (실제론 더 효율적인 비교 권장) + newCollaborators.sort((a, b) => a.id.localeCompare(b.id)); // 순서 보장 + if (JSON.stringify(this.collaboratorsCache) !== JSON.stringify(newCollaborators)) { + this.collaboratorsCache = newCollaborators; + } + + this.cursorsCache = newCursors; + } + + subscribe = (callback: () => void) => { + const handler = () => { + this.processAwarenessData(); + callback(); + }; + this.awareness.on("change", handler); + return () => this.awareness.off("change", handler); + }; + + // 1. 유저 목록 스냅샷 + getCollaboratorsSnapshot = () => { + return this.collaboratorsCache; + }; + + // 2. 커서 스냅샷 + getCursorsSnapshot = () => { + return this.cursorsCache; + }; + + destroy() { + this.updateCursor.cancel(); // throttle 취소 + this.awareness.setLocalState(null); + } +} From e3108911b57ddf57d9b6981dd4e39118fa1a0c6d Mon Sep 17 00:00:00 2001 From: pakxe Date: Fri, 13 Feb 2026 11:14:41 +0900 Subject: [PATCH 26/36] =?UTF-8?q?chore:=20=EB=8D=B0=EB=AA=A8=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/CollaboratorList.tsx | 29 ++- .../components/CursorsOverlay.tsx | 176 +++++++++------- .../shared_mindmap/hooks/useSharedMindmap.ts | 30 ++- .../shared_mindmap/show_cases/ShowCase.tsx | 195 ++++++++++-------- .../utils/SharedMindmapController.ts | 6 +- .../utils/SharedMindmapLayoutManager.ts | 2 - .../src/shared/components/list/ListRow.tsx | 2 +- .../components/profile_icon/ProfileIcon.tsx | 2 +- 8 files changed, 252 insertions(+), 190 deletions(-) diff --git a/frontend/src/features/mindmap/shared_mindmap/components/CollaboratorList.tsx b/frontend/src/features/mindmap/shared_mindmap/components/CollaboratorList.tsx index cc172a0eb..a1d7062fc 100644 --- a/frontend/src/features/mindmap/shared_mindmap/components/CollaboratorList.tsx +++ b/frontend/src/features/mindmap/shared_mindmap/components/CollaboratorList.tsx @@ -1,5 +1,9 @@ import { useCollaborators } from "@/features/mindmap/shared_mindmap/hooks/useCollaborators"; import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; +import Icon from "@/shared/components/icon/Icon"; +import List from "@/shared/components/list/List"; +import ListRow from "@/shared/components/list/ListRow"; +import ProfileIcon from "@/shared/components/profile_icon/ProfileIcon"; type Props = { manager: CollaboratorsManager; @@ -8,12 +12,23 @@ type Props = { export function CollaboratorList({ manager }: Props) { const users = useCollaborators(manager); return ( -
- {users.map((u) => ( -
- {u.name.slice(0, 6)} -
- ))} -
+ + } + > + +
+ {users.map((u) => ( + } + contents={u.name} + > + ))} +
+
); } diff --git a/frontend/src/features/mindmap/shared_mindmap/components/CursorsOverlay.tsx b/frontend/src/features/mindmap/shared_mindmap/components/CursorsOverlay.tsx index 42ccd31d6..808c7cfca 100644 --- a/frontend/src/features/mindmap/shared_mindmap/components/CursorsOverlay.tsx +++ b/frontend/src/features/mindmap/shared_mindmap/components/CursorsOverlay.tsx @@ -5,45 +5,103 @@ import { useCursors } from "@/features/mindmap/shared_mindmap/hooks/useCursors"; import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; // --- [Sub Component] 개별 커서 (Perfect Cursor 적용) --- +const COLOR_MAP = [ + { color: "blue", text: "text-cursor-blue", bg: "bg-cursor-blue" }, + { color: "pink", text: "text-cursor-pink", bg: "bg-cursor-pink" }, + { color: "green", text: "text-cursor-green", bg: "bg-cursor-green" }, + { color: "purple", text: "text-cursor-purple", bg: "bg-cursor-purple" }, + { color: "orange", text: "text-cursor-orange", bg: "bg-cursor-orange" }, +]; + +// 간단하고 안정적인 32bit 해시 (FNV-1a) +function hashString(str: string): number { + let hash = 0x811c9dc5; + + for (let i = 0; i < str.length; i++) { + hash ^= str.charCodeAt(i); + hash = (hash * 0x01000193) >>> 0; + } + + return hash; +} + +function getColorFromName(name: string) { + const hash = hashString(name); + const index = hash % COLOR_MAP.length; + + return COLOR_MAP[index]!; +} +// --- [Main Component] Overlay --- +type Props = { + manager: CollaboratorsManager; + pan: { x: number; y: number }; + scale: number; // scale 추가 +}; + +export function CursorOverlay({ manager, pan, scale }: Props) { + const cursors = useCursors(manager); + + return ( +
+ {Array.from(cursors.entries()).map(([userId, cursor]) => ( + + ))} +
+ ); +} + +// --- [Sub Component] 개별 커서 --- const OtherUserCursor = ({ point, - color, name, pan, + scale, // scale 파라미터 추가 }: { - point: { x: number; y: number }; // World 좌표 - color: string; + point: { x: number; y: number }; name: string; pan: { x: number; y: number }; + scale: number; }) => { const rCursor = useRef(null); + const color = React.useMemo(() => getColorFromName(name), [name]); + const [pc] = useState( () => - new PerfectCursor((point) => { - // PerfectCursor가 계산해준 매 프레임의 좌표 (point) + new PerfectCursor((p) => { if (rCursor.current) { - // [중요] 여기서 Pan 값을 더해서 화면 좌표로 변환 - // Hooks 안에서 pan 값을 실시간으로 참조하기 위해 ref나 closure 주의 필요 - // 하지만 performant한 애니메이션을 위해 style 직접 수정 - rCursor.current.style.setProperty("transform", `translate(${point[0]}px, ${point[1]}px)`); + // 계산된 최종 화면 좌표로 이동 + rCursor.current.style.setProperty("transform", `translate(${p[0]}px, ${p[1]}px)`); } }), ); - // 1. World 좌표가 업데이트되면 PerfectCursor에게 "여기로 가!"라고 알려줌 useLayoutEffect(() => { - // Pan은 CSS transform 단계에서 더해지므로, PC에는 World 좌표만 줌? - // -> 아님. PC는 궤적 계산기임. - // 화면에 그릴 때 (Pan + PC값)을 해야 함. - // 하지만 PC 콜백 안에서 Pan state를 가져오기 까다로우므로, - // PC에게는 [화면 좌표]를 줘버리는게 가장 부드러움. + // [핵심 로직] + // 1. World 좌표(point)에 현재 배율(scale)을 곱합니다. + // 2. 그 결과에 Pan 값을 더해 최종 화면(Screen) 위치를 구합니다. + const screenX = point.x * scale + pan.x; + const screenY = point.y * scale + pan.y; - const screenX = point.x + pan.x; - const screenY = point.y + pan.y; pc.addPoint([screenX, screenY]); - }, [point, pc, pan]); // pan이 바뀌어도 목표 지점이 바뀌는 것이므로 addPoint + }, [point, pc, pan, scale]); // scale이 바뀔 때도 위치를 재계산해야 함 - // 2. 컴포넌트 언마운트 시 정리 React.useEffect(() => { return () => pc.dispose(); }, [pc]); @@ -55,67 +113,37 @@ const OtherUserCursor = ({ position: "absolute", top: 0, left: 0, - width: "12px", - height: "12px", pointerEvents: "none", zIndex: 9999, - // transition 제거! (PerfectCursor가 JS로 매 프레임 움직여줌) willChange: "transform", }} > - - - -
- {name} + {/* 커서 아이콘: 스타일은 className이나 style로 적용 */} +
+ + + + {/* 유저 이름 라벨 */} +
+ {name} +
); }; - -// --- [Main Component] Overlay --- -type Props = { - manager: CollaboratorsManager; - pan: { x: number; y: number }; -}; - -export function CursorOverlay({ manager, pan }: Props) { - const cursors = useCursors(manager); - - return ( -
- {Array.from(cursors.entries()).map(([userId, cursor]) => ( - - ))} -
- ); -} diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts index f3b6c7b51..8cc6544c8 100644 --- a/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router"; import { WebsocketProvider } from "y-websocket"; import * as Y from "yjs"; @@ -16,26 +17,37 @@ type UseSharedMindmapProps = { roomId: MindmapRoomId; }; -/** - * 동시편집을 위해 웹소켓을 연결합니다. - */ export const useSharedMindmap = ({ roomId }: UseSharedMindmapProps) => { const [connectionStatus, setConnectionStatus] = useState("disconnected"); + const [searchParams] = useSearchParams(); + + const [userId] = useState(() => generateId()); + + const userName = useMemo(() => { + return searchParams.get("name") || `Guest-${userId.slice(0, 4)}`; + }, [searchParams, userId]); + const { controller, provider, broker, collaboratorsManager } = useMemo(() => { const doc = new Y.Doc(); - const provider = new WebsocketProvider(ENV.WS_BASE_URL, roomId, doc); const broker = new EventBroker(); const controller = new SharedMindMapController(doc, broker, roomId); - const n = generateId(); + + const userId = generateId(); const collaboratorsManager = new CollaboratorsManager({ provider, - userInfo: { id: n, name: n, color: "#ff3421" }, + userInfo: { + id: userId, + name: userName, + color: "#ff3421", + }, }); return { controller, provider, broker, collaboratorsManager }; - }, [roomId]); + + // userName이 바뀌면 새로운 유저 세션으로 간주하고 다시 생성합니다. + }, [roomId, userName]); useEffect(() => { if (!provider.shouldConnect) { @@ -56,13 +68,9 @@ export const useSharedMindmap = ({ roomId }: UseSharedMindmapProps) => { return { controller, - container: controller.container, - broker, - collaboratorsManager, - connectionStatus, }; }; diff --git a/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx b/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx index 432a2df57..17e159d05 100644 --- a/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx +++ b/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { CollaboratorList } from "@/features/mindmap/shared_mindmap/components/CollaboratorList"; import { CursorOverlay } from "@/features/mindmap/shared_mindmap/components/CursorsOverlay"; @@ -32,7 +32,7 @@ const NodeItem = ({ nodeId, controller, broker }: NodeItemProps) => { if (!node) return null; const handleAddChild = (e: React.MouseEvent) => { - e.stopPropagation(); // 캔버스 Pan 방지 + e.stopPropagation(); controller.addChildNode(nodeId); }; @@ -43,13 +43,10 @@ const NodeItem = ({ nodeId, controller, broker }: NodeItemProps) => { } }; - // --- Style --- const style: React.CSSProperties = { position: "absolute", - left: node.x, // Controller가 계산해준 좌표 사용 + left: node.x, top: node.y, - - // 스타일링 minWidth: "100px", minHeight: "40px", backgroundColor: node.type === "root" ? "#FFB74D" : "#FFFFFF", @@ -57,28 +54,25 @@ const NodeItem = ({ nodeId, controller, broker }: NodeItemProps) => { flexDirection: "column", alignItems: "center", justifyContent: "center", - transition: "left 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), top 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)", // 부드러운 애니메이션 + transition: "left 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), top 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)", zIndex: node.type === "root" ? 10 : 1, whiteSpace: "nowrap", padding: "20px", + border: "1px solid #ddd", // 가시성을 위해 살짝 추가 + borderRadius: "8px", }; return ( <> -
e.stopPropagation()} // 드래그 방지 - > - {/* 노드 내용 */} -
+
e.stopPropagation()}> +
{node.id}
- {node.parentId} -
({node.x}, {node.y}) + + Parent: {node.parentId || "None"}
({Math.round(node.x)}, {Math.round(node.y)}) +
- {/* 컨트롤 버튼 */}
- {/* 자식 노드 재귀 렌더링 */} + {/* 3. 자식 노드 렌더링 시 controller.container를 직접 참조하므로 + 이미 등록된 자식 리스트가 변경될 때만 리렌더링됩니다. */} {controller.container.getChildIds(nodeId).map((childId) => ( ))} ); }; +// (prevProps, nextProps) => { +// // 4. props 비교 로직 (선택 사항) +// // controller와 broker는 인스턴스이므로 보통 참조가 유지됩니다. +// // nodeId가 같다면 굳이 다시 그릴 필요가 없습니다. +// return prevProps.nodeId === nextProps.nodeId && prevProps.controller === nextProps.controller; +// }, +// 표시 이름 설정 (디버깅 용이) + +NodeItem.displayName = "NodeItem"; const btnStyle: React.CSSProperties = { cursor: "pointer", @@ -109,35 +113,50 @@ const btnStyle: React.CSSProperties = { }; const DUMMY_ROOM_ID = "ㅁ"; + +// ... (NodeItem 컴포넌트와 btnStyle은 기존과 동일하므로 생략) + export default function MindmapShowcaseV3() { - const { controller, connectionStatus, broker, collaboratorsManager } = useSharedMindmap({ roomId: DUMMY_ROOM_ID }); + const { controller, broker, collaboratorsManager } = useSharedMindmap({ roomId: DUMMY_ROOM_ID }); - // 2. Canvas Panning State + // 1. Zoom & Pan State const [pan, setPan] = useState({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); + const [scale, setScale] = useState(1); const [isPanning, setIsPanning] = useState(false); const lastPos = useRef({ x: 0, y: 0 }); - // 3. Keyboard Shortcuts (Undo/Redo) + // [추가] 컨테이너 참조를 위한 Ref + const containerRef = useRef(null); + + // 2. Zoom Handler (useEffect를 이용한 수동 등록) useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (["INPUT", "TEXTAREA"].includes((e.target as HTMLElement).tagName)) return; + const handleWheelNative = (e: WheelEvent) => { + // 브라우저의 기본 스크롤 동작을 방지 (passive: false 설정 덕분에 에러가 나지 않음) + e.preventDefault(); - const isCtrl = e.ctrlKey || e.metaKey; + const zoomSpeed = 0.001; + const delta = -e.deltaY; - if (isCtrl && e.key === "z" && !e.shiftKey) { - e.preventDefault(); - controller.undo(); // Controller에게 위임 - } - if ((isCtrl && e.key === "y") || (isCtrl && e.key === "z" && e.shiftKey)) { - e.preventDefault(); - controller.redo(); // Controller에게 위임 + setScale((prevScale) => { + const newScale = Math.min(Math.max(prevScale + delta * zoomSpeed, 0.2), 3); + return newScale; + }); + }; + + const container = containerRef.current; + if (container) { + // passive: false를 명시하여 preventDefault() 허용 + container.addEventListener("wheel", handleWheelNative, { passive: false }); + } + + return () => { + if (container) { + container.removeEventListener("wheel", handleWheelNative); } }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [controller]); + }, []); // scale 의존성을 제거하기 위해 setScale 내부에서 함수형 업데이트 사용 - // --- Panning Handlers --- + // 3. Panning Handlers const handlePointerDown = (e: React.PointerEvent) => { if (e.button !== 0) return; (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); @@ -146,17 +165,16 @@ export default function MindmapShowcaseV3() { }; const handlePointerMove = (e: React.PointerEvent) => { - // [핵심 수정] 화면 좌표에서 Pan을 빼서 "World 좌표"로 변환하여 전송 - const worldX = e.clientX - pan.x; - const worldY = e.clientY - pan.y; + const worldX = (e.clientX - pan.x) / scale; + const worldY = (e.clientY - pan.y) / scale; collaboratorsManager.updateCursor(worldX, worldY); - // 2. Panning 로직 (기존 동일) if (!isPanning) return; - e.preventDefault(); + const dx = e.clientX - lastPos.current.x; const dy = e.clientY - lastPos.current.y; + setPan((prev) => ({ x: prev.x + dx, y: prev.y + dy })); lastPos.current = { x: e.clientX, y: e.clientY }; }; @@ -168,24 +186,40 @@ export default function MindmapShowcaseV3() { return ( <> - - + {/* UI 컨트롤러 */} +
+
+ Zoom: {Math.round(scale * 100)}% +
+ + +
+ + +
- {/* Status Indicator */} -
-
- - - Status: {connectionStatus} - -
- - {/* Canvas Content */}
- {/* Root Node 렌더링 시작 */}
); } + +const topBtnStyle: React.CSSProperties = { + padding: "8px 16px", + backgroundColor: "#4A90E2", + color: "white", + border: "none", + borderRadius: "8px", + cursor: "pointer", + fontWeight: "bold", + boxShadow: "0 2px 5px rgba(0,0,0,0.2)", +}; diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts index 84659e75d..4993eee1e 100644 --- a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts +++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts @@ -19,7 +19,7 @@ export default class SharedMindMapController { this.handleTransaction(event); }, }); - this.layoutManager = new SharedMindmapLayoutManager({ xGap: 140, yGap: 60 }); + this.layoutManager = new SharedMindmapLayoutManager({ xGap: 300, yGap: 100 }); } private handleTransaction(event: Y.YMapEvent) { @@ -31,7 +31,7 @@ export default class SharedMindMapController { this.layoutManager.invalidate(nodeId, this.container); }); - this.refreshLayout(); + // this.refreshLayout(); } private refreshLayout() { @@ -50,7 +50,7 @@ export default class SharedMindMapController { } this.container.appendChild({ parentNodeId: parentId }); - this.refreshLayout(); + // this.refreshLayout(); }); } diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts index f020dfb65..f54c1c4a4 100644 --- a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts +++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts @@ -220,8 +220,6 @@ export default class SharedMindmapLayoutManager { return 0; } - console.log(partition); - const totalGap = this.config.yGap * (partition.length - 1); const totalNodesHeight = partition.reduce( (acc, node) => acc + this.getSubTreeHeight(node, container), diff --git a/frontend/src/shared/components/list/ListRow.tsx b/frontend/src/shared/components/list/ListRow.tsx index 8efd6e7ab..fdf5e0baa 100644 --- a/frontend/src/shared/components/list/ListRow.tsx +++ b/frontend/src/shared/components/list/ListRow.tsx @@ -14,7 +14,7 @@ const ListRow = ({ variant = "default", leftSlot, contents, rightSlot, className return (
  • Date: Fri, 13 Feb 2026 11:16:37 +0900 Subject: [PATCH 27/36] chore: routing showcase --- frontend/src/App.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 14388f3d3..b8b0354e3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import EpisodeArchivePage from "@/features/episode_archive/pages/EpisodeArchiveP import HomePage from "@/features/home/pages/HomePage"; import LandingPage from "@/features/landing/pages/LandingPage"; import MindmapPage from "@/features/mindmap/pages/MindmapPage"; +import MindmapShowcaseV3 from "@/features/mindmap/shared_mindmap/show_cases/ShowCase"; import SelfDiagnosisPage from "@/features/self_diagnosis/pages/SelfDiagnosisPage"; import LoginPage from "@/features/user/login/pages/LoginPages"; import { Toaster } from "@/shared/components/ui/sonner"; @@ -55,6 +56,10 @@ const router = createBrowserRouter([ path: routeHelper.login(), element: , }, + { + path: "/showcase", + element: , + }, ]); function App() { From f08aeceeafe4f347b2a0fb2c549f18d3e8ff15cf Mon Sep 17 00:00:00 2001 From: pakxe Date: Fri, 13 Feb 2026 11:18:06 +0900 Subject: [PATCH 28/36] =?UTF-8?q?chore:=20import=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/mindmap/shared_mindmap/show_cases/ShowCase.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx b/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx index 17e159d05..4c25ab6e6 100644 --- a/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx +++ b/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx @@ -1,11 +1,10 @@ -import { memo, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { CollaboratorList } from "@/features/mindmap/shared_mindmap/components/CollaboratorList"; import { CursorOverlay } from "@/features/mindmap/shared_mindmap/components/CursorsOverlay"; import { useNode } from "@/features/mindmap/shared_mindmap/hooks/useNode"; import { useNodeResizeObserver } from "@/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver"; import { useSharedMindmap } from "@/features/mindmap/shared_mindmap/hooks/useSharedMindmap"; -import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; import SharedMindMapController from "@/features/mindmap/shared_mindmap/utils/SharedMindmapController"; import { NodeId } from "@/features/mindmap/types/mindmap"; import { EventBroker } from "@/utils/EventBroker"; From bda323ff3457c313b5cb7423831bece6d7104db9 Mon Sep 17 00:00:00 2001 From: pakxe Date: Sat, 14 Feb 2026 19:59:01 +0900 Subject: [PATCH 29/36] =?UTF-8?q?feat:=20=EB=8F=99=EC=8B=9C=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EC=9E=90=20=EB=AA=A9=EB=A1=9D=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mindmap/shared_mindmap/components/CollaboratorList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/mindmap/shared_mindmap/components/CollaboratorList.tsx b/frontend/src/features/mindmap/shared_mindmap/components/CollaboratorList.tsx index a1d7062fc..79f0ab878 100644 --- a/frontend/src/features/mindmap/shared_mindmap/components/CollaboratorList.tsx +++ b/frontend/src/features/mindmap/shared_mindmap/components/CollaboratorList.tsx @@ -1,5 +1,5 @@ import { useCollaborators } from "@/features/mindmap/shared_mindmap/hooks/useCollaborators"; -import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; +import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorsManager"; import Icon from "@/shared/components/icon/Icon"; import List from "@/shared/components/list/List"; import ListRow from "@/shared/components/list/ListRow"; @@ -8,7 +8,7 @@ import ProfileIcon from "@/shared/components/profile_icon/ProfileIcon"; type Props = { manager: CollaboratorsManager; }; -// 1. 목록 컴포넌트 (조용함) + export function CollaboratorList({ manager }: Props) { const users = useCollaborators(manager); return ( From 673d80e884198db4be5a6f05564da0e163045eda Mon Sep 17 00:00:00 2001 From: pakxe Date: Sat, 14 Feb 2026 19:59:35 +0900 Subject: [PATCH 30/36] =?UTF-8?q?chore:=20import=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/mindmap/providers/MindmapProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/features/mindmap/providers/MindmapProvider.tsx b/frontend/src/features/mindmap/providers/MindmapProvider.tsx index b54fc5f3f..b3ff90ecb 100644 --- a/frontend/src/features/mindmap/providers/MindmapProvider.tsx +++ b/frontend/src/features/mindmap/providers/MindmapProvider.tsx @@ -5,7 +5,7 @@ */ import { createContext, useCallback, useContext, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { NodeId } from "@/features/mindmap/types/mindmap"; +import { NodeId } from "@/features/mindmap/types/mindmap_node"; import MindmapLayoutManager from "@/features/mindmap/utils/MindmapLayoutManager"; import TreeContainer from "@/features/mindmap/utils/TreeContainer"; import { EventBroker } from "@/utils/EventBroker"; From ce52c9f67269ffef308db3e8616459c7d8804ef8 Mon Sep 17 00:00:00 2001 From: pakxe Date: Sat, 14 Feb 2026 20:00:28 +0900 Subject: [PATCH 31/36] =?UTF-8?q?feat:=20Cursors=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/CursorsOverlay.tsx | 141 ++++++++---------- 1 file changed, 59 insertions(+), 82 deletions(-) diff --git a/frontend/src/features/mindmap/shared_mindmap/components/CursorsOverlay.tsx b/frontend/src/features/mindmap/shared_mindmap/components/CursorsOverlay.tsx index 808c7cfca..7c2d46fdb 100644 --- a/frontend/src/features/mindmap/shared_mindmap/components/CursorsOverlay.tsx +++ b/frontend/src/features/mindmap/shared_mindmap/components/CursorsOverlay.tsx @@ -1,10 +1,8 @@ -import { PerfectCursor } from "perfect-cursors"; -import React, { useLayoutEffect, useRef, useState } from "react"; +import React from "react"; import { useCursors } from "@/features/mindmap/shared_mindmap/hooks/useCursors"; -import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; +import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorsManager"; -// --- [Sub Component] 개별 커서 (Perfect Cursor 적용) --- const COLOR_MAP = [ { color: "blue", text: "text-cursor-blue", bg: "bg-cursor-blue" }, { color: "pink", text: "text-cursor-pink", bg: "bg-cursor-pink" }, @@ -13,7 +11,6 @@ const COLOR_MAP = [ { color: "orange", text: "text-cursor-orange", bg: "bg-cursor-orange" }, ]; -// 간단하고 안정적인 32bit 해시 (FNV-1a) function hashString(str: string): number { let hash = 0x811c9dc5; @@ -31,11 +28,11 @@ function getColorFromName(name: string) { return COLOR_MAP[index]!; } -// --- [Main Component] Overlay --- + type Props = { manager: CollaboratorsManager; pan: { x: number; y: number }; - scale: number; // scale 추가 + scale: number; }; export function CursorOverlay({ manager, pan, scale }: Props) { @@ -60,90 +57,70 @@ export function CursorOverlay({ manager, pan, scale }: Props) { point={{ x: cursor.x, y: cursor.y }} name={cursor.name} pan={pan} - scale={scale} // scale 전달 + scale={scale} /> ))}
  • ); } -// --- [Sub Component] 개별 커서 --- -const OtherUserCursor = ({ - point, - name, - pan, - scale, // scale 파라미터 추가 -}: { - point: { x: number; y: number }; - name: string; - pan: { x: number; y: number }; - scale: number; -}) => { - const rCursor = useRef(null); - const color = React.useMemo(() => getColorFromName(name), [name]); - - const [pc] = useState( - () => - new PerfectCursor((p) => { - if (rCursor.current) { - // 계산된 최종 화면 좌표로 이동 - rCursor.current.style.setProperty("transform", `translate(${p[0]}px, ${p[1]}px)`); - } - }), - ); +const OtherUserCursor = React.memo( + ({ + point, + name, + pan, + scale, + }: { + point: { x: number; y: number }; + name: string; + pan: { x: number; y: number }; + scale: number; + }) => { + const color = React.useMemo(() => getColorFromName(name), [name]); - useLayoutEffect(() => { - // [핵심 로직] - // 1. World 좌표(point)에 현재 배율(scale)을 곱합니다. - // 2. 그 결과에 Pan 값을 더해 최종 화면(Screen) 위치를 구합니다. const screenX = point.x * scale + pan.x; const screenY = point.y * scale + pan.y; - pc.addPoint([screenX, screenY]); - }, [point, pc, pan, scale]); // scale이 바뀔 때도 위치를 재계산해야 함 - - React.useEffect(() => { - return () => pc.dispose(); - }, [pc]); - - return ( -
    - {/* 커서 아이콘: 스타일은 className이나 style로 적용 */} -
    - - - - {/* 유저 이름 라벨 */} -
    - {name} + return ( +
    +
    + + + +
    + {name} +
    -
    - ); -}; + ); + }, +); + +OtherUserCursor.displayName = "OtherUserCursor"; From 1fa78ab006351187f5424d758674e3d558c68f28 Mon Sep 17 00:00:00 2001 From: pakxe Date: Sat, 14 Feb 2026 20:01:14 +0900 Subject: [PATCH 32/36] =?UTF-8?q?feat:=20=EC=BB=A4=EC=84=9C=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0,=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B3=B5=EC=9C=A0?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=ED=9B=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/mindmap/shared_mindmap/hooks/useCollaborators.ts | 4 +--- .../src/features/mindmap/shared_mindmap/hooks/useCursors.ts | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useCollaborators.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useCollaborators.ts index 6a865d68b..a45afef3d 100644 --- a/frontend/src/features/mindmap/shared_mindmap/hooks/useCollaborators.ts +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useCollaborators.ts @@ -1,9 +1,7 @@ import { useSyncExternalStore } from "react"; -import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; +import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorsManager"; -// hooks/useCollaborators.ts (기존) export function useCollaborators(manager: CollaboratorsManager) { - // collaboratorsCache 참조가 바뀔 때만 리렌더링 발생 return useSyncExternalStore(manager.subscribe, manager.getCollaboratorsSnapshot); } diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useCursors.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useCursors.ts index 23b81eeeb..43649a003 100644 --- a/frontend/src/features/mindmap/shared_mindmap/hooks/useCursors.ts +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useCursors.ts @@ -1,9 +1,7 @@ import { useSyncExternalStore } from "react"; -import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; +import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorsManager"; -// hooks/useCursors.ts (신규) export function useCursors(manager: CollaboratorsManager) { - // cursorsCache 참조가 바뀔 때(마우스 움직일 때)마다 리렌더링 발생 return useSyncExternalStore(manager.subscribe, manager.getCursorsSnapshot); } From f6a3d8b50dd2a95843117f3cb9f028841eba50ca Mon Sep 17 00:00:00 2001 From: pakxe Date: Sat, 14 Feb 2026 20:01:36 +0900 Subject: [PATCH 33/36] =?UTF-8?q?chore:=20import=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/mindmap/shared_mindmap/hooks/useNode.ts | 2 +- .../mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts | 2 +- .../features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useNode.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useNode.ts index f5326a4e2..2c559ab6d 100644 --- a/frontend/src/features/mindmap/shared_mindmap/hooks/useNode.ts +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useNode.ts @@ -1,7 +1,7 @@ import { useSyncExternalStore } from "react"; import SharedMindMapController from "@/features/mindmap/shared_mindmap/utils/SharedMindmapController"; -import { NodeId } from "@/features/mindmap/types/mindmap"; +import { NodeId } from "@/features/mindmap/types/mindmap_node"; import { EventBroker } from "@/utils/EventBroker"; /** diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts index 13c2dda82..9c4330fb7 100644 --- a/frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts @@ -1,6 +1,6 @@ import { useLayoutEffect, useRef } from "react"; -import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap"; +import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap_node"; import { isSame } from "@/utils/is_same"; type Props = { diff --git a/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts b/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts index 8cc6544c8..b4a251c17 100644 --- a/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts +++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts @@ -4,9 +4,9 @@ import { WebsocketProvider } from "y-websocket"; import * as Y from "yjs"; import { ENV } from "@/constants/env"; -import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; +import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorsManager"; import SharedMindMapController from "@/features/mindmap/shared_mindmap/utils/SharedMindmapController"; -import { NodeId } from "@/features/mindmap/types/mindmap"; +import { NodeId } from "@/features/mindmap/types/mindmap_node"; import { MindmapRoomId } from "@/features/mindmap/types/mindmap_room"; import { EventBroker } from "@/utils/EventBroker"; import generateId from "@/utils/generate_id"; From 35e7c6cc9ab0a4a0175afeaa0947046703b054d2 Mon Sep 17 00:00:00 2001 From: pakxe Date: Sat, 14 Feb 2026 20:02:13 +0900 Subject: [PATCH 34/36] =?UTF-8?q?chore:=20import=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx b/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx index 4c25ab6e6..9ffb31c18 100644 --- a/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx +++ b/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx @@ -6,7 +6,7 @@ import { useNode } from "@/features/mindmap/shared_mindmap/hooks/useNode"; import { useNodeResizeObserver } from "@/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver"; import { useSharedMindmap } from "@/features/mindmap/shared_mindmap/hooks/useSharedMindmap"; import SharedMindMapController from "@/features/mindmap/shared_mindmap/utils/SharedMindmapController"; -import { NodeId } from "@/features/mindmap/types/mindmap"; +import { NodeId } from "@/features/mindmap/types/mindmap_node"; import { EventBroker } from "@/utils/EventBroker"; // [Read Hook] 특정 노드의 데이터 변경을 구독 From 62efe64bafb696fefeb12fa8c04513fad4ced7d7 Mon Sep 17 00:00:00 2001 From: pakxe Date: Sat, 14 Feb 2026 20:02:33 +0900 Subject: [PATCH 35/36] =?UTF-8?q?chore:=20import=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/mindmap/types/mindmap.ts | 51 ++++++++----------- .../mindmap/types/mindmap_interaction.ts | 2 +- .../features/mindmap/types/mindmap_node.ts | 33 ++++++++++++ 3 files changed, 54 insertions(+), 32 deletions(-) create mode 100644 frontend/src/features/mindmap/types/mindmap_node.ts diff --git a/frontend/src/features/mindmap/types/mindmap.ts b/frontend/src/features/mindmap/types/mindmap.ts index 5f163aba9..0f052bbee 100644 --- a/frontend/src/features/mindmap/types/mindmap.ts +++ b/frontend/src/features/mindmap/types/mindmap.ts @@ -1,33 +1,22 @@ -export type NodeId = string; - -export type Node = { - id: NodeId; - - x: number; - y: number; - - width: number; - height: number; - - data: NodeData; +export type MindmapType = "ALL" | "PUBLIC" | "PRIVATE"; + +export type MindmapItem = { + mindmapId: string; + mindmapName: string; + createdAt: string; + updatedAt: string; + isFavorite: boolean; }; -export type NodeType = "root" | "normal"; - -export type NodeElement = Node & { - parentId: NodeId; - - // double linked list - nextId: NodeId | null; - prevId: NodeId | null; - - firstChildId: NodeId | null; - lastChildId: NodeId | null; - - type: NodeType; -}; - -export type NodeData = { - contents: string; - pakxepakxe?: "뭔 타입이 올지 모르겠으니.."; -}; +export type ActivityCategory = "INTERN" | "STUDY" | "CLUB" | "PROJECT" | "VOLUNTEER" | "PARTTIME" | "CONTEST" | "ETC"; + +export const ACTIVITY_CATEGORIES: ReadonlyArray<{ id: ActivityCategory; label: string; emoji: string }> = [ + { id: "INTERN", label: "인턴", emoji: "💼" }, + { id: "STUDY", label: "학업", emoji: "📚" }, + { id: "CLUB", label: "동아리", emoji: "🎯" }, + { id: "PROJECT", label: "프로젝트", emoji: "🚀" }, + { id: "PARTTIME", label: "아르바이트", emoji: "💰" }, + { id: "VOLUNTEER", label: "봉사활동", emoji: "🍀" }, + { id: "CONTEST", label: "공모전", emoji: "🏆" }, + { id: "ETC", label: "기타", emoji: "✨" }, +]; diff --git a/frontend/src/features/mindmap/types/mindmap_interaction.ts b/frontend/src/features/mindmap/types/mindmap_interaction.ts index 9b03fb1db..d64fbc8d8 100644 --- a/frontend/src/features/mindmap/types/mindmap_interaction.ts +++ b/frontend/src/features/mindmap/types/mindmap_interaction.ts @@ -1,4 +1,4 @@ -import { NodeId } from "@/features/mindmap/types/mindmap"; +import { NodeId } from "@/features/mindmap/types/mindmap_node"; export type InteractionMode = "idle" | "potential_drag" | "dragging" | "panning"; diff --git a/frontend/src/features/mindmap/types/mindmap_node.ts b/frontend/src/features/mindmap/types/mindmap_node.ts new file mode 100644 index 000000000..5f163aba9 --- /dev/null +++ b/frontend/src/features/mindmap/types/mindmap_node.ts @@ -0,0 +1,33 @@ +export type NodeId = string; + +export type Node = { + id: NodeId; + + x: number; + y: number; + + width: number; + height: number; + + data: NodeData; +}; + +export type NodeType = "root" | "normal"; + +export type NodeElement = Node & { + parentId: NodeId; + + // double linked list + nextId: NodeId | null; + prevId: NodeId | null; + + firstChildId: NodeId | null; + lastChildId: NodeId | null; + + type: NodeType; +}; + +export type NodeData = { + contents: string; + pakxepakxe?: "뭔 타입이 올지 모르겠으니.."; +}; From 15433541b8a2eb38866568d90627ce95dc046219 Mon Sep 17 00:00:00 2001 From: pakxe Date: Sat, 14 Feb 2026 20:04:17 +0900 Subject: [PATCH 36/36] =?UTF-8?q?chore:=20import=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...atorManager.ts => CollaboratorsManager.ts} | 36 +++++++++++++++---- .../utils/SharedMindmapController.ts | 2 +- .../utils/SharedMindmapLayoutManager.ts | 2 +- .../utils/SharedTreeContainer.ts | 2 +- .../utils/MindmapInteractionManager.ts | 2 +- .../mindmap/utils/MindmapLayoutManager.ts | 2 +- .../src/features/mindmap/utils/Renderer.ts | 2 +- .../features/mindmap/utils/TreeContainer.ts | 2 +- 8 files changed, 36 insertions(+), 14 deletions(-) rename frontend/src/features/mindmap/shared_mindmap/utils/{CollaboratorManager.ts => CollaboratorsManager.ts} (71%) diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/CollaboratorManager.ts b/frontend/src/features/mindmap/shared_mindmap/utils/CollaboratorsManager.ts similarity index 71% rename from frontend/src/features/mindmap/shared_mindmap/utils/CollaboratorManager.ts rename to frontend/src/features/mindmap/shared_mindmap/utils/CollaboratorsManager.ts index 1f247ac42..cff87aec7 100644 --- a/frontend/src/features/mindmap/shared_mindmap/utils/CollaboratorManager.ts +++ b/frontend/src/features/mindmap/shared_mindmap/utils/CollaboratorsManager.ts @@ -4,13 +4,15 @@ import { WebsocketProvider } from "y-websocket"; import { CursorMap, UserProfile } from "@/features/mindmap/shared_mindmap/types/collaborator"; +const WS_HZ = 100; + export default class CollaboratorsManager { private awareness: Awareness; private localUser: UserProfile; private collaboratorsCache: UserProfile[] = []; private cursorsCache: CursorMap = new Map(); - + private rafId: number | null = null; constructor({ provider, userInfo }: { provider: WebsocketProvider; userInfo: UserProfile }) { this.awareness = provider.awareness; this.localUser = userInfo; @@ -34,7 +36,7 @@ export default class CollaboratorsManager { public updateCursor = throttle((x: number, y: number) => { this.awareness.setLocalStateField("cursor", { x, y }); - }, 80); + }, WS_HZ); private processAwarenessData() { const states = this.awareness.getStates(); @@ -42,7 +44,7 @@ export default class CollaboratorsManager { const newCollaborators: UserProfile[] = []; const newCursors: CursorMap = new Map(); - const isCollaboratorsChanged = false; + // const isCollaboratorsChanged = false; states.forEach((state: any, clientId: number) => { if (!state.user) return; @@ -71,11 +73,26 @@ export default class CollaboratorsManager { subscribe = (callback: () => void) => { const handler = () => { - this.processAwarenessData(); - callback(); + // 이미 예약된 애니메이션 프레임이 없다면 새로 예약 + if (this.rafId === null) { + this.rafId = requestAnimationFrame(() => { + this.processAwarenessData(); // 데이터 가공 + callback(); // React 리렌더링 트리거 (useSyncExternalStore) + this.rafId = null; // 실행 완료 후 초기화 + }); + } }; + this.awareness.on("change", handler); - return () => this.awareness.off("change", handler); + + return () => { + this.awareness.off("change", handler); + // 언마운트 시 예약된 프레임이 있다면 취소하여 메모리 누수 방지 + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + }; }; // 1. 유저 목록 스냅샷 @@ -89,7 +106,12 @@ export default class CollaboratorsManager { }; destroy() { - this.updateCursor.cancel(); // throttle 취소 + this.updateCursor.cancel(); this.awareness.setLocalState(null); + // 💡 추가: 인스턴스 파괴 시 rAF 정리 + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } } } diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts index 4993eee1e..0358efe70 100644 --- a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts +++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts @@ -2,7 +2,7 @@ import * as Y from "yjs"; import SharedMindmapLayoutManager from "@/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager"; import SharedTreeContainer from "@/features/mindmap/shared_mindmap/utils/SharedTreeContainer"; -import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap"; +import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap_node"; import { MindmapRoomId } from "@/features/mindmap/types/mindmap_room"; import { EventBroker } from "@/utils/EventBroker"; diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts index f54c1c4a4..5a87fbe2d 100644 --- a/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts +++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts @@ -1,5 +1,5 @@ import SharedTreeContainer from "@/features/mindmap/shared_mindmap/utils/SharedTreeContainer"; -import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap"; +import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap_node"; import { calcPartitionIndex } from "@/utils/calc_partition"; import { isSame } from "@/utils/is_same"; diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts b/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts index 2e2918259..f70f61d0e 100644 --- a/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts +++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedTreeContainer.ts @@ -1,6 +1,6 @@ import * as Y from "yjs"; -import { NodeData, NodeElement, NodeId, NodeType } from "@/features/mindmap/types/mindmap"; +import { NodeData, NodeElement, NodeId, NodeType } from "@/features/mindmap/types/mindmap_node"; import { MindmapRoomId } from "@/features/mindmap/types/mindmap_room"; import { EventBroker } from "@/utils/EventBroker"; import { exhaustiveCheck } from "@/utils/exhaustive_check"; diff --git a/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts b/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts index 50ebf6597..434a05cfe 100644 --- a/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts +++ b/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts @@ -1,7 +1,7 @@ import { MOUSE_DOWN } from "@/constants/mouse"; import { ATTRIBUTE_NAME_OF_NODE_ID } from "@/features/mindmap/constants/node"; -import { NodeId } from "@/features/mindmap/types/mindmap"; import { BaseNodeInfo, InteractionMode, ViewportTransform } from "@/features/mindmap/types/mindmap_interaction"; +import { NodeId } from "@/features/mindmap/types/mindmap_node"; import TreeContainer from "@/features/mindmap/utils/TreeContainer"; import { calcDistance } from "@/utils/calc_distance"; diff --git a/frontend/src/features/mindmap/utils/MindmapLayoutManager.ts b/frontend/src/features/mindmap/utils/MindmapLayoutManager.ts index 0ccbc9073..0fd67afb5 100644 --- a/frontend/src/features/mindmap/utils/MindmapLayoutManager.ts +++ b/frontend/src/features/mindmap/utils/MindmapLayoutManager.ts @@ -1,4 +1,4 @@ -import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap"; +import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap_node"; import TreeContainer from "@/features/mindmap/utils/TreeContainer"; import { CacheMap } from "@/utils/CacheMap"; import { calcPartitionIndex } from "@/utils/calc_partition"; diff --git a/frontend/src/features/mindmap/utils/Renderer.ts b/frontend/src/features/mindmap/utils/Renderer.ts index 60f6f44be..ca941c33e 100644 --- a/frontend/src/features/mindmap/utils/Renderer.ts +++ b/frontend/src/features/mindmap/utils/Renderer.ts @@ -1,4 +1,4 @@ -import { NodeElement } from "@/features/mindmap/types/mindmap"; +import { NodeElement } from "@/features/mindmap/types/mindmap_node"; import { Rect } from "@/features/quad_tree/types/rect"; import QuadTree from "@/features/quad_tree/utils/QuadTree"; diff --git a/frontend/src/features/mindmap/utils/TreeContainer.ts b/frontend/src/features/mindmap/utils/TreeContainer.ts index c66a7d45f..168491155 100644 --- a/frontend/src/features/mindmap/utils/TreeContainer.ts +++ b/frontend/src/features/mindmap/utils/TreeContainer.ts @@ -1,4 +1,4 @@ -import { NodeData, NodeElement, NodeId, NodeType } from "@/features/mindmap/types/mindmap"; +import { NodeData, NodeElement, NodeId, NodeType } from "@/features/mindmap/types/mindmap_node"; import { EventBroker } from "@/utils/EventBroker"; import { exhaustiveCheck } from "@/utils/exhaustive_check"; import generateId from "@/utils/generate_id";