mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 12:54:23 +01:00 
			
		
		
		
	* 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>
		
			
				
	
	
		
			364 lines
		
	
	
		
			9.9 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			364 lines
		
	
	
		
			9.9 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import {
 | 
						|
  ExcalidrawElement,
 | 
						|
  FileId,
 | 
						|
  OrderedExcalidrawElement,
 | 
						|
} from "../../packages/excalidraw/element/types";
 | 
						|
import { getSceneVersion } from "../../packages/excalidraw/element";
 | 
						|
import Portal from "../collab/Portal";
 | 
						|
import { restoreElements } from "../../packages/excalidraw/data/restore";
 | 
						|
import {
 | 
						|
  AppState,
 | 
						|
  BinaryFileData,
 | 
						|
  BinaryFileMetadata,
 | 
						|
  DataURL,
 | 
						|
} from "../../packages/excalidraw/types";
 | 
						|
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
 | 
						|
import { decompressData } from "../../packages/excalidraw/data/encode";
 | 
						|
import {
 | 
						|
  encryptData,
 | 
						|
  decryptData,
 | 
						|
} from "../../packages/excalidraw/data/encryption";
 | 
						|
import { MIME_TYPES } from "../../packages/excalidraw/constants";
 | 
						|
import { getSyncableElements, SyncableExcalidrawElement } from ".";
 | 
						|
import { ResolutionType } from "../../packages/excalidraw/utility-types";
 | 
						|
import type { Socket } from "socket.io-client";
 | 
						|
import {
 | 
						|
  RemoteExcalidrawElement,
 | 
						|
  reconcileElements,
 | 
						|
} from "../../packages/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 firebasePromise: Promise<typeof import("firebase/app").default> | null =
 | 
						|
  null;
 | 
						|
let firestorePromise: Promise<any> | null | true = null;
 | 
						|
let firebaseStoragePromise: Promise<any> | null | true = null;
 | 
						|
 | 
						|
let isFirebaseInitialized = false;
 | 
						|
 | 
						|
const _loadFirebase = async () => {
 | 
						|
  const firebase = (
 | 
						|
    await import(/* webpackChunkName: "firebase" */ "firebase/app")
 | 
						|
  ).default;
 | 
						|
 | 
						|
  if (!isFirebaseInitialized) {
 | 
						|
    try {
 | 
						|
      firebase.initializeApp(FIREBASE_CONFIG);
 | 
						|
    } catch (error: any) {
 | 
						|
      // trying initialize again throws. Usually this is harmless, and happens
 | 
						|
      // mainly in dev (HMR)
 | 
						|
      if (error.code === "app/duplicate-app") {
 | 
						|
        console.warn(error.name, error.code);
 | 
						|
      } else {
 | 
						|
        throw error;
 | 
						|
      }
 | 
						|
    }
 | 
						|
    isFirebaseInitialized = true;
 | 
						|
  }
 | 
						|
 | 
						|
  return firebase;
 | 
						|
};
 | 
						|
 | 
						|
const _getFirebase = async (): Promise<
 | 
						|
  typeof import("firebase/app").default
 | 
						|
> => {
 | 
						|
  if (!firebasePromise) {
 | 
						|
    firebasePromise = _loadFirebase();
 | 
						|
  }
 | 
						|
  return firebasePromise;
 | 
						|
};
 | 
						|
 | 
						|
// -----------------------------------------------------------------------------
 | 
						|
 | 
						|
const loadFirestore = async () => {
 | 
						|
  const firebase = await _getFirebase();
 | 
						|
  if (!firestorePromise) {
 | 
						|
    firestorePromise = import(
 | 
						|
      /* webpackChunkName: "firestore" */ "firebase/firestore"
 | 
						|
    );
 | 
						|
  }
 | 
						|
  if (firestorePromise !== true) {
 | 
						|
    await firestorePromise;
 | 
						|
    firestorePromise = true;
 | 
						|
  }
 | 
						|
  return firebase;
 | 
						|
};
 | 
						|
 | 
						|
