diff --git a/frontend/src/components/editor/Editor.tsx b/frontend/src/components/editor/Editor.tsx index d7010279..2682c81e 100644 --- a/frontend/src/components/editor/Editor.tsx +++ b/frontend/src/components/editor/Editor.tsx @@ -1,21 +1,20 @@ -import { useCallback, useEffect, useState } from "react"; -import { EditorState } from "@codemirror/state"; -import { EditorView, basicSetup } from "codemirror"; import { markdown } from "@codemirror/lang-markdown"; -import { useDispatch, useSelector } from "react-redux"; -import { selectEditor, setCmView } from "../../store/editorSlice"; -import { yorkieCodeMirror } from "../../utils/yorkie"; -import { xcodeLight, xcodeDark } from "@uiw/codemirror-theme-xcode"; -import { useCurrentTheme } from "../../hooks/useCurrentTheme"; +import { EditorState } from "@codemirror/state"; import { keymap, ViewUpdate } from "@codemirror/view"; -import { intelligencePivot } from "../../utils/intelligence/intelligencePivot"; -import { imageUploader } from "../../utils/imageUploader"; -import { useCreateUploadUrlMutation, useUploadFileMutation } from "../../hooks/api/file"; -import { selectWorkspace } from "../../store/workspaceSlice"; +import { xcodeDark, xcodeLight } from "@uiw/codemirror-theme-xcode"; +import { basicSetup, EditorView } from "codemirror"; +import { useCallback, useEffect, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; import { ScrollSyncPane } from "react-scroll-sync"; +import { useCreateUploadUrlMutation, useUploadFileMutation } from "../../hooks/api/file"; +import { useCurrentTheme } from "../../hooks/useCurrentTheme"; +import { FormatType, ToolBarState, useFormatUtils } from "../../hooks/useFormatUtils"; +import { selectEditor, setCmView } from "../../store/editorSlice"; import { selectSetting } from "../../store/settingSlice"; -import { ToolBarState, useFormatUtils, FormatType } from "../../hooks/useFormatUtils"; - +import { selectWorkspace } from "../../store/workspaceSlice"; +import { imageUploader } from "../../utils/imageUploader"; +import { intelligencePivot } from "../../utils/intelligence/intelligencePivot"; +import { yorkieCodeMirror } from "../../utils/yorkie"; import ToolBar from "./ToolBar"; function Editor() { diff --git a/frontend/src/components/headers/DocumentHeader.tsx b/frontend/src/components/headers/DocumentHeader.tsx index e10bcaad..75f014b4 100644 --- a/frontend/src/components/headers/DocumentHeader.tsx +++ b/frontend/src/components/headers/DocumentHeader.tsx @@ -15,36 +15,20 @@ import { import { useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useNavigate } from "react-router-dom"; -import { useList } from "react-use"; -import { ActorID } from "yorkie-js-sdk"; +import { useUserPresence } from "../../hooks/useUserPresence"; import { EditorModeType, selectEditor, setMode } from "../../store/editorSlice"; import { selectWorkspace } from "../../store/workspaceSlice"; -import { YorkieCodeMirrorPresenceType } from "../../utils/yorkie/yorkieSync"; import DownloadMenu from "../common/DownloadMenu"; import ShareButton from "../common/ShareButton"; import ThemeButton from "../common/ThemeButton"; -import UserPresence from "./UserPresence"; - -export type Presence = { - clientID: ActorID; - presence: YorkieCodeMirrorPresenceType; -}; +import UserPresenceList from "./UserPresenceList"; function DocumentHeader() { const dispatch = useDispatch(); const navigate = useNavigate(); const editorState = useSelector(selectEditor); const workspaceState = useSelector(selectWorkspace); - const [ - presenceList, - { - set: setPresenceList, - push: pushToPresenceList, - removeAt: removePresenceAt, - clear: clearPresenceList, - filter: filterPresenceList, - }, - ] = useList([]); + const { presenceList } = useUserPresence(editorState.doc); useEffect(() => { if (editorState.shareRole === "READ") { @@ -52,34 +36,6 @@ function DocumentHeader() { } }, [dispatch, editorState.shareRole]); - useEffect(() => { - if (!editorState.doc) return; - - setPresenceList(editorState.doc.getPresences()); - - const unsubscribe = editorState.doc.subscribe("others", (event) => { - if (event.type === "watched") { - setPresenceList(editorState.doc?.getPresences?.() ?? []); - } - - if (event.type === "unwatched") { - filterPresenceList((presence) => presence.clientID !== event.value.clientID); - } - }); - - return () => { - unsubscribe(); - clearPresenceList(); - }; - }, [ - editorState.doc, - clearPresenceList, - pushToPresenceList, - removePresenceAt, - setPresenceList, - filterPresenceList, - ]); - const handleChangeMode = (newMode: EditorModeType) => { if (!newMode) return; dispatch(setMode(newMode)); @@ -130,7 +86,7 @@ function DocumentHeader() { - + {!editorState.shareRole && } diff --git a/frontend/src/components/headers/UserPresence.tsx b/frontend/src/components/headers/UserPresenceList.tsx similarity index 67% rename from frontend/src/components/headers/UserPresence.tsx rename to frontend/src/components/headers/UserPresenceList.tsx index 503ed430..849f0713 100644 --- a/frontend/src/components/headers/UserPresence.tsx +++ b/frontend/src/components/headers/UserPresenceList.tsx @@ -9,17 +9,21 @@ import { Tooltip, Typography, } from "@mui/material"; +import { EditorView } from "codemirror"; import { useState } from "react"; -import { Presence } from "./DocumentHeader"; +import { useSelector } from "react-redux"; +import { Presence } from "../../hooks/useUserPresence"; +import { selectEditor } from "../../store/editorSlice"; -interface UserPresenceProps { +interface UserPresenceListProps { presenceList: Presence[]; } -function UserPresence(props: UserPresenceProps) { +function UserPresenceList(props: UserPresenceListProps) { const { presenceList } = props; const [anchorEl, setAnchorEl] = useState(null); const popoverOpen = Boolean(anchorEl); + const editorStore = useSelector(selectEditor); const handleOpenPopover = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -29,12 +33,27 @@ function UserPresence(props: UserPresenceProps) { setAnchorEl(null); }; + const handleScrollToUserLocation = (presence: Presence) => { + const cursor = presence.presence.cursor; + if (cursor === null) return; + + editorStore.cmView?.dispatch({ + effects: EditorView.scrollIntoView(cursor[0], { + y: "center", + }), + }); + }; + const MAX_VISIBLE_AVATARS = 4; const hiddenAvatars = presenceList.slice(MAX_VISIBLE_AVATARS); const renderAvatar = (presence: Presence) => ( - + handleScrollToUserLocation(presence)} + alt={presence.presence.name} + sx={{ bgcolor: presence.presence.color }} + > {presence.presence.name[0]} @@ -62,7 +81,11 @@ function UserPresence(props: UserPresenceProps) { Additional Users {hiddenAvatars.map((presence) => ( - + handleScrollToUserLocation(presence)} + > { + const [presenceList, setPresenceList] = useState([]); + + useEffect(() => { + if (!doc) return; + + const updatePresences = () => setPresenceList(doc.getPresences() ?? []); + + updatePresences(); + + const unsubscribe = doc.subscribe("others", (event) => { + if (event.type === "presence-changed" || event.type === "watched") { + updatePresences(); + } + + if (event.type === "unwatched") { + setPresenceList((prev) => + prev.filter((presence) => presence.clientID !== event.value.clientID) + ); + } + }); + + return () => { + unsubscribe(); + setPresenceList([]); + }; + }, [doc]); + + return { presenceList }; +}; diff --git a/frontend/src/hooks/useYorkieDocument.ts b/frontend/src/hooks/useYorkieDocument.ts index 20a485d2..d853bd31 100644 --- a/frontend/src/hooks/useYorkieDocument.ts +++ b/frontend/src/hooks/useYorkieDocument.ts @@ -1,11 +1,15 @@ -import { useCallback, useEffect, useState } from "react"; -import * as yorkie from "yorkie-js-sdk"; -import { YorkieCodeMirrorDocType, YorkieCodeMirrorPresenceType } from "../utils/yorkie/yorkieSync"; import Color from "color"; import randomColor from "randomcolor"; -import { useSearchParams } from "react-router-dom"; +import { useCallback, useEffect, useState } from "react"; import { useSelector } from "react-redux"; +import { useSearchParams } from "react-router-dom"; +import * as yorkie from "yorkie-js-sdk"; import { selectAuth } from "../store/authSlice"; +import { CodePairDocType } from "../store/editorSlice"; +import { YorkieCodeMirrorDocType, YorkieCodeMirrorPresenceType } from "../utils/yorkie/yorkieSync"; + +const YORKIE_API_ADDR = import.meta.env.VITE_YORKIE_API_ADDR; +const YORKIE_API_KEY = import.meta.env.VITE_YORKIE_API_KEY; yorkie.setLogLevel(4); @@ -16,65 +20,93 @@ export const useYorkieDocument = ( const [searchParams] = useSearchParams(); const authStore = useSelector(selectAuth); const [client, setClient] = useState(null); - const [doc, setDoc] = useState | null>(null); - const cleanUpYorkieDocument = useCallback(async () => { - if (!client || !doc) return; - - await client?.detach(doc); - await client?.deactivate(); - }, [client, doc]); + const [doc, setDoc] = useState(null); - useEffect(() => { - let mounted = true; - if (!yorkieDocumentId || !presenceName || doc || client) return; - - let yorkieToken = `default:${authStore.accessToken}`; - - if (searchParams.get("token")) { - yorkieToken = `share:${searchParams.get("token")}`; - } + const getYorkieToken = useCallback(() => { + const shareToken = searchParams.get("token"); + return shareToken ? `share:${shareToken}` : `default:${authStore.accessToken}`; + }, [authStore.accessToken, searchParams]); - const initializeYorkie = async () => { - const newClient = new yorkie.Client(import.meta.env.VITE_YORKIE_API_ADDR, { - apiKey: import.meta.env.VITE_YORKIE_API_KEY, - token: yorkieToken, - }); - await newClient.activate(); + const createYorkieClient = useCallback(async (yorkieToken: string) => { + const newClient = new yorkie.Client(YORKIE_API_ADDR, { + apiKey: YORKIE_API_KEY, + token: yorkieToken, + }); + await newClient.activate(); + return newClient; + }, []); - const newDoc = new yorkie.Document< + const createYorkieDocument = useCallback( + (client: yorkie.Client, yorkieDocumentId: string, presenceName: string) => { + const newDocument = new yorkie.Document< YorkieCodeMirrorDocType, YorkieCodeMirrorPresenceType - >(yorkieDocumentId, { - enableDevtools: true, - }); - - await newClient.attach(newDoc, { + >(yorkieDocumentId, { enableDevtools: true }); + return client.attach(newDocument, { initialPresence: { name: presenceName, color: Color(randomColor()).fade(0.15).toString(), selection: null, + cursor: null, }, }); + }, + [] + ); - // Clean up if the component is unmounted before the initialization is done - if (!mounted) { - await newClient.detach(newDoc); - await newClient.deactivate(); - return; - } + const cleanUpYorkieDocument = useCallback(async () => { + if (!client || !doc) return; + + try { + await client.detach(doc); + await client.deactivate(); + } catch (error) { + console.error("Error during Yorkie cleanup:", error); + } + }, [client, doc]); - setClient(newClient); - setDoc(newDoc); + useEffect(() => { + let mounted = true; + if (!yorkieDocumentId || !presenceName || doc || client) return; + + const initializeYorkie = async () => { + try { + const yorkieToken = getYorkieToken(); + const newClient = await createYorkieClient(yorkieToken); + const newDoc = await createYorkieDocument( + newClient, + yorkieDocumentId, + presenceName + ); + + // Clean up if the component is unmounted before the initialization is done + if (!mounted) { + await newClient.detach(newDoc); + await newClient.deactivate(); + return; + } + + setClient(newClient); + setDoc(newDoc); + } catch (error) { + console.error("Error initializing Yorkie: ", error); + } }; + initializeYorkie(); return () => { mounted = false; }; - }, [presenceName, yorkieDocumentId, doc, client, authStore.accessToken, searchParams]); + }, [ + presenceName, + yorkieDocumentId, + doc, + client, + getYorkieToken, + createYorkieClient, + createYorkieDocument, + ]); useEffect(() => { return () => { diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 79dd1d85..4388001e 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -3,17 +3,17 @@ import GuestRoute from "./components/common/GuestRoute"; import PrivateRoute from "./components/common/PrivateRoute"; import DocumentLayout from "./components/layouts/DocumentLayout"; import MainLayout from "./components/layouts/MainLayout"; +import SettingLayout from "./components/layouts/SettingLayout"; import WorkspaceLayout from "./components/layouts/WorkspaceLayout"; import Index from "./pages/Index"; import CallbackIndex from "./pages/auth/callback/Index"; +import NotFound from "./pages/error"; +import ProfileIndex from "./pages/settings/profile/Index"; import WorkspaceIndex from "./pages/workspace/Index"; import DocumentIndex from "./pages/workspace/document/Index"; import DocumentShareIndex from "./pages/workspace/document/share/Index"; import JoinIndex from "./pages/workspace/join/Index"; import MemberIndex from "./pages/workspace/member/Index"; -import NotFound from "./pages/error"; -import ProfileIndex from "./pages/settings/profile/Index"; -import SettingLayout from "./components/layouts/SettingLayout"; interface CodePairRoute { path: string; @@ -29,7 +29,7 @@ interface CodePairRoute { const enum AccessType { PUBLIC, // Everyone can access (Default) - PRIVATE, // Authroized user can access only + PRIVATE, // Authorized user can access only GUEST, // Not authorized user can access only } diff --git a/frontend/src/utils/yorkie/remoteSelection.ts b/frontend/src/utils/yorkie/remoteSelection.ts index b4e62cbd..6487800f 100644 --- a/frontend/src/utils/yorkie/remoteSelection.ts +++ b/frontend/src/utils/yorkie/remoteSelection.ts @@ -1,17 +1,15 @@ -import * as cmView from "@codemirror/view"; - import * as cmState from "@codemirror/state"; +import * as cmView from "@codemirror/view"; import * as dom from "lib0/dom"; import * as pair from "lib0/pair"; +import _ from "lodash"; import * as yorkie from "yorkie-js-sdk"; - import { - YorkieSyncConfig, YorkieCodeMirrorDocType, YorkieCodeMirrorPresenceType, + YorkieSyncConfig, yorkieSyncFacet, } from "./yorkieSync.js"; -import _ from "lodash"; export const yorkieRemoteSelectionsTheme = cmView.EditorView.baseTheme({ ".cm-ySelection": {}, @@ -151,15 +149,18 @@ export class YorkieRemoteSelectionsPluginValue { if (sel && root.content) { const selection = root.content.indexRangeToPosRange([sel.anchor, sel.head]); + const cursor = root.content.posRangeToIndexRange(selection); if (!_.isEqual(selection, presence.get("selection"))) { presence.set({ selection, + cursor, }); } } else if (presence.get("selection")) { presence.set({ selection: null, + cursor: null, }); } }); diff --git a/frontend/src/utils/yorkie/yorkieSync.ts b/frontend/src/utils/yorkie/yorkieSync.ts index 8c5ccdcb..eec7e022 100644 --- a/frontend/src/utils/yorkie/yorkieSync.ts +++ b/frontend/src/utils/yorkie/yorkieSync.ts @@ -10,6 +10,7 @@ export type YorkieCodeMirrorPresenceType = { color: string; name: string; selection: yorkie.TextPosStructRange | null; + cursor: [number, number] | null; }; export class YorkieSyncConfig<