mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eb3416b5c | |||
| c1cfe158cd | |||
| ab81903299 | |||
| 14698ebb05 | |||
| efa52ea4c8 | |||
| a4750bff56 | |||
| 5c9eed53c0 |
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 LibraryPersistedData = {
|
||||
@@ -8,8 +10,8 @@ export interface LibraryPersistenceAdapter {
|
||||
load(metadata: { source: "load" | "save" }):
|
||||
| Promise<{ libraryItems: LibraryItems } | null>
|
||||
| {
|
||||
libraryItems: LibraryItems;
|
||||
}
|
||||
libraryItems: LibraryItems;
|
||||
}
|
||||
| null;
|
||||
|
||||
save(libraryData: LibraryPersistedData): Promise<void> | void;
|
||||
@@ -25,7 +27,10 @@ export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error downloading Excalidraw library from localStorage", e);
|
||||
console.error(
|
||||
"Error downloading Excalidraw library from localStorage",
|
||||
e,
|
||||
);
|
||||
}
|
||||
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,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { svgStringToFile } from "@/lib";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
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 ReactClearModal from "react-clear-modal";
|
||||
import clsx from "clsx";
|
||||
@@ -22,8 +23,9 @@ import { IconEdit } from "@tabler/icons-react";
|
||||
import { lazy } from "react";
|
||||
import { Suspense } from "react";
|
||||
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 { useExcalidrawCollab } from "./use-excalidraw-collab";
|
||||
|
||||
const Excalidraw = lazy(() =>
|
||||
import("@excalidraw/excalidraw").then((module) => ({
|
||||
@@ -46,6 +48,16 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
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 () => {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
@@ -157,6 +169,14 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
scrollToContent: true,
|
||||
}}
|
||||
theme={computedColorScheme}
|
||||
onChange={handleChange}
|
||||
onPointerUpdate={broadcastPointer}
|
||||
renderTopRightUI={() => (
|
||||
<LiveCollaborationTrigger
|
||||
isCollaborating={isCollaborating}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Suspense>
|
||||
</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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
@@ -11,17 +13,23 @@ import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
|
||||
import { OnModuleDestroy } from '@nestjs/common';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import * as cookie from 'cookie';
|
||||
import { ExcalidrawCollabService } from './services/excalidraw-collab.service';
|
||||
import { ExcalidrawFollowPayload } from './types/excalidraw.types';
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: { origin: '*' },
|
||||
transports: ['websocket'],
|
||||
})
|
||||
export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
||||
export class WsGateway
|
||||
implements OnGatewayConnection, OnGatewayDisconnect, OnModuleDestroy
|
||||
{
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
constructor(
|
||||
private tokenService: TokenService,
|
||||
private spaceMemberRepo: SpaceMemberRepo,
|
||||
private excalidrawCollabService: ExcalidrawCollabService,
|
||||
) {}
|
||||
|
||||
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));
|
||||
|
||||
client.join([workspaceRoom, ...spaceRooms]);
|
||||
|
||||
this.server.to(client.id).emit('init-room');
|
||||
} catch (err) {
|
||||
client.emit('Unauthorized');
|
||||
client.disconnect();
|
||||
@@ -76,6 +86,75 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
||||
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() {
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WsGateway } from './ws.gateway';
|
||||
import { TokenModule } from '../core/auth/token.module';
|
||||
import { ExcalidrawCollabService } from './services/excalidraw-collab.service';
|
||||
|
||||
@Module({
|
||||
imports: [TokenModule],
|
||||
providers: [WsGateway],
|
||||
providers: [WsGateway, ExcalidrawCollabService],
|
||||
})
|
||||
export class WsModule {}
|
||||
|
||||
Reference in New Issue
Block a user