export const loadFirebaseStorage = async () => {
 | 
						|
  const firebase = await _getFirebase();
 | 
						|
  if (!firebaseStoragePromise) {
 | 
						|
    firebaseStoragePromise = import(
 | 
						|
      /* webpackChunkName: "storage" */ "firebase/storage"
 | 
						|
    );
 | 
						|
  }
 | 
						|
  if (firebaseStoragePromise !== true) {
 | 
						|
    await firebaseStoragePromise;
 | 
						|
    firebaseStoragePromise = true;
 | 
						|
  }
 | 
						|
  return firebase;
 | 
						|
};
 | 
						|
 | 
						|
interface FirebaseStoredScene {
 | 
						|
  sceneVersion: number;
 | 
						|
  iv: firebase.default.firestore.Blob;
 | 
						|
  ciphertext: firebase.default.firestore.Blob;
 | 
						|
}
 | 
						|
 | 
						|
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 firebase = await loadFirebaseStorage();
 | 
						|
 | 
						|
  const erroredFiles = new Map<FileId, true>();
 | 
						|
  const savedFiles = new Map<FileId, true>();
 | 
						|
 | 
						|
  await Promise.all(
 | 
						|
    files.map(async ({ id, buffer }) => {
 | 
						|
      try {
 | 
						|
        await firebase
 | 
						|
          .storage()
 | 
						|
          .ref(`${prefix}/${id}`)
 | 
						|
          .put(
 | 
						|
            new Blob([buffer], {
 | 
						|
              type: MIME_TYPES.binary,
 | 
						|
            }),
 | 
						|
            {
 | 
						|
              cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
 | 
						|
            },
 | 
						|
          );
 | 
						|
        savedFiles.set(id, true);
 | 
						|
      } catch (error: any) {
 | 
						|
        erroredFiles.set(id, true);
 | 
						|
      }
 | 
						|
    }),
 | 
						|
  );
 | 
						|
 | 
						|
  return { savedFiles, erroredFiles };
 | 
						|
};
 | 
						|
 | 
						|
const createFirebaseSceneDocument = async (
 | 
						|
  firebase: ResolutionType<typeof loadFirestore>,
 | 
						|
  elements: readonly SyncableExcalidrawElement[],
 | 
						|
  roomKey: string,
 | 
						|
) => {
 | 
						|
  const sceneVersion = getSceneVersion(elements);
 | 
						|
  const { ciphertext, iv } = await encryptElements(roomKey, elements);
 | 
						|
  return {
 | 
						|
    sceneVersion,
 | 
						|
    ciphertext: firebase.firestore.Blob.fromUint8Array(
 | 
						|
      new Uint8Array(ciphertext),
 | 
						|
    ),
 | 
						|
    iv: firebase.firestore.Blob.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 firebase = await loadFirestore();
 | 
						|
  const firestore = firebase.firestore();
 | 
						|
 | 
						|
  const docRef = firestore.collection("scenes").doc(roomId);
 | 
						|
 | 
						|
  const storedScene = await firestore.runTransaction(async (transaction) => {
 | 
						|
    const snapshot = await transaction.get(docRef);
 | 
						|
 | 
						|
    if (!snapshot.exists) {
 | 
						|
      const storedScene = await createFirebaseSceneDocument(
 | 
						|
        firebase,
 | 
						|
        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(
 | 
						|
      firebase,
 | 
						|
      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 firebase = await loadFirestore();
 | 
						|
  const db = firebase.firestore();
 | 
						|
 | 
						|
  const docRef = db.collection("scenes").doc(roomId);
 | 
						|
  const doc = await docRef.get();
 | 
						|
  if (!doc.exists) {
 | 
						|
    return null;
 | 
						|
  }
 | 
						|
  const storedScene = doc.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 };
 | 
						|
};
 |