Skip to content
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

Add scroll navigation to user's location from profile view #316

Merged
merged 11 commits into from
Aug 26, 2024
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} />
choidabom marked this conversation as resolved.
Show resolved Hide resolved
{!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