From ca89d47d4c8c92e8e720e54cd28cbeb2d04e84c3 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Thu, 27 Jan 2022 17:51:55 +0530 Subject: [PATCH] feat: Sync local storage state across tabs when out of sync (#4545) Co-authored-by: dwelle --- src/element/textWysiwyg.test.tsx | 32 ++++---- src/excalidraw-app/app_constants.ts | 11 +++ src/excalidraw-app/collab/CollabWrapper.tsx | 15 +++- src/excalidraw-app/data/localStorage.ts | 25 ++++-- src/excalidraw-app/data/tabSync.ts | 29 +++++++ src/excalidraw-app/index.tsx | 84 ++++++++++++++++++--- src/tests/test-utils.ts | 2 +- 7 files changed, 161 insertions(+), 37 deletions(-) create mode 100644 src/excalidraw-app/data/tabSync.ts diff --git a/src/element/textWysiwyg.test.tsx b/src/element/textWysiwyg.test.tsx index 126650cb..75a7ea14 100644 --- a/src/element/textWysiwyg.test.tsx +++ b/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(); + 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", diff --git a/src/excalidraw-app/app_constants.ts b/src/excalidraw-app/app_constants.ts index 532cdbcd..b4f41e09 100644 --- a/src/excalidraw-app/app_constants.ts +++ b/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; diff --git a/src/excalidraw-app/collab/CollabWrapper.tsx b/src/excalidraw-app/collab/CollabWrapper.tsx index 9b35e7b5..23454bce 100644 --- a/src/excalidraw-app/collab/CollabWrapper.tsx +++ b/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 { 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 { 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 { this.contextValue.broadcastElements = this.broadcastElements; this.contextValue.fetchImageFilesFromFirebase = this.fetchImageFilesFromFirebase; + this.contextValue.setUsername = this.setUsername; return this.contextValue; }; diff --git a/src/excalidraw-app/data/localStorage.ts b/src/excalidraw-app/data/localStorage.ts index 095c61d4..3d548081 100644 --- a/src/excalidraw-app/data/localStorage.ts +++ b/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 []; + } +}; diff --git a/src/excalidraw-app/data/tabSync.ts b/src/excalidraw-app/data/tabSync.ts new file mode 100644 index 00000000..ddb70dbb --- /dev/null +++ b/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; + } +}; diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 2c664091..b0601c63 100644 --- a/src/excalidraw-app/index.tsx +++ b/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(); const erroredFiles = new Map(); + // 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]); diff --git a/src/tests/test-utils.ts b/src/tests/test-utils.ts index f6d0aeb6..be9871c3 100644 --- a/src/tests/test-utils.ts +++ b/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";