mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-31 02:44:50 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			258 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			258 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 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;
 | 
