123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257 |
- import { CaptureUpdateAction } from "@excalidraw/excalidraw";
- import { trackEvent } from "@excalidraw/excalidraw/analytics";
- import { encryptData } from "@excalidraw/excalidraw/data/encryption";
- import { newElementWith } from "@excalidraw/element/mutateElement";
- 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;
|