mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Offline support with increments peristed and restored to / from indexedb
This commit is contained in:
parent
15d2942aaa
commit
040a57f56a
19 changed files with 1827 additions and 1104 deletions
|
@ -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")}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue