mirror of
https://github.com/docmost/docmost.git
synced 2026-05-19 16:04:17 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eb3416b5c | |||
| c1cfe158cd | |||
| ab81903299 | |||
| 14698ebb05 | |||
| efa52ea4c8 | |||
| a4750bff56 | |||
| 5c9eed53c0 |
@@ -3,15 +3,3 @@ import { atom } from 'jotai';
|
|||||||
export const showCommentPopupAtom = atom<boolean>(false);
|
export const showCommentPopupAtom = atom<boolean>(false);
|
||||||
export const activeCommentIdAtom = atom<string>('');
|
export const activeCommentIdAtom = atom<string>('');
|
||||||
export const draftCommentIdAtom = atom<string>('');
|
export const draftCommentIdAtom = atom<string>('');
|
||||||
|
|
||||||
// Read-only comment state
|
|
||||||
export const showReadOnlyCommentPopupAtom = atom<boolean>(false);
|
|
||||||
export type YjsSelection = {
|
|
||||||
anchor: any;
|
|
||||||
head: any;
|
|
||||||
};
|
|
||||||
export type ReadOnlyCommentData = {
|
|
||||||
yjsSelection: YjsSelection;
|
|
||||||
selectedText: string;
|
|
||||||
};
|
|
||||||
export const readOnlyCommentDataAtom = atom<ReadOnlyCommentData | null>(null);
|
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
createComment,
|
createComment,
|
||||||
createReadOnlyComment,
|
|
||||||
CreateReadOnlyCommentData,
|
|
||||||
deleteComment,
|
deleteComment,
|
||||||
getPageComments,
|
getPageComments,
|
||||||
updateComment,
|
updateComment,
|
||||||
@@ -108,23 +106,4 @@ export function useDeleteCommentMutation(pageId?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateReadOnlyCommentMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation<IComment, Error, CreateReadOnlyCommentData>({
|
|
||||||
mutationFn: (data) => createReadOnlyComment(data),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.refetchQueries({ queryKey: RQ_KEY(data.pageId) });
|
|
||||||
notifications.show({ message: t("Comment created successfully") });
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
notifications.show({
|
|
||||||
message: t("Error creating comment"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
|
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
|
||||||
|
|||||||
@@ -40,20 +40,3 @@ export async function getPageComments(
|
|||||||
export async function deleteComment(commentId: string): Promise<void> {
|
export async function deleteComment(commentId: string): Promise<void> {
|
||||||
await api.post("/comments/delete", { commentId });
|
await api.post("/comments/delete", { commentId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateReadOnlyCommentData = {
|
|
||||||
pageId: string;
|
|
||||||
content: string;
|
|
||||||
selection?: string;
|
|
||||||
yjsSelection: {
|
|
||||||
anchor: any;
|
|
||||||
head: any;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function createReadOnlyComment(
|
|
||||||
data: CreateReadOnlyCommentData,
|
|
||||||
): Promise<IComment> {
|
|
||||||
const req = await api.post<IComment>("/comments/create-readonly", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import {
|
|
||||||
BubbleMenu,
|
|
||||||
isNodeSelection,
|
|
||||||
isTextSelection,
|
|
||||||
useEditor,
|
|
||||||
} from "@tiptap/react";
|
|
||||||
import { FC, useEffect, useRef } from "react";
|
|
||||||
import { IconMessage } from "@tabler/icons-react";
|
|
||||||
import classes from "./bubble-menu.module.css";
|
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
|
||||||
import {
|
|
||||||
readOnlyCommentDataAtom,
|
|
||||||
showReadOnlyCommentPopupAtom,
|
|
||||||
} from "@/features/comment/atoms/comment-atom";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { isCellSelection } from "@docmost/editor-ext";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ySyncPluginKey } from "y-prosemirror";
|
|
||||||
import { getRelativeSelection } from "y-prosemirror";
|
|
||||||
|
|
||||||
type ReadOnlyBubbleMenuProps = {
|
|
||||||
editor: ReturnType<typeof useEditor>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ReadOnlyBubbleMenu: FC<ReadOnlyBubbleMenuProps> = ({ editor }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [showReadOnlyCommentPopup, setShowReadOnlyCommentPopup] = useAtom(
|
|
||||||
showReadOnlyCommentPopupAtom,
|
|
||||||
);
|
|
||||||
const [, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
|
|
||||||
const showPopupRef = useRef(showReadOnlyCommentPopup);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
showPopupRef.current = showReadOnlyCommentPopup;
|
|
||||||
}, [showReadOnlyCommentPopup]);
|
|
||||||
|
|
||||||
const handleCommentClick = () => {
|
|
||||||
if (!editor) return;
|
|
||||||
|
|
||||||
const view = editor.view;
|
|
||||||
const ystate = ySyncPluginKey.getState(view.state);
|
|
||||||
|
|
||||||
if (ystate?.binding) {
|
|
||||||
const selection = getRelativeSelection(ystate.binding, view.state);
|
|
||||||
const { from, to } = editor.state.selection;
|
|
||||||
const selectedText = editor.state.doc.textBetween(from, to);
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
setReadOnlyCommentData({
|
|
||||||
yjsSelection: {
|
|
||||||
anchor: selection.anchor,
|
|
||||||
head: selection.head,
|
|
||||||
},
|
|
||||||
selectedText,
|
|
||||||
});
|
|
||||||
setShowReadOnlyCommentPopup(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Don't render if editor is not available or is editable
|
|
||||||
if (!editor || editor.isEditable) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BubbleMenu
|
|
||||||
editor={editor}
|
|
||||||
pluginKey="readonly"
|
|
||||||
shouldShow={({ state, editor }) => {
|
|
||||||
// Safety check - don't show if editor became editable
|
|
||||||
if (!editor || editor.isEditable || editor.isDestroyed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { selection } = state;
|
|
||||||
const { empty, from, to } = selection;
|
|
||||||
|
|
||||||
if (
|
|
||||||
editor.isActive("image") ||
|
|
||||||
empty ||
|
|
||||||
isNodeSelection(selection) ||
|
|
||||||
isCellSelection(selection) ||
|
|
||||||
showPopupRef?.current
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if actual text is selected (not just empty block)
|
|
||||||
const hasText = state.doc.textBetween(from, to).length > 0;
|
|
||||||
return isTextSelection(selection) && hasText;
|
|
||||||
}}
|
|
||||||
tippyOptions={{
|
|
||||||
moveTransition: "transform 0.15s ease-out",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={classes.bubbleMenu}>
|
|
||||||
<Tooltip label={t("Comment")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
radius="0"
|
|
||||||
aria-label={t("Comment")}
|
|
||||||
style={{ border: "none" }}
|
|
||||||
onClick={handleCommentClick}
|
|
||||||
>
|
|
||||||
<IconMessage size={16} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</BubbleMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { Dialog, Group, Stack, Text } from "@mantine/core";
|
|
||||||
import { useClickOutside } from "@mantine/hooks";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import {
|
|
||||||
activeCommentIdAtom,
|
|
||||||
readOnlyCommentDataAtom,
|
|
||||||
showReadOnlyCommentPopupAtom,
|
|
||||||
} from "@/features/comment/atoms/comment-atom";
|
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
|
||||||
import { useCreateReadOnlyCommentMutation } from "@/features/comment/queries/comment-query";
|
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
|
||||||
import { useEditor } from "@tiptap/react";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
|
|
||||||
type ReadOnlyCommentDialogProps = {
|
|
||||||
editor: ReturnType<typeof useEditor>;
|
|
||||||
pageId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ReadOnlyCommentDialog({ editor, pageId }: ReadOnlyCommentDialogProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [comment, setComment] = useState("");
|
|
||||||
const [, setShowReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
|
||||||
const [readOnlyCommentData, setReadOnlyCommentData] = useAtom(
|
|
||||||
readOnlyCommentDataAtom,
|
|
||||||
);
|
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
|
||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
|
||||||
const useClickOutsideRef = useClickOutside(() => {
|
|
||||||
handleDialogClose();
|
|
||||||
});
|
|
||||||
const createCommentMutation = useCreateReadOnlyCommentMutation();
|
|
||||||
const { isPending } = createCommentMutation;
|
|
||||||
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
const handleDialogClose = () => {
|
|
||||||
setShowReadOnlyCommentPopup(false);
|
|
||||||
setReadOnlyCommentData(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddComment = async () => {
|
|
||||||
if (!readOnlyCommentData) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const commentData = {
|
|
||||||
pageId: pageId,
|
|
||||||
content: JSON.stringify(comment),
|
|
||||||
selection: readOnlyCommentData.selectedText,
|
|
||||||
yjsSelection: readOnlyCommentData.yjsSelection,
|
|
||||||
};
|
|
||||||
|
|
||||||
const createdComment =
|
|
||||||
await createCommentMutation.mutateAsync(commentData);
|
|
||||||
|
|
||||||
setActiveCommentId(createdComment.id);
|
|
||||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
|
||||||
const commentElement = document.querySelector(selector);
|
|
||||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
}, 400);
|
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: pageId,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setShowReadOnlyCommentPopup(false);
|
|
||||||
setReadOnlyCommentData(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCommentEditorChange = (newContent: any) => {
|
|
||||||
setComment(newContent);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
opened={true}
|
|
||||||
onClose={handleDialogClose}
|
|
||||||
ref={useClickOutsideRef}
|
|
||||||
size="lg"
|
|
||||||
radius="md"
|
|
||||||
w={300}
|
|
||||||
position={{ bottom: 500, right: 50 }}
|
|
||||||
withCloseButton
|
|
||||||
withBorder
|
|
||||||
>
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Group>
|
|
||||||
<CustomAvatar
|
|
||||||
size="sm"
|
|
||||||
avatarUrl={currentUser.user.avatarUrl}
|
|
||||||
name={currentUser.user.name}
|
|
||||||
/>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Text size="sm" fw={500} lineClamp={1}>
|
|
||||||
{currentUser.user.name}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<CommentEditor
|
|
||||||
onUpdate={handleCommentEditorChange}
|
|
||||||
onSave={handleAddComment}
|
|
||||||
placeholder={t("Write a comment")}
|
|
||||||
editable={true}
|
|
||||||
autofocus={true}
|
|
||||||
/>
|
|
||||||
<CommentActions onSave={handleAddComment} isLoading={isPending} />
|
|
||||||
</Stack>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReadOnlyCommentDialog;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
|||||||
|
import { ENCRYPTION_KEY_BITS } from "@excalidraw/common";
|
||||||
|
|
||||||
type LibraryItems = any;
|
type LibraryItems = any;
|
||||||
|
|
||||||
type LibraryPersistedData = {
|
type LibraryPersistedData = {
|
||||||
@@ -8,8 +10,8 @@ export interface LibraryPersistenceAdapter {
|
|||||||
load(metadata: { source: "load" | "save" }):
|
load(metadata: { source: "load" | "save" }):
|
||||||
| Promise<{ libraryItems: LibraryItems } | null>
|
| Promise<{ libraryItems: LibraryItems } | null>
|
||||||
| {
|
| {
|
||||||
libraryItems: LibraryItems;
|
libraryItems: LibraryItems;
|
||||||
}
|
}
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
save(libraryData: LibraryPersistedData): Promise<void> | void;
|
save(libraryData: LibraryPersistedData): Promise<void> | void;
|
||||||
@@ -25,7 +27,10 @@ export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
|
|||||||
return JSON.parse(data);
|
return JSON.parse(data);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error downloading Excalidraw library from localStorage", e);
|
console.error(
|
||||||
|
"Error downloading Excalidraw library from localStorage",
|
||||||
|
e,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -40,3 +45,124 @@ export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
|
||||||
|
if ("arrayBuffer" in blob) {
|
||||||
|
return blob.arrayBuffer();
|
||||||
|
}
|
||||||
|
// Safari
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
if (!event.target?.result) {
|
||||||
|
return reject(new Error("Couldn't convert blob to ArrayBuffer"));
|
||||||
|
}
|
||||||
|
resolve(event.target.result as ArrayBuffer);
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(blob);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IV_LENGTH_BYTES = 12;
|
||||||
|
|
||||||
|
// Pre-transform error: No known conditions for "./data/encryption" specifier in "@excalidraw/excalidraw" package
|
||||||
|
// Plugin: vite:import-analysis
|
||||||
|
// File: /Users/lite/WebstormProjects/docmost-ee/apps/client/src/features/editor/components/excalidraw/use-excalidraw-collab.ts:11:7
|
||||||
|
// 7 | decryptData,
|
||||||
|
// 8 | encryptData
|
||||||
|
// 9 | } from "@excalidraw/excalidraw/data/encryption";
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
export const createIV = (): Uint8Array<ArrayBuffer> => {
|
||||||
|
const arr = new Uint8Array(IV_LENGTH_BYTES);
|
||||||
|
return window.crypto.getRandomValues(arr);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateEncryptionKey = async <
|
||||||
|
T extends "string" | "cryptoKey" = "string",
|
||||||
|
>(
|
||||||
|
returnAs?: T,
|
||||||
|
): Promise<T extends "cryptoKey" ? CryptoKey : string> => {
|
||||||
|
const key = await window.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
length: ENCRYPTION_KEY_BITS,
|
||||||
|
},
|
||||||
|
true, // extractable
|
||||||
|
["encrypt", "decrypt"],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
returnAs === "cryptoKey"
|
||||||
|
? key
|
||||||
|
: (await window.crypto.subtle.exportKey("jwk", key)).k
|
||||||
|
) as T extends "cryptoKey" ? CryptoKey : string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCryptoKey = (key: string, usage: KeyUsage) =>
|
||||||
|
window.crypto.subtle.importKey(
|
||||||
|
"jwk",
|
||||||
|
{
|
||||||
|
alg: "A128GCM",
|
||||||
|
ext: true,
|
||||||
|
k: key,
|
||||||
|
key_ops: ["encrypt", "decrypt"],
|
||||||
|
kty: "oct",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
length: ENCRYPTION_KEY_BITS,
|
||||||
|
},
|
||||||
|
false, // extractable
|
||||||
|
[usage],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const encryptData = async (
|
||||||
|
key: string | CryptoKey,
|
||||||
|
//@ts-ignore
|
||||||
|
data: Uint8Array<ArrayBuffer> | ArrayBuffer | Blob | File | string,
|
||||||
|
//@ts-ignore
|
||||||
|
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array<ArrayBuffer> }> => {
|
||||||
|
const importedKey =
|
||||||
|
typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
|
||||||
|
const iv = createIV();
|
||||||
|
//@ts-ignore
|
||||||
|
const buffer: ArrayBuffer | Uint8Array<ArrayBuffer> =
|
||||||
|
typeof data === "string"
|
||||||
|
? new TextEncoder().encode(data)
|
||||||
|
: data instanceof Uint8Array
|
||||||
|
? data
|
||||||
|
: data instanceof Blob
|
||||||
|
? await blobToArrayBuffer(data)
|
||||||
|
: data;
|
||||||
|
|
||||||
|
// We use symmetric encryption. AES-GCM is the recommended algorithm and
|
||||||
|
// includes checks that the ciphertext has not been modified by an attacker.
|
||||||
|
const encryptedBuffer = await window.crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv,
|
||||||
|
},
|
||||||
|
importedKey,
|
||||||
|
buffer,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { encryptedBuffer, iv };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decryptData = async (
|
||||||
|
//@ts-ignore
|
||||||
|
iv: Uint8Array<ArrayBuffer>,
|
||||||
|
//@ts-ignore
|
||||||
|
encrypted: Uint8Array<ArrayBuffer> | ArrayBuffer,
|
||||||
|
privateKey: string,
|
||||||
|
): Promise<ArrayBuffer> => {
|
||||||
|
const key = await getCryptoKey(privateKey, "decrypt");
|
||||||
|
return window.crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv,
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
encrypted,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
useComputedColorScheme,
|
useComputedColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
import { svgStringToFile } from "@/lib";
|
import { svgStringToFile } from "@/lib";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import "@excalidraw/excalidraw/index.css";
|
import "@excalidraw/excalidraw/index.css";
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
import type { ExcalidrawImperativeAPI, Gesture } from "@excalidraw/excalidraw/types";
|
||||||
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||||
import ReactClearModal from "react-clear-modal";
|
import ReactClearModal from "react-clear-modal";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -22,8 +23,9 @@ import { IconEdit } from "@tabler/icons-react";
|
|||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
import { useHandleLibrary, LiveCollaborationTrigger } from "@excalidraw/excalidraw";
|
||||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||||
|
import { useExcalidrawCollab } from "./use-excalidraw-collab";
|
||||||
|
|
||||||
const Excalidraw = lazy(() =>
|
const Excalidraw = lazy(() =>
|
||||||
import("@excalidraw/excalidraw").then((module) => ({
|
import("@excalidraw/excalidraw").then((module) => ({
|
||||||
@@ -46,6 +48,16 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const computedColorScheme = useComputedColorScheme();
|
const computedColorScheme = useComputedColorScheme();
|
||||||
|
|
||||||
|
const pageId = editor.storage?.pageId;
|
||||||
|
const { broadcastScene, broadcastPointer, isCollaborating } = useExcalidrawCollab(excalidrawAPI, pageId, opened);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(elements: readonly ExcalidrawElement[]) => {
|
||||||
|
broadcastScene(elements);
|
||||||
|
},
|
||||||
|
[broadcastScene],
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpen = async () => {
|
const handleOpen = async () => {
|
||||||
if (!editor.isEditable) {
|
if (!editor.isEditable) {
|
||||||
return;
|
return;
|
||||||
@@ -157,6 +169,14 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
scrollToContent: true,
|
scrollToContent: true,
|
||||||
}}
|
}}
|
||||||
theme={computedColorScheme}
|
theme={computedColorScheme}
|
||||||
|
onChange={handleChange}
|
||||||
|
onPointerUpdate={broadcastPointer}
|
||||||
|
renderTopRightUI={() => (
|
||||||
|
<LiveCollaborationTrigger
|
||||||
|
isCollaborating={isCollaborating}
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||||
|
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||||
|
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||||
|
import { newElementWith } from "@excalidraw/element";
|
||||||
|
import throttle from "lodash.throttle";
|
||||||
|
|
||||||
|
import type { UserIdleState } from "@excalidraw/common";
|
||||||
|
import type { OrderedExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
import type {
|
||||||
|
OnUserFollowedPayload,
|
||||||
|
SocketId,
|
||||||
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
|
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
|
||||||
|
import { isSyncableElement } from "../data";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
SocketUpdateData,
|
||||||
|
SocketUpdateDataSource,
|
||||||
|
SyncableExcalidrawElement,
|
||||||
|
} from "../data";
|
||||||
|
import type { TCollabClass } from "./Collab";
|
||||||
|
import type { Socket } from "socket.io-client";
|
||||||
|
|
||||||
|
class Portal {
|
||||||
|
collab: TCollabClass;
|
||||||
|
socket: Socket | null = null;
|
||||||
|
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
|
||||||
|
roomId: string | null = null;
|
||||||
|
roomKey: string | null = null;
|
||||||
|
broadcastedElementVersions: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
constructor(collab: TCollabClass) {
|
||||||
|
this.collab = collab;
|
||||||
|
}
|
||||||
|
|
||||||
|
open(socket: Socket, id: string, key: string) {
|
||||||
|
this.socket = socket;
|
||||||
|
this.roomId = id;
|
||||||
|
this.roomKey = key;
|
||||||
|
|
||||||
|
// Initialize socket listeners
|
||||||
|
this.socket.on("init-room", () => {
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.emit("join-room", this.roomId);
|
||||||
|
trackEvent("share", "room joined");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.socket.on("new-user", async (_socketId: string) => {
|
||||||
|
this.broadcastScene(
|
||||||
|
WS_SUBTYPES.INIT,
|
||||||
|
this.collab.getSceneElementsIncludingDeleted(),
|
||||||
|
/* syncAll */ true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this.socket.on("room-user-change", (clients: SocketId[]) => {
|
||||||
|
this.collab.setCollaborators(clients);
|
||||||
|
});
|
||||||
|
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (!this.socket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.queueFileUpload.flush();
|
||||||
|
this.socket.close();
|
||||||
|
this.socket = null;
|
||||||
|
this.roomId = null;
|
||||||
|
this.roomKey = null;
|
||||||
|
this.socketInitialized = false;
|
||||||
|
this.broadcastedElementVersions = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpen() {
|
||||||
|
return !!(
|
||||||
|
this.socketInitialized &&
|
||||||
|
this.socket &&
|
||||||
|
this.roomId &&
|
||||||
|
this.roomKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _broadcastSocketData(
|
||||||
|
data: SocketUpdateData,
|
||||||
|
volatile: boolean = false,
|
||||||
|
roomId?: string,
|
||||||
|
) {
|
||||||
|
if (this.isOpen()) {
|
||||||
|
const json = JSON.stringify(data);
|
||||||
|
const encoded = new TextEncoder().encode(json);
|
||||||
|
const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
|
||||||
|
|
||||||
|
this.socket?.emit(
|
||||||
|
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
|
||||||
|
roomId ?? this.roomId,
|
||||||
|
encryptedBuffer,
|
||||||
|
iv,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queueFileUpload = throttle(async () => {
|
||||||
|
try {
|
||||||
|
await this.collab.fileManager.saveFiles({
|
||||||
|
elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||||
|
files: this.collab.excalidrawAPI.getFiles(),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name !== "AbortError") {
|
||||||
|
this.collab.excalidrawAPI.updateScene({
|
||||||
|
appState: {
|
||||||
|
errorMessage: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let isChanged = false;
|
||||||
|
const newElements = this.collab.excalidrawAPI
|
||||||
|
.getSceneElementsIncludingDeleted()
|
||||||
|
.map((element) => {
|
||||||
|
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
|
||||||
|
isChanged = true;
|
||||||
|
// this will signal collaborators to pull image data from server
|
||||||
|
// (using mutation instead of newElementWith otherwise it'd break
|
||||||
|
// in-progress dragging)
|
||||||
|
return newElementWith(element, { status: "saved" });
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isChanged) {
|
||||||
|
this.collab.excalidrawAPI.updateScene({
|
||||||
|
elements: newElements,
|
||||||
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, FILE_UPLOAD_TIMEOUT);
|
||||||
|
|
||||||
|
broadcastScene = async (
|
||||||
|
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
|
||||||
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
|
syncAll: boolean,
|
||||||
|
) => {
|
||||||
|
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
|
||||||
|
throw new Error("syncAll must be true when sending SCENE.INIT");
|
||||||
|
}
|
||||||
|
|
||||||
|
// sync out only the elements we think we need to to save bandwidth.
|
||||||
|
// periodically we'll resync the whole thing to make sure no one diverges
|
||||||
|
// due to a dropped message (server goes down etc).
|
||||||
|
const syncableElements = elements.reduce((acc, element) => {
|
||||||
|
if (
|
||||||
|
(syncAll ||
|
||||||
|
!this.broadcastedElementVersions.has(element.id) ||
|
||||||
|
element.version > this.broadcastedElementVersions.get(element.id)!) &&
|
||||||
|
isSyncableElement(element)
|
||||||
|
) {
|
||||||
|
acc.push(element);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [] as SyncableExcalidrawElement[]);
|
||||||
|
|
||||||
|
const data: SocketUpdateDataSource[typeof updateType] = {
|
||||||
|
type: updateType,
|
||||||
|
payload: {
|
||||||
|
elements: syncableElements,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const syncableElement of syncableElements) {
|
||||||
|
this.broadcastedElementVersions.set(
|
||||||
|
syncableElement.id,
|
||||||
|
syncableElement.version,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.queueFileUpload();
|
||||||
|
|
||||||
|
await this._broadcastSocketData(data as SocketUpdateData);
|
||||||
|
};
|
||||||
|
|
||||||
|
broadcastIdleChange = (userState: UserIdleState) => {
|
||||||
|
if (this.socket?.id) {
|
||||||
|
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
|
||||||
|
type: WS_SUBTYPES.IDLE_STATUS,
|
||||||
|
payload: {
|
||||||
|
socketId: this.socket.id as SocketId,
|
||||||
|
userState,
|
||||||
|
username: this.collab.state.username,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return this._broadcastSocketData(
|
||||||
|
data as SocketUpdateData,
|
||||||
|
true, // volatile
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
broadcastMouseLocation = (payload: {
|
||||||
|
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
|
||||||
|
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
||||||
|
}) => {
|
||||||
|
if (this.socket?.id) {
|
||||||
|
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
|
||||||
|
type: WS_SUBTYPES.MOUSE_LOCATION,
|
||||||
|
payload: {
|
||||||
|
socketId: this.socket.id as SocketId,
|
||||||
|
pointer: payload.pointer,
|
||||||
|
button: payload.button || "up",
|
||||||
|
selectedElementIds:
|
||||||
|
this.collab.excalidrawAPI.getAppState().selectedElementIds,
|
||||||
|
username: this.collab.state.username,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return this._broadcastSocketData(
|
||||||
|
data as SocketUpdateData,
|
||||||
|
true, // volatile
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
broadcastVisibleSceneBounds = (
|
||||||
|
payload: {
|
||||||
|
sceneBounds: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"]["payload"]["sceneBounds"];
|
||||||
|
},
|
||||||
|
roomId: string,
|
||||||
|
) => {
|
||||||
|
if (this.socket?.id) {
|
||||||
|
const data: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"] = {
|
||||||
|
type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS,
|
||||||
|
payload: {
|
||||||
|
socketId: this.socket.id as SocketId,
|
||||||
|
username: this.collab.state.username,
|
||||||
|
sceneBounds: payload.sceneBounds,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return this._broadcastSocketData(
|
||||||
|
data as SocketUpdateData,
|
||||||
|
true, // volatile
|
||||||
|
roomId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
broadcastUserFollowed = (payload: OnUserFollowedPayload) => {
|
||||||
|
if (this.socket?.id) {
|
||||||
|
this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Portal;
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { useEffect, useRef, useCallback, useMemo, useState } from "react";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
|
||||||
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
|
import type {
|
||||||
|
ExcalidrawImperativeAPI,
|
||||||
|
Collaborator,
|
||||||
|
Gesture,
|
||||||
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
import { reconcileElements, getSceneVersion } from "@excalidraw/excalidraw";
|
||||||
|
import throttle from "lodash.throttle";
|
||||||
|
|
||||||
|
// Message types for collaboration
|
||||||
|
type SceneUpdateMessage = {
|
||||||
|
type: "SCENE_UPDATE";
|
||||||
|
payload: { elements: readonly ExcalidrawElement[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
type PointerUpdateMessage = {
|
||||||
|
type: "POINTER_UPDATE";
|
||||||
|
payload: {
|
||||||
|
socketId: string;
|
||||||
|
pointer: { x: number; y: number };
|
||||||
|
button: "down" | "up";
|
||||||
|
username: string;
|
||||||
|
selectedElementIds: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type CollabMessage = SceneUpdateMessage | PointerUpdateMessage;
|
||||||
|
|
||||||
|
export function useExcalidrawCollab(
|
||||||
|
excalidrawAPI: ExcalidrawImperativeAPI | null,
|
||||||
|
pageId: string | undefined,
|
||||||
|
isOpen: boolean,
|
||||||
|
) {
|
||||||
|
const [socket] = useAtom(socketAtom);
|
||||||
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
|
const lastBroadcastedVersion = useRef(-1);
|
||||||
|
const isInitialized = useRef(false);
|
||||||
|
const collaboratorsRef = useRef<Map<string, Collaborator>>(new Map());
|
||||||
|
const [isCollaborating, setIsCollaborating] = useState(false);
|
||||||
|
|
||||||
|
// Track broadcasted element versions for bandwidth optimization
|
||||||
|
const broadcastedElementVersions = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
|
const roomId = pageId ? `excalidraw-${pageId}` : null;
|
||||||
|
const username = currentUser?.user?.name || "Anonymous";
|
||||||
|
|
||||||
|
// Broadcast pointer/cursor updates (volatile - can be dropped)
|
||||||
|
const broadcastPointer = useMemo(
|
||||||
|
() =>
|
||||||
|
throttle(
|
||||||
|
(payload: {
|
||||||
|
pointer: { x: number; y: number };
|
||||||
|
button: "down" | "up";
|
||||||
|
pointersMap: Gesture["pointers"];
|
||||||
|
}) => {
|
||||||
|
if (!socket || !roomId || !isInitialized.current) return;
|
||||||
|
if (payload.pointersMap.size >= 2) return; // Skip multi-touch
|
||||||
|
|
||||||
|
const data: PointerUpdateMessage = {
|
||||||
|
type: "POINTER_UPDATE",
|
||||||
|
payload: {
|
||||||
|
socketId: socket.id!,
|
||||||
|
pointer: payload.pointer,
|
||||||
|
button: payload.button,
|
||||||
|
username,
|
||||||
|
selectedElementIds:
|
||||||
|
excalidrawAPI?.getAppState().selectedElementIds || {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const json = JSON.stringify(data);
|
||||||
|
socket.emit("ex-server-volatile-broadcast", [roomId, json, null]);
|
||||||
|
},
|
||||||
|
50,
|
||||||
|
),
|
||||||
|
[socket, roomId, username, excalidrawAPI],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast scene changes with bandwidth optimization
|
||||||
|
const broadcastScene = useCallback(
|
||||||
|
(elements: readonly ExcalidrawElement[], syncAll = false) => {
|
||||||
|
if (!socket || !roomId || !isInitialized.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sceneVersion = getSceneVersion(elements);
|
||||||
|
|
||||||
|
if (sceneVersion <= lastBroadcastedVersion.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only send elements that changed since last broadcast
|
||||||
|
const changedElements = elements.filter((element) => {
|
||||||
|
const lastVersion = broadcastedElementVersions.current.get(element.id);
|
||||||
|
return syncAll || lastVersion === undefined || element.version > lastVersion;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (changedElements.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: SceneUpdateMessage = {
|
||||||
|
type: "SCENE_UPDATE",
|
||||||
|
payload: { elements: changedElements },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update tracking map
|
||||||
|
for (const element of changedElements) {
|
||||||
|
broadcastedElementVersions.current.set(element.id, element.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = JSON.stringify(data);
|
||||||
|
socket.emit("ex-server-broadcast", [roomId, json, null]);
|
||||||
|
lastBroadcastedVersion.current = sceneVersion;
|
||||||
|
},
|
||||||
|
[socket, roomId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Throttled version for onChange handler
|
||||||
|
const throttledBroadcastScene = useMemo(
|
||||||
|
() => throttle((elements: readonly ExcalidrawElement[]) => broadcastScene(elements, false), 100),
|
||||||
|
[broadcastScene],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle incoming broadcasts
|
||||||
|
const handleClientBroadcast = useCallback(
|
||||||
|
(jsonData: string, _iv: Uint8Array | null) => {
|
||||||
|
if (!excalidrawAPI || !socket) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data: CollabMessage = JSON.parse(jsonData);
|
||||||
|
|
||||||
|
if (data.type === "SCENE_UPDATE" && data.payload?.elements) {
|
||||||
|
const remoteElements = data.payload.elements;
|
||||||
|
const localElements =
|
||||||
|
excalidrawAPI.getSceneElementsIncludingDeleted();
|
||||||
|
|
||||||
|
const reconciledElements = reconcileElements(
|
||||||
|
localElements,
|
||||||
|
// @ts-ignore
|
||||||
|
remoteElements,
|
||||||
|
excalidrawAPI.getAppState(),
|
||||||
|
);
|
||||||
|
|
||||||
|
excalidrawAPI.updateScene({
|
||||||
|
elements: reconciledElements,
|
||||||
|
});
|
||||||
|
|
||||||
|
lastBroadcastedVersion.current = getSceneVersion(reconciledElements);
|
||||||
|
} else if (data.type === "POINTER_UPDATE") {
|
||||||
|
const { socketId, pointer, button, username, selectedElementIds } =
|
||||||
|
data.payload;
|
||||||
|
|
||||||
|
// Don't update our own cursor
|
||||||
|
if (socketId === socket.id) return;
|
||||||
|
|
||||||
|
// Update collaborator with pointer info
|
||||||
|
const collaborator = collaboratorsRef.current.get(socketId) || {};
|
||||||
|
collaboratorsRef.current.set(socketId, {
|
||||||
|
...collaborator,
|
||||||
|
// @ts-ignore
|
||||||
|
pointer,
|
||||||
|
button,
|
||||||
|
username,
|
||||||
|
// @ts-ignore
|
||||||
|
selectedElementIds,
|
||||||
|
isCurrentUser: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
excalidrawAPI.updateScene({
|
||||||
|
// @ts-ignore
|
||||||
|
collaborators: collaboratorsRef.current,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to process broadcast:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[excalidrawAPI, socket],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle room user changes
|
||||||
|
const handleRoomUserChange = useCallback(
|
||||||
|
(socketIds: string[]) => {
|
||||||
|
if (!excalidrawAPI || !socket) return;
|
||||||
|
|
||||||
|
// Update collaborators map, preserving existing data
|
||||||
|
const newCollaborators = new Map<string, Collaborator>();
|
||||||
|
for (const id of socketIds) {
|
||||||
|
const existing = collaboratorsRef.current.get(id);
|
||||||
|
newCollaborators.set(id, {
|
||||||
|
...existing,
|
||||||
|
isCurrentUser: id === socket.id,
|
||||||
|
username:
|
||||||
|
existing?.username || (id === socket.id ? username : "User"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
collaboratorsRef.current = newCollaborators;
|
||||||
|
// @ts-ignore
|
||||||
|
excalidrawAPI.updateScene({ collaborators: newCollaborators });
|
||||||
|
|
||||||
|
// We're collaborating if there are other users
|
||||||
|
setIsCollaborating(socketIds.length > 1);
|
||||||
|
},
|
||||||
|
[excalidrawAPI, socket, username],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Join/leave room based on modal state
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket || !roomId || !isOpen) {
|
||||||
|
setIsCollaborating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Joining room:", roomId);
|
||||||
|
socket.emit("ex-join-room", roomId);
|
||||||
|
isInitialized.current = true;
|
||||||
|
|
||||||
|
// Set up listeners
|
||||||
|
socket.on("ex-client-broadcast", handleClientBroadcast);
|
||||||
|
socket.on("ex-room-user-change", handleRoomUserChange);
|
||||||
|
socket.on("ex-first-in-room", () => {
|
||||||
|
console.log("First in excalidraw room");
|
||||||
|
});
|
||||||
|
socket.on("ex-new-user", (socketId: string) => {
|
||||||
|
console.log("New user joined:", socketId);
|
||||||
|
if (excalidrawAPI) {
|
||||||
|
// Send full scene to new user (syncAll = true)
|
||||||
|
broadcastScene(excalidrawAPI.getSceneElements(), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("Leaving room:", roomId);
|
||||||
|
socket.emit("ex-leave-room", roomId);
|
||||||
|
socket.off("ex-client-broadcast", handleClientBroadcast);
|
||||||
|
socket.off("ex-room-user-change", handleRoomUserChange);
|
||||||
|
socket.off("ex-first-in-room");
|
||||||
|
socket.off("ex-new-user");
|
||||||
|
isInitialized.current = false;
|
||||||
|
lastBroadcastedVersion.current = -1;
|
||||||
|
broadcastedElementVersions.current = new Map();
|
||||||
|
collaboratorsRef.current = new Map();
|
||||||
|
setIsCollaborating(false);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
socket,
|
||||||
|
roomId,
|
||||||
|
isOpen,
|
||||||
|
handleClientBroadcast,
|
||||||
|
handleRoomUserChange,
|
||||||
|
broadcastScene,
|
||||||
|
excalidrawAPI,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
broadcastScene: throttledBroadcastScene,
|
||||||
|
broadcastPointer,
|
||||||
|
isCollaborating,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -28,12 +28,9 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
|
|||||||
import {
|
import {
|
||||||
activeCommentIdAtom,
|
activeCommentIdAtom,
|
||||||
showCommentPopupAtom,
|
showCommentPopupAtom,
|
||||||
showReadOnlyCommentPopupAtom,
|
|
||||||
} from "@/features/comment/atoms/comment-atom";
|
} from "@/features/comment/atoms/comment-atom";
|
||||||
import CommentDialog from "@/features/comment/components/comment-dialog";
|
import CommentDialog from "@/features/comment/components/comment-dialog";
|
||||||
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
|
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
|
||||||
import { ReadOnlyBubbleMenu } from "@/features/editor/components/bubble-menu/read-only-bubble-menu";
|
|
||||||
import ReadOnlyCommentDialog from "@/features/editor/components/bubble-menu/read-only-comment-dialog";
|
|
||||||
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
|
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
|
||||||
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
||||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||||
@@ -87,7 +84,6 @@ export default function PageEditor({
|
|||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
|
||||||
const ydocRef = useRef<Y.Doc | null>(null);
|
const ydocRef = useRef<Y.Doc | null>(null);
|
||||||
if (!ydocRef.current) {
|
if (!ydocRef.current) {
|
||||||
ydocRef.current = new Y.Doc();
|
ydocRef.current = new Y.Doc();
|
||||||
@@ -433,13 +429,7 @@ export default function PageEditor({
|
|||||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{editor && !editorIsEditable && (
|
|
||||||
<ReadOnlyBubbleMenu key="readonly-bubble" editor={editor} />
|
|
||||||
)}
|
|
||||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||||
{showReadOnlyCommentPopup && (
|
|
||||||
<ReadOnlyCommentDialog editor={editor} pageId={pageId} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => editor.commands.focus("end")}
|
onClick={() => editor.commands.focus("end")}
|
||||||
|
|||||||
@@ -67,8 +67,4 @@ export class CollaborationGateway {
|
|||||||
async destroy(): Promise<void> {
|
async destroy(): Promise<void> {
|
||||||
await this.hocuspocus.destroy();
|
await this.hocuspocus.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
async openDirectConnection(documentName: string) {
|
|
||||||
return this.hocuspocus.openDirectConnection(documentName);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import { StarterKit } from '@tiptap/starter-kit';
|
import { StarterKit } from '@tiptap/starter-kit';
|
||||||
import {
|
|
||||||
initProseMirrorDoc,
|
|
||||||
relativePositionToAbsolutePosition,
|
|
||||||
} from 'y-prosemirror';
|
|
||||||
import * as Y from 'yjs';
|
|
||||||
import { Document } from '@hocuspocus/server';
|
|
||||||
import { TextAlign } from '@tiptap/extension-text-align';
|
import { TextAlign } from '@tiptap/extension-text-align';
|
||||||
import { TaskList } from '@tiptap/extension-task-list';
|
import { TaskList } from '@tiptap/extension-task-list';
|
||||||
import { TaskItem } from '@tiptap/extension-task-item';
|
import { TaskItem } from '@tiptap/extension-task-item';
|
||||||
@@ -122,169 +116,3 @@ export function jsonToNode(tiptapJson: JSONContent) {
|
|||||||
export function getPageId(documentName: string) {
|
export function getPageId(documentName: string) {
|
||||||
return documentName.split('.')[1];
|
return documentName.split('.')[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type YjsSelection = {
|
|
||||||
anchor: any;
|
|
||||||
head: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function setYjsMark(
|
|
||||||
doc: Document,
|
|
||||||
fragment: Y.XmlFragment,
|
|
||||||
yjsSelection: YjsSelection,
|
|
||||||
markName: string,
|
|
||||||
markAttributes: Record<string, any>,
|
|
||||||
) {
|
|
||||||
const schema = getSchema(tiptapExtensions);
|
|
||||||
const { mapping } = initProseMirrorDoc(fragment, schema);
|
|
||||||
|
|
||||||
// Convert JSON positions to Y.js RelativePosition objects
|
|
||||||
const anchorRelPos = Y.createRelativePositionFromJSON(yjsSelection.anchor);
|
|
||||||
const headRelPos = Y.createRelativePositionFromJSON(yjsSelection.head);
|
|
||||||
|
|
||||||
const anchor = relativePositionToAbsolutePosition(
|
|
||||||
doc,
|
|
||||||
fragment,
|
|
||||||
anchorRelPos,
|
|
||||||
mapping,
|
|
||||||
);
|
|
||||||
const head = relativePositionToAbsolutePosition(
|
|
||||||
doc,
|
|
||||||
fragment,
|
|
||||||
headRelPos,
|
|
||||||
mapping,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (anchor === null || head === null) {
|
|
||||||
throw new Error(
|
|
||||||
'Could not resolve Y.js relative positions to absolute positions',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const from = Math.min(anchor, head);
|
|
||||||
const to = Math.max(anchor, head);
|
|
||||||
|
|
||||||
// Apply mark directly to Y.js XmlText nodes
|
|
||||||
// This bypasses updateYFragment which has compatibility issues
|
|
||||||
applyMarkToYFragment(fragment, from, to, markName, markAttributes);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMarkToYFragment(
|
|
||||||
fragment: Y.XmlFragment,
|
|
||||||
from: number,
|
|
||||||
to: number,
|
|
||||||
markName: string,
|
|
||||||
markAttributes: Record<string, any>,
|
|
||||||
) {
|
|
||||||
let pos = 0;
|
|
||||||
|
|
||||||
const processItem = (item: any): boolean => {
|
|
||||||
if (pos >= to) return false;
|
|
||||||
|
|
||||||
if (item instanceof Y.XmlText) {
|
|
||||||
const textLength = item.length;
|
|
||||||
const itemEnd = pos + textLength;
|
|
||||||
|
|
||||||
if (itemEnd > from && pos < to) {
|
|
||||||
const formatFrom = Math.max(0, from - pos);
|
|
||||||
const formatTo = Math.min(textLength, to - pos);
|
|
||||||
const formatLength = formatTo - formatFrom;
|
|
||||||
|
|
||||||
if (formatLength > 0) {
|
|
||||||
item.format(formatFrom, formatLength, { [markName]: markAttributes });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pos = itemEnd;
|
|
||||||
} else if (item instanceof Y.XmlElement) {
|
|
||||||
pos++; // Opening tag
|
|
||||||
for (let i = 0; i < item.length; i++) {
|
|
||||||
if (!processItem(item.get(i))) return false;
|
|
||||||
}
|
|
||||||
pos++; // Closing tag
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < fragment.length; i++) {
|
|
||||||
if (!processItem(fragment.get(i))) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a mark from all text in the fragment that has the specified attribute value.
|
|
||||||
* Useful for deleting comments by commentId.
|
|
||||||
*/
|
|
||||||
export function removeYjsMarkByAttribute(
|
|
||||||
fragment: Y.XmlFragment,
|
|
||||||
markName: string,
|
|
||||||
attributeName: string,
|
|
||||||
attributeValue: string,
|
|
||||||
) {
|
|
||||||
const processItem = (item: any) => {
|
|
||||||
if (item instanceof Y.XmlText) {
|
|
||||||
// Get all formatting deltas to find ranges with this mark
|
|
||||||
const deltas = item.toDelta();
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
for (const delta of deltas) {
|
|
||||||
const length = delta.insert?.length ?? 0;
|
|
||||||
const attributes = delta.attributes ?? {};
|
|
||||||
const markAttr = attributes[markName];
|
|
||||||
|
|
||||||
if (markAttr && markAttr[attributeName] === attributeValue) {
|
|
||||||
// Remove the mark by setting it to null
|
|
||||||
item.format(offset, length, { [markName]: null });
|
|
||||||
}
|
|
||||||
offset += length;
|
|
||||||
}
|
|
||||||
} else if (item instanceof Y.XmlElement) {
|
|
||||||
for (let i = 0; i < item.length; i++) {
|
|
||||||
processItem(item.get(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < fragment.length; i++) {
|
|
||||||
processItem(fragment.get(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates a mark's attributes for all text that has the specified attribute value.
|
|
||||||
* Useful for resolving/unresolving comments by commentId.
|
|
||||||
*/
|
|
||||||
export function updateYjsMarkAttribute(
|
|
||||||
fragment: Y.XmlFragment,
|
|
||||||
markName: string,
|
|
||||||
findByAttribute: { name: string; value: string },
|
|
||||||
newAttributes: Record<string, any>,
|
|
||||||
) {
|
|
||||||
const processItem = (item: any) => {
|
|
||||||
if (item instanceof Y.XmlText) {
|
|
||||||
const deltas = item.toDelta();
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
for (const delta of deltas) {
|
|
||||||
const length = delta.insert?.length ?? 0;
|
|
||||||
const attributes = delta.attributes ?? {};
|
|
||||||
const markAttr = attributes[markName];
|
|
||||||
|
|
||||||
if (markAttr && markAttr[findByAttribute.name] === findByAttribute.value) {
|
|
||||||
// Update the mark with new attributes (merge with existing)
|
|
||||||
item.format(offset, length, {
|
|
||||||
[markName]: { ...markAttr, ...newAttributes },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
offset += length;
|
|
||||||
}
|
|
||||||
} else if (item instanceof Y.XmlElement) {
|
|
||||||
for (let i = 0; i < item.length; i++) {
|
|
||||||
processItem(item.get(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < fragment.length; i++) {
|
|
||||||
processItem(fragment.get(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
content: tiptapJson,
|
content: tiptapJson,
|
||||||
textContent: textContent,
|
textContent: textContent,
|
||||||
ydoc: ydocState,
|
ydoc: ydocState,
|
||||||
lastUpdatedById: context?.user?.id,
|
lastUpdatedById: context.user.id,
|
||||||
contributorIds: contributorIds,
|
contributorIds: contributorIds,
|
||||||
},
|
},
|
||||||
pageId,
|
pageId,
|
||||||
@@ -157,7 +157,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
page: {
|
page: {
|
||||||
...page,
|
...page,
|
||||||
content: tiptapJson,
|
content: tiptapJson,
|
||||||
lastUpdatedById: context?.user?.id,
|
lastUpdatedById: context.user.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
|
|
||||||
async onChange(data: onChangePayload) {
|
async onChange(data: onChangePayload) {
|
||||||
const documentName = data.documentName;
|
const documentName = data.documentName;
|
||||||
const userId = data.context?.user?.id;
|
const userId = data.context?.user.id;
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
if (!this.contributors.has(documentName)) {
|
if (!this.contributors.has(documentName)) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CommentService } from './comment.service';
|
import { CommentService } from './comment.service';
|
||||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
import { CreateReadOnlyCommentDto } from './dto/create-readonly-comment.dto';
|
|
||||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||||
import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
@@ -63,28 +62,6 @@ export class CommentController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('create-readonly')
|
|
||||||
async createReadOnly(
|
|
||||||
@Body() createCommentDto: CreateReadOnlyCommentDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const page = await this.pageRepo.findById(createCommentDto.pageId);
|
|
||||||
if (!page || page.deletedAt) {
|
|
||||||
throw new NotFoundException('Page not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.commentService.createReadOnlyComment(
|
|
||||||
{
|
|
||||||
userId: user.id,
|
|
||||||
page,
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
},
|
|
||||||
createCommentDto,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/')
|
@Post('/')
|
||||||
async findPageComments(
|
async findPageComments(
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CommentService } from './comment.service';
|
import { CommentService } from './comment.service';
|
||||||
import { CommentController } from './comment.controller';
|
import { CommentController } from './comment.controller';
|
||||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CollaborationModule],
|
imports: [],
|
||||||
controllers: [CommentController],
|
controllers: [CommentController],
|
||||||
providers: [CommentService],
|
providers: [CommentService],
|
||||||
exports: [CommentService],
|
exports: [CommentService],
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
import { CreateReadOnlyCommentDto } from './dto/create-readonly-comment.dto';
|
|
||||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||||
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
||||||
@@ -14,19 +12,13 @@ import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
|||||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { CollaborationGateway } from '../../collaboration/collaboration.gateway';
|
|
||||||
import { setYjsMark } from '../../collaboration/collaboration.util';
|
|
||||||
import * as Y from 'yjs';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentService {
|
export class CommentService {
|
||||||
private readonly logger = new Logger(CommentService.name);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private commentRepo: CommentRepo,
|
private commentRepo: CommentRepo,
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
private spaceMemberRepo: SpaceMemberRepo,
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
private collaborationGateway: CollaborationGateway,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(commentId: string) {
|
async findById(commentId: string) {
|
||||||
@@ -113,49 +105,4 @@ export class CommentService {
|
|||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createReadOnlyComment(
|
|
||||||
opts: { userId: string; page: Page; workspaceId: string },
|
|
||||||
createCommentDto: CreateReadOnlyCommentDto,
|
|
||||||
): Promise<Comment> {
|
|
||||||
const { userId, page, workspaceId } = opts;
|
|
||||||
const commentContent = JSON.parse(createCommentDto.content);
|
|
||||||
|
|
||||||
const comment = await this.commentRepo.insertComment({
|
|
||||||
pageId: page.id,
|
|
||||||
content: commentContent,
|
|
||||||
selection: createCommentDto?.selection?.substring(0, 250),
|
|
||||||
type: 'inline',
|
|
||||||
creatorId: userId,
|
|
||||||
workspaceId: workspaceId,
|
|
||||||
spaceId: page.spaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentName = `page.${page.id}`;
|
|
||||||
const directConnection =
|
|
||||||
await this.collaborationGateway.openDirectConnection(documentName);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await directConnection.transact((doc) => {
|
|
||||||
const fragment = doc.getXmlFragment('default');
|
|
||||||
setYjsMark(doc, fragment, createCommentDto.yjsSelection, 'comment', {
|
|
||||||
commentId: comment.id,
|
|
||||||
resolved: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
`Failed to apply comment mark for comment ${comment.id}`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
await this.commentRepo.deleteComment(comment.id);
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Failed to apply comment mark. Selection may have changed.',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await directConnection.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
return comment;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { IsJSON, IsObject, IsOptional, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateReadOnlyCommentDto {
|
|
||||||
@IsString()
|
|
||||||
pageId: string;
|
|
||||||
|
|
||||||
@IsJSON()
|
|
||||||
content: any;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
selection: string;
|
|
||||||
|
|
||||||
@IsObject()
|
|
||||||
yjsSelection: {
|
|
||||||
anchor: any;
|
|
||||||
head: any;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+1
-1
Submodule apps/server/src/ee updated: 1d1ab6cf81...fce3e9e945
@@ -0,0 +1,127 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { ExcalidrawFollowPayload } from '../types/excalidraw.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExcalidrawCollabService {
|
||||||
|
// Track socket -> rooms mapping for disconnect handling
|
||||||
|
// (Socket.IO clears client.rooms before handleDisconnect runs)
|
||||||
|
private socketRooms = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
async handleJoinRoom(
|
||||||
|
client: Socket,
|
||||||
|
server: Server,
|
||||||
|
roomId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.join(roomId);
|
||||||
|
|
||||||
|
// Track room membership
|
||||||
|
if (!this.socketRooms.has(client.id)) {
|
||||||
|
this.socketRooms.set(client.id, new Set());
|
||||||
|
}
|
||||||
|
this.socketRooms.get(client.id).add(roomId);
|
||||||
|
|
||||||
|
const sockets = await server.in(roomId).fetchSockets();
|
||||||
|
|
||||||
|
if (sockets.length <= 1) {
|
||||||
|
server.to(client.id).emit('ex-first-in-room');
|
||||||
|
} else {
|
||||||
|
client.broadcast.to(roomId).emit('ex-new-user', client.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
server.in(roomId).emit(
|
||||||
|
'ex-room-user-change',
|
||||||
|
sockets.map((socket) => socket.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleLeaveRoom(
|
||||||
|
client: Socket,
|
||||||
|
server: Server,
|
||||||
|
roomId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.leave(roomId);
|
||||||
|
|
||||||
|
// Remove from tracking
|
||||||
|
this.socketRooms.get(client.id)?.delete(roomId);
|
||||||
|
|
||||||
|
// Notify remaining users
|
||||||
|
const sockets = await server.in(roomId).fetchSockets();
|
||||||
|
if (sockets.length > 0) {
|
||||||
|
server.in(roomId).emit(
|
||||||
|
'ex-room-user-change',
|
||||||
|
sockets.map((socket) => socket.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleServerBroadcast(
|
||||||
|
client: Socket,
|
||||||
|
roomId: string,
|
||||||
|
encryptedData: ArrayBuffer,
|
||||||
|
iv: Uint8Array,
|
||||||
|
): void {
|
||||||
|
client.broadcast.to(roomId).emit('ex-client-broadcast', encryptedData, iv);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleServerVolatileBroadcast(
|
||||||
|
client: Socket,
|
||||||
|
roomId: string,
|
||||||
|
encryptedData: ArrayBuffer,
|
||||||
|
iv: Uint8Array,
|
||||||
|
): void {
|
||||||
|
client.volatile.broadcast
|
||||||
|
.to(roomId)
|
||||||
|
.emit('ex-client-broadcast', encryptedData, iv);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleUserFollow(
|
||||||
|
client: Socket,
|
||||||
|
server: Server,
|
||||||
|
payload: ExcalidrawFollowPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
const roomId = `follow@${payload.userToFollow.socketId}`;
|
||||||
|
|
||||||
|
if (payload.action === 'FOLLOW') {
|
||||||
|
await client.join(roomId);
|
||||||
|
} else {
|
||||||
|
await client.leave(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sockets = await server.in(roomId).fetchSockets();
|
||||||
|
const followedBy = sockets.map((socket) => socket.id);
|
||||||
|
|
||||||
|
server.to(payload.userToFollow.socketId).emit(
|
||||||
|
'ex-user-follow-room-change',
|
||||||
|
followedBy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDisconnecting(client: Socket, server: Server): Promise<void> {
|
||||||
|
// Use tracked rooms since client.rooms is empty by this point
|
||||||
|
const rooms = this.socketRooms.get(client.id) || new Set();
|
||||||
|
|
||||||
|
for (const roomId of rooms) {
|
||||||
|
const otherClients = (await server.in(roomId).fetchSockets()).filter(
|
||||||
|
(socket) => socket.id !== client.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isFollowRoom = roomId.startsWith('follow@');
|
||||||
|
|
||||||
|
if (!isFollowRoom && otherClients.length > 0) {
|
||||||
|
server.to(roomId).emit(
|
||||||
|
'ex-room-user-change',
|
||||||
|
otherClients.map((socket) => socket.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFollowRoom && otherClients.length === 0) {
|
||||||
|
const socketId = roomId.replace('follow@', '');
|
||||||
|
server.to(socketId).emit('ex-broadcast-unfollow');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up tracking
|
||||||
|
this.socketRooms.delete(client.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export type ExcalidrawUserToFollow = {
|
||||||
|
socketId: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExcalidrawFollowPayload = {
|
||||||
|
userToFollow: ExcalidrawUserToFollow;
|
||||||
|
action: 'FOLLOW' | 'UNFOLLOW';
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
ConnectedSocket,
|
||||||
MessageBody,
|
MessageBody,
|
||||||
OnGatewayConnection,
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
SubscribeMessage,
|
SubscribeMessage,
|
||||||
WebSocketGateway,
|
WebSocketGateway,
|
||||||
WebSocketServer,
|
WebSocketServer,
|
||||||
@@ -11,17 +13,23 @@ import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
|
|||||||
import { OnModuleDestroy } from '@nestjs/common';
|
import { OnModuleDestroy } from '@nestjs/common';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import * as cookie from 'cookie';
|
import * as cookie from 'cookie';
|
||||||
|
import { ExcalidrawCollabService } from './services/excalidraw-collab.service';
|
||||||
|
import { ExcalidrawFollowPayload } from './types/excalidraw.types';
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: { origin: '*' },
|
cors: { origin: '*' },
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
})
|
})
|
||||||
export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
export class WsGateway
|
||||||
|
implements OnGatewayConnection, OnGatewayDisconnect, OnModuleDestroy
|
||||||
|
{
|
||||||
@WebSocketServer()
|
@WebSocketServer()
|
||||||
server: Server;
|
server: Server;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private tokenService: TokenService,
|
private tokenService: TokenService,
|
||||||
private spaceMemberRepo: SpaceMemberRepo,
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
|
private excalidrawCollabService: ExcalidrawCollabService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
|
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
|
||||||
@@ -41,6 +49,8 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
|||||||
const spaceRooms = userSpaceIds.map((id) => this.getSpaceRoomName(id));
|
const spaceRooms = userSpaceIds.map((id) => this.getSpaceRoomName(id));
|
||||||
|
|
||||||
client.join([workspaceRoom, ...spaceRooms]);
|
client.join([workspaceRoom, ...spaceRooms]);
|
||||||
|
|
||||||
|
this.server.to(client.id).emit('init-room');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
client.emit('Unauthorized');
|
client.emit('Unauthorized');
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
@@ -76,6 +86,75 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
|||||||
client.leave(roomName);
|
client.leave(roomName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Excalidraw Sync
|
||||||
|
@SubscribeMessage('ex-join-room')
|
||||||
|
async handleExJoinRoom(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() roomId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.excalidrawCollabService.handleJoinRoom(
|
||||||
|
client,
|
||||||
|
this.server,
|
||||||
|
roomId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('ex-leave-room')
|
||||||
|
async handleExLeaveRoom(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() roomId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.excalidrawCollabService.handleLeaveRoom(
|
||||||
|
client,
|
||||||
|
this.server,
|
||||||
|
roomId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('ex-server-broadcast')
|
||||||
|
handleServerBroadcast(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() [roomId, encryptedData, iv]: [string, ArrayBuffer, Uint8Array],
|
||||||
|
): void {
|
||||||
|
this.excalidrawCollabService.handleServerBroadcast(
|
||||||
|
client,
|
||||||
|
roomId,
|
||||||
|
encryptedData,
|
||||||
|
iv,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('ex-server-volatile-broadcast')
|
||||||
|
handleServerVolatileBroadcast(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() [roomId, encryptedData, iv]: [string, ArrayBuffer, Uint8Array],
|
||||||
|
): void {
|
||||||
|
this.excalidrawCollabService.handleServerVolatileBroadcast(
|
||||||
|
client,
|
||||||
|
roomId,
|
||||||
|
encryptedData,
|
||||||
|
iv,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('ex-user-follow')
|
||||||
|
async handleUserFollow(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() payload: ExcalidrawFollowPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.excalidrawCollabService.handleUserFollow(
|
||||||
|
client,
|
||||||
|
this.server,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDisconnect(client: Socket): Promise<void> {
|
||||||
|
await this.excalidrawCollabService.handleDisconnecting(client, this.server);
|
||||||
|
}
|
||||||
|
|
||||||
onModuleDestroy() {
|
onModuleDestroy() {
|
||||||
if (this.server) {
|
if (this.server) {
|
||||||
this.server.close();
|
this.server.close();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { WsGateway } from './ws.gateway';
|
import { WsGateway } from './ws.gateway';
|
||||||
import { TokenModule } from '../core/auth/token.module';
|
import { TokenModule } from '../core/auth/token.module';
|
||||||
|
import { ExcalidrawCollabService } from './services/excalidraw-collab.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TokenModule],
|
imports: [TokenModule],
|
||||||
providers: [WsGateway],
|
providers: [WsGateway, ExcalidrawCollabService],
|
||||||
})
|
})
|
||||||
export class WsModule {}
|
export class WsModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user