mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 04:44:31 +01:00 
			
		
		
		
	Compare commits
	
		
			8 Commits
		
	
	
		
			98702ace88
			...
			ryan-di/ro
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					baf68fe663 | ||
| 
						 | 
					08a39e2034 | ||
| 
						 | 
					f71c200106 | ||
| 
						 | 
					ed63af1ad8 | ||
| 
						 | 
					ca5c34ac48 | ||
| 
						 | 
					97cc331530 | ||
| 
						 | 
					23175654b8 | ||
| 
						 | 
					48ec3716ca | 
@@ -23,6 +23,7 @@ export enum WS_SUBTYPES {
 | 
				
			|||||||
  INVALID_RESPONSE = "INVALID_RESPONSE",
 | 
					  INVALID_RESPONSE = "INVALID_RESPONSE",
 | 
				
			||||||
  INIT = "SCENE_INIT",
 | 
					  INIT = "SCENE_INIT",
 | 
				
			||||||
  UPDATE = "SCENE_UPDATE",
 | 
					  UPDATE = "SCENE_UPDATE",
 | 
				
			||||||
 | 
					  DELETE = "SCENE_DELETE",
 | 
				
			||||||
  MOUSE_LOCATION = "MOUSE_LOCATION",
 | 
					  MOUSE_LOCATION = "MOUSE_LOCATION",
 | 
				
			||||||
  IDLE_STATUS = "IDLE_STATUS",
 | 
					  IDLE_STATUS = "IDLE_STATUS",
 | 
				
			||||||
  USER_VISIBLE_SCENE_BOUNDS = "USER_VISIBLE_SCENE_BOUNDS",
 | 
					  USER_VISIBLE_SCENE_BOUNDS = "USER_VISIBLE_SCENE_BOUNDS",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -72,6 +72,7 @@ import {
 | 
				
			|||||||
} from "../data/FileManager";
 | 
					} from "../data/FileManager";
 | 
				
			||||||
import { LocalData } from "../data/LocalData";
 | 
					import { LocalData } from "../data/LocalData";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
 | 
					  deleteRoomFromFirebase,
 | 
				
			||||||
  isSavedToFirebase,
 | 
					  isSavedToFirebase,
 | 
				
			||||||
  loadFilesFromFirebase,
 | 
					  loadFilesFromFirebase,
 | 
				
			||||||
  loadFromFirebase,
 | 
					  loadFromFirebase,
 | 
				
			||||||
@@ -83,6 +84,7 @@ import {
 | 
				
			|||||||
  saveUsernameToLocalStorage,
 | 
					  saveUsernameToLocalStorage,
 | 
				
			||||||
} from "../data/localStorage";
 | 
					} from "../data/localStorage";
 | 
				
			||||||
import { resetBrowserStateVersions } from "../data/tabSync";
 | 
					import { resetBrowserStateVersions } from "../data/tabSync";
 | 
				
			||||||
 | 
					import { roomManager } from "../data/roomManager";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { collabErrorIndicatorAtom } from "./CollabError";
 | 
					import { collabErrorIndicatorAtom } from "./CollabError";
 | 
				
			||||||
import Portal from "./Portal";
 | 
					import Portal from "./Portal";
 | 
				
			||||||
