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 ( +
+
+ + + +
+ {name} +
+
+
+ ); + }, +); + +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