Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
b4ebf12
feat: ws로 ydoc에 저장된 마인드맵 데이터를 가져오고 갱신하는 useSharedMindmap 훅 구현
pakxe Feb 11, 2026
cf0015f
chore: 동시 편집을 윟하여 y js관련 라이브러리 설치
pakxe Feb 11, 2026
6ce3024
feat: env 헬퍼 상수 추가
pakxe Feb 11, 2026
09e43f2
design: 다른 type의 toast는 다른 색을 사용할 수 있도록 수정
pakxe Feb 11, 2026
4f09a4f
chore: import 경로 수정
pakxe Feb 11, 2026
710f104
feat: 오프라인일 때에도 마인드맵 변경 사항을 보존할 수 있도록 y-indexeddb
pakxe Feb 11, 2026
37cd3ab
chore: import 수정
pakxe Feb 12, 2026
659429f
feat: useNode 훅 추가하여 nodeId 변경 시 컴포넌트 rerendering 지원
pakxe Feb 12, 2026
aa2e9c7
feat: useNodeResizeObserver 훅 추가하여 노드 크기 변경 시 콜백 실행
pakxe Feb 12, 2026
a24c8e7
feat: useSharedMindmap 훅 추가하여 동시편집을 위한 웹소켓 연결 구현
pakxe Feb 12, 2026
11e255a
feat: useOfflineMindmap 훅 추가하여 오프라인 상태에서 작업 내용을 indexeddb에 반영
pakxe Feb 12, 2026
c5dec33
feat: SharedMindMapController 클래스 추가 및 레이아웃 관리 기능 구현
pakxe Feb 12, 2026
7149efd
feat: SharedMindmapLayoutManager 클래스 추가 및 레이아웃 계산 기능 구현
pakxe Feb 12, 2026
5407f3a
feat: SharedTreeContainer 클래스 추가 및 마인드맵 노드 관리 기능 구현
pakxe Feb 12, 2026
acc59e3
feat: 마인드맵 관련 타입 정의 추가, 파일명수정
pakxe Feb 12, 2026
524761e
chore: SharedMindmapShowCase
pakxe Feb 12, 2026
9edc63f
chore: 불필요한 파일 제거
pakxe Feb 12, 2026
4fa7ddb
refactor: 연산값을 변수로 분리
pakxe Feb 12, 2026
6fbb2bc
fix: 오타 수정, 외부에서 roomId 주입받도록 수정
pakxe Feb 12, 2026
4049374
feat: roomId외부에서 주입
pakxe Feb 12, 2026
15b626b
chore: showCase
pakxe Feb 12, 2026
ea4a64a
feat: broker 등 public인자를 private로 변경하고 생성자의 인자로 주입하도록 수정
pakxe Feb 12, 2026
e35f002
fix: 불필요한 origin param 제거
pakxe Feb 12, 2026
c2ddbc1
chore: 사용자 정보를 공유하기 위한 라이브러리 추가
pakxe Feb 12, 2026
3537b3a
feat: 동시 편집중인 사용자의 정보, 커서 시각화
pakxe Feb 12, 2026
f7d76dc
chore: 충돌 병합
pakxe Feb 12, 2026
e310891
chore: 데모를 위한 개발
pakxe Feb 13, 2026
c3552de
chore: routing showcase
pakxe Feb 13, 2026
397ca58
Merge branch 'dev' into feat/#316/share_mindmap_update
pakxe Feb 13, 2026
f08aece
chore: import 수정
pakxe Feb 13, 2026
b31191a
Merge branch 'feat/#316/share_mindmap_update' of https://github.com/s…
pakxe Feb 13, 2026
bda323f
feat: 동시 편집자 목록 렌더링
pakxe Feb 14, 2026
673d80e
chore: import 경로 수정
pakxe Feb 14, 2026
ce52c9f
feat: Cursors 렌더링
pakxe Feb 14, 2026
1fa78ab
feat: 커서 공유, 사용자 공유를 위한 훅 구현
pakxe Feb 14, 2026
f6a3d8b
chore: import 경로 수정
pakxe Feb 14, 2026
35e7c6c
chore: import 경로 수정
pakxe Feb 14, 2026
62efe64
chore: import 경로 수정
pakxe Feb 14, 2026
1543354
chore: import 경로 수정
pakxe Feb 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -55,6 +56,10 @@ const router = createBrowserRouter([
path: routeHelper.login(),
element: <LoginPage />,
},
{
path: "/showcase",
element: <MindmapShowcaseV3 />,
},
]);

