aboutsummaryrefslogtreecommitdiffstats
path: root/excalidraw-app/data/index.ts
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commitbfc2cec7d43eb8eaa46dd3f91084932381257059 (patch)
tree0857e3aac2cff922826d4871ff54536b26fad6fc /excalidraw-app/data/index.ts
parent225db4a7805befe009fe055fc2ef5daedd6c04f9 (diff)
refactor: excalidraw-app/
Diffstat (limited to 'excalidraw-app/data/index.ts')
-rw-r--r--excalidraw-app/data/index.ts338
1 files changed, 338 insertions, 0 deletions
diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts
new file mode 100644
index 0000000..6ecb98f
--- /dev/null
+++ b/excalidraw-app/data/index.ts
@@ -0,0 +1,338 @@
+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 type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
+import type { SceneBounds } from "@excalidraw/excalidraw/element/bounds";
+import { isInvisiblySmallElement } from "@excalidraw/excalidraw/element/sizeHelpers";
+import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
+import type {
+ ExcalidrawElement,
+ FileId,
+ OrderedExcalidrawElement,
+} from "@excalidraw/excalidraw/element/types";
+import { t } from "@excalidraw/excalidraw/i18n";
+import type {
+ AppState,
+ BinaryFileData,
+ BinaryFiles,
+ SocketId,
+} from "@excalidraw/excalidraw/types";
+import type { UserIdleState } from "@excalidraw/excalidraw/constants";
+import type { MakeBrand } from "@excalidraw/excalidraw/utility-types";
+import { bytesToHexString } from "@excalidraw/excalidraw/utils";
+import type { WS_SUBTYPES } from "../app_constants";
+import {
+ DELETED_ELEMENT_TIMEOUT,
+ FILE_UPLOAD_MAX_BYTES,
+ ROOM_ID_BYTES,
+} 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,
+ };
+};
+
+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") };
+ }
+};