mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-25 00:44:38 +02:00 
			
		
		
		
	 32df5502ae
			
		
	
	32df5502ae
	
	
	
		
			
			* Introducing fractional indices as part of `element.index` * Ensuring invalid fractional indices are always synchronized with the array order * Simplifying reconciliation based on the fractional indices * Moving reconciliation inside the `@excalidraw/excalidraw` package --------- Co-authored-by: Marcel Mraz <marcel@excalidraw.com> Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
		
			
				
	
	
		
			340 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			340 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import {
 | |
|   compressData,
 | |
|   decompressData,
 | |
| } from "../../packages/excalidraw/data/encode";
 | |
| import {
 | |
|   decryptData,
 | |
|   generateEncryptionKey,
 | |
|   IV_LENGTH_BYTES,
 | |
| } from "../../packages/excalidraw/data/encryption";
 | |
| import { serializeAsJSON } from "../../packages/excalidraw/data/json";
 | |
| import { restore } from "../../packages/excalidraw/data/restore";
 | |
| import { ImportedDataState } from "../../packages/excalidraw/data/types";
 | |
| import { SceneBounds } from "../../packages/excalidraw/element/bounds";
 | |
| import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
 | |
| import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
 | |
| import {
 | |
|   ExcalidrawElement,
 | |
|   FileId,
 | |
|   OrderedExcalidrawElement,
 | |
| } from "../../packages/excalidraw/element/types";
 | |
| import { t } from "../../packages/excalidraw/i18n";
 | |
| import {
 | |
|   AppState,
 | |
|   BinaryFileData,
 | |
|   BinaryFiles,
 | |
|   SocketId,
 | |
|   UserIdleState,
 | |
| } from "../../packages/excalidraw/types";
 | |
| import { MakeBrand } from "../../packages/excalidraw/utility-types";
 | |
| import { bytesToHexString } from "../../packages/excalidraw/utils";
 | |
| import {
 | |
|   DELETED_ELEMENT_TIMEOUT,
 | |
|   FILE_UPLOAD_MAX_BYTES,
 | |
|   ROOM_ID_BYTES,
 | |
|   WS_SUBTYPES,
 | |
| } from "../app_constants";
 | |
| import { encodeFilesForUpload } from "./FileManager";
 | |
| import { saveFilesToFirebase } from "./firebase";
 | |
| 
 | |
| export type SyncableExcalidrawElement = OrderedExcalidrawElement &
 | |
|   MakeBrand<"SyncableExcalidrawElement">;
 | |
| 
 | |
| export const isSyncableElement = (
 | |
|   element: OrderedExcalidrawElement,
 | |
| ): element is SyncableExcalidrawElement => {
 | |
|   if (element.isDeleted) {
 | |
|     if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
 | |
|       return true;
 | |
|     }
 | |
|     return false;
 | |
|   }
 | |
|   return !isInvisiblySmallElement(element);
 | |
| };
 | |
| 
 | |
| export const getSyncableElements = (
 | |
|   elements: readonly OrderedExcalidrawElement[],
 | |
| ) =>
 | |
|   elements.filter((element) =>
 | |
|     isSyncableElement(element),
 | |
|   ) as SyncableExcalidrawElement[];
 | |
| 
 | |
| const BACKEND_V2_GET = import.meta.env.VITE_APP_BACKEND_V2_GET_URL;
 | |
| const BACKEND_V2_POST = import.meta.env.VITE_APP_BACKEND_V2_POST_URL;
 | |
| 
 | |
| const generateRoomId = async () => {
 | |
|   const buffer = new Uint8Array(ROOM_ID_BYTES);
 | |
|   window.crypto.getRandomValues(buffer);
 | |
|   return bytesToHexString(buffer);
 | |
| };
 | |
| 
 | |
| export type EncryptedData = {
 | |
|   data: ArrayBuffer;
 | |
|   iv: Uint8Array;
 | |
| };
 | |
| 
 | |
