mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: reconcile when saving to firebase (#4991)
* naming tweaks * do not mark local element as duplicate when there's no remote counterpart * merge instead of overwrite elements when saving to firebase & reconcile local state * decouple syncing from persistence * fix ts * clarify doc * fix reconciliation not removing duplicates
This commit is contained in:
parent
3840e2f4e6
commit
4d13dbf625
7 changed files with 269 additions and 98 deletions
|
@ -22,7 +22,7 @@ import {
|
|||
FIREBASE_STORAGE_PREFIXES,
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
LOAD_IMAGES_TIMEOUT,
|
||||
SCENE,
|
||||
WS_SCENE_EVENT_TYPES,
|
||||
STORAGE_KEYS,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
} from "../app_constants";
|
||||
|
@ -88,7 +88,7 @@ export interface CollabAPI {
|
|||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
||||
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
||||
broadcastElements: CollabInstance["broadcastElements"];
|
||||
syncElements: CollabInstance["syncElements"];
|
||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||
setUsername: (username: string) => void;
|
||||
}
|
||||
|
@ -232,12 +232,20 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||
});
|
||||
|
||||
saveCollabRoomToFirebase = async (
|
||||
syncableElements: readonly ExcalidrawElement[] = this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
syncableElements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
try {
|
||||
await saveToFirebase(this.portal, syncableElements);
|
||||
const savedData = await saveToFirebase(
|
||||
this.portal,
|
||||
syncableElements,
|
||||
this.excalidrawAPI.getAppState(),
|
||||
);
|
||||
|
||||
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(savedData.reconciledElements),
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
|
@ -250,9 +258,14 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||
|
||||
closePortal = () => {
|
||||
this.queueBroadcastAllElements.cancel();
|
||||
this.queueSaveToFirebase.cancel();
|
||||
this.loadImageFiles.cancel();
|
||||
|
||||
this.saveCollabRoomToFirebase();
|
||||
this.saveCollabRoomToFirebase(
|
||||
this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
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
|
||||
|
@ -400,10 +413,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||
commitToHistory: true,
|
||||
});
|
||||
|
||||
this.broadcastElements(elements);
|
||||
|
||||
const syncableElements = this.getSyncableElements(elements);
|
||||
this.saveCollabRoomToFirebase(syncableElements);
|
||||
this.saveCollabRoomToFirebase(this.getSyncableElements(elements));
|
||||
}
|
||||
|
||||
// fallback in case you're not alone in the room but still don't receive
|
||||
|
@ -433,7 +443,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||
switch (decryptedData.type) {
|
||||
case "INVALID_RESPONSE":
|
||||
return;
|
||||
case SCENE.INIT: {
|
||||
case WS_SCENE_EVENT_TYPES.INIT: {
|
||||
if (!this.portal.socketInitialized) {
|
||||
this.initializeRoom({ fetchScene: false });
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
|
@ -449,7 +459,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||
}
|
||||
break;
|
||||
}
|
||||
case SCENE.UPDATE:
|
||||
case WS_SCENE_EVENT_TYPES.UPDATE:
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(decryptedData.payload.elements),
|
||||
);
|
||||
|
@ -711,15 +721,20 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||
getSceneVersion(elements) >
|
||||
this.getLastBroadcastedOrReceivedSceneVersion()
|
||||
) {
|
||||
this.portal.broadcastScene(SCENE.UPDATE, elements, false);
|
||||
this.portal.broadcastScene(WS_SCENE_EVENT_TYPES.UPDATE, elements, false);
|
||||
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
|
||||
this.queueBroadcastAllElements();
|
||||
}
|
||||
};
|
||||
|
||||
syncElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
this.broadcastElements(elements);
|
||||
this.queueSaveToFirebase();
|
||||
};
|
||||
|
||||
queueBroadcastAllElements = throttle(() => {
|
||||
this.portal.broadcastScene(
|
||||
SCENE.UPDATE,
|
||||
WS_SCENE_EVENT_TYPES.UPDATE,
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
true,
|
||||
);
|
||||
|
@ -731,6 +746,16 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||
|
||||
queueSaveToFirebase = throttle(() => {
|
||||
if (this.portal.socketInitialized) {
|
||||
this.saveCollabRoomToFirebase(
|
||||
this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||
|
||||
handleClose = () => {
|
||||
this.setState({ modalIsShown: false });
|
||||
};
|
||||
|
@ -771,7 +796,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||
this.contextValue.onPointerUpdate = this.onPointerUpdate;
|
||||
this.contextValue.initializeSocketClient = this.initializeSocketClient;
|
||||
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
|
||||
this.contextValue.broadcastElements = this.broadcastElements;
|
||||
this.contextValue.syncElements = this.syncElements;
|
||||
this.contextValue.fetchImageFilesFromFirebase =
|
||||
this.fetchImageFilesFromFirebase;
|
||||
this.contextValue.setUsername = this.setUsername;
|
||||
|
|
|
@ -3,7 +3,11 @@ import { SocketUpdateData, SocketUpdateDataSource } from "../data";
|
|||
import CollabWrapper from "./CollabWrapper";
|
||||
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { BROADCAST, FILE_UPLOAD_TIMEOUT, SCENE } from "../app_constants";
|
||||
import {
|
||||
WS_EVENTS,
|
||||
FILE_UPLOAD_TIMEOUT,
|
||||
WS_SCENE_EVENT_TYPES,
|
||||
} from "../app_constants";
|
||||
import { UserIdleState } from "../../types";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { throttle } from "lodash";
|
||||
|
@ -37,7 +41,7 @@ class Portal {
|
|||
});
|
||||
this.socket.on("new-user", async (_socketId: string) => {
|
||||
this.broadcastScene(
|
||||
SCENE.INIT,
|
||||
WS_SCENE_EVENT_TYPES.INIT,
|
||||
this.collab.getSceneElementsIncludingDeleted(),
|
||||
/* syncAll */ true,
|
||||
);
|
||||
|
@ -81,7 +85,7 @@ class Portal {
|
|||
const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
|
||||
|
||||
this.socket?.emit(
|
||||
volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER,
|
||||
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
|
||||
this.roomId,
|
||||
encryptedBuffer,
|
||||
iv,
|
||||
|
@ -121,11 +125,11 @@ class Portal {
|
|||
}, FILE_UPLOAD_TIMEOUT);
|
||||
|
||||
broadcastScene = async (
|
||||
sceneType: SCENE.INIT | SCENE.UPDATE,
|
||||
updateType: WS_SCENE_EVENT_TYPES.INIT | WS_SCENE_EVENT_TYPES.UPDATE,
|
||||
allElements: readonly ExcalidrawElement[],
|
||||
syncAll: boolean,
|
||||
) => {
|
||||
if (sceneType === SCENE.INIT && !syncAll) {
|
||||
if (updateType === WS_SCENE_EVENT_TYPES.INIT && !syncAll) {
|
||||
throw new Error("syncAll must be true when sending SCENE.INIT");
|
||||
}
|
||||
|
||||
|
@ -152,8 +156,8 @@ class Portal {
|
|||
[] as BroadcastedExcalidrawElement[],
|
||||
);
|
||||
|
||||
const data: SocketUpdateDataSource[typeof sceneType] = {
|
||||
type: sceneType,
|
||||
const data: SocketUpdateDataSource[typeof updateType] = {
|
||||
type: updateType,
|
||||
payload: {
|
||||
elements: syncableElements,
|
||||
},
|
||||
|
@ -166,20 +170,9 @@ class Portal {
|
|||
);
|
||||
}
|
||||
|
||||
const broadcastPromise = this._broadcastSocketData(
|
||||
data as SocketUpdateData,
|
||||
);
|
||||
|
||||
this.queueFileUpload();
|
||||
|
||||
if (syncAll && this.collab.isCollaborating()) {
|
||||
await Promise.all([
|
||||
broadcastPromise,
|
||||
this.collab.saveCollabRoomToFirebase(syncableElements),
|
||||
]);
|
||||
} else {
|
||||
await broadcastPromise;
|
||||
}
|
||||
await this._broadcastSocketData(data as SocketUpdateData);
|
||||
};
|
||||
|
||||
broadcastIdleChange = (userState: UserIdleState) => {
|
||||
|
|
|
@ -78,8 +78,14 @@ export const reconcileElements = (
|
|||
continue;
|
||||
}
|
||||
|
||||
// Mark duplicate for removal as it'll be replaced with the remote element
|
||||
if (local) {
|
||||
// mark for removal since it'll be replaced with the remote element
|
||||
// Unless the ramote and local elements are the same element in which case
|
||||
// we need to keep it as we'd otherwise discard it from the resulting
|
||||
// array.
|
||||
if (local[0] === remoteElement) {
|
||||
continue;
|
||||
}
|
||||
duplicates.set(local[0], true);
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue