mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-25 00:44:38 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			258 lines
		
	
	
		
			7.3 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			258 lines
		
	
	
		
			7.3 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import {
 | |
|   isSyncableElement,
 | |
|   SocketUpdateData,
 | |
|   SocketUpdateDataSource,
 | |
| } from "../data";
 | |
| 
 | |
| import { TCollabClass } from "./Collab";
 | |
| 
 | |
| import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
 | |
| import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
 | |
| import {
 | |
|   OnUserFollowedPayload,
 | |
|   SocketId,
 | |
|   UserIdleState,
 | |
| } from "../../packages/excalidraw/types";
 | |
| import { trackEvent } from "../../packages/excalidraw/analytics";
 | |
| import throttle from "lodash.throttle";
 | |
| import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
 | |
| import { BroadcastedExcalidrawElement } from "./reconciliation";
 | |
| import { encryptData } from "../../packages/excalidraw/data/encryption";
 | |
| import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
 | |
| 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,
 | |
|           },
 | |
|         });
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     this.collab.excalidrawAPI.updateScene({
 | |
|       elements: this.collab.excalidrawAPI
 | |
|         .getSceneElementsIncludingDeleted()
 | |
|         .map((element) => {
 | |
|           if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
 | |
|             // 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;
 | |
|         }),
 | |
|     });
 | |
|   }, FILE_UPLOAD_TIMEOUT);
 | |
| 
 | |
|   broadcastScene = async (
 | |
|     updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
 | |
|     allElements: readonly ExcalidrawElement[],
 | |
|     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 = allElements.reduce(
 | |
|       (acc, element: BroadcastedExcalidrawElement, idx, elements) => {
 | |
|         if (
 | |
|           (syncAll ||
 | |
|             !this.broadcastedElementVersions.has(element.id) ||
 | |
|             element.version >
 | |
|               this.broadcastedElementVersions.get(element.id)!) &&
 | |
|           isSyncableElement(element)
 | |
|         ) {
 | |
|           acc.push({
 | |
|             ...element,
 | |
|             // z-index info for the reconciler
 | |
|             [PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
 | |
|           });
 | |
|         }
 | |
|         return acc;
 | |
|       },
 | |
|       [] as BroadcastedExcalidrawElement[],
 | |
|     );
 | |
| 
 | |
|     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;
 | 