| export type SocketUpdateDataSource = {
 | |
|   INVALID_RESPONSE: {
 | |
|     type: WS_SUBTYPES.INVALID_RESPONSE;
 | |
|   };
 | |
|   SCENE_INIT: {
 | |
|     type: WS_SUBTYPES.INIT;
 | |
|     payload: {
 | |
|       elements: readonly ExcalidrawElement[];
 | |
|     };
 | |
|   };
 | |
|   SCENE_UPDATE: {
 | |
|     type: WS_SUBTYPES.UPDATE;
 | |
|     payload: {
 | |
|       elements: readonly ExcalidrawElement[];
 | |
|     };
 | |
|   };
 | |
|   MOUSE_LOCATION: {
 | |
|     type: WS_SUBTYPES.MOUSE_LOCATION;
 | |
|     payload: {
 | |
|       socketId: SocketId;
 | |
|       pointer: { x: number; y: number; tool: "pointer" | "laser" };
 | |
|       button: "down" | "up";
 | |
|       selectedElementIds: AppState["selectedElementIds"];
 | |
|       username: string;
 | |
|     };
 | |
|   };
 | |
|   USER_VISIBLE_SCENE_BOUNDS: {
 | |
|     type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS;
 | |
|     payload: {
 | |
|       socketId: SocketId;
 | |
|       username: string;
 | |
|       sceneBounds: SceneBounds;
 | |
|     };
 | |
|   };
 | |
|   IDLE_STATUS: {
 | |
|     type: WS_SUBTYPES.IDLE_STATUS;
 | |
|     payload: {
 | |
|       socketId: SocketId;
 | |
|       userState: UserIdleState;
 | |
|       username: string;
 | |
|     };
 | |
|   };
 | |
| };
 | |
| 
 | |
| export type SocketUpdateDataIncoming =
 | |
|   SocketUpdateDataSource[keyof SocketUpdateDataSource];
 | |
| 
 | |
| export type SocketUpdateData =
 | |
|   SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
 | |
|     _brand: "socketUpdateData";
 | |
|   };
 | |
| 
 | |
| const RE_COLLAB_LINK = /^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/;
 | |
| 
 | |
| export const isCollaborationLink = (link: string) => {
 | |
|   const hash = new URL(link).hash;
 | |
|   return RE_COLLAB_LINK.test(hash);
 | |
| };
 | |
| 
 | |
| export const getCollaborationLinkData = (link: string) => {
 | |
|   const hash = new URL(link).hash;
 | |
|   const match = hash.match(RE_COLLAB_LINK);
 | |
|   if (match && match[2].length !== 22) {
 | |
|     window.alert(t("alerts.invalidEncryptionKey"));
 | |
|     return null;
 | |
|   }
 | |
|   return match ? { roomId: match[1], roomKey: match[2] } : null;
 | |
| };
 | |
| 
 | |
| export const generateCollaborationLinkData = async () => {
 | |
|   const roomId = await generateRoomId();
 | |
|   const roomKey = await generateEncryptionKey();
 | |
| 
 | |
|   if (!roomKey) {
 | |
|     throw new Error("Couldn't generate room key");
 | |
|   }
 | |
| 
 | |
|   return { roomId, roomKey };
 | |
| };
 | |
| 
 | |
| export const getCollaborationLink = (data: {
 | |
|   roomId: string;
 | |
|   roomKey: string;
 | |
| }) => {
 | |
|   return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Decodes shareLink data using the legacy buffer format.
 | |
|  * @deprecated
 | |
|  */
 | |
| const legacy_decodeFromBackend = async ({
 | |
|   buffer,
 | |
|   decryptionKey,
 | |
| }: {
 | |
|   buffer: ArrayBuffer;
 | |
|   decryptionKey: string;
 | |
| }) => {
 | |
|   let decrypted: ArrayBuffer;
 | |
| 
 | |
|   try {
 | |
|     // Buffer should contain both the IV (fixed length) and encrypted data
 | |
|     const iv = buffer.slice(0, IV_LENGTH_BYTES);
 | |
|     const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength);
 | |
|     decrypted = await decryptData(new Uint8Array(iv), encrypted, decryptionKey);
 | |
|   } catch (error: any) {
 | |
|     // Fixed IV (old format, backward compatibility)
 | |
|     const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
 | |
|     decrypted = await decryptData(fixedIv, buffer, decryptionKey);
 | |
|   }
 | |
| 
 | |
|   // We need to convert the decrypted array buffer to a string
 | |
|   const string = new window.TextDecoder("utf-8").decode(
 | |
|     new Uint8Array(decrypted),
 | |
|   );
 | |
|   const data: ImportedDataState = JSON.parse(string);
 | |
| 
 | |
|   return {
 | |
|     elements: data.elements || null,
 | |
|     appState: data.appState || null,
 | |
|   };
 | |
| };
 | |
| 
 | |
