From c2416ad072541728b3c9cb2e3411dcf189506b87 Mon Sep 17 00:00:00 2001 From: yewonJin Date: Tue, 19 Nov 2024 20:05:02 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EC=A0=91=EC=86=8D=20=EC=9D=B8?= =?UTF-8?q?=EC=9B=90=20=EC=B2=B4=ED=81=AC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?user=20store=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend/src/lib/utils.ts | 20 +++++++++++ apps/frontend/src/store/useUserStore.ts | 46 +++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 apps/frontend/src/store/useUserStore.ts diff --git a/apps/frontend/src/lib/utils.ts b/apps/frontend/src/lib/utils.ts index a5ef1935..e172f8d7 100644 --- a/apps/frontend/src/lib/utils.ts +++ b/apps/frontend/src/lib/utils.ts @@ -4,3 +4,23 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function getRandomColor() { + const COLORS = [ + "#7d7b94", + "#41c76d", + "#f86e7e", + "#f6b8b8", + "#f7d353", + "#3b5bf7", + "#59cbf7", + ] as const; + + return COLORS[Math.floor(Math.random() * COLORS.length)]; +} + +export function getRandomHexString(length = 10) { + return [...Array(length)] + .map(() => Math.floor(Math.random() * 16).toString(16)) + .join(""); +} diff --git a/apps/frontend/src/store/useUserStore.ts b/apps/frontend/src/store/useUserStore.ts new file mode 100644 index 00000000..4bfa52d4 --- /dev/null +++ b/apps/frontend/src/store/useUserStore.ts @@ -0,0 +1,46 @@ +import { create } from "zustand"; +import * as Y from "yjs"; +import { SocketIOProvider } from "y-socket.io"; + +import { getRandomColor, getRandomHexString } from "@/lib/utils"; + +export interface User { + clientId: string; + color: string; + currentPageId: string | null; +} + +interface UserStore { + provider: SocketIOProvider; + users: User[]; + currentUser: User; + setUsers: (users: User[]) => void; + setCurrentUser: (user: User) => void; +} + +const useUserStore = create((set) => ({ + provider: new SocketIOProvider( + import.meta.env.VITE_WS_URL, + `users`, + new Y.Doc(), + { + autoConnect: true, + disableBc: false, + }, + { + reconnectionDelayMax: 10000, + timeout: 5000, + transports: ["websocket", "polling"], + }, + ), + users: [], + currentUser: { + clientId: getRandomHexString(10), + color: getRandomColor(), + currentPageId: null, + }, + setUsers: (users: User[]) => set({ users }), + setCurrentUser: (user: User) => set({ currentUser: user }), +})); + +export default useUserStore; From f7a072a324cd87925b9932f88c758c06abd0fe79 Mon Sep 17 00:00:00 2001 From: yewonJin Date: Tue, 19 Nov 2024 20:05:10 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=ED=95=98=EB=8A=94=20hooks=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend/src/App.tsx | 5 +++ apps/frontend/src/hooks/useSyncedUsers.ts | 52 +++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 apps/frontend/src/hooks/useSyncedUsers.ts diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 737f64b9..f7f9597e 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + import Sidebar from "./components/sidebar"; import HoverTrigger from "./components/HoverTrigger"; import EditorView from "./components/EditorView"; @@ -6,9 +7,13 @@ import SideWrapper from "./components/layout/SideWrapper"; import Canvas from "./components/canvas"; import ScrollWrapper from "./components/layout/ScrollWrapper"; +import { useSyncedUsers } from "./hooks/useSyncedUsers"; + const queryClient = new QueryClient(); function App() { + useSyncedUsers(); + return (
diff --git a/apps/frontend/src/hooks/useSyncedUsers.ts b/apps/frontend/src/hooks/useSyncedUsers.ts new file mode 100644 index 00000000..8e8f1326 --- /dev/null +++ b/apps/frontend/src/hooks/useSyncedUsers.ts @@ -0,0 +1,52 @@ +import { useEffect } from "react"; + +import usePageStore from "@/store/usePageStore"; +import useUserStore, { User } from "@/store/useUserStore"; + +export const useSyncedUsers = () => { + const { currentPage } = usePageStore(); + const { provider, currentUser, setCurrentUser, setUsers } = useUserStore(); + + const updateUsersFromAwareness = () => { + const values = Array.from( + provider.awareness.getStates().values(), + ) as User[]; + setUsers(values); + }; + + const getLocalStorageUser = (): User | null => { + const userData = localStorage.getItem("currentUser"); + return userData ? JSON.parse(userData) : null; + }; + + useEffect(() => { + if (currentPage === null) return; + + const updatedUser: User = { + ...currentUser, + currentPageId: currentPage.toString(), + }; + + setCurrentUser(updatedUser); + provider.awareness.setLocalState(updatedUser); + }, [currentPage]); + + useEffect(() => { + const localStorageUser = getLocalStorageUser(); + + if (!localStorageUser) { + localStorage.setItem("currentUser", JSON.stringify(currentUser)); + } else { + setCurrentUser(localStorageUser); + provider.awareness.setLocalState(localStorageUser); + } + + updateUsersFromAwareness(); + + provider.awareness.on("change", updateUsersFromAwareness); + + return () => { + provider.awareness.off("change", updateUsersFromAwareness); + }; + }, []); +}; From 45a968890c84ecd64ef28e5fe54fd77ad46c6461 Mon Sep 17 00:00:00 2001 From: yewonJin Date: Tue, 19 Nov 2024 20:05:15 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EB=85=B8=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EC=A0=91=EC=86=8D=20=EC=9C=A0=EC=A0=80=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/canvas/NoteNode.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/frontend/src/components/canvas/NoteNode.tsx b/apps/frontend/src/components/canvas/NoteNode.tsx index f63d4412..cae5d3b8 100644 --- a/apps/frontend/src/components/canvas/NoteNode.tsx +++ b/apps/frontend/src/components/canvas/NoteNode.tsx @@ -1,11 +1,14 @@ import { Handle, NodeProps, Position, type Node } from "@xyflow/react"; import usePageStore from "@/store/usePageStore"; +import useUserStore from "@/store/useUserStore"; +import { cn } from "@/lib/utils"; export type NoteNodeData = { title: string; id: number }; export type NoteNodeType = Node; export function NoteNode({ data }: NodeProps) { const { setCurrentPage } = usePageStore(); + const { users } = useUserStore(); const handleNodeClick = () => { const id = data.id; @@ -46,6 +49,24 @@ export function NoteNode({ data }: NodeProps) { isConnectable={true} /> {data.title} +
+ {users + .filter((user) => user.currentPageId === data.id.toString()) + .map((user) => ( +
+
+ {user.clientId} +
+
+ ))} +
); } From 9f024fb76a160a5d8fa158b650150fbbbebf122b Mon Sep 17 00:00:00 2001 From: yewonJin Date: Tue, 19 Nov 2024 20:13:23 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20=EA=B3=B5=EC=9A=A9=20=EC=A0=91?= =?UTF-8?q?=EC=86=8D=20=EC=9D=B8=EC=9B=90=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend/src/components/EditorView.tsx | 14 ++++++++-- .../src/components/canvas/NoteNode.tsx | 28 ++++++------------- .../components/commons/activeUser/index.tsx | 28 +++++++++++++++++++ 3 files changed, 49 insertions(+), 21 deletions(-) create mode 100644 apps/frontend/src/components/commons/activeUser/index.tsx diff --git a/apps/frontend/src/components/EditorView.tsx b/apps/frontend/src/components/EditorView.tsx index f6502e11..6a6c1f45 100644 --- a/apps/frontend/src/components/EditorView.tsx +++ b/apps/frontend/src/components/EditorView.tsx @@ -5,11 +5,14 @@ import * as Y from "yjs"; import { SocketIOProvider } from "y-socket.io"; import Editor from "./editor"; -import usePageStore from "@/store/usePageStore"; -import { usePage, useUpdatePage } from "@/hooks/usePages"; import EditorLayout from "./layout/EditorLayout"; import EditorTitle from "./editor/EditorTitle"; import SaveStatus from "./editor/ui/SaveStatus"; +import ActiveUser from "./commons/activeUser"; + +import usePageStore from "@/store/usePageStore"; +import useUserStore from "@/store/useUserStore"; +import { usePage, useUpdatePage } from "@/hooks/usePages"; export default function EditorView() { const { currentPage } = usePageStore(); @@ -17,6 +20,7 @@ export default function EditorView() { const [saveStatus, setSaveStatus] = useState<"saved" | "unsaved">("saved"); const [ydoc, setYDoc] = useState(null); const [provider, setProvider] = useState(null); + const { users } = useUserStore(); useEffect(() => { if (!currentPage) return; @@ -88,6 +92,12 @@ export default function EditorView() { currentPage={currentPage} pageContent={pageContent} /> + user.currentPageId === currentPage.toString(), + )} + /> ; @@ -49,24 +51,12 @@ export function NoteNode({ data }: NodeProps) { isConnectable={true} /> {data.title} -
- {users - .filter((user) => user.currentPageId === data.id.toString()) - .map((user) => ( -
-
- {user.clientId} -
-
- ))} -
+ user.currentPageId === data.id.toString(), + )} + /> ); } diff --git a/apps/frontend/src/components/commons/activeUser/index.tsx b/apps/frontend/src/components/commons/activeUser/index.tsx new file mode 100644 index 00000000..1001ddb4 --- /dev/null +++ b/apps/frontend/src/components/commons/activeUser/index.tsx @@ -0,0 +1,28 @@ +import { User } from "@/store/useUserStore"; +import { cn } from "@/lib/utils"; + +interface ActiveUserProps { + users: User[]; + className?: string; +} + +export default function ActiveUser({ users, className }: ActiveUserProps) { + return ( +
+ {users.map((user) => ( +
+
+ {user.clientId} +
+
+ ))} +
+ ); +} From e885ad2e0680cb05bf0da6f0b0a7e668fc014635 Mon Sep 17 00:00:00 2001 From: yewonJin Date: Tue, 19 Nov 2024 21:09:19 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=EC=BB=A4=EC=84=9C=EC=97=90=20clien?= =?UTF-8?q?tId=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend/src/components/CursorView.tsx | 11 ++++++++++- apps/frontend/src/components/cursor/index.tsx | 10 +++++++++- apps/frontend/src/hooks/useCursor.ts | 19 +++++++------------ 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/apps/frontend/src/components/CursorView.tsx b/apps/frontend/src/components/CursorView.tsx index 1c01c858..e3762af8 100644 --- a/apps/frontend/src/components/CursorView.tsx +++ b/apps/frontend/src/components/CursorView.tsx @@ -3,6 +3,7 @@ import { useReactFlow } from "@xyflow/react"; import { type AwarenessState } from "@/hooks/useCursor"; import Cursor from "./cursor"; import { useMemo } from "react"; +import useUserStore from "@/store/useUserStore"; interface CollaborativeCursorsProps { cursors: Map; @@ -11,8 +12,15 @@ interface CollaborativeCursorsProps { export function CollaborativeCursors({ cursors }: CollaborativeCursorsProps) { const { flowToScreenPosition } = useReactFlow(); + const { currentUser } = useUserStore(); + const validCursors = useMemo( - () => Array.from(cursors.values()).filter((cursor) => cursor.cursor), + () => + Array.from(cursors.values()).filter( + (cursor) => + cursor.cursor && + (cursor.clientId as unknown as string) !== currentUser.clientId, + ), [cursors], ); @@ -26,6 +34,7 @@ export function CollaborativeCursors({ cursors }: CollaborativeCursorsProps) { y: cursor.cursor!.y, })} color={cursor.color} + clientId={cursor.clientId.toString()} /> ))} diff --git a/apps/frontend/src/components/cursor/index.tsx b/apps/frontend/src/components/cursor/index.tsx index 12a61de9..a48cccab 100644 --- a/apps/frontend/src/components/cursor/index.tsx +++ b/apps/frontend/src/components/cursor/index.tsx @@ -7,10 +7,15 @@ export interface Coors { interface CursorProps { coors: Coors; + clientId: string; color?: string; } -export default function Cursor({ coors, color = "#ffb8b9" }: CursorProps) { +export default function Cursor({ + coors, + color = "#ffb8b9", + clientId, +}: CursorProps) { const { x, y } = coors; return ( @@ -35,6 +40,9 @@ export default function Cursor({ coors, color = "#ffb8b9" }: CursorProps) { strokeWidth="4" /> +

+ {clientId} +

); } diff --git a/apps/frontend/src/hooks/useCursor.ts b/apps/frontend/src/hooks/useCursor.ts index e495ac42..e2501502 100644 --- a/apps/frontend/src/hooks/useCursor.ts +++ b/apps/frontend/src/hooks/useCursor.ts @@ -2,6 +2,7 @@ import { useReactFlow, type XYPosition } from "@xyflow/react"; import * as Y from "yjs"; import { useCallback, useEffect, useRef, useState } from "react"; import { SocketIOProvider } from "y-socket.io"; +import useUserStore from "@/store/useUserStore"; const CURSOR_COLORS = [ "#7d7b94", @@ -33,10 +34,7 @@ export function useCollaborativeCursors({ const [cursors, setCursors] = useState>( new Map(), ); - const clientId = useRef(null); - const userColor = useRef( - CURSOR_COLORS[Math.floor(Math.random() * CURSOR_COLORS.length)], - ); + const { currentUser } = useUserStore(); useEffect(() => { const wsProvider = new SocketIOProvider( @@ -55,21 +53,18 @@ export function useCollaborativeCursors({ ); provider.current = wsProvider; - clientId.current = wsProvider.awareness.clientID; wsProvider.awareness.setLocalState({ cursor: null, - color: userColor.current, - clientId: wsProvider.awareness.clientID, + color: currentUser.color, + clientId: currentUser.clientId, }); wsProvider.awareness.on("change", () => { const states = new Map( Array.from( wsProvider.awareness.getStates() as Map, - ).filter( - ([key, state]) => key !== clientId.current && state.cursor !== null, - ), + ).filter(([key, state]) => state.cursor !== null), ); setCursors(states); }); @@ -90,8 +85,8 @@ export function useCollaborativeCursors({ provider.current.awareness.setLocalState({ cursor, - color: userColor.current, - clientId: provider.current.awareness.clientID, + color: currentUser.color, + clientId: currentUser.clientId, }); }, [flowInstance], From d489d0ca4c7f710ef40adec6da1497fabc53e4cf Mon Sep 17 00:00:00 2001 From: yewonJin Date: Tue, 19 Nov 2024 21:27:47 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20lint=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend/src/hooks/useCursor.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/apps/frontend/src/hooks/useCursor.ts b/apps/frontend/src/hooks/useCursor.ts index e2501502..fc356d87 100644 --- a/apps/frontend/src/hooks/useCursor.ts +++ b/apps/frontend/src/hooks/useCursor.ts @@ -4,16 +4,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { SocketIOProvider } from "y-socket.io"; import useUserStore from "@/store/useUserStore"; -const CURSOR_COLORS = [ - "#7d7b94", - "#41c76d", - "#f86e7e", - "#f6b8b8", - "#f7d353", - "#3b5bf7", - "#59cbf7", -] as const; - export interface AwarenessState { cursor: XYPosition | null; color: string; @@ -64,7 +54,7 @@ export function useCollaborativeCursors({ const states = new Map( Array.from( wsProvider.awareness.getStates() as Map, - ).filter(([key, state]) => state.cursor !== null), + ).filter(([, state]) => state.cursor !== null), ); setCursors(states); });