Cache received changes, ignore snapshot cache for durable changes, revert StoreAction, history fix, indices fix

mrazator/delta-based-sync
Marcel Mraz 1 week ago
parent 310a9ae4e0
commit 7e0f5b6369
No known key found for this signature in database
GPG Key ID: 4EBD6E62DC830CD2

@ -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 |
| `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. |
| `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`. |
| `storeAction` | `StoreAction` | 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 `StoreAction.CAPTURE`. |
```jsx live
function App() {

@ -24,7 +24,7 @@ import {
Excalidraw,
LiveCollaborationTrigger,
TTDDialogTrigger,
SnapshotAction,
StoreAction,
reconcileElements,
newElementWith,
} from "../packages/excalidraw";
@ -527,7 +527,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
}
});
@ -554,7 +554,7 @@ const ExcalidrawWrapper = () => {
setLangCode(getPreferredLanguage());
excalidrawAPI.updateScene({
...localDataState,
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
LibraryIndexedDBAdapter.load().then((data) => {
if (data) {
@ -686,7 +686,7 @@ const ExcalidrawWrapper = () => {
if (didChange) {
excalidrawAPI.updateScene({
elements,
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
}
}
@ -856,9 +856,15 @@ const ExcalidrawWrapper = () => {
const debouncedTimeTravel = debounce(
(value: number, direction: "forward" | "backward") => {
let elements = new Map(
excalidrawAPI?.getSceneElements().map((x) => [x.id, x]),
);
if (!excalidrawAPI) {
return;
}
let nextAppState = excalidrawAPI.getAppState();
// CFDO: retrieve the scene map already
let nextElements = new Map(
excalidrawAPI.getSceneElements().map((x) => [x.id, x]),
) as SceneElementsMap;
let deltas: StoreDelta[] = [];
@ -879,19 +885,20 @@ const ExcalidrawWrapper = () => {
}
for (const delta of deltas) {
[elements] = delta.elements.applyTo(
elements as SceneElementsMap,
excalidrawAPI?.store.snapshot.elements!,
[nextElements, nextAppState] = excalidrawAPI.store.applyDeltaTo(
delta,
nextElements,
nextAppState,
);
}
excalidrawAPI?.updateScene({
appState: {
...excalidrawAPI?.getAppState(),
...nextAppState,
viewModeEnabled: value !== acknowledgedDeltas.length,
},
elements: Array.from(elements.values()),
snapshotAction: SnapshotAction.NONE,
elements: Array.from(nextElements.values()),
storeAction: StoreAction.UPDATE,
});
},
0,
@ -918,7 +925,6 @@ const ExcalidrawWrapper = () => {
value={sliderVersion}
onChange={(value) => {
const nextSliderVersion = value as number;
// CFDO II: should be disabled when offline! (later we could have speculative changes in the versioning log as well)
// CFDO: in safari the whole canvas gets selected when dragging
if (nextSliderVersion !== acknowledgedDeltas.length) {
// don't listen to updates in the detached mode

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -419,7 +419,7 @@ import { COLOR_PALETTE } from "../colors";
import { ElementCanvasButton } from "./MagicButton";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import FollowMode from "./FollowMode/FollowMode";
import { Store, SnapshotAction } from "../store";
import { Store, StoreAction } from "../store";
import { AnimationFrameHandler } from "../animation-frame-handler";
import { AnimatedTrail } from "../animated-trail";
import { LaserTrails } from "../laser-trails";
@ -2093,12 +2093,12 @@ class App extends React.Component<AppProps, AppState> {
if (shouldUpdateStrokeColor) {
this.syncActionResult({
appState: { ...this.state, currentItemStrokeColor: color },
storeAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
} else {
this.syncActionResult({
appState: { ...this.state, currentItemBackgroundColor: color },
storeAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
}
} else {
@ -2112,7 +2112,7 @@ class App extends React.Component<AppProps, AppState> {
}
return el;
}),
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
}
},
@ -2334,7 +2334,7 @@ class App extends React.Component<AppProps, AppState> {
this.resetHistory();
this.syncActionResult({
...scene,
storeAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
// clear the shape and image cache so that any images in initialData
@ -3869,45 +3869,48 @@ class App extends React.Component<AppProps, AppState> {
elements?: SceneData["elements"];
appState?: Pick<AppState, K> | null;
collaborators?: SceneData["collaborators"];
/** @default SnapshotAction.NONE */
snapshotAction?: SceneData["snapshotAction"];
/** @default StoreAction.NONE */
storeAction?: SceneData["storeAction"];
}) => {
// flush all pending updates (if any) most of the time it's no-op
// flush all pending updates (if any), most of the time it should be a 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 { elements, appState, collaborators, storeAction } = sceneData;
const nextElements = elements
? syncInvalidIndices(elements)
: undefined;
if (sceneData.snapshotAction) {
if (storeAction) {
const prevCommittedAppState = this.store.snapshot.appState;
const prevCommittedElements = this.store.snapshot.elements;
const nextCommittedAppState = sceneData.appState
? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
const nextCommittedAppState = appState
? Object.assign({}, prevCommittedAppState, appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
: prevCommittedAppState;
const nextCommittedElements = sceneData.elements
const nextCommittedElements = elements
? this.store.filterUncomittedElements(
this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements
arrayToMap(nextElements), // We expect all (already reconciled) elements
arrayToMap(nextElements ?? []), // We expect all (already reconciled) elements
)
: prevCommittedElements;
this.store.scheduleAction(sceneData.snapshotAction);
this.store.scheduleAction(storeAction);
this.store.commit(nextCommittedElements, nextCommittedAppState);
}
if (sceneData.appState) {
this.setState(sceneData.appState);
if (appState) {
this.setState(appState);
}
if (sceneData.elements) {
if (nextElements) {
this.scene.replaceAllElements(nextElements);
}
if (sceneData.collaborators) {
this.setState({ collaborators: sceneData.collaborators });
if (collaborators) {
this.setState({ collaborators });
}
});
},
@ -4571,7 +4574,7 @@ class App extends React.Component<AppProps, AppState> {
if (!event.altKey) {
if (this.flowChartNavigator.isExploring) {
this.flowChartNavigator.clear();
this.syncActionResult({ storeAction: SnapshotAction.CAPTURE });
this.syncActionResult({ storeAction: StoreAction.CAPTURE });
}
}
@ -4618,7 +4621,7 @@ class App extends React.Component<AppProps, AppState> {
}
this.flowChartCreator.clear();
this.syncActionResult({ storeAction: SnapshotAction.CAPTURE });
this.syncActionResult({ storeAction: StoreAction.CAPTURE });
}
}
});
@ -6347,10 +6350,10 @@ class App extends React.Component<AppProps, AppState> {
this.state,
),
},
snapshotAction:
storeAction:
this.state.openDialog?.name === "elementLinkSelector"
? SnapshotAction.NONE
: SnapshotAction.UPDATE,
? StoreAction.NONE
: StoreAction.UPDATE,
});
return;
}
@ -9002,7 +9005,7 @@ class App extends React.Component<AppProps, AppState> {
appState: {
newElement: null,
},
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
return;
@ -9172,7 +9175,7 @@ class App extends React.Component<AppProps, AppState> {
elements: this.scene
.getElementsIncludingDeleted()
.filter((el) => el.id !== resizingElement.id),
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
}
@ -10137,7 +10140,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: false,
},
replaceFiles: true,
storeAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
return;
} catch (error: any) {
@ -10255,7 +10258,7 @@ class App extends React.Component<AppProps, AppState> {
// restore the fractional indices by mutating 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
this.store.scheduleAction(SnapshotAction.UPDATE);
this.store.scheduleAction(StoreAction.UPDATE);
this.store.commit(arrayToMap(elements), this.state);
this.setState({ isLoading: true });
@ -10266,7 +10269,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: false,
},
replaceFiles: true,
storeAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
} else if (ret.type === MIME_TYPES.excalidrawlib) {
await this.library

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

@ -1271,7 +1271,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
});
}
// CFDO II: this looks wrong
// CFDO: this looks wrong
if (isImageElement(element)) {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset

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

@ -106,6 +106,7 @@ export class History {
[nextElements, nextAppState, containsVisibleChange] =
this.store.applyDeltaTo(historyEntry, nextElements, nextAppState, {
triggerIncrement: true,
updateSnapshot: true,
});
prevSnapshot = this.store.snapshot;

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

@ -296,6 +296,7 @@ class Scene {
validateIndicesThrottled(_nextElements);
// CFDO: if technically this leads to modifying the indices, it should update the snapshot immediately (as it shall be an non-undoable change)
this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear();
this.elements.forEach((element) => {

@ -8,11 +8,13 @@ import { deepCopyElement } from "./element/newElement";
import type { AppState, ObservedAppState } from "./types";
import type { DTO, ValueOf } from "./utility-types";
import type {
ExcalidrawElement,
OrderedExcalidrawElement,
SceneElementsMap,
} from "./element/types";
import { arrayToMap, assertNever } from "./utils";
import { hashElementsVersion } from "./element";
import { assertNever } from "./utils";
import { syncMovedIndices } from "./fractionalIndex";
// hidden non-enumerable property for runtime checks
const hiddenObservedAppStateProp = "__observedAppState";
@ -43,7 +45,7 @@ const isObservedAppState = (
!!Reflect.get(appState, hiddenObservedAppStateProp);
// 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 = {
export const StoreAction = {
/**
* Immediately undoable.
*
@ -68,7 +70,7 @@ export const SnapshotAction = {
* Use for updates which should not be captured as deltas immediately, such as
* exceptions which are part of some async multi-step proces.
*
* These updates will be captured with the next `SnapshotAction.CAPTURE`,
* These updates will be captured with the next `StoreAction.CAPTURE`,
* triggered either by the next `updateScene` or internally by the editor.
*
* These updates will _eventually_ make it to the local undo / redo stacks.
@ -78,7 +80,7 @@ export const SnapshotAction = {
NONE: "NONE",
} as const;
export type SnapshotActionType = ValueOf<typeof SnapshotAction>;
export type StoreActionType = ValueOf<typeof StoreAction>;
/**
* Store which captures the observed changes and emits them as `StoreIncrement` events.
@ -98,9 +100,9 @@ export class Store {
this._snapshot = snapshot;
}
private scheduledActions: Set<SnapshotActionType> = new Set();
private scheduledActions: Set<StoreActionType> = new Set();
public scheduleAction(action: SnapshotActionType) {
public scheduleAction(action: StoreActionType) {
this.scheduledActions.add(action);
this.satisfiesScheduledActionsInvariant();
}
@ -110,26 +112,27 @@ export class Store {
*/
// TODO: Suspicious that this is called so many places. Seems error-prone.
public scheduleCapture() {
this.scheduleAction(SnapshotAction.CAPTURE);
this.scheduleAction(StoreAction.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;
if (this.scheduledActions.has(StoreAction.CAPTURE)) {
return StoreAction.CAPTURE;
}
// Update has a precedence over none, since it also emits an (ephemeral) increment
if (this.scheduledActions.has(SnapshotAction.UPDATE)) {
return SnapshotAction.UPDATE;
if (this.scheduledActions.has(StoreAction.UPDATE)) {
return StoreAction.UPDATE;
}
// CFDO: maybe it should be explicitly set so that we don't clone on every single component update
// Emit ephemeral increment, don't update the snapshot
return SnapshotAction.NONE;
return StoreAction.NONE;
}
/**
* Performs the incoming `SnapshotAction` and emits the corresponding `StoreIncrement`.
* Performs the incoming `StoreAction` and emits the corresponding `StoreIncrement`.
* Emits `DurableStoreIncrement` when action is "capture", emits `EphemeralStoreIncrement` otherwise.
*
* @emits StoreIncrement
@ -142,13 +145,14 @@ export class Store {
const { scheduledAction } = this;
switch (scheduledAction) {
case SnapshotAction.CAPTURE:
case StoreAction.CAPTURE:
this.snapshot = this.captureDurableIncrement(elements, appState);
break;
case SnapshotAction.UPDATE:
case StoreAction.UPDATE:
this.snapshot = this.emitEphemeralIncrement(elements);
break;
case SnapshotAction.NONE:
case StoreAction.NONE:
// ÇFDO: consider perf. optimisation without creating a snapshot if it is not updated in the end, it shall not be needed (more complex though)
this.emitEphemeralIncrement(elements);
return;
default:
@ -171,7 +175,9 @@ export class Store {
appState: AppState | ObservedAppState | undefined,
) {
const prevSnapshot = this.snapshot;
const nextSnapshot = this.snapshot.maybeClone(elements, appState);
const nextSnapshot = this.snapshot.maybeClone(elements, appState, {
shouldIgnoreCache: true,
});
// Optimisation, don't continue if nothing has changed
if (prevSnapshot === nextSnapshot) {
@ -229,14 +235,16 @@ export class Store {
* This is necessary in updates in which we receive reconciled elements, already containing elements which were not yet captured by the local store (i.e. collab).
*/
public filterUncomittedElements(
prevElements: Map<string, OrderedExcalidrawElement>,
nextElements: Map<string, OrderedExcalidrawElement>,
) {
prevElements: Map<string, ExcalidrawElement>,
nextElements: Map<string, ExcalidrawElement>,
): Map<string, OrderedExcalidrawElement> {
const movedElements = new Map<string, ExcalidrawElement>();
for (const [id, prevElement] of prevElements.entries()) {
const nextElement = nextElements.get(id);
if (!nextElement) {
// Nothing to care about here, elements were forcefully deleted
// Nothing to care about here, element was forcefully deleted
continue;
}
@ -249,10 +257,18 @@ export class Store {
} else if (elementSnapshot.version < prevElement.version) {
// Element was already commited, but the snapshot version is lower than current local version
nextElements.set(id, elementSnapshot);
// Mark the element as potentially moved, as it could have
movedElements.set(id, elementSnapshot);
}
}
return nextElements;
// Make sure to sync only potentially invalid indices for all elements restored from the snapshot
const syncedElements = syncMovedIndices(
Array.from(nextElements.values()),
movedElements,
);
return arrayToMap(syncedElements);
}
/**
@ -266,8 +282,10 @@ export class Store {
appState: AppState,
options: {
triggerIncrement: boolean;
updateSnapshot: boolean;
} = {
triggerIncrement: false,
updateSnapshot: false,
},
): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
@ -282,7 +300,9 @@ export class Store {
elementsContainVisibleChange || appStateContainsVisibleChange;
const prevSnapshot = this.snapshot;
const nextSnapshot = this.snapshot.maybeClone(nextElements, nextAppState);
const nextSnapshot = this.snapshot.maybeClone(nextElements, nextAppState, {
shouldIgnoreCache: true,
});
if (options.triggerIncrement) {
const change = StoreChange.create(prevSnapshot, nextSnapshot);
@ -290,7 +310,12 @@ export class Store {
this.onStoreIncrementEmitter.trigger(increment);
}
this.snapshot = nextSnapshot;
// CFDO: maybe I should not update the snapshot here so that it always syncs ephemeral change after durable change,
// so that clients exchange the latest element versions between each other,
// meaning if it will be ignored on other clients, other clients would initiate a relay with current version instead of doing nothing
if (options.updateSnapshot) {
this.snapshot = nextSnapshot;
}
return [nextElements, nextAppState, appliedVisibleChanges];
}
@ -307,7 +332,7 @@ export class Store {
if (
!(
this.scheduledActions.size >= 0 &&
this.scheduledActions.size <= Object.keys(SnapshotAction).length
this.scheduledActions.size <= Object.keys(StoreAction).length
)
) {
const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledActions.size}".`;
@ -441,9 +466,7 @@ export class StoreDelta {
* Inverse store delta, creates new instance of `StoreDelta`.
*/
public static inverse(delta: StoreDelta): StoreDelta {
return this.create(delta.elements.inverse(), delta.appState.inverse(), {
id: delta.id,
});
return this.create(delta.elements.inverse(), delta.appState.inverse());
}
/**
@ -538,8 +561,16 @@ export class StoreSnapshot {
public maybeClone(
elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined,
options: {
shouldIgnoreCache: boolean;
} = {
shouldIgnoreCache: false,
},
) {
const nextElementsSnapshot = this.maybeCreateElementsSnapshot(elements);
const nextElementsSnapshot = this.maybeCreateElementsSnapshot(
elements,
options,
);
const nextAppStateSnapshot = this.maybeCreateAppStateSnapshot(appState);
let didElementsChange = false;
@ -597,12 +628,17 @@ export class StoreSnapshot {
private maybeCreateElementsSnapshot(
elements: Map<string, OrderedExcalidrawElement> | undefined,
options: {
shouldIgnoreCache: boolean;
} = {
shouldIgnoreCache: false,
},
) {
if (!elements) {
return this.elements;
}
const changedElements = this.detectChangedElements(elements);
const changedElements = this.detectChangedElements(elements, options);
if (!changedElements?.size) {
return this.elements;
@ -619,6 +655,11 @@ export class StoreSnapshot {
*/
private detectChangedElements(
nextElements: Map<string, OrderedExcalidrawElement>,
options: {
shouldIgnoreCache: boolean;
} = {
shouldIgnoreCache: false,
},
) {
if (this.elements === nextElements) {
return;
@ -653,10 +694,18 @@ export class StoreSnapshot {
return;
}
// if we wouldn't ignore a cache, durable increment would be skipped
// in case there was an ephemeral increment emitter just before
// with the same changed elements
if (options.shouldIgnoreCache) {
return changedElements;
}
// 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
// otherwise we would have ephemeral increments even for component updates unrelated to elements
const changedElementsHash = hashElementsVersion(
Array.from(changedElements.values()),
);

@ -10,10 +10,10 @@ import {
type MetadataRepository,
type DeltasRepository,
} from "./queue";
import { SnapshotAction, StoreDelta } from "../store";
import { StoreAction, StoreDelta } from "../store";
import type { StoreChange } from "../store";
import type { ExcalidrawImperativeAPI } from "../types";
import type { SceneElementsMap } from "../element/types";
import type { ExcalidrawElement, SceneElementsMap } from "../element/types";
import type { CLIENT_MESSAGE_RAW, SERVER_DELTA, CHANGE } from "./protocol";
import { debounce } from "../utils";
import { randomId } from "../random";
@ -38,7 +38,7 @@ class SocketClient {
private isOffline = true;
private socket: ReconnectingWebSocket | null = null;
private get isDisconnected() {
public get isDisconnected() {
return !this.socket;
}
@ -204,6 +204,11 @@ export class SyncClient {
private readonly metadata: MetadataRepository;
private readonly client: SocketClient;
private relayedElementsVersionsCache = new Map<
string,
ExcalidrawElement["version"]
>();
// #region ACKNOWLEDGED DELTAS & METADATA
// CFDO: shouldn't be stateful, only request / response
private readonly acknowledgedDeltasMap: Map<string, AcknowledgedDelta> =
@ -264,11 +269,12 @@ export class SyncClient {
// #region PUBLIC API METHODS
public connect() {
return this.client.connect();
this.client.connect();
}
public disconnect() {
return this.client.disconnect();
this.client.disconnect();
this.relayedElementsVersionsCache.clear();
}
public pull(sinceVersion?: number): void {
@ -298,6 +304,32 @@ export class SyncClient {
// CFDO: should be throttled! 60 fps for live scenes, 10s or so for single player
public relay(change: StoreChange): void {
if (this.client.isDisconnected) {
// don't reconnect if we're explicitly disconnected
// otherwise versioning slider would trigger sync on every slider step
return;
}
let shouldRelay = false;
for (const [id, element] of Object.entries(change.elements)) {
const cachedElementVersion = this.relayedElementsVersionsCache.get(id);
if (!cachedElementVersion || cachedElementVersion < element.version) {
this.relayedElementsVersionsCache.set(id, element.version);
if (!shouldRelay) {
// it's enough that a single element is not cached or is outdated in cache
// to relay the whole change, otherwise we skip the relay as we've already received this change
shouldRelay = true;
}
}
}
if (!shouldRelay) {
return;
}
this.client.send({
type: "relay",
payload: { ...change },
@ -357,12 +389,13 @@ export class SyncClient {
existingElement.version < relayedElement.version // updated element
) {
nextElements.set(id, relayedElement);
this.relayedElementsVersionsCache.set(id, relayedElement.version);
}
}
this.api.updateScene({
elements: Array.from(nextElements.values()),
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
} catch (e) {
console.error("Failed to apply relayed change:", e);
@ -426,16 +459,22 @@ export class SyncClient {
delta,
nextElements,
appState,
{
triggerIncrement: false,
updateSnapshot: true,
},
);
prevSnapshot = this.api.store.snapshot;
}
// CFDO: I still need to filter out uncomitted elements
// I still need to update snapshot with the new elements
// CFDO: might need to restore first due to potentially stale delta versions
this.api.updateScene({
elements: Array.from(nextElements.values()),
snapshotAction: SnapshotAction.NONE,
// even though the snapshot should be up-to-date already,
// still some more updates might be triggered,
// i.e. as a result from syncing invalid indices
storeAction: StoreAction.UPDATE,
});
this.lastAcknowledgedVersion = nextAcknowledgedVersion;

@ -42,7 +42,7 @@ import {
import { vi } from "vitest";
import { queryByText } from "@testing-library/react";
import { AppStateDelta, ElementsDelta } from "../delta";
import { SnapshotAction, StoreDelta } from "../store";
import { StoreAction, StoreDelta } from "../store";
import type { LocalPoint, Radians } from "../../math";
import { pointFrom } from "../../math";
import type { AppState } from "../types.js";
@ -216,7 +216,7 @@ describe("history", () => {
API.updateScene({
elements: [rect1, rect2],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
expect(API.getUndoStack().length).toBe(1);
@ -228,7 +228,7 @@ describe("history", () => {
API.updateScene({
elements: [rect1, rect2],
snapshotAction: SnapshotAction.CAPTURE, // even though the flag is on, same elements are passed, nothing to commit
storeAction: StoreAction.CAPTURE, // even though the flag is on, same elements are passed, nothing to commit
});
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
@ -596,7 +596,7 @@ describe("history", () => {
appState: {
name: "New name",
},
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
expect(API.getUndoStack().length).toBe(1);
@ -607,7 +607,7 @@ describe("history", () => {
appState: {
viewBackgroundColor: "#000",
},
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
@ -620,7 +620,7 @@ describe("history", () => {
name: "New name",
viewBackgroundColor: "#000",
},
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
@ -1327,7 +1327,7 @@ describe("history", () => {
API.updateScene({
elements: [rect1, text, rect2],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
// bind text1 to rect1
@ -1899,7 +1899,7 @@ describe("history", () => {
strokeColor: blue,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.undo();
@ -1937,7 +1937,7 @@ describe("history", () => {
strokeColor: yellow,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.undo();
@ -1985,7 +1985,7 @@ describe("history", () => {
backgroundColor: yellow,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
// At this point our entry gets updated from `red` -> `blue` into `red` -> `yellow`
@ -2001,7 +2001,7 @@ describe("history", () => {
backgroundColor: violet,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
// At this point our (inversed) entry gets updated from `red` -> `yellow` into `violet` -> `yellow`
@ -2046,7 +2046,7 @@ describe("history", () => {
API.updateScene({
elements: [rect, diamond],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
// Connect the arrow
@ -2095,7 +2095,7 @@ describe("history", () => {
} as FixedPointBinding,
},
],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
Keyboard.undo();
@ -2110,7 +2110,7 @@ describe("history", () => {
}
: el,
),
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.undo();
@ -2134,7 +2134,7 @@ describe("history", () => {
// Initialize scene
API.updateScene({
elements: [rect1, rect2],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
// Simulate local update
@ -2143,7 +2143,7 @@ describe("history", () => {
newElementWith(h.elements[0], { groupIds: ["A"] }),
newElementWith(h.elements[1], { groupIds: ["A"] }),
],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
const rect3 = API.createElement({ type: "rectangle", groupIds: ["B"] });
@ -2157,7 +2157,7 @@ describe("history", () => {
rect3,
rect4,
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.undo();
@ -2203,7 +2203,7 @@ describe("history", () => {
] as LocalPoint[],
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.undo(); // undo `actionFinalize`
@ -2298,7 +2298,7 @@ describe("history", () => {
isDeleted: false, // undeletion might happen due to concurrency between clients
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
expect(API.getSelectedElements()).toEqual([]);
@ -2375,7 +2375,7 @@ describe("history", () => {
isDeleted: true,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
expect(h.elements).toEqual([
@ -2437,7 +2437,7 @@ describe("history", () => {
isDeleted: true,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.undo();
@ -2513,7 +2513,7 @@ describe("history", () => {
isDeleted: true,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.undo();
@ -2552,7 +2552,7 @@ describe("history", () => {
isDeleted: false,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.redo();
@ -2598,7 +2598,7 @@ describe("history", () => {
// Simulate remote update
API.updateScene({
elements: [rect1, rect2],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.withModifierKeys({ ctrl: true }, () => {
@ -2608,7 +2608,7 @@ describe("history", () => {
// Simulate remote update
API.updateScene({
elements: [h.elements[0], h.elements[1], rect3, rect4],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.withModifierKeys({ ctrl: true }, () => {
@ -2629,7 +2629,7 @@ describe("history", () => {
isDeleted: true,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.undo();
@ -2654,7 +2654,7 @@ describe("history", () => {
isDeleted: false,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.redo();
@ -2665,7 +2665,7 @@ describe("history", () => {
// Simulate remote update
API.updateScene({
elements: [h.elements[0], h.elements[1], rect3, rect4],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.redo();
@ -2711,7 +2711,7 @@ describe("history", () => {
isDeleted: true,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.undo();
@ -2732,7 +2732,7 @@ describe("history", () => {
}),
h.elements[1],
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.undo();
@ -2775,7 +2775,7 @@ describe("history", () => {
isDeleted: true,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.undo();
@ -2818,7 +2818,7 @@ describe("history", () => {
h.elements[0],
h.elements[1],
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
expect(API.getUndoStack().length).toBe(2);
@ -2857,7 +2857,7 @@ describe("history", () => {
h.elements[0],
h.elements[1],
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
expect(API.getUndoStack().length).toBe(2);
@ -2908,7 +2908,7 @@ describe("history", () => {
h.elements[0], // rect2
h.elements[1], // rect1
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.undo();
@ -2938,7 +2938,7 @@ describe("history", () => {
h.elements[0], // rect3
h.elements[2], // rect1
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.undo();
@ -2968,7 +2968,7 @@ describe("history", () => {
// Simulate remote update
API.updateScene({
elements: [...h.elements, rect],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
mouse.moveTo(60, 60);
@ -3020,7 +3020,7 @@ describe("history", () => {
// // Simulate remote update
API.updateScene({
elements: [...h.elements, rect3],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
mouse.moveTo(100, 100);
@ -3110,7 +3110,7 @@ describe("history", () => {
// Simulate remote update
API.updateScene({
elements: [...h.elements, rect3],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
mouse.moveTo(100, 100);
@ -3287,7 +3287,7 @@ describe("history", () => {
// Initialize the scene
API.updateScene({
elements: [container, text],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
// Simulate local update
@ -3300,7 +3300,7 @@ describe("history", () => {
containerId: container.id,
}),
],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
Keyboard.undo();
@ -3331,7 +3331,7 @@ describe("history", () => {
x: h.elements[1].x + 10,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
runTwice(() => {
@ -3374,7 +3374,7 @@ describe("history", () => {
// Initialize the scene
API.updateScene({
elements: [container, text],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
// Simulate local update
@ -3387,7 +3387,7 @@ describe("history", () => {
containerId: container.id,
}),
],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
Keyboard.undo();
@ -3421,7 +3421,7 @@ describe("history", () => {
remoteText,
h.elements[1],
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
runTwice(() => {
@ -3477,7 +3477,7 @@ describe("history", () => {
// Initialize the scene
API.updateScene({
elements: [container, text],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
// Simulate local update
@ -3490,7 +3490,7 @@ describe("history", () => {
containerId: container.id,
}),
],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
Keyboard.undo();
@ -3527,7 +3527,7 @@ describe("history", () => {
containerId: remoteContainer.id,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
runTwice(() => {
@ -3585,7 +3585,7 @@ describe("history", () => {
// Simulate local update
API.updateScene({
elements: [container],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
// Simulate remote update
@ -3596,7 +3596,7 @@ describe("history", () => {
}),
newElementWith(text, { containerId: container.id }),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
runTwice(() => {
@ -3646,7 +3646,7 @@ describe("history", () => {
// Simulate local update
API.updateScene({
elements: [text],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
// Simulate remote update
@ -3657,7 +3657,7 @@ describe("history", () => {
}),
newElementWith(text, { containerId: container.id }),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
runTwice(() => {
@ -3706,7 +3706,7 @@ describe("history", () => {
// Simulate local update
API.updateScene({
elements: [container],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
// Simulate remote update
@ -3719,7 +3719,7 @@ describe("history", () => {
containerId: container.id,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.undo();
@ -3756,7 +3756,7 @@ describe("history", () => {
// rebinding the container with a new text element!
remoteText,
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
runTwice(() => {
@ -3813,7 +3813,7 @@ describe("history", () => {
// Simulate local update
API.updateScene({
elements: [text],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
// Simulate remote update
@ -3826,7 +3826,7 @@ describe("history", () => {
containerId: container.id,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.undo();
@ -3863,7 +3863,7 @@ describe("history", () => {
containerId: container.id,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
runTwice(() => {
@ -3919,7 +3919,7 @@ describe("history", () => {
// Simulate local update
API.updateScene({
elements: [container],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
// Simulate remote update
@ -3933,7 +3933,7 @@ describe("history", () => {
isDeleted: true,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
runTwice(() => {
@ -3976,7 +3976,7 @@ describe("history", () => {
// Simulate local update
API.updateScene({
elements: [text],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
// Simulate remote update
@ -3990,7 +3990,7 @@ describe("history", () => {
containerId: container.id,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
runTwice(() => {
@ -4033,7 +4033,7 @@ describe("history", () => {
// Initialize the scene
API.updateScene({
elements: [container],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
// Simulate local update
@ -4045,7 +4045,7 @@ describe("history", () => {
angle: 90 as Radians,
}),
],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
Keyboard.undo();
@ -4058,7 +4058,7 @@ describe("history", () => {
}),
newElementWith(text, { containerId: container.id }),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
expect(h.elements).toEqual([
@ -4151,7 +4151,7 @@ describe("history", () => {
// Initialize the scene
API.updateScene({
elements: [text],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
// Simulate local update
@ -4163,7 +4163,7 @@ describe("history", () => {
angle: 90 as Radians,
}),
],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
Keyboard.undo();
@ -4178,7 +4178,7 @@ describe("history", () => {
containerId: container.id,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
expect(API.getUndoStack().length).toBe(0);
@ -4269,7 +4269,7 @@ describe("history", () => {
// Simulate local update
API.updateScene({
elements: [rect1, rect2],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
mouse.reset();
@ -4358,7 +4358,7 @@ describe("history", () => {
x: h.elements[1].x + 50,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
runTwice(() => {
@ -4502,7 +4502,7 @@ describe("history", () => {
}),
remoteContainer,
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
runTwice(() => {
@ -4609,7 +4609,7 @@ describe("history", () => {
boundElements: [{ id: arrow.id, type: "arrow" }],
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
runTwice(() => {
@ -4686,7 +4686,7 @@ describe("history", () => {
// Simulate local update
API.updateScene({
elements: [arrow],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
// Simulate remote update
@ -4713,7 +4713,7 @@ describe("history", () => {
boundElements: [{ id: arrow.id, type: "arrow" }],
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
runTwice(() => {
@ -4845,7 +4845,7 @@ describe("history", () => {
newElementWith(h.elements[1], { x: 500, y: -500 }),
h.elements[2],
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.redo();
@ -4917,13 +4917,13 @@ describe("history", () => {
// Initialize the scene
API.updateScene({
elements: [frame],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
// Simulate local update
API.updateScene({
elements: [rect, h.elements[0]],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
// Simulate local update
@ -4934,7 +4934,7 @@ describe("history", () => {
}),
h.elements[1],
],
snapshotAction: SnapshotAction.CAPTURE,
storeAction: StoreAction.CAPTURE,
});
Keyboard.undo();
@ -4978,7 +4978,7 @@ describe("history", () => {
isDeleted: true,
}),
],
snapshotAction: SnapshotAction.UPDATE,
storeAction: StoreAction.UPDATE,
});
Keyboard.redo();

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

@ -43,7 +43,7 @@ import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types";
import type {
DurableStoreIncrement,
EphemeralStoreIncrement,
SnapshotActionType,
StoreActionType as StoreActionType,
} from "./store";
export type SocketId = string & { _brand: "SocketId" };
@ -578,7 +578,7 @@ export type SceneData = {
elements?: ImportedDataState["elements"];
appState?: ImportedDataState["appState"];
collaborators?: Map<SocketId, Collaborator>;
snapshotAction?: SnapshotActionType;
storeAction?: StoreActionType;
};
export enum UserIdleState {

Loading…
Cancel
Save