Offline support with increments peristed and restored to / from indexedb

This commit is contained in:
Marcel Mraz 2024-12-12 14:41:20 +01:00
parent 15d2942aaa
commit 040a57f56a
No known key found for this signature in database
GPG key ID: 4EBD6E62DC830CD2
19 changed files with 1827 additions and 1104 deletions

View file

@ -75,7 +75,6 @@ import {
exportToExcalidrawPlus,
} from "./components/ExportToExcalidrawPlus";
import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../packages/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase";
import {
@ -372,8 +371,8 @@ const ExcalidrawWrapper = () => {
const [syncAPI] = useAtom(syncAPIAtom);
const [nextVersion, setNextVersion] = useState(-1);
const currentVersion = useRef(-1);
const [acknowledgedChanges, setAcknowledgedChanges] = useState<
ElementsChange[]
const [acknowledgedIncrements, setAcknowledgedIncrements] = useState<
StoreIncrement[]
>([]);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
return isCollaborationLink(window.location.href);
@ -382,7 +381,7 @@ const ExcalidrawWrapper = () => {
useEffect(() => {
const interval = setInterval(() => {
setAcknowledgedChanges([...(syncAPI?.acknowledgedChanges ?? [])]);
setAcknowledgedIncrements([...(syncAPI?.acknowledgedIncrements ?? [])]);
}, 250);
syncAPI?.reconnect();
@ -648,35 +647,36 @@ const ExcalidrawWrapper = () => {
// this check is redundant, but since this is a hot path, it's best
// not to evaludate the nested expression every time
if (!LocalData.isSavePaused()) {
LocalData.save(elements, appState, files, () => {
if (excalidrawAPI) {
let didChange = false;
// CFDO: temporary
// if (!LocalData.isSavePaused()) {
// LocalData.save(elements, appState, files, () => {
// if (excalidrawAPI) {
// let didChange = false;
const elements = excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
) {
const newElement = newElementWith(element, { status: "saved" });
if (newElement !== element) {
didChange = true;
}
return newElement;
}
return element;
});
// const elements = excalidrawAPI
// .getSceneElementsIncludingDeleted()
// .map((element) => {
// if (
// LocalData.fileStorage.shouldUpdateImageElementStatus(element)
// ) {
// const newElement = newElementWith(element, { status: "saved" });
// if (newElement !== element) {
// didChange = true;
// }
// return newElement;
// }
// return element;
// });
if (didChange) {
excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
});
}
}
});
}
// if (didChange) {
// excalidrawAPI.updateScene({
// elements,
// storeAction: StoreAction.UPDATE,
// });
// }
// }
// });
// }
// Render the debug scene if the debug canvas is available
if (debugCanvasRef.current && excalidrawAPI) {
@ -694,10 +694,13 @@ const ExcalidrawWrapper = () => {
// - wysiwyg, dragging elements / points, mouse movements, etc.
const { elementsChange } = increment;
// some appState like selections should also be transfered (we could even persist it)
// CFDO: some appState like selections should also be transfered (we could even persist it)
if (!elementsChange.isEmpty()) {
console.log(elementsChange)
syncAPI?.push("durable", [elementsChange]);
try {
syncAPI?.push("durable", increment);
} catch (e) {
console.error(e);
}
}
};
@ -828,22 +831,23 @@ const ExcalidrawWrapper = () => {
excalidrawAPI?.getSceneElements().map((x) => [x.id, x]),
);
let changes: ElementsChange[] = [];
let increments: StoreIncrement[] = [];
const goingLeft =
currentVersion.current === -1 || value - currentVersion.current <= 0;
if (goingLeft) {
changes = acknowledgedChanges
increments = acknowledgedIncrements
.slice(value)
.reverse()
.map((x) => x.inverse());
} else {
changes = acknowledgedChanges.slice(currentVersion.current, value) ?? [];
increments =
acknowledgedIncrements.slice(currentVersion.current, value) ?? [];
}
for (const change of changes) {
[elements] = change.applyTo(
for (const increment of increments) {
[elements] = increment.elementsChange.applyTo(
elements as SceneElementsMap,
excalidrawAPI?.store.snapshot.elements!,
);
@ -852,7 +856,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI?.updateScene({
appState: {
...excalidrawAPI?.getAppState(),
viewModeEnabled: value !== acknowledgedChanges.length,
viewModeEnabled: value !== acknowledgedIncrements.length,
},
elements: Array.from(elements.values()),
storeAction: StoreAction.UPDATE,
@ -878,8 +882,8 @@ const ExcalidrawWrapper = () => {
}}
step={1}
min={0}
max={acknowledgedChanges.length}
value={nextVersion === -1 ? acknowledgedChanges.length : nextVersion}
max={acknowledgedIncrements.length}
value={nextVersion === -1 ? acknowledgedIncrements.length : nextVersion}
onChange={(value) => {
setNextVersion(value as number);
debouncedTimeTravel(value as number);
@ -967,7 +971,6 @@ const ExcalidrawWrapper = () => {
/>
<OverwriteConfirmDialog>
<OverwriteConfirmDialog.Actions.ExportToImage />
<OverwriteConfirmDialog.Actions.SaveToDisk />
{excalidrawAPI && (
<OverwriteConfirmDialog.Action
title={t("overwriteConfirm.action.excalidrawPlus.title")}

View file

@ -45,6 +45,7 @@ export const STORAGE_KEYS = {
VERSION_FILES: "version-files",
IDB_LIBRARY: "excalidraw-library",
IDB_SYNC: "excalidraw-sync",
// do not use apart from migrations
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",

View file

@ -78,7 +78,7 @@ import {
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import { decryptData } from "../../packages/excalidraw/data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
import { LocalData, SyncIndexedDBAdapter } from "../data/LocalData";
import { appJotaiStore, atom } from "../app-jotai";
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
@ -88,9 +88,9 @@ import type {
ReconciledExcalidrawElement,
RemoteExcalidrawElement,
} from "../../packages/excalidraw/data/reconcile";
import { ExcalidrawSyncClient } from "../../packages/excalidraw/sync/client";
import { SyncClient } from "../../packages/excalidraw/sync/client";
export const syncAPIAtom = atom<ExcalidrawSyncClient | null>(null);
export const syncAPIAtom = atom<SyncClient | null>(null);
export const collabAPIAtom = atom<CollabAPI | null>(null);
export const isCollaboratingAtom = atom(false);
export const isOfflineAtom = atom(false);
@ -236,9 +236,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
};
appJotaiStore.set(collabAPIAtom, collabAPI);
appJotaiStore.set(
syncAPIAtom,
new ExcalidrawSyncClient(this.excalidrawAPI),
SyncClient.create(this.excalidrawAPI, SyncIndexedDBAdapter).then(
(syncAPI) => {
appJotaiStore.set(syncAPIAtom, syncAPI);
},
);
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {

View file

@ -36,12 +36,16 @@ import type {
BinaryFileData,
BinaryFiles,
} from "../../packages/excalidraw/types";
import type { MaybePromise } from "../../packages/excalidraw/utility-types";
import type {
DTO,
MaybePromise,
} from "../../packages/excalidraw/utility-types";
import { debounce } from "../../packages/excalidraw/utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager";
import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync";
import { StoreIncrement } from "../../packages/excalidraw/store";
const filesStore = createStore("files-db", "files-store");
@ -65,34 +69,35 @@ class LocalFileManager extends FileManager {
};
}
const saveDataStateToLocalStorage = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
try {
const _appState = clearAppStateForLocalStorage(appState);
// CFDO: temporary
// const saveDataStateToLocalStorage = (
// elements: readonly ExcalidrawElement[],
// appState: AppState,
// ) => {
// try {
// const _appState = clearAppStateForLocalStorage(appState);
if (
_appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
_appState.openSidebar.tab === CANVAS_SEARCH_TAB
) {
_appState.openSidebar = null;
}
// if (
// _appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
// _appState.openSidebar.tab === CANVAS_SEARCH_TAB
// ) {
// _appState.openSidebar = null;
// }
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(clearElementsForLocalStorage(elements)),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(_appState),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
// localStorage.setItem(
// STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
// JSON.stringify(clearElementsForLocalStorage(elements)),
// );
// localStorage.setItem(
// STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
// JSON.stringify(_appState),
// );
// updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
// } catch (error: any) {
// // Unable to access window.localStorage
// console.error(error);
// }
// };
type SavingLockTypes = "collaboration";
@ -104,13 +109,12 @@ export class LocalData {
files: BinaryFiles,
onFilesSaved: () => void,
) => {
saveDataStateToLocalStorage(elements, appState);
await this.fileStorage.saveFiles({
elements,
files,
});
onFilesSaved();
// saveDataStateToLocalStorage(elements, appState);
// await this.fileStorage.saveFiles({
// elements,
// files,
// });
// onFilesSaved();
},
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
);
@ -256,3 +260,66 @@ export class LibraryLocalStorageMigrationAdapter {
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
}
}
interface SyncIncrementPersistedData {
increments: DTO<StoreIncrement>[];
}
interface SyncMetaPersistedData {
lastAcknowledgedVersion: number;
}
export class SyncIndexedDBAdapter {
/** IndexedDB database and store name */
private static idb_name = STORAGE_KEYS.IDB_SYNC;
/** library data store keys */
private static incrementsKey = "increments";
private static metadataKey = "metadata";
private static store = createStore(
`${SyncIndexedDBAdapter.idb_name}-db`,
`${SyncIndexedDBAdapter.idb_name}-store`,
);
static async loadIncrements() {
const IDBData = await get<SyncIncrementPersistedData>(
SyncIndexedDBAdapter.incrementsKey,
SyncIndexedDBAdapter.store,
);
if (IDBData?.increments?.length) {
return {
increments: IDBData.increments.map((storeIncrementDTO) =>
StoreIncrement.restore(storeIncrementDTO),
),
};
}
return null;
}
static async saveIncrements(data: SyncIncrementPersistedData): Promise<void> {
return set(
SyncIndexedDBAdapter.incrementsKey,
data,
SyncIndexedDBAdapter.store,
);
}
static async loadMetadata() {
const IDBData = await get<SyncMetaPersistedData>(
SyncIndexedDBAdapter.metadataKey,
SyncIndexedDBAdapter.store,
);
return IDBData || null;
}
static async saveMetadata(data: SyncMetaPersistedData): Promise<void> {
return set(
SyncIndexedDBAdapter.metadataKey,
data,
SyncIndexedDBAdapter.store,
);
}
}