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

This commit is contained in:
Marcel Mraz 2025-01-21 11:34:42 +01:00
parent 310a9ae4e0
commit 7e0f5b6369
No known key found for this signature in database
GPG key ID: 4EBD6E62DC830CD2
50 changed files with 437 additions and 338 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. |
| `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 ```jsx live
function App() { function App() {

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import { SnapshotAction } from "../../packages/excalidraw"; import { StoreAction } 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;
}), }),
snapshotAction: SnapshotAction.UPDATE, storeAction: StoreAction.UPDATE,
}); });
}; };

View file

@ -11,7 +11,7 @@ import {
createRedoAction, createRedoAction,
createUndoAction, createUndoAction,
} from "../../packages/excalidraw/actions/actionHistory"; } from "../../packages/excalidraw/actions/actionHistory";
import { SnapshotAction, newElementWith } from "../../packages/excalidraw"; import { StoreAction, 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]),
snapshotAction: SnapshotAction.CAPTURE, storeAction: StoreAction.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 }),
]), ]),
snapshotAction: SnapshotAction.CAPTURE, storeAction: StoreAction.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]),
snapshotAction: SnapshotAction.UPDATE, storeAction: StoreAction.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 }),
]), ]),
snapshotAction: SnapshotAction.CAPTURE, storeAction: StoreAction.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]),
snapshotAction: SnapshotAction.UPDATE, storeAction: StoreAction.UPDATE,
}); });
// snapshot was correctly updated and marked the element as deleted // snapshot was correctly updated and marked the element as deleted

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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@ -106,7 +106,7 @@ export const actionAlignBottom = register({
position: "end", position: "end",
axis: "y", axis: "y",
}), }),
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@ -140,7 +140,7 @@ export const actionAlignLeft = register({
position: "start", position: "start",
axis: "x", axis: "x",
}), }),
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@ -174,7 +174,7 @@ export const actionAlignRight = register({
position: "end", position: "end",
axis: "x", axis: "x",
}), }),
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@ -208,7 +208,7 @@ export const actionAlignVerticallyCentered = register({
position: "center", position: "center",
axis: "y", axis: "y",
}), }),
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}, },
}); });
@ -323,7 +323,7 @@ export const actionWrapTextInContainer = register({
...appState, ...appState,
selectedElementIds: containerIds, selectedElementIds: containerIds,
}, },
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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
? SnapshotAction.CAPTURE ? StoreAction.CAPTURE
: SnapshotAction.NONE, : StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}, },
}); });
@ -140,7 +140,7 @@ export const actionZoomIn = register({
), ),
userToFollow: null, userToFollow: null,
}, },
storeAction: SnapshotAction.NONE, storeAction: StoreAction.NONE,
}; };
}, },
PanelComponent: ({ updateData, appState }) => ( PanelComponent: ({ updateData, appState }) => (
@ -181,7 +181,7 @@ export const actionZoomOut = register({
), ),
userToFollow: null, userToFollow: null,
}, },
storeAction: SnapshotAction.NONE, storeAction: StoreAction.NONE,
}; };
}, },
PanelComponent: ({ updateData, appState }) => ( PanelComponent: ({ updateData, appState }) => (
@ -222,7 +222,7 @@ export const actionResetZoom = register({
), ),
userToFollow: null, userToFollow: null,
}, },
storeAction: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.NONE, storeAction: StoreAction.NONE,
appState: { appState: {
...appState, ...appState,
errorMessage: error.message, errorMessage: error.message,
@ -41,7 +41,7 @@ export const actionCopy = register({
} }
return { return {
storeAction: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.NONE,
appState: { appState: {
...appState, ...appState,
errorMessage: t("errors.asyncPasteFailedOnParse"), errorMessage: t("errors.asyncPasteFailedOnParse"),
@ -98,7 +98,7 @@ export const actionPaste = register({
} }
return { return {
storeAction: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.NONE,
}; };
} }
@ -167,7 +167,7 @@ export const actionCopyAsSvg = register({
}), }),
}, },
}, },
storeAction: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.NONE,
}; };
} }
const selectedElements = app.scene.getSelectedElements({ const selectedElements = app.scene.getSelectedElements({
@ -227,7 +227,7 @@ export const actionCopyAsPng = register({
}), }),
}, },
}, },
storeAction: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.NONE,
}; };
} }
}, },
@ -263,7 +263,7 @@ export const copyText = register({
throw new Error(t("errors.copyToSystemClipboardFailed")); throw new Error(t("errors.copyToSystemClipboardFailed"));
} }
return { return {
storeAction: SnapshotAction.NONE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.CAPTURE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
} }
@ -221,7 +221,7 @@ export const actionDeleteSelected = register({
: [0], : [0],
}, },
}, },
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.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,
) )
? SnapshotAction.CAPTURE ? StoreAction.CAPTURE
: SnapshotAction.NONE, : StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@ -91,7 +91,7 @@ export const distributeVertically = register({
space: "between", space: "between",
axis: "y", axis: "y",
}), }),
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
} catch { } catch {
return false; return false;
@ -61,7 +61,7 @@ export const actionDuplicateSelection = register({
return { return {
...duplicateElements(elements, appState), ...duplicateElements(elements, appState),
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE }; return { storeAction: StoreAction.NONE };
} }
}, },
// CFDO: temporary // CFDO: temporary
@ -208,7 +208,7 @@ export const actionSaveFileToDisk = register({
app.getName(), app.getName(),
); );
return { return {
storeAction: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE }; return { storeAction: StoreAction.NONE };
} }
}, },
keyTest: (event) => keyTest: (event) =>
@ -261,7 +261,7 @@ export const actionLoadScene = register({
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
files, files,
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } from "../store";
import { import {
isArrowElement, isArrowElement,
isElbowArrow, isElbowArrow,
@ -47,7 +47,7 @@ export const actionFlipHorizontal = register({
app, app,
), ),
appState, appState,
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>

View file

@ -13,7 +13,7 @@ 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"; import { StoreAction } 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: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
} }
return { return {
elements, elements,
appState, appState,
storeAction: SnapshotAction.NONE, storeAction: StoreAction.NONE,
}; };
}, },
predicate: (elements, appState, _, app) => predicate: (elements, appState, _, app) =>
@ -80,14 +80,14 @@ export const actionRemoveAllElementsFromFrame = register({
[selectedElement.id]: true, [selectedElement.id]: true,
}, },
}, },
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
} }
return { return {
elements, elements,
appState, appState,
storeAction: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.NONE }; return { appState, elements, storeAction: StoreAction.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: SnapshotAction.NONE }; return { appState, elements, storeAction: StoreAction.NONE };
} }
} }
@ -170,7 +170,7 @@ export const actionGroup = register({
), ),
}, },
elements: reorderedElements, elements: reorderedElements,
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.NONE }; return { appState, elements, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>

View file

@ -9,7 +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 { SnapshotAction } from "../store"; import { StoreAction } from "../store";
import { useEmitter } from "../hooks/useEmitter"; import { useEmitter } from "../hooks/useEmitter";
const executeHistoryAction = ( const executeHistoryAction = (
@ -29,7 +29,7 @@ const executeHistoryAction = (
const result = updater(); const result = updater();
if (!result) { if (!result) {
return { storeAction: SnapshotAction.NONE }; return { storeAction: StoreAction.NONE };
} }
const [nextElementsMap, nextAppState] = result; const [nextElementsMap, nextAppState] = result;
@ -38,11 +38,11 @@ const executeHistoryAction = (
return { return {
appState: nextAppState, appState: nextAppState,
elements: nextElements, elements: nextElements,
storeAction: SnapshotAction.UPDATE, storeAction: StoreAction.UPDATE,
}; };
} }
return { storeAction: SnapshotAction.NONE }; return { storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.CAPTURE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.CAPTURE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.NONE,
}), }),
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
@ -74,7 +74,7 @@ export const actionShortcuts = register({
name: "help", name: "help",
}, },
}, },
storeAction: SnapshotAction.NONE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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 { SnapshotActionType } from "../store"; import type { StoreActionType } 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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}; };
@ -301,8 +301,8 @@ export const actionChangeStrokeColor = register({
...value, ...value,
}, },
storeAction: !!value.currentItemStrokeColor storeAction: !!value.currentItemStrokeColor
? SnapshotAction.CAPTURE ? StoreAction.CAPTURE
: SnapshotAction.NONE, : StoreAction.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
? SnapshotAction.CAPTURE ? StoreAction.CAPTURE
: SnapshotAction.NONE, : StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
@ -814,22 +814,22 @@ export const actionChangeFontFamily = register({
...appState, ...appState,
...nextAppState, ...nextAppState,
}, },
storeAction: SnapshotAction.UPDATE, storeAction: StoreAction.UPDATE,
}; };
} }
const { currentItemFontFamily, currentHoveredFontFamily } = value; const { currentItemFontFamily, currentHoveredFontFamily } = value;
let nexStoreAction: SnapshotActionType = SnapshotAction.NONE; let nexStoreAction: StoreActionType = StoreAction.NONE;
let nextFontFamily: FontFamilyValues | undefined; let nextFontFamily: FontFamilyValues | undefined;
let skipOnHoverRender = false; let skipOnHoverRender = false;
if (currentItemFontFamily) { if (currentItemFontFamily) {
nextFontFamily = currentItemFontFamily; nextFontFamily = currentItemFontFamily;
nexStoreAction = SnapshotAction.CAPTURE; nexStoreAction = StoreAction.CAPTURE;
} else if (currentHoveredFontFamily) { } else if (currentHoveredFontFamily) {
nextFontFamily = currentHoveredFontFamily; nextFontFamily = currentHoveredFontFamily;
nexStoreAction = SnapshotAction.NONE; nexStoreAction = StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
@ -1277,7 +1277,7 @@ export const actionChangeVerticalAlign = register({
appState: { appState: {
...appState, ...appState,
}, },
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.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,
snapshotAction: SnapshotAction.CAPTURE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.CAPTURE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE }; return { elements, storeAction: StoreAction.NONE };
} }
const selectedElements = getSelectedElements(elements, appState, { const selectedElements = getSelectedElements(elements, appState, {
@ -159,7 +159,7 @@ export const actionPasteStyles = register({
} }
return element; return element;
}), }),
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.CAPTURE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.NONE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.NONE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.NONE, storeAction: StoreAction.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: SnapshotAction.NONE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.NONE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.NONE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.NONE, storeAction: StoreAction.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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.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: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>

View file

@ -10,7 +10,7 @@ import type {
BinaryFiles, BinaryFiles,
UIAppState, UIAppState,
} from "../types"; } from "../types";
import type { SnapshotActionType } from "../store"; import type { StoreActionType } 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: SnapshotActionType; storeAction: StoreActionType;
replaceFiles?: boolean; replaceFiles?: boolean;
} }
| false; | false;

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, SnapshotAction } from "../store"; import { Store, StoreAction } 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";
@ -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: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}); });
} else { } else {
this.syncActionResult({ this.syncActionResult({
appState: { ...this.state, currentItemBackgroundColor: color }, appState: { ...this.state, currentItemBackgroundColor: color },
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}); });
} }
} else { } else {
@ -2112,7 +2112,7 @@ class App extends React.Component<AppProps, AppState> {
} }
return el; return el;
}), }),
snapshotAction: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}); });
} }
}, },
@ -2334,7 +2334,7 @@ class App extends React.Component<AppProps, AppState> {
this.resetHistory(); this.resetHistory();
this.syncActionResult({ this.syncActionResult({
...scene, ...scene,
storeAction: SnapshotAction.UPDATE, storeAction: StoreAction.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
@ -3869,45 +3869,48 @@ 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 SnapshotAction.NONE */ /** @default StoreAction.NONE */
snapshotAction?: SceneData["snapshotAction"]; 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(() => {}); flushSync(() => {});
// flush all incoming updates immediately, so that they couldn't be batched with other updates, having different `storeAction` // flush all incoming updates immediately, so that they couldn't be batched with other updates, having different `storeAction`
flushSync(() => { 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 prevCommittedAppState = this.store.snapshot.appState;
const prevCommittedElements = this.store.snapshot.elements; const prevCommittedElements = this.store.snapshot.elements;
const nextCommittedAppState = sceneData.appState const nextCommittedAppState = appState
? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState` ? Object.assign({}, prevCommittedAppState, appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
: prevCommittedAppState; : prevCommittedAppState;
const nextCommittedElements = sceneData.elements const nextCommittedElements = elements
? this.store.filterUncomittedElements( ? this.store.filterUncomittedElements(
this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements 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; : prevCommittedElements;
this.store.scheduleAction(sceneData.snapshotAction); this.store.scheduleAction(storeAction);
this.store.commit(nextCommittedElements, nextCommittedAppState); this.store.commit(nextCommittedElements, nextCommittedAppState);
} }
if (sceneData.appState) { if (appState) {
this.setState(sceneData.appState); this.setState(appState);
} }
if (sceneData.elements) { if (nextElements) {
this.scene.replaceAllElements(nextElements); this.scene.replaceAllElements(nextElements);
} }
if (sceneData.collaborators) { if (collaborators) {
this.setState({ collaborators: sceneData.collaborators }); this.setState({ collaborators });
} }
}); });
}, },
@ -4571,7 +4574,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: SnapshotAction.CAPTURE }); this.syncActionResult({ storeAction: StoreAction.CAPTURE });
} }
} }
@ -4618,7 +4621,7 @@ class App extends React.Component<AppProps, AppState> {
} }
this.flowChartCreator.clear(); 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, this.state,
), ),
}, },
snapshotAction: storeAction:
this.state.openDialog?.name === "elementLinkSelector" this.state.openDialog?.name === "elementLinkSelector"
? SnapshotAction.NONE ? StoreAction.NONE
: SnapshotAction.UPDATE, : StoreAction.UPDATE,
}); });
return; return;
} }
@ -9002,7 +9005,7 @@ class App extends React.Component<AppProps, AppState> {
appState: { appState: {
newElement: null, newElement: null,
}, },
snapshotAction: SnapshotAction.UPDATE, storeAction: StoreAction.UPDATE,
}); });
return; return;
@ -9172,7 +9175,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),
snapshotAction: SnapshotAction.UPDATE, storeAction: StoreAction.UPDATE,
}); });
} }
@ -10137,7 +10140,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: false, isLoading: false,
}, },
replaceFiles: true, replaceFiles: true,
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}); });
return; return;
} catch (error: any) { } catch (error: any) {
@ -10255,7 +10258,7 @@ class App extends React.Component<AppProps, AppState> {
// 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.scheduleAction(SnapshotAction.UPDATE); this.store.scheduleAction(StoreAction.UPDATE);
this.store.commit(arrayToMap(elements), this.state); this.store.commit(arrayToMap(elements), this.state);
this.setState({ isLoading: true }); this.setState({ isLoading: true });
@ -10266,7 +10269,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: false, isLoading: false,
}, },
replaceFiles: true, replaceFiles: true,
storeAction: SnapshotAction.CAPTURE, storeAction: StoreAction.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 { SnapshotAction } from "../../store"; import { StoreAction } 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: SnapshotAction.CAPTURE }); app.syncActionResult({ storeAction: StoreAction.CAPTURE });
} }
}; };
@ -276,7 +276,7 @@ const StatsDragInput = <
false, false,
); );
app.syncActionResult({ storeAction: SnapshotAction.CAPTURE }); app.syncActionResult({ storeAction: StoreAction.CAPTURE });
lastPointer = null; lastPointer = null;
accumulatedChange = 0; accumulatedChange = 0;

View file

@ -1271,7 +1271,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
}); });
} }
// CFDO II: this looks wrong // CFDO: 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

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 { SnapshotAction } from "../store"; import { StoreAction } 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: SnapshotAction.NONE, storeAction: StoreAction.NONE,
}; };
}, },
}); });

View file

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

View file

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

View file

@ -296,6 +296,7 @@ class Scene {
validateIndicesThrottled(_nextElements); 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.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear(); this.elementsMap.clear();
this.elements.forEach((element) => { this.elements.forEach((element) => {

View file

@ -8,11 +8,13 @@ import { deepCopyElement } from "./element/newElement";
import type { AppState, ObservedAppState } from "./types"; import type { AppState, ObservedAppState } from "./types";
import type { DTO, ValueOf } from "./utility-types"; import type { DTO, ValueOf } from "./utility-types";
import type { import type {
ExcalidrawElement,
OrderedExcalidrawElement, OrderedExcalidrawElement,
SceneElementsMap, SceneElementsMap,
} from "./element/types"; } from "./element/types";
import { arrayToMap, assertNever } from "./utils";
import { hashElementsVersion } from "./element"; import { hashElementsVersion } from "./element";
import { assertNever } from "./utils"; import { syncMovedIndices } from "./fractionalIndex";
// hidden non-enumerable property for runtime checks // hidden non-enumerable property for runtime checks
const hiddenObservedAppStateProp = "__observedAppState"; const hiddenObservedAppStateProp = "__observedAppState";
@ -43,7 +45,7 @@ const isObservedAppState = (
!!Reflect.get(appState, hiddenObservedAppStateProp); !!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) // 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. * Immediately undoable.
* *
@ -68,7 +70,7 @@ export const SnapshotAction = {
* Use for updates which should not be captured as deltas immediately, such as * Use for updates which should not be captured as deltas immediately, such as
* exceptions which are part of some async multi-step proces. * 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. * 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.
@ -78,7 +80,7 @@ export const SnapshotAction = {
NONE: "NONE", NONE: "NONE",
} as const; } 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. * Store which captures the observed changes and emits them as `StoreIncrement` events.
@ -98,9 +100,9 @@ export class Store {
this._snapshot = snapshot; 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.scheduledActions.add(action);
this.satisfiesScheduledActionsInvariant(); this.satisfiesScheduledActionsInvariant();
} }
@ -110,26 +112,27 @@ export class Store {
*/ */
// TODO: Suspicious that this is called so many places. Seems error-prone. // TODO: Suspicious that this is called so many places. Seems error-prone.
public scheduleCapture() { public scheduleCapture() {
this.scheduleAction(SnapshotAction.CAPTURE); this.scheduleAction(StoreAction.CAPTURE);
} }
private get scheduledAction() { private get scheduledAction() {
// Capture has a precedence over update, since it also performs snapshot update // Capture has a precedence over update, since it also performs snapshot update
if (this.scheduledActions.has(SnapshotAction.CAPTURE)) { if (this.scheduledActions.has(StoreAction.CAPTURE)) {
return SnapshotAction.CAPTURE; return StoreAction.CAPTURE;
} }
// Update has a precedence over none, since it also emits an (ephemeral) increment // Update has a precedence over none, since it also emits an (ephemeral) increment
if (this.scheduledActions.has(SnapshotAction.UPDATE)) { if (this.scheduledActions.has(StoreAction.UPDATE)) {
return SnapshotAction.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 // 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 `DurableStoreIncrement` when action is "capture", emits `EphemeralStoreIncrement` otherwise.
* *
* @emits StoreIncrement * @emits StoreIncrement
@ -142,13 +145,14 @@ export class Store {
const { scheduledAction } = this; const { scheduledAction } = this;
switch (scheduledAction) { switch (scheduledAction) {
case SnapshotAction.CAPTURE: case StoreAction.CAPTURE:
this.snapshot = this.captureDurableIncrement(elements, appState); this.snapshot = this.captureDurableIncrement(elements, appState);
break; break;
case SnapshotAction.UPDATE: case StoreAction.UPDATE:
this.snapshot = this.emitEphemeralIncrement(elements); this.snapshot = this.emitEphemeralIncrement(elements);
break; 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); this.emitEphemeralIncrement(elements);
return; return;
default: default:
@ -171,7 +175,9 @@ export class Store {
appState: AppState | ObservedAppState | undefined, appState: AppState | ObservedAppState | undefined,
) { ) {
const prevSnapshot = this.snapshot; 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 // Optimisation, don't continue if nothing has changed
if (prevSnapshot === nextSnapshot) { 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). * 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( public filterUncomittedElements(
prevElements: Map<string, OrderedExcalidrawElement>, prevElements: Map<string, ExcalidrawElement>,
nextElements: Map<string, OrderedExcalidrawElement>, nextElements: Map<string, ExcalidrawElement>,
) { ): Map<string, OrderedExcalidrawElement> {
const movedElements = new Map<string, ExcalidrawElement>();
for (const [id, prevElement] of prevElements.entries()) { for (const [id, prevElement] of prevElements.entries()) {
const nextElement = nextElements.get(id); const nextElement = nextElements.get(id);
if (!nextElement) { if (!nextElement) {
// Nothing to care about here, elements were forcefully deleted // Nothing to care about here, element was forcefully deleted
continue; continue;
} }
@ -249,10 +257,18 @@ export class Store {
} else if (elementSnapshot.version < prevElement.version) { } else if (elementSnapshot.version < prevElement.version) {
// Element was already commited, but the snapshot version is lower than 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);
// 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, appState: AppState,
options: { options: {
triggerIncrement: boolean; triggerIncrement: boolean;
updateSnapshot: boolean;
} = { } = {
triggerIncrement: false, triggerIncrement: false,
updateSnapshot: false,
}, },
): [SceneElementsMap, AppState, boolean] { ): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo( const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
@ -282,7 +300,9 @@ export class Store {
elementsContainVisibleChange || appStateContainsVisibleChange; elementsContainVisibleChange || appStateContainsVisibleChange;
const prevSnapshot = this.snapshot; const prevSnapshot = this.snapshot;
const nextSnapshot = this.snapshot.maybeClone(nextElements, nextAppState); const nextSnapshot = this.snapshot.maybeClone(nextElements, nextAppState, {
shouldIgnoreCache: true,
});
if (options.triggerIncrement) { if (options.triggerIncrement) {
const change = StoreChange.create(prevSnapshot, nextSnapshot); const change = StoreChange.create(prevSnapshot, nextSnapshot);
@ -290,7 +310,12 @@ export class Store {
this.onStoreIncrementEmitter.trigger(increment); this.onStoreIncrementEmitter.trigger(increment);
} }
// 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; this.snapshot = nextSnapshot;
}
return [nextElements, nextAppState, appliedVisibleChanges]; return [nextElements, nextAppState, appliedVisibleChanges];
} }
@ -307,7 +332,7 @@ export class Store {
if ( if (
!( !(
this.scheduledActions.size >= 0 && 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}".`; 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`. * Inverse store delta, creates new instance of `StoreDelta`.
*/ */
public static inverse(delta: StoreDelta): StoreDelta { public static inverse(delta: StoreDelta): StoreDelta {
return this.create(delta.elements.inverse(), delta.appState.inverse(), { return this.create(delta.elements.inverse(), delta.appState.inverse());
id: delta.id,
});
} }
/** /**
@ -538,8 +561,16 @@ export class StoreSnapshot {
public maybeClone( public maybeClone(
elements: Map<string, OrderedExcalidrawElement> | undefined, elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | 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); const nextAppStateSnapshot = this.maybeCreateAppStateSnapshot(appState);
let didElementsChange = false; let didElementsChange = false;
@ -597,12 +628,17 @@ export class StoreSnapshot {
private maybeCreateElementsSnapshot( private maybeCreateElementsSnapshot(
elements: Map<string, OrderedExcalidrawElement> | undefined, elements: Map<string, OrderedExcalidrawElement> | undefined,
options: {
shouldIgnoreCache: boolean;
} = {
shouldIgnoreCache: false,
},
) { ) {
if (!elements) { if (!elements) {
return this.elements; return this.elements;
} }
const changedElements = this.detectChangedElements(elements); const changedElements = this.detectChangedElements(elements, options);
if (!changedElements?.size) { if (!changedElements?.size) {
return this.elements; return this.elements;
@ -619,6 +655,11 @@ export class StoreSnapshot {
*/ */
private detectChangedElements( private detectChangedElements(
nextElements: Map<string, OrderedExcalidrawElement>, nextElements: Map<string, OrderedExcalidrawElement>,
options: {
shouldIgnoreCache: boolean;
} = {
shouldIgnoreCache: false,
},
) { ) {
if (this.elements === nextElements) { if (this.elements === nextElements) {
return; return;
@ -653,10 +694,18 @@ export class StoreSnapshot {
return; 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, // due to snapshot containing only durable changes,
// we might have already processed these elements in a previous run, // we might have already processed these elements in a previous run,
// hence additionally check whether the hash of the elements has changed // hence additionally check whether the hash of the elements has changed
// since if it didn't, we don't need to process them again // 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( const changedElementsHash = hashElementsVersion(
Array.from(changedElements.values()), Array.from(changedElements.values()),
); );

View file

@ -10,10 +10,10 @@ import {
type MetadataRepository, type MetadataRepository,
type DeltasRepository, type DeltasRepository,
} from "./queue"; } from "./queue";
import { SnapshotAction, StoreDelta } from "../store"; import { StoreAction, StoreDelta } from "../store";
import type { StoreChange } 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 { ExcalidrawElement, SceneElementsMap } from "../element/types";
import type { CLIENT_MESSAGE_RAW, SERVER_DELTA, CHANGE } from "./protocol"; import type { CLIENT_MESSAGE_RAW, SERVER_DELTA, CHANGE } from "./protocol";
import { debounce } from "../utils"; import { debounce } from "../utils";
import { randomId } from "../random"; import { randomId } from "../random";
@ -38,7 +38,7 @@ class SocketClient {
private isOffline = true; private isOffline = true;
private socket: ReconnectingWebSocket | null = null; private socket: ReconnectingWebSocket | null = null;
private get isDisconnected() { public get isDisconnected() {
return !this.socket; return !this.socket;
} }
@ -204,6 +204,11 @@ export class SyncClient {
private readonly metadata: MetadataRepository; private readonly metadata: MetadataRepository;
private readonly client: SocketClient; private readonly client: SocketClient;
private relayedElementsVersionsCache = new Map<
string,
ExcalidrawElement["version"]
>();
// #region ACKNOWLEDGED DELTAS & METADATA // #region ACKNOWLEDGED DELTAS & METADATA
// CFDO: shouldn't be stateful, only request / response // CFDO: shouldn't be stateful, only request / response
private readonly acknowledgedDeltasMap: Map<string, AcknowledgedDelta> = private readonly acknowledgedDeltasMap: Map<string, AcknowledgedDelta> =
@ -264,11 +269,12 @@ export class SyncClient {
// #region PUBLIC API METHODS // #region PUBLIC API METHODS
public connect() { public connect() {
return this.client.connect(); this.client.connect();
} }
public disconnect() { public disconnect() {
return this.client.disconnect(); this.client.disconnect();
this.relayedElementsVersionsCache.clear();
} }
public pull(sinceVersion?: number): void { 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 // CFDO: should be throttled! 60 fps for live scenes, 10s or so for single player
public relay(change: StoreChange): void { 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({ this.client.send({
type: "relay", type: "relay",
payload: { ...change }, payload: { ...change },
@ -357,12 +389,13 @@ export class SyncClient {
existingElement.version < relayedElement.version // updated element existingElement.version < relayedElement.version // updated element
) { ) {
nextElements.set(id, relayedElement); nextElements.set(id, relayedElement);
this.relayedElementsVersionsCache.set(id, relayedElement.version);
} }
} }
this.api.updateScene({ this.api.updateScene({
elements: Array.from(nextElements.values()), elements: Array.from(nextElements.values()),
snapshotAction: SnapshotAction.UPDATE, storeAction: StoreAction.UPDATE,
}); });
} catch (e) { } catch (e) {
console.error("Failed to apply relayed change:", e); console.error("Failed to apply relayed change:", e);
@ -426,16 +459,22 @@ export class SyncClient {
delta, delta,
nextElements, nextElements,
appState, appState,
{
triggerIncrement: false,
updateSnapshot: true,
},
); );
prevSnapshot = this.api.store.snapshot; prevSnapshot = this.api.store.snapshot;
} }
// CFDO: I still need to filter out uncomitted elements // CFDO: might need to restore first due to potentially stale delta versions
// I still need to update snapshot with the new elements
this.api.updateScene({ this.api.updateScene({
elements: Array.from(nextElements.values()), 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; this.lastAcknowledgedVersion = nextAcknowledgedVersion;

View file

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

View file

@ -43,7 +43,7 @@ import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types";
import type { import type {
DurableStoreIncrement, DurableStoreIncrement,
EphemeralStoreIncrement, EphemeralStoreIncrement,
SnapshotActionType, StoreActionType as StoreActionType,
} from "./store"; } from "./store";
export type SocketId = string & { _brand: "SocketId" }; export type SocketId = string & { _brand: "SocketId" };
@ -578,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>;
snapshotAction?: SnapshotActionType; storeAction?: StoreActionType;
}; };
export enum UserIdleState { export enum UserIdleState {