diff --git a/frontend/package.json b/frontend/package.json
index 3675d895..c6f0efbb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,28 +7,36 @@
"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",
"@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",
- "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-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/src/App.tsx b/frontend/src/App.tsx
index 14388f3d..b8b0354e 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() {
diff --git a/frontend/src/constants/env.ts b/frontend/src/constants/env.ts
new file mode 100644
index 00000000..49033389
--- /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;
diff --git a/frontend/src/features/mindmap/providers/MindmapProvider.tsx b/frontend/src/features/mindmap/providers/MindmapProvider.tsx
index 3f8f74b0..b3ff90ec 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_node";
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/shared_mindmap/components/CollaboratorList.tsx b/frontend/src/features/mindmap/shared_mindmap/components/CollaboratorList.tsx
new file mode 100644
index 00000000..79f0ab87
--- /dev/null
+++ b/frontend/src/features/mindmap/shared_mindmap/components/CollaboratorList.tsx
@@ -0,0 +1,34 @@
+import { useCollaborators } from "@/features/mindmap/shared_mindmap/hooks/useCollaborators";
+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";
+import ProfileIcon from "@/shared/components/profile_icon/ProfileIcon";
+
+type Props = {
+ manager: CollaboratorsManager;
+};
+
+export function CollaboratorList({ manager }: Props) {
+ const users = useCollaborators(manager);
+ return (
+
+ }
+ >
+
+
+ {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
new file mode 100644
index 00000000..7c2d46fd
--- /dev/null
+++ b/frontend/src/features/mindmap/shared_mindmap/components/CursorsOverlay.tsx
@@ -0,0 +1,126 @@
+import React from "react";
+
+import { useCursors } from "@/features/mindmap/shared_mindmap/hooks/useCursors";
+import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorsManager";
+
+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" },
+];
+
+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]!;
+}
+
+type Props = {
+ manager: CollaboratorsManager;
+ pan: { x: number; y: number };
+ scale: number;
+};
+
+export function CursorOverlay({ manager, pan, scale }: Props) {
+ const cursors = useCursors(manager);
+
+ return (
+
+ {Array.from(cursors.entries()).map(([userId, cursor]) => (
+
+ ))}
+
+ );
+}
+
+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]);
+
+ const screenX = point.x * scale + pan.x;
+ const screenY = point.y * scale + pan.y;
+
+ return (
+
+ );
+ },
+);
+
+OtherUserCursor.displayName = "OtherUserCursor";
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 00000000..a45afef3
--- /dev/null
+++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useCollaborators.ts
@@ -0,0 +1,7 @@
+import { useSyncExternalStore } from "react";
+
+import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorsManager";
+
+export function useCollaborators(manager: CollaboratorsManager) {
+ 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 00000000..43649a00
--- /dev/null
+++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useCursors.ts
@@ -0,0 +1,7 @@
+import { useSyncExternalStore } from "react";
+
+import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorsManager";
+
+export function useCursors(manager: CollaboratorsManager) {
+ return useSyncExternalStore(manager.subscribe, manager.getCursorsSnapshot);
+}
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 00000000..2c559ab6
--- /dev/null
+++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useNode.ts
@@ -0,0 +1,23 @@
+import { useSyncExternalStore } from "react";
+
+import SharedMindMapController from "@/features/mindmap/shared_mindmap/utils/SharedMindmapController";
+import { NodeId } from "@/features/mindmap/types/mindmap_node";
+import { EventBroker } from "@/utils/EventBroker";
+
+/**
+ * Node UI 컴포넌트 안에서 사용하는 훅입니다.
+ * nodeId에 해당하는 데이터가 변경되면 해당 컴포넌트만 rerendering을 트리거 해줍니다.
+ */
+export function useNode(nodeId: NodeId, controller: SharedMindMapController, broker: EventBroker) {
+ const { container } = controller;
+
+ const subscribe = (callback: () => void) => {
+ return broker.subscribe({ key: nodeId, callback });
+ };
+
+ const getSnapshot = () => {
+ return container.safeGetNode(nodeId);
+ };
+
+ return useSyncExternalStore(subscribe, getSnapshot);
+}
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 00000000..9c4330fb
--- /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_node";
+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();
+
+ const newWidth = Math.ceil(rect.width);
+ const newHeight = Math.ceil(rect.height);
+
+ const isChanged = !isSame(node.width, newWidth) || !isSame(node.height, newHeight);
+ if (isChanged) {
+ onResize({ height: newHeight, width: newWidth });
+ }
+ }, [nodeId, node?.x, node?.y]);
+
+ return nodeRef;
+}
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 00000000..5feb6c86
--- /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]);
+};
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 00000000..b4a251c1
--- /dev/null
+++ b/frontend/src/features/mindmap/shared_mindmap/hooks/useSharedMindmap.ts
@@ -0,0 +1,76 @@
+import { useEffect, useMemo, useState } from "react";
+import { useSearchParams } from "react-router";
+import { WebsocketProvider } from "y-websocket";
+import * as Y from "yjs";
+
+import { ENV } from "@/constants/env";
+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_node";
+import { MindmapRoomId } from "@/features/mindmap/types/mindmap_room";
+import { EventBroker } from "@/utils/EventBroker";
+import generateId from "@/utils/generate_id";
+
+type ConnectionStatus = "disconnected" | "connecting" | "connected";
+
+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 userId = generateId();
+ const collaboratorsManager = new CollaboratorsManager({
+ provider,
+ userInfo: {
+ id: userId,
+ name: userName,
+ color: "#ff3421",
+ },
+ });
+
+ return { controller, provider, broker, collaboratorsManager };
+
+ // userName이 바뀌면 새로운 유저 세션으로 간주하고 다시 생성합니다.
+ }, [roomId, userName]);
+
+ 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,
+ 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
new file mode 100644
index 00000000..9ffb31c1
--- /dev/null
+++ b/frontend/src/features/mindmap/shared_mindmap/show_cases/ShowCase.tsx
@@ -0,0 +1,260 @@
+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 SharedMindMapController from "@/features/mindmap/shared_mindmap/utils/SharedMindmapController";
+import { NodeId } from "@/features/mindmap/types/mindmap_node";
+import { EventBroker } from "@/utils/EventBroker";
+
+// [Read Hook] 특정 노드의 데이터 변경을 구독
+
+// [View Logic] ResizeObserver 대신 getBoundingClientRect 사용
+
+type NodeItemProps = {
+ nodeId: NodeId;
+ controller: SharedMindMapController;
+ broker: EventBroker;
+};
+
+const NodeItem = ({ nodeId, controller, broker }: NodeItemProps) => {
+ const node = useNode(nodeId, controller, broker);
+
+ const nodeRef = useNodeResizeObserver({
+ nodeId,
+ node,
+ onResize: (args) => controller.updateNodeSize({ ...args, nodeId }),
+ });
+
+ if (!node) return null;
+
+ const handleAddChild = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ controller.addChildNode(nodeId);
+ };
+
+ const handleDelete = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (node.type !== "root") {
+ controller.deleteNode(nodeId);
+ }
+ };
+
+ const style: React.CSSProperties = {
+ position: "absolute",
+ left: node.x,
+ 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",
+ border: "1px solid #ddd", // 가시성을 위해 살짝 추가
+ borderRadius: "8px",
+ };
+
+ return (
+ <>
+ e.stopPropagation()}>
+
+ {node.id}
+
+
+ Parent: {node.parentId || "None"}
({Math.round(node.x)}, {Math.round(node.y)})
+
+
+
+
+
+ {node.type !== "root" && (
+
+ )}
+
+
+
+ {/* 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",
+ padding: "2px 6px",
+ fontSize: "12px",
+ border: "1px solid #ccc",
+ borderRadius: "4px",
+ background: "#f9f9f9",
+};
+
+const DUMMY_ROOM_ID = "ㅁ";
+
+// ... (NodeItem 컴포넌트와 btnStyle은 기존과 동일하므로 생략)
+
+export default function MindmapShowcaseV3() {
+ const { controller, broker, collaboratorsManager } = useSharedMindmap({ roomId: DUMMY_ROOM_ID });
+
+ // 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 });
+
+ // [추가] 컨테이너 참조를 위한 Ref
+ const containerRef = useRef(null);
+
+ // 2. Zoom Handler (useEffect를 이용한 수동 등록)
+ useEffect(() => {
+ const handleWheelNative = (e: WheelEvent) => {
+ // 브라우저의 기본 스크롤 동작을 방지 (passive: false 설정 덕분에 에러가 나지 않음)
+ e.preventDefault();
+
+ const zoomSpeed = 0.001;
+ const delta = -e.deltaY;
+
+ 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);
+ }
+ };
+ }, []); // scale 의존성을 제거하기 위해 setScale 내부에서 함수형 업데이트 사용
+
+ // 3. 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) => {
+ const worldX = (e.clientX - pan.x) / scale;
+ const worldY = (e.clientY - pan.y) / scale;
+
+ collaboratorsManager.updateCursor(worldX, worldY);
+
+ if (!isPanning) return;
+
+ 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 (
+ <>
+ {/* UI 컨트롤러 */}
+
+
+ Zoom: {Math.round(scale * 100)}%
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+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/types/collaborator.ts b/frontend/src/features/mindmap/shared_mindmap/types/collaborator.ts
new file mode 100644
index 00000000..7b5c31ec
--- /dev/null
+++ b/frontend/src/features/mindmap/shared_mindmap/types/collaborator.ts
@@ -0,0 +1,12 @@
+export type UserProfile = {
+ id: string;
+ name: string;
+ color: string;
+};
+
+export type CollaborativeUserState = {
+ user: UserProfile;
+ cursor?: { x: number; y: number } | null;
+};
+
+export type CursorMap = Map;
diff --git a/frontend/src/features/mindmap/shared_mindmap/utils/CollaboratorsManager.ts b/frontend/src/features/mindmap/shared_mindmap/utils/CollaboratorsManager.ts
new file mode 100644
index 00000000..cff87aec
--- /dev/null
+++ b/frontend/src/features/mindmap/shared_mindmap/utils/CollaboratorsManager.ts
@@ -0,0 +1,117 @@
+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";
+
+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;
+
+ 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 });
+ }, WS_HZ);
+
+ 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 = () => {
+ // 이미 예약된 애니메이션 프레임이 없다면 새로 예약
+ 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);
+ // 언마운트 시 예약된 프레임이 있다면 취소하여 메모리 누수 방지
+ if (this.rafId !== null) {
+ cancelAnimationFrame(this.rafId);
+ this.rafId = null;
+ }
+ };
+ };
+
+ // 1. 유저 목록 스냅샷
+ getCollaboratorsSnapshot = () => {
+ return this.collaboratorsCache;
+ };
+
+ // 2. 커서 스냅샷
+ getCursorsSnapshot = () => {
+ return this.cursorsCache;
+ };
+
+ destroy() {
+ 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
new file mode 100644
index 00000000..0358efe7
--- /dev/null
+++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapController.ts
@@ -0,0 +1,101 @@
+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_node";
+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, roomId: MindmapRoomId) {
+ this.container = new SharedTreeContainer({
+ doc,
+ broker,
+ roomId,
+ onTransaction: (event) => {
+ this.handleTransaction(event);
+ },
+ });
+ this.layoutManager = new SharedMindmapLayoutManager({ xGap: 300, yGap: 100 });
+ }
+
+ private handleTransaction(event: Y.YMapEvent) {
+ if (event.transaction.local) {
+ return;
+ }
+
+ event.keysChanged.forEach((nodeId) => {
+ this.layoutManager.invalidate(nodeId, this.container);
+ });
+
+ // this.refreshLayout();
+ }
+
+ private refreshLayout() {
+ const updates = this.layoutManager.calculateLayout(this.container);
+
+ this.container.updateNodes(updates);
+ }
+
+ public addChildNode(parentId: NodeId) {
+ this.container.getDoc().transact(() => {
+ const parent = this.container.safeGetNode(parentId);
+
+ if (!parent) {
+ console.error("Parent node not found");
+ return;
+ }
+
+ this.container.appendChild({ parentNodeId: parentId });
+ // this.refreshLayout();
+ });
+ }
+
+ public resetMindMap() {
+ this.container.getDoc().transact(() => {
+ if (confirm("정말로 모든 내용을 삭제하고 초기화하시겠습니까?")) {
+ this.container.clear();
+
+ this.refreshLayout();
+ }
+ });
+ }
+
+ public deleteNode(nodeId: NodeId) {
+ this.container.getDoc().transact(() => {
+ this.container.delete({ nodeId });
+ });
+ }
+
+ public updateNodeSize({ nodeId, width, height }: { nodeId: NodeId; width: number; height: number }) {
+ 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.getDoc().transact(() => {
+ this.layoutManager.invalidate(nodeId, this.container);
+
+ this.container.updateNode(nodeId, { data: { contents } });
+
+ this.refreshLayout();
+ });
+ }
+
+ // TODO: 이후 추가할예정. 지금 지원 안함
+ public undo() {
+ // this.container.undoManager.undo();
+ }
+
+ public redo() {
+ // this.container.undoManager.redo();
+ }
+}
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 00000000..5a87fbe2
--- /dev/null
+++ b/frontend/src/features/mindmap/shared_mindmap/utils/SharedMindmapLayoutManager.ts
@@ -0,0 +1,231 @@
+import SharedTreeContainer from "@/features/mindmap/shared_mindmap/utils/SharedTreeContainer";
+import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap_node";
+import { calcPartitionIndex } from "@/utils/calc_partition";
+import { isSame } from "@/utils/is_same";
+
+type LayoutConfig = {
+ xGap: number;
+ yGap: number;
+};
+
+type PartitionDirection = "right" | "left";
+
+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,
+ ): Map> {
+ const rootId = container.getRootId();
+ const rootNode = container.safeGetNode(rootId);
+
+ const updates: Map> = 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: Map>;
+ }) {
+ 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: Map>;
+ }) {
+ 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;
+ }
+}
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 00000000..f70f61d0
--- /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_node";
+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";
+
+// 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";
+
+export default class SharedTreeContainer {
+ // private undoManager: Y.UndoManager;
+ private doc: Y.Doc;
+ private yNodes: Y.Map;
+ private cachedNodes: Map;
+
+ private broker: EventBroker;
+ private isThrowError: boolean;
+ private rootNodeId: NodeId = ROOT_NODE_ID;
+
+ private onTransaction: (event: Y.YMapEvent) => void;
+
+ constructor({
+ isThrowError = true,
+ name = ROOT_NODE_CONTENTS,
+ broker,
+ doc,
+ roomId,
+ onTransaction,
+ }: {
+ broker: EventBroker;
+ name?: string;
+ isThrowError?: boolean;
+ doc: Y.Doc;
+ roomId: MindmapRoomId;
+ onTransaction: (event: Y.YMapEvent) => 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) => {
+ 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([TRANSACTION_ORIGINS.USER_ACTION]),
+ // });
+
+ this.isThrowError = isThrowError;
+
+ this.yNodes.observe((event) => {
+ 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);
+ }
+ });
+ }
+
+ private initRootNode(contents: string) {
+ this.generateNewNodeElement({
+ nodeData: { contents },
+ id: this.rootNodeId,
+ type: "root",
+ });
+ }
+
+ public getDoc() {
+ return this.doc;
+ }
+
+ appendChild({ parentNodeId, childNodeId }: { parentNodeId: NodeId; childNodeId?: NodeId }) {
+ 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);
+ }
+ });
+ }
+
+ 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 }: { nodeId: NodeId }) {
+ 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);
+ }
+ });
+ }
+
+ 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() {
+ this.yNodes.clear();
+ this.cachedNodes.clear();
+
+ this.initRootNode("");
+ }
+
+ 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 });
+ });
+ }
+
+ public updateNodes(nodes: Map>) {
+ this.doc.transact(() => {
+ nodes.forEach((value, key) => {
+ this.updateNode(key, value);
+ });
+ });
+ }
+
+ public setNode(nodeId: NodeId, node: NodeElement) {
+ this.doc.transact(() => {
+ this.yNodes.set(nodeId, node);
+ this.cachedNodes.set(nodeId, node);
+ });
+ }
+
+ public deleteNode(nodeId: NodeId) {
+ this.doc.transact(() => {
+ this.yNodes.delete(nodeId);
+ this.cachedNodes.delete(nodeId);
+ });
+ }
+
+ 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;
+ }
+ }
+}
diff --git a/frontend/src/features/mindmap/types/mindmap.ts b/frontend/src/features/mindmap/types/mindmap.ts
new file mode 100644
index 00000000..0f052bbe
--- /dev/null
+++ b/frontend/src/features/mindmap/types/mindmap.ts
@@ -0,0 +1,22 @@
+export type MindmapType = "ALL" | "PUBLIC" | "PRIVATE";
+
+export type MindmapItem = {
+ mindmapId: string;
+ mindmapName: string;
+ createdAt: string;
+ updatedAt: string;
+ isFavorite: boolean;
+};
+
+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_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 ac23394e..d64fbc8d 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/mindmapType";
+import { NodeId } from "@/features/mindmap/types/mindmap_node";
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_node.ts
similarity index 100%
rename from frontend/src/features/mindmap/types/mindmapType.ts
rename to frontend/src/features/mindmap/types/mindmap_node.ts
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 00000000..3ba6e073
--- /dev/null
+++ b/frontend/src/features/mindmap/types/mindmap_room.ts
@@ -0,0 +1 @@
+export type MindmapRoomId = string;
diff --git a/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts b/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts
index 7cba9c18..434a05cf 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 { 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 152a4936..0fd67afb 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_node";
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 8fd2a7a4..ca941c33 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_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 457b0eae..16849115 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_node";
import { EventBroker } from "@/utils/EventBroker";
import { exhaustiveCheck } from "@/utils/exhaustive_check";
import generateId from "@/utils/generate_id";
diff --git a/frontend/src/shared/components/list/ListRow.tsx b/frontend/src/shared/components/list/ListRow.tsx
index 8efd6e7a..fdf5e0ba 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 (
{
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