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";