mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-03 20:34:40 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			348 lines
		
	
	
		
			9.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			348 lines
		
	
	
		
			9.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import {
 | 
						|
  compressData,
 | 
						|
  decompressData,
 | 
						|
} from "@excalidraw/excalidraw/data/encode";
 | 
						|
import {
 | 
						|
  decryptData,
 | 
						|
  generateEncryptionKey,
 | 
						|
  IV_LENGTH_BYTES,
 | 
						|
} from "@excalidraw/excalidraw/data/encryption";
 | 
						|
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
 | 
						|
import { restore } from "@excalidraw/excalidraw/data/restore";
 | 
						|
import { isInvisiblySmallElement } from "@excalidraw/element";
 | 
						|
import { isInitializedImageElement } from "@excalidraw/element";
 | 
						|
import { t } from "@excalidraw/excalidraw/i18n";
 | 
						|
import { bytesToHexString } from "@excalidraw/common";
 | 
						|
 | 
						|
import type { UserIdleState } from "@excalidraw/common";
 | 
						|
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
 | 
						|
import type { SceneBounds } from "@excalidraw/element";
 | 
						|
import type {
 | 
						|
  ExcalidrawElement,
 | 
						|
  FileId,
 | 
						|
  OrderedExcalidrawElement,
 | 
						|
} from "@excalidraw/element/types";
 | 
						|
import type {
 | 
						|
  AppState,
 | 
						|
  BinaryFileData,
 | 
						|
  BinaryFiles,
 | 
						|
  SocketId,
 | 
						|
} from "@excalidraw/excalidraw/types";
 | 
						|
import type { MakeBrand } from "@excalidraw/common/utility-types";
 | 
						|
 | 
						|
import {
 | 
						|
  DELETED_ELEMENT_TIMEOUT,
 | 
						|
  FILE_UPLOAD_MAX_BYTES,
 | 
						|
  ROOM_ID_BYTES,
 | 
						|
} from "../app_constants";
 | 
						|
 | 
						|
import { encodeFilesForUpload } from "./FileManager";
 | 
						|
import { saveFilesToFirebase } from "./firebase";
 | 
						|
 | 
						|
import type { WS_SUBTYPES } from "../app_constants";
 | 
						|
 | 
						|
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,
 | 
						|
        deleteInvisibleElements: true,
 | 
						|
      },
 | 
						|
    );
 | 
						|
  } else {
 | 
						|
    data = restore(localDataState || null, null, null, {
 | 
						|
      repairBindings: true,
 | 
						|
      deleteInvisibleElements: 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,
 | 
						|
  };
 | 
						|
};
 | 
						|
 | 
						|
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") };
 | 
						|
  }
 | 
						|
};
 |