function App() {
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/constants/env.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +8 to +11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

환경 변수가 설정되지 않았을 때 'invalid' 문자열을 기본값으로 사용하는 것은 나중에 예측하기 어려운 런타임 오류를 발생시킬 수 있습니다. 애플리케이션 시작 시점에 필요한 환경 변수가 없는 경우, 명시적으로 오류를 발생시켜 빠르게 문제를 인지하고 수정할 수 있도록 하는 것이 좋습니다.

Suggested change
export const ENV = {
API_BASE_URL: env.VITE_API_BASE_URL || `invalid`, // 에러나게 아무 뻥값 넣었습니다.
WS_BASE_URL: env.VITE_WS_BASE_URL || `invalid`,
} as const;
function getEnv(key: keyof AppEnv): string {
const value = env[key];
if (value === undefined || value === '') {
throw new Error(`Missing environment variable: ${key}`);
}
return value;
}
export const ENV = {
API_BASE_URL: getEnv('VITE_API_BASE_URL'),
WS_BASE_URL: getEnv('VITE_WS_BASE_URL'),
} as const;

Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<List className="absolute top-10 right-10 z-50 p-4 gap-4" hasDivider={false}>
<ListRow
contents={"공동작업자"}
className="typo-caption-12-semibold p-0"
leftSlot={<Icon name="ic_team" size="16" />}
></ListRow>

<div className="flex flex-col gap-2">
{users.map((u) => (
<ListRow
className="typo-caption-12-medium min-w-50 p-0"
key={u.id}
leftSlot={<ProfileIcon size="sm" name={u.name} isOnline={true} />}
contents={u.name}
></ListRow>
))}
</div>
</List>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
overflow: "hidden",
zIndex: 10,
}}
>
{Array.from(cursors.entries()).map(([userId, cursor]) => (
<OtherUserCursor
key={userId}
point={{ x: cursor.x, y: cursor.y }}
name={cursor.name}
pan={pan}
scale={scale}
/>
))}
</div>
);
}

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 (
<div
style={{
position: "absolute",
top: 0,
left: 0,
transform: `translate(${screenX}px, ${screenY}px)`,
transition: "transform 120ms linear",
pointerEvents: "none",
zIndex: 9999,
willChange: "transform",
}}
>
<div className={color.text}>
<svg width="24" height="24" viewBox="0 0 24 24" style={{ transform: "translate(-2px, -2px)" }}>
<path
stroke="white"
fill="currentColor"
d="M5.65376 12.3673H5.46026L5.31717 12.4976L0.500002 16.8829L0.500002 1.19138L11.7841 12.3673H5.65376Z"
/>
</svg>
<div
className={color.bg}
style={{
position: "absolute",
top: 15,
left: 10,
color: "white",
padding: "2px 6px",
borderRadius: "4px",
fontSize: "10px",
whiteSpace: "nowrap",
}}
>
{name}
</div>
</div>
</div>
);
},
);

OtherUserCursor.displayName = "OtherUserCursor";
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
}
23 changes: 23 additions & 0 deletions frontend/src/features/mindmap/shared_mindmap/hooks/useNode.ts
Original file line number Diff line number Diff line change
@@ -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<NodeId>) {
const { container } = controller;

const subscribe = (callback: () => void) => {
return broker.subscribe({ key: nodeId, callback });
};

const getSnapshot = () => {
return container.safeGetNode(nodeId);
};

return useSyncExternalStore(subscribe, getSnapshot);
}
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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;
}
Original file line number Diff line number Diff line change
@@ -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]);
};
Loading