Skip to content

Commit

Permalink
Add scroll navigation to user's location from profile view (#316)
Browse files Browse the repository at this point in the history
* Rename to UserPresenceList

* Fix typo Authorized

* Add useUserPresence hook

* Refactor useYorkieDocument hook

* Fix import Presence path

* Make clicking user profiles scroll the view to their respective sections

* Fix initialPresence init cursor

* Add lazy loading for UserPresenceList

* Replace useList with useState in useUserPresence hook

* Handle null state in cursor

* Remove unnecessary lazy loading
  • Loading branch information
choidabom authored Aug 26, 2024
1 parent 2ca8d32 commit 995e4d0
Show file tree
Hide file tree
Showing 8 changed files with 173 additions and 121 deletions.
27 changes: 13 additions & 14 deletions frontend/src/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down
52 changes: 4 additions & 48 deletions frontend/src/components/headers/DocumentHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,71 +15,27 @@ 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<Presence>([]);
const { presenceList } = useUserPresence(editorState.doc);

useEffect(() => {
if (editorState.shareRole === "READ") {
dispatch(setMode("read"));
}
}, [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));
Expand Down Expand Up @@ -130,7 +86,7 @@ function DocumentHeader() {
<DownloadMenu />
</Stack>
<Stack direction="row" alignItems="center" gap={1}>
<UserPresence presenceList={presenceList} />
<UserPresenceList presenceList={presenceList} />
{!editorState.shareRole && <ShareButton />}
<ThemeButton />
</Stack>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement | null>(null);
const popoverOpen = Boolean(anchorEl);
const editorStore = useSelector(selectEditor);

const handleOpenPopover = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
Expand All @@ -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) => (
<Tooltip key={presence.clientID} title={presence.presence.name}>
<Avatar alt={presence.presence.name} sx={{ bgcolor: presence.presence.color }}>
<Avatar
onClick={() => handleScrollToUserLocation(presence)}
alt={presence.presence.name}
sx={{ bgcolor: presence.presence.color }}
>
{presence.presence.name[0]}
</Avatar>
</Tooltip>
Expand Down Expand Up @@ -62,7 +81,11 @@ function UserPresence(props: UserPresenceProps) {
<Paper sx={{ padding: 2 }}>
<Typography variant="subtitle2">Additional Users</Typography>
{hiddenAvatars.map((presence) => (
<ListItem key={presence.clientID} sx={{ paddingY: 1 }}>
<ListItem
key={presence.clientID}
sx={{ paddingY: 1 }}
onClick={() => handleScrollToUserLocation(presence)}
>
<ListItemAvatar>
<Avatar
sx={{
Expand All @@ -89,4 +112,4 @@ function UserPresence(props: UserPresenceProps) {
);
}

export default UserPresence;
export default UserPresenceList;
40 changes: 40 additions & 0 deletions frontend/src/hooks/useUserPresence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { ActorID } from "yorkie-js-sdk";
import { CodePairDocType } from "../store/editorSlice";
import { YorkieCodeMirrorPresenceType } from "../utils/yorkie/yorkieSync";

export type Presence = {
clientID: ActorID;
presence: YorkieCodeMirrorPresenceType;
};

export const useUserPresence = (doc: CodePairDocType | null) => {
const [presenceList, setPresenceList] = useState<Presence[]>([]);

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 };
};
120 changes: 76 additions & 44 deletions frontend/src/hooks/useYorkieDocument.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand All @@ -16,65 +20,93 @@ export const useYorkieDocument = (
const [searchParams] = useSearchParams();
const authStore = useSelector(selectAuth);
const [client, setClient] = useState<yorkie.Client | null>(null);
const [doc, setDoc] = useState<yorkie.Document<
YorkieCodeMirrorDocType,
YorkieCodeMirrorPresenceType
> | null>(null);
const cleanUpYorkieDocument = useCallback(async () => {
if (!client || !doc) return;

await client?.detach(doc);
await client?.deactivate();
}, [client, doc]);
const [doc, setDoc] = useState<CodePairDocType | null>(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 () => {
Expand Down
Loading

0 comments on commit 995e4d0

Please sign in to comment.