| const importFromBackend = async (
 | |
|   id: string,
 | |
|   decryptionKey: string,
 | |
| ): Promise<ImportedDataState> => {
 | |
|   try {
 | |
|     const response = await fetch(`${BACKEND_V2_GET}${id}`);
 | |
| 
 | |
|     if (!response.ok) {
 | |
|       window.alert(t("alerts.importBackendFailed"));
 | |
|       return {};
 | |
|     }
 | |
|     const buffer = await response.arrayBuffer();
 | |
| 
 | |
|     try {
 | |
|       const { data: decodedBuffer } = await decompressData(
 | |
|         new Uint8Array(buffer),
 | |
|         {
 | |
|           decryptionKey,
 | |
|         },
 | |
|       );
 | |
|       const data: ImportedDataState = JSON.parse(
 | |
|         new TextDecoder().decode(decodedBuffer),
 | |
|       );
 | |
| 
 | |
|       return {
 | |
|         elements: data.elements || null,
 | |
|         appState: data.appState || null,
 | |
|       };
 | |
|     } catch (error: any) {
 | |
|       console.warn(
 | |
|         "error when decoding shareLink data using the new format:",
 | |
|         error,
 | |
|       );
 | |
|       return legacy_decodeFromBackend({ buffer, decryptionKey });
 | |
|     }
 | |
|   } catch (error: any) {
 | |
|     window.alert(t("alerts.importBackendFailed"));
 | |
|     console.error(error);
 | |
|     return {};
 | |
|   }
 | |
| };
 | |
| 
 | |
| export const loadScene = async (
 | |
|   id: string | null,
 | |
|   privateKey: string | null,
 | |
|   // Supply local state even if importing from backend to ensure we restore
 | |
|   // localStorage user settings which we do not persist on server.
 | |
|   // Non-optional so we don't forget to pass it even if `undefined`.
 | |
|   localDataState: ImportedDataState | undefined | null,
 | |
| ) => {
 | |
|   let data;
 | |
|   if (id != null && privateKey != null) {
 | |
|     // the private key is used to decrypt the content from the server, take
 | |
|     // extra care not to leak it
 | |
|     data = restore(
 | |
|       await importFromBackend(id, privateKey),
 | |
|       localDataState?.appState,
 | |
|       localDataState?.elements,
 | |
|       { repairBindings: true, refreshDimensions: false },
 | |
|     );
 | |
|   } else {
 | |
|     data = restore(localDataState || null, null, null, {
 | |
|       repairBindings: true,
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   return {
 | |
|     elements: data.elements,
 | |
|     appState: data.appState,
 | |
|     // note: this will always be empty because we're not storing files
 | |
|     // in the scene database/localStorage, and instead fetch them async
 | |
|     // from a different database
 | |
|     files: data.files,
 | |
|     commitToHistory: false,
 | |
|   };
 | |
| };
 | |
| 
 | |
| type ExportToBackendResult =
 | |
|   | { url: null; errorMessage: string }
 | |
|   | { url: string; errorMessage: null };
 | |
| 
 | |
| export const exportToBackend = async (
 | |
|   elements: readonly ExcalidrawElement[],
 | |
|   appState: Partial<AppState>,
 | |
|   files: BinaryFiles,
 | |
| ): Promise<ExportToBackendResult> => {
 | |
|   const encryptionKey = await generateEncryptionKey("string");
 | |
| 
 | |
|   const payload = await compressData(
 | |
|     new TextEncoder().encode(
 | |
|       serializeAsJSON(elements, appState, files, "database"),
 | |
|     ),
 | |
|     { encryptionKey },
 | |
|   );
 | |
| 
 | |
|   try {
 | |
|     const filesMap = new Map<FileId, BinaryFileData>();
 | |
|     for (const element of elements) {
 | |
|       if (isInitializedImageElement(element) && files[element.fileId]) {
 | |
|         filesMap.set(element.fileId, files[element.fileId]);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     const filesToUpload = await encodeFilesForUpload({
 | |
|       files: filesMap,
 | |
|       encryptionKey,
 | |
|       maxBytes: FILE_UPLOAD_MAX_BYTES,
 | |
|     });
 | |
| 
 | |
|     const response = await fetch(BACKEND_V2_POST, {
 | |
|       method: "POST",
 | |
|       body: payload.buffer,
 | |
|     });
 | |
|     const json = await response.json();
 | |
|     if (json.id) {
 | |
|       const url = new URL(window.location.href);
 | |
|       // We need to store the key (and less importantly the id) as hash instead
 | |
|       // of queryParam in order to never send it to the server
 | |
|       url.hash = `json=${json.id},${encryptionKey}`;
 | |
|       const urlString = url.toString();
 | |
| 
 | |
|       await saveFilesToFirebase({
 | |
|         prefix: `/files/shareLinks/${json.id}`,
 | |
|         files: filesToUpload,
 | |
|       });
 | |
| 
 | |
|       return { url: urlString, errorMessage: null };
 | |
|     } else if (json.error_class === "RequestTooLargeError") {
 | |
|       return {
 | |
|         url: null,
 | |
|         errorMessage: t("alerts.couldNotCreateShareableLinkTooBig"),
 | |
|       };
 | |
|     }
 | |
| 
 | |
|     return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
 | |
|   } catch (error: any) {
 | |
|     console.error(error);
 | |
| 
 | |
|     return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
 | |
|   }
 | |
| };
 |