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:
parent
18c526d877
commit
ca89d47d4c
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
};
|
||||
|
||||
|
@ -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
src/excalidraw-app/data/tabSync.ts
Normal file
29
src/excalidraw-app/data/tabSync.ts
Normal 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;
|
||||
}
|
||||
};
|
@ -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]);
|
||||
|
@ -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";
|
||||
|
Loading…
x
Reference in New Issue
Block a user