Bläddra i källkod

feat: Sync local storage state across tabs when out of sync (#4545)

Co-authored-by: dwelle <luzar.david@gmail.com>
Aakansha Doshi 3 år sedan
förälder
incheckning
ca89d47d4c

+ 17 - 15
src/element/textWysiwyg.test.tsx

@@ -201,13 +201,7 @@ describe("textWysiwyg", () => {
 
   describe("Test bounded text", () => {
     let rectangle: any;
-    const {
-      h,
-    }: {
-      h: {
-        elements: any;
-      };
-    } = window;
+    const { h } = window;
 
     const DUMMY_HEIGHT = 240;
     const DUMMY_WIDTH = 160;
@@ -222,6 +216,7 @@ describe("textWysiwyg", () => {
 
     beforeEach(async () => {
       await render(<ExcalidrawApp />);
+      h.elements = [];
 
       rectangle = UI.createElement("rectangle", {
         x: 10,
@@ -249,9 +244,9 @@ describe("textWysiwyg", () => {
         ".excalidraw-textEditorContainer > textarea",
       ) as HTMLTextAreaElement;
 
-      await new Promise((r) => setTimeout(r, 0));
-
       fireEvent.change(editor, { target: { value: "Hello World!" } });
+
+      await new Promise((r) => setTimeout(r, 0));
       editor.blur();
       expect(rectangle.boundElements).toStrictEqual([
         { id: text.id, type: "text" },
@@ -285,6 +280,8 @@ describe("textWysiwyg", () => {
     });
 
     it("should update font family correctly on undo/redo by selecting bounded text when font family was updated", async () => {
+      expect(h.elements.length).toBe(1);
+
       mouse.doubleClickAt(
         rectangle.x + rectangle.width / 2,
         rectangle.y + rectangle.height / 2,
@@ -316,19 +313,25 @@ describe("textWysiwyg", () => {
 
       await new Promise((r) => setTimeout(r, 0));
       editor.blur();
-      expect(h.elements[1].fontFamily).toEqual(FONT_FAMILY.Cascadia);
+      expect(
+        (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
+      ).toEqual(FONT_FAMILY.Cascadia);
 
       //undo
       Keyboard.withModifierKeys({ ctrl: true }, () => {
         Keyboard.keyPress(KEYS.Z);
       });
-      expect(h.elements[1].fontFamily).toEqual(FONT_FAMILY.Virgil);
+      expect(
+        (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
+      ).toEqual(FONT_FAMILY.Virgil);
 
       //redo
       Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
         Keyboard.keyPress(KEYS.Z);
       });
-      expect(h.elements[1].fontFamily).toEqual(FONT_FAMILY.Cascadia);
+      expect(
+        (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
+      ).toEqual(FONT_FAMILY.Cascadia);
     });
 
     it("should wrap text and vertcially center align once text submitted", async () => {
@@ -365,10 +368,9 @@ describe("textWysiwyg", () => {
           };
         });
 
-      Keyboard.withModifierKeys({}, () => {
-        Keyboard.keyPress(KEYS.ENTER);
-      });
+      expect(h.elements.length).toBe(1);
 
+      Keyboard.keyDown(KEYS.ENTER);
       let text = h.elements[1] as ExcalidrawTextElementWithContainer;
       let editor = document.querySelector(
         ".excalidraw-textEditorContainer > textarea",

+ 11 - 0
src/excalidraw-app/app_constants.ts

@@ -4,6 +4,7 @@ export const INITIAL_SCENE_UPDATE_TIMEOUT = 5000;
 export const FILE_UPLOAD_TIMEOUT = 300;
 export const LOAD_IMAGES_TIMEOUT = 500;
 export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
+export const SYNC_BROWSER_TABS_TIMEOUT = 50;
 
 export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
 // 1 year (https://stackoverflow.com/a/25201898/927631)
@@ -25,3 +26,13 @@ export const FIREBASE_STORAGE_PREFIXES = {
 };
 
 export const ROOM_ID_BYTES = 10;
+
+export const STORAGE_KEYS = {
+  LOCAL_STORAGE_ELEMENTS: "excalidraw",
+  LOCAL_STORAGE_APP_STATE: "excalidraw-state",
+  LOCAL_STORAGE_COLLAB: "excalidraw-collab",
+  LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG: "collabLinkForceLoadFlag",
+  LOCAL_STORAGE_LIBRARY: "excalidraw-library",
+  VERSION_DATA_STATE: "version-dataState",
+  VERSION_FILES: "version-files",
+} as const;

+ 13 - 2
src/excalidraw-app/collab/CollabWrapper.tsx

@@ -21,6 +21,7 @@ import {
   INITIAL_SCENE_UPDATE_TIMEOUT,
   LOAD_IMAGES_TIMEOUT,
   SCENE,
+  STORAGE_KEYS,
   SYNC_FULL_SCENE_INTERVAL_MS,
 } from "../app_constants";
 import {
@@ -39,7 +40,6 @@ import {
 import {
   importUsernameFromLocalStorage,
   saveUsernameToLocalStorage,
-  STORAGE_KEYS,
 } from "../data/localStorage";
 import Portal from "./Portal";
 import RoomDialog from "./RoomDialog";
@@ -65,6 +65,7 @@ import {
   reconcileElements as _reconcileElements,
 } from "./reconciliation";
 import { decryptData } from "../../data/encryption";
+import { resetBrowserStateVersions } from "../data/tabSync";
 
 interface CollabState {
   modalIsShown: boolean;
@@ -86,6 +87,7 @@ export interface CollabAPI {
   onCollabButtonClick: CollabInstance["onCollabButtonClick"];
   broadcastElements: CollabInstance["broadcastElements"];
   fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
+  setUsername: (username: string) => void;
 }
 
 interface Props {
@@ -246,6 +248,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
 
     this.saveCollabRoomToFirebase();
     if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
+      // hack to ensure that we prefer we disregard any new browser state
+      // that could have been saved in other tabs while we were collaborating
+      resetBrowserStateVersions();
+
       window.history.pushState({}, APP_NAME, window.location.origin);
       this.destroySocketClient();
       trackEvent("share", "room closed");
@@ -677,8 +683,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
     this.setState({ modalIsShown: false });
   };
 
-  onUsernameChange = (username: string) => {
+  setUsername = (username: string) => {
     this.setState({ username });
+  };
+
+  onUsernameChange = (username: string) => {
+    this.setUsername(username);
     saveUsernameToLocalStorage(username);
   };
 
@@ -712,6 +722,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
     this.contextValue.broadcastElements = this.broadcastElements;
     this.contextValue.fetchImageFilesFromFirebase =
       this.fetchImageFilesFromFirebase;
+    this.contextValue.setUsername = this.setUsername;
     return this.contextValue;
   };
 

+ 17 - 8
src/excalidraw-app/data/localStorage.ts

@@ -5,14 +5,8 @@ import {
   getDefaultAppState,
 } from "../../appState";
 import { clearElementsForLocalStorage } from "../../element";
-
-export const STORAGE_KEYS = {
-  LOCAL_STORAGE_ELEMENTS: "excalidraw",
-  LOCAL_STORAGE_APP_STATE: "excalidraw-state",
-  LOCAL_STORAGE_COLLAB: "excalidraw-collab",
-  LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG: "collabLinkForceLoadFlag",
-  LOCAL_STORAGE_LIBRARY: "excalidraw-library",
-};
+import { updateBrowserStateVersion } from "./tabSync";
+import { STORAGE_KEYS } from "../app_constants";
 
 export const saveUsernameToLocalStorage = (username: string) => {
   try {
@@ -53,6 +47,7 @@ export const saveToLocalStorage = (
       STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
       JSON.stringify(clearAppStateForLocalStorage(appState)),
     );
+    updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
   } catch (error: any) {
     // Unable to access window.localStorage
     console.error(error);
@@ -125,3 +120,17 @@ export const getTotalStorageSize = () => {
     return 0;
   }
 };
+
+export const getLibraryItemsFromStorage = () => {
+  try {
+    const libraryItems =
+      JSON.parse(
+        localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
+      ) || [];
+
+    return libraryItems;
+  } catch (e) {
+    console.error(e);
+    return [];
+  }
+};

+ 29 - 0
src/excalidraw-app/data/tabSync.ts

@@ -0,0 +1,29 @@
+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();
+  localStorage.setItem(type, JSON.stringify(timestamp));
+  LOCAL_STATE_VERSIONS[type] = timestamp;
+};
+
+export const resetBrowserStateVersions = () => {
+  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;
+  }
+};

+ 73 - 11
src/excalidraw-app/index.tsx

@@ -34,6 +34,7 @@ import {
 import {
   debounce,
   getVersion,
+  isTestEnv,
   preventUnload,
   ResolvablePromise,
   resolvablePromise,
@@ -41,6 +42,8 @@ import {
 import {
   FIREBASE_STORAGE_PREFIXES,
   SAVE_TO_LOCAL_STORAGE_TIMEOUT,
+  STORAGE_KEYS,
+  SYNC_BROWSER_TABS_TIMEOUT,
 } from "./app_constants";
 import CollabWrapper, {
   CollabAPI,
@@ -50,9 +53,10 @@ import CollabWrapper, {
 import { LanguageList } from "./components/LanguageList";
 import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
 import {
+  getLibraryItemsFromStorage,
   importFromLocalStorage,
+  importUsernameFromLocalStorage,
   saveToLocalStorage,
-  STORAGE_KEYS,
 } from "./data/localStorage";
 import CustomStats from "./CustomStats";
 import { restoreAppState, RestoredDataState } from "../data/restore";
@@ -67,6 +71,10 @@ import { FileManager, updateStaleImageStatuses } from "./data/FileManager";
 import { newElementWith } from "../element/mutateElement";
 import { isInitializedImageElement } from "../element/typeChecks";
 import { loadFilesFromFirebase } from "./data/firebase";
+import {
+  isBrowserStorageStateNewer,
+  updateBrowserStateVersion,
+} from "./data/tabSync";
 
 const filesStore = createStore("files-db", "files-store");
 
@@ -104,6 +112,11 @@ const localFileStorage = new FileManager({
     const savedFiles = new Map<FileId, true>();
     const erroredFiles = new Map<FileId, true>();
 
+    // 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 {
@@ -142,7 +155,6 @@ const saveDebounced = debounce(
       elements,
       files,
     });
-
     onFilesSaved();
   },
   SAVE_TO_LOCAL_STORAGE_TIMEOUT,
@@ -278,7 +290,6 @@ const ExcalidrawWrapper = () => {
     currentLangCode = currentLangCode[0];
   }
   const [langCode, setLangCode] = useState(currentLangCode);
-
   // initial state
   // ---------------------------------------------------------------------------
 
@@ -372,14 +383,7 @@ const ExcalidrawWrapper = () => {
         }
       }
 
-      try {
-        data.scene.libraryItems =
-          JSON.parse(
-            localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
-          ) || [];
-      } catch (error: any) {
-        console.error(error);
-      }
+      data.scene.libraryItems = getLibraryItemsFromStorage();
     };
 
     initializeScene({ collabAPI }).then((data) => {
@@ -415,13 +419,71 @@ const ExcalidrawWrapper = () => {
       () => (document.title = APP_NAME),
       TITLE_TIMEOUT,
     );
+
+    const syncData = debounce(() => {
+      if (isTestEnv()) {
+        return;
+      }
+      if (!document.hidden && !collabAPI.isCollaborating()) {
+        // don't sync if local state is newer or identical to browser state
+        if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
+          const localDataState = importFromLocalStorage();
+          const username = importUsernameFromLocalStorage();
+          let langCode = languageDetector.detect() || defaultLang.code;
+          if (Array.isArray(langCode)) {
+            langCode = langCode[0];
+          }
+          setLangCode(langCode);
+          excalidrawAPI.updateScene({
+            ...localDataState,
+            libraryItems: getLibraryItemsFromStorage(),
+          });
+          collabAPI.setUsername(username || "");
+        }
+
+        if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
+          const elements = excalidrawAPI.getSceneElementsIncludingDeleted();
+          const currFiles = excalidrawAPI.getFiles();
+          const fileIds =
+            elements?.reduce((acc, element) => {
+              if (
+                isInitializedImageElement(element) &&
+                // only load and update images that aren't already loaded
+                !currFiles[element.fileId]
+              ) {
+                return acc.concat(element.fileId);
+              }
+              return acc;
+            }, [] as FileId[]) || [];
+          if (fileIds.length) {
+            localFileStorage
+              .getFiles(fileIds)
+              .then(({ loadedFiles, erroredFiles }) => {
+                if (loadedFiles.length) {
+                  excalidrawAPI.addFiles(loadedFiles);
+                }
+                updateStaleImageStatuses({
+                  excalidrawAPI,
+                  erroredFiles,
+                  elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
+                });
+              });
+          }
+        }
+      }
+    }, SYNC_BROWSER_TABS_TIMEOUT);
+
     window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
     window.addEventListener(EVENT.UNLOAD, onBlur, false);
     window.addEventListener(EVENT.BLUR, onBlur, false);
+    document.addEventListener(EVENT.VISIBILITY_CHANGE, syncData, false);
+    window.addEventListener(EVENT.FOCUS, syncData, false);
     return () => {
       window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
       window.removeEventListener(EVENT.UNLOAD, onBlur, false);
       window.removeEventListener(EVENT.BLUR, onBlur, false);
+      window.removeEventListener(EVENT.FOCUS, syncData, false);
+      document.removeEventListener(EVENT.VISIBILITY_CHANGE, syncData, false);
       clearTimeout(titleTimeout);
     };
   }, [collabAPI, excalidrawAPI]);

+ 1 - 1
src/tests/test-utils.ts

@@ -10,7 +10,7 @@ import {
 
 import * as toolQueries from "./queries/toolQueries";
 import { ImportedDataState } from "../data/types";
-import { STORAGE_KEYS } from "../excalidraw-app/data/localStorage";
+import { STORAGE_KEYS } from "../excalidraw-app/app_constants";
 
 import { SceneData } from "../types";
 import { getSelectedElements } from "../scene/selection";