CollabWrapper.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829
  1. import throttle from "lodash.throttle";
  2. import { PureComponent } from "react";
  3. import { ExcalidrawImperativeAPI } from "../../types";
  4. import { ErrorDialog } from "../../components/ErrorDialog";
  5. import { APP_NAME, ENV, EVENT } from "../../constants";
  6. import { ImportedDataState } from "../../data/types";
  7. import {
  8. ExcalidrawElement,
  9. InitializedExcalidrawImageElement,
  10. } from "../../element/types";
  11. import { getSceneVersion } from "../../packages/excalidraw/index";
  12. import { Collaborator, Gesture } from "../../types";
  13. import {
  14. getFrame,
  15. preventUnload,
  16. resolvablePromise,
  17. withBatchedUpdates,
  18. } from "../../utils";
  19. import {
  20. CURSOR_SYNC_TIMEOUT,
  21. FILE_UPLOAD_MAX_BYTES,
  22. FIREBASE_STORAGE_PREFIXES,
  23. INITIAL_SCENE_UPDATE_TIMEOUT,
  24. LOAD_IMAGES_TIMEOUT,
  25. SCENE,
  26. STORAGE_KEYS,
  27. SYNC_FULL_SCENE_INTERVAL_MS,
  28. } from "../app_constants";
  29. import {
  30. generateCollaborationLinkData,
  31. getCollaborationLink,
  32. getCollabServer,
  33. SocketUpdateDataSource,
  34. } from "../data";
  35. import {
  36. isSavedToFirebase,
  37. loadFilesFromFirebase,
  38. loadFromFirebase,
  39. saveFilesToFirebase,
  40. saveToFirebase,
  41. } from "../data/firebase";
  42. import {
  43. importUsernameFromLocalStorage,
  44. saveUsernameToLocalStorage,
  45. } from "../data/localStorage";
  46. import Portal from "./Portal";
  47. import RoomDialog from "./RoomDialog";
  48. import { createInverseContext } from "../../createInverseContext";
  49. import { t } from "../../i18n";
  50. import { UserIdleState } from "../../types";
  51. import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
  52. import { trackEvent } from "../../analytics";
  53. import { isInvisiblySmallElement } from "../../element";
  54. import {
  55. encodeFilesForUpload,
  56. FileManager,
  57. updateStaleImageStatuses,
  58. } from "../data/FileManager";
  59. import { AbortError } from "../../errors";
  60. import {
  61. isImageElement,
  62. isInitializedImageElement,
  63. } from "../../element/typeChecks";
  64. import { newElementWith } from "../../element/mutateElement";
  65. import {
  66. ReconciledElements,
  67. reconcileElements as _reconcileElements,
  68. } from "./reconciliation";
  69. import { decryptData } from "../../data/encryption";
  70. import { resetBrowserStateVersions } from "../data/tabSync";
  71. import { LocalData } from "../data/LocalData";
  72. interface CollabState {
  73. modalIsShown: boolean;
  74. errorMessage: string;
  75. username: string;
  76. userState: UserIdleState;
  77. activeRoomLink: string;
  78. }
  79. type CollabInstance = InstanceType<typeof CollabWrapper>;
  80. export interface CollabAPI {
  81. /** function so that we can access the latest value from stale callbacks */
  82. isCollaborating: () => boolean;
  83. username: CollabState["username"];
  84. userState: CollabState["userState"];
  85. onPointerUpdate: CollabInstance["onPointerUpdate"];
  86. initializeSocketClient: CollabInstance["initializeSocketClient"];
  87. onCollabButtonClick: CollabInstance["onCollabButtonClick"];
  88. broadcastElements: CollabInstance["broadcastElements"];
  89. fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
  90. setUsername: (username: string) => void;
  91. }
  92. interface Props {
  93. excalidrawAPI: ExcalidrawImperativeAPI;
  94. onRoomClose?: () => void;
  95. }
  96. const {
  97. Context: CollabContext,
  98. Consumer: CollabContextConsumer,
  99. Provider: CollabContextProvider,
  100. } = createInverseContext<{ api: CollabAPI | null }>({ api: null });
  101. export { CollabContext, CollabContextConsumer };
  102. class CollabWrapper extends PureComponent<Props, CollabState> {
  103. portal: Portal;
  104. fileManager: FileManager;
  105. excalidrawAPI: Props["excalidrawAPI"];
  106. activeIntervalId: number | null;
  107. idleTimeoutId: number | null;
  108. // marked as private to ensure we don't change it outside this class
  109. private _isCollaborating: boolean = false;
  110. private socketInitializationTimer?: number;
  111. private lastBroadcastedOrReceivedSceneVersion: number = -1;
  112. private collaborators = new Map<string, Collaborator>();
  113. constructor(props: Props) {
  114. super(props);
  115. this.state = {
  116. modalIsShown: false,
  117. errorMessage: "",
  118. username: importUsernameFromLocalStorage() || "",
  119. userState: UserIdleState.ACTIVE,
  120. activeRoomLink: "",
  121. };
  122. this.portal = new Portal(this);
  123. this.fileManager = new FileManager({
  124. getFiles: async (fileIds) => {
  125. const { roomId, roomKey } = this.portal;
  126. if (!roomId || !roomKey) {
  127. throw new AbortError();
  128. }
  129. return loadFilesFromFirebase(`files/rooms/${roomId}`, roomKey, fileIds);
  130. },
  131. saveFiles: async ({ addedFiles }) => {
  132. const { roomId, roomKey } = this.portal;
  133. if (!roomId || !roomKey) {
  134. throw new AbortError();
  135. }
  136. return saveFilesToFirebase({
  137. prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
  138. files: await encodeFilesForUpload({
  139. files: addedFiles,
  140. encryptionKey: roomKey,
  141. maxBytes: FILE_UPLOAD_MAX_BYTES,
  142. }),
  143. });
  144. },
  145. });
  146. this.excalidrawAPI = props.excalidrawAPI;
  147. this.activeIntervalId = null;
  148. this.idleTimeoutId = null;
  149. }
  150. componentDidMount() {
  151. window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
  152. window.addEventListener(EVENT.UNLOAD, this.onUnload);
  153. if (
  154. process.env.NODE_ENV === ENV.TEST ||
  155. process.env.NODE_ENV === ENV.DEVELOPMENT
  156. ) {
  157. window.collab = window.collab || ({} as Window["collab"]);
  158. Object.defineProperties(window, {
  159. collab: {
  160. configurable: true,
  161. value: this,
  162. },
  163. });
  164. }
  165. }
  166. componentWillUnmount() {
  167. window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
  168. window.removeEventListener(EVENT.UNLOAD, this.onUnload);
  169. window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
  170. window.removeEventListener(
  171. EVENT.VISIBILITY_CHANGE,
  172. this.onVisibilityChange,
  173. );
  174. if (this.activeIntervalId) {
  175. window.clearInterval(this.activeIntervalId);
  176. this.activeIntervalId = null;
  177. }
  178. if (this.idleTimeoutId) {
  179. window.clearTimeout(this.idleTimeoutId);
  180. this.idleTimeoutId = null;
  181. }
  182. }
  183. isCollaborating = () => this._isCollaborating;
  184. private onUnload = () => {
  185. this.destroySocketClient({ isUnload: true });
  186. };
  187. private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
  188. const syncableElements = this.getSyncableElements(
  189. this.getSceneElementsIncludingDeleted(),
  190. );
  191. if (
  192. this._isCollaborating &&
  193. (this.fileManager.shouldPreventUnload(syncableElements) ||
  194. !isSavedToFirebase(this.portal, syncableElements))
  195. ) {
  196. // this won't run in time if user decides to leave the site, but
  197. // the purpose is to run in immediately after user decides to stay
  198. this.saveCollabRoomToFirebase(syncableElements);
  199. preventUnload(event);
  200. }
  201. if (this.isCollaborating || this.portal.roomId) {
  202. try {
  203. localStorage?.setItem(
  204. STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
  205. JSON.stringify({
  206. timestamp: Date.now(),
  207. room: this.portal.roomId,
  208. }),
  209. );
  210. } catch {}
  211. }
  212. });
  213. saveCollabRoomToFirebase = async (
  214. syncableElements: readonly ExcalidrawElement[] = this.getSyncableElements(
  215. this.excalidrawAPI.getSceneElementsIncludingDeleted(),
  216. ),
  217. ) => {
  218. try {
  219. await saveToFirebase(this.portal, syncableElements);
  220. } catch (error: any) {
  221. console.error(error);
  222. }
  223. };
  224. openPortal = async () => {
  225. trackEvent("share", "room creation", `ui (${getFrame()})`);
  226. return this.initializeSocketClient(null);
  227. };
  228. closePortal = () => {
  229. this.queueBroadcastAllElements.cancel();
  230. this.loadImageFiles.cancel();
  231. this.saveCollabRoomToFirebase();
  232. if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
  233. // hack to ensure that we prefer we disregard any new browser state
  234. // that could have been saved in other tabs while we were collaborating
  235. resetBrowserStateVersions();
  236. window.history.pushState({}, APP_NAME, window.location.origin);
  237. this.destroySocketClient();
  238. trackEvent("share", "room closed");
  239. this.props.onRoomClose?.();
  240. const elements = this.excalidrawAPI
  241. .getSceneElementsIncludingDeleted()
  242. .map((element) => {
  243. if (isImageElement(element) && element.status === "saved") {
  244. return newElementWith(element, { status: "pending" });
  245. }
  246. return element;
  247. });
  248. this.excalidrawAPI.updateScene({
  249. elements,
  250. commitToHistory: false,
  251. });
  252. }
  253. };
  254. private destroySocketClient = (opts?: { isUnload: boolean }) => {
  255. if (!opts?.isUnload) {
  256. this.collaborators = new Map();
  257. this.excalidrawAPI.updateScene({
  258. collaborators: this.collaborators,
  259. });
  260. this.setState({
  261. activeRoomLink: "",
  262. });
  263. this._isCollaborating = false;
  264. LocalData.resumeSave("collaboration");
  265. }
  266. this.lastBroadcastedOrReceivedSceneVersion = -1;
  267. this.portal.close();
  268. this.fileManager.reset();
  269. };
  270. private fetchImageFilesFromFirebase = async (scene: {
  271. elements: readonly ExcalidrawElement[];
  272. }) => {
  273. const unfetchedImages = scene.elements
  274. .filter((element) => {
  275. return (
  276. isInitializedImageElement(element) &&
  277. !this.fileManager.isFileHandled(element.fileId) &&
  278. !element.isDeleted &&
  279. element.status === "saved"
  280. );
  281. })
  282. .map((element) => (element as InitializedExcalidrawImageElement).fileId);
  283. return await this.fileManager.getFiles(unfetchedImages);
  284. };
  285. private decryptPayload = async (
  286. iv: Uint8Array,
  287. encryptedData: ArrayBuffer,
  288. decryptionKey: string,
  289. ) => {
  290. try {
  291. const decrypted = await decryptData(iv, encryptedData, decryptionKey);
  292. const decodedData = new TextDecoder("utf-8").decode(
  293. new Uint8Array(decrypted),
  294. );
  295. return JSON.parse(decodedData);
  296. } catch (error) {
  297. window.alert(t("alerts.decryptFailed"));
  298. console.error(error);
  299. return {
  300. type: "INVALID_RESPONSE",
  301. };
  302. }
  303. };
  304. private initializeSocketClient = async (
  305. existingRoomLinkData: null | { roomId: string; roomKey: string },
  306. ): Promise<ImportedDataState | null> => {
  307. if (this.portal.socket) {
  308. return null;
  309. }
  310. let roomId;
  311. let roomKey;
  312. if (existingRoomLinkData) {
  313. ({ roomId, roomKey } = existingRoomLinkData);
  314. } else {
  315. ({ roomId, roomKey } = await generateCollaborationLinkData());
  316. window.history.pushState(
  317. {},
  318. APP_NAME,
  319. getCollaborationLink({ roomId, roomKey }),
  320. );
  321. }
  322. const scenePromise = resolvablePromise<ImportedDataState | null>();
  323. this._isCollaborating = true;
  324. LocalData.pauseSave("collaboration");
  325. const { default: socketIOClient } = await import(
  326. /* webpackChunkName: "socketIoClient" */ "socket.io-client"
  327. );
  328. try {
  329. const socketServerData = await getCollabServer();
  330. this.portal.socket = this.portal.open(
  331. socketIOClient(socketServerData.url, {
  332. transports: socketServerData.polling
  333. ? ["websocket", "polling"]
  334. : ["websocket"],
  335. }),
  336. roomId,
  337. roomKey,
  338. );
  339. } catch (error: any) {
  340. console.error(error);
  341. this.setState({ errorMessage: error.message });
  342. return null;
  343. }
  344. if (!existingRoomLinkData) {
  345. const elements = this.excalidrawAPI.getSceneElements().map((element) => {
  346. if (isImageElement(element) && element.status === "saved") {
  347. return newElementWith(element, { status: "pending" });
  348. }
  349. return element;
  350. });
  351. // remove deleted elements from elements array & history to ensure we don't
  352. // expose potentially sensitive user data in case user manually deletes
  353. // existing elements (or clears scene), which would otherwise be persisted
  354. // to database even if deleted before creating the room.
  355. this.excalidrawAPI.history.clear();
  356. this.excalidrawAPI.updateScene({
  357. elements,
  358. commitToHistory: true,
  359. });
  360. this.broadcastElements(elements);
  361. const syncableElements = this.getSyncableElements(elements);
  362. this.saveCollabRoomToFirebase(syncableElements);
  363. }
  364. // fallback in case you're not alone in the room but still don't receive
  365. // initial SCENE_INIT message
  366. this.socketInitializationTimer = window.setTimeout(() => {
  367. this.initializeRoom({
  368. roomLinkData: existingRoomLinkData,
  369. fetchScene: true,
  370. });
  371. scenePromise.resolve(null);
  372. }, INITIAL_SCENE_UPDATE_TIMEOUT);
  373. // All socket listeners are moving to Portal
  374. this.portal.socket.on(
  375. "client-broadcast",
  376. async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
  377. if (!this.portal.roomKey) {
  378. return;
  379. }
  380. const decryptedData = await this.decryptPayload(
  381. iv,
  382. encryptedData,
  383. this.portal.roomKey,
  384. );
  385. switch (decryptedData.type) {
  386. case "INVALID_RESPONSE":
  387. return;
  388. case SCENE.INIT: {
  389. if (!this.portal.socketInitialized) {
  390. this.initializeRoom({ fetchScene: false });
  391. const remoteElements = decryptedData.payload.elements;
  392. const reconciledElements = this.reconcileElements(remoteElements);
  393. this.handleRemoteSceneUpdate(reconciledElements, {
  394. init: true,
  395. });
  396. // noop if already resolved via init from firebase
  397. scenePromise.resolve({
  398. elements: reconciledElements,
  399. scrollToContent: true,
  400. });
  401. }
  402. break;
  403. }
  404. case SCENE.UPDATE:
  405. this.handleRemoteSceneUpdate(
  406. this.reconcileElements(decryptedData.payload.elements),
  407. );
  408. break;
  409. case "MOUSE_LOCATION": {
  410. const { pointer, button, username, selectedElementIds } =
  411. decryptedData.payload;
  412. const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
  413. decryptedData.payload.socketId ||
  414. // @ts-ignore legacy, see #2094 (#2097)
  415. decryptedData.payload.socketID;
  416. const collaborators = new Map(this.collaborators);
  417. const user = collaborators.get(socketId) || {}!;
  418. user.pointer = pointer;
  419. user.button = button;
  420. user.selectedElementIds = selectedElementIds;
  421. user.username = username;
  422. collaborators.set(socketId, user);
  423. this.excalidrawAPI.updateScene({
  424. collaborators,
  425. });
  426. break;
  427. }
  428. case "IDLE_STATUS": {
  429. const { userState, socketId, username } = decryptedData.payload;
  430. const collaborators = new Map(this.collaborators);
  431. const user = collaborators.get(socketId) || {}!;
  432. user.userState = userState;
  433. user.username = username;
  434. this.excalidrawAPI.updateScene({
  435. collaborators,
  436. });
  437. break;
  438. }
  439. }
  440. },
  441. );
  442. this.portal.socket.on("first-in-room", async () => {
  443. if (this.portal.socket) {
  444. this.portal.socket.off("first-in-room");
  445. }
  446. const sceneData = await this.initializeRoom({
  447. fetchScene: true,
  448. roomLinkData: existingRoomLinkData,
  449. });
  450. scenePromise.resolve(sceneData);
  451. });
  452. this.initializeIdleDetector();
  453. this.setState({
  454. activeRoomLink: window.location.href,
  455. });
  456. return scenePromise;
  457. };
  458. private initializeRoom = async ({
  459. fetchScene,
  460. roomLinkData,
  461. }:
  462. | {
  463. fetchScene: true;
  464. roomLinkData: { roomId: string; roomKey: string } | null;
  465. }
  466. | { fetchScene: false; roomLinkData?: null }) => {
  467. clearTimeout(this.socketInitializationTimer!);
  468. if (fetchScene && roomLinkData && this.portal.socket) {
  469. this.excalidrawAPI.resetScene();
  470. try {
  471. const elements = await loadFromFirebase(
  472. roomLinkData.roomId,
  473. roomLinkData.roomKey,
  474. this.portal.socket,
  475. );
  476. if (elements) {
  477. this.setLastBroadcastedOrReceivedSceneVersion(
  478. getSceneVersion(elements),
  479. );
  480. return {
  481. elements,
  482. scrollToContent: true,
  483. };
  484. }
  485. } catch (error: any) {
  486. // log the error and move on. other peers will sync us the scene.
  487. console.error(error);
  488. } finally {
  489. this.portal.socketInitialized = true;
  490. }
  491. } else {
  492. this.portal.socketInitialized = true;
  493. }
  494. return null;
  495. };
  496. private reconcileElements = (
  497. remoteElements: readonly ExcalidrawElement[],
  498. ): ReconciledElements => {
  499. const localElements = this.getSceneElementsIncludingDeleted();
  500. const appState = this.excalidrawAPI.getAppState();
  501. const reconciledElements = _reconcileElements(
  502. localElements,
  503. remoteElements,
  504. appState,
  505. );
  506. // Avoid broadcasting to the rest of the collaborators the scene
  507. // we just received!
  508. // Note: this needs to be set before updating the scene as it
  509. // synchronously calls render.
  510. this.setLastBroadcastedOrReceivedSceneVersion(
  511. getSceneVersion(reconciledElements),
  512. );
  513. return reconciledElements;
  514. };
  515. private loadImageFiles = throttle(async () => {
  516. const { loadedFiles, erroredFiles } =
  517. await this.fetchImageFilesFromFirebase({
  518. elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
  519. });
  520. this.excalidrawAPI.addFiles(loadedFiles);
  521. updateStaleImageStatuses({
  522. excalidrawAPI: this.excalidrawAPI,
  523. erroredFiles,
  524. elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
  525. });
  526. }, LOAD_IMAGES_TIMEOUT);
  527. private handleRemoteSceneUpdate = (
  528. elements: ReconciledElements,
  529. { init = false }: { init?: boolean } = {},
  530. ) => {
  531. this.excalidrawAPI.updateScene({
  532. elements,
  533. commitToHistory: !!init,
  534. });
  535. // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
  536. // when we receive any messages from another peer. This UX can be pretty rough -- if you
  537. // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
  538. // right now we think this is the right tradeoff.
  539. this.excalidrawAPI.history.clear();
  540. this.loadImageFiles();
  541. };
  542. private onPointerMove = () => {
  543. if (this.idleTimeoutId) {
  544. window.clearTimeout(this.idleTimeoutId);
  545. this.idleTimeoutId = null;
  546. }
  547. this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
  548. if (!this.activeIntervalId) {
  549. this.activeIntervalId = window.setInterval(
  550. this.reportActive,
  551. ACTIVE_THRESHOLD,
  552. );
  553. }
  554. };
  555. private onVisibilityChange = () => {
  556. if (document.hidden) {
  557. if (this.idleTimeoutId) {
  558. window.clearTimeout(this.idleTimeoutId);
  559. this.idleTimeoutId = null;
  560. }
  561. if (this.activeIntervalId) {
  562. window.clearInterval(this.activeIntervalId);
  563. this.activeIntervalId = null;
  564. }
  565. this.onIdleStateChange(UserIdleState.AWAY);
  566. } else {
  567. this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
  568. this.activeIntervalId = window.setInterval(
  569. this.reportActive,
  570. ACTIVE_THRESHOLD,
  571. );
  572. this.onIdleStateChange(UserIdleState.ACTIVE);
  573. }
  574. };
  575. private reportIdle = () => {
  576. this.onIdleStateChange(UserIdleState.IDLE);
  577. if (this.activeIntervalId) {
  578. window.clearInterval(this.activeIntervalId);
  579. this.activeIntervalId = null;
  580. }
  581. };
  582. private reportActive = () => {
  583. this.onIdleStateChange(UserIdleState.ACTIVE);
  584. };
  585. private initializeIdleDetector = () => {
  586. document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
  587. document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
  588. };
  589. setCollaborators(sockets: string[]) {
  590. this.setState((state) => {
  591. const collaborators: InstanceType<typeof CollabWrapper>["collaborators"] =
  592. new Map();
  593. for (const socketId of sockets) {
  594. if (this.collaborators.has(socketId)) {
  595. collaborators.set(socketId, this.collaborators.get(socketId)!);
  596. } else {
  597. collaborators.set(socketId, {});
  598. }
  599. }
  600. this.collaborators = collaborators;
  601. this.excalidrawAPI.updateScene({ collaborators });
  602. });
  603. }
  604. public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
  605. this.lastBroadcastedOrReceivedSceneVersion = version;
  606. };
  607. public getLastBroadcastedOrReceivedSceneVersion = () => {
  608. return this.lastBroadcastedOrReceivedSceneVersion;
  609. };
  610. public getSceneElementsIncludingDeleted = () => {
  611. return this.excalidrawAPI.getSceneElementsIncludingDeleted();
  612. };
  613. onPointerUpdate = throttle(
  614. (payload: {
  615. pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
  616. button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
  617. pointersMap: Gesture["pointers"];
  618. }) => {
  619. payload.pointersMap.size < 2 &&
  620. this.portal.socket &&
  621. this.portal.broadcastMouseLocation(payload);
  622. },
  623. CURSOR_SYNC_TIMEOUT,
  624. );
  625. onIdleStateChange = (userState: UserIdleState) => {
  626. this.setState({ userState });
  627. this.portal.broadcastIdleChange(userState);
  628. };
  629. broadcastElements = (elements: readonly ExcalidrawElement[]) => {
  630. if (
  631. getSceneVersion(elements) >
  632. this.getLastBroadcastedOrReceivedSceneVersion()
  633. ) {
  634. this.portal.broadcastScene(SCENE.UPDATE, elements, false);
  635. this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
  636. this.queueBroadcastAllElements();
  637. }
  638. };
  639. queueBroadcastAllElements = throttle(() => {
  640. this.portal.broadcastScene(
  641. SCENE.UPDATE,
  642. this.excalidrawAPI.getSceneElementsIncludingDeleted(),
  643. true,
  644. );
  645. const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion();
  646. const newVersion = Math.max(
  647. currentVersion,
  648. getSceneVersion(this.getSceneElementsIncludingDeleted()),
  649. );
  650. this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
  651. }, SYNC_FULL_SCENE_INTERVAL_MS);
  652. handleClose = () => {
  653. this.setState({ modalIsShown: false });
  654. };
  655. setUsername = (username: string) => {
  656. this.setState({ username });
  657. };
  658. onUsernameChange = (username: string) => {
  659. this.setUsername(username);
  660. saveUsernameToLocalStorage(username);
  661. };
  662. onCollabButtonClick = () => {
  663. this.setState({
  664. modalIsShown: true,
  665. });
  666. };
  667. isSyncableElement = (element: ExcalidrawElement) => {
  668. return element.isDeleted || !isInvisiblySmallElement(element);
  669. };
  670. getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
  671. elements.filter((element) => this.isSyncableElement(element));
  672. /** PRIVATE. Use `this.getContextValue()` instead. */
  673. private contextValue: CollabAPI | null = null;
  674. /** Getter of context value. Returned object is stable. */
  675. getContextValue = (): CollabAPI => {
  676. if (!this.contextValue) {
  677. this.contextValue = {} as CollabAPI;
  678. }
  679. this.contextValue.isCollaborating = this.isCollaborating;
  680. this.contextValue.username = this.state.username;
  681. this.contextValue.onPointerUpdate = this.onPointerUpdate;
  682. this.contextValue.initializeSocketClient = this.initializeSocketClient;
  683. this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
  684. this.contextValue.broadcastElements = this.broadcastElements;
  685. this.contextValue.fetchImageFilesFromFirebase =
  686. this.fetchImageFilesFromFirebase;
  687. this.contextValue.setUsername = this.setUsername;
  688. return this.contextValue;
  689. };
  690. render() {
  691. const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
  692. return (
  693. <>
  694. {modalIsShown && (
  695. <RoomDialog
  696. handleClose={this.handleClose}
  697. activeRoomLink={activeRoomLink}
  698. username={username}
  699. onUsernameChange={this.onUsernameChange}
  700. onRoomCreate={this.openPortal}
  701. onRoomDestroy={this.closePortal}
  702. setErrorMessage={(errorMessage) => {
  703. this.setState({ errorMessage });
  704. }}
  705. theme={this.excalidrawAPI.getAppState().theme}
  706. />
  707. )}
  708. {errorMessage && (
  709. <ErrorDialog
  710. message={errorMessage}
  711. onClose={() => this.setState({ errorMessage: "" })}
  712. />
  713. )}
  714. <CollabContextProvider
  715. value={{
  716. api: this.getContextValue(),
  717. }}
  718. />
  719. </>
  720. );
  721. }
  722. }
  723. declare global {
  724. interface Window {
  725. collab: InstanceType<typeof CollabWrapper>;
  726. }
  727. }
  728. if (
  729. process.env.NODE_ENV === ENV.TEST ||
  730. process.env.NODE_ENV === ENV.DEVELOPMENT
  731. ) {
  732. window.collab = window.collab || ({} as Window["collab"]);
  733. }
  734. export default CollabWrapper;