summaryrefslogtreecommitdiffstats
path: root/excalidraw-app/data
diff options
context:
space:
mode:
Diffstat (limited to 'excalidraw-app/data')
-rw-r--r--excalidraw-app/data/FileManager.ts273
-rw-r--r--excalidraw-app/data/LocalData.ts258
-rw-r--r--excalidraw-app/data/Locker.ts18
-rw-r--r--excalidraw-app/data/firebase.ts313
-rw-r--r--excalidraw-app/data/index.ts338
-rw-r--r--excalidraw-app/data/localStorage.ts99
-rw-r--r--excalidraw-app/data/tabSync.ts39
7 files changed, 1338 insertions, 0 deletions
diff --git a/excalidraw-app/data/FileManager.ts b/excalidraw-app/data/FileManager.ts
new file mode 100644
index 0000000..f5f4eac
--- /dev/null
+++ b/excalidraw-app/data/FileManager.ts
@@ -0,0 +1,273 @@
+import { CaptureUpdateAction } from "@excalidraw/excalidraw";
+import { compressData } from "@excalidraw/excalidraw/data/encode";
+import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
+import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
+import type {
+ ExcalidrawElement,
+ ExcalidrawImageElement,
+ FileId,
+ InitializedExcalidrawImageElement,
+} from "@excalidraw/excalidraw/element/types";
+import { t } from "@excalidraw/excalidraw/i18n";
+import type {
+ BinaryFileData,
+ BinaryFileMetadata,
+ ExcalidrawImperativeAPI,
+ BinaryFiles,
+} from "@excalidraw/excalidraw/types";
+
+type FileVersion = Required<BinaryFileData>["version"];
+
+export class FileManager {
+ /** files being fetched */
+ private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
+ private erroredFiles_fetch = new Map<
+ ExcalidrawImageElement["fileId"],
+ true
+ >();
+ /** files being saved */
+ private savingFiles = new Map<
+ ExcalidrawImageElement["fileId"],
+ FileVersion
+ >();
+ /* files already saved to persistent storage */
+ private savedFiles = new Map<ExcalidrawImageElement["fileId"], FileVersion>();
+ private erroredFiles_save = new Map<
+ ExcalidrawImageElement["fileId"],
+ FileVersion
+ >();
+
+ private _getFiles;
+ private _saveFiles;
+
+ constructor({
+ getFiles,
+ saveFiles,
+ }: {
+ getFiles: (fileIds: FileId[]) => Promise<{
+ loadedFiles: BinaryFileData[];
+ erroredFiles: Map<FileId, true>;
+ }>;
+ saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
+ savedFiles: Map<FileId, BinaryFileData>;
+ erroredFiles: Map<FileId, BinaryFileData>;
+ }>;
+ }) {
+ this._getFiles = getFiles;
+ this._saveFiles = saveFiles;
+ }
+
+ /**
+ * returns whether file is saved/errored, or being processed
+ */
+ isFileTracked = (id: FileId) => {
+ return (
+ this.savedFiles.has(id) ||
+ this.savingFiles.has(id) ||
+ this.fetchingFiles.has(id) ||
+ this.erroredFiles_fetch.has(id) ||
+ this.erroredFiles_save.has(id)
+ );
+ };
+
+ isFileSavedOrBeingSaved = (file: BinaryFileData) => {
+ const fileVersion = this.getFileVersion(file);
+ return (
+ this.savedFiles.get(file.id) === fileVersion ||
+ this.savingFiles.get(file.id) === fileVersion
+ );
+ };
+
+ getFileVersion = (file: BinaryFileData) => {
+ return file.version ?? 1;
+ };
+
+ saveFiles = async ({
+ elements,
+ files,
+ }: {
+ elements: readonly ExcalidrawElement[];
+ files: BinaryFiles;
+ }) => {
+ const addedFiles: Map<FileId, BinaryFileData> = new Map();
+
+ for (const element of elements) {
+ const fileData =
+ isInitializedImageElement(element) && files[element.fileId];
+
+ if (
+ fileData &&
+ // NOTE if errored during save, won't retry due to this check
+ !this.isFileSavedOrBeingSaved(fileData)
+ ) {
+ addedFiles.set(element.fileId, files[element.fileId]);
+ this.savingFiles.set(element.fileId, this.getFileVersion(fileData));
+ }
+ }
+
+ try {
+ const { savedFiles, erroredFiles } = await this._saveFiles({
+ addedFiles,
+ });
+
+ for (const [fileId, fileData] of savedFiles) {
+ this.savedFiles.set(fileId, this.getFileVersion(fileData));
+ }
+
+ for (const [fileId, fileData] of erroredFiles) {
+ this.erroredFiles_save.set(fileId, this.getFileVersion(fileData));
+ }
+
+ return {
+ savedFiles,
+ erroredFiles,
+ };
+ } finally {
+ for (const [fileId] of addedFiles) {
+ this.savingFiles.delete(fileId);
+ }
+ }
+ };
+
+ getFiles = async (
+ ids: FileId[],
+ ): Promise<{
+ loadedFiles: BinaryFileData[];
+ erroredFiles: Map<FileId, true>;
+ }> => {
+ if (!ids.length) {
+ return {
+ loadedFiles: [],
+ erroredFiles: new Map(),
+ };
+ }
+ for (const id of ids) {
+ this.fetchingFiles.set(id, true);
+ }
+
+ try {
+ const { loadedFiles, erroredFiles } = await this._getFiles(ids);
+
+ for (const file of loadedFiles) {
+ this.savedFiles.set(file.id, this.getFileVersion(file));
+ }
+ for (const [fileId] of erroredFiles) {
+ this.erroredFiles_fetch.set(fileId, true);
+ }
+
+ return { loadedFiles, erroredFiles };
+ } finally {
+ for (const id of ids) {
+ this.fetchingFiles.delete(id);
+ }
+ }
+ };
+
+ /** a file element prevents unload only if it's being saved regardless of
+ * its `status`. This ensures that elements who for any reason haven't
+ * beed set to `saved` status don't prevent unload in future sessions.
+ * Technically we should prevent unload when the origin client haven't
+ * yet saved the `status` update to storage, but that should be taken care
+ * of during regular beforeUnload unsaved files check.
+ */
+ shouldPreventUnload = (elements: readonly ExcalidrawElement[]) => {
+ return elements.some((element) => {
+ return (
+ isInitializedImageElement(element) &&
+ !element.isDeleted &&
+ this.savingFiles.has(element.fileId)
+ );
+ });
+ };
+
+ /**
+ * helper to determine if image element status needs updating
+ */
+ shouldUpdateImageElementStatus = (
+ element: ExcalidrawElement,
+ ): element is InitializedExcalidrawImageElement => {
+ return (
+ isInitializedImageElement(element) &&
+ this.savedFiles.has(element.fileId) &&
+ element.status === "pending"
+ );
+ };
+
+ reset() {
+ this.fetchingFiles.clear();
+ this.savingFiles.clear();
+ this.savedFiles.clear();
+ this.erroredFiles_fetch.clear();
+ this.erroredFiles_save.clear();
+ }
+}
+
+export const encodeFilesForUpload = async ({
+ files,
+ maxBytes,
+ encryptionKey,
+}: {
+ files: Map<FileId, BinaryFileData>;
+ maxBytes: number;
+ encryptionKey: string;
+}) => {
+ const processedFiles: {
+ id: FileId;
+ buffer: Uint8Array;
+ }[] = [];
+
+ for (const [id, fileData] of files) {
+ const buffer = new TextEncoder().encode(fileData.dataURL);
+
+ const encodedFile = await compressData<BinaryFileMetadata>(buffer, {
+ encryptionKey,
+ metadata: {
+ id,
+ mimeType: fileData.mimeType,
+ created: Date.now(),
+ lastRetrieved: Date.now(),
+ },
+ });
+
+ if (buffer.byteLength > maxBytes) {
+ throw new Error(
+ t("errors.fileTooBig", {
+ maxSize: `${Math.trunc(maxBytes / 1024 / 1024)}MB`,
+ }),
+ );
+ }
+
+ processedFiles.push({
+ id,
+ buffer: encodedFile,
+ });
+ }
+
+ return processedFiles;
+};
+
+export const updateStaleImageStatuses = (params: {
+ excalidrawAPI: ExcalidrawImperativeAPI;
+ erroredFiles: Map<FileId, true>;
+ elements: readonly ExcalidrawElement[];
+}) => {
+ if (!params.erroredFiles.size) {
+ return;
+ }
+ params.excalidrawAPI.updateScene({
+ elements: params.excalidrawAPI
+ .getSceneElementsIncludingDeleted()
+ .map((element) => {
+ if (
+ isInitializedImageElement(element) &&
+ params.erroredFiles.has(element.fileId)
+ ) {
+ return newElementWith(element, {
+ status: "error",
+ });
+ }
+ return element;
+ }),
+ captureUpdate: CaptureUpdateAction.NEVER,
+ });
+};
diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts
new file mode 100644
index 0000000..3ab0ad9
--- /dev/null
+++ b/excalidraw-app/data/LocalData.ts
@@ -0,0 +1,258 @@
+/**
+ * This file deals with saving data state (appState, elements, images, ...)
+ * locally to the browser.
+ *
+ * Notes:
+ *
+ * - DataState refers to full state of the app: appState, elements, images,
+ * though some state is saved separately (collab username, library) for one
+ * reason or another. We also save different data to different storage
+ * (localStorage, indexedDB).
+ */
+
+import {
+ createStore,
+ entries,
+ del,
+ getMany,
+ set,
+ setMany,
+ get,
+} from "idb-keyval";
+import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState";
+import {
+ CANVAS_SEARCH_TAB,
+ DEFAULT_SIDEBAR,
+} from "@excalidraw/excalidraw/constants";
+import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
+import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
+import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element";
+import type {
+ ExcalidrawElement,
+ FileId,
+} from "@excalidraw/excalidraw/element/types";
+import type {
+ AppState,
+ BinaryFileData,
+ BinaryFiles,
+} from "@excalidraw/excalidraw/types";
+import type { MaybePromise } from "@excalidraw/excalidraw/utility-types";
+import { debounce } from "@excalidraw/excalidraw/utils";
+import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
+import { FileManager } from "./FileManager";
+import { Locker } from "./Locker";
+import { updateBrowserStateVersion } from "./tabSync";
+
+const filesStore = createStore("files-db", "files-store");
+
+class LocalFileManager extends FileManager {
+ clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
+ await entries(filesStore).then((entries) => {
+ for (const [id, imageData] of entries as [FileId, BinaryFileData][]) {
+ // if image is unused (not on canvas) & is older than 1 day, delete it
+ // from storage. We check `lastRetrieved` we care about the last time
+ // the image was used (loaded on canvas), not when it was initially
+ // created.
+ if (
+ (!imageData.lastRetrieved ||
+ Date.now() - imageData.lastRetrieved > 24 * 3600 * 1000) &&
+ !opts.currentFileIds.includes(id as FileId)
+ ) {
+ del(id, filesStore);
+ }
+ }
+ });
+ };
+}
+
+const saveDataStateToLocalStorage = (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+) => {
+ try {
+ const _appState = clearAppStateForLocalStorage(appState);
+
+ if (
+ _appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
+ _appState.openSidebar.tab === CANVAS_SEARCH_TAB
+ ) {
+ _appState.openSidebar = null;
+ }
+
+ localStorage.setItem(
+ STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
+ JSON.stringify(clearElementsForLocalStorage(elements)),
+ );
+ localStorage.setItem(
+ STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
+ JSON.stringify(_appState),
+ );
+ updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
+ } catch (error: any) {
+ // Unable to access window.localStorage
+ console.error(error);
+ }
+};
+
+type SavingLockTypes = "collaboration";
+
+export class LocalData {
+ private static _save = debounce(
+ async (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+ files: BinaryFiles,
+ onFilesSaved: () => void,
+ ) => {
+ saveDataStateToLocalStorage(elements, appState);
+
+ await this.fileStorage.saveFiles({
+ elements,
+ files,
+ });
+ onFilesSaved();
+ },
+ SAVE_TO_LOCAL_STORAGE_TIMEOUT,
+ );
+
+ /** Saves DataState, including files. Bails if saving is paused */
+ static save = (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+ files: BinaryFiles,
+ onFilesSaved: () => void,
+ ) => {
+ // we need to make the `isSavePaused` check synchronously (undebounced)
+ if (!this.isSavePaused()) {
+ this._save(elements, appState, files, onFilesSaved);
+ }
+ };
+
+ static flushSave = () => {
+ this._save.flush();
+ };
+
+ private static locker = new Locker<SavingLockTypes>();
+
+ static pauseSave = (lockType: SavingLockTypes) => {
+ this.locker.lock(lockType);
+ };
+
+ static resumeSave = (lockType: SavingLockTypes) => {
+ this.locker.unlock(lockType);
+ };
+
+ static isSavePaused = () => {
+ return document.hidden || this.locker.isLocked();
+ };
+
+ // ---------------------------------------------------------------------------
+
+ static fileStorage = new LocalFileManager({
+ getFiles(ids) {
+ return getMany(ids, filesStore).then(
+ async (filesData: (BinaryFileData | undefined)[]) => {
+ const loadedFiles: BinaryFileData[] = [];
+ const erroredFiles = new Map<FileId, true>();
+
+ const filesToSave: [FileId, BinaryFileData][] = [];
+
+ filesData.forEach((data, index) => {
+ const id = ids[index];
+ if (data) {
+ const _data: BinaryFileData = {
+ ...data,
+ lastRetrieved: Date.now(),
+ };
+ filesToSave.push([id, _data]);
+ loadedFiles.push(_data);
+ } else {
+ erroredFiles.set(id, true);
+ }
+ });
+
+ try {
+ // save loaded files back to storage with updated `lastRetrieved`
+ setMany(filesToSave, filesStore);
+ } catch (error) {
+ console.warn(error);
+ }
+
+ return { loadedFiles, erroredFiles };
+ },
+ );
+ },
+ async saveFiles({ addedFiles }) {
+ const savedFiles = new Map<FileId, BinaryFileData>();
+ const erroredFiles = new Map<FileId, BinaryFileData>();
+
+ // before we use `storage` event synchronization, let's update the flag
+ // optimistically. Hopefully nothing fails, and an IDB read executed
+ // before an IDB write finishes will read the latest value.
+ updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES);
+
+ await Promise.all(
+ [...addedFiles].map(async ([id, fileData]) => {
+ try {
+ await set(id, fileData, filesStore);
+ savedFiles.set(id, fileData);
+ } catch (error: any) {
+ console.error(error);
+ erroredFiles.set(id, fileData);
+ }
+ }),
+ );
+
+ return { savedFiles, erroredFiles };
+ },
+ });
+}
+export class LibraryIndexedDBAdapter {
+ /** IndexedDB database and store name */
+ private static idb_name = STORAGE_KEYS.IDB_LIBRARY;
+ /** library data store key */
+ private static key = "libraryData";
+
+ private static store = createStore(
+ `${LibraryIndexedDBAdapter.idb_name}-db`,
+ `${LibraryIndexedDBAdapter.idb_name}-store`,
+ );
+
+ static async load() {
+ const IDBData = await get<LibraryPersistedData>(
+ LibraryIndexedDBAdapter.key,
+ LibraryIndexedDBAdapter.store,
+ );
+
+ return IDBData || null;
+ }
+
+ static save(data: LibraryPersistedData): MaybePromise<void> {
+ return set(
+ LibraryIndexedDBAdapter.key,
+ data,
+ LibraryIndexedDBAdapter.store,
+ );
+ }
+}
+
+/** LS Adapter used only for migrating LS library data
+ * to indexedDB */
+export class LibraryLocalStorageMigrationAdapter {
+ static load() {
+ const LSData = localStorage.getItem(
+ STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY,
+ );
+ if (LSData != null) {
+ const libraryItems: ImportedDataState["libraryItems"] =
+ JSON.parse(LSData);
+ if (libraryItems) {
+ return { libraryItems };
+ }
+ }
+ return null;
+ }
+ static clear() {
+ localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
+ }
+}
diff --git a/excalidraw-app/data/Locker.ts b/excalidraw-app/data/Locker.ts
new file mode 100644
index 0000000..c99673e
--- /dev/null
+++ b/excalidraw-app/data/Locker.ts
@@ -0,0 +1,18 @@
+export class Locker<T extends string> {
+ private locks = new Map<T, true>();
+
+ lock = (lockType: T) => {
+ this.locks.set(lockType, true);
+ };
+
+ /** @returns whether no locks remaining */
+ unlock = (lockType: T) => {
+ this.locks.delete(lockType);
+ return !this.isLocked();
+ };
+
+ /** @returns whether some (or specific) locks are present */
+ isLocked(lockType?: T) {
+ return lockType ? this.locks.has(lockType) : !!this.locks.size;
+ }
+}
diff --git a/excalidraw-app/data/firebase.ts b/excalidraw-app/data/firebase.ts
new file mode 100644
index 0000000..a871b48
--- /dev/null
+++ b/excalidraw-app/data/firebase.ts
@@ -0,0 +1,313 @@
+import { reconcileElements } from "@excalidraw/excalidraw";
+import type {
+ ExcalidrawElement,
+ FileId,
+ OrderedExcalidrawElement,
+} from "@excalidraw/excalidraw/element/types";
+import { getSceneVersion } from "@excalidraw/excalidraw/element";
+import type Portal from "../collab/Portal";
+import { restoreElements } from "@excalidraw/excalidraw/data/restore";
+import type {
+ AppState,
+ BinaryFileData,
+ BinaryFileMetadata,
+ DataURL,
+} from "@excalidraw/excalidraw/types";
+import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
+import { decompressData } from "@excalidraw/excalidraw/data/encode";
+import {
+ encryptData,
+ decryptData,
+} from "@excalidraw/excalidraw/data/encryption";
+import { MIME_TYPES } from "@excalidraw/excalidraw/constants";
+import type { SyncableExcalidrawElement } from ".";
+import { getSyncableElements } from ".";
+import { initializeApp } from "firebase/app";
+import {
+ getFirestore,
+ doc,
+ getDoc,
+ runTransaction,
+ Bytes,
+} from "firebase/firestore";
+import { getStorage, ref, uploadBytes } from "firebase/storage";
+import type { Socket } from "socket.io-client";
+import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
+
+// private
+// -----------------------------------------------------------------------------
+
+let FIREBASE_CONFIG: Record<string, any>;
+try {
+ FIREBASE_CONFIG = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
+} catch (error: any) {
+ console.warn(
+ `Error JSON parsing firebase config. Supplied value: ${
+ import.meta.env.VITE_APP_FIREBASE_CONFIG
+ }`,
+ );
+ FIREBASE_CONFIG = {};
+}
+
+let firebaseApp: ReturnType<typeof initializeApp> | null = null;
+let firestore: ReturnType<typeof getFirestore> | null = null;
+let firebaseStorage: ReturnType<typeof getStorage> | null = null;
+
+const _initializeFirebase = () => {
+ if (!firebaseApp) {
+ firebaseApp = initializeApp(FIREBASE_CONFIG);
+ }
+ return firebaseApp;
+};
+
+const _getFirestore = () => {
+ if (!firestore) {
+ firestore = getFirestore(_initializeFirebase());
+ }
+ return firestore;
+};
+
+const _getStorage = () => {
+ if (!firebaseStorage) {
+ firebaseStorage = getStorage(_initializeFirebase());
+ }
+ return firebaseStorage;
+};
+
+// -----------------------------------------------------------------------------
+
+export const loadFirebaseStorage = async () => {
+ return _getStorage();
+};
+
+type FirebaseStoredScene = {
+ sceneVersion: number;
+ iv: Bytes;
+ ciphertext: Bytes;
+};
+
+const encryptElements = async (
+ key: string,
+ elements: readonly ExcalidrawElement[],
+): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => {
+ const json = JSON.stringify(elements);
+ const encoded = new TextEncoder().encode(json);
+ const { encryptedBuffer, iv } = await encryptData(key, encoded);
+
+ return { ciphertext: encryptedBuffer, iv };
+};
+
+const decryptElements = async (
+ data: FirebaseStoredScene,
+ roomKey: string,
+): Promise<readonly ExcalidrawElement[]> => {
+ const ciphertext = data.ciphertext.toUint8Array();
+ const iv = data.iv.toUint8Array();
+
+ const decrypted = await decryptData(iv, ciphertext, roomKey);
+ const decodedData = new TextDecoder("utf-8").decode(
+ new Uint8Array(decrypted),
+ );
+ return JSON.parse(decodedData);
+};
+
+class FirebaseSceneVersionCache {
+ private static cache = new WeakMap<Socket, number>();
+ static get = (socket: Socket) => {
+ return FirebaseSceneVersionCache.cache.get(socket);
+ };
+ static set = (
+ socket: Socket,
+ elements: readonly SyncableExcalidrawElement[],
+ ) => {
+ FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements));
+ };
+}
+
+export const isSavedToFirebase = (
+ portal: Portal,
+ elements: readonly ExcalidrawElement[],
+): boolean => {
+ if (portal.socket && portal.roomId && portal.roomKey) {
+ const sceneVersion = getSceneVersion(elements);
+
+ return FirebaseSceneVersionCache.get(portal.socket) === sceneVersion;
+ }
+ // if no room exists, consider the room saved so that we don't unnecessarily
+ // prevent unload (there's nothing we could do at that point anyway)
+ return true;
+};
+
+export const saveFilesToFirebase = async ({
+ prefix,
+ files,
+}: {
+ prefix: string;
+ files: { id: FileId; buffer: Uint8Array }[];
+}) => {
+ const storage = await loadFirebaseStorage();
+
+ const erroredFiles: FileId[] = [];
+ const savedFiles: FileId[] = [];
+
+ await Promise.all(
+ files.map(async ({ id, buffer }) => {
+ try {
+ const storageRef = ref(storage, `${prefix}/${id}`);
+ await uploadBytes(storageRef, buffer, {
+ cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
+ });
+ savedFiles.push(id);
+ } catch (error: any) {
+ erroredFiles.push(id);
+ }
+ }),
+ );
+
+ return { savedFiles, erroredFiles };
+};
+
+const createFirebaseSceneDocument = async (
+ elements: readonly SyncableExcalidrawElement[],
+ roomKey: string,
+) => {
+ const sceneVersion = getSceneVersion(elements);
+ const { ciphertext, iv } = await encryptElements(roomKey, elements);
+ return {
+ sceneVersion,
+ ciphertext: Bytes.fromUint8Array(new Uint8Array(ciphertext)),
+ iv: Bytes.fromUint8Array(iv),
+ } as FirebaseStoredScene;
+};
+
+export const saveToFirebase = async (
+ portal: Portal,
+ elements: readonly SyncableExcalidrawElement[],
+ appState: AppState,
+) => {
+ const { roomId, roomKey, socket } = portal;
+ if (
+ // bail if no room exists as there's nothing we can do at this point
+ !roomId ||
+ !roomKey ||
+ !socket ||
+ isSavedToFirebase(portal, elements)
+ ) {
+ return null;
+ }
+
+ const firestore = _getFirestore();
+ const docRef = doc(firestore, "scenes", roomId);
+
+ const storedScene = await runTransaction(firestore, async (transaction) => {
+ const snapshot = await transaction.get(docRef);
+
+ if (!snapshot.exists()) {
+ const storedScene = await createFirebaseSceneDocument(elements, roomKey);
+
+ transaction.set(docRef, storedScene);
+
+ return storedScene;
+ }
+
+ const prevStoredScene = snapshot.data() as FirebaseStoredScene;
+ const prevStoredElements = getSyncableElements(
+ restoreElements(await decryptElements(prevStoredScene, roomKey), null),
+ );
+ const reconciledElements = getSyncableElements(
+ reconcileElements(
+ elements,
+ prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
+ appState,
+ ),
+ );
+
+ const storedScene = await createFirebaseSceneDocument(
+ reconciledElements,
+ roomKey,
+ );
+
+ transaction.update(docRef, storedScene);
+
+ // Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime
+ return storedScene;
+ });
+
+ const storedElements = getSyncableElements(
+ restoreElements(await decryptElements(storedScene, roomKey), null),
+ );
+
+ FirebaseSceneVersionCache.set(socket, storedElements);
+
+ return storedElements;
+};
+
+export const loadFromFirebase = async (
+ roomId: string,
+ roomKey: string,
+ socket: Socket | null,
+): Promise<readonly SyncableExcalidrawElement[] | null> => {
+ const firestore = _getFirestore();
+ const docRef = doc(firestore, "scenes", roomId);
+ const docSnap = await getDoc(docRef);
+ if (!docSnap.exists()) {
+ return null;
+ }
+ const storedScene = docSnap.data() as FirebaseStoredScene;
+ const elements = getSyncableElements(
+ restoreElements(await decryptElements(storedScene, roomKey), null),
+ );
+
+ if (socket) {
+ FirebaseSceneVersionCache.set(socket, elements);
+ }
+
+ return elements;
+};
+
+export const loadFilesFromFirebase = async (
+ prefix: string,
+ decryptionKey: string,
+ filesIds: readonly FileId[],
+) => {
+ const loadedFiles: BinaryFileData[] = [];
+ const erroredFiles = new Map<FileId, true>();
+
+ await Promise.all(
+ [...new Set(filesIds)].map(async (id) => {
+ try {
+ const url = `https://firebasestorage.googleapis.com/v0/b/${
+ FIREBASE_CONFIG.storageBucket
+ }/o/${encodeURIComponent(prefix.replace(/^\//, ""))}%2F${id}`;
+ const response = await fetch(`${url}?alt=media`);
+ if (response.status < 400) {
+ const arrayBuffer = await response.arrayBuffer();
+
+ const { data, metadata } = await decompressData<BinaryFileMetadata>(
+ new Uint8Array(arrayBuffer),
+ {
+ decryptionKey,
+ },
+ );
+
+ const dataURL = new TextDecoder().decode(data) as DataURL;
+
+ loadedFiles.push({
+ mimeType: metadata.mimeType || MIME_TYPES.binary,
+ id,
+ dataURL,
+ created: metadata?.created || Date.now(),
+ lastRetrieved: metadata?.created || Date.now(),
+ });
+ } else {
+ erroredFiles.set(id, true);
+ }
+ } catch (error: any) {
+ erroredFiles.set(id, true);
+ console.error(error);
+ }
+ }),
+ );
+
+ return { loadedFiles, erroredFiles };
+};
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") };
+ }
+};
diff --git a/excalidraw-app/data/localStorage.ts b/excalidraw-app/data/localStorage.ts
new file mode 100644
index 0000000..640dfdb
--- /dev/null
+++ b/excalidraw-app/data/localStorage.ts
@@ -0,0 +1,99 @@
+import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types";
+import type { AppState } from "@excalidraw/excalidraw/types";
+import {
+ clearAppStateForLocalStorage,
+ getDefaultAppState,
+} from "@excalidraw/excalidraw/appState";
+import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element";
+import { STORAGE_KEYS } from "../app_constants";
+
+export const saveUsernameToLocalStorage = (username: string) => {
+ try {
+ localStorage.setItem(
+ STORAGE_KEYS.LOCAL_STORAGE_COLLAB,
+ JSON.stringify({ username }),
+ );
+ } catch (error: any) {
+ // Unable to access window.localStorage
+ console.error(error);
+ }
+};
+
+export const importUsernameFromLocalStorage = (): string | null => {
+ try {
+ const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
+ if (data) {
+ return JSON.parse(data).username;
+ }
+ } catch (error: any) {
+ // Unable to access localStorage
+ console.error(error);
+ }
+
+ return null;
+};
+
+export const importFromLocalStorage = () => {
+ let savedElements = null;
+ let savedState = null;
+
+ try {
+ savedElements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
+ savedState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
+ } catch (error: any) {
+ // Unable to access localStorage
+ console.error(error);
+ }
+
+ let elements: ExcalidrawElement[] = [];
+ if (savedElements) {
+ try {
+ elements = clearElementsForLocalStorage(JSON.parse(savedElements));
+ } catch (error: any) {
+ console.error(error);
+ // Do nothing because elements array is already empty
+ }
+ }
+
+ let appState = null;
+ if (savedState) {
+ try {
+ appState = {
+ ...getDefaultAppState(),
+ ...clearAppStateForLocalStorage(
+ JSON.parse(savedState) as Partial<AppState>,
+ ),
+ };
+ } catch (error: any) {
+ console.error(error);
+ // Do nothing because appState is already null
+ }
+ }
+ return { elements, appState };
+};
+
+export const getElementsStorageSize = () => {
+ try {
+ const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
+ const elementsSize = elements?.length || 0;
+ return elementsSize;
+ } catch (error: any) {
+ console.error(error);
+ return 0;
+ }
+};
+
+export const getTotalStorageSize = () => {
+ try {
+ const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
+ const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
+
+ const appStateSize = appState?.length || 0;
+ const collabSize = collab?.length || 0;
+
+ return appStateSize + collabSize + getElementsStorageSize();
+ } catch (error: any) {
+ console.error(error);
+ return 0;
+ }
+};
diff --git a/excalidraw-app/data/tabSync.ts b/excalidraw-app/data/tabSync.ts
new file mode 100644
index 0000000..0617a55
--- /dev/null
+++ b/excalidraw-app/data/tabSync.ts
@@ -0,0 +1,39 @@
+import { STORAGE_KEYS } from "../app_constants";
+
+// in-memory state (this tab's current state) versions. Currently just
+// timestamps of the last time the state was saved to browser storage.
+const LOCAL_STATE_VERSIONS = {
+ [STORAGE_KEYS.VERSION_DATA_STATE]: -1,
+ [STORAGE_KEYS.VERSION_FILES]: -1,
+};
+
+type BrowserStateTypes = keyof typeof LOCAL_STATE_VERSIONS;
+
+export const isBrowserStorageStateNewer = (type: BrowserStateTypes) => {
+ const storageTimestamp = JSON.parse(localStorage.getItem(type) || "-1");
+ return storageTimestamp > LOCAL_STATE_VERSIONS[type];
+};
+
+export const updateBrowserStateVersion = (type: BrowserStateTypes) => {
+ const timestamp = Date.now();
+ try {
+ localStorage.setItem(type, JSON.stringify(timestamp));
+ LOCAL_STATE_VERSIONS[type] = timestamp;
+ } catch (error) {
+ console.error("error while updating browser state verison", error);
+ }
+};
+
+export const resetBrowserStateVersions = () => {
+ try {
+ for (const key of Object.keys(
+ LOCAL_STATE_VERSIONS,
+ ) as BrowserStateTypes[]) {
+ const timestamp = -1;
+ localStorage.setItem(key, JSON.stringify(timestamp));
+ LOCAL_STATE_VERSIONS[key] = timestamp;
+ }
+ } catch (error) {
+ console.error("error while resetting browser state verison", error);
+ }
+};