-
Notifications
You must be signed in to change notification settings - Fork 3
[FE] 동시 편집을 위한 마인드맵 상태 공유 #342
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 22 commits
b4ebf12
cf0015f
6ce3024
09e43f2
4f09a4f
710f104
37cd3ab
659429f
aa2e9c7
a24c8e7
11e255a
c5dec33
7149efd
5407f3a
acc59e3
524761e
9edc63f
4fa7ddb
6fbb2bc
4049374
15b626b
ea4a64a
e35f002
c2ddbc1
3537b3a
f7d76dc
e310891
c3552de
397ca58
f08aece
b31191a
bda323f
673d80e
ce52c9f
1fa78ab
f6a3d8b
35e7c6c
62efe64
1543354
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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; | ||
| 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]); | ||||||
|
||||||
| }, [nodeId]); | |
| }, [node, onResize]); |
| 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, | ||
| }; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
환경 변수가 설정되지 않았을 때
'invalid'문자열을 기본값으로 사용하는 것은 나중에 예측하기 어려운 런타임 오류를 발생시킬 수 있습니다. 애플리케이션 시작 시점에 필요한 환경 변수가 없는 경우, 명시적으로 오류를 발생시켜 빠르게 문제를 인지하고 수정할 수 있도록 하는 것이 좋습니다.