Skip to content
Closed
Show file tree
Hide file tree
Changes from 22 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
12 changes: 8 additions & 4 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,29 @@
"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",
"lucide-react": "^0.563.0",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"nanoid": "^5.1.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.1.0",
"react-router": "^7.13.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
"tailwindcss": "^4.1.18",
"y-indexeddb": "^9.0.12",
"y-websocket": "^3.0.0",
"yjs": "^13.6.29"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
Expand Down
65 changes: 65 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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";
import MindmapLayoutManager from "@/features/mindmap/utils/MindmapLayoutManager";
import TreeContainer from "@/features/mindmap/utils/TreeContainer";
import { EventBroker } from "@/utils/EventBroker";
Expand Down
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";
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,35 @@
import { useLayoutEffect, useRef } from "react";

import { NodeElement, NodeId } from "@/features/mindmap/types/mindmap";
import { isSame } from "@/utils/is_same";

type Props = {
nodeId: NodeId;
node?: NodeElement;
onResize: ({ width, height }: { width: number; height: number }) => void;
};
/**
* 타겟 노드의 크기가 변경되면 콜백을 실행합니다.
*/
export function useNodeResizeObserver({ nodeId, node, onResize }: Props) {
const nodeRef = useRef<HTMLDivElement>(null);

useLayoutEffect(() => {
if (!nodeRef.current || !node) {
return;
}

const rect = nodeRef.current.getBoundingClientRect();

// 안잘리게 ceil
const newWidth = Math.ceil(rect.width);
const newHeight = Math.ceil(rect.height);

const isChanged = !isSame(node.width, newWidth) || !isSame(node.height, newHeight);
if (isChanged) {
onResize({ height: newHeight, width: newWidth });
}
}, [nodeId]);
Copy link
Contributor

Choose a reason for hiding this comment

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

high

useLayoutEffect의 의존성 배열이 [nodeId]로 되어 있습니다. nodeId는 일반적으로 변경되지 않는 값이므로, 이 effect는 컴포넌트가 마운트될 때 한 번만 실행됩니다. 노드의 콘텐츠가 변경되어 크기가 바뀌는 경우를 감지하려면 node 객체 자체를 의존성 배열에 추가해야 합니다. 또한, React Hook의 규칙에 따라 effect 내부에서 사용하는 모든 외부 스코프의 값들(node, onResize)을 의존성 배열에 포함하는 것이 좋습니다.

Suggested change
}, [nodeId]);
}, [node, onResize]);


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]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useEffect, useMemo, useState } from "react";
import { WebsocketProvider } from "y-websocket";
import * as Y from "yjs";

import { ENV } from "@/constants/env";
import SharedMindMapController from "@/features/mindmap/shared_mindmap/utils/SharedMindmapController";
import { NodeId } from "@/features/mindmap/types/mindmap";
import { MindmapRoomId } from "@/features/mindmap/types/mindmap_room";
import { EventBroker } from "@/utils/EventBroker";

type ConnectionStatus = "disconnected" | "connecting" | "connected";

type UseSharedMindmapProps = {
roomId: MindmapRoomId;
};

/**
* 동시편집을 위해 웹소켓을 연결합니다.
*/
export const useSharedMindmap = ({ roomId }: UseSharedMindmapProps) => {
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("disconnected");

const { controller, provider, broker } = useMemo(() => {
const doc = new Y.Doc();

const provider = new WebsocketProvider(ENV.WS_BASE_URL, roomId, doc);
const broker = new EventBroker<NodeId>();
const controller = new SharedMindMapController(doc, broker, roomId);

return { controller, provider, broker };
}, [roomId]);

useEffect(() => {
if (!provider.shouldConnect) {
provider.connect();
}

const handleStatus = (event: { status: ConnectionStatus }) => {
setConnectionStatus(event.status);
};

provider.on("status", handleStatus);

return () => {
provider.off("status", handleStatus);
provider.disconnect();
};
}, [provider]);

return {
controller,

container: controller.container,

broker,

connectionStatus,
};
};
Loading