Syncing ephemeral element updates

This commit is contained in:
Marcel Mraz 2025-01-20 15:07:37 +01:00
parent c57249481e
commit 310a9ae4e0
No known key found for this signature in database
GPG key ID: 4EBD6E62DC830CD2
60 changed files with 1104 additions and 906 deletions

View file

@ -65,7 +65,7 @@ You can use this function to update the scene with the sceneData. It accepts the
| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene | | `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene |
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. | | `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. |
| `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. | | `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. |
| `commitToStore` | `boolean` | Implies if the change should be captured and commited to the `store`. Commited changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. | | `SnapshotAction` | `SnapshotAction` | Implies if the change should be captured by the `store`. Captured changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `SnapshotAction.CAPTURE`. |
```jsx live ```jsx live
function App() { function App() {

View file

@ -24,7 +24,7 @@ import {
Excalidraw, Excalidraw,
LiveCollaborationTrigger, LiveCollaborationTrigger,
TTDDialogTrigger, TTDDialogTrigger,
StoreAction, SnapshotAction,
reconcileElements, reconcileElements,
newElementWith, newElementWith,
} from "../packages/excalidraw"; } from "../packages/excalidraw";
@ -44,6 +44,7 @@ import {
preventUnload, preventUnload,
resolvablePromise, resolvablePromise,
isRunningInIframe, isRunningInIframe,
assertNever,
} from "../packages/excalidraw/utils"; } from "../packages/excalidraw/utils";
import { import {
FIREBASE_STORAGE_PREFIXES, FIREBASE_STORAGE_PREFIXES,
@ -109,7 +110,11 @@ import Trans from "../packages/excalidraw/components/Trans";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError"; import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile"; 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 { import {
CommandPalette, CommandPalette,
DEFAULT_CATEGORIES, DEFAULT_CATEGORIES,
@ -370,20 +375,30 @@ const ExcalidrawWrapper = () => {
const [, setShareDialogState] = useAtom(shareDialogStateAtom); const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom); const [collabAPI] = useAtom(collabAPIAtom);
const [syncAPI] = useAtom(syncApiAtom); const [syncAPI] = useAtom(syncApiAtom);
const [nextVersion, setNextVersion] = useState(-1); const [sliderVersion, setSliderVersion] = useState(0);
const currentVersion = useRef(-1); const [acknowledgedDeltas, setAcknowledgedDeltas] = useState<StoreDelta[]>(
const [acknowledgedIncrements, setAcknowledgedIncrements] = useState< [],
StoreIncrement[] );
>([]); const acknowledgedDeltasRef = useRef<StoreDelta[]>(acknowledgedDeltas);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
return isCollaborationLink(window.location.href); return isCollaborationLink(window.location.href);
}); });
const collabError = useAtomValue(collabErrorIndicatorAtom); const collabError = useAtomValue(collabErrorIndicatorAtom);
useEffect(() => {
acknowledgedDeltasRef.current = acknowledgedDeltas;
}, [acknowledgedDeltas]);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
setAcknowledgedIncrements([...(syncAPI?.acknowledgedIncrements ?? [])]); const deltas = syncAPI?.acknowledgedDeltas ?? [];
}, 250);
// CFDO: buffer local deltas as well, not only acknowledged ones
if (deltas.length > acknowledgedDeltasRef.current.length) {
setAcknowledgedDeltas([...deltas]);
setSliderVersion(deltas.length);
}
}, 1000);
syncAPI?.connect(); syncAPI?.connect();
@ -512,7 +527,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
...data.scene, ...data.scene,
...restore(data.scene, null, null, { repairBindings: true }), ...restore(data.scene, null, null, { repairBindings: true }),
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
} }
}); });
@ -539,7 +554,7 @@ const ExcalidrawWrapper = () => {
setLangCode(getPreferredLanguage()); setLangCode(getPreferredLanguage());
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
...localDataState, ...localDataState,
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
LibraryIndexedDBAdapter.load().then((data) => { LibraryIndexedDBAdapter.load().then((data) => {
if (data) { if (data) {
@ -671,7 +686,7 @@ const ExcalidrawWrapper = () => {
if (didChange) { if (didChange) {
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
elements, elements,
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
} }
} }
@ -689,18 +704,31 @@ const ExcalidrawWrapper = () => {
} }
}; };
const onIncrement = (increment: StoreIncrement) => { const onIncrement = (
// ephemerals are not part of this (which is alright) increment: DurableStoreIncrement | EphemeralStoreIncrement,
// - 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 { try {
syncAPI?.push(increment); if (!syncAPI) {
} catch (e) { return;
console.error(e);
} }
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,28 +854,32 @@ const ExcalidrawWrapper = () => {
}, },
}; };
const debouncedTimeTravel = debounce((value: number) => { const debouncedTimeTravel = debounce(
(value: number, direction: "forward" | "backward") => {
let elements = new Map( let elements = new Map(
excalidrawAPI?.getSceneElements().map((x) => [x.id, x]), excalidrawAPI?.getSceneElements().map((x) => [x.id, x]),
); );
let increments: StoreIncrement[] = []; let deltas: StoreDelta[] = [];
const goingLeft = switch (direction) {
currentVersion.current === -1 || value - currentVersion.current <= 0; case "forward": {
deltas = acknowledgedDeltas.slice(sliderVersion, value) ?? [];
if (goingLeft) { break;
increments = acknowledgedIncrements }
case "backward": {
deltas = acknowledgedDeltas
.slice(value) .slice(value)
.reverse() .reverse()
.map((x) => x.inverse()); .map((x) => StoreDelta.inverse(x));
} else { break;
increments = }
acknowledgedIncrements.slice(currentVersion.current, value) ?? []; default:
assertNever(direction, `Unknown direction: ${direction}`);
} }
for (const increment of increments) { for (const delta of deltas) {
[elements] = increment.elementsChange.applyTo( [elements] = delta.elements.applyTo(
elements as SceneElementsMap, elements as SceneElementsMap,
excalidrawAPI?.store.snapshot.elements!, excalidrawAPI?.store.snapshot.elements!,
); );
@ -856,16 +888,14 @@ const ExcalidrawWrapper = () => {
excalidrawAPI?.updateScene({ excalidrawAPI?.updateScene({
appState: { appState: {
...excalidrawAPI?.getAppState(), ...excalidrawAPI?.getAppState(),
viewModeEnabled: value !== -1, viewModeEnabled: value !== acknowledgedDeltas.length,
}, },
elements: Array.from(elements.values()), elements: Array.from(elements.values()),
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.NONE,
}); });
},
currentVersion.current = value; 0,
}, 0); );
const latestVersion = acknowledgedIncrements.length;
return ( return (
<div <div
@ -884,25 +914,30 @@ const ExcalidrawWrapper = () => {
}} }}
step={1} step={1}
min={0} min={0}
max={latestVersion} max={acknowledgedDeltas.length}
value={nextVersion === -1 ? latestVersion : nextVersion} value={sliderVersion}
onChange={(value) => { onChange={(value) => {
let nextValue: number; 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: 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 // 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 // don't listen to updates in the detached mode
syncAPI?.disconnect(); syncAPI?.disconnect();
nextValue = value as number;
} else { } else {
// reconnect once we're back to the latest version // reconnect once we're back to the latest version
syncAPI?.connect(); syncAPI?.connect();
nextValue = -1;
} }
setNextVersion(nextValue); if (nextSliderVersion === sliderVersion) {
debouncedTimeTravel(nextValue); return;
}
debouncedTimeTravel(
nextSliderVersion,
nextSliderVersion < sliderVersion ? "backward" : "forward",
);
setSliderVersion(nextSliderVersion);
}} }}
/> />
<Excalidraw <Excalidraw

View file

@ -15,7 +15,7 @@ import type {
OrderedExcalidrawElement, OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types"; } from "../../packages/excalidraw/element/types";
import { import {
StoreAction, SnapshotAction,
getSceneVersion, getSceneVersion,
restoreElements, restoreElements,
zoomToFitBounds, zoomToFitBounds,
@ -393,7 +393,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.excalidrawAPI.updateScene({ this.excalidrawAPI.updateScene({
elements, 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. // to database even if deleted before creating the room.
this.excalidrawAPI.updateScene({ this.excalidrawAPI.updateScene({
elements, elements,
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
this.saveCollabRoomToFirebase(getSyncableElements(elements)); this.saveCollabRoomToFirebase(getSyncableElements(elements));
@ -782,7 +782,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
) => { ) => {
this.excalidrawAPI.updateScene({ this.excalidrawAPI.updateScene({
elements, elements,
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
this.loadImageFiles(); this.loadImageFiles();

View file

@ -19,7 +19,7 @@ import throttle from "lodash.throttle";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import { encryptData } from "../../packages/excalidraw/data/encryption"; import { encryptData } from "../../packages/excalidraw/data/encryption";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
import { StoreAction } from "../../packages/excalidraw"; import { SnapshotAction } from "../../packages/excalidraw";
class Portal { class Portal {
collab: TCollabClass; collab: TCollabClass;
@ -133,7 +133,7 @@ class Portal {
if (isChanged) { if (isChanged) {
this.collab.excalidrawAPI.updateScene({ this.collab.excalidrawAPI.updateScene({
elements: newElements, elements: newElements,
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
} }
}, FILE_UPLOAD_TIMEOUT); }, FILE_UPLOAD_TIMEOUT);

View file

@ -1,4 +1,4 @@
import { StoreAction } from "../../packages/excalidraw"; import { SnapshotAction } from "../../packages/excalidraw";
import { compressData } from "../../packages/excalidraw/data/encode"; import { compressData } from "../../packages/excalidraw/data/encode";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks"; import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
@ -268,6 +268,6 @@ export const updateStaleImageStatuses = (params: {
} }
return element; return element;
}), }),
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
}; };

View file

@ -45,7 +45,7 @@ import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager"; import { FileManager } from "./FileManager";
import { Locker } from "./Locker"; import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync"; import { updateBrowserStateVersion } from "./tabSync";
import { StoreIncrement } from "../../packages/excalidraw/store"; import { StoreDelta } from "../../packages/excalidraw/store";
const filesStore = createStore("files-db", "files-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 = { type SyncMetaPersistedData = {
lastAcknowledgedVersion: number; lastAcknowledgedVersion: number;
@ -270,7 +270,7 @@ export class SyncIndexedDBAdapter {
/** IndexedDB database and store name */ /** IndexedDB database and store name */
private static idb_name = STORAGE_KEYS.IDB_SYNC; private static idb_name = STORAGE_KEYS.IDB_SYNC;
/** library data store keys */ /** library data store keys */
private static incrementsKey = "increments"; private static deltasKey = "deltas";
private static metadataKey = "metadata"; private static metadataKey = "metadata";
private static store = createStore( private static store = createStore(
@ -278,24 +278,22 @@ export class SyncIndexedDBAdapter {
`${SyncIndexedDBAdapter.idb_name}-store`, `${SyncIndexedDBAdapter.idb_name}-store`,
); );
static async loadIncrements() { static async loadDeltas() {
const increments = await get<SyncIncrementPersistedData>( const deltas = await get<SyncDeltaPersistedData>(
SyncIndexedDBAdapter.incrementsKey, SyncIndexedDBAdapter.deltasKey,
SyncIndexedDBAdapter.store, SyncIndexedDBAdapter.store,
); );
if (increments?.length) { if (deltas?.length) {
return increments.map((storeIncrementDTO) => return deltas.map((storeDeltaDTO) => StoreDelta.restore(storeDeltaDTO));
StoreIncrement.restore(storeIncrementDTO),
);
} }
return null; return null;
} }
static async saveIncrements(data: SyncIncrementPersistedData): Promise<void> { static async saveDeltas(data: SyncDeltaPersistedData): Promise<void> {
return set( return set(
SyncIndexedDBAdapter.incrementsKey, SyncIndexedDBAdapter.deltasKey,
data, data,
SyncIndexedDBAdapter.store, SyncIndexedDBAdapter.store,
); );

View file

@ -11,7 +11,7 @@ import {
createRedoAction, createRedoAction,
createUndoAction, createUndoAction,
} from "../../packages/excalidraw/actions/actionHistory"; } from "../../packages/excalidraw/actions/actionHistory";
import { StoreAction, newElementWith } from "../../packages/excalidraw"; import { SnapshotAction, newElementWith } from "../../packages/excalidraw";
const { h } = window; const { h } = window;
@ -89,7 +89,7 @@ describe("collaboration", () => {
API.updateScene({ API.updateScene({
elements: syncInvalidIndices([rect1, rect2]), elements: syncInvalidIndices([rect1, rect2]),
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
API.updateScene({ API.updateScene({
@ -97,7 +97,7 @@ describe("collaboration", () => {
rect1, rect1,
newElementWith(h.elements[1], { isDeleted: true }), newElementWith(h.elements[1], { isDeleted: true }),
]), ]),
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
await waitFor(() => { await waitFor(() => {
@ -144,7 +144,7 @@ describe("collaboration", () => {
// simulate force deleting the element remotely // simulate force deleting the element remotely
API.updateScene({ API.updateScene({
elements: syncInvalidIndices([rect1]), elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
await waitFor(() => { await waitFor(() => {
@ -182,7 +182,7 @@ describe("collaboration", () => {
h.elements[0], h.elements[0],
newElementWith(h.elements[1], { x: 100 }), newElementWith(h.elements[1], { x: 100 }),
]), ]),
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
await waitFor(() => { await waitFor(() => {
@ -217,7 +217,7 @@ describe("collaboration", () => {
// simulate force deleting the element remotely // simulate force deleting the element remotely
API.updateScene({ API.updateScene({
elements: syncInvalidIndices([rect1]), elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
// snapshot was correctly updated and marked the element as deleted // snapshot was correctly updated and marked the element as deleted

View file

@ -49,8 +49,8 @@ Please add the latest change on the top under the correct section.
| | Before `commitToHistory` | After `storeAction` | Notes | | | Before `commitToHistory` | After `storeAction` | Notes |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| _Immediately undoable_ | `true` | `"capture"` | As before, use for all updates which should be recorded by the store & history. Should be used for the most of the local updates. These updates will _immediately_ make it to the local undo / redo stacks. | | _Immediately undoable_ | `true` | `"capture"` | As before, use for all updates which should be captured by the store & history. Should be used for the most of the local updates (excluding ephemeral updates such as dragging or resizing). These updates will _immediately_ make it to the local undo / redo stacks. |
| _Eventually undoable_ | `false` | `"none"` | Similar to before, use for all updates which should not be recorded immediately (likely exceptions which are part of some async multi-step process) or those not meant to be recorded at all (i.e. updates to `collaborators` object, parts of `AppState` which are not observed by the store & history - not `ObservedAppState`).<br/><br/>**IMPORTANT** It's likely you should switch to `"update"` in all the other cases. Otherwise, all such updates would end up being recorded with the next `"capture"` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. | | _Eventually undoable_ | `false` | `"none"` | Similar to before, use for all updates which should not be captured immediately (likely exceptions which are part of some async multi-step process) or those not meant to be captured at all (i.e. updates to `collaborators` object, parts of `AppState` which are not observed by the store & history - not `ObservedAppState`).<br/><br/>**IMPORTANT** It's likely you should switch to `"update"` in all the other cases. Otherwise, all such updates would end up being captured with the next `"capture"` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. |
| _Never undoable_ | n/a | `"update"` | **NEW**: previously there was no equivalent for this value. Now, it's recommended to use `"update"` for all remote updates (from the other clients), scene initialization, or those updates, which should not be locally "undoable". These updates will _never_ make it to the local undo / redo stacks. | | _Never undoable_ | n/a | `"update"` | **NEW**: previously there was no equivalent for this value. Now, it's recommended to use `"update"` for all remote updates (from the other clients), scene initialization, or those updates, which should not be locally "undoable". These updates will _never_ make it to the local undo / redo stacks. |
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539) - `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)

View file

@ -3,7 +3,7 @@ import { deepCopyElement } from "../element/newElement";
import { randomId } from "../random"; import { randomId } from "../random";
import { t } from "../i18n"; import { t } from "../i18n";
import { LIBRARY_DISABLED_TYPES } from "../constants"; import { LIBRARY_DISABLED_TYPES } from "../constants";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
export const actionAddToLibrary = register({ export const actionAddToLibrary = register({
name: "addToLibrary", name: "addToLibrary",
@ -18,7 +18,7 @@ export const actionAddToLibrary = register({
for (const type of LIBRARY_DISABLED_TYPES) { for (const type of LIBRARY_DISABLED_TYPES) {
if (selectedElements.some((element) => element.type === type)) { if (selectedElements.some((element) => element.type === type)) {
return { return {
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
appState: { appState: {
...appState, ...appState,
errorMessage: t(`errors.libraryElementTypeError.${type}`), errorMessage: t(`errors.libraryElementTypeError.${type}`),
@ -42,7 +42,7 @@ export const actionAddToLibrary = register({
}) })
.then(() => { .then(() => {
return { return {
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
appState: { appState: {
...appState, ...appState,
toast: { message: t("toast.addedToLibrary") }, toast: { message: t("toast.addedToLibrary") },
@ -51,7 +51,7 @@ export const actionAddToLibrary = register({
}) })
.catch((error) => { .catch((error) => {
return { return {
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
appState: { appState: {
...appState, ...appState,
errorMessage: error.message, errorMessage: error.message,

View file

@ -16,7 +16,7 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import type { AppClassProperties, AppState, UIAppState } from "../types"; import type { AppClassProperties, AppState, UIAppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
@ -72,7 +72,7 @@ export const actionAlignTop = register({
position: "start", position: "start",
axis: "y", axis: "y",
}), }),
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@ -106,7 +106,7 @@ export const actionAlignBottom = register({
position: "end", position: "end",
axis: "y", axis: "y",
}), }),
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@ -140,7 +140,7 @@ export const actionAlignLeft = register({
position: "start", position: "start",
axis: "x", axis: "x",
}), }),
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@ -174,7 +174,7 @@ export const actionAlignRight = register({
position: "end", position: "end",
axis: "x", axis: "x",
}), }),
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@ -208,7 +208,7 @@ export const actionAlignVerticallyCentered = register({
position: "center", position: "center",
axis: "y", axis: "y",
}), }),
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
@ -238,7 +238,7 @@ export const actionAlignHorizontallyCentered = register({
position: "center", position: "center",
axis: "x", axis: "x",
}), }),
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (

View file

@ -34,7 +34,7 @@ import type { Mutable } from "../utility-types";
import { arrayToMap, getFontString } from "../utils"; import { arrayToMap, getFontString } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { syncMovedIndices } from "../fractionalIndex"; import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
export const actionUnbindText = register({ export const actionUnbindText = register({
name: "unbindText", name: "unbindText",
@ -86,7 +86,7 @@ export const actionUnbindText = register({
return { return {
elements, elements,
appState, appState,
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
}); });
@ -163,7 +163,7 @@ export const actionBindText = register({
return { return {
elements: pushTextAboveContainer(elements, container, textElement), elements: pushTextAboveContainer(elements, container, textElement),
appState: { ...appState, selectedElementIds: { [container.id]: true } }, appState: { ...appState, selectedElementIds: { [container.id]: true } },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
}); });
@ -323,7 +323,7 @@ export const actionWrapTextInContainer = register({
...appState, ...appState,
selectedElementIds: containerIds, selectedElementIds: containerIds,
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
}); });

View file

@ -37,7 +37,7 @@ import {
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import type { SceneBounds } from "../element/bounds"; import type { SceneBounds } from "../element/bounds";
import { setCursor } from "../cursor"; import { setCursor } from "../cursor";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import { clamp, roundToStep } from "../../math"; import { clamp, roundToStep } from "../../math";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
@ -55,8 +55,8 @@ export const actionChangeViewBackgroundColor = register({
return { return {
appState: { ...appState, ...value }, appState: { ...appState, ...value },
storeAction: !!value.viewBackgroundColor storeAction: !!value.viewBackgroundColor
? StoreAction.CAPTURE ? SnapshotAction.CAPTURE
: StoreAction.NONE, : SnapshotAction.NONE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, appProps }) => { PanelComponent: ({ elements, appState, updateData, appProps }) => {
@ -115,7 +115,7 @@ export const actionClearCanvas = register({
? { ...appState.activeTool, type: "selection" } ? { ...appState.activeTool, type: "selection" }
: appState.activeTool, : appState.activeTool,
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
}); });
@ -140,7 +140,7 @@ export const actionZoomIn = register({
), ),
userToFollow: null, userToFollow: null,
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
PanelComponent: ({ updateData, appState }) => ( PanelComponent: ({ updateData, appState }) => (
@ -181,7 +181,7 @@ export const actionZoomOut = register({
), ),
userToFollow: null, userToFollow: null,
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
PanelComponent: ({ updateData, appState }) => ( PanelComponent: ({ updateData, appState }) => (
@ -222,7 +222,7 @@ export const actionResetZoom = register({
), ),
userToFollow: null, userToFollow: null,
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
PanelComponent: ({ updateData, appState }) => ( PanelComponent: ({ updateData, appState }) => (
@ -341,7 +341,7 @@ export const zoomToFitBounds = ({
scrollY: centerScroll.scrollY, scrollY: centerScroll.scrollY,
zoom: { value: newZoomValue }, zoom: { value: newZoomValue },
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}; };
@ -472,7 +472,7 @@ export const actionToggleTheme = register({
theme: theme:
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT), value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D, keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
@ -510,7 +510,7 @@ export const actionToggleEraserTool = register({
activeEmbeddable: null, activeEmbeddable: null,
activeTool, activeTool,
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event) => event.key === KEYS.E, keyTest: (event) => event.key === KEYS.E,
@ -549,7 +549,7 @@ export const actionToggleHandTool = register({
activeEmbeddable: null, activeEmbeddable: null,
activeTool, activeTool,
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>

View file

@ -14,7 +14,7 @@ import { getTextFromElements, isTextElement } from "../element";
import { t } from "../i18n"; import { t } from "../i18n";
import { isFirefox } from "../constants"; import { isFirefox } from "../constants";
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons"; import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
export const actionCopy = register({ export const actionCopy = register({
name: "copy", name: "copy",
@ -32,7 +32,7 @@ export const actionCopy = register({
await copyToClipboard(elementsToCopy, app.files, event); await copyToClipboard(elementsToCopy, app.files, event);
} catch (error: any) { } catch (error: any) {
return { return {
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
appState: { appState: {
...appState, ...appState,
errorMessage: error.message, errorMessage: error.message,
@ -41,7 +41,7 @@ export const actionCopy = register({
} }
return { return {
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
// don't supply a shortcut since we handle this conditionally via onCopy event // don't supply a shortcut since we handle this conditionally via onCopy event
@ -67,7 +67,7 @@ export const actionPaste = register({
if (isFirefox) { if (isFirefox) {
return { return {
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
appState: { appState: {
...appState, ...appState,
errorMessage: t("hints.firefox_clipboard_write"), errorMessage: t("hints.firefox_clipboard_write"),
@ -76,7 +76,7 @@ export const actionPaste = register({
} }
return { return {
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
appState: { appState: {
...appState, ...appState,
errorMessage: t("errors.asyncPasteFailedOnRead"), errorMessage: t("errors.asyncPasteFailedOnRead"),
@ -89,7 +89,7 @@ export const actionPaste = register({
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
return { return {
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
appState: { appState: {
...appState, ...appState,
errorMessage: t("errors.asyncPasteFailedOnParse"), errorMessage: t("errors.asyncPasteFailedOnParse"),
@ -98,7 +98,7 @@ export const actionPaste = register({
} }
return { return {
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
// don't supply a shortcut since we handle this conditionally via onCopy event // don't supply a shortcut since we handle this conditionally via onCopy event
@ -125,7 +125,7 @@ export const actionCopyAsSvg = register({
perform: async (elements, appState, _data, app) => { perform: async (elements, appState, _data, app) => {
if (!app.canvas) { if (!app.canvas) {
return { return {
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
} }
@ -167,7 +167,7 @@ export const actionCopyAsSvg = register({
}), }),
}, },
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
@ -175,7 +175,7 @@ export const actionCopyAsSvg = register({
appState: { appState: {
errorMessage: error.message, errorMessage: error.message,
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
} }
}, },
@ -193,7 +193,7 @@ export const actionCopyAsPng = register({
perform: async (elements, appState, _data, app) => { perform: async (elements, appState, _data, app) => {
if (!app.canvas) { if (!app.canvas) {
return { return {
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
} }
const selectedElements = app.scene.getSelectedElements({ const selectedElements = app.scene.getSelectedElements({
@ -227,7 +227,7 @@ export const actionCopyAsPng = register({
}), }),
}, },
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
@ -236,7 +236,7 @@ export const actionCopyAsPng = register({
...appState, ...appState,
errorMessage: error.message, errorMessage: error.message,
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
} }
}, },
@ -263,7 +263,7 @@ export const copyText = register({
throw new Error(t("errors.copyToSystemClipboardFailed")); throw new Error(t("errors.copyToSystemClipboardFailed"));
} }
return { return {
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
predicate: (elements, appState, _, app) => { predicate: (elements, appState, _, app) => {

View file

@ -1,6 +1,6 @@
import { register } from "./register"; import { register } from "./register";
import { cropIcon } from "../components/icons"; import { cropIcon } from "../components/icons";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { isImageElement } from "../element/typeChecks"; import { isImageElement } from "../element/typeChecks";
@ -25,7 +25,7 @@ export const actionToggleCropEditor = register({
isCropping: false, isCropping: false,
croppingElementId: selectedElement.id, croppingElementId: selectedElement.id,
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
predicate: (elements, appState, _, app) => { predicate: (elements, appState, _, app) => {

View file

@ -17,7 +17,7 @@ import {
} from "../element/typeChecks"; } from "../element/typeChecks";
import { updateActiveTool } from "../utils"; import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
const deleteSelectedElements = ( const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@ -189,7 +189,7 @@ export const actionDeleteSelected = register({
...nextAppState, ...nextAppState,
editingLinearElement: null, editingLinearElement: null,
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
} }
@ -221,7 +221,7 @@ export const actionDeleteSelected = register({
: [0], : [0],
}, },
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
} }
let { elements: nextElements, appState: nextAppState } = let { elements: nextElements, appState: nextAppState } =
@ -245,8 +245,8 @@ export const actionDeleteSelected = register({
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
) )
? StoreAction.CAPTURE ? SnapshotAction.CAPTURE
: StoreAction.NONE, : SnapshotAction.NONE,
}; };
}, },
keyTest: (event, appState, elements) => keyTest: (event, appState, elements) =>

View file

@ -12,7 +12,7 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
@ -60,7 +60,7 @@ export const distributeHorizontally = register({
space: "between", space: "between",
axis: "x", axis: "x",
}), }),
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@ -91,7 +91,7 @@ export const distributeVertically = register({
space: "between", space: "between",
axis: "y", axis: "y",
}), }),
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>

View file

@ -32,7 +32,7 @@ import {
getSelectedElements, getSelectedElements,
} from "../scene/selection"; } from "../scene/selection";
import { syncMovedIndices } from "../fractionalIndex"; import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
name: "duplicateSelection", name: "duplicateSelection",
@ -52,7 +52,7 @@ export const actionDuplicateSelection = register({
return { return {
elements, elements,
appState: newAppState, appState: newAppState,
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
} catch { } catch {
return false; return false;
@ -61,7 +61,7 @@ export const actionDuplicateSelection = register({
return { return {
...duplicateElements(elements, appState), ...duplicateElements(elements, appState),
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,

View file

@ -4,7 +4,7 @@ import { isFrameLikeElement } from "../element/typeChecks";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { register } from "./register"; import { register } from "./register";
@ -67,7 +67,7 @@ export const actionToggleElementLock = register({
? null ? null
: appState.selectedLinearElement, : appState.selectedLinearElement,
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event, appState, elements, app) => { keyTest: (event, appState, elements, app) => {
@ -112,7 +112,7 @@ export const actionUnlockAllElements = register({
lockedElements.map((el) => [el.id, true]), lockedElements.map((el) => [el.id, true]),
), ),
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
label: "labels.elementLock.unlockAll", label: "labels.elementLock.unlockAll",

View file

@ -19,7 +19,7 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import type { Theme } from "../element/types"; import type { Theme } from "../element/types";
import "../components/ToolIcon.scss"; import "../components/ToolIcon.scss";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
export const actionChangeProjectName = register({ export const actionChangeProjectName = register({
name: "changeProjectName", name: "changeProjectName",
@ -28,7 +28,7 @@ export const actionChangeProjectName = register({
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, name: value }, appState: { ...appState, name: value },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
PanelComponent: ({ appState, updateData, appProps, data, app }) => ( PanelComponent: ({ appState, updateData, appProps, data, app }) => (
@ -48,7 +48,7 @@ export const actionChangeExportScale = register({
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportScale: value }, appState: { ...appState, exportScale: value },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
PanelComponent: ({ elements: allElements, appState, updateData }) => { PanelComponent: ({ elements: allElements, appState, updateData }) => {
@ -98,7 +98,7 @@ export const actionChangeExportBackground = register({
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportBackground: value }, appState: { ...appState, exportBackground: value },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (
@ -118,7 +118,7 @@ export const actionChangeExportEmbedScene = register({
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportEmbedScene: value }, appState: { ...appState, exportEmbedScene: value },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (
@ -160,7 +160,7 @@ export const actionSaveToActiveFile = register({
: await saveAsJSON(elements, appState, app.files, app.getName()); : await saveAsJSON(elements, appState, app.files, app.getName());
return { return {
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
appState: { appState: {
...appState, ...appState,
fileHandle, fileHandle,
@ -182,7 +182,7 @@ export const actionSaveToActiveFile = register({
} else { } else {
console.warn(error); console.warn(error);
} }
return { storeAction: StoreAction.NONE }; return { storeAction: SnapshotAction.NONE };
} }
}, },
// CFDO: temporary // CFDO: temporary
@ -208,7 +208,7 @@ export const actionSaveFileToDisk = register({
app.getName(), app.getName(),
); );
return { return {
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
appState: { appState: {
...appState, ...appState,
openDialog: null, openDialog: null,
@ -222,7 +222,7 @@ export const actionSaveFileToDisk = register({
} else { } else {
console.warn(error); console.warn(error);
} }
return { storeAction: StoreAction.NONE }; return { storeAction: SnapshotAction.NONE };
} }
}, },
keyTest: (event) => keyTest: (event) =>
@ -261,7 +261,7 @@ export const actionLoadScene = register({
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
files, files,
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
} catch (error: any) { } catch (error: any) {
if (error?.name === "AbortError") { if (error?.name === "AbortError") {
@ -272,7 +272,7 @@ export const actionLoadScene = register({
elements, elements,
appState: { ...appState, errorMessage: error.message }, appState: { ...appState, errorMessage: error.message },
files: app.files, files: app.files,
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
} }
}, },
@ -286,7 +286,7 @@ export const actionExportWithDarkMode = register({
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportWithDarkMode: value }, appState: { ...appState, exportWithDarkMode: value },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (

View file

@ -14,7 +14,7 @@ import {
import { isBindingElement, isLinearElement } from "../element/typeChecks"; import { isBindingElement, isLinearElement } from "../element/typeChecks";
import type { AppState } from "../types"; import type { AppState } from "../types";
import { resetCursor } from "../cursor"; import { resetCursor } from "../cursor";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import { pointFrom } from "../../math"; import { pointFrom } from "../../math";
import { isPathALoop } from "../shapes"; import { isPathALoop } from "../shapes";
@ -52,7 +52,7 @@ export const actionFinalize = register({
cursorButton: "up", cursorButton: "up",
editingLinearElement: null, editingLinearElement: null,
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
} }
} }
@ -199,7 +199,7 @@ export const actionFinalize = register({
pendingImageElementId: null, pendingImageElementId: null,
}, },
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit // TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>

View file

@ -18,7 +18,7 @@ import {
} from "../element/binding"; } from "../element/binding";
import { updateFrameMembershipOfSelectedElements } from "../frame"; import { updateFrameMembershipOfSelectedElements } from "../frame";
import { flipHorizontal, flipVertical } from "../components/icons"; import { flipHorizontal, flipVertical } from "../components/icons";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import { import {
isArrowElement, isArrowElement,
isElbowArrow, isElbowArrow,
@ -47,7 +47,7 @@ export const actionFlipHorizontal = register({
app, app,
), ),
appState, appState,
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event) => event.shiftKey && event.code === CODES.H, keyTest: (event) => event.shiftKey && event.code === CODES.H,
@ -72,7 +72,7 @@ export const actionFlipVertical = register({
app, app,
), ),
appState, appState,
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>

View file

@ -9,11 +9,11 @@ import { setCursorForShape } from "../cursor";
import { register } from "./register"; import { register } from "./register";
import { isFrameLikeElement } from "../element/typeChecks"; import { isFrameLikeElement } from "../element/typeChecks";
import { frameToolIcon } from "../components/icons"; import { frameToolIcon } from "../components/icons";
import { StoreAction } from "../store";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { newFrameElement } from "../element/newElement"; import { newFrameElement } from "../element/newElement";
import { getElementsInGroup } from "../groups"; import { getElementsInGroup } from "../groups";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { SnapshotAction } from "../store";
const isSingleFrameSelected = ( const isSingleFrameSelected = (
appState: UIAppState, appState: UIAppState,
@ -49,14 +49,14 @@ export const actionSelectAllElementsInFrame = register({
return acc; return acc;
}, {} as Record<ExcalidrawElement["id"], true>), }, {} as Record<ExcalidrawElement["id"], true>),
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
} }
return { return {
elements, elements,
appState, appState,
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
predicate: (elements, appState, _, app) => predicate: (elements, appState, _, app) =>
@ -80,14 +80,14 @@ export const actionRemoveAllElementsFromFrame = register({
[selectedElement.id]: true, [selectedElement.id]: true,
}, },
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
} }
return { return {
elements, elements,
appState, appState,
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
predicate: (elements, appState, _, app) => predicate: (elements, appState, _, app) =>
@ -109,7 +109,7 @@ export const actionupdateFrameRendering = register({
enabled: !appState.frameRendering.enabled, enabled: !appState.frameRendering.enabled,
}, },
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
checked: (appState: AppState) => appState.frameRendering.enabled, checked: (appState: AppState) => appState.frameRendering.enabled,
@ -139,7 +139,7 @@ export const actionSetFrameAsActiveTool = register({
type: "frame", type: "frame",
}), }),
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>

View file

@ -34,7 +34,7 @@ import {
replaceAllElementsInFrame, replaceAllElementsInFrame,
} from "../frame"; } from "../frame";
import { syncMovedIndices } from "../fractionalIndex"; import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) { if (elements.length >= 2) {
@ -84,7 +84,7 @@ export const actionGroup = register({
); );
if (selectedElements.length < 2) { if (selectedElements.length < 2) {
// nothing to group // nothing to group
return { appState, elements, storeAction: StoreAction.NONE }; return { appState, elements, storeAction: SnapshotAction.NONE };
} }
// if everything is already grouped into 1 group, there is nothing to do // if everything is already grouped into 1 group, there is nothing to do
const selectedGroupIds = getSelectedGroupIds(appState); const selectedGroupIds = getSelectedGroupIds(appState);
@ -104,7 +104,7 @@ export const actionGroup = register({
]); ]);
if (combinedSet.size === elementIdsInGroup.size) { if (combinedSet.size === elementIdsInGroup.size) {
// no incremental ids in the selected ids // no incremental ids in the selected ids
return { appState, elements, storeAction: StoreAction.NONE }; return { appState, elements, storeAction: SnapshotAction.NONE };
} }
} }
@ -170,7 +170,7 @@ export const actionGroup = register({
), ),
}, },
elements: reorderedElements, elements: reorderedElements,
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
predicate: (elements, appState, _, app) => predicate: (elements, appState, _, app) =>
@ -200,7 +200,7 @@ export const actionUngroup = register({
const elementsMap = arrayToMap(elements); const elementsMap = arrayToMap(elements);
if (groupIds.length === 0) { if (groupIds.length === 0) {
return { appState, elements, storeAction: StoreAction.NONE }; return { appState, elements, storeAction: SnapshotAction.NONE };
} }
let nextElements = [...elements]; let nextElements = [...elements];
@ -273,7 +273,7 @@ export const actionUngroup = register({
return { return {
appState: { ...appState, ...updateAppState }, appState: { ...appState, ...updateAppState },
elements: nextElements, elements: nextElements,
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>

View file

@ -9,8 +9,7 @@ import { KEYS, matchKey } from "../keys";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { isWindows } from "../constants"; import { isWindows } from "../constants";
import type { SceneElementsMap } from "../element/types"; import type { SceneElementsMap } from "../element/types";
import type { Store } from "../store"; import { SnapshotAction } from "../store";
import { StoreAction } from "../store";
import { useEmitter } from "../hooks/useEmitter"; import { useEmitter } from "../hooks/useEmitter";
const executeHistoryAction = ( const executeHistoryAction = (
@ -30,7 +29,7 @@ const executeHistoryAction = (
const result = updater(); const result = updater();
if (!result) { if (!result) {
return { storeAction: StoreAction.NONE }; return { storeAction: SnapshotAction.NONE };
} }
const [nextElementsMap, nextAppState] = result; const [nextElementsMap, nextAppState] = result;
@ -39,11 +38,11 @@ const executeHistoryAction = (
return { return {
appState: nextAppState, appState: nextAppState,
elements: nextElements, elements: nextElements,
storeAction: StoreAction.UPDATE, storeAction: SnapshotAction.UPDATE,
}; };
} }
return { storeAction: StoreAction.NONE }; return { storeAction: SnapshotAction.NONE };
}; };
type ActionCreator = (history: History) => Action; type ActionCreator = (history: History) => Action;

View file

@ -2,7 +2,7 @@ import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { isElbowArrow, isLinearElement } from "../element/typeChecks"; import { isElbowArrow, isLinearElement } from "../element/typeChecks";
import type { ExcalidrawLinearElement } from "../element/types"; import type { ExcalidrawLinearElement } from "../element/types";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import { register } from "./register"; import { register } from "./register";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
@ -51,7 +51,7 @@ export const actionToggleLinearEditor = register({
...appState, ...appState,
editingLinearElement, editingLinearElement,
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
PanelComponent: ({ appState, updateData, app }) => { PanelComponent: ({ appState, updateData, app }) => {

View file

@ -5,7 +5,7 @@ import { isEmbeddableElement } from "../element/typeChecks";
import { t } from "../i18n"; import { t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
@ -25,7 +25,7 @@ export const actionLink = register({
showHyperlinkPopup: "editor", showHyperlinkPopup: "editor",
openMenu: null, openMenu: null,
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
trackEvent: { category: "hyperlink", action: "click" }, trackEvent: { category: "hyperlink", action: "click" },

View file

@ -4,7 +4,7 @@ import { t } from "../i18n";
import { showSelectedShapeActions, getNonDeletedElements } from "../element"; import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register"; import { register } from "./register";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
export const actionToggleCanvasMenu = register({ export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu", name: "toggleCanvasMenu",
@ -15,7 +15,7 @@ export const actionToggleCanvasMenu = register({
...appState, ...appState,
openMenu: appState.openMenu === "canvas" ? null : "canvas", openMenu: appState.openMenu === "canvas" ? null : "canvas",
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}), }),
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (
<ToolButton <ToolButton
@ -37,7 +37,7 @@ export const actionToggleEditMenu = register({
...appState, ...appState,
openMenu: appState.openMenu === "shape" ? null : "shape", openMenu: appState.openMenu === "shape" ? null : "shape",
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}), }),
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
@ -74,7 +74,7 @@ export const actionShortcuts = register({
name: "help", name: "help",
}, },
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
keyTest: (event) => event.key === KEYS.QUESTION_MARK, keyTest: (event) => event.key === KEYS.QUESTION_MARK,

View file

@ -7,7 +7,7 @@ import {
microphoneMutedIcon, microphoneMutedIcon,
} from "../components/icons"; } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import type { Collaborator } from "../types"; import type { Collaborator } from "../types";
import { register } from "./register"; import { register } from "./register";
import clsx from "clsx"; import clsx from "clsx";
@ -28,7 +28,7 @@ export const actionGoToCollaborator = register({
...appState, ...appState,
userToFollow: null, userToFollow: null,
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
} }
@ -42,7 +42,7 @@ export const actionGoToCollaborator = register({
// Close mobile menu // Close mobile menu
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu, openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
PanelComponent: ({ updateData, data, appState }) => { PanelComponent: ({ updateData, data, appState }) => {

View file

@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { AppClassProperties, AppState, Primitive } from "../types"; import type { AppClassProperties, AppState, Primitive } from "../types";
import type { StoreActionType } from "../store"; import type { SnapshotActionType } from "../store";
import { import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS, DEFAULT_ELEMENT_BACKGROUND_PICKS,
@ -109,7 +109,7 @@ import {
tupleToCoors, tupleToCoors,
} from "../utils"; } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import { Fonts, getLineHeight } from "../fonts"; import { Fonts, getLineHeight } from "../fonts";
import { import {
bindLinearElement, bindLinearElement,
@ -270,7 +270,7 @@ const changeFontSize = (
? [...newFontSizes][0] ? [...newFontSizes][0]
: fallbackValue ?? appState.currentItemFontSize, : fallbackValue ?? appState.currentItemFontSize,
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}; };
@ -301,8 +301,8 @@ export const actionChangeStrokeColor = register({
...value, ...value,
}, },
storeAction: !!value.currentItemStrokeColor storeAction: !!value.currentItemStrokeColor
? StoreAction.CAPTURE ? SnapshotAction.CAPTURE
: StoreAction.NONE, : SnapshotAction.NONE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, appProps }) => ( PanelComponent: ({ elements, appState, updateData, appProps }) => (
@ -347,8 +347,8 @@ export const actionChangeBackgroundColor = register({
...value, ...value,
}, },
storeAction: !!value.currentItemBackgroundColor storeAction: !!value.currentItemBackgroundColor
? StoreAction.CAPTURE ? SnapshotAction.CAPTURE
: StoreAction.NONE, : SnapshotAction.NONE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, appProps }) => ( PanelComponent: ({ elements, appState, updateData, appProps }) => (
@ -392,7 +392,7 @@ export const actionChangeFillStyle = register({
}), }),
), ),
appState: { ...appState, currentItemFillStyle: value }, appState: { ...appState, currentItemFillStyle: value },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => {
@ -465,7 +465,7 @@ export const actionChangeStrokeWidth = register({
}), }),
), ),
appState: { ...appState, currentItemStrokeWidth: value }, appState: { ...appState, currentItemStrokeWidth: value },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
@ -520,7 +520,7 @@ export const actionChangeSloppiness = register({
}), }),
), ),
appState: { ...appState, currentItemRoughness: value }, appState: { ...appState, currentItemRoughness: value },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
@ -571,7 +571,7 @@ export const actionChangeStrokeStyle = register({
}), }),
), ),
appState: { ...appState, currentItemStrokeStyle: value }, appState: { ...appState, currentItemStrokeStyle: value },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
@ -626,7 +626,7 @@ export const actionChangeOpacity = register({
true, true,
), ),
appState: { ...appState, currentItemOpacity: value }, appState: { ...appState, currentItemOpacity: value },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
@ -814,22 +814,22 @@ export const actionChangeFontFamily = register({
...appState, ...appState,
...nextAppState, ...nextAppState,
}, },
storeAction: StoreAction.UPDATE, storeAction: SnapshotAction.UPDATE,
}; };
} }
const { currentItemFontFamily, currentHoveredFontFamily } = value; const { currentItemFontFamily, currentHoveredFontFamily } = value;
let nexStoreAction: StoreActionType = StoreAction.NONE; let nexStoreAction: SnapshotActionType = SnapshotAction.NONE;
let nextFontFamily: FontFamilyValues | undefined; let nextFontFamily: FontFamilyValues | undefined;
let skipOnHoverRender = false; let skipOnHoverRender = false;
if (currentItemFontFamily) { if (currentItemFontFamily) {
nextFontFamily = currentItemFontFamily; nextFontFamily = currentItemFontFamily;
nexStoreAction = StoreAction.CAPTURE; nexStoreAction = SnapshotAction.CAPTURE;
} else if (currentHoveredFontFamily) { } else if (currentHoveredFontFamily) {
nextFontFamily = currentHoveredFontFamily; nextFontFamily = currentHoveredFontFamily;
nexStoreAction = StoreAction.NONE; nexStoreAction = SnapshotAction.NONE;
const selectedTextElements = getSelectedElements(elements, appState, { const selectedTextElements = getSelectedElements(elements, appState, {
includeBoundTextElement: true, includeBoundTextElement: true,
@ -1187,7 +1187,7 @@ export const actionChangeTextAlign = register({
...appState, ...appState,
currentItemTextAlign: value, currentItemTextAlign: value,
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
@ -1277,7 +1277,7 @@ export const actionChangeVerticalAlign = register({
appState: { appState: {
...appState, ...appState,
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
@ -1362,7 +1362,7 @@ export const actionChangeRoundness = register({
...appState, ...appState,
currentItemRoundness: value, currentItemRoundness: value,
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => {
@ -1521,7 +1521,7 @@ export const actionChangeArrowhead = register({
? "currentItemStartArrowhead" ? "currentItemStartArrowhead"
: "currentItemEndArrowhead"]: value.type, : "currentItemEndArrowhead"]: value.type,
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => {
@ -1731,7 +1731,7 @@ export const actionChangeArrowType = register({
return { return {
elements: newElements, elements: newElements,
appState: newState, appState: newState,
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => {

View file

@ -6,7 +6,7 @@ import type { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { selectAllIcon } from "../components/icons"; import { selectAllIcon } from "../components/icons";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
export const actionSelectAll = register({ export const actionSelectAll = register({
name: "selectAll", name: "selectAll",
@ -50,7 +50,7 @@ export const actionSelectAll = register({
? new LinearElementEditor(elements[0]) ? new LinearElementEditor(elements[0])
: null, : null,
}, },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,

View file

@ -23,7 +23,7 @@ import {
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import type { ExcalidrawTextElement } from "../element/types"; import type { ExcalidrawTextElement } from "../element/types";
import { paintIcon } from "../components/icons"; import { paintIcon } from "../components/icons";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import { getLineHeight } from "../fonts"; import { getLineHeight } from "../fonts";
// `copiedStyles` is exported only for tests. // `copiedStyles` is exported only for tests.
@ -53,7 +53,7 @@ export const actionCopyStyles = register({
...appState, ...appState,
toast: { message: t("toast.copyStyles") }, toast: { message: t("toast.copyStyles") },
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@ -70,7 +70,7 @@ export const actionPasteStyles = register({
const pastedElement = elementsCopied[0]; const pastedElement = elementsCopied[0];
const boundTextElement = elementsCopied[1]; const boundTextElement = elementsCopied[1];
if (!isExcalidrawElement(pastedElement)) { if (!isExcalidrawElement(pastedElement)) {
return { elements, storeAction: StoreAction.NONE }; return { elements, storeAction: SnapshotAction.NONE };
} }
const selectedElements = getSelectedElements(elements, appState, { const selectedElements = getSelectedElements(elements, appState, {
@ -159,7 +159,7 @@ export const actionPasteStyles = register({
} }
return element; return element;
}), }),
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>

View file

@ -2,7 +2,7 @@ import { isTextElement } from "../element";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { measureText } from "../element/textElement"; import { measureText } from "../element/textElement";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import type { AppClassProperties } from "../types"; import type { AppClassProperties } from "../types";
import { getFontString } from "../utils"; import { getFontString } from "../utils";
import { register } from "./register"; import { register } from "./register";
@ -42,7 +42,7 @@ export const actionTextAutoResize = register({
} }
return element; return element;
}), }),
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
}); });

View file

@ -2,7 +2,7 @@ import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import type { AppState } from "../types"; import type { AppState } from "../types";
import { gridIcon } from "../components/icons"; import { gridIcon } from "../components/icons";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
export const actionToggleGridMode = register({ export const actionToggleGridMode = register({
name: "gridMode", name: "gridMode",
@ -21,7 +21,7 @@ export const actionToggleGridMode = register({
gridModeEnabled: !this.checked!(appState), gridModeEnabled: !this.checked!(appState),
objectsSnapModeEnabled: false, objectsSnapModeEnabled: false,
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
checked: (appState: AppState) => appState.gridModeEnabled, checked: (appState: AppState) => appState.gridModeEnabled,

View file

@ -1,6 +1,6 @@
import { magnetIcon } from "../components/icons"; import { magnetIcon } from "../components/icons";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import { register } from "./register"; import { register } from "./register";
export const actionToggleObjectsSnapMode = register({ export const actionToggleObjectsSnapMode = register({
@ -19,7 +19,7 @@ export const actionToggleObjectsSnapMode = register({
objectsSnapModeEnabled: !this.checked!(appState), objectsSnapModeEnabled: !this.checked!(appState),
gridModeEnabled: false, gridModeEnabled: false,
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
checked: (appState) => appState.objectsSnapModeEnabled, checked: (appState) => appState.objectsSnapModeEnabled,

View file

@ -2,7 +2,7 @@ import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import type { AppState } from "../types"; import type { AppState } from "../types";
import { searchIcon } from "../components/icons"; import { searchIcon } from "../components/icons";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants"; import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
export const actionToggleSearchMenu = register({ export const actionToggleSearchMenu = register({
@ -29,7 +29,7 @@ export const actionToggleSearchMenu = register({
if (searchInput?.matches(":focus")) { if (searchInput?.matches(":focus")) {
return { return {
appState: { ...appState, openSidebar: null }, appState: { ...appState, openSidebar: null },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
} }
@ -44,7 +44,7 @@ export const actionToggleSearchMenu = register({
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB }, openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB },
openDialog: null, openDialog: null,
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
checked: (appState: AppState) => appState.gridModeEnabled, checked: (appState: AppState) => appState.gridModeEnabled,

View file

@ -1,7 +1,7 @@
import { register } from "./register"; import { register } from "./register";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { abacusIcon } from "../components/icons"; import { abacusIcon } from "../components/icons";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
export const actionToggleStats = register({ export const actionToggleStats = register({
name: "stats", name: "stats",
@ -17,7 +17,7 @@ export const actionToggleStats = register({
...appState, ...appState,
stats: { ...appState.stats, open: !this.checked!(appState) }, stats: { ...appState.stats, open: !this.checked!(appState) },
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
checked: (appState) => appState.stats.open, checked: (appState) => appState.stats.open,

View file

@ -1,6 +1,6 @@
import { eyeIcon } from "../components/icons"; import { eyeIcon } from "../components/icons";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import { register } from "./register"; import { register } from "./register";
export const actionToggleViewMode = register({ export const actionToggleViewMode = register({
@ -19,7 +19,7 @@ export const actionToggleViewMode = register({
...appState, ...appState,
viewModeEnabled: !this.checked!(appState), viewModeEnabled: !this.checked!(appState),
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
checked: (appState) => appState.viewModeEnabled, checked: (appState) => appState.viewModeEnabled,

View file

@ -1,6 +1,6 @@
import { coffeeIcon } from "../components/icons"; import { coffeeIcon } from "../components/icons";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
import { register } from "./register"; import { register } from "./register";
export const actionToggleZenMode = register({ export const actionToggleZenMode = register({
@ -19,7 +19,7 @@ export const actionToggleZenMode = register({
...appState, ...appState,
zenModeEnabled: !this.checked!(appState), zenModeEnabled: !this.checked!(appState),
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
checked: (appState) => appState.zenModeEnabled, checked: (appState) => appState.zenModeEnabled,

View file

@ -15,7 +15,7 @@ import {
SendToBackIcon, SendToBackIcon,
} from "../components/icons"; } from "../components/icons";
import { isDarwin } from "../constants"; import { isDarwin } from "../constants";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
export const actionSendBackward = register({ export const actionSendBackward = register({
name: "sendBackward", name: "sendBackward",
@ -27,7 +27,7 @@ export const actionSendBackward = register({
return { return {
elements: moveOneLeft(elements, appState), elements: moveOneLeft(elements, appState),
appState, appState,
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyPriority: 40, keyPriority: 40,
@ -57,7 +57,7 @@ export const actionBringForward = register({
return { return {
elements: moveOneRight(elements, appState), elements: moveOneRight(elements, appState),
appState, appState,
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyPriority: 40, keyPriority: 40,
@ -87,7 +87,7 @@ export const actionSendToBack = register({
return { return {
elements: moveAllLeft(elements, appState), elements: moveAllLeft(elements, appState),
appState, appState,
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@ -125,7 +125,7 @@ export const actionBringToFront = register({
return { return {
elements: moveAllRight(elements, appState), elements: moveAllRight(elements, appState),
appState, appState,
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>

View file

@ -10,7 +10,7 @@ import type {
BinaryFiles, BinaryFiles,
UIAppState, UIAppState,
} from "../types"; } from "../types";
import type { StoreActionType } from "../store"; import type { SnapshotActionType } from "../store";
export type ActionSource = export type ActionSource =
| "ui" | "ui"
@ -25,7 +25,7 @@ export type ActionResult =
elements?: readonly ExcalidrawElement[] | null; elements?: readonly ExcalidrawElement[] | null;
appState?: Partial<AppState> | null; appState?: Partial<AppState> | null;
files?: BinaryFiles | null; files?: BinaryFiles | null;
storeAction: StoreActionType; storeAction: SnapshotActionType;
replaceFiles?: boolean; replaceFiles?: boolean;
} }
| false; | false;

View file

@ -1,21 +1,17 @@
import type { import type { DeltasRepository, DELTA, SERVER_DELTA } from "../sync/protocol";
IncrementsRepository,
CLIENT_INCREMENT,
SERVER_INCREMENT,
} from "../sync/protocol";
// CFDO: add senderId, possibly roomId as well // CFDO: add senderId, possibly roomId as well
export class DurableIncrementsRepository implements IncrementsRepository { export class DurableDeltasRepository implements DeltasRepository {
// there is a 2MB row limit, hence working max row size of 1.5 MB // there is a 2MB row limit, hence working max row size of 1.5 MB
// and leaving a buffer for other row metadata // and leaving a buffer for other row metadata
private static readonly MAX_PAYLOAD_SIZE = 1_500_000; private static readonly MAX_PAYLOAD_SIZE = 1_500_000;
constructor(private storage: DurableObjectStorage) { constructor(private storage: DurableObjectStorage) {
// #region DEV ONLY // #region DEV ONLY
// this.storage.sql.exec(`DROP TABLE IF EXISTS increments;`); // this.storage.sql.exec(`DROP TABLE IF EXISTS deltas;`);
// #endregion // #endregion
this.storage.sql.exec(`CREATE TABLE IF NOT EXISTS increments( this.storage.sql.exec(`CREATE TABLE IF NOT EXISTS deltas(
id TEXT NOT NULL, id TEXT NOT NULL,
version INTEGER NOT NULL, version INTEGER NOT NULL,
position INTEGER NOT NULL, position INTEGER NOT NULL,
@ -25,41 +21,41 @@ export class DurableIncrementsRepository implements IncrementsRepository {
);`); );`);
} }
public save(increment: CLIENT_INCREMENT): SERVER_INCREMENT | null { public save(delta: DELTA): SERVER_DELTA | null {
return this.storage.transactionSync(() => { return this.storage.transactionSync(() => {
const existingIncrement = this.getById(increment.id); const existingDelta = this.getById(delta.id);
// don't perist the same increment twice // don't perist the same delta twice
if (existingIncrement) { if (existingDelta) {
return existingIncrement; return existingDelta;
} }
try { try {
const payload = JSON.stringify(increment); const payload = JSON.stringify(delta);
const payloadSize = new TextEncoder().encode(payload).byteLength; const payloadSize = new TextEncoder().encode(payload).byteLength;
const nextVersion = this.getLastVersion() + 1; const nextVersion = this.getLastVersion() + 1;
const chunksCount = Math.ceil( const chunksCount = Math.ceil(
payloadSize / DurableIncrementsRepository.MAX_PAYLOAD_SIZE, payloadSize / DurableDeltasRepository.MAX_PAYLOAD_SIZE,
); );
for (let position = 0; position < chunksCount; position++) { for (let position = 0; position < chunksCount; position++) {
const start = position * DurableIncrementsRepository.MAX_PAYLOAD_SIZE; const start = position * DurableDeltasRepository.MAX_PAYLOAD_SIZE;
const end = start + DurableIncrementsRepository.MAX_PAYLOAD_SIZE; const end = start + DurableDeltasRepository.MAX_PAYLOAD_SIZE;
// slicing the chunk payload // slicing the chunk payload
const chunkedPayload = payload.slice(start, end); const chunkedPayload = payload.slice(start, end);
this.storage.sql.exec( this.storage.sql.exec(
`INSERT INTO increments (id, version, position, payload) VALUES (?, ?, ?, ?);`, `INSERT INTO deltas (id, version, position, payload) VALUES (?, ?, ?, ?);`,
increment.id, delta.id,
nextVersion, nextVersion,
position, position,
chunkedPayload, chunkedPayload,
); );
} }
} catch (e) { } catch (e) {
// check if the increment has been already acknowledged // check if the delta has been already acknowledged
// in case client for some reason did not receive acknowledgement // in case client for some reason did not receive acknowledgement
// and reconnected while the we still have the increment in the worker // and reconnected while the we still have the delta in the worker
// otherwise the client is doomed to full a restore // otherwise the client is doomed to full a restore
if (e instanceof Error && e.message.includes("SQLITE_CONSTRAINT")) { if (e instanceof Error && e.message.includes("SQLITE_CONSTRAINT")) {
// continue; // continue;
@ -68,68 +64,66 @@ export class DurableIncrementsRepository implements IncrementsRepository {
} }
} }
const acknowledged = this.getById(increment.id); const acknowledged = this.getById(delta.id);
return acknowledged; return acknowledged;
}); });
} }
// CFDO: for versioning we need deletions, but not for the "snapshot" update; // CFDO: for versioning we need deletions, but not for the "snapshot" update;
public getAllSinceVersion(version: number): Array<SERVER_INCREMENT> { public getAllSinceVersion(version: number): Array<SERVER_DELTA> {
const increments = this.storage.sql const deltas = this.storage.sql
.exec<SERVER_INCREMENT>( .exec<SERVER_DELTA>(
`SELECT id, payload, version FROM increments WHERE version > (?) ORDER BY version, position, createdAt ASC;`, `SELECT id, payload, version FROM deltas WHERE version > (?) ORDER BY version, position, createdAt ASC;`,
version, version,
) )
.toArray(); .toArray();
return this.restoreServerIncrements(increments); return this.restoreServerDeltas(deltas);
} }
public getLastVersion(): number { public getLastVersion(): number {
// CFDO: might be in memory to reduce number of rows read (or position on version at least, if btree affect rows read) // CFDO: might be in memory to reduce number of rows read (or position on version at least, if btree affect rows read)
const result = this.storage.sql const result = this.storage.sql
.exec(`SELECT MAX(version) FROM increments;`) .exec(`SELECT MAX(version) FROM deltas;`)
.one(); .one();
return result ? Number(result["MAX(version)"]) : 0; return result ? Number(result["MAX(version)"]) : 0;
} }
public getById(id: string): SERVER_INCREMENT | null { public getById(id: string): SERVER_DELTA | null {
const increments = this.storage.sql const deltas = this.storage.sql
.exec<SERVER_INCREMENT>( .exec<SERVER_DELTA>(
`SELECT id, payload, version FROM increments WHERE id = (?) ORDER BY position ASC`, `SELECT id, payload, version FROM deltas WHERE id = (?) ORDER BY position ASC`,
id, id,
) )
.toArray(); .toArray();
if (!increments.length) { if (!deltas.length) {
return null; return null;
} }
const restoredIncrements = this.restoreServerIncrements(increments); const restoredDeltas = this.restoreServerDeltas(deltas);
if (restoredIncrements.length !== 1) { if (restoredDeltas.length !== 1) {
throw new Error( throw new Error(
`Expected exactly one restored increment, but received "${restoredIncrements.length}".`, `Expected exactly one restored delta, but received "${restoredDeltas.length}".`,
); );
} }
return restoredIncrements[0]; return restoredDeltas[0];
} }
private restoreServerIncrements( private restoreServerDeltas(deltas: SERVER_DELTA[]): SERVER_DELTA[] {
increments: SERVER_INCREMENT[],
): SERVER_INCREMENT[] {
return Array.from( return Array.from(
increments deltas
.reduce((acc, curr) => { .reduce((acc, curr) => {
const increment = acc.get(curr.version); const delta = acc.get(curr.version);
if (increment) { if (delta) {
acc.set(curr.version, { acc.set(curr.version, {
...increment, ...delta,
// glueing the chunks payload back // glueing the chunks payload back
payload: increment.payload + curr.payload, payload: delta.payload + curr.payload,
}); });
} else { } else {
// let's not unnecessarily expose more props than these // let's not unnecessarily expose more props than these
@ -141,7 +135,7 @@ export class DurableIncrementsRepository implements IncrementsRepository {
} }
return acc; return acc;
}, new Map<number, SERVER_INCREMENT>()) }, new Map<number, SERVER_DELTA>())
.values(), .values(),
); );
} }

View file

@ -1,5 +1,5 @@
import { DurableObject } from "cloudflare:workers"; import { DurableObject } from "cloudflare:workers";
import { DurableIncrementsRepository } from "./repository"; import { DurableDeltasRepository } from "./repository";
import { ExcalidrawSyncServer } from "../sync/server"; import { ExcalidrawSyncServer } from "../sync/server";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
@ -34,9 +34,8 @@ export class DurableRoom extends DurableObject {
this.roomId = (await this.ctx.storage.get("roomId")) || null; this.roomId = (await this.ctx.storage.get("roomId")) || null;
}); });
this.sync = new ExcalidrawSyncServer( const repository = new DurableDeltasRepository(ctx.storage);
new DurableIncrementsRepository(ctx.storage), this.sync = new ExcalidrawSyncServer(repository);
);
// in case it hibernates, let's get take active connections // in case it hibernates, let's get take active connections
for (const ws of this.ctx.getWebSockets()) { for (const ws of this.ctx.getWebSockets()) {

View file

@ -419,7 +419,7 @@ import { COLOR_PALETTE } from "../colors";
import { ElementCanvasButton } from "./MagicButton"; import { ElementCanvasButton } from "./MagicButton";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import FollowMode from "./FollowMode/FollowMode"; import FollowMode from "./FollowMode/FollowMode";
import { Store, StoreAction } from "../store"; import { Store, SnapshotAction } from "../store";
import { AnimationFrameHandler } from "../animation-frame-handler"; import { AnimationFrameHandler } from "../animation-frame-handler";
import { AnimatedTrail } from "../animated-trail"; import { AnimatedTrail } from "../animated-trail";
import { LaserTrails } from "../laser-trails"; import { LaserTrails } from "../laser-trails";
@ -1819,7 +1819,7 @@ class App extends React.Component<AppProps, AppState> {
public getSceneElementsMapIncludingDeleted = () => { public getSceneElementsMapIncludingDeleted = () => {
return this.scene.getElementsMapIncludingDeleted(); return this.scene.getElementsMapIncludingDeleted();
} };
public getSceneElements = () => { public getSceneElements = () => {
return this.scene.getNonDeletedElements(); return this.scene.getNonDeletedElements();
@ -2093,12 +2093,12 @@ class App extends React.Component<AppProps, AppState> {
if (shouldUpdateStrokeColor) { if (shouldUpdateStrokeColor) {
this.syncActionResult({ this.syncActionResult({
appState: { ...this.state, currentItemStrokeColor: color }, appState: { ...this.state, currentItemStrokeColor: color },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}); });
} else { } else {
this.syncActionResult({ this.syncActionResult({
appState: { ...this.state, currentItemBackgroundColor: color }, appState: { ...this.state, currentItemBackgroundColor: color },
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}); });
} }
} else { } else {
@ -2112,7 +2112,7 @@ class App extends React.Component<AppProps, AppState> {
} }
return el; return el;
}), }),
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
} }
}, },
@ -2133,11 +2133,7 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (actionResult.storeAction === StoreAction.UPDATE) { this.store.scheduleAction(actionResult.storeAction);
this.store.shouldUpdateSnapshot();
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
this.store.shouldCaptureIncrement();
}
let didUpdate = false; let didUpdate = false;
@ -2210,7 +2206,7 @@ class App extends React.Component<AppProps, AppState> {
didUpdate = true; didUpdate = true;
} }
if (!didUpdate && actionResult.storeAction !== StoreAction.NONE) { if (!didUpdate) {
this.scene.triggerUpdate(); this.scene.triggerUpdate();
} }
}); });
@ -2338,7 +2334,7 @@ class App extends React.Component<AppProps, AppState> {
this.resetHistory(); this.resetHistory();
this.syncActionResult({ this.syncActionResult({
...scene, ...scene,
storeAction: StoreAction.UPDATE, storeAction: SnapshotAction.UPDATE,
}); });
// clear the shape and image cache so that any images in initialData // clear the shape and image cache so that any images in initialData
@ -3273,7 +3269,7 @@ class App extends React.Component<AppProps, AppState> {
this.addMissingFiles(opts.files); this.addMissingFiles(opts.files);
} }
this.store.shouldCaptureIncrement(); this.store.scheduleCapture();
const nextElementsToSelect = const nextElementsToSelect =
excludeElementsInFramesFromSelection(newElements); excludeElementsInFramesFromSelection(newElements);
@ -3530,7 +3526,7 @@ class App extends React.Component<AppProps, AppState> {
PLAIN_PASTE_TOAST_SHOWN = true; PLAIN_PASTE_TOAST_SHOWN = true;
} }
this.store.shouldCaptureIncrement(); this.store.scheduleCapture();
} }
setAppState: React.Component<any, AppState>["setState"] = ( setAppState: React.Component<any, AppState>["setState"] = (
@ -3873,12 +3869,17 @@ class App extends React.Component<AppProps, AppState> {
elements?: SceneData["elements"]; elements?: SceneData["elements"];
appState?: Pick<AppState, K> | null; appState?: Pick<AppState, K> | null;
collaborators?: SceneData["collaborators"]; collaborators?: SceneData["collaborators"];
/** @default StoreAction.NONE */ /** @default SnapshotAction.NONE */
storeAction?: SceneData["storeAction"]; snapshotAction?: SceneData["snapshotAction"];
}) => { }) => {
// flush all pending updates (if any) most of the time it's no-op
flushSync(() => {});
// flush all incoming updates immediately, so that they couldn't be batched with other updates, having different `storeAction`
flushSync(() => {
const nextElements = syncInvalidIndices(sceneData.elements ?? []); const nextElements = syncInvalidIndices(sceneData.elements ?? []);
if (sceneData.storeAction && sceneData.storeAction !== StoreAction.NONE) { if (sceneData.snapshotAction) {
const prevCommittedAppState = this.store.snapshot.appState; const prevCommittedAppState = this.store.snapshot.appState;
const prevCommittedElements = this.store.snapshot.elements; const prevCommittedElements = this.store.snapshot.elements;
@ -3893,19 +3894,8 @@ class App extends React.Component<AppProps, AppState> {
) )
: prevCommittedElements; : prevCommittedElements;
// WARN: store action always performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter this.store.scheduleAction(sceneData.snapshotAction);
// do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well this.store.commit(nextCommittedElements, nextCommittedAppState);
if (sceneData.storeAction === StoreAction.CAPTURE) {
this.store.captureIncrement(
nextCommittedElements,
nextCommittedAppState,
);
} else if (sceneData.storeAction === StoreAction.UPDATE) {
this.store.updateSnapshot(
nextCommittedElements,
nextCommittedAppState,
);
}
} }
if (sceneData.appState) { if (sceneData.appState) {
@ -3919,6 +3909,7 @@ class App extends React.Component<AppProps, AppState> {
if (sceneData.collaborators) { if (sceneData.collaborators) {
this.setState({ collaborators: sceneData.collaborators }); this.setState({ collaborators: sceneData.collaborators });
} }
});
}, },
); );
@ -4372,7 +4363,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement.elementId !== this.state.editingLinearElement.elementId !==
selectedElements[0].id selectedElements[0].id
) { ) {
this.store.shouldCaptureIncrement(); this.store.scheduleCapture();
if (!isElbowArrow(selectedElement)) { if (!isElbowArrow(selectedElement)) {
this.setState({ this.setState({
editingLinearElement: new LinearElementEditor( editingLinearElement: new LinearElementEditor(
@ -4580,7 +4571,7 @@ class App extends React.Component<AppProps, AppState> {
if (!event.altKey) { if (!event.altKey) {
if (this.flowChartNavigator.isExploring) { if (this.flowChartNavigator.isExploring) {
this.flowChartNavigator.clear(); this.flowChartNavigator.clear();
this.syncActionResult({ storeAction: StoreAction.CAPTURE }); this.syncActionResult({ storeAction: SnapshotAction.CAPTURE });
} }
} }
@ -4627,7 +4618,7 @@ class App extends React.Component<AppProps, AppState> {
} }
this.flowChartCreator.clear(); this.flowChartCreator.clear();
this.syncActionResult({ storeAction: StoreAction.CAPTURE }); this.syncActionResult({ storeAction: SnapshotAction.CAPTURE });
} }
} }
}); });
@ -4691,7 +4682,7 @@ class App extends React.Component<AppProps, AppState> {
} as const; } as const;
if (nextActiveTool.type === "freedraw") { if (nextActiveTool.type === "freedraw") {
this.store.shouldCaptureIncrement(); this.store.scheduleCapture();
} }
if (nextActiveTool.type !== "selection") { if (nextActiveTool.type !== "selection") {
@ -4894,7 +4885,7 @@ class App extends React.Component<AppProps, AppState> {
]); ]);
} }
if (!isDeleted || isExistingElement) { if (!isDeleted || isExistingElement) {
this.store.shouldCaptureIncrement(); this.store.scheduleCapture();
} }
flushSync(() => { flushSync(() => {
@ -5304,7 +5295,7 @@ class App extends React.Component<AppProps, AppState> {
}; };
private startImageCropping = (image: ExcalidrawImageElement) => { private startImageCropping = (image: ExcalidrawImageElement) => {
this.store.shouldCaptureIncrement(); this.store.scheduleCapture();
this.setState({ this.setState({
croppingElementId: image.id, croppingElementId: image.id,
}); });
@ -5312,7 +5303,7 @@ class App extends React.Component<AppProps, AppState> {
private finishImageCropping = () => { private finishImageCropping = () => {
if (this.state.croppingElementId) { if (this.state.croppingElementId) {
this.store.shouldCaptureIncrement(); this.store.scheduleCapture();
this.setState({ this.setState({
croppingElementId: null, croppingElementId: null,
}); });
@ -5347,7 +5338,7 @@ class App extends React.Component<AppProps, AppState> {
selectedElements[0].id) && selectedElements[0].id) &&
!isElbowArrow(selectedElements[0]) !isElbowArrow(selectedElements[0])
) { ) {
this.store.shouldCaptureIncrement(); this.store.scheduleCapture();
this.setState({ this.setState({
editingLinearElement: new LinearElementEditor(selectedElements[0]), editingLinearElement: new LinearElementEditor(selectedElements[0]),
}); });
@ -5430,7 +5421,7 @@ class App extends React.Component<AppProps, AppState> {
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds); getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
if (selectedGroupId) { if (selectedGroupId) {
this.store.shouldCaptureIncrement(); this.store.scheduleCapture();
this.setState((prevState) => ({ this.setState((prevState) => ({
...prevState, ...prevState,
...selectGroupsForSelectedElements( ...selectGroupsForSelectedElements(
@ -6356,10 +6347,10 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
), ),
}, },
storeAction: snapshotAction:
this.state.openDialog?.name === "elementLinkSelector" this.state.openDialog?.name === "elementLinkSelector"
? StoreAction.NONE ? SnapshotAction.NONE
: StoreAction.UPDATE, : SnapshotAction.UPDATE,
}); });
return; return;
} }
@ -8913,7 +8904,7 @@ class App extends React.Component<AppProps, AppState> {
if (isLinearElement(newElement)) { if (isLinearElement(newElement)) {
if (newElement!.points.length > 1) { if (newElement!.points.length > 1) {
this.store.shouldCaptureIncrement(); this.store.scheduleCapture();
} }
const pointerCoords = viewportCoordsToSceneCoords( const pointerCoords = viewportCoordsToSceneCoords(
childEvent, childEvent,
@ -9011,7 +9002,7 @@ class App extends React.Component<AppProps, AppState> {
appState: { appState: {
newElement: null, newElement: null,
}, },
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
return; return;
@ -9172,7 +9163,7 @@ class App extends React.Component<AppProps, AppState> {
} }
if (resizingElement) { if (resizingElement) {
this.store.shouldCaptureIncrement(); this.store.scheduleCapture();
} }
if (resizingElement && isInvisiblySmallElement(resizingElement)) { if (resizingElement && isInvisiblySmallElement(resizingElement)) {
@ -9181,7 +9172,7 @@ class App extends React.Component<AppProps, AppState> {
elements: this.scene elements: this.scene
.getElementsIncludingDeleted() .getElementsIncludingDeleted()
.filter((el) => el.id !== resizingElement.id), .filter((el) => el.id !== resizingElement.id),
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
} }
@ -9501,7 +9492,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedElementIds, this.state.selectedElementIds,
) )
) { ) {
this.store.shouldCaptureIncrement(); this.store.scheduleCapture();
} }
if ( if (
@ -9590,7 +9581,7 @@ class App extends React.Component<AppProps, AppState> {
this.elementsPendingErasure = new Set(); this.elementsPendingErasure = new Set();
if (didChange) { if (didChange) {
this.store.shouldCaptureIncrement(); this.store.scheduleCapture();
this.scene.replaceAllElements(elements); this.scene.replaceAllElements(elements);
} }
}; };
@ -10146,7 +10137,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: false, isLoading: false,
}, },
replaceFiles: true, replaceFiles: true,
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}); });
return; return;
} catch (error: any) { } catch (error: any) {
@ -10263,9 +10254,9 @@ class App extends React.Component<AppProps, AppState> {
if (ret.type === MIME_TYPES.excalidraw) { if (ret.type === MIME_TYPES.excalidraw) {
// restore the fractional indices by mutating elements // restore the fractional indices by mutating elements
syncInvalidIndices(elements.concat(ret.data.elements)); syncInvalidIndices(elements.concat(ret.data.elements));
// update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo // update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo
this.store.updateSnapshot(arrayToMap(elements), this.state); this.store.scheduleAction(SnapshotAction.UPDATE);
this.store.commit(arrayToMap(elements), this.state);
this.setState({ isLoading: true }); this.setState({ isLoading: true });
this.syncActionResult({ this.syncActionResult({
@ -10275,7 +10266,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: false, isLoading: false,
}, },
replaceFiles: true, replaceFiles: true,
storeAction: StoreAction.CAPTURE, storeAction: SnapshotAction.CAPTURE,
}); });
} else if (ret.type === MIME_TYPES.excalidrawlib) { } else if (ret.type === MIME_TYPES.excalidrawlib) {
await this.library await this.library

View file

@ -8,7 +8,7 @@ import { useApp } from "../App";
import { InlineIcon } from "../InlineIcon"; import { InlineIcon } from "../InlineIcon";
import type { StatsInputProperty } from "./utils"; import type { StatsInputProperty } from "./utils";
import { SMALLEST_DELTA } from "./utils"; import { SMALLEST_DELTA } from "./utils";
import { StoreAction } from "../../store"; import { SnapshotAction } from "../../store";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import "./DragInput.scss"; import "./DragInput.scss";
@ -132,7 +132,7 @@ const StatsDragInput = <
originalAppState: appState, originalAppState: appState,
setInputValue: (value) => setInputValue(String(value)), setInputValue: (value) => setInputValue(String(value)),
}); });
app.syncActionResult({ storeAction: StoreAction.CAPTURE }); app.syncActionResult({ storeAction: SnapshotAction.CAPTURE });
} }
}; };
@ -276,7 +276,7 @@ const StatsDragInput = <
false, false,
); );
app.syncActionResult({ storeAction: StoreAction.CAPTURE }); app.syncActionResult({ storeAction: SnapshotAction.CAPTURE });
lastPointer = null; lastPointer = null;
accumulatedChange = 0; accumulatedChange = 0;

View file

@ -57,7 +57,7 @@ import {
* *
* Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load. * Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load.
*/ */
class Delta<T> { export class Delta<T> {
private constructor( private constructor(
public readonly deleted: Partial<T>, public readonly deleted: Partial<T>,
public readonly inserted: Partial<T>, public readonly inserted: Partial<T>,
@ -369,7 +369,8 @@ class Delta<T> {
); );
} }
// TODO: order the keys based on the most common ones to change (i.e. x/y, width/height, isDeleted, etc.) // CFDO: order the keys based on the most common ones to change
// (i.e. x/y, width/height, isDeleted, etc.) for quick exit
for (const key of keys) { for (const key of keys) {
const object1Value = object1[key as keyof T]; const object1Value = object1[key as keyof T];
const object2Value = object2[key as keyof T]; const object2Value = object2[key as keyof T];
@ -393,58 +394,56 @@ class Delta<T> {
} }
/** /**
* Encapsulates the modifications captured as `Delta`/s. * Encapsulates a set of application-level `Delta`s.
*/ */
interface Change<T> { interface DeltaContainer<T> {
/** /**
* Inverses the `Delta`s inside while creating a new `Change`. * Inverses the `Delta`s while creating a new `DeltaContainer` instance.
*/ */
inverse(): Change<T>; inverse(): DeltaContainer<T>;
/** /**
* Applies the `Change` to the previous object. * Applies the `Delta`s to the previous object.
* *
* @returns a tuple of the next object `T` with applied change, and `boolean`, indicating whether the applied change resulted in a visible change. * @returns a tuple of the next object `T` with applied `Delta`s, and `boolean`, indicating whether the applied deltas resulted in a visible change.
*/ */
applyTo(previous: T, ...options: unknown[]): [T, boolean]; applyTo(previous: T, ...options: unknown[]): [T, boolean];
/** /**
* Checks whether there are actually `Delta`s. * Checks whether all `Delta`s are empty.
*/ */
isEmpty(): boolean; isEmpty(): boolean;
} }
export class AppStateChange implements Change<AppState> { export class AppStateDelta implements DeltaContainer<AppState> {
private constructor(public readonly delta: Delta<ObservedAppState>) {} private constructor(public readonly delta: Delta<ObservedAppState>) {}
public static calculate<T extends ObservedAppState>( public static calculate<T extends ObservedAppState>(
prevAppState: T, prevAppState: T,
nextAppState: T, nextAppState: T,
): AppStateChange { ): AppStateDelta {
const delta = Delta.calculate( const delta = Delta.calculate(
prevAppState, prevAppState,
nextAppState, nextAppState,
undefined, undefined,
AppStateChange.postProcess, AppStateDelta.postProcess,
); );
return new AppStateChange(delta); return new AppStateDelta(delta);
} }
public static restore( public static restore(appStateDeltaDTO: DTO<AppStateDelta>): AppStateDelta {
appStateChangeDTO: DTO<AppStateChange>, const { delta } = appStateDeltaDTO;
): AppStateChange { return new AppStateDelta(delta);
const { delta } = appStateChangeDTO;
return new AppStateChange(delta);
} }
public static empty() { public static empty() {
return new AppStateChange(Delta.create({}, {})); return new AppStateDelta(Delta.create({}, {}));
} }
public inverse(): AppStateChange { public inverse(): AppStateDelta {
const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted); const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted);
return new AppStateChange(inversedDelta); return new AppStateDelta(inversedDelta);
} }
public applyTo( public applyTo(
@ -519,7 +518,7 @@ export class AppStateChange implements Change<AppState> {
return [nextAppState, constainsVisibleChanges]; return [nextAppState, constainsVisibleChanges];
} catch (e) { } catch (e) {
// shouldn't really happen, but just in case // shouldn't really happen, but just in case
console.error(`Couldn't apply appstate change`, e); console.error(`Couldn't apply appstate delta`, e);
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
throw e; throw e;
@ -583,13 +582,13 @@ export class AppStateChange implements Change<AppState> {
const nextObservedAppState = getObservedAppState(nextAppState); const nextObservedAppState = getObservedAppState(nextAppState);
const containsStandaloneDifference = Delta.isRightDifferent( const containsStandaloneDifference = Delta.isRightDifferent(
AppStateChange.stripElementsProps(prevObservedAppState), AppStateDelta.stripElementsProps(prevObservedAppState),
AppStateChange.stripElementsProps(nextObservedAppState), AppStateDelta.stripElementsProps(nextObservedAppState),
); );
const containsElementsDifference = Delta.isRightDifferent( const containsElementsDifference = Delta.isRightDifferent(
AppStateChange.stripStandaloneProps(prevObservedAppState), AppStateDelta.stripStandaloneProps(prevObservedAppState),
AppStateChange.stripStandaloneProps(nextObservedAppState), AppStateDelta.stripStandaloneProps(nextObservedAppState),
); );
if (!containsStandaloneDifference && !containsElementsDifference) { if (!containsStandaloneDifference && !containsElementsDifference) {
@ -604,8 +603,8 @@ export class AppStateChange implements Change<AppState> {
if (containsElementsDifference) { if (containsElementsDifference) {
// filter invisible changes on each iteration // filter invisible changes on each iteration
const changedElementsProps = Delta.getRightDifferences( const changedElementsProps = Delta.getRightDifferences(
AppStateChange.stripStandaloneProps(prevObservedAppState), AppStateDelta.stripStandaloneProps(prevObservedAppState),
AppStateChange.stripStandaloneProps(nextObservedAppState), AppStateDelta.stripStandaloneProps(nextObservedAppState),
) as Array<keyof ObservedElementsAppState>; ) as Array<keyof ObservedElementsAppState>;
let nonDeletedGroupIds = new Set<string>(); let nonDeletedGroupIds = new Set<string>();
@ -622,7 +621,7 @@ export class AppStateChange implements Change<AppState> {
for (const key of changedElementsProps) { for (const key of changedElementsProps) {
switch (key) { switch (key) {
case "selectedElementIds": case "selectedElementIds":
nextAppState[key] = AppStateChange.filterSelectedElements( nextAppState[key] = AppStateDelta.filterSelectedElements(
nextAppState[key], nextAppState[key],
nextElements, nextElements,
visibleDifferenceFlag, visibleDifferenceFlag,
@ -630,7 +629,7 @@ export class AppStateChange implements Change<AppState> {
break; break;
case "selectedGroupIds": case "selectedGroupIds":
nextAppState[key] = AppStateChange.filterSelectedGroups( nextAppState[key] = AppStateDelta.filterSelectedGroups(
nextAppState[key], nextAppState[key],
nonDeletedGroupIds, nonDeletedGroupIds,
visibleDifferenceFlag, visibleDifferenceFlag,
@ -666,7 +665,7 @@ export class AppStateChange implements Change<AppState> {
break; break;
case "selectedLinearElementId": case "selectedLinearElementId":
case "editingLinearElementId": case "editingLinearElementId":
const appStateKey = AppStateChange.convertToAppStateKey(key); const appStateKey = AppStateDelta.convertToAppStateKey(key);
const linearElement = nextAppState[appStateKey]; const linearElement = nextAppState[appStateKey];
if (!linearElement) { if (!linearElement) {
@ -803,19 +802,15 @@ export class AppStateChange implements Change<AppState> {
} }
} }
// CFDO: consider adding here (nonnullable) version & versionNonce & updated & seed (so that we have correct versions when recunstructing from remote) // CFDO: consider adding here (nonnullable) version & versionNonce & updated (so that we have correct versions when recunstructing from remote)
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> =
ElementUpdate<Ordered<T>>; ElementUpdate<Ordered<T>>;
type ElementsChangeOptions = {
shouldRedistribute: boolean;
};
/** /**
* Elements change is a low level primitive to capture a change between two sets of elements. * Elements delta is a low level primitive to encapsulate property changes between two sets of elements.
* It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions. * It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions.
*/ */
export class ElementsChange implements Change<SceneElementsMap> { export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private constructor( private constructor(
public readonly added: Record<string, Delta<ElementPartial>>, public readonly added: Record<string, Delta<ElementPartial>>,
public readonly removed: Record<string, Delta<ElementPartial>>, public readonly removed: Record<string, Delta<ElementPartial>>,
@ -826,12 +821,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
added: Record<string, Delta<ElementPartial>>, added: Record<string, Delta<ElementPartial>>,
removed: Record<string, Delta<ElementPartial>>, removed: Record<string, Delta<ElementPartial>>,
updated: Record<string, Delta<ElementPartial>>, updated: Record<string, Delta<ElementPartial>>,
options: ElementsChangeOptions = { options: {
shouldRedistribute: boolean;
} = {
shouldRedistribute: false, shouldRedistribute: false,
}, },
) { ) {
const { shouldRedistribute } = options; const { shouldRedistribute } = options;
let change: ElementsChange; let delta: ElementsDelta;
if (shouldRedistribute) { if (shouldRedistribute) {
const nextAdded: Record<string, Delta<ElementPartial>> = {}; const nextAdded: Record<string, Delta<ElementPartial>> = {};
@ -854,25 +851,23 @@ export class ElementsChange implements Change<SceneElementsMap> {
} }
} }
change = new ElementsChange(nextAdded, nextRemoved, nextUpdated); delta = new ElementsDelta(nextAdded, nextRemoved, nextUpdated);
} else { } else {
change = new ElementsChange(added, removed, updated); delta = new ElementsDelta(added, removed, updated);
} }
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
ElementsChange.validate(change, "added", this.satisfiesAddition); ElementsDelta.validate(delta, "added", this.satisfiesAddition);
ElementsChange.validate(change, "removed", this.satisfiesRemoval); ElementsDelta.validate(delta, "removed", this.satisfiesRemoval);
ElementsChange.validate(change, "updated", this.satisfiesUpdate); ElementsDelta.validate(delta, "updated", this.satisfiesUpdate);
} }
return change; return delta;
} }
public static restore( public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta {
elementsChangeDTO: DTO<ElementsChange>, const { added, removed, updated } = elementsDeltaDTO;
): ElementsChange { return ElementsDelta.create(added, removed, updated);
const { added, removed, updated } = elementsChangeDTO;
return ElementsChange.create(added, removed, updated);
} }
private static satisfiesAddition = ({ private static satisfiesAddition = ({
@ -894,17 +889,17 @@ export class ElementsChange implements Change<SceneElementsMap> {
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted; }: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
private static validate( private static validate(
change: ElementsChange, elementsDelta: ElementsDelta,
type: "added" | "removed" | "updated", type: "added" | "removed" | "updated",
satifies: (delta: Delta<ElementPartial>) => boolean, satifies: (delta: Delta<ElementPartial>) => boolean,
) { ) {
for (const [id, delta] of Object.entries(change[type])) { for (const [id, delta] of Object.entries(elementsDelta[type])) {
if (!satifies(delta)) { if (!satifies(delta)) {
console.error( console.error(
`Broken invariant for "${type}" delta, element "${id}", delta:`, `Broken invariant for "${type}" delta, element "${id}", delta:`,
delta, delta,
); );
throw new Error(`ElementsChange invariant broken for element "${id}".`); throw new Error(`ElementsDelta invariant broken for element "${id}".`);
} }
} }
} }
@ -915,14 +910,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
* @param prevElements - Map representing the previous state of elements. * @param prevElements - Map representing the previous state of elements.
* @param nextElements - Map representing the next state of elements. * @param nextElements - Map representing the next state of elements.
* *
* @returns `ElementsChange` instance representing the `Delta` changes between the two sets of elements. * @returns `ElementsDelta` instance representing the `Delta` changes between the two sets of elements.
*/ */
public static calculate<T extends OrderedExcalidrawElement>( public static calculate<T extends OrderedExcalidrawElement>(
prevElements: Map<string, T>, prevElements: Map<string, T>,
nextElements: Map<string, T>, nextElements: Map<string, T>,
): ElementsChange { ): ElementsDelta {
if (prevElements === nextElements) { if (prevElements === nextElements) {
return ElementsChange.empty(); return ElementsDelta.empty();
} }
const added: Record<string, Delta<ElementPartial>> = {}; const added: Record<string, Delta<ElementPartial>> = {};
@ -940,7 +935,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
const delta = Delta.create( const delta = Delta.create(
deleted, deleted,
inserted, inserted,
ElementsChange.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
removed[prevElement.id] = delta; removed[prevElement.id] = delta;
@ -960,7 +955,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
const delta = Delta.create( const delta = Delta.create(
deleted, deleted,
inserted, inserted,
ElementsChange.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
added[nextElement.id] = delta; added[nextElement.id] = delta;
@ -972,8 +967,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
const delta = Delta.calculate<ElementPartial>( const delta = Delta.calculate<ElementPartial>(
prevElement, prevElement,
nextElement, nextElement,
ElementsChange.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
ElementsChange.postProcess, ElementsDelta.postProcess,
); );
if ( if (
@ -999,14 +994,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
} }
} }
return ElementsChange.create(added, removed, updated); return ElementsDelta.create(added, removed, updated);
} }
public static empty() { public static empty() {
return ElementsChange.create({}, {}, {}); return ElementsDelta.create({}, {}, {});
} }
public inverse(): ElementsChange { public inverse(): ElementsDelta {
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => { const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
const inversedDeltas: Record<string, Delta<ElementPartial>> = {}; const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
@ -1023,7 +1018,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
// notice we inverse removed with added not to break the invariants // notice we inverse removed with added not to break the invariants
// notice we force generate a new id // notice we force generate a new id
return ElementsChange.create(removed, added, updated); return ElementsDelta.create(removed, added, updated);
} }
public isEmpty(): boolean { public isEmpty(): boolean {
@ -1041,7 +1036,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated * @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
* @returns new instance with modified delta/s * @returns new instance with modified delta/s
*/ */
public applyLatestChanges(elements: SceneElementsMap): ElementsChange { public applyLatestChanges(elements: SceneElementsMap): ElementsDelta {
const modifier = const modifier =
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => { (element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
const latestPartial: { [key: string]: unknown } = {}; const latestPartial: { [key: string]: unknown } = {};
@ -1090,7 +1085,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
const removed = applyLatestChangesInternal(this.removed); const removed = applyLatestChangesInternal(this.removed);
const updated = applyLatestChangesInternal(this.updated); const updated = applyLatestChangesInternal(this.updated);
return ElementsChange.create(added, removed, updated, { return ElementsDelta.create(added, removed, updated, {
shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
}); });
} }
@ -1109,7 +1104,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation) // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
try { try {
const applyDeltas = ElementsChange.createApplier( const applyDeltas = ElementsDelta.createApplier(
nextElements, nextElements,
snapshot, snapshot,
flags, flags,
@ -1129,7 +1124,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
...affectedElements, ...affectedElements,
]); ]);
} catch (e) { } catch (e) {
console.error(`Couldn't apply elements change`, e); console.error(`Couldn't apply elements delta`, e);
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
throw e; throw e;
@ -1144,18 +1139,18 @@ export class ElementsChange implements Change<SceneElementsMap> {
try { try {
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements); ElementsDelta.redrawTextBoundingBoxes(nextElements, changedElements);
// the following reorder performs also mutations, but only on new instances of changed elements // the following reorder performs also mutations, but only on new instances of changed elements
// (unless something goes really bad and it fallbacks to fixing all invalid indices) // (unless something goes really bad and it fallbacks to fixing all invalid indices)
nextElements = ElementsChange.reorderElements( nextElements = ElementsDelta.reorderElements(
nextElements, nextElements,
changedElements, changedElements,
flags, flags,
); );
// Need ordered nextElements to avoid z-index binding issues // Need ordered nextElements to avoid z-index binding issues
ElementsChange.redrawBoundArrows(nextElements, changedElements); ElementsDelta.redrawBoundArrows(nextElements, changedElements);
} catch (e) { } catch (e) {
console.error( console.error(
`Couldn't mutate elements after applying elements change`, `Couldn't mutate elements after applying elements change`,
@ -1183,7 +1178,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
type: "added" | "removed" | "updated", type: "added" | "removed" | "updated",
deltas: Record<string, Delta<ElementPartial>>, deltas: Record<string, Delta<ElementPartial>>,
) => { ) => {
const getElement = ElementsChange.createGetter( const getElement = ElementsDelta.createGetter(
type, type,
nextElements, nextElements,
snapshot, snapshot,
@ -1194,7 +1189,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
const element = getElement(id, delta.inserted); const element = getElement(id, delta.inserted);
if (element) { if (element) {
const newElement = ElementsChange.applyDelta(element, delta, flags); const newElement = ElementsDelta.applyDelta(element, delta, flags);
nextElements.set(newElement.id, newElement); nextElements.set(newElement.id, newElement);
acc.set(newElement.id, newElement); acc.set(newElement.id, newElement);
} }
@ -1276,6 +1271,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
}); });
} }
// CFDO II: this looks wrong
if (isImageElement(element)) { if (isImageElement(element)) {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>; const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset // we want to override `crop` only if modified so that we don't reset
@ -1291,8 +1287,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
if (!flags.containsVisibleDifference) { if (!flags.containsVisibleDifference) {
// strip away fractional as even if it would be different, it doesn't have to result in visible change // strip away fractional as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial; const { index, ...rest } = directlyApplicablePartial;
const containsVisibleDifference = const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
ElementsChange.checkForVisibleDifference(element, rest); element,
rest,
);
flags.containsVisibleDifference = containsVisibleDifference; flags.containsVisibleDifference = containsVisibleDifference;
} }
@ -1335,6 +1333,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
* Resolves conflicts for all previously added, removed and updated elements. * Resolves conflicts for all previously added, removed and updated elements.
* Updates the previous deltas with all the changes after conflict resolution. * Updates the previous deltas with all the changes after conflict resolution.
* *
* // CFDO: revisit since arrow seem often redrawn incorrectly
*
* @returns all elements affected by the conflict resolution * @returns all elements affected by the conflict resolution
*/ */
private resolveConflicts( private resolveConflicts(
@ -1373,12 +1373,12 @@ export class ElementsChange implements Change<SceneElementsMap> {
// removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound // removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
for (const id of Object.keys(this.removed)) { for (const id of Object.keys(this.removed)) {
ElementsChange.unbindAffected(prevElements, nextElements, id, updater); ElementsDelta.unbindAffected(prevElements, nextElements, id, updater);
} }
// added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound // added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound
for (const id of Object.keys(this.added)) { for (const id of Object.keys(this.added)) {
ElementsChange.rebindAffected(prevElements, nextElements, id, updater); ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
} }
// updated delta is affecting the binding only in case it contains changed binding or bindable property // updated delta is affecting the binding only in case it contains changed binding or bindable property
@ -1394,7 +1394,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
continue; continue;
} }
ElementsChange.rebindAffected(prevElements, nextElements, id, updater); ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
} }
// filter only previous elements, which were now affected // filter only previous elements, which were now affected
@ -1404,7 +1404,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
// calculate complete deltas for affected elements, and assign them back to all the deltas // calculate complete deltas for affected elements, and assign them back to all the deltas
// technically we could do better here if perf. would become an issue // technically we could do better here if perf. would become an issue
const { added, removed, updated } = ElementsChange.calculate( const { added, removed, updated } = ElementsDelta.calculate(
prevAffectedElements, prevAffectedElements,
nextAffectedElements, nextAffectedElements,
); );
@ -1590,7 +1590,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id); Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
} catch (e) { } catch (e) {
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it // if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess elements change deltas.`); console.error(`Couldn't postprocess elements delta.`);
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
throw e; throw e;

View file

@ -16,7 +16,7 @@ import type {
IframeData, IframeData,
} from "./types"; } from "./types";
import type { MarkRequired } from "../utility-types"; import type { MarkRequired } from "../utility-types";
import { StoreAction } from "../store"; import { SnapshotAction } from "../store";
type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">; type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
@ -344,7 +344,7 @@ export const actionSetEmbeddableAsActiveTool = register({
type: "embeddable", type: "embeddable",
}), }),
}, },
storeAction: StoreAction.NONE, storeAction: SnapshotAction.NONE,
}; };
}, },
}); });

View file

@ -775,7 +775,7 @@ export class LinearElementEditor {
}); });
ret.didAddPoint = true; ret.didAddPoint = true;
} }
store.shouldCaptureIncrement(); store.scheduleCapture();
ret.linearElementEditor = { ret.linearElementEditor = {
...linearElementEditor, ...linearElementEditor,
pointerDownState: { pointerDownState: {

View file

@ -1,11 +1,9 @@
import { Emitter } from "./emitter"; import { Emitter } from "./emitter";
import { type Store, StoreDelta, StoreIncrement } from "./store";
import type { SceneElementsMap } from "./element/types"; import type { SceneElementsMap } from "./element/types";
import type { Store, StoreIncrement } from "./store";
import type { AppState } from "./types"; import type { AppState } from "./types";
type HistoryEntry = StoreIncrement & { export class HistoryEntry extends StoreDelta {}
skipRecording?: true;
};
type HistoryStack = HistoryEntry[]; type HistoryStack = HistoryEntry[];
@ -42,12 +40,18 @@ export class History {
/** /**
* Record a local change which will go into the history * Record a local change which will go into the history
*/ */
public record(entry: HistoryEntry) { public record(increment: StoreIncrement) {
if (!entry.skipRecording && !entry.isEmpty()) { if (
// we have the latest changes, no need to `applyLatest`, which is done within `History.push` StoreIncrement.isDurable(increment) &&
this.undoStack.push(entry.inverse()); !increment.delta.isEmpty() &&
!(increment.delta instanceof HistoryEntry)
) {
// construct history entry, so once it's emitted, it's not recorded again
const entry = HistoryEntry.inverse(increment.delta);
if (!entry.elementsChange.isEmpty()) { this.undoStack.push(entry);
if (!entry.elements.isEmpty()) {
// don't reset redo stack on local appState changes, // don't reset redo stack on local appState changes,
// as a simple click (unselect) could lead to losing all the redo entries // as a simple click (unselect) could lead to losing all the redo entries
// only reset on non empty elements changes! // only reset on non empty elements changes!
@ -91,6 +95,7 @@ export class History {
return; return;
} }
let prevSnapshot = this.store.snapshot;
let nextElements = elements; let nextElements = elements;
let nextAppState = appState; let nextAppState = appState;
let containsVisibleChange = false; let containsVisibleChange = false;
@ -98,17 +103,18 @@ export class History {
// iterate through the history entries in case they result in no visible changes // iterate through the history entries in case they result in no visible changes
while (historyEntry) { while (historyEntry) {
try { try {
// skip re-recording the history entry, as it gets emitted and is manually pushed to the undo / redo stack
Object.assign(historyEntry, { skipRecording: true });
// CFDO: consider encapsulating element & appState update inside applyIncrement
[nextElements, nextAppState, containsVisibleChange] = [nextElements, nextAppState, containsVisibleChange] =
this.store.applyIncrementTo( this.store.applyDeltaTo(historyEntry, nextElements, nextAppState, {
historyEntry, triggerIncrement: true,
nextElements, });
nextAppState,
); prevSnapshot = this.store.snapshot;
} catch (e) {
console.error("Failed to apply history entry:", e);
// rollback to the previous snapshot, so that we don't end up in an incosistent state
this.store.snapshot = prevSnapshot;
} finally { } finally {
// make sure to always push, even if the increment is corrupted // make sure to always push, even if the delta is corrupted
push(historyEntry); push(historyEntry);
} }
@ -152,7 +158,7 @@ export class History {
entry: HistoryEntry, entry: HistoryEntry,
prevElements: SceneElementsMap, prevElements: SceneElementsMap,
) { ) {
const updatedEntry = entry.applyLatestChanges(prevElements); const updatedEntry = HistoryEntry.applyLatestChanges(entry, prevElements);
return stack.push(updatedEntry); return stack.push(updatedEntry);
} }
} }

View file

@ -259,7 +259,7 @@ export {
bumpVersion, bumpVersion,
} from "./element/mutateElement"; } from "./element/mutateElement";
export { StoreAction } from "./store"; export { SnapshotAction } from "./store";
export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library"; export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library";

View file

@ -1,9 +1,8 @@
import { ENV } from "./constants"; import { ENV } from "./constants";
import { Emitter } from "./emitter"; import { Emitter } from "./emitter";
import { randomId } from "./random"; import { randomId } from "./random";
import { isShallowEqual } from "./utils";
import { getDefaultAppState } from "./appState"; import { getDefaultAppState } from "./appState";
import { AppStateChange, ElementsChange } from "./change"; import { AppStateDelta, Delta, ElementsDelta } from "./delta";
import { newElementWith } from "./element/mutateElement"; import { newElementWith } from "./element/mutateElement";
import { deepCopyElement } from "./element/newElement"; import { deepCopyElement } from "./element/newElement";
import type { AppState, ObservedAppState } from "./types"; import type { AppState, ObservedAppState } from "./types";
@ -12,6 +11,8 @@ import type {
OrderedExcalidrawElement, OrderedExcalidrawElement,
SceneElementsMap, SceneElementsMap,
} from "./element/types"; } from "./element/types";
import { hashElementsVersion } from "./element";
import { assertNever } from "./utils";
// hidden non-enumerable property for runtime checks // hidden non-enumerable property for runtime checks
const hiddenObservedAppStateProp = "__observedAppState"; const hiddenObservedAppStateProp = "__observedAppState";
@ -41,48 +42,52 @@ const isObservedAppState = (
): appState is ObservedAppState => ): appState is ObservedAppState =>
!!Reflect.get(appState, hiddenObservedAppStateProp); !!Reflect.get(appState, hiddenObservedAppStateProp);
export const StoreAction = { // CFDO: consider adding a "remote" action, which should perform update but never be emitted (so that it we don't have to filter it when pushing it into sync api)
export const SnapshotAction = {
/** /**
* Immediately undoable. * Immediately undoable.
* *
* Use for updates which should be captured. * Use for updates which should be captured as durable deltas.
* Should be used for most of the local updates. * Should be used for most of the local updates (except ephemerals such as dragging or resizing).
* *
* These updates will _immediately_ make it to the local undo / redo stacks. * These updates will _immediately_ make it to the local undo / redo stacks.
*/ */
CAPTURE: "capture", CAPTURE: "CAPTURE_DELTA",
/** /**
* Never undoable. * Never undoable.
* *
* Use for updates which should never be recorded, such as remote updates * Use for updates which should never be captured as deltas, such as remote updates
* or scene initialization. * or scene initialization.
* *
* These updates will _never_ make it to the local undo / redo stacks. * These updates will _never_ make it to the local undo / redo stacks.
*/ */
UPDATE: "update", UPDATE: "UPDATE_SNAPSHOT",
/** /**
* Eventually undoable. * Eventually undoable.
* *
* Use for updates which should not be captured immediately - likely * Use for updates which should not be captured as deltas immediately, such as
* exceptions which are part of some async multi-step process. Otherwise, all * exceptions which are part of some async multi-step proces.
* such updates would end up being captured with the next *
* `StoreAction.CAPTURE` - triggered either by the next `updateScene` * These updates will be captured with the next `SnapshotAction.CAPTURE`,
* or internally by the editor. * triggered either by the next `updateScene` or internally by the editor.
* *
* These updates will _eventually_ make it to the local undo / redo stacks. * These updates will _eventually_ make it to the local undo / redo stacks.
*/ */
NONE: "none", // CFDO I: none is not really "none" anymore, as it at very least emits an ephemeral increment
// we should likely rename these somehow and keep "none" only for real "no action" cases
NONE: "NONE",
} as const; } as const;
export type StoreActionType = ValueOf<typeof StoreAction>; export type SnapshotActionType = ValueOf<typeof SnapshotAction>;
/** /**
* Store which captures the observed changes and emits them as `StoreIncrement` events. * Store which captures the observed changes and emits them as `StoreIncrement` events.
*/ */
export class Store { export class Store {
public readonly onStoreIncrementEmitter = new Emitter<[StoreIncrement]>(); public readonly onStoreIncrementEmitter = new Emitter<
[DurableStoreIncrement | EphemeralStoreIncrement]
>();
private scheduledActions: Set<StoreActionType> = new Set();
private _snapshot = StoreSnapshot.empty(); private _snapshot = StoreSnapshot.empty();
public get snapshot() { public get snapshot() {
@ -93,41 +98,61 @@ export class Store {
this._snapshot = snapshot; this._snapshot = snapshot;
} }
/** private scheduledActions: Set<SnapshotActionType> = new Set();
* Use to schedule calculation of a store increment.
*/
// TODO: Suspicious that this is called so many places. Seems error-prone.
public shouldCaptureIncrement() {
this.scheduleAction(StoreAction.CAPTURE);
}
/** public scheduleAction(action: SnapshotActionType) {
* Use to schedule update of the snapshot, useful on updates for which we don't need to calculate increments (i.e. remote updates).
*/
public shouldUpdateSnapshot() {
this.scheduleAction(StoreAction.UPDATE);
}
private scheduleAction(action: StoreActionType) {
this.scheduledActions.add(action); this.scheduledActions.add(action);
this.satisfiesScheduledActionsInvariant(); this.satisfiesScheduledActionsInvariant();
} }
/** /**
* Based on the scheduled operation, either only updates store snapshot or also calculates increment and emits the result as a `StoreIncrement`. * Use to schedule a delta calculation, which will consquentially be emitted as `DurableStoreIncrement` and pushed in the undo stack.
*/
// TODO: Suspicious that this is called so many places. Seems error-prone.
public scheduleCapture() {
this.scheduleAction(SnapshotAction.CAPTURE);
}
private get scheduledAction() {
// Capture has a precedence over update, since it also performs snapshot update
if (this.scheduledActions.has(SnapshotAction.CAPTURE)) {
return SnapshotAction.CAPTURE;
}
// Update has a precedence over none, since it also emits an (ephemeral) increment
if (this.scheduledActions.has(SnapshotAction.UPDATE)) {
return SnapshotAction.UPDATE;
}
// Emit ephemeral increment, don't update the snapshot
return SnapshotAction.NONE;
}
/**
* Performs the incoming `SnapshotAction` and emits the corresponding `StoreIncrement`.
* Emits `DurableStoreIncrement` when action is "capture", emits `EphemeralStoreIncrement` otherwise.
* *
* @emits StoreIncrement when increment is calculated. * @emits StoreIncrement
*/ */
public commit( public commit(
elements: Map<string, OrderedExcalidrawElement> | undefined, elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined, appState: AppState | ObservedAppState | undefined,
): void { ): void {
try { try {
// Capture has precedence since it also performs update const { scheduledAction } = this;
if (this.scheduledActions.has(StoreAction.CAPTURE)) {
this.captureIncrement(elements, appState); switch (scheduledAction) {
} else if (this.scheduledActions.has(StoreAction.UPDATE)) { case SnapshotAction.CAPTURE:
this.updateSnapshot(elements, appState); this.snapshot = this.captureDurableIncrement(elements, appState);
break;
case SnapshotAction.UPDATE:
this.snapshot = this.emitEphemeralIncrement(elements);
break;
case SnapshotAction.NONE:
this.emitEphemeralIncrement(elements);
return;
default:
assertNever(scheduledAction, `Unknown store action`);
} }
} finally { } finally {
this.satisfiesScheduledActionsInvariant(); this.satisfiesScheduledActionsInvariant();
@ -137,11 +162,11 @@ export class Store {
} }
/** /**
* Performs diff calculation, calculates and emits the increment. * Performs delta calculation and emits the increment.
* *
* @emits StoreIncrement when increment is calculated. * @emits StoreIncrement.
*/ */
public captureIncrement( private captureDurableIncrement(
elements: Map<string, OrderedExcalidrawElement> | undefined, elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined, appState: AppState | ObservedAppState | undefined,
) { ) {
@ -149,41 +174,53 @@ export class Store {
const nextSnapshot = this.snapshot.maybeClone(elements, appState); const nextSnapshot = this.snapshot.maybeClone(elements, appState);
// Optimisation, don't continue if nothing has changed // Optimisation, don't continue if nothing has changed
if (prevSnapshot !== nextSnapshot) { if (prevSnapshot === nextSnapshot) {
// Calculate and record the changes based on the previous and next snapshot return prevSnapshot;
const elementsChange = nextSnapshot.metadata.didElementsChange }
? ElementsChange.calculate(prevSnapshot.elements, nextSnapshot.elements) // Calculate the deltas based on the previous and next snapshot
: ElementsChange.empty(); const elementsDelta = nextSnapshot.metadata.didElementsChange
? ElementsDelta.calculate(prevSnapshot.elements, nextSnapshot.elements)
: ElementsDelta.empty();
const appStateChange = nextSnapshot.metadata.didAppStateChange const appStateDelta = nextSnapshot.metadata.didAppStateChange
? AppStateChange.calculate(prevSnapshot.appState, nextSnapshot.appState) ? AppStateDelta.calculate(prevSnapshot.appState, nextSnapshot.appState)
: AppStateChange.empty(); : AppStateDelta.empty();
if (!elementsDelta.isEmpty() || !appStateDelta.isEmpty()) {
const delta = StoreDelta.create(elementsDelta, appStateDelta);
const change = StoreChange.create(prevSnapshot, nextSnapshot);
const increment = new DurableStoreIncrement(change, delta);
if (!elementsChange.isEmpty() || !appStateChange.isEmpty()) {
// Notify listeners with the increment // Notify listeners with the increment
this.onStoreIncrementEmitter.trigger( this.onStoreIncrementEmitter.trigger(increment);
StoreIncrement.create(elementsChange, appStateChange),
);
} }
// Update snapshot return nextSnapshot;
this.snapshot = nextSnapshot;
}
} }
/** /**
* Updates the snapshot without performing any diff calculation. * When change is detected, emits an ephemeral increment and returns the next snapshot.
*
* @emits EphemeralStoreIncrement
*/ */
public updateSnapshot( private emitEphemeralIncrement(
elements: Map<string, OrderedExcalidrawElement> | undefined, elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined,
) { ) {
const nextSnapshot = this.snapshot.maybeClone(elements, appState); const prevSnapshot = this.snapshot;
const nextSnapshot = this.snapshot.maybeClone(elements, undefined);
if (this.snapshot !== nextSnapshot) { if (prevSnapshot === nextSnapshot) {
// Update snapshot // nothing has changed
this.snapshot = nextSnapshot; return prevSnapshot;
} }
const change = StoreChange.create(prevSnapshot, nextSnapshot);
const increment = new EphemeralStoreIncrement(change);
// Notify listeners with the increment
this.onStoreIncrementEmitter.trigger(increment);
return nextSnapshot;
} }
/** /**
@ -210,7 +247,7 @@ export class Store {
// Detected yet uncomitted local element // Detected yet uncomitted local element
nextElements.delete(id); nextElements.delete(id);
} else if (elementSnapshot.version < prevElement.version) { } else if (elementSnapshot.version < prevElement.version) {
// Element was already commited, but the snapshot version is lower than current current local version // Element was already commited, but the snapshot version is lower than current local version
nextElements.set(id, elementSnapshot); nextElements.set(id, elementSnapshot);
} }
} }
@ -223,21 +260,37 @@ export class Store {
* *
* @emits StoreIncrement when increment is applied. * @emits StoreIncrement when increment is applied.
*/ */
public applyIncrementTo( public applyDeltaTo(
increment: StoreIncrement, delta: StoreDelta,
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
options: {
triggerIncrement: boolean;
} = {
triggerIncrement: false,
},
): [SceneElementsMap, AppState, boolean] { ): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
increment.elementsChange.applyTo(elements, this.snapshot.elements); elements,
this.snapshot.elements,
);
const [nextAppState, appStateContainsVisibleChange] = const [nextAppState, appStateContainsVisibleChange] =
increment.appStateChange.applyTo(appState, nextElements); delta.appState.applyTo(appState, nextElements);
const appliedVisibleChanges = const appliedVisibleChanges =
elementsContainVisibleChange || appStateContainsVisibleChange; elementsContainVisibleChange || appStateContainsVisibleChange;
const prevSnapshot = this.snapshot;
const nextSnapshot = this.snapshot.maybeClone(nextElements, nextAppState);
if (options.triggerIncrement) {
const change = StoreChange.create(prevSnapshot, nextSnapshot);
const increment = new DurableStoreIncrement(change, delta);
this.onStoreIncrementEmitter.trigger(increment); this.onStoreIncrementEmitter.trigger(increment);
}
this.snapshot = nextSnapshot;
return [nextElements, nextAppState, appliedVisibleChanges]; return [nextElements, nextAppState, appliedVisibleChanges];
} }
@ -251,7 +304,12 @@ export class Store {
} }
private satisfiesScheduledActionsInvariant() { private satisfiesScheduledActionsInvariant() {
if (!(this.scheduledActions.size >= 0 && this.scheduledActions.size <= 3)) { if (
!(
this.scheduledActions.size >= 0 &&
this.scheduledActions.size <= Object.keys(SnapshotAction).length
)
) {
const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledActions.size}".`; const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledActions.size}".`;
console.error(message, this.scheduledActions.values()); console.error(message, this.scheduledActions.values());
@ -263,90 +321,161 @@ export class Store {
} }
/** /**
* Represent an increment to the Store. * Repsents a change to the store containg changed elements and appState.
*/ */
export class StoreIncrement { export class StoreChange {
// CFDO: consider adding (observed & syncable) appState, though bare in mind that it's processed on every component update,
// so figuring out what has changed should ideally be just quick reference checks
private constructor( private constructor(
public readonly elements: Record<string, OrderedExcalidrawElement>,
) {}
public static create(
prevSnapshot: StoreSnapshot,
nextSnapshot: StoreSnapshot,
) {
const changedElements = nextSnapshot.getChangedElements(prevSnapshot);
return new StoreChange(changedElements);
}
}
/**
* Encpasulates any change to the store (durable or ephemeral).
*/
export abstract class StoreIncrement {
protected constructor(
public readonly type: "durable" | "ephemeral",
public readonly change: StoreChange,
) {}
public static isDurable(
increment: StoreIncrement,
): increment is DurableStoreIncrement {
return increment.type === "durable";
}
public static isEphemeral(
increment: StoreIncrement,
): increment is EphemeralStoreIncrement {
return increment.type === "ephemeral";
}
}
/**
* Represents a durable change to the store.
*/
export class DurableStoreIncrement extends StoreIncrement {
constructor(
public readonly change: StoreChange,
public readonly delta: StoreDelta,
) {
super("durable", change);
}
}
/**
* Represents an ephemeral change to the store.
*/
export class EphemeralStoreIncrement extends StoreIncrement {
constructor(public readonly change: StoreChange) {
super("ephemeral", change);
}
}
/**
* Represents a captured delta by the Store.
*/
export class StoreDelta {
protected constructor(
public readonly id: string, public readonly id: string,
public readonly elementsChange: ElementsChange, public readonly elements: ElementsDelta,
public readonly appStateChange: AppStateChange, public readonly appState: AppStateDelta,
) {} ) {}
/** /**
* Create a new instance of `StoreIncrement`. * Create a new instance of `StoreDelta`.
*/ */
public static create( public static create(
elementsChange: ElementsChange, elements: ElementsDelta,
appStateChange: AppStateChange, appState: AppStateDelta,
opts: { opts: {
id: string; id: string;
} = { } = {
id: randomId(), id: randomId(),
}, },
) { ) {
return new StoreIncrement(opts.id, elementsChange, appStateChange); return new this(opts.id, elements, appState);
} }
/** /**
* Restore a store increment instance from a DTO. * Restore a store delta instance from a DTO.
*/ */
public static restore(storeIncrementDTO: DTO<StoreIncrement>) { public static restore(storeDeltaDTO: DTO<StoreDelta>) {
const { id, elementsChange, appStateChange } = storeIncrementDTO; const { id, elements, appState } = storeDeltaDTO;
return new StoreIncrement( return new this(
id, id,
ElementsChange.restore(elementsChange), ElementsDelta.restore(elements),
AppStateChange.restore(appStateChange), AppStateDelta.restore(appState),
); );
} }
// CFDO: why it would be a string if it can be a DTO?
/** /**
* Parse and load the increment from the remote payload. * Parse and load the delta from the remote payload.
*/ */
// CFDO: why it would be a string if it can be a DTO?
public static load(payload: string) { public static load(payload: string) {
// CFDO: ensure typesafety // CFDO: ensure typesafety
const { const {
id, id,
elementsChange: { added, removed, updated }, elements: { added, removed, updated },
} = JSON.parse(payload); } = JSON.parse(payload);
const elementsChange = ElementsChange.create(added, removed, updated, { const elements = ElementsDelta.create(added, removed, updated, {
shouldRedistribute: false, shouldRedistribute: false,
}); });
return new StoreIncrement(id, elementsChange, AppStateChange.empty()); return new this(id, elements, AppStateDelta.empty());
} }
/** /**
* Inverse store increment, creates new instance of `StoreIncrement`. * Inverse store delta, creates new instance of `StoreDelta`.
*/ */
public inverse(): StoreIncrement { public static inverse(delta: StoreDelta): StoreDelta {
return new StoreIncrement( return this.create(delta.elements.inverse(), delta.appState.inverse(), {
randomId(), id: delta.id,
this.elementsChange.inverse(), });
this.appStateChange.inverse(),
);
} }
/** /**
* Apply latest (remote) changes to the increment, creates new instance of `StoreIncrement`. * Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
*/ */
public applyLatestChanges(elements: SceneElementsMap): StoreIncrement { public static applyLatestChanges(
const inversedIncrement = this.inverse(); delta: StoreDelta,
elements: SceneElementsMap,
): StoreDelta {
const inversedDelta = this.inverse(delta);
return new StoreIncrement( return this.create(
inversedIncrement.id, inversedDelta.elements.applyLatestChanges(elements),
inversedIncrement.elementsChange.applyLatestChanges(elements), inversedDelta.appState,
inversedIncrement.appStateChange, {
id: inversedDelta.id,
},
); );
} }
public isEmpty() { public isEmpty() {
return this.elementsChange.isEmpty() && this.appStateChange.isEmpty(); return this.elements.isEmpty() && this.appState.isEmpty();
} }
} }
/**
* Represents a snapshot of the captured or updated changes in the store,
* used for producing deltas and emitting `DurableStoreIncrement`s.
*/
export class StoreSnapshot { export class StoreSnapshot {
private _lastChangedElementsHash: number = 0;
private constructor( private constructor(
public readonly elements: Map<string, OrderedExcalidrawElement>, public readonly elements: Map<string, OrderedExcalidrawElement>,
public readonly appState: ObservedAppState, public readonly appState: ObservedAppState,
@ -369,6 +498,34 @@ export class StoreSnapshot {
); );
} }
public getChangedElements(prevSnapshot: StoreSnapshot) {
const changedElements: Record<string, OrderedExcalidrawElement> = {};
for (const [id, nextElement] of this.elements.entries()) {
// Due to the structural clone inside `maybeClone`, we can perform just these reference checks
if (prevSnapshot.elements.get(id) !== nextElement) {
changedElements[id] = nextElement;
}
}
return changedElements;
}
public getChangedAppState(
prevSnapshot: StoreSnapshot,
): Partial<ObservedAppState> {
return Delta.getRightDifferences(
prevSnapshot.appState,
this.appState,
).reduce(
(acc, key) =>
Object.assign(acc, {
[key]: this.appState[key as keyof ObservedAppState],
}),
{} as Partial<ObservedAppState>,
);
}
public isEmpty() { public isEmpty() {
return this.metadata.isEmpty; return this.metadata.isEmpty;
} }
@ -434,10 +591,8 @@ export class StoreSnapshot {
} }
private detectChangedAppState(nextObservedAppState: ObservedAppState) { private detectChangedAppState(nextObservedAppState: ObservedAppState) {
return !isShallowEqual(this.appState, nextObservedAppState, { // CFDO: could we optimize by checking only reference changes? (i.e. selectedElementIds should be stable now)
selectedElementIds: isShallowEqual, return Delta.isRightDifferent(this.appState, nextObservedAppState);
selectedGroupIds: isShallowEqual,
});
} }
private maybeCreateElementsSnapshot( private maybeCreateElementsSnapshot(
@ -447,13 +602,13 @@ export class StoreSnapshot {
return this.elements; return this.elements;
} }
const didElementsChange = this.detectChangedElements(elements); const changedElements = this.detectChangedElements(elements);
if (!didElementsChange) { if (!changedElements?.size) {
return this.elements; return this.elements;
} }
const elementsSnapshot = this.createElementsSnapshot(elements); const elementsSnapshot = this.createElementsSnapshot(changedElements);
return elementsSnapshot; return elementsSnapshot;
} }
@ -466,67 +621,72 @@ export class StoreSnapshot {
nextElements: Map<string, OrderedExcalidrawElement>, nextElements: Map<string, OrderedExcalidrawElement>,
) { ) {
if (this.elements === nextElements) { if (this.elements === nextElements) {
return false; return;
} }
if (this.elements.size !== nextElements.size) { const changedElements: Map<string, OrderedExcalidrawElement> = new Map();
return true;
}
// loop from right to left as changes are likelier to happen on new elements for (const [id, prevElement] of this.elements) {
const keys = Array.from(nextElements.keys()); const nextElement = nextElements.get(id);
for (let i = keys.length - 1; i >= 0; i--) { if (!nextElement) {
const prev = this.elements.get(keys[i]); // element was deleted
const next = nextElements.get(keys[i]); changedElements.set(
if (
!prev ||
!next ||
prev.id !== next.id ||
prev.version !== next.version ||
prev.versionNonce !== next.versionNonce
) {
return true;
}
}
return false;
}
/**
* Perform structural clone, cloning only elements that changed.
*/
private createElementsSnapshot(
nextElements: Map<string, OrderedExcalidrawElement>,
) {
const clonedElements = new Map();
for (const [id, prevElement] of this.elements.entries()) {
// Clone previous elements, never delete, in case nextElements would be just a subset of previous elements
// i.e. during collab, persist or whenenever isDeleted elements get cleared
if (!nextElements.get(id)) {
// When we cannot find the prev element in the next elements, we mark it as deleted
clonedElements.set(
id, id,
newElementWith(prevElement, { isDeleted: true }), newElementWith(prevElement, { isDeleted: true }),
); );
} else {
clonedElements.set(id, prevElement);
} }
} }
for (const [id, nextElement] of nextElements.entries()) { for (const [id, nextElement] of nextElements) {
const prevElement = clonedElements.get(id); const prevElement = this.elements.get(id);
// At this point our elements are reconcilled already, meaning the next element is always newer
if ( if (
!prevElement || // element was added !prevElement || // element was added
(prevElement && prevElement.versionNonce !== nextElement.versionNonce) // element was updated prevElement.version < nextElement.version // element was updated
) { ) {
clonedElements.set(id, deepCopyElement(nextElement)); changedElements.set(id, nextElement);
} }
} }
if (!changedElements.size) {
return;
}
// due to snapshot containing only durable changes,
// we might have already processed these elements in a previous run,
// hence additionally check whether the hash of the elements has changed
// since if it didn't, we don't need to process them again
const changedElementsHash = hashElementsVersion(
Array.from(changedElements.values()),
);
if (this._lastChangedElementsHash === changedElementsHash) {
return;
}
this._lastChangedElementsHash = changedElementsHash;
return changedElements;
}
/**
* Perform structural clone, deep cloning only elements that changed.
*/
private createElementsSnapshot(
changedElements: Map<string, OrderedExcalidrawElement>,
) {
const clonedElements = new Map();
for (const [id, prevElement] of this.elements) {
// Clone previous elements, never delete, in case nextElements would be just a subset of previous elements
// i.e. during collab, persist or whenenever isDeleted elements get cleared
clonedElements.set(id, prevElement);
}
for (const [id, changedElement] of changedElements) {
clonedElements.set(id, deepCopyElement(changedElement));
}
return clonedElements; return clonedElements;
} }
} }

View file

@ -6,18 +6,15 @@ import ReconnectingWebSocket, {
} from "reconnecting-websocket"; } from "reconnecting-websocket";
import { Utils } from "./utils"; import { Utils } from "./utils";
import { import {
SyncQueue, LocalDeltasQueue,
type MetadataRepository, type MetadataRepository,
type IncrementsRepository, type DeltasRepository,
} from "./queue"; } from "./queue";
import { StoreIncrement } from "../store"; import { SnapshotAction, StoreDelta } from "../store";
import type { StoreChange } from "../store";
import type { ExcalidrawImperativeAPI } from "../types"; import type { ExcalidrawImperativeAPI } from "../types";
import type { SceneElementsMap } from "../element/types"; import type { SceneElementsMap } from "../element/types";
import type { import type { CLIENT_MESSAGE_RAW, SERVER_DELTA, CHANGE } from "./protocol";
CLIENT_INCREMENT,
CLIENT_MESSAGE_RAW,
SERVER_INCREMENT,
} from "./protocol";
import { debounce } from "../utils"; import { debounce } from "../utils";
import { randomId } from "../random"; import { randomId } from "../random";
@ -38,12 +35,6 @@ class SocketClient {
// thus working with a slighter smaller limit of 800 kB (leaving 224kB for metadata) // thus working with a slighter smaller limit of 800 kB (leaving 224kB for metadata)
private static readonly MAX_MESSAGE_SIZE = 800_000; private static readonly MAX_MESSAGE_SIZE = 800_000;
private static readonly NORMAL_CLOSURE_CODE = 1000;
// Chrome throws "Uncaught InvalidAccessError" with message:
// "The close code must be either 1000, or between 3000 and 4999. 1009 is neither."
// therefore using custom codes instead.
private static readonly MESSAGE_IS_TOO_LARGE_ERROR_CODE = 3009;
private isOffline = true; private isOffline = true;
private socket: ReconnectingWebSocket | null = null; private socket: ReconnectingWebSocket | null = null;
@ -129,11 +120,11 @@ class SocketClient {
public send(message: { public send(message: {
type: "relay" | "pull" | "push"; type: "relay" | "pull" | "push";
payload: any; payload: Record<string, unknown>;
}): void { }): void {
if (this.isOffline) { if (this.isOffline) {
// connection opened, don't let the WS buffer the messages, // connection opened, don't let the WS buffer the messages,
// as we do explicitly buffer unacknowledged increments // as we do explicitly buffer unacknowledged deltas
return; return;
} }
@ -145,6 +136,7 @@ class SocketClient {
const { type, payload } = message; const { type, payload } = message;
// CFDO II: could be slowish for large payloads, thing about a better solution (i.e. msgpack 10x faster, 2x smaller)
const stringifiedPayload = JSON.stringify(payload); const stringifiedPayload = JSON.stringify(payload);
const payloadSize = new TextEncoder().encode(stringifiedPayload).byteLength; const payloadSize = new TextEncoder().encode(stringifiedPayload).byteLength;
@ -193,8 +185,8 @@ class SocketClient {
}; };
} }
interface AcknowledgedIncrement { interface AcknowledgedDelta {
increment: StoreIncrement; delta: StoreDelta;
version: number; version: number;
} }
@ -208,21 +200,19 @@ export class SyncClient {
: "test_room_prod"; : "test_room_prod";
private readonly api: ExcalidrawImperativeAPI; private readonly api: ExcalidrawImperativeAPI;
private readonly queue: SyncQueue; private readonly localDeltas: LocalDeltasQueue;
private readonly metadata: MetadataRepository; private readonly metadata: MetadataRepository;
private readonly client: SocketClient; private readonly client: SocketClient;
// #region ACKNOWLEDGED INCREMENTS & METADATA // #region ACKNOWLEDGED DELTAS & METADATA
// CFDO: shouldn't be stateful, only request / response // CFDO: shouldn't be stateful, only request / response
private readonly acknowledgedIncrementsMap: Map< private readonly acknowledgedDeltasMap: Map<string, AcknowledgedDelta> =
string, new Map();
AcknowledgedIncrement
> = new Map();
public get acknowledgedIncrements() { public get acknowledgedDeltas() {
return Array.from(this.acknowledgedIncrementsMap.values()) return Array.from(this.acknowledgedDeltasMap.values())
.sort((a, b) => (a.version < b.version ? -1 : 1)) .sort((a, b) => (a.version < b.version ? -1 : 1))
.map((x) => x.increment); .map((x) => x.delta);
} }
private _lastAcknowledgedVersion = 0; private _lastAcknowledgedVersion = 0;
@ -240,12 +230,12 @@ export class SyncClient {
private constructor( private constructor(
api: ExcalidrawImperativeAPI, api: ExcalidrawImperativeAPI,
repository: MetadataRepository, repository: MetadataRepository,
queue: SyncQueue, queue: LocalDeltasQueue,
options: { host: string; roomId: string; lastAcknowledgedVersion: number }, options: { host: string; roomId: string; lastAcknowledgedVersion: number },
) { ) {
this.api = api; this.api = api;
this.metadata = repository; this.metadata = repository;
this.queue = queue; this.localDeltas = queue;
this.lastAcknowledgedVersion = options.lastAcknowledgedVersion; this.lastAcknowledgedVersion = options.lastAcknowledgedVersion;
this.client = new SocketClient(options.host, options.roomId, { this.client = new SocketClient(options.host, options.roomId, {
onOpen: this.onOpen, onOpen: this.onOpen,
@ -257,16 +247,16 @@ export class SyncClient {
// #region SYNC_CLIENT FACTORY // #region SYNC_CLIENT FACTORY
public static async create( public static async create(
api: ExcalidrawImperativeAPI, api: ExcalidrawImperativeAPI,
repository: IncrementsRepository & MetadataRepository, repository: DeltasRepository & MetadataRepository,
) { ) {
const queue = await SyncQueue.create(repository); const queue = await LocalDeltasQueue.create(repository);
// CFDO: temporary for custom roomId (though E+ will be similar) // CFDO: temporary for custom roomId (though E+ will be similar)
const roomId = window.location.pathname.split("/").at(-1); const roomId = window.location.pathname.split("/").at(-1);
return new SyncClient(api, repository, queue, { return new SyncClient(api, repository, queue, {
host: SyncClient.HOST_URL, host: SyncClient.HOST_URL,
roomId: roomId ?? SyncClient.ROOM_ID, roomId: roomId ?? SyncClient.ROOM_ID,
// CFDO: temporary, so that all increments are loaded and applied on init // CFDO: temporary, so that all deltas are loaded and applied on init
lastAcknowledgedVersion: 0, lastAcknowledgedVersion: 0,
}); });
} }
@ -290,26 +280,27 @@ export class SyncClient {
}); });
} }
public push(increment?: StoreIncrement): void { public push(delta?: StoreDelta): void {
if (increment) { if (delta) {
this.queue.add(increment); this.localDeltas.add(delta);
} }
// re-send all already queued increments // re-send all already queued deltas
for (const queuedIncrement of this.queue.getAll()) { for (const delta of this.localDeltas.getAll()) {
this.client.send({ this.client.send({
type: "push", type: "push",
payload: { payload: {
...queuedIncrement, ...delta,
}, },
}); });
} }
} }
public relay(buffer: ArrayBuffer): void { // CFDO: should be throttled! 60 fps for live scenes, 10s or so for single player
public relay(change: StoreChange): void {
this.client.send({ this.client.send({
type: "relay", type: "relay",
payload: { buffer }, payload: { ...change },
}); });
} }
// #endregion // #endregion
@ -349,76 +340,110 @@ export class SyncClient {
} }
}; };
// CFDO: refactor by applying all operations to store, not to the elements private handleRelayed = (payload: CHANGE) => {
private handleAcknowledged = (payload: {
increments: Array<SERVER_INCREMENT>;
}) => {
let nextAcknowledgedVersion = this.lastAcknowledgedVersion;
let elements = new Map(
// CFDO: retrieve the map already // CFDO: retrieve the map already
const nextElements = new Map(
this.api.getSceneElementsIncludingDeleted().map((el) => [el.id, el]), this.api.getSceneElementsIncludingDeleted().map((el) => [el.id, el]),
) as SceneElementsMap; ) as SceneElementsMap;
try { try {
const { increments: remoteIncrements } = payload; const { elements: relayedElements } = payload;
// apply remote increments for (const [id, relayedElement] of Object.entries(relayedElements)) {
for (const { id, version, payload } of remoteIncrements) { const existingElement = nextElements.get(id);
// CFDO: temporary to load all increments on init
this.acknowledgedIncrementsMap.set(id, { if (
increment: StoreIncrement.load(payload), !existingElement || // new element
existingElement.version < relayedElement.version // updated element
) {
nextElements.set(id, relayedElement);
}
}
this.api.updateScene({
elements: Array.from(nextElements.values()),
snapshotAction: SnapshotAction.UPDATE,
});
} catch (e) {
console.error("Failed to apply relayed change:", e);
}
};
// CFDO: refactor by applying all operations to store, not to the elements
private handleAcknowledged = (payload: { deltas: Array<SERVER_DELTA> }) => {
let prevSnapshot = this.api.store.snapshot;
try {
const remoteDeltas = Array.from(payload.deltas);
const applicableDeltas: Array<StoreDelta> = [];
const appState = this.api.getAppState();
let nextAcknowledgedVersion = this.lastAcknowledgedVersion;
let nextElements = new Map(
// CFDO: retrieve the map already
this.api.getSceneElementsIncludingDeleted().map((el) => [el.id, el]),
) as SceneElementsMap;
for (const { id, version, payload } of remoteDeltas) {
// CFDO: temporary to load all deltas on init
this.acknowledgedDeltasMap.set(id, {
delta: StoreDelta.load(payload),
version, version,
}); });
// we've already applied this increment // we've already applied this delta!
if (version <= nextAcknowledgedVersion) { if (version <= nextAcknowledgedVersion) {
continue; continue;
} }
if (version === nextAcknowledgedVersion + 1) { // CFDO:strictly checking for out of order deltas; might be relaxed if it becomes a problem
nextAcknowledgedVersion = version; if (version !== nextAcknowledgedVersion + 1) {
} else { throw new Error(
// it's fine to apply increments our of order, `Received out of order delta, expected "${
// as they are idempontent, so that we can re-apply them again,
// as long as we don't mark their version as acknowledged
console.debug(
`Received out of order increment, expected "${
nextAcknowledgedVersion + 1 nextAcknowledgedVersion + 1
}", but received "${version}"`, }", but received "${version}"`,
); );
} }
// local increment shall not have to be applied again if (this.localDeltas.has(id)) {
if (this.queue.has(id)) { // local delta does not have to be applied again
this.queue.remove(id); this.localDeltas.remove(id);
} else { } else {
// apply remote increment with higher version than the last acknowledged one // this is a new remote delta, adding it to the list of applicable deltas
const remoteIncrement = StoreIncrement.load(payload); const remoteDelta = StoreDelta.load(payload);
[elements] = remoteIncrement.elementsChange.applyTo( applicableDeltas.push(remoteDelta);
elements,
this.api.store.snapshot.elements,
);
} }
// apply local increments nextAcknowledgedVersion = version;
for (const localIncrement of this.queue.getAll()) {
// CFDO: in theory only necessary when remote increments modified same element properties!
[elements] = localIncrement.elementsChange.applyTo(
elements,
this.api.store.snapshot.elements,
);
} }
// adding all yet unacknowledged local deltas
const localDeltas = this.localDeltas.getAll();
applicableDeltas.push(...localDeltas);
for (const delta of applicableDeltas) {
[nextElements] = this.api.store.applyDeltaTo(
delta,
nextElements,
appState,
);
prevSnapshot = this.api.store.snapshot;
}
// CFDO: I still need to filter out uncomitted elements
// I still need to update snapshot with the new elements
this.api.updateScene({ this.api.updateScene({
elements: Array.from(elements.values()), elements: Array.from(nextElements.values()),
storeAction: "update", snapshotAction: SnapshotAction.NONE,
}); });
}
this.lastAcknowledgedVersion = nextAcknowledgedVersion; this.lastAcknowledgedVersion = nextAcknowledgedVersion;
} catch (e) { } catch (e) {
console.error("Failed to apply acknowledged increments:", e); console.error("Failed to apply acknowledged deltas:", e);
// CFDO: might just be on error // rollback to the previous snapshot, so that we don't end up in an incosistent state
this.api.store.snapshot = prevSnapshot;
// schedule another fresh pull in case of a failure
this.schedulePull(); this.schedulePull();
} }
}; };
@ -427,17 +452,10 @@ export class SyncClient {
ids: Array<string>; ids: Array<string>;
message: string; message: string;
}) => { }) => {
// handle rejected increments // handle rejected deltas
console.error("Rejected message received:", payload); console.error("Rejected message received:", payload);
}; };
private handleRelayed = (payload: {
increments: Array<CLIENT_INCREMENT>;
}) => {
// apply relayed increments / buffer
console.log("Relayed message received:", payload);
};
private schedulePull = debounce(() => this.pull(), 1000); private schedulePull = debounce(() => this.pull(), 1000);
// #endregion // #endregion
} }

View file

@ -1,11 +1,12 @@
import type { StoreIncrement } from "../store"; import type { StoreChange, StoreDelta } from "../store";
import type { DTO } from "../utility-types"; import type { DTO } from "../utility-types";
export type CLIENT_INCREMENT = DTO<StoreIncrement>; export type DELTA = DTO<StoreDelta>;
export type CHANGE = DTO<StoreChange>;
export type RELAY_PAYLOAD = { buffer: ArrayBuffer }; export type RELAY_PAYLOAD = CHANGE;
export type PUSH_PAYLOAD = DELTA;
export type PULL_PAYLOAD = { lastAcknowledgedVersion: number }; export type PULL_PAYLOAD = { lastAcknowledgedVersion: number };
export type PUSH_PAYLOAD = CLIENT_INCREMENT;
export type CHUNK_INFO = { export type CHUNK_INFO = {
id: string; id: string;
@ -25,22 +26,21 @@ export type CLIENT_MESSAGE = { chunkInfo: CHUNK_INFO } & (
| { type: "push"; payload: PUSH_PAYLOAD } | { type: "push"; payload: PUSH_PAYLOAD }
); );
export type SERVER_INCREMENT = { id: string; version: number; payload: string }; export type SERVER_DELTA = { id: string; version: number; payload: string };
export type SERVER_MESSAGE = export type SERVER_MESSAGE =
| { | {
type: "relayed"; type: "relayed";
// CFDO: should likely be just elements payload: RELAY_PAYLOAD;
// payload: { increments: Array<CLIENT_INCREMENT> } | RELAY_PAYLOAD;
} }
| { type: "acknowledged"; payload: { increments: Array<SERVER_INCREMENT> } } | { type: "acknowledged"; payload: { deltas: Array<SERVER_DELTA> } }
| { | {
type: "rejected"; type: "rejected";
payload: { increments: Array<CLIENT_INCREMENT>; message: string }; payload: { deltas: Array<DELTA>; message: string };
}; };
export interface IncrementsRepository { export interface DeltasRepository {
save(increment: CLIENT_INCREMENT): SERVER_INCREMENT | null; save(delta: DELTA): SERVER_DELTA | null;
getAllSinceVersion(version: number): Array<SERVER_INCREMENT>; getAllSinceVersion(version: number): Array<SERVER_DELTA>;
getLastVersion(): number; getLastVersion(): number;
} }

View file

@ -1,9 +1,9 @@
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import type { StoreIncrement } from "../store"; import type { StoreDelta } from "../store";
export interface IncrementsRepository { export interface DeltasRepository {
loadIncrements(): Promise<Array<StoreIncrement> | null>; loadDeltas(): Promise<Array<StoreDelta> | null>;
saveIncrements(params: StoreIncrement[]): Promise<void>; saveDeltas(params: StoreDelta[]): Promise<void>;
} }
export interface MetadataRepository { export interface MetadataRepository {
@ -11,24 +11,24 @@ export interface MetadataRepository {
saveMetadata(metadata: { lastAcknowledgedVersion: number }): Promise<void>; saveMetadata(metadata: { lastAcknowledgedVersion: number }): Promise<void>;
} }
// CFDO: make sure the increments are always acknowledged (deleted from the repository) // CFDO: make sure the deltas are always acknowledged (deleted from the repository)
export class SyncQueue { export class LocalDeltasQueue {
private readonly queue: Map<string, StoreIncrement>; private readonly queue: Map<string, StoreDelta>;
private readonly repository: IncrementsRepository; private readonly repository: DeltasRepository;
private constructor( private constructor(
queue: Map<string, StoreIncrement> = new Map(), queue: Map<string, StoreDelta> = new Map(),
repository: IncrementsRepository, repository: DeltasRepository,
) { ) {
this.queue = queue; this.queue = queue;
this.repository = repository; this.repository = repository;
} }
public static async create(repository: IncrementsRepository) { public static async create(repository: DeltasRepository) {
const increments = await repository.loadIncrements(); const deltas = await repository.loadDeltas();
return new SyncQueue( return new LocalDeltasQueue(
new Map(increments?.map((increment) => [increment.id, increment])), new Map(deltas?.map((delta) => [delta.id, delta])),
repository, repository,
); );
} }
@ -37,23 +37,23 @@ export class SyncQueue {
return Array.from(this.queue.values()); return Array.from(this.queue.values());
} }
public get(id: StoreIncrement["id"]) { public get(id: StoreDelta["id"]) {
return this.queue.get(id); return this.queue.get(id);
} }
public has(id: StoreIncrement["id"]) { public has(id: StoreDelta["id"]) {
return this.queue.has(id); return this.queue.has(id);
} }
public add(...increments: StoreIncrement[]) { public add(...deltas: StoreDelta[]) {
for (const increment of increments) { for (const delta of deltas) {
this.queue.set(increment.id, increment); this.queue.set(delta.id, delta);
} }
this.persist(); this.persist();
} }
public remove(...ids: StoreIncrement["id"][]) { public remove(...ids: StoreDelta["id"][]) {
for (const id of ids) { for (const id of ids) {
this.queue.delete(id); this.queue.delete(id);
} }
@ -64,7 +64,7 @@ export class SyncQueue {
public persist = throttle( public persist = throttle(
async () => { async () => {
try { try {
await this.repository.saveIncrements(this.getAll()); await this.repository.saveDeltas(this.getAll());
} catch (e) { } catch (e) {
console.error("Failed to persist the sync queue:", e); console.error("Failed to persist the sync queue:", e);
} }

View file

@ -2,14 +2,15 @@ import AsyncLock from "async-lock";
import { Utils } from "./utils"; import { Utils } from "./utils";
import type { import type {
IncrementsRepository, DeltasRepository,
CLIENT_MESSAGE, CLIENT_MESSAGE,
PULL_PAYLOAD, PULL_PAYLOAD,
PUSH_PAYLOAD, PUSH_PAYLOAD,
SERVER_MESSAGE, SERVER_MESSAGE,
SERVER_INCREMENT, SERVER_DELTA,
CLIENT_MESSAGE_RAW, CLIENT_MESSAGE_RAW,
CHUNK_INFO, CHUNK_INFO,
RELAY_PAYLOAD,
} from "./protocol"; } from "./protocol";
// CFDO: message could be binary (cbor, protobuf, etc.) // CFDO: message could be binary (cbor, protobuf, etc.)
@ -25,7 +26,7 @@ export class ExcalidrawSyncServer {
Map<CHUNK_INFO["position"], CLIENT_MESSAGE_RAW["payload"]> Map<CHUNK_INFO["position"], CLIENT_MESSAGE_RAW["payload"]>
>(); >();
constructor(private readonly incrementsRepository: IncrementsRepository) {} constructor(private readonly repository: DeltasRepository) {}
// CFDO: should send a message about collaborators (no collaborators => no need to send ephemerals) // CFDO: should send a message about collaborators (no collaborators => no need to send ephemerals)
public onConnect(client: WebSocket) { public onConnect(client: WebSocket) {
@ -65,8 +66,8 @@ export class ExcalidrawSyncServer {
} }
switch (type) { switch (type) {
// case "relay": case "relay":
// return this.relay(client, parsedPayload as RELAY_PAYLOAD); return this.relay(client, parsedPayload as RELAY_PAYLOAD);
case "pull": case "pull":
return this.pull(client, parsedPayload as PULL_PAYLOAD); return this.pull(client, parsedPayload as PULL_PAYLOAD);
case "push": case "push":
@ -137,24 +138,21 @@ export class ExcalidrawSyncServer {
} }
} }
// private relay( private relay(client: WebSocket, payload: RELAY_PAYLOAD) {
// client: WebSocket, // CFDO: we should likely apply these to the snapshot
// payload: { increments: Array<CLIENT_INCREMENT> } | RELAY_PAYLOAD, return this.broadcast(
// ) { {
// return this.broadcast( type: "relayed",
// { payload,
// type: "relayed", },
// payload, client,
// }, );
// client, }
// );
// }
private pull(client: WebSocket, payload: PULL_PAYLOAD) { private pull(client: WebSocket, payload: PULL_PAYLOAD) {
// CFDO: test for invalid payload // CFDO: test for invalid payload
const lastAcknowledgedClientVersion = payload.lastAcknowledgedVersion; const lastAcknowledgedClientVersion = payload.lastAcknowledgedVersion;
const lastAcknowledgedServerVersion = const lastAcknowledgedServerVersion = this.repository.getLastVersion();
this.incrementsRepository.getLastVersion();
const versionΔ = const versionΔ =
lastAcknowledgedServerVersion - lastAcknowledgedClientVersion; lastAcknowledgedServerVersion - lastAcknowledgedClientVersion;
@ -167,37 +165,33 @@ export class ExcalidrawSyncServer {
return; return;
} }
const increments: SERVER_INCREMENT[] = []; const deltas: SERVER_DELTA[] = [];
if (versionΔ > 0) { if (versionΔ > 0) {
increments.push( deltas.push(
...this.incrementsRepository.getAllSinceVersion( ...this.repository.getAllSinceVersion(lastAcknowledgedClientVersion),
lastAcknowledgedClientVersion,
),
); );
} }
this.send(client, { this.send(client, {
type: "acknowledged", type: "acknowledged",
payload: { payload: {
increments, deltas,
}, },
}); });
} }
private push(client: WebSocket, increment: PUSH_PAYLOAD) { private push(client: WebSocket, delta: PUSH_PAYLOAD) {
// CFDO: try to apply the increments to the snapshot // CFDO: try to apply the deltas to the snapshot
const [acknowledged, error] = Utils.try(() => const [acknowledged, error] = Utils.try(() => this.repository.save(delta));
this.incrementsRepository.save(increment),
);
if (error || !acknowledged) { if (error || !acknowledged) {
// everything should be automatically rolled-back -> double-check // everything should be automatically rolled-back -> double-check
return this.send(client, { return this.send(client, {
type: "rejected", type: "rejected",
payload: { payload: {
message: error ? error.message : "Coudn't persist the increment.", message: error ? error.message : "Coudn't persist the delta.",
increments: [increment], deltas: [delta],
}, },
}); });
} }
@ -205,7 +199,7 @@ export class ExcalidrawSyncServer {
return this.broadcast({ return this.broadcast({
type: "acknowledged", type: "acknowledged",
payload: { payload: {
increments: [acknowledged], deltas: [acknowledged],
}, },
}); });
} }

View file

@ -41,8 +41,8 @@ import {
} from "../actions"; } from "../actions";
import { vi } from "vitest"; import { vi } from "vitest";
import { queryByText } from "@testing-library/react"; import { queryByText } from "@testing-library/react";
import { AppStateChange, ElementsChange } from "../change"; import { AppStateDelta, ElementsDelta } from "../delta";
import { StoreAction, StoreIncrement } from "../store"; import { SnapshotAction, StoreDelta } from "../store";
import type { LocalPoint, Radians } from "../../math"; import type { LocalPoint, Radians } from "../../math";
import { pointFrom } from "../../math"; import { pointFrom } from "../../math";
import type { AppState } from "../types.js"; import type { AppState } from "../types.js";
@ -91,10 +91,10 @@ const checkpoint = (name: string) => {
h.history.undoStack.map((x) => ({ h.history.undoStack.map((x) => ({
...x, ...x,
elementsChange: { elementsChange: {
...x.elementsChange, ...x.elements,
added: stripSeed(x.elementsChange.added), added: stripSeed(x.elements.added),
removed: stripSeed(x.elementsChange.updated), removed: stripSeed(x.elements.updated),
updated: stripSeed(x.elementsChange.removed), updated: stripSeed(x.elements.removed),
}, },
})), })),
).toMatchSnapshot(`[${name}] undo stack`); ).toMatchSnapshot(`[${name}] undo stack`);
@ -103,10 +103,10 @@ const checkpoint = (name: string) => {
h.history.redoStack.map((x) => ({ h.history.redoStack.map((x) => ({
...x, ...x,
elementsChange: { elementsChange: {
...x.elementsChange, ...x.elements,
added: stripSeed(x.elementsChange.added), added: stripSeed(x.elements.added),
removed: stripSeed(x.elementsChange.updated), removed: stripSeed(x.elements.updated),
updated: stripSeed(x.elementsChange.removed), updated: stripSeed(x.elements.removed),
}, },
})), })),
).toMatchSnapshot(`[${name}] redo stack`); ).toMatchSnapshot(`[${name}] redo stack`);
@ -137,16 +137,14 @@ describe("history", () => {
API.setElements([rect]); API.setElements([rect]);
const corrupedEntry = StoreIncrement.create( const corrupedEntry = StoreDelta.create(
ElementsChange.empty(), ElementsDelta.empty(),
AppStateChange.empty(), AppStateDelta.empty(),
); );
vi.spyOn(corrupedEntry.elementsChange, "applyTo").mockImplementation( vi.spyOn(corrupedEntry.elements, "applyTo").mockImplementation(() => {
() => {
throw new Error("Oh no, I am corrupted!"); throw new Error("Oh no, I am corrupted!");
}, });
);
(h.history as any).undoStack.push(corrupedEntry); (h.history as any).undoStack.push(corrupedEntry);
@ -218,7 +216,7 @@ describe("history", () => {
API.updateScene({ API.updateScene({
elements: [rect1, rect2], elements: [rect1, rect2],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
@ -230,7 +228,7 @@ describe("history", () => {
API.updateScene({ API.updateScene({
elements: [rect1, rect2], elements: [rect1, rect2],
storeAction: StoreAction.CAPTURE, // even though the flag is on, same elements are passed, nothing to commit snapshotAction: SnapshotAction.CAPTURE, // even though the flag is on, same elements are passed, nothing to commit
}); });
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
@ -598,7 +596,7 @@ describe("history", () => {
appState: { appState: {
name: "New name", name: "New name",
}, },
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
@ -609,7 +607,7 @@ describe("history", () => {
appState: { appState: {
viewBackgroundColor: "#000", viewBackgroundColor: "#000",
}, },
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
expect(API.getUndoStack().length).toBe(2); expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
@ -622,7 +620,7 @@ describe("history", () => {
name: "New name", name: "New name",
viewBackgroundColor: "#000", viewBackgroundColor: "#000",
}, },
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
expect(API.getUndoStack().length).toBe(2); expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
@ -1329,7 +1327,7 @@ describe("history", () => {
API.updateScene({ API.updateScene({
elements: [rect1, text, rect2], elements: [rect1, text, rect2],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
// bind text1 to rect1 // bind text1 to rect1
@ -1901,7 +1899,7 @@ describe("history", () => {
strokeColor: blue, strokeColor: blue,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -1939,7 +1937,7 @@ describe("history", () => {
strokeColor: yellow, strokeColor: yellow,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -1987,7 +1985,7 @@ describe("history", () => {
backgroundColor: yellow, backgroundColor: yellow,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
// At this point our entry gets updated from `red` -> `blue` into `red` -> `yellow` // At this point our entry gets updated from `red` -> `blue` into `red` -> `yellow`
@ -2003,7 +2001,7 @@ describe("history", () => {
backgroundColor: violet, backgroundColor: violet,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
// At this point our (inversed) entry gets updated from `red` -> `yellow` into `violet` -> `yellow` // At this point our (inversed) entry gets updated from `red` -> `yellow` into `violet` -> `yellow`
@ -2048,7 +2046,7 @@ describe("history", () => {
API.updateScene({ API.updateScene({
elements: [rect, diamond], elements: [rect, diamond],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
// Connect the arrow // Connect the arrow
@ -2097,7 +2095,7 @@ describe("history", () => {
} as FixedPointBinding, } as FixedPointBinding,
}, },
], ],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -2112,7 +2110,7 @@ describe("history", () => {
} }
: el, : el,
), ),
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -2136,7 +2134,7 @@ describe("history", () => {
// Initialize scene // Initialize scene
API.updateScene({ API.updateScene({
elements: [rect1, rect2], elements: [rect1, rect2],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
// Simulate local update // Simulate local update
@ -2145,7 +2143,7 @@ describe("history", () => {
newElementWith(h.elements[0], { groupIds: ["A"] }), newElementWith(h.elements[0], { groupIds: ["A"] }),
newElementWith(h.elements[1], { groupIds: ["A"] }), newElementWith(h.elements[1], { groupIds: ["A"] }),
], ],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
const rect3 = API.createElement({ type: "rectangle", groupIds: ["B"] }); const rect3 = API.createElement({ type: "rectangle", groupIds: ["B"] });
@ -2159,7 +2157,7 @@ describe("history", () => {
rect3, rect3,
rect4, rect4,
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -2205,7 +2203,7 @@ describe("history", () => {
] as LocalPoint[], ] as LocalPoint[],
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.undo(); // undo `actionFinalize` Keyboard.undo(); // undo `actionFinalize`
@ -2300,7 +2298,7 @@ describe("history", () => {
isDeleted: false, // undeletion might happen due to concurrency between clients isDeleted: false, // undeletion might happen due to concurrency between clients
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
expect(API.getSelectedElements()).toEqual([]); expect(API.getSelectedElements()).toEqual([]);
@ -2377,7 +2375,7 @@ describe("history", () => {
isDeleted: true, isDeleted: true,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
expect(h.elements).toEqual([ expect(h.elements).toEqual([
@ -2439,7 +2437,7 @@ describe("history", () => {
isDeleted: true, isDeleted: true,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -2515,7 +2513,7 @@ describe("history", () => {
isDeleted: true, isDeleted: true,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -2554,7 +2552,7 @@ describe("history", () => {
isDeleted: false, isDeleted: false,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.redo(); Keyboard.redo();
@ -2600,7 +2598,7 @@ describe("history", () => {
// Simulate remote update // Simulate remote update
API.updateScene({ API.updateScene({
elements: [rect1, rect2], elements: [rect1, rect2],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
@ -2610,7 +2608,7 @@ describe("history", () => {
// Simulate remote update // Simulate remote update
API.updateScene({ API.updateScene({
elements: [h.elements[0], h.elements[1], rect3, rect4], elements: [h.elements[0], h.elements[1], rect3, rect4],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
@ -2631,7 +2629,7 @@ describe("history", () => {
isDeleted: true, isDeleted: true,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -2656,7 +2654,7 @@ describe("history", () => {
isDeleted: false, isDeleted: false,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.redo(); Keyboard.redo();
@ -2667,7 +2665,7 @@ describe("history", () => {
// Simulate remote update // Simulate remote update
API.updateScene({ API.updateScene({
elements: [h.elements[0], h.elements[1], rect3, rect4], elements: [h.elements[0], h.elements[1], rect3, rect4],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.redo(); Keyboard.redo();
@ -2713,7 +2711,7 @@ describe("history", () => {
isDeleted: true, isDeleted: true,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -2734,7 +2732,7 @@ describe("history", () => {
}), }),
h.elements[1], h.elements[1],
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -2777,7 +2775,7 @@ describe("history", () => {
isDeleted: true, isDeleted: true,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -2820,7 +2818,7 @@ describe("history", () => {
h.elements[0], h.elements[0],
h.elements[1], h.elements[1],
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
expect(API.getUndoStack().length).toBe(2); expect(API.getUndoStack().length).toBe(2);
@ -2859,7 +2857,7 @@ describe("history", () => {
h.elements[0], h.elements[0],
h.elements[1], h.elements[1],
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
expect(API.getUndoStack().length).toBe(2); expect(API.getUndoStack().length).toBe(2);
@ -2910,7 +2908,7 @@ describe("history", () => {
h.elements[0], // rect2 h.elements[0], // rect2
h.elements[1], // rect1 h.elements[1], // rect1
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -2940,7 +2938,7 @@ describe("history", () => {
h.elements[0], // rect3 h.elements[0], // rect3
h.elements[2], // rect1 h.elements[2], // rect1
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -2970,7 +2968,7 @@ describe("history", () => {
// Simulate remote update // Simulate remote update
API.updateScene({ API.updateScene({
elements: [...h.elements, rect], elements: [...h.elements, rect],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
mouse.moveTo(60, 60); mouse.moveTo(60, 60);
@ -3022,7 +3020,7 @@ describe("history", () => {
// // Simulate remote update // // Simulate remote update
API.updateScene({ API.updateScene({
elements: [...h.elements, rect3], elements: [...h.elements, rect3],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
mouse.moveTo(100, 100); mouse.moveTo(100, 100);
@ -3112,7 +3110,7 @@ describe("history", () => {
// Simulate remote update // Simulate remote update
API.updateScene({ API.updateScene({
elements: [...h.elements, rect3], elements: [...h.elements, rect3],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
mouse.moveTo(100, 100); mouse.moveTo(100, 100);
@ -3289,7 +3287,7 @@ describe("history", () => {
// Initialize the scene // Initialize the scene
API.updateScene({ API.updateScene({
elements: [container, text], elements: [container, text],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
// Simulate local update // Simulate local update
@ -3302,7 +3300,7 @@ describe("history", () => {
containerId: container.id, containerId: container.id,
}), }),
], ],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -3333,7 +3331,7 @@ describe("history", () => {
x: h.elements[1].x + 10, x: h.elements[1].x + 10,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
runTwice(() => { runTwice(() => {
@ -3376,7 +3374,7 @@ describe("history", () => {
// Initialize the scene // Initialize the scene
API.updateScene({ API.updateScene({
elements: [container, text], elements: [container, text],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
// Simulate local update // Simulate local update
@ -3389,7 +3387,7 @@ describe("history", () => {
containerId: container.id, containerId: container.id,
}), }),
], ],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -3423,7 +3421,7 @@ describe("history", () => {
remoteText, remoteText,
h.elements[1], h.elements[1],
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
runTwice(() => { runTwice(() => {
@ -3479,7 +3477,7 @@ describe("history", () => {
// Initialize the scene // Initialize the scene
API.updateScene({ API.updateScene({
elements: [container, text], elements: [container, text],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
// Simulate local update // Simulate local update
@ -3492,7 +3490,7 @@ describe("history", () => {
containerId: container.id, containerId: container.id,
}), }),
], ],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -3529,7 +3527,7 @@ describe("history", () => {
containerId: remoteContainer.id, containerId: remoteContainer.id,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
runTwice(() => { runTwice(() => {
@ -3539,7 +3537,7 @@ describe("history", () => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
id: container.id, id: container.id,
// rebound the text as we captured the full bidirectional binding in history! // rebound the text as we recorded the full bidirectional binding in history!
boundElements: [{ id: text.id, type: "text" }], boundElements: [{ id: text.id, type: "text" }],
isDeleted: false, isDeleted: false,
}), }),
@ -3587,7 +3585,7 @@ describe("history", () => {
// Simulate local update // Simulate local update
API.updateScene({ API.updateScene({
elements: [container], elements: [container],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
// Simulate remote update // Simulate remote update
@ -3598,7 +3596,7 @@ describe("history", () => {
}), }),
newElementWith(text, { containerId: container.id }), newElementWith(text, { containerId: container.id }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
runTwice(() => { runTwice(() => {
@ -3648,7 +3646,7 @@ describe("history", () => {
// Simulate local update // Simulate local update
API.updateScene({ API.updateScene({
elements: [text], elements: [text],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
// Simulate remote update // Simulate remote update
@ -3659,7 +3657,7 @@ describe("history", () => {
}), }),
newElementWith(text, { containerId: container.id }), newElementWith(text, { containerId: container.id }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
runTwice(() => { runTwice(() => {
@ -3708,7 +3706,7 @@ describe("history", () => {
// Simulate local update // Simulate local update
API.updateScene({ API.updateScene({
elements: [container], elements: [container],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
// Simulate remote update // Simulate remote update
@ -3721,7 +3719,7 @@ describe("history", () => {
containerId: container.id, containerId: container.id,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -3758,7 +3756,7 @@ describe("history", () => {
// rebinding the container with a new text element! // rebinding the container with a new text element!
remoteText, remoteText,
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
runTwice(() => { runTwice(() => {
@ -3815,7 +3813,7 @@ describe("history", () => {
// Simulate local update // Simulate local update
API.updateScene({ API.updateScene({
elements: [text], elements: [text],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
// Simulate remote update // Simulate remote update
@ -3828,7 +3826,7 @@ describe("history", () => {
containerId: container.id, containerId: container.id,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -3865,7 +3863,7 @@ describe("history", () => {
containerId: container.id, containerId: container.id,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
runTwice(() => { runTwice(() => {
@ -3921,7 +3919,7 @@ describe("history", () => {
// Simulate local update // Simulate local update
API.updateScene({ API.updateScene({
elements: [container], elements: [container],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
// Simulate remote update // Simulate remote update
@ -3935,7 +3933,7 @@ describe("history", () => {
isDeleted: true, isDeleted: true,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
runTwice(() => { runTwice(() => {
@ -3978,7 +3976,7 @@ describe("history", () => {
// Simulate local update // Simulate local update
API.updateScene({ API.updateScene({
elements: [text], elements: [text],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
// Simulate remote update // Simulate remote update
@ -3992,7 +3990,7 @@ describe("history", () => {
containerId: container.id, containerId: container.id,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
runTwice(() => { runTwice(() => {
@ -4035,7 +4033,7 @@ describe("history", () => {
// Initialize the scene // Initialize the scene
API.updateScene({ API.updateScene({
elements: [container], elements: [container],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
// Simulate local update // Simulate local update
@ -4047,7 +4045,7 @@ describe("history", () => {
angle: 90 as Radians, angle: 90 as Radians,
}), }),
], ],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -4060,7 +4058,7 @@ describe("history", () => {
}), }),
newElementWith(text, { containerId: container.id }), newElementWith(text, { containerId: container.id }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
expect(h.elements).toEqual([ expect(h.elements).toEqual([
@ -4153,7 +4151,7 @@ describe("history", () => {
// Initialize the scene // Initialize the scene
API.updateScene({ API.updateScene({
elements: [text], elements: [text],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
// Simulate local update // Simulate local update
@ -4165,7 +4163,7 @@ describe("history", () => {
angle: 90 as Radians, angle: 90 as Radians,
}), }),
], ],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -4180,7 +4178,7 @@ describe("history", () => {
containerId: container.id, containerId: container.id,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
expect(API.getUndoStack().length).toBe(0); expect(API.getUndoStack().length).toBe(0);
@ -4271,7 +4269,7 @@ describe("history", () => {
// Simulate local update // Simulate local update
API.updateScene({ API.updateScene({
elements: [rect1, rect2], elements: [rect1, rect2],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
mouse.reset(); mouse.reset();
@ -4360,7 +4358,7 @@ describe("history", () => {
x: h.elements[1].x + 50, x: h.elements[1].x + 50,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
runTwice(() => { runTwice(() => {
@ -4504,7 +4502,7 @@ describe("history", () => {
}), }),
remoteContainer, remoteContainer,
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
runTwice(() => { runTwice(() => {
@ -4611,7 +4609,7 @@ describe("history", () => {
boundElements: [{ id: arrow.id, type: "arrow" }], boundElements: [{ id: arrow.id, type: "arrow" }],
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
runTwice(() => { runTwice(() => {
@ -4688,7 +4686,7 @@ describe("history", () => {
// Simulate local update // Simulate local update
API.updateScene({ API.updateScene({
elements: [arrow], elements: [arrow],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
// Simulate remote update // Simulate remote update
@ -4715,7 +4713,7 @@ describe("history", () => {
boundElements: [{ id: arrow.id, type: "arrow" }], boundElements: [{ id: arrow.id, type: "arrow" }],
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
runTwice(() => { runTwice(() => {
@ -4847,7 +4845,7 @@ describe("history", () => {
newElementWith(h.elements[1], { x: 500, y: -500 }), newElementWith(h.elements[1], { x: 500, y: -500 }),
h.elements[2], h.elements[2],
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.redo(); Keyboard.redo();
@ -4919,13 +4917,13 @@ describe("history", () => {
// Initialize the scene // Initialize the scene
API.updateScene({ API.updateScene({
elements: [frame], elements: [frame],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
// Simulate local update // Simulate local update
API.updateScene({ API.updateScene({
elements: [rect, h.elements[0]], elements: [rect, h.elements[0]],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
// Simulate local update // Simulate local update
@ -4936,7 +4934,7 @@ describe("history", () => {
}), }),
h.elements[1], h.elements[1],
], ],
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
Keyboard.undo(); Keyboard.undo();
@ -4980,7 +4978,7 @@ describe("history", () => {
isDeleted: true, isDeleted: true,
}), }),
], ],
storeAction: StoreAction.UPDATE, snapshotAction: SnapshotAction.UPDATE,
}); });
Keyboard.redo(); Keyboard.redo();

View file

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { Excalidraw, StoreAction } from "../../index"; import { Excalidraw, SnapshotAction } from "../../index";
import type { ExcalidrawImperativeAPI } from "../../types"; import type { ExcalidrawImperativeAPI } from "../../types";
import { resolvablePromise } from "../../utils"; import { resolvablePromise } from "../../utils";
import { render } from "../test-utils"; import { render } from "../test-utils";
@ -31,7 +31,7 @@ describe("event callbacks", () => {
excalidrawAPI.onChange(onChange); excalidrawAPI.onChange(onChange);
API.updateScene({ API.updateScene({
appState: { viewBackgroundColor: "red" }, appState: { viewBackgroundColor: "red" },
storeAction: StoreAction.CAPTURE, snapshotAction: SnapshotAction.CAPTURE,
}); });
expect(onChange).toHaveBeenCalledWith( expect(onChange).toHaveBeenCalledWith(
// elements // elements

View file

@ -10,6 +10,6 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Node", "moduleResolution": "Node",
"resolveJsonModule": true, "resolveJsonModule": true,
"jsx": "react-jsx", "jsx": "react-jsx"
} }
} }

View file

@ -40,7 +40,11 @@ import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
import type { ContextMenuItems } from "./components/ContextMenu"; import type { ContextMenuItems } from "./components/ContextMenu";
import type { SnapLine } from "./snapping"; import type { SnapLine } from "./snapping";
import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types"; import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types";
import type { StoreActionType, StoreIncrement } from "./store"; import type {
DurableStoreIncrement,
EphemeralStoreIncrement,
SnapshotActionType,
} from "./store";
export type SocketId = string & { _brand: "SocketId" }; export type SocketId = string & { _brand: "SocketId" };
@ -498,7 +502,9 @@ export interface ExcalidrawProps {
appState: AppState, appState: AppState,
files: BinaryFiles, files: BinaryFiles,
) => void; ) => void;
onIncrement?: (event: StoreIncrement) => void; onIncrement?: (
event: DurableStoreIncrement | EphemeralStoreIncrement,
) => void;
initialData?: initialData?:
| (() => MaybePromise<ExcalidrawInitialDataState | null>) | (() => MaybePromise<ExcalidrawInitialDataState | null>)
| MaybePromise<ExcalidrawInitialDataState | null>; | MaybePromise<ExcalidrawInitialDataState | null>;
@ -572,7 +578,7 @@ export type SceneData = {
elements?: ImportedDataState["elements"]; elements?: ImportedDataState["elements"];
appState?: ImportedDataState["appState"]; appState?: ImportedDataState["appState"];
collaborators?: Map<SocketId, Collaborator>; collaborators?: Map<SocketId, Collaborator>;
storeAction?: StoreActionType; snapshotAction?: SnapshotActionType;
}; };
export enum UserIdleState { export enum UserIdleState {
@ -785,7 +791,7 @@ export interface ExcalidrawImperativeAPI {
) => void, ) => void,
) => UnsubscribeCallback; ) => UnsubscribeCallback;
onIncrement: ( onIncrement: (
callback: (event: StoreIncrement) => void, callback: (event: DurableStoreIncrement | EphemeralStoreIncrement) => void,
) => UnsubscribeCallback; ) => UnsubscribeCallback;
onPointerDown: ( onPointerDown: (
callback: ( callback: (

View file

@ -14,7 +14,7 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx"
}, },
"include": ["packages", "excalidraw-app"], "include": ["packages", "excalidraw-app"],
"exclude": ["packages/excalidraw/types", "examples"] "exclude": ["packages/excalidraw/types", "examples"]