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

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Aakansha Doshi 2022-01-27 17:51:55 +05:30 committed by GitHub
parent 18c526d877
commit ca89d47d4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 161 additions and 37 deletions

View File

@ -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",

View File

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

View File

@ -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;
};

View File

@ -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 [];
}
};

View File

@ -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;
}
};

View File

@ -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]);

View File

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