@@ -114,6 +116,7 @@ export interface CollabAPI {
 | 
				
			|||||||
  onPointerUpdate: CollabInstance["onPointerUpdate"];
 | 
					  onPointerUpdate: CollabInstance["onPointerUpdate"];
 | 
				
			||||||
  startCollaboration: CollabInstance["startCollaboration"];
 | 
					  startCollaboration: CollabInstance["startCollaboration"];
 | 
				
			||||||
  stopCollaboration: CollabInstance["stopCollaboration"];
 | 
					  stopCollaboration: CollabInstance["stopCollaboration"];
 | 
				
			||||||
 | 
					  deleteRoom: CollabInstance["deleteRoom"];
 | 
				
			||||||
  syncElements: CollabInstance["syncElements"];
 | 
					  syncElements: CollabInstance["syncElements"];
 | 
				
			||||||
  fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
 | 
					  fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
 | 
				
			||||||
  setUsername: CollabInstance["setUsername"];
 | 
					  setUsername: CollabInstance["setUsername"];
 | 
				
			||||||
@@ -227,6 +230,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
 | 
				
			|||||||
      isCollaborating: this.isCollaborating,
 | 
					      isCollaborating: this.isCollaborating,
 | 
				
			||||||
      onPointerUpdate: this.onPointerUpdate,
 | 
					      onPointerUpdate: this.onPointerUpdate,
 | 
				
			||||||
      startCollaboration: this.startCollaboration,
 | 
					      startCollaboration: this.startCollaboration,
 | 
				
			||||||
 | 
					      deleteRoom: this.deleteRoom,
 | 
				
			||||||
      syncElements: this.syncElements,
 | 
					      syncElements: this.syncElements,
 | 
				
			||||||
      fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
 | 
					      fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
 | 
				
			||||||
      stopCollaboration: this.stopCollaboration,
 | 
					      stopCollaboration: this.stopCollaboration,
 | 
				
			||||||
@@ -547,6 +551,25 @@ class Collab extends PureComponent<CollabProps, CollabState> {
 | 
				
			|||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      this.saveCollabRoomToFirebase(getSyncableElements(elements));
 | 
					      this.saveCollabRoomToFirebase(getSyncableElements(elements));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Save room data to local room manager for new rooms
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        await roomManager.addRoom(
 | 
				
			||||||
 | 
					          roomId,
 | 
				
			||||||
 | 
					          roomKey,
 | 
				
			||||||
 | 
					          getCollaborationLink({ roomId, roomKey }),
 | 
				
			||||||
 | 
					          "", // User can edit this later
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        console.warn("Failed to save room to local storage:", error);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      // Update access time for existing rooms
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        await roomManager.updateRoomAccess(existingRoomLinkData.roomId);
 | 
				
			||||||
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        console.warn("Failed to update room access time:", error);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // fallback in case you're not alone in the room but still don't receive
 | 
					    // fallback in case you're not alone in the room but still don't receive
 | 
				
			||||||
@@ -655,6 +678,18 @@ class Collab extends PureComponent<CollabProps, CollabState> {
 | 
				
			|||||||
            break;
 | 
					            break;
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          case WS_SUBTYPES.DELETE: {
 | 
				
			||||||
 | 
					            const { roomId } = decryptedData.payload;
 | 
				
			||||||
 | 
					            if (this.portal.roomId === roomId) {
 | 
				
			||||||
 | 
					              this.destroySocketClient({ isUnload: true });
 | 
				
			||||||
 | 
					              this.setIsCollaborating(false);
 | 
				
			||||||
 | 
					              this.setActiveRoomLink(null);
 | 
				
			||||||
 | 
					              this.setErrorDialog(t("alerts.collabRoomDeleted"));
 | 
				
			||||||
 | 
					              window.history.pushState({}, APP_NAME, window.location.origin);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            break;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          default: {
 | 
					          default: {
 | 
				
			||||||
            assertNever(decryptedData, null);
 | 
					            assertNever(decryptedData, null);
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
@@ -874,6 +909,42 @@ class Collab extends PureComponent<CollabProps, CollabState> {
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  deleteRoom = async (): Promise<void> => {
 | 
				
			||||||
 | 
					    if (!this.portal.socket || !this.portal.roomId) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const { roomId, roomKey } = this.portal;
 | 
				
			||||||
 | 
					    if (!roomId || !roomKey) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const link = this.getActiveRoomLink();
 | 
				
			||||||
 | 
					    if (!link) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // check if the room belongs to the current user
 | 
				
			||||||
 | 
					    const isOwner = await roomManager.isRoomOwnedByUser(link);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!isOwner) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      this.portal.broadcastRoomDeletion();
 | 
				
			||||||
 | 
					      await deleteRoomFromFirebase(roomId, roomKey);
 | 
				
			||||||
 | 
					      await roomManager.deleteRoom(roomId);
 | 
				
			||||||
 | 
					      this.stopCollaboration(false);
 | 
				
			||||||
 | 
					      this.setActiveRoomLink(null);
 | 
				
			||||||
 | 
					      window.history.pushState({}, APP_NAME, window.location.origin);
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error("Failed to delete room:", error);
 | 
				
			||||||
 | 
					      this.setErrorDialog(t("errors.roomDeletionFailed"));
 | 
				
			||||||
 | 
					      throw error;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
 | 
					  public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
 | 
				
			||||||
    this.lastBroadcastedOrReceivedSceneVersion = version;
 | 
					    this.lastBroadcastedOrReceivedSceneVersion = version;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -252,6 +252,20 @@ class Portal {
 | 
				
			|||||||
      this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload);
 | 
					      this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  broadcastRoomDeletion = async () => {
 | 
				
			||||||
 | 
					    if (this.socket?.id) {
 | 
				
			||||||
 | 
					      const data: SocketUpdateDataSource["ROOM_DELETED"] = {
 | 
				
			||||||
 | 
					        type: WS_SUBTYPES.DELETE,
 | 
				
			||||||
 | 
					        payload: {
 | 
				
			||||||
 | 
					          socketId: this.socket.id as SocketId,
 | 
				
			||||||
 | 
					          roomId: this.roomId!,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      this._broadcastSocketData(data as SocketUpdateData);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default Portal;
 | 
					export default Portal;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -315,3 +315,10 @@ export const loadFilesFromFirebase = async (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  return { loadedFiles, erroredFiles };
 | 
					  return { loadedFiles, erroredFiles };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const deleteRoomFromFirebase = async (
 | 
				
			||||||
 | 
					  roomId: string,
 | 
				
			||||||
 | 
					  roomKey: string,
 | 
				
			||||||
 | 
					): Promise<void> => {
 | 
				
			||||||
 | 
					  // TODO: delete the room...
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -119,6 +119,13 @@ export type SocketUpdateDataSource = {
 | 
				
			|||||||
      username: string;
 | 
					      username: string;
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					  ROOM_DELETED: {
 | 
				
			||||||
 | 
					    type: WS_SUBTYPES.DELETE;
 | 
				
			||||||
 | 
					    payload: {
 | 
				
			||||||
 | 
					      socketId: SocketId;
 | 
				
			||||||
 | 
					      roomId: string;
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type SocketUpdateDataIncoming =
 | 
					export type SocketUpdateDataIncoming =
 | 
				
			||||||
@@ -310,7 +317,7 @@ export const exportToBackend = async (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    const response = await fetch(BACKEND_V2_POST, {
 | 
					    const response = await fetch(BACKEND_V2_POST, {
 | 
				
			||||||
      method: "POST",
 | 
					      method: "POST",
 | 
				
			||||||
      body: payload.buffer,
 | 
					      body: new Uint8Array(payload.buffer),
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    const json = await response.json();
 | 
					    const json = await response.json();
 | 
				
			||||||
    if (json.id) {
 | 
					    if (json.id) {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										218
									
								
								excalidraw-app/data/roomManager.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										218
									
								
								excalidraw-app/data/roomManager.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,218 @@
 | 
				
			|||||||
 | 
					import {
 | 
				
			||||||
 | 
					  generateEncryptionKey,
 | 
				
			||||||
 | 
					  encryptData,
 | 
				
			||||||
 | 
					  decryptData,
 | 
				
			||||||
 | 
					} from "@excalidraw/excalidraw/data/encryption";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface CollabRoom {
 | 
				
			||||||
 | 
					  id: string;
 | 
				
			||||||
 | 
					  roomId: string;
 | 
				
			||||||
 | 
					  roomKey: string;
 | 
				
			||||||
 | 
					  createdAt: number;
 | 
				
			||||||
 | 
					  lastAccessed: number;
 | 
				
			||||||
 | 
					  url: string;
 | 
				
			||||||
 | 
					  name?: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface EncryptedRoomData {
 | 
				
			||||||
 | 
					  rooms: CollabRoom[];
 | 
				
			||||||
 | 
					  version: number;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const ROOM_STORAGE_KEY = "excalidraw-user-rooms";
 | 
				
			||||||
 | 
					const ROOM_STORAGE_VERSION = 1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class RoomManager {
 | 
				
			||||||
 | 
					  private userKey: string | null = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async getUserKey(): Promise<string> {
 | 
				
			||||||
 | 
					    if (this.userKey) {
 | 
				
			||||||
 | 
					      return this.userKey;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const stored = localStorage.getItem(`${ROOM_STORAGE_KEY}-key`);
 | 
				
			||||||
 | 
					      if (stored) {
 | 
				
			||||||
 | 
					        this.userKey = stored;
 | 
				
			||||||
 | 
					        return this.userKey;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.warn("Failed to load user key from localStorage:", error);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.userKey = await generateEncryptionKey();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      localStorage.setItem(`${ROOM_STORAGE_KEY}-key`, this.userKey);
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.warn("Failed to save user key to localStorage:", error);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return this.userKey;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async encryptRoomData(
 | 
				
			||||||
 | 
					    data: EncryptedRoomData,
 | 
				
			||||||
 | 
					  ): Promise<{ data: ArrayBuffer; iv: Uint8Array }> {
 | 
				
			||||||
 | 
					    const userKey = await this.getUserKey();
 | 
				
			||||||
 | 
					    const jsonData = JSON.stringify(data);
 | 
				
			||||||
 | 
					    const { encryptedBuffer, iv } = await encryptData(userKey, jsonData);
 | 
				
			||||||
 | 
					    return { data: encryptedBuffer, iv };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async decryptRoomData(
 | 
				
			||||||
 | 
					    encryptedData: ArrayBuffer,
 | 
				
			||||||
 | 
					    iv: Uint8Array,
 | 
				
			||||||
 | 
					  ): Promise<EncryptedRoomData | null> {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const userKey = await this.getUserKey();
 | 
				
			||||||
 | 
					      const decryptedBuffer = await decryptData(iv, encryptedData, userKey);
 | 
				
			||||||
 | 
					      const jsonString = new TextDecoder().decode(decryptedBuffer);
 | 
				
			||||||
 | 
					      const data = JSON.parse(jsonString) as EncryptedRoomData;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (data.version === ROOM_STORAGE_VERSION && Array.isArray(data.rooms)) {
 | 
				
			||||||
 | 
					        return data;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return null;
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.warn("Failed to decrypt room data:", error);
 | 
				
			||||||
 | 
					      return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async loadRooms(): Promise<CollabRoom[]> {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const storedData = localStorage.getItem(ROOM_STORAGE_KEY);
 | 
				
			||||||
 | 
					      if (!storedData) {
 | 
				
			||||||
 | 
					        return [];
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const { data, iv } = JSON.parse(storedData);
 | 
				
			||||||
 | 
					      const dataBuffer = new Uint8Array(data).buffer;
 | 
				
			||||||
 | 
					      const ivArray = new Uint8Array(iv);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const decryptedData = await this.decryptRoomData(dataBuffer, ivArray);
 | 
				
			||||||
 | 
					      return decryptedData?.rooms || [];
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.warn("Failed to load rooms:", error);
 | 
				
			||||||
 | 
					      return [];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async saveRooms(rooms: CollabRoom[]): Promise<void> {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const data: EncryptedRoomData = {
 | 
				
			||||||
 | 
					        rooms,
 | 
				
			||||||
 | 
					        version: ROOM_STORAGE_VERSION,
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const { data: encryptedData, iv } = await this.encryptRoomData(data);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const storageData = {
 | 
				
			||||||
 | 
					        data: Array.from(new Uint8Array(encryptedData)),
 | 
				
			||||||
 | 
					        iv: Array.from(iv),
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      localStorage.setItem(ROOM_STORAGE_KEY, JSON.stringify(storageData));
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.warn("Failed to save rooms:", error);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async addRoom(
 | 
				
			||||||
 | 
					    roomId: string,
 | 
				
			||||||
 | 
					    roomKey: string,
 | 
				
			||||||
 | 
					    url: string,
 | 
				
			||||||
 | 
					    name?: string,
 | 
				
			||||||
 | 
					  ): Promise<void> {
 | 
				
			||||||
 | 
					    const rooms = await this.loadRooms();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const filteredRooms = rooms.filter((room) => room.roomId !== roomId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const newRoom: CollabRoom = {
 | 
				
			||||||
 | 
					      id: crypto.randomUUID(),
 | 
				
			||||||
 | 
					      roomId,
 | 
				
			||||||
 | 
					      roomKey,
 | 
				
			||||||
 | 
					      createdAt: Date.now(),
 | 
				
			||||||
 | 
					      lastAccessed: Date.now(),
 | 
				
			||||||
 | 
					      url,
 | 
				
			||||||
 | 
					      name,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    filteredRooms.unshift(newRoom);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await this.saveRooms(filteredRooms);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async getRooms(): Promise<CollabRoom[]> {
 | 
				
			||||||
 | 
					    const rooms = await this.loadRooms();
 | 
				
			||||||
 | 
					    return rooms.sort((a, b) => b.lastAccessed - a.lastAccessed);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async updateRoomAccess(roomId: string): Promise<void> {
 | 
				
			||||||
 | 
					    const rooms = await this.loadRooms();
 | 
				
			||||||
 | 
					    const room = rooms.find((r) => r.roomId === roomId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (room) {
 | 
				
			||||||
 | 
					      room.lastAccessed = Date.now();
 | 
				
			||||||
 | 
					      await this.saveRooms(rooms);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async deleteRoom(roomId: string): Promise<void> {
 | 
				
			||||||
 | 
					    const rooms = await this.loadRooms();
 | 
				
			||||||
 | 
					    const filteredRooms = rooms.filter((room) => room.roomId !== roomId);
 | 
				
			||||||
 | 
					    await this.saveRooms(filteredRooms);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async updateRoomName(roomId: string, name: string): Promise<void> {
 | 
				
			||||||
 | 
					    const rooms = await this.loadRooms();
 | 
				
			||||||
 | 
					    const room = rooms.find((r) => r.roomId === roomId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (room) {
 | 
				
			||||||
 | 
					      room.name = name;
 | 
				
			||||||
 | 
					      await this.saveRooms(rooms);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async isRoomOwnedByUser(url: string): Promise<boolean> {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const rooms = await this.loadRooms();
 | 
				
			||||||
 | 
					      const _url = new URL(url);
 | 
				
			||||||
 | 
					      const match = _url.hash.match(/room=([^,]+),([^&]+)/);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!match) {
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const roomId = match[1];
 | 
				
			||||||
 | 
					      return rooms.some((room) => room.roomId === roomId);
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.warn("Failed to check room ownership:", error);
 | 
				
			||||||
 | 
					      return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async getCurrentRoom(): Promise<CollabRoom | null> {
 | 
				
			||||||
 | 
					    const rooms = await this.loadRooms();
 | 
				
			||||||
 | 
					    if (rooms.length === 0) {
 | 
				
			||||||
 | 
					      return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Return the most recently accessed room
 | 
				
			||||||
 | 
					    return rooms.sort((a, b) => b.lastAccessed - a.lastAccessed)[0];
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async clearAllRooms(): Promise<void> {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      localStorage.removeItem(ROOM_STORAGE_KEY);
 | 
				
			||||||
 | 
					      localStorage.removeItem(`${ROOM_STORAGE_KEY}-key`);
 | 
				
			||||||
 | 
					      this.userKey = null;
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.warn("Failed to clear rooms:", error);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const roomManager = new RoomManager();
 | 
				
			||||||
@@ -106,6 +106,10 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        color: var(--text-primary-color);
 | 
					        color: var(--text-primary-color);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        &__text {
 | 
				
			||||||
 | 
					          margin-bottom: 1rem;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        & strong {
 | 
					        & strong {
 | 
				
			||||||
          display: block;
 | 
					          display: block;
 | 
				
			||||||
          font-weight: 700;
 | 
					          font-weight: 700;
 | 
				
			||||||
@@ -155,11 +159,16 @@
 | 
				
			|||||||
        & p + p {
 | 
					        & p + p {
 | 
				
			||||||
          margin-top: 1em;
 | 
					          margin-top: 1em;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        & h3 {
 | 
				
			||||||
 | 
					          font-weight: 600;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      &__actions {
 | 
					      &__actions {
 | 
				
			||||||
        display: flex;
 | 
					        display: flex;
 | 
				
			||||||
        justify-content: center;
 | 
					        justify-content: center;
 | 
				
			||||||
 | 
					        gap: 0.75rem;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,6 +11,7 @@ import {
 | 
				
			|||||||
  share,
 | 
					  share,
 | 
				
			||||||
  shareIOS,
 | 
					  shareIOS,
 | 
				
			||||||
  shareWindows,
 | 
					  shareWindows,
 | 
				
			||||||
 | 
					  TrashIcon,
 | 
				
			||||||
} from "@excalidraw/excalidraw/components/icons";
 | 
					} from "@excalidraw/excalidraw/components/icons";
 | 
				
			||||||
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
 | 
					import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
 | 
				
			||||||
import { useCopyStatus } from "@excalidraw/excalidraw/hooks/useCopiedIndicator";
 | 
					import { useCopyStatus } from "@excalidraw/excalidraw/hooks/useCopiedIndicator";
 | 
				
			||||||
@@ -18,6 +19,8 @@ import { useI18n } from "@excalidraw/excalidraw/i18n";
 | 
				
			|||||||
import { KEYS, getFrame } from "@excalidraw/common";
 | 
					import { KEYS, getFrame } from "@excalidraw/common";
 | 
				
			||||||
import { useEffect, useRef, useState } from "react";
 | 
					import { useEffect, useRef, useState } from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { roomManager } from "excalidraw-app/data/roomManager";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { atom, useAtom, useAtomValue } from "../app-jotai";
 | 
					import { atom, useAtom, useAtomValue } from "../app-jotai";
 | 
				
			||||||
import { activeRoomLinkAtom } from "../collab/Collab";
 | 
					import { activeRoomLinkAtom } from "../collab/Collab";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -69,6 +72,19 @@ const ActiveRoomDialog = ({
 | 
				
			|||||||
  const isShareSupported = "share" in navigator;
 | 
					  const isShareSupported = "share" in navigator;
 | 
				
			||||||
  const { onCopy, copyStatus } = useCopyStatus();
 | 
					  const { onCopy, copyStatus } = useCopyStatus();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [isRoomOwner, setIsRoomOwner] = useState(false);
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    roomManager
 | 
				
			||||||
 | 
					      .isRoomOwnedByUser(activeRoomLink)
 | 
				
			||||||
 | 
					      .then((isOwned) => {
 | 
				
			||||||
 | 
					        setIsRoomOwner(isOwned);
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					      .catch((error) => {
 | 
				
			||||||
 | 
					        console.warn("Failed to check room ownership:", error);
 | 
				
			||||||
 | 
					        setIsRoomOwner(false);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					  }, [activeRoomLink]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const copyRoomLink = async () => {
 | 
					  const copyRoomLink = async () => {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await copyTextToSystemClipboard(activeRoomLink);
 | 
					      await copyTextToSystemClipboard(activeRoomLink);
 | 
				
			||||||
@@ -153,7 +169,10 @@ const ActiveRoomDialog = ({
 | 
				
			|||||||
          </span>
 | 
					          </span>
 | 
				
			||||||
          {t("roomDialog.desc_privacy")}
 | 
					          {t("roomDialog.desc_privacy")}
 | 
				
			||||||
        </p>
 | 
					        </p>
 | 
				
			||||||
 | 
					        <h3>Stop Session</h3>
 | 
				
			||||||
        <p>{t("roomDialog.desc_exitSession")}</p>
 | 
					        <p>{t("roomDialog.desc_exitSession")}</p>
 | 
				
			||||||
 | 
					        {isRoomOwner && <h3>Delete Session</h3>}
 | 
				
			||||||
 | 
					        {isRoomOwner && <p>{t("roomDialog.desc_deleteSession")}</p>}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div className="ShareDialog__active__actions">
 | 
					      <div className="ShareDialog__active__actions">
 | 
				
			||||||
@@ -171,6 +190,21 @@ const ActiveRoomDialog = ({
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
          }}
 | 
					          }}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
 | 
					        {isRoomOwner && (
 | 
				
			||||||
 | 
					          <FilledButton
 | 
				
			||||||
 | 
					            size="large"
 | 
				
			||||||
 | 
					            label={t("roomDialog.button_deleteSession")}
 | 
				
			||||||
 | 
					            icon={TrashIcon}
 | 
				
			||||||
 | 
					            color="danger"
 | 
				
			||||||
 | 
					            onClick={() => {
 | 
				
			||||||
 | 
					              trackEvent("share", "room deleted");
 | 
				
			||||||
 | 
					              collabAPI.deleteRoom();
 | 
				
			||||||
 | 
					              if (!collabAPI.isCollaborating()) {
 | 
				
			||||||
 | 
					                handleClose();
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
@@ -180,7 +214,6 @@ const ShareDialogPicker = (props: ShareDialogProps) => {
 | 
				
			|||||||
  const { t } = useI18n();
 | 
					  const { t } = useI18n();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { collabAPI } = props;
 | 
					  const { collabAPI } = props;
 | 
				
			||||||
 | 
					 | 
				
			||||||
  const startCollabJSX = collabAPI ? (
 | 
					  const startCollabJSX = collabAPI ? (
 | 
				
			||||||
    <>
 | 
					    <>
 | 
				
			||||||
      <div className="ShareDialog__picker__header">
 | 
					      <div className="ShareDialog__picker__header">
 | 
				
			||||||
@@ -188,8 +221,15 @@ const ShareDialogPicker = (props: ShareDialogProps) => {
 | 
				
			|||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div className="ShareDialog__picker__description">
 | 
					      <div className="ShareDialog__picker__description">
 | 
				
			||||||
        <div style={{ marginBottom: "1em" }}>{t("roomDialog.desc_intro")}</div>
 | 
					        <div className="ShareDialog__picker__description__text">
 | 
				
			||||||
        {t("roomDialog.desc_privacy")}
 | 
					          {t("roomDialog.desc_intro")}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div className="ShareDialog__picker__description__text">
 | 
				
			||||||
 | 
					          {t("roomDialog.desc_privacy")}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div className="ShareDialog__picker__description__text">
 | 
				
			||||||
 | 
					          {t("roomDialog.desc_warning")}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div className="ShareDialog__picker__button">
 | 
					      <div className="ShareDialog__picker__button">
 | 
				
			||||||
@@ -204,6 +244,14 @@ const ShareDialogPicker = (props: ShareDialogProps) => {
 | 
				
			|||||||
        />
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div
 | 
				
			||||||
 | 
					        style={{
 | 
				
			||||||
 | 
					          height: "1px",
 | 
				
			||||||
 | 
					          backgroundColor: "var(--color-border)",
 | 
				
			||||||
 | 
					          width: "100%",
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					      ></div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {props.type === "share" && (
 | 
					      {props.type === "share" && (
 | 
				
			||||||
        <div className="ShareDialog__separator">
 | 
					        <div className="ShareDialog__separator">
 | 
				
			||||||
          <span>{t("shareDialog.or")}</span>
 | 
					          <span>{t("shareDialog.or")}</span>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -253,7 +253,8 @@
 | 
				
			|||||||
    "resetLibrary": "This will clear your library. Are you sure?",
 | 
					    "resetLibrary": "This will clear your library. Are you sure?",
 | 
				
			||||||
    "removeItemsFromsLibrary": "Delete {{count}} item(s) from library?",
 | 
					    "removeItemsFromsLibrary": "Delete {{count}} item(s) from library?",
 | 
				
			||||||
    "invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled.",
 | 
					    "invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled.",
 | 
				
			||||||
    "collabOfflineWarning": "No internet connection available.\nYour changes will not be saved!"
 | 
					    "collabOfflineWarning": "No internet connection available.\nYour changes will not be saved!",
 | 
				
			||||||
 | 
					    "collabRoomDeleted": "This collab room has been deleted by its owner."
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "errors": {
 | 
					  "errors": {
 | 
				
			||||||
    "unsupportedFileType": "Unsupported file type.",
 | 
					    "unsupportedFileType": "Unsupported file type.",
 | 
				
			||||||
@@ -280,7 +281,8 @@
 | 
				
			|||||||
    },
 | 
					    },
 | 
				
			||||||
    "asyncPasteFailedOnRead": "Couldn't paste (couldn't read from system clipboard).",
 | 
					    "asyncPasteFailedOnRead": "Couldn't paste (couldn't read from system clipboard).",
 | 
				
			||||||
    "asyncPasteFailedOnParse": "Couldn't paste.",
 | 
					    "asyncPasteFailedOnParse": "Couldn't paste.",
 | 
				
			||||||
    "copyToSystemClipboardFailed": "Couldn't copy to clipboard."
 | 
					    "copyToSystemClipboardFailed": "Couldn't copy to clipboard.",
 | 
				
			||||||
 | 
					    "roomDeletionFailed": "Couldn't delete the collaboration room."
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "toolBar": {
 | 
					  "toolBar": {
 | 
				
			||||||
    "selection": "Selection",
 | 
					    "selection": "Selection",
 | 
				
			||||||
@@ -376,11 +378,14 @@
 | 
				
			|||||||
  "roomDialog": {
 | 
					  "roomDialog": {
 | 
				
			||||||
    "desc_intro": "Invite people to collaborate on your drawing.",
 | 
					    "desc_intro": "Invite people to collaborate on your drawing.",
 | 
				
			||||||
    "desc_privacy": "Don't worry, the session is end-to-end encrypted, and fully private. Not even our server can see what you draw.",
 | 
					    "desc_privacy": "Don't worry, the session is end-to-end encrypted, and fully private. Not even our server can see what you draw.",
 | 
				
			||||||
 | 
					    "desc_warning": "Starting a new session will automatically delete your last active session. Please make sure to save your work from the last session before starting a new one.",
 | 
				
			||||||
    "button_startSession": "Start session",
 | 
					    "button_startSession": "Start session",
 | 
				
			||||||
    "button_stopSession": "Stop session",
 | 
					    "button_stopSession": "Stop session",
 | 
				
			||||||
 | 
					    "button_deleteSession": "Delete session",
 | 
				
			||||||
    "desc_inProgressIntro": "Live-collaboration session is now in progress.",
 | 
					    "desc_inProgressIntro": "Live-collaboration session is now in progress.",
 | 
				
			||||||
    "desc_shareLink": "Share this link with anyone you want to collaborate with:",
 | 
					    "desc_shareLink": "Share this link with anyone you want to collaborate with:",
 | 
				
			||||||
    "desc_exitSession": "Stopping the session will disconnect you from the room, but you'll be able to continue working with the scene, locally. Note that this won't affect other people, and they'll still be able to collaborate on their version.",
 | 
					    "desc_exitSession": "Stopping the session will disconnect you from the room, but you'll be able to continue working with the scene, locally. Note that this won't affect other people, and they'll still be able to collaborate on their version.",
 | 
				
			||||||
 | 
					    "desc_deleteSession": "You're the creator of this session, so you can delete it if you wish to stop collaborating with others. Deleting a session is permanent and will make the scene inaccessible to everyone (including you). Please be sure to save anything important before deleting.",
 | 
				
			||||||
    "shareTitle": "Join a live collaboration session on Excalidraw"
 | 
					    "shareTitle": "Join a live collaboration session on Excalidraw"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "errorDialog": {
 | 
					  "errorDialog": {
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user