diff options
Diffstat (limited to 'excalidraw-app/collab/Portal.tsx')
| -rw-r--r-- | excalidraw-app/collab/Portal.tsx | 256 |
1 files changed, 256 insertions, 0 deletions
diff --git a/excalidraw-app/collab/Portal.tsx b/excalidraw-app/collab/Portal.tsx new file mode 100644 index 0000000..1788d39 --- /dev/null +++ b/excalidraw-app/collab/Portal.tsx @@ -0,0 +1,256 @@ +import type { + SocketUpdateData, + SocketUpdateDataSource, + SyncableExcalidrawElement, +} from "../data"; +import { isSyncableElement } from "../data"; + +import type { TCollabClass } from "./Collab"; + +import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types"; +import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants"; +import type { + OnUserFollowedPayload, + SocketId, +} from "@excalidraw/excalidraw/types"; +import type { UserIdleState } from "@excalidraw/excalidraw/constants"; +import { trackEvent } from "@excalidraw/excalidraw/analytics"; +import throttle from "lodash.throttle"; +import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement"; +import { encryptData } from "@excalidraw/excalidraw/data/encryption"; +import type { Socket } from "socket.io-client"; +import { CaptureUpdateAction } from "@excalidraw/excalidraw"; + +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; |
