mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Syncing ephemeral element updates
This commit is contained in:
parent
c57249481e
commit
310a9ae4e0
60 changed files with 1104 additions and 906 deletions
|
@ -24,7 +24,7 @@ import {
|
|||
Excalidraw,
|
||||
LiveCollaborationTrigger,
|
||||
TTDDialogTrigger,
|
||||
StoreAction,
|
||||
SnapshotAction,
|
||||
reconcileElements,
|
||||
newElementWith,
|
||||
} from "../packages/excalidraw";
|
||||
|
@ -44,6 +44,7 @@ import {
|
|||
preventUnload,
|
||||
resolvablePromise,
|
||||
isRunningInIframe,
|
||||
assertNever,
|
||||
} from "../packages/excalidraw/utils";
|
||||
import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
|
@ -109,7 +110,11 @@ import Trans from "../packages/excalidraw/components/Trans";
|
|||
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
|
||||
import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile";
|
||||
import type { StoreIncrement } from "../packages/excalidraw/store";
|
||||
import type {
|
||||
DurableStoreIncrement,
|
||||
EphemeralStoreIncrement,
|
||||
} from "../packages/excalidraw/store";
|
||||
import { StoreDelta, StoreIncrement } from "../packages/excalidraw/store";
|
||||
import {
|
||||
CommandPalette,
|
||||
DEFAULT_CATEGORIES,
|
||||
|
@ -370,20 +375,30 @@ const ExcalidrawWrapper = () => {
|
|||
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||
const [collabAPI] = useAtom(collabAPIAtom);
|
||||
const [syncAPI] = useAtom(syncApiAtom);
|
||||
const [nextVersion, setNextVersion] = useState(-1);
|
||||
const currentVersion = useRef(-1);
|
||||
const [acknowledgedIncrements, setAcknowledgedIncrements] = useState<
|
||||
StoreIncrement[]
|
||||
>([]);
|
||||
const [sliderVersion, setSliderVersion] = useState(0);
|
||||
const [acknowledgedDeltas, setAcknowledgedDeltas] = useState<StoreDelta[]>(
|
||||
[],
|
||||
);
|
||||
const acknowledgedDeltasRef = useRef<StoreDelta[]>(acknowledgedDeltas);
|
||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||
return isCollaborationLink(window.location.href);
|
||||
});
|
||||
const collabError = useAtomValue(collabErrorIndicatorAtom);
|
||||
|
||||
useEffect(() => {
|
||||
acknowledgedDeltasRef.current = acknowledgedDeltas;
|
||||
}, [acknowledgedDeltas]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setAcknowledgedIncrements([...(syncAPI?.acknowledgedIncrements ?? [])]);
|
||||
}, 250);
|
||||
const deltas = syncAPI?.acknowledgedDeltas ?? [];
|
||||
|
||||
// CFDO: buffer local deltas as well, not only acknowledged ones
|
||||
if (deltas.length > acknowledgedDeltasRef.current.length) {
|
||||
setAcknowledgedDeltas([...deltas]);
|
||||
setSliderVersion(deltas.length);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
syncAPI?.connect();
|
||||
|
||||
|
@ -512,7 +527,7 @@ const ExcalidrawWrapper = () => {
|
|||
excalidrawAPI.updateScene({
|
||||
...data.scene,
|
||||
...restore(data.scene, null, null, { repairBindings: true }),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -539,7 +554,7 @@ const ExcalidrawWrapper = () => {
|
|||
setLangCode(getPreferredLanguage());
|
||||
excalidrawAPI.updateScene({
|
||||
...localDataState,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
LibraryIndexedDBAdapter.load().then((data) => {
|
||||
if (data) {
|
||||
|
@ -671,7 +686,7 @@ const ExcalidrawWrapper = () => {
|
|||
if (didChange) {
|
||||
excalidrawAPI.updateScene({
|
||||
elements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -689,18 +704,31 @@ const ExcalidrawWrapper = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const onIncrement = (increment: StoreIncrement) => {
|
||||
// ephemerals are not part of this (which is alright)
|
||||
// - wysiwyg, dragging elements / points, mouse movements, etc.
|
||||
const { elementsChange } = increment;
|
||||
|
||||
// CFDO: some appState like selections should also be transfered (we could even persist it)
|
||||
if (!elementsChange.isEmpty()) {
|
||||
try {
|
||||
syncAPI?.push(increment);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const onIncrement = (
|
||||
increment: DurableStoreIncrement | EphemeralStoreIncrement,
|
||||
) => {
|
||||
try {
|
||||
if (!syncAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (StoreIncrement.isDurable(increment)) {
|
||||
// push only if there are element changes
|
||||
if (!increment.delta.elements.isEmpty()) {
|
||||
syncAPI.push(increment.delta);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (StoreIncrement.isEphemeral(increment)) {
|
||||
syncAPI.relay(increment.change);
|
||||
return;
|
||||
}
|
||||
|
||||
assertNever(increment, `Unknown increment type`);
|
||||
} catch (e) {
|
||||
console.error("Error during onIncrement handler", e);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -826,46 +854,48 @@ const ExcalidrawWrapper = () => {
|
|||
},
|
||||
};
|
||||
|
||||
const debouncedTimeTravel = debounce((value: number) => {
|
||||
let elements = new Map(
|
||||
excalidrawAPI?.getSceneElements().map((x) => [x.id, x]),
|
||||
);
|
||||
|
||||
let increments: StoreIncrement[] = [];
|
||||
|
||||
const goingLeft =
|
||||
currentVersion.current === -1 || value - currentVersion.current <= 0;
|
||||
|
||||
if (goingLeft) {
|
||||
increments = acknowledgedIncrements
|
||||
.slice(value)
|
||||
.reverse()
|
||||
.map((x) => x.inverse());
|
||||
} else {
|
||||
increments =
|
||||
acknowledgedIncrements.slice(currentVersion.current, value) ?? [];
|
||||
}
|
||||
|
||||
for (const increment of increments) {
|
||||
[elements] = increment.elementsChange.applyTo(
|
||||
elements as SceneElementsMap,
|
||||
excalidrawAPI?.store.snapshot.elements!,
|
||||
const debouncedTimeTravel = debounce(
|
||||
(value: number, direction: "forward" | "backward") => {
|
||||
let elements = new Map(
|
||||
excalidrawAPI?.getSceneElements().map((x) => [x.id, x]),
|
||||
);
|
||||
}
|
||||
|
||||
excalidrawAPI?.updateScene({
|
||||
appState: {
|
||||
...excalidrawAPI?.getAppState(),
|
||||
viewModeEnabled: value !== -1,
|
||||
},
|
||||
elements: Array.from(elements.values()),
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
let deltas: StoreDelta[] = [];
|
||||
|
||||
currentVersion.current = value;
|
||||
}, 0);
|
||||
switch (direction) {
|
||||
case "forward": {
|
||||
deltas = acknowledgedDeltas.slice(sliderVersion, value) ?? [];
|
||||
break;
|
||||
}
|
||||
case "backward": {
|
||||
deltas = acknowledgedDeltas
|
||||
.slice(value)
|
||||
.reverse()
|
||||
.map((x) => StoreDelta.inverse(x));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
assertNever(direction, `Unknown direction: ${direction}`);
|
||||
}
|
||||
|
||||
const latestVersion = acknowledgedIncrements.length;
|
||||
for (const delta of deltas) {
|
||||
[elements] = delta.elements.applyTo(
|
||||
elements as SceneElementsMap,
|
||||
excalidrawAPI?.store.snapshot.elements!,
|
||||
);
|
||||
}
|
||||
|
||||
excalidrawAPI?.updateScene({
|
||||
appState: {
|
||||
...excalidrawAPI?.getAppState(),
|
||||
viewModeEnabled: value !== acknowledgedDeltas.length,
|
||||
},
|
||||
elements: Array.from(elements.values()),
|
||||
snapshotAction: SnapshotAction.NONE,
|
||||
});
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -884,25 +914,30 @@ const ExcalidrawWrapper = () => {
|
|||
}}
|
||||
step={1}
|
||||
min={0}
|
||||
max={latestVersion}
|
||||
value={nextVersion === -1 ? latestVersion : nextVersion}
|
||||
max={acknowledgedDeltas.length}
|
||||
value={sliderVersion}
|
||||
onChange={(value) => {
|
||||
let nextValue: number;
|
||||
|
||||
// CFDO: should be disabled when offline! (later we could have speculative changes in the versioning log as well)
|
||||
const nextSliderVersion = value as number;
|
||||
// CFDO II: should be disabled when offline! (later we could have speculative changes in the versioning log as well)
|
||||
// CFDO: in safari the whole canvas gets selected when dragging
|
||||
if (value !== acknowledgedIncrements.length) {
|
||||
if (nextSliderVersion !== acknowledgedDeltas.length) {
|
||||
// don't listen to updates in the detached mode
|
||||
syncAPI?.disconnect();
|
||||
nextValue = value as number;
|
||||
} else {
|
||||
// reconnect once we're back to the latest version
|
||||
syncAPI?.connect();
|
||||
nextValue = -1;
|
||||
}
|
||||
|
||||
setNextVersion(nextValue);
|
||||
debouncedTimeTravel(nextValue);
|
||||
if (nextSliderVersion === sliderVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
debouncedTimeTravel(
|
||||
nextSliderVersion,
|
||||
nextSliderVersion < sliderVersion ? "backward" : "forward",
|
||||
);
|
||||
|
||||
setSliderVersion(nextSliderVersion);
|
||||
}}
|
||||
/>
|
||||
<Excalidraw
|
||||
|
|
|
@ -15,7 +15,7 @@ import type {
|
|||
OrderedExcalidrawElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
StoreAction,
|
||||
SnapshotAction,
|
||||
getSceneVersion,
|
||||
restoreElements,
|
||||
zoomToFitBounds,
|
||||
|
@ -393,7 +393,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -544,7 +544,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||
// to database even if deleted before creating the room.
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
||||
|
@ -782,7 +782,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||
) => {
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
this.loadImageFiles();
|
||||
|
|
|
@ -19,7 +19,7 @@ import throttle from "lodash.throttle";
|
|||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import { StoreAction } from "../../packages/excalidraw";
|
||||
import { SnapshotAction } from "../../packages/excalidraw";
|
||||
|
||||
class Portal {
|
||||
collab: TCollabClass;
|
||||
|
@ -133,7 +133,7 @@ class Portal {
|
|||
if (isChanged) {
|
||||
this.collab.excalidrawAPI.updateScene({
|
||||
elements: newElements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
}
|
||||
}, FILE_UPLOAD_TIMEOUT);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { StoreAction } from "../../packages/excalidraw";
|
||||
import { SnapshotAction } from "../../packages/excalidraw";
|
||||
import { compressData } from "../../packages/excalidraw/data/encode";
|
||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||
|
@ -268,6 +268,6 @@ export const updateStaleImageStatuses = (params: {
|
|||
}
|
||||
return element;
|
||||
}),
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -45,7 +45,7 @@ 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";
|
||||
import { StoreDelta } from "../../packages/excalidraw/store";
|
||||
|
||||
const filesStore = createStore("files-db", "files-store");
|
||||
|
||||
|
@ -260,7 +260,7 @@ export class LibraryLocalStorageMigrationAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
type SyncIncrementPersistedData = DTO<StoreIncrement>[];
|
||||
type SyncDeltaPersistedData = DTO<StoreDelta>[];
|
||||
|
||||
type SyncMetaPersistedData = {
|
||||
lastAcknowledgedVersion: number;
|
||||
|
@ -270,7 +270,7 @@ 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 deltasKey = "deltas";
|
||||
private static metadataKey = "metadata";
|
||||
|
||||
private static store = createStore(
|
||||
|
@ -278,24 +278,22 @@ export class SyncIndexedDBAdapter {
|
|||
`${SyncIndexedDBAdapter.idb_name}-store`,
|
||||
);
|
||||
|
||||
static async loadIncrements() {
|
||||
const increments = await get<SyncIncrementPersistedData>(
|
||||
SyncIndexedDBAdapter.incrementsKey,
|
||||
static async loadDeltas() {
|
||||
const deltas = await get<SyncDeltaPersistedData>(
|
||||
SyncIndexedDBAdapter.deltasKey,
|
||||
SyncIndexedDBAdapter.store,
|
||||
);
|
||||
|
||||
if (increments?.length) {
|
||||
return increments.map((storeIncrementDTO) =>
|
||||
StoreIncrement.restore(storeIncrementDTO),
|
||||
);
|
||||
if (deltas?.length) {
|
||||
return deltas.map((storeDeltaDTO) => StoreDelta.restore(storeDeltaDTO));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static async saveIncrements(data: SyncIncrementPersistedData): Promise<void> {
|
||||
static async saveDeltas(data: SyncDeltaPersistedData): Promise<void> {
|
||||
return set(
|
||||
SyncIndexedDBAdapter.incrementsKey,
|
||||
SyncIndexedDBAdapter.deltasKey,
|
||||
data,
|
||||
SyncIndexedDBAdapter.store,
|
||||
);
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
createRedoAction,
|
||||
createUndoAction,
|
||||
} from "../../packages/excalidraw/actions/actionHistory";
|
||||
import { StoreAction, newElementWith } from "../../packages/excalidraw";
|
||||
import { SnapshotAction, newElementWith } from "../../packages/excalidraw";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
|
@ -89,7 +89,7 @@ describe("collaboration", () => {
|
|||
|
||||
API.updateScene({
|
||||
elements: syncInvalidIndices([rect1, rect2]),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
API.updateScene({
|
||||
|
@ -97,7 +97,7 @@ describe("collaboration", () => {
|
|||
rect1,
|
||||
newElementWith(h.elements[1], { isDeleted: true }),
|
||||
]),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
@ -144,7 +144,7 @@ describe("collaboration", () => {
|
|||
// simulate force deleting the element remotely
|
||||
API.updateScene({
|
||||
elements: syncInvalidIndices([rect1]),
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
@ -182,7 +182,7 @@ describe("collaboration", () => {
|
|||
h.elements[0],
|
||||
newElementWith(h.elements[1], { x: 100 }),
|
||||
]),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
snapshotAction: SnapshotAction.CAPTURE,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
@ -217,7 +217,7 @@ describe("collaboration", () => {
|
|||
// simulate force deleting the element remotely
|
||||
API.updateScene({
|
||||
elements: syncInvalidIndices([rect1]),
|
||||
storeAction: StoreAction.UPDATE,
|
||||
snapshotAction: SnapshotAction.UPDATE,
|
||||
});
|
||||
|
||||
// snapshot was correctly updated and marked the element as deleted
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue