-
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
Closed
Closed
Changes from 31 commits
Commits
Show all changes
39 commits
Select commit
Hold shift + click to select a range
b4ebf12
feat: ws로 ydoc에 저장된 마인드맵 데이터를 가져오고 갱신하는 useSharedMindmap 훅 구현
pakxe cf0015f
chore: 동시 편집을 윟하여 y js관련 라이브러리 설치
pakxe 6ce3024
feat: env 헬퍼 상수 추가
pakxe 09e43f2
design: 다른 type의 toast는 다른 색을 사용할 수 있도록 수정
pakxe 4f09a4f
chore: import 경로 수정
pakxe 710f104
feat: 오프라인일 때에도 마인드맵 변경 사항을 보존할 수 있도록 y-indexeddb
pakxe 37cd3ab
chore: import 수정
pakxe 659429f
feat: useNode 훅 추가하여 nodeId 변경 시 컴포넌트 rerendering 지원
pakxe aa2e9c7
feat: useNodeResizeObserver 훅 추가하여 노드 크기 변경 시 콜백 실행
pakxe a24c8e7
feat: useSharedMindmap 훅 추가하여 동시편집을 위한 웹소켓 연결 구현
pakxe 11e255a
feat: useOfflineMindmap 훅 추가하여 오프라인 상태에서 작업 내용을 indexeddb에 반영
pakxe c5dec33
feat: SharedMindMapController 클래스 추가 및 레이아웃 관리 기능 구현
pakxe 7149efd
feat: SharedMindmapLayoutManager 클래스 추가 및 레이아웃 계산 기능 구현
pakxe 5407f3a
feat: SharedTreeContainer 클래스 추가 및 마인드맵 노드 관리 기능 구현
pakxe acc59e3
feat: 마인드맵 관련 타입 정의 추가, 파일명수정
pakxe 524761e
chore: SharedMindmapShowCase
pakxe 9edc63f
chore: 불필요한 파일 제거
pakxe 4fa7ddb
refactor: 연산값을 변수로 분리
pakxe 6fbb2bc
fix: 오타 수정, 외부에서 roomId 주입받도록 수정
pakxe 4049374
feat: roomId외부에서 주입
pakxe 15b626b
chore: showCase
pakxe ea4a64a
feat: broker 등 public인자를 private로 변경하고 생성자의 인자로 주입하도록 수정
pakxe e35f002
fix: 불필요한 origin param 제거
pakxe c2ddbc1
chore: 사용자 정보를 공유하기 위한 라이브러리 추가
pakxe 3537b3a
feat: 동시 편집중인 사용자의 정보, 커서 시각화
pakxe f7d76dc
chore: 충돌 병합
pakxe e310891
chore: 데모를 위한 개발
pakxe c3552de
chore: routing showcase
pakxe 397ca58
Merge branch 'dev' into feat/#316/share_mindmap_update
pakxe f08aece
chore: import 수정
pakxe b31191a
Merge branch 'feat/#316/share_mindmap_update' of https://github.com/s…
pakxe bda323f
feat: 동시 편집자 목록 렌더링
pakxe 673d80e
chore: import 경로 수정
pakxe ce52c9f
feat: Cursors 렌더링
pakxe 1fa78ab
feat: 커서 공유, 사용자 공유를 위한 훅 구현
pakxe f6a3d8b
chore: import 경로 수정
pakxe 35e7c6c
chore: import 경로 수정
pakxe 62efe64
chore: import 경로 수정
pakxe 1543354
chore: import 경로 수정
pakxe File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
34 changes: 34 additions & 0 deletions
34
frontend/src/features/mindmap/shared_mindmap/components/CollaboratorList.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/CollaboratorManager"; | ||
| 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; | ||
| }; | ||
| // 1. 목록 컴포넌트 (조용함) | ||
| 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> | ||
| ); | ||
| } |
149 changes: 149 additions & 0 deletions
149
frontend/src/features/mindmap/shared_mindmap/components/CursorsOverlay.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| import { PerfectCursor } from "perfect-cursors"; | ||
| import React, { useLayoutEffect, useRef, useState } from "react"; | ||
|
|
||
| import { useCursors } from "@/features/mindmap/shared_mindmap/hooks/useCursors"; | ||
| import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; | ||
|
|
||
| // --- [Sub Component] 개별 커서 (Perfect Cursor 적용) --- | ||
| 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" }, | ||
| ]; | ||
|
|
||
| // 간단하고 안정적인 32bit 해시 (FNV-1a) | ||
| 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]!; | ||
| } | ||
| // --- [Main Component] Overlay --- | ||
| type Props = { | ||
| manager: CollaboratorsManager; | ||
| pan: { x: number; y: number }; | ||
| scale: number; // scale 추가 | ||
| }; | ||
|
|
||
| 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} // scale 전달 | ||
| /> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| // --- [Sub Component] 개별 커서 --- | ||
| const OtherUserCursor = ({ | ||
| point, | ||
| name, | ||
| pan, | ||
| scale, // scale 파라미터 추가 | ||
| }: { | ||
| point: { x: number; y: number }; | ||
| name: string; | ||
| pan: { x: number; y: number }; | ||
| scale: number; | ||
| }) => { | ||
| const rCursor = useRef<HTMLDivElement>(null); | ||
| const color = React.useMemo(() => getColorFromName(name), [name]); | ||
|
|
||
| const [pc] = useState( | ||
| () => | ||
| new PerfectCursor((p) => { | ||
| if (rCursor.current) { | ||
| // 계산된 최종 화면 좌표로 이동 | ||
| rCursor.current.style.setProperty("transform", `translate(${p[0]}px, ${p[1]}px)`); | ||
| } | ||
| }), | ||
| ); | ||
|
|
||
| useLayoutEffect(() => { | ||
| // [핵심 로직] | ||
| // 1. World 좌표(point)에 현재 배율(scale)을 곱합니다. | ||
| // 2. 그 결과에 Pan 값을 더해 최종 화면(Screen) 위치를 구합니다. | ||
| const screenX = point.x * scale + pan.x; | ||
| const screenY = point.y * scale + pan.y; | ||
|
|
||
| pc.addPoint([screenX, screenY]); | ||
| }, [point, pc, pan, scale]); // scale이 바뀔 때도 위치를 재계산해야 함 | ||
|
|
||
| React.useEffect(() => { | ||
| return () => pc.dispose(); | ||
| }, [pc]); | ||
|
|
||
| return ( | ||
| <div | ||
| ref={rCursor} | ||
| style={{ | ||
| position: "absolute", | ||
| top: 0, | ||
| left: 0, | ||
| pointerEvents: "none", | ||
| zIndex: 9999, | ||
| willChange: "transform", | ||
| }} | ||
| > | ||
| {/* 커서 아이콘: 스타일은 className이나 style로 적용 */} | ||
| <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> | ||
| ); | ||
| }; |
9 changes: 9 additions & 0 deletions
9
frontend/src/features/mindmap/shared_mindmap/hooks/useCollaborators.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { useSyncExternalStore } from "react"; | ||
|
|
||
| import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; | ||
|
|
||
| // hooks/useCollaborators.ts (기존) | ||
| export function useCollaborators(manager: CollaboratorsManager) { | ||
| // collaboratorsCache 참조가 바뀔 때만 리렌더링 발생 | ||
| return useSyncExternalStore(manager.subscribe, manager.getCollaboratorsSnapshot); | ||
| } |
9 changes: 9 additions & 0 deletions
9
frontend/src/features/mindmap/shared_mindmap/hooks/useCursors.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { useSyncExternalStore } from "react"; | ||
|
|
||
| import CollaboratorsManager from "@/features/mindmap/shared_mindmap/utils/CollaboratorManager"; | ||
|
|
||
| // hooks/useCursors.ts (신규) | ||
| export function useCursors(manager: CollaboratorsManager) { | ||
| // cursorsCache 참조가 바뀔 때(마우스 움직일 때)마다 리렌더링 발생 | ||
| return useSyncExternalStore(manager.subscribe, manager.getCursorsSnapshot); | ||
| } |
23 changes: 23 additions & 0 deletions
23
frontend/src/features/mindmap/shared_mindmap/hooks/useNode.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
34 changes: 34 additions & 0 deletions
34
frontend/src/features/mindmap/shared_mindmap/hooks/useNodeResizeObserver.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; | ||
| 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; | ||
| } |
31 changes: 31 additions & 0 deletions
31
frontend/src/features/mindmap/shared_mindmap/hooks/useOfflineMindmap.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]); | ||
| }; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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'문자열을 기본값으로 사용하는 것은 나중에 예측하기 어려운 런타임 오류를 발생시킬 수 있습니다. 애플리케이션 시작 시점에 필요한 환경 변수가 없는 경우, 명시적으로 오류를 발생시켜 빠르게 문제를 인지하고 수정할 수 있도록 하는 것이 좋습니다.