First step towards delta based history

Introducing independent change detection for appState and elements

Generalizing object change, cleanup, refactoring, comments, solving typing issues

Shaping increment, change, delta hierarchy

Structural clone of elements

Introducing store and incremental API

Disabling buttons for canvas actions, smaller store and changes improvements

Update history entry based on latest changes, iterate through the stack for visible changes to limit empty commands

Solving concurrency issues, solving (partly) linear element issues,  introducing commitToStore breaking change

Fixing existing tests, updating snapshots

Trying to be smarter on the appstate change detection

Extending collab test, refactoring action / updateScene params, bugfixes

Resetting snapshots

Resetting snapshots

UI / API tests for history - WIP

Changing actions related to the observed appstate to at least update the store snapshot - WIP

Adding skipping of snapshot update flag for most no-breaking changes compatible solution

Ignoring uncomitted elements from local async actions, updating store directly in updateScene

Bound element issues - WIP
mrazator/test-fractional-index-and-granular-history
Marcel Mraz 1 year ago
parent 5e98047267
commit 260706c42f

@ -22,7 +22,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git
| API | Signature | Usage |
| --- | --- | --- |
| [updateScene](#updatescene) | `function` | updates the scene with the sceneData |
| [updateLibrary](#updatelibrary) | `function` | updates the scene with the sceneData |
| [updateLibrary](#updatelibrary) | `function` | updates the library |
| [addFiles](#addfiles) | `function` | add files data to the appState |
| [resetScene](#resetscene) | `function` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. |
| [getSceneElementsIncludingDeleted](#getsceneelementsincludingdeleted) | `function` | Returns all the elements including the deleted in the scene |
@ -65,7 +65,8 @@ 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/src/data/types.ts#L38) | The `elements` to be updated in the scene |
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/src/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/src/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. |
| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. |
| `commitToStore` | `boolean` | Implies if the `store` should update it's snapshot, capture the update and calculates the diff. Captured changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. |
| `skipSnapshotUpdate` | `boolean` | Implies whether the `store` should skip update of its snapshot, which is necessary for correct diff calculation. Relevant only when `elements` or `appState` are passed in. When `true`, `commitToStore` value will be ignored. Defaults to `false`. |
```jsx live
function App() {

@ -302,7 +302,6 @@ class Collab extends PureComponent<Props, CollabState> {
this.excalidrawAPI.updateScene({
elements,
commitToHistory: false,
});
}
};
@ -449,14 +448,12 @@ class Collab extends PureComponent<Props, CollabState> {
}
return element;
});
// remove deleted elements from elements array & history to ensure we don't
// remove deleted elements from elements array to ensure we don't
// expose potentially sensitive user data in case user manually deletes
// existing elements (or clears scene), which would otherwise be persisted
// to database even if deleted before creating the room.
this.excalidrawAPI.history.clear();
this.excalidrawAPI.updateScene({
elements,
commitToHistory: true,
});
this.saveCollabRoomToFirebase(getSyncableElements(elements));
@ -491,9 +488,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.initializeRoom({ fetchScene: false });
const remoteElements = decryptedData.payload.elements;
const reconciledElements = this.reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements, {
init: true,
});
this.handleRemoteSceneUpdate(reconciledElements);
// noop if already resolved via init from firebase
scenePromise.resolve({
elements: reconciledElements,
@ -649,21 +644,11 @@ class Collab extends PureComponent<Props, CollabState> {
});
}, LOAD_IMAGES_TIMEOUT);
private handleRemoteSceneUpdate = (
elements: ReconciledElements,
{ init = false }: { init?: boolean } = {},
) => {
private handleRemoteSceneUpdate = (elements: ReconciledElements) => {
this.excalidrawAPI.updateScene({
elements,
commitToHistory: !!init,
});
// We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
// when we receive any messages from another peer. This UX can be pretty rough -- if you
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
// right now we think this is the right tradeoff.
this.excalidrawAPI.history.clear();
this.loadImageFiles();
};

@ -19,7 +19,7 @@ const shouldDiscardRemoteElement = (
// local element is being edited
(local.id === localAppState.editingElement?.id ||
local.id === localAppState.resizingElement?.id ||
local.id === localAppState.draggingElement?.id ||
local.id === localAppState.draggingElement?.id || // Is this still valid? As draggingElement is selection element, which is never part of the elements array
// local element is newer
local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with

@ -278,7 +278,7 @@ export const loadScene = async (
// in the scene database/localStorage, and instead fetch them async
// from a different database
files: data.files,
commitToHistory: false,
commitToStore: false,
};
};

@ -409,7 +409,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
commitToHistory: true,
commitToStore: true,
});
}
});
@ -590,6 +590,7 @@ const ExcalidrawWrapper = () => {
if (didChange) {
excalidrawAPI.updateScene({
elements,
skipSnapshotUpdate: true,
});
}
}

@ -64,7 +64,7 @@ vi.mock("socket.io-client", () => {
});
describe("collaboration", () => {
it("creating room should reset deleted elements", async () => {
it("creating room should reset deleted elements while keeping store snapshot in sync", async () => {
await render(<ExcalidrawApp />);
// To update the scene with deleted elements before starting collab
updateSceneData({
@ -76,26 +76,43 @@ describe("collaboration", () => {
isDeleted: true,
}),
],
commitToStore: true,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
expect(API.getStateHistory().length).toBe(1);
expect(Array.from(h.store.snapshot.elements.values())).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
});
window.collab.startCollaboration(null);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1);
// We never delete from the local store as it is used for correct diff calculation
expect(Array.from(h.store.snapshot.elements.values())).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
});
const undoAction = createUndoAction(h.history);
// noop
h.app.actionManager.executeAction(undoAction);
// As it was introduced #2270, undo is a noop here, but we might want to re-enable it,
// since inability to undo your own deletions could be a bigger upsetting factor here
await waitFor(() => {
expect(h.history.isUndoStackEmpty).toBeTruthy();
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1);
expect(Array.from(h.store.snapshot.elements.values())).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
});
});
});

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

@ -18,6 +18,7 @@ import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
import { StoreAction } from "./types";
const alignActionsPredicate = (
elements: readonly ExcalidrawElement[],
@ -63,7 +64,7 @@ export const actionAlignTop = register({
position: "start",
axis: "y",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@ -94,7 +95,7 @@ export const actionAlignBottom = register({
position: "end",
axis: "y",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@ -125,7 +126,7 @@ export const actionAlignLeft = register({
position: "start",
axis: "x",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@ -156,7 +157,7 @@ export const actionAlignRight = register({
position: "end",
axis: "x",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@ -187,7 +188,7 @@ export const actionAlignVerticallyCentered = register({
position: "center",
axis: "y",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
@ -214,7 +215,7 @@ export const actionAlignHorizontallyCentered = register({
position: "center",
axis: "x",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (

@ -33,6 +33,7 @@ import { AppState } from "../types";
import { Mutable } from "../utility-types";
import { getFontString } from "../utils";
import { register } from "./register";
import { StoreAction } from "./types";
export const actionUnbindText = register({
name: "unbindText",
@ -80,7 +81,7 @@ export const actionUnbindText = register({
return {
elements,
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
});
@ -149,7 +150,7 @@ export const actionBindText = register({
return {
elements: pushTextAboveContainer(elements, container, textElement),
appState: { ...appState, selectedElementIds: { [container.id]: true } },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
});
@ -299,7 +300,7 @@ export const actionWrapTextInContainer = register({
...appState,
selectedElementIds: containerIds,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
});

@ -1,7 +1,13 @@
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
import {
CURSOR_TYPE,
MAX_ZOOM,
MIN_ZOOM,
THEME,
ZOOM_STEP,
} from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
@ -22,6 +28,7 @@ import {
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { Bounds } from "../element/bounds";
import { setCursor } from "../cursor";
import { StoreAction } from "./types";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@ -35,7 +42,9 @@ export const actionChangeViewBackgroundColor = register({
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
commitToHistory: !!value.viewBackgroundColor,
storeAction: !!value.viewBackgroundColor
? StoreAction.CAPTURE
: StoreAction.NONE,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => {
@ -88,7 +97,7 @@ export const actionClearCanvas = register({
? { ...appState.activeTool, type: "selection" }
: appState.activeTool,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
});
@ -110,16 +119,17 @@ export const actionZoomIn = register({
appState,
),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ updateData }) => (
PanelComponent: ({ updateData, appState }) => (
<ToolButton
type="button"
className="zoom-in-button zoom-button"
icon={ZoomInIcon}
title={`${t("buttons.zoomIn")}${getShortcutKey("CtrlOrCmd++")}`}
aria-label={t("buttons.zoomIn")}
disabled={appState.zoom.value >= MAX_ZOOM}
onClick={() => {
updateData(null);
}}
@ -147,16 +157,17 @@ export const actionZoomOut = register({
appState,
),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ updateData }) => (
PanelComponent: ({ updateData, appState }) => (
<ToolButton
type="button"
className="zoom-out-button zoom-button"
icon={ZoomOutIcon}
title={`${t("buttons.zoomOut")}${getShortcutKey("CtrlOrCmd+-")}`}
aria-label={t("buttons.zoomOut")}
disabled={appState.zoom.value <= MIN_ZOOM}
onClick={() => {
updateData(null);
}}
@ -184,7 +195,7 @@ export const actionResetZoom = register({
appState,
),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ updateData, appState }) => (
@ -261,8 +272,8 @@ export const zoomToFit = ({
// Apply clamping to newZoomValue to be between 10% and 3000%
newZoomValue = Math.min(
Math.max(newZoomValue, 0.1),
30.0,
Math.max(newZoomValue, MIN_ZOOM),
MAX_ZOOM,
) as NormalizedZoomValue;
let appStateWidth = appState.width;
@ -307,7 +318,7 @@ export const zoomToFit = ({
scrollY,
zoom: { value: newZoomValue },
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
};
@ -377,7 +388,7 @@ export const actionToggleTheme = register({
theme:
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
@ -414,7 +425,7 @@ export const actionToggleEraserTool = register({
activeEmbeddable: null,
activeTool,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) => event.key === KEYS.E,
@ -449,7 +460,7 @@ export const actionToggleHandTool = register({
activeEmbeddable: null,
activeTool,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>

@ -13,6 +13,7 @@ import { exportCanvas, prepareElementsForExport } from "../data/index";
import { isTextElement } from "../element";
import { t } from "../i18n";
import { isFirefox } from "../constants";
import { StoreAction } from "./types";
export const actionCopy = register({
name: "copy",
@ -28,7 +29,7 @@ export const actionCopy = register({
await copyToClipboard(elementsToCopy, app.files, event);
} catch (error: any) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: error.message,
@ -37,7 +38,7 @@ export const actionCopy = register({
}
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.copy",
@ -63,7 +64,7 @@ export const actionPaste = register({
if (isFirefox) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: t("hints.firefox_clipboard_write"),
@ -72,7 +73,7 @@ export const actionPaste = register({
}
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnRead"),
@ -85,7 +86,7 @@ export const actionPaste = register({
} catch (error: any) {
console.error(error);
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnParse"),
@ -94,7 +95,7 @@ export const actionPaste = register({
}
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.paste",
@ -119,7 +120,7 @@ export const actionCopyAsSvg = register({
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
@ -141,7 +142,7 @@ export const actionCopyAsSvg = register({
},
);
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
} catch (error: any) {
console.error(error);
@ -150,7 +151,7 @@ export const actionCopyAsSvg = register({
...appState,
errorMessage: error.message,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
},
@ -166,7 +167,7 @@ export const actionCopyAsPng = register({
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
const selectedElements = app.scene.getSelectedElements({
@ -199,7 +200,7 @@ export const actionCopyAsPng = register({
}),
},
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
} catch (error: any) {
console.error(error);
@ -208,7 +209,7 @@ export const actionCopyAsPng = register({
...appState,
errorMessage: error.message,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
},
@ -238,7 +239,7 @@ export const copyText = register({
.join("\n\n");
copyTextToSystemClipboard(text);
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
predicate: (elements, appState, _, app) => {

@ -13,6 +13,7 @@ import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
import { StoreAction } from "./types";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
@ -109,7 +110,7 @@ export const actionDeleteSelected = register({
...nextAppState,
editingLinearElement: null,
},
commitToHistory: false,
storeAction: StoreAction.UPDATE,
};
}
@ -141,7 +142,7 @@ export const actionDeleteSelected = register({
: [0],
},
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
}
let { elements: nextElements, appState: nextAppState } =
@ -161,10 +162,12 @@ export const actionDeleteSelected = register({
multiElement: null,
activeEmbeddable: null,
},
commitToHistory: isSomeElementSelected(
storeAction: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
)
? StoreAction.CAPTURE
: StoreAction.NONE,
};
},
contextItemLabel: "labels.delete",

@ -14,6 +14,7 @@ import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
import { StoreAction } from "./types";
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
@ -53,7 +54,7 @@ export const distributeHorizontally = register({
space: "between",
axis: "x",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@ -83,7 +84,7 @@ export const distributeVertically = register({
space: "between",
axis: "y",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>

@ -14,7 +14,7 @@ import {
} from "../groups";
import { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding";
import { ActionResult } from "./types";
import { ActionResult, StoreAction } from "./types";
import { GRID_SIZE } from "../constants";
import {
bindTextToShapeAfterDuplication,
@ -48,13 +48,13 @@ export const actionDuplicateSelection = register({
return {
elements,
appState: ret.appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
}
return {
...duplicateElements(elements, appState),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.duplicateSelection",

@ -4,6 +4,7 @@ import { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
import { register } from "./register";
import { StoreAction } from "./types";
const shouldLock = (elements: readonly ExcalidrawElement[]) =>
elements.every((el) => !el.locked);
@ -44,7 +45,7 @@ export const actionToggleElementLock = register({
? null
: appState.selectedLinearElement,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: (elements, appState, app) => {
@ -98,7 +99,7 @@ export const actionUnlockAllElements = register({
lockedElements.map((el) => [el.id, true]),
),
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.elementLock.unlockAll",

@ -19,12 +19,16 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types";
import "../components/ToolIcon.scss";
import { StoreAction } from "./types";
export const actionChangeProjectName = register({
name: "changeProjectName",
trackEvent: false,
perform: (_elements, appState, value) => {
return { appState: { ...appState, name: value }, commitToHistory: false };
return {
appState: { ...appState, name: value },
storeAction: StoreAction.UPDATE,
};
},
PanelComponent: ({ appState, updateData, appProps, data }) => (
<ProjectName
@ -45,7 +49,7 @@ export const actionChangeExportScale = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportScale: value },
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ elements: allElements, appState, updateData }) => {
@ -94,7 +98,7 @@ export const actionChangeExportBackground = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportBackground: value },
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ appState, updateData }) => (
@ -113,7 +117,7 @@ export const actionChangeExportEmbedScene = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportEmbedScene: value },
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ appState, updateData }) => (
@ -148,7 +152,7 @@ export const actionSaveToActiveFile = register({
: await saveAsJSON(elements, appState, app.files);
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
fileHandle,
@ -170,7 +174,7 @@ export const actionSaveToActiveFile = register({
} else {
console.warn(error);
}
return { commitToHistory: false };
return { storeAction: StoreAction.NONE };
}
},
keyTest: (event) =>
@ -192,7 +196,7 @@ export const actionSaveFileToDisk = register({
app.files,
);
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
openDialog: null,
@ -206,7 +210,7 @@ export const actionSaveFileToDisk = register({
} else {
console.warn(error);
}
return { commitToHistory: false };
return { storeAction: StoreAction.NONE };
}
},
keyTest: (event) =>
@ -244,7 +248,7 @@ export const actionLoadScene = register({
elements: loadedElements,
appState: loadedAppState,
files,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
} catch (error: any) {
if (error?.name === "AbortError") {
@ -255,7 +259,7 @@ export const actionLoadScene = register({
elements,
appState: { ...appState, errorMessage: error.message },
files: app.files,
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
},
@ -268,7 +272,7 @@ export const actionExportWithDarkMode = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportWithDarkMode: value },
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ appState, updateData }) => (

@ -16,6 +16,7 @@ import {
import { isBindingElement, isLinearElement } from "../element/typeChecks";
import { AppState } from "../types";
import { resetCursor } from "../cursor";
import { StoreAction } from "./types";
export const actionFinalize = register({
name: "finalize",
@ -49,7 +50,7 @@ export const actionFinalize = register({
cursorButton: "up",
editingLinearElement: null,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
}
}
@ -190,7 +191,10 @@ export const actionFinalize = register({
: appState.selectedLinearElement,
pendingImageElementId: null,
},
commitToHistory: appState.activeTool.type === "freedraw",
storeAction:
appState.activeTool.type === "freedraw"
? StoreAction.CAPTURE
: StoreAction.UPDATE,
};
},
keyTest: (event, appState) =>

@ -13,6 +13,7 @@ import {
unbindLinearElements,
} from "../element/binding";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { StoreAction } from "./types";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
@ -25,7 +26,7 @@ export const actionFlipHorizontal = register({
app,
),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) => event.shiftKey && event.code === CODES.H,
@ -43,7 +44,7 @@ export const actionFlipVertical = register({
app,
),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>

@ -8,6 +8,7 @@ import { updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { register } from "./register";
import { isFrameLikeElement } from "../element/typeChecks";
import { StoreAction } from "./types";
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
@ -39,14 +40,14 @@ export const actionSelectAllElementsInFrame = register({
return acc;
}, {} as Record<ExcalidrawElement["id"], true>),
},
commitToHistory: false,
storeAction: StoreAction.CAPTURE,
};
}
return {
elements,
appState,
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.selectAllElementsInFrame",
@ -74,14 +75,14 @@ export const actionRemoveAllElementsFromFrame = register({
[selectedElement.id]: true,
},
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
}
return {
elements,
appState,
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.removeAllElementsFromFrame",
@ -103,7 +104,7 @@ export const actionupdateFrameRendering = register({
enabled: !appState.frameRendering.enabled,
},
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.updateFrameRendering",
@ -131,7 +132,7 @@ export const actionSetFrameAsActiveTool = register({
type: "frame",
}),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
keyTest: (event) =>

@ -27,6 +27,7 @@ import {
removeElementsFromFrame,
replaceAllElementsInFrame,
} from "../frame";
import { StoreAction } from "./types";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) {
@ -69,7 +70,7 @@ export const actionGroup = register({
});
if (selectedElements.length < 2) {
// nothing to group
return { appState, elements, commitToHistory: false };
return { appState, elements, storeAction: StoreAction.NONE };
}
// if everything is already grouped into 1 group, there is nothing to do
const selectedGroupIds = getSelectedGroupIds(appState);
@ -89,7 +90,7 @@ export const actionGroup = register({
]);
if (combinedSet.size === elementIdsInGroup.size) {
// no incremental ids in the selected ids
return { appState, elements, commitToHistory: false };
return { appState, elements, storeAction: StoreAction.NONE };
}
}
@ -155,7 +156,7 @@ export const actionGroup = register({
),
},
elements: nextElements,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.group",
@ -182,7 +183,7 @@ export const actionUngroup = register({
perform: (elements, appState, _, app) => {
const groupIds = getSelectedGroupIds(appState);
if (groupIds.length === 0) {
return { appState, elements, commitToHistory: false };
return { appState, elements, storeAction: StoreAction.NONE, };
}
let nextElements = [...elements];
@ -250,7 +251,7 @@ export const actionUngroup = register({
return {
appState: { ...appState, ...updateAppState },
elements: nextElements,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>

@ -1,62 +1,48 @@
import { Action, ActionResult } from "./types";
import { Action, ActionResult, StoreAction } from "./types";
import { UndoIcon, RedoIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import History, { HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types";
import { History } from "../history";
import { AppState } from "../types";
import { KEYS } from "../keys";
import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding";
import { arrayToMap } from "../utils";
import { isWindows } from "../constants";
import { ExcalidrawElement } from "../element/types";
import { fixBindingsAfterDeletion } from "../element/binding";
const writeData = (
prevElements: readonly ExcalidrawElement[],
appState: AppState,
updater: () => HistoryEntry | null,
appState: Readonly<AppState>,
updater: () => [Map<string, ExcalidrawElement>, AppState] | void,
): ActionResult => {
const commitToHistory = false;
if (
!appState.multiElement &&
!appState.resizingElement &&
!appState.editingElement &&
!appState.draggingElement
) {
const data = updater();
if (data === null) {
return { commitToHistory };
const result = updater();
if (!result) {
return { storeAction: StoreAction.NONE };
}
const prevElementMap = arrayToMap(prevElements);
const nextElements = data.elements;
const nextElementMap = arrayToMap(nextElements);
// TODO_UNDO: worth detecting z-index deltas or do we just order based on fractional indices?
const [nextElementsMap, nextAppState] = result;
const nextElements = Array.from(nextElementsMap.values());
const deletedElements = prevElements.filter(
(prevElement) => !nextElementMap.has(prevElement.id),
);
const elements = nextElements
.map((nextElement) =>
newElementWith(
prevElementMap.get(nextElement.id) || nextElement,
nextElement,
),
)
.concat(
deletedElements.map((prevElement) =>
newElementWith(prevElement, { isDeleted: true }),
),
);
fixBindingsAfterDeletion(elements, deletedElements);
// TODO_UNDO: these are all deleted elements, but ideally we should get just those that were delted at this moment
const deletedElements = nextElements.filter((element) => element.isDeleted);
// TODO_UNDO: this doesn't really work for bound text
fixBindingsAfterDeletion(nextElements, deletedElements);
return {
elements,
appState: { ...appState, ...data.appState },
commitToHistory,
syncHistory: true,
appState: nextAppState,
elements: Array.from(nextElementsMap.values()),
storeAction: StoreAction.UPDATE,
};
}
return { commitToHistory };
return { storeAction: StoreAction.NONE };
};
type ActionCreator = (history: History) => Action;
@ -65,7 +51,7 @@ export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
trackEvent: { category: "history" },
perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()),
writeData(appState, () => history.undo(arrayToMap(elements), appState)),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
@ -77,16 +63,16 @@ export const createUndoAction: ActionCreator = (history) => ({
aria-label={t("buttons.undo")}
onClick={updateData}
size={data?.size || "medium"}
disabled={history.isUndoStackEmpty}
/>
),
commitToHistory: () => false,
});
export const createRedoAction: ActionCreator = (history) => ({
name: "redo",
trackEvent: { category: "history" },
perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()),
writeData(appState, () => history.redo(arrayToMap(elements), appState)),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
@ -99,7 +85,7 @@ export const createRedoAction: ActionCreator = (history) => ({
aria-label={t("buttons.redo")}
onClick={updateData}
size={data?.size || "medium"}
disabled={history.isRedoStackEmpty}
/>
),
commitToHistory: () => false,
});

@ -2,6 +2,7 @@ import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks";
import { ExcalidrawLinearElement } from "../element/types";
import { register } from "./register";
import { StoreAction } from "./types";
export const actionToggleLinearEditor = register({
name: "toggleLinearEditor",
@ -30,7 +31,7 @@ export const actionToggleLinearEditor = register({
...appState,
editingLinearElement,
},
commitToHistory: false,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: (elements, appState, app) => {

@ -4,6 +4,7 @@ import { t } from "../i18n";
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register";
import { KEYS } from "../keys";
import { StoreAction } from "./types";
export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu",
@ -13,7 +14,7 @@ export const actionToggleCanvasMenu = register({
...appState,
openMenu: appState.openMenu === "canvas" ? null : "canvas",
},
commitToHistory: false,
storeAction: StoreAction.NONE,
}),
PanelComponent: ({ appState, updateData }) => (
<ToolButton
@ -34,7 +35,7 @@ export const actionToggleEditMenu = register({
...appState,
openMenu: appState.openMenu === "shape" ? null : "shape",
},
commitToHistory: false,
storeAction: StoreAction.NONE,
}),
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
@ -64,7 +65,7 @@ export const actionShortcuts = register({
...appState,
openDialog: appState.openDialog === "help" ? null : "help",
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
keyTest: (event) => event.key === KEYS.QUESTION_MARK,

@ -3,6 +3,7 @@ import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll";
import { Collaborator } from "../types";
import { register } from "./register";
import { StoreAction } from "./types";
export const actionGoToCollaborator = register({
name: "goToCollaborator",
@ -11,7 +12,7 @@ export const actionGoToCollaborator = register({
perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"];
if (!point) {
return { appState, commitToHistory: false };
return { appState, storeAction: StoreAction.NONE };
}
return {
@ -28,7 +29,7 @@ export const actionGoToCollaborator = register({
// Close mobile menu
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ updateData, data }) => {

@ -92,6 +92,7 @@ import {
import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
import { StoreAction } from "./types";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@ -222,7 +223,7 @@ const changeFontSize = (
? [...newFontSizes][0]
: fallbackValue ?? appState.currentItemFontSize,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
};
@ -251,7 +252,9 @@ export const actionChangeStrokeColor = register({
...appState,
...value,
},
commitToHistory: !!value.currentItemStrokeColor,
storeAction: !!value.currentItemStrokeColor
? StoreAction.CAPTURE
: StoreAction.NONE,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => (
@ -294,7 +297,9 @@ export const actionChangeBackgroundColor = register({
...appState,
...value,
},
commitToHistory: !!value.currentItemBackgroundColor,
storeAction: !!value.currentItemBackgroundColor
? StoreAction.CAPTURE
: StoreAction.NONE,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => (
@ -337,7 +342,7 @@ export const actionChangeFillStyle = register({
}),
),
appState: { ...appState, currentItemFillStyle: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@ -409,7 +414,7 @@ export const actionChangeStrokeWidth = register({
}),
),
appState: { ...appState, currentItemStrokeWidth: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@ -463,7 +468,7 @@ export const actionChangeSloppiness = register({
}),
),
appState: { ...appState, currentItemRoughness: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@ -513,7 +518,7 @@ export const actionChangeStrokeStyle = register({
}),
),
appState: { ...appState, currentItemStrokeStyle: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@ -567,7 +572,7 @@ export const actionChangeOpacity = register({
true,
),
appState: { ...appState, currentItemOpacity: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@ -725,7 +730,7 @@ export const actionChangeFontFamily = register({
...appState,
currentItemFontFamily: value,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@ -814,7 +819,7 @@ export const actionChangeTextAlign = register({
...appState,
currentItemTextAlign: value,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@ -894,7 +899,7 @@ export const actionChangeVerticalAlign = register({
appState: {
...appState,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@ -967,7 +972,7 @@ export const actionChangeRoundness = register({
...appState,
currentItemRoundness: value,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@ -1047,7 +1052,7 @@ export const actionChangeArrowhead = register({
? "currentItemStartArrowhead"
: "currentItemEndArrowhead"]: value.type,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {

@ -6,6 +6,7 @@ import { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { StoreAction } from "./types";
export const actionSelectAll = register({
name: "selectAll",
@ -46,7 +47,7 @@ export const actionSelectAll = register({
? new LinearElementEditor(elements[0], app.scene)
: null,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.selectAll",

@ -25,6 +25,7 @@ import {
} from "../element/typeChecks";
import { getSelectedElements } from "../scene";
import { ExcalidrawTextElement } from "../element/types";
import { StoreAction } from "./types";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
@ -48,7 +49,7 @@ export const actionCopyStyles = register({
...appState,
toast: { message: t("toast.copyStyles") },
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.copyStyles",
@ -64,7 +65,7 @@ export const actionPasteStyles = register({
const pastedElement = elementsCopied[0];
const boundTextElement = elementsCopied[1];
if (!isExcalidrawElement(pastedElement)) {
return { elements, commitToHistory: false };
return { elements, storeAction: StoreAction.NONE };
}
const selectedElements = getSelectedElements(elements, appState, {
@ -149,7 +150,7 @@ export const actionPasteStyles = register({
}
return element;
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.pasteStyles",

@ -2,6 +2,7 @@ import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
import { AppState } from "../types";
import { StoreAction } from "./types";
export const actionToggleGridMode = register({
name: "gridMode",
@ -17,7 +18,7 @@ export const actionToggleGridMode = register({
gridSize: this.checked!(appState) ? null : GRID_SIZE,
objectsSnapModeEnabled: false,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState: AppState) => appState.gridSize !== null,

@ -1,5 +1,6 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { StoreAction } from "./types";
export const actionToggleObjectsSnapMode = register({
name: "objectsSnapMode",
@ -15,7 +16,7 @@ export const actionToggleObjectsSnapMode = register({
objectsSnapModeEnabled: !this.checked!(appState),
gridSize: null,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.objectsSnapModeEnabled,

@ -1,5 +1,6 @@
import { register } from "./register";
import { CODES, KEYS } from "../keys";
import { StoreAction } from "./types";
export const actionToggleStats = register({
name: "stats",
@ -11,7 +12,7 @@ export const actionToggleStats = register({
...appState,
showStats: !this.checked!(appState),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.showStats,

@ -1,5 +1,6 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { StoreAction } from "./types";
export const actionToggleViewMode = register({
name: "viewMode",
@ -14,7 +15,7 @@ export const actionToggleViewMode = register({
...appState,
viewModeEnabled: !this.checked!(appState),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.viewModeEnabled,

@ -1,5 +1,6 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { StoreAction } from "./types";
export const actionToggleZenMode = register({
name: "zenMode",
@ -14,7 +15,7 @@ export const actionToggleZenMode = register({
...appState,
zenModeEnabled: !this.checked!(appState),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.zenModeEnabled,

@ -15,6 +15,7 @@ import {
SendToBackIcon,
} from "../components/icons";
import { isDarwin } from "../constants";
import { StoreAction } from "./types";
export const actionSendBackward = register({
name: "sendBackward",
@ -23,7 +24,7 @@ export const actionSendBackward = register({
return {
elements: moveOneLeft(elements, appState),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.sendBackward",
@ -51,7 +52,7 @@ export const actionBringForward = register({
return {
elements: moveOneRight(elements, appState),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.bringForward",
@ -79,7 +80,7 @@ export const actionSendToBack = register({
return {
elements: moveAllLeft(elements, appState),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.sendToBack",
@ -115,7 +116,7 @@ export const actionBringToFront = register({
return {
elements: moveAllRight(elements, appState),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.bringToFront",

@ -10,6 +10,12 @@ import { MarkOptional } from "../utility-types";
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
export enum StoreAction {
NONE = "none",
UPDATE = "update", // TODO_UNDO: think about better naming as this one is confusing
CAPTURE = "capture",
}
/** if false, the action should be prevented */
export type ActionResult =
| {
@ -19,8 +25,7 @@ export type ActionResult =
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null;
commitToHistory: boolean;
syncHistory?: boolean;
storeAction: StoreAction;
replaceFiles?: boolean;
}
| false;

@ -0,0 +1,567 @@
import { newElementWith } from "./element/mutateElement";
import { ExcalidrawElement } from "./element/types";
import {
AppState,
ObservedAppState,
ObservedElementsAppState,
ObservedStandaloneAppState,
} from "./types";
import { SubtypeOf } from "./utility-types";
import { isShallowEqual } from "./utils";
/**
* Represents the difference between two `T` objects.
*
* Keeping it as pure object (without transient state, side-effects, etc.), so we don't have to instantiate it on load.
*/
class Delta<T> {
private constructor(
public readonly from: Partial<T>,
public readonly to: Partial<T>,
) {}
public static create<T>(
from: Partial<T>,
to: Partial<T>,
modifier?: (delta: Partial<T>) => Partial<T>,
modifierOptions?: "from" | "to",
) {
const modifiedFrom =
modifier && modifierOptions !== "to" ? modifier(from) : from;
const modifiedTo =
modifier && modifierOptions !== "from" ? modifier(to) : to;
return new Delta(modifiedFrom, modifiedTo);
}
/**
* Calculates the delta between two objects.
*
* @param prevObject - The previous state of the object.
* @param nextObject - The next state of the object.
*
* @returns new Delta instance.
*/
public static calculate<T extends Object>(
prevObject: T,
nextObject: T,
modifier?: (delta: Partial<T>) => Partial<T>,
): Delta<T> {
if (prevObject === nextObject) {
return Delta.empty();
}
const from = {} as Partial<T>;
const to = {} as Partial<T>;
const unionOfKeys = new Set([
...Object.keys(prevObject),
...Object.keys(nextObject),
]);
for (const key of unionOfKeys) {
const prevValue = prevObject[key as keyof T];
const nextValue = nextObject[key as keyof T];
if (prevValue !== nextValue) {
from[key as keyof T] = prevValue;
to[key as keyof T] = nextValue;
}
}
return Delta.create(from, to, modifier);
}
public static empty() {
return new Delta({}, {});
}
public static isEmpty<T>(delta: Delta<T>): boolean {
return !Object.keys(delta.from).length && !Object.keys(delta.to).length;
}
/**
* Compares if the delta contains any different values compared to the object.
*
* WARN: it's based on shallow compare performed only on the first level, won't work for objects with deeper props.
*/
public static containsDifference<T>(delta: Partial<T>, object: T): boolean {
const anyDistinctKey = this.distinctKeysIterator(delta, object).next()
.value;
return !!anyDistinctKey;
}
/**
* Returns all the keys that have distinct values.
*
* WARN: it's based on shallow compare performed only on the first level, won't work for objects with deeper props.
*/
public static gatherDifferences<T>(delta: Partial<T>, object: T) {
const distinctKeys = new Set<string>();
for (const key of this.distinctKeysIterator(delta, object)) {
distinctKeys.add(key);
}
return Array.from(distinctKeys);
}
private static *distinctKeysIterator<T>(delta: Partial<T>, object: T) {
for (const [key, deltaValue] of Object.entries(delta)) {
const objectValue = object[key as keyof T];
if (deltaValue !== objectValue) {
// TODO_UNDO: staticly fail (typecheck) on deeper objects?
if (
typeof deltaValue === "object" &&
typeof objectValue === "object" &&
deltaValue !== null &&
objectValue !== null &&
isShallowEqual(
deltaValue as Record<string, any>,
objectValue as Record<string, any>,
)
) {
continue;
}
yield key;
}
}
}
}
/**
* Encapsulates the modifications captured as `Delta`/s.
*/
interface Change<T> {
/**
* Inverses the `Delta`s inside while creating a new `Change`.
*/
inverse(): Change<T>;
/**
* Applies the `Change` to the previous object.
*
* @returns new object instance and boolean, indicating if there was any visible change made.
*/
applyTo(previous: Readonly<T>, ...options: unknown[]): [T, boolean];
/**
* Checks whether there are actually `Delta`s.
*/
isEmpty(): boolean;
}
export class AppStateChange implements Change<AppState> {
private constructor(private readonly delta: Delta<ObservedAppState>) {}
public static calculate<T extends Partial<ObservedAppState>>(
prevAppState: T,
nextAppState: T,
): AppStateChange {
const delta = Delta.calculate(prevAppState, nextAppState);
return new AppStateChange(delta);
}
public static empty() {
return new AppStateChange(Delta.create({}, {}));
}
public inverse(): AppStateChange {
const inversedDelta = Delta.create(this.delta.to, this.delta.from);
return new AppStateChange(inversedDelta);
}
public applyTo(
appState: Readonly<AppState>,
elements: Readonly<Map<string, ExcalidrawElement>>,
): [AppState, boolean] {
const constainsVisibleChanges = this.checkForVisibleChanges(
appState,
elements,
);
const newAppState = {
...appState,
...this.delta.to, // TODO_UNDO: probably shouldn't apply element related changes
};
return [newAppState, constainsVisibleChanges];
}
public isEmpty(): boolean {
return Delta.isEmpty(this.delta);
}
private checkForVisibleChanges(
appState: ObservedAppState,
elements: Map<string, ExcalidrawElement>,
): boolean {
const containsStandaloneDifference = Delta.containsDifference(
AppStateChange.stripElementsProps(this.delta.to),
appState,
);
if (containsStandaloneDifference) {
// We detected a a difference which is unrelated to the elements
return true;
}
const containsElementsDifference = Delta.containsDifference(
AppStateChange.stripStandaloneProps(this.delta.to),
appState,
);
if (!containsStandaloneDifference && !containsElementsDifference) {
// There is no difference detected at all
return false;
}
// We need to handle elements differences separately,
// as they could be related to deleted elements and/or they could on their own result in no visible action
const changedDeltaKeys = Delta.gatherDifferences(
AppStateChange.stripStandaloneProps(this.delta.to),
appState,
) as Array<keyof ObservedElementsAppState>;
// Check whether delta properties are related to the existing non-deleted elements
for (const key of changedDeltaKeys) {
switch (key) {
case "selectedElementIds":
if (
AppStateChange.checkForSelectedElementsDifferences(
this.delta.to[key],
appState,
elements,
)
) {
return true;
}
break;
case "selectedLinearElement":
case "editingLinearElement":
if (
AppStateChange.checkForLinearElementDifferences(
this.delta.to[key],
elements,
)
) {
return true;
}
break;
case "editingGroupId":
case "selectedGroupIds":
return AppStateChange.checkForGroupsDifferences();
default: {
// WARN: this exhaustive check in the switch statement is here to catch unexpected future changes
// TODO_UNDO: use assertNever
const exhaustiveCheck: never = key;
throw new Error(
`Unknown ObservedElementsAppState key '${exhaustiveCheck}'.`,
);
}
}
}
return false;
}
private static checkForSelectedElementsDifferences(
deltaIds: ObservedElementsAppState["selectedElementIds"] | undefined,
appState: Pick<AppState, "selectedElementIds">,
elements: Map<string, ExcalidrawElement>,
) {
if (!deltaIds) {
// There are no selectedElementIds in the delta
return;
}
// TODO_UNDO: it could have been visible before (and now it's not)
// TODO_UNDO: it could have been selected
for (const id of Object.keys(deltaIds)) {
const element = elements.get(id);
if (element && !element.isDeleted) {
// // TODO_UNDO: breaks multi selection
// if (appState.selectedElementIds[id]) {
// // Element is already selected
// return;
// }
// Found related visible element!
return true;
}
}
}
private static checkForLinearElementDifferences(
linearElement:
| ObservedElementsAppState["editingLinearElement"]
| ObservedAppState["selectedLinearElement"]
| undefined,
elements: Map<string, ExcalidrawElement>,
) {
if (!linearElement) {
return;
}
const element = elements.get(linearElement.elementId);
if (element && !element.isDeleted) {
// Found related visible element!
return true;
}
}
// Currently we don't have an index of elements by groupIds, which means
// the calculation for getting the visible elements based on the groupIds stored in delta
// is not worth performing - due to perf. and dev. complexity.
//
// Therefore we are accepting in these cases empty undos / redos, which should be pretty rare:
// - only when one of these (or both) are in delta and the are no non deleted elements containing these group ids
private static checkForGroupsDifferences() {
return true;
}
private static stripElementsProps(
delta: Partial<ObservedAppState>,
): Partial<ObservedStandaloneAppState> {
// WARN: Do not remove the type-casts as they here for exhaustive type checks
const {
editingGroupId,
selectedGroupIds,
selectedElementIds,
editingLinearElement,
selectedLinearElement,
...standaloneProps
} = delta as ObservedAppState;
return standaloneProps as SubtypeOf<
typeof standaloneProps,
ObservedStandaloneAppState
>;
}
private static stripStandaloneProps(
delta: Partial<ObservedAppState>,
): Partial<ObservedElementsAppState> {
// WARN: Do not remove the type-casts as they here for exhaustive type checks
const { name, viewBackgroundColor, ...elementsProps } =
delta as ObservedAppState;
return elementsProps as SubtypeOf<
typeof elementsProps,
ObservedElementsAppState
>;
}
}
/**
* Elements change is a low level primitive to capture a change between two sets of elements.
* It does so by encapsulating forward and backward `Delta`s, which allow to travel in both directions.
*
* We could be smarter about the change in the future, ideas for improvements are:
* - for memory, share the same delta instances between different deltas (flyweight-like)
* - for serialization, compress the deltas into a tree-like structures with custom pointers or let one delta instance contain multiple element ids
* - for performance, emit the changes directly by the user actions, then apply them in from store into the state (no diffing!)
* - for performance, add operations in addition to deltas, which increment (decrement) properties by given value (could be used i.e. for presence-like move)
*/
export class ElementsChange implements Change<Map<string, ExcalidrawElement>> {
private constructor(
// TODO_UNDO: re-think the possible need for added/ remove/ updated deltas (possibly for handling edge cases with deletion, fixing bindings for deletion, showing changes added/modified/updated for version end etc.)
private readonly deltas: Map<string, Delta<ExcalidrawElement>>,
) {}
public static create(deltas: Map<string, Delta<ExcalidrawElement>>) {
return new ElementsChange(deltas);
}
/**
* Calculates the `Delta`s between the previous and next set of elements.
*
* @param prevElements - Map representing the previous state of elements.
* @param nextElements - Map representing the next state of elements.
*
* @returns `ElementsChange` instance representing the `Delta` changes between the two sets of elements.
*/
public static calculate<T extends ExcalidrawElement>(
prevElements: Map<string, ExcalidrawElement>,
nextElements: Map<string, ExcalidrawElement>,
): ElementsChange {
if (prevElements === nextElements) {
return ElementsChange.empty();
}
const deltas = new Map<string, Delta<T>>();
// This might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed
for (const prevElement of prevElements.values()) {
const nextElement = nextElements.get(prevElement.id);
// Element got removed
if (!nextElement) {
const from = { ...prevElement, isDeleted: false } as T;
const to = { isDeleted: true } as T;
const delta = Delta.create(
from,
to,
ElementsChange.stripIrrelevantProps,
);
deltas.set(prevElement.id, delta as Delta<T>);
}
}
for (const nextElement of nextElements.values()) {
const prevElement = prevElements.get(nextElement.id);
// Element got added
if (!prevElement) {
if (nextElement.isDeleted) {
// Special case when an element is added as deleted (i.e. through the API).
// Creating a delta for it wouldn't make sense, as it would go from isDeleted `true` into `true` again.
// We are going to skip it for now, later we could be have separate `added` & `removed` entries in the elements change,
// so that we would distinguish between actual addition, removal and "soft" (un)deletion.
continue;
}
const from = { isDeleted: true } as T;
const to = { ...nextElement, isDeleted: false } as T;
const delta = Delta.create(
from,
to,
ElementsChange.stripIrrelevantProps,
);
deltas.set(nextElement.id, delta as Delta<T>);
continue;
}
// Element got updated
if (prevElement.versionNonce !== nextElement.versionNonce) {
// O(n^2) here, but it's not as bad as it looks:
// - we do this only on history recordings, not on every frame
// - we do this only on changed elements
// - # of element's properties is reasonably small
// - otherwise we would have to emit deltas on user actions & apply them on every frame
const delta = Delta.calculate<ExcalidrawElement>(
prevElement,
nextElement,
ElementsChange.stripIrrelevantProps,
);
// Make sure there are at least some changes (except changes to irrelevant data)
if (!Delta.isEmpty(delta)) {
deltas.set(nextElement.id, delta as Delta<T>);
}
}
}
return new ElementsChange(deltas);
}
public static empty() {
return new ElementsChange(new Map());
}
public inverse(): ElementsChange {
const deltas = new Map<string, Delta<ExcalidrawElement>>();
for (const [id, delta] of this.deltas.entries()) {
deltas.set(id, Delta.create(delta.to, delta.from));
}
return new ElementsChange(deltas);
}
public applyTo(
elements: Readonly<Map<string, ExcalidrawElement>>,
): [Map<string, ExcalidrawElement>, boolean] {
let containsVisibleDifference = false;
for (const [id, delta] of this.deltas.entries()) {
const existingElement = elements.get(id);
if (existingElement) {
// Check if there was actually any visible change before applying
if (!containsVisibleDifference) {
// Special case, when delta deletes element, it results in a visible change
if (existingElement.isDeleted && delta.to.isDeleted === false) {
containsVisibleDifference = true;
} else if (!existingElement.isDeleted) {
// Check for any difference on a visible element
containsVisibleDifference = Delta.containsDifference(
delta.to,
existingElement,
);
}
}
elements.set(id, newElementWith(existingElement, delta.to, true));
}
}
return [elements, containsVisibleDifference];
}
public isEmpty(): boolean {
// TODO_UNDO: might need to go through all deltas and check for emptiness
return this.deltas.size === 0;
}
/**
* Update the delta/s based on the existing elements.
*
* @param elements current elements
* @param modifierOptions defines which of the delta (`from` or `to`) will be updated
* @returns new instance with modified delta/s
*/
public applyLatestChanges(
elements: Map<string, ExcalidrawElement>,
modifierOptions: "from" | "to",
): ElementsChange {
const modifier =
(element: ExcalidrawElement) => (partial: Partial<ExcalidrawElement>) => {
const modifiedPartial: { [key: string]: unknown } = {};
for (const key of Object.keys(partial)) {
modifiedPartial[key] = element[key as keyof ExcalidrawElement];
}
return modifiedPartial;
};
const deltas = new Map<string, Delta<ExcalidrawElement>>();
for (const [id, delta] of this.deltas.entries()) {
const existingElement = elements.get(id);
if (existingElement) {
const modifiedDelta = Delta.create(
delta.from,
delta.to,
modifier(existingElement),
modifierOptions,
);
deltas.set(id, modifiedDelta);
} else {
// Keep whatever we had
deltas.set(id, delta);
}
}
return ElementsChange.create(deltas);
}
private static stripIrrelevantProps(delta: Partial<ExcalidrawElement>) {
// TODO_UNDO: is seed correctly stripped?
const { id, updated, version, versionNonce, seed, ...strippedDelta } =
delta;
return strippedDelta;
}
}

@ -12,6 +12,7 @@
font-size: 0.875rem !important;
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size) !important;
height: var(--lg-icon-size) !important;

@ -41,6 +41,7 @@ import { createRedoAction, createUndoAction } from "../actions/actionHistory";
import { ActionManager } from "../actions/manager";
import { actions } from "../actions/register";
import { Action, ActionResult } from "../actions/types";
import { ActionResult, StoreAction } from "../actions/types";
import { trackEvent } from "../analytics";
import {
getDefaultAppState,
@ -194,7 +195,7 @@ import {
isSelectedViaGroup,
selectGroupsForSelectedElements,
} from "../groups";
import History from "../history";
import { History } from "../history";
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
import {
CODES,
@ -268,6 +269,7 @@ import {
muteFSAbortError,
isTestEnv,
easeOut,
isShallowEqual,
arrayToMap,
} from "../utils";
import {
@ -400,6 +402,7 @@ import { ElementCanvasButton } from "./MagicButton";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { EditorLocalStorage } from "../data/EditorLocalStorage";
import { fixFractionalIndices } from "../fractionalIndex";
import { Store } from "../store";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@ -514,6 +517,7 @@ class App extends React.Component<AppProps, AppState> {
public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined;
public id: string;
private store: Store;
private history: History;
private excalidrawContainerValue: {
container: HTMLDivElement | null;
@ -597,6 +601,9 @@ class App extends React.Component<AppProps, AppState> {
this.rc = rough.canvas(this.canvas);
this.renderer = new Renderer(this.scene);
this.store = new Store();
this.history = new History();
if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = {
updateScene: this.updateScene,
@ -604,6 +611,10 @@ class App extends React.Component<AppProps, AppState> {
addFiles: this.addFiles,
resetScene: this.resetScene,
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
store: {
clear: this.store.clear,
listen: this.store.listen,
},
history: {
clear: this.resetHistory,
},
@ -643,8 +654,14 @@ class App extends React.Component<AppProps, AppState> {
onSceneUpdated: this.onSceneUpdated,
});
this.history = new History();
this.actionManager.registerAll(actions);
this.actionManager = new ActionManager(
this.syncActionResult,
() => this.state,
() => this.scene.getElementsIncludingDeleted(),
this,
);
this.actionManager.registerAll(actions);
this.actionManager.registerAction(createUndoAction(this.history));
this.actionManager.registerAction(createRedoAction(this.history));
}
@ -1915,15 +1932,16 @@ class App extends React.Component<AppProps, AppState> {
if (shouldUpdateStrokeColor) {
this.syncActionResult({
appState: { ...this.state, currentItemStrokeColor: color },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
} else {
this.syncActionResult({
appState: { ...this.state, currentItemBackgroundColor: color },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
}
} else {
// TODO_UNDO: test if we didn't regress here - shouldn't this commit to store?
this.updateScene({
elements: this.scene.getElementsIncludingDeleted().map((el) => {
if (this.state.selectedElementIds[el.id]) {
@ -1959,8 +1977,11 @@ class App extends React.Component<AppProps, AppState> {
}
});
this.scene.replaceAllElements(actionResult.elements);
if (actionResult.commitToHistory) {
this.history.resumeRecording();
if (actionResult.storeAction === StoreAction.UPDATE) {
this.store.scheduleSnapshotUpdate();
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
this.store.resumeCapturing();
}
}
@ -1972,8 +1993,10 @@ class App extends React.Component<AppProps, AppState> {
}
if (actionResult.appState || editingElement || this.state.contextMenu) {
if (actionResult.commitToHistory) {
this.history.resumeRecording();
if (actionResult.storeAction === StoreAction.UPDATE) {
this.store.scheduleSnapshotUpdate();
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
this.store.resumeCapturing();
}
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
@ -2007,34 +2030,24 @@ class App extends React.Component<AppProps, AppState> {
editingElement = null;
}
this.setState(
(state) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into
// regarding the resulting type as not containing undefined
// (which the following expression will never contain)
return Object.assign(actionResult.appState || {}, {
// NOTE this will prevent opening context menu using an action
// or programmatically from the host, so it will need to be
// rewritten later
contextMenu: null,
editingElement,
viewModeEnabled,
zenModeEnabled,
gridSize,
theme,
name,
errorMessage,
});
},
() => {
if (actionResult.syncHistory) {
this.history.setCurrentState(
this.state,
this.scene.getElementsIncludingDeleted(),
);
}
},
);
this.setState((state) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into
// regarding the resulting type as not containing undefined
// (which the following expression will never contain)
return Object.assign(actionResult.appState || {}, {
// NOTE this will prevent opening context menu using an action
// or programmatically from the host, so it will need to be
// rewritten later
contextMenu: null,
editingElement,
viewModeEnabled,
zenModeEnabled,
gridSize,
theme,
name,
errorMessage,
});
});
}
},
);
@ -2058,6 +2071,10 @@ class App extends React.Component<AppProps, AppState> {
this.history.clear();
};
private resetStore = () => {
this.store.clear();
};
/**
* Resets scene & history.
* ! Do not use to clear scene user action !
@ -2070,6 +2087,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: opts?.resetLoadingState ? false : state.isLoading,
theme: this.state.theme,
}));
this.resetStore();
this.resetHistory();
},
);
@ -2154,10 +2172,11 @@ class App extends React.Component<AppProps, AppState> {
// seems faster even in browsers that do fire the loadingdone event.
this.fonts.loadFontsForElements(scene.elements);
this.resetStore();
this.resetHistory();
this.syncActionResult({
...scene,
commitToHistory: true,
storeAction: StoreAction.UPDATE, // TODO_UNDO: double-check for regression
});
};
@ -2247,9 +2266,17 @@ class App extends React.Component<AppProps, AppState> {
configurable: true,
value: this.history,
},
store: {
configurable: true,
value: this.store,
},
});
}
this.store.listen((...args) => {
this.history.record(...args);
});
this.scene.addCallback(this.onSceneUpdated);
this.addEventListeners();
@ -2305,6 +2332,7 @@ class App extends React.Component<AppProps, AppState> {
this.library.destroy();
this.laserPathManager.destroy();
this.onChangeEmitter.destroy();
this.store.destroy();
ShapeCache.destroy();
SnapCache.destroy();
clearTimeout(touchTimeout);
@ -2554,7 +2582,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement &&
!this.state.selectedElementIds[this.state.editingLinearElement.elementId]
) {
// defer so that the commitToHistory flag isn't reset via current update
// defer so that the storeAction flag isn't reset via current update
setTimeout(() => {
// execute only if the condition still holds when the deferred callback
// executes (it can be scheduled multiple times depending on how
@ -2598,7 +2626,11 @@ class App extends React.Component<AppProps, AppState> {
),
);
}
this.history.record(this.state, this.scene.getElementsIncludingDeleted());
this.store.capture(
arrayToMap(this.scene.getElementsIncludingDeleted()),
this.state,
);
// Do not notify consumers if we're still loading the scene. Among other
// potential issues, this fixes a case where the tab isn't focused during
@ -2936,7 +2968,7 @@ class App extends React.Component<AppProps, AppState> {
this.files = { ...this.files, ...opts.files };
}
this.history.resumeRecording();
this.store.resumeCapturing();
const nextElementsToSelect =
excludeElementsInFramesFromSelection(newElements);
@ -3177,7 +3209,7 @@ class App extends React.Component<AppProps, AppState> {
PLAIN_PASTE_TOAST_SHOWN = true;
}
this.history.resumeRecording();
this.store.resumeCapturing();
}
setAppState: React.Component<any, AppState>["setState"] = (
@ -3438,10 +3470,33 @@ class App extends React.Component<AppProps, AppState> {
elements?: SceneData["elements"];
appState?: Pick<AppState, K> | null;
collaborators?: SceneData["collaborators"];
commitToHistory?: SceneData["commitToHistory"];
commitToStore?: SceneData["commitToStore"];
skipSnapshotUpdate?: SceneData["skipSnapshotUpdate"];
}) => {
if (sceneData.commitToHistory) {
this.history.resumeRecording();
if (
!sceneData.skipSnapshotUpdate &&
(sceneData.elements || sceneData.appState)
) {
this.store.scheduleSnapshotUpdate();
if (sceneData.commitToStore) {
this.store.resumeCapturing();
}
// We need to filter out yet uncomitted local elements
// Once we will be exchanging just store increments and updating changes this won't be necessary
const localElements = this.scene.getElementsIncludingDeleted();
const nextElements = this.store.ignoreUncomittedElements(
arrayToMap(localElements),
arrayToMap(sceneData.elements || localElements), // Here we expect all next elements
);
const nextAppState: AppState = {
...this.state,
...(sceneData.appState || {}), // Here we expect just partial appState
};
this.store.capture(nextElements, nextAppState);
}
if (sceneData.appState) {
@ -3650,7 +3705,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement.elementId !==
selectedElements[0].id
) {
this.history.resumeRecording();
this.store.resumeCapturing();
this.setState({
editingLinearElement: new LinearElementEditor(
selectedElement,
@ -4042,7 +4097,7 @@ class App extends React.Component<AppProps, AppState> {
]);
}
if (!isDeleted || isExistingElement) {
this.history.resumeRecording();
this.store.resumeCapturing();
}
this.setState({
@ -4334,7 +4389,7 @@ class App extends React.Component<AppProps, AppState> {
(!this.state.editingLinearElement ||
this.state.editingLinearElement.elementId !== selectedElements[0].id)
) {
this.history.resumeRecording();
this.store.resumeCapturing();
this.setState({
editingLinearElement: new LinearElementEditor(
selectedElements[0],
@ -5154,6 +5209,7 @@ class App extends React.Component<AppProps, AppState> {
this.state,
),
},
skipSnapshotUpdate: true, // TODO_UNDO: test if we didn't regress here
});
return;
}
@ -5754,7 +5810,7 @@ class App extends React.Component<AppProps, AppState> {
const ret = LinearElementEditor.handlePointerDown(
event,
this.state,
this.history,
this.store,
pointerDownState.origin,
linearElementEditor,
);
@ -7307,7 +7363,7 @@ class App extends React.Component<AppProps, AppState> {
if (isLinearElement(draggingElement)) {
if (draggingElement!.points.length > 1) {
this.history.resumeRecording();
this.store.resumeCapturing();
}
const pointerCoords = viewportCoordsToSceneCoords(
childEvent,
@ -7542,7 +7598,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (resizingElement) {
this.history.resumeRecording();
this.store.resumeCapturing();
}
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
@ -7843,9 +7899,13 @@ class App extends React.Component<AppProps, AppState> {
if (
activeTool.type !== "selection" ||
isSomeElementSelected(this.scene.getNonDeletedElements(), this.state)
isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) ||
!isShallowEqual(
this.state.previousSelectedElementIds,
this.state.selectedElementIds,
)
) {
this.history.resumeRecording();
this.store.resumeCapturing();
}
if (pointerDownState.drag.hasOccurred || isResizing || isRotating) {
@ -7951,7 +8011,7 @@ class App extends React.Component<AppProps, AppState> {
return ele;
});
this.history.resumeRecording();
this.store.resumeCapturing();
this.scene.replaceAllElements(elements);
};
@ -8499,7 +8559,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: false,
},
replaceFiles: true,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
return;
} catch (error: any) {
@ -8585,15 +8645,21 @@ class App extends React.Component<AppProps, AppState> {
fileHandle,
);
if (ret.type === MIME_TYPES.excalidraw) {
// First we need to delete existing elements, so they get recorded in the undo stack
const deletedExistingElements = this.scene
.getNonDeletedElements()
.map((element) => newElementWith(element, { isDeleted: true }));
this.setState({ isLoading: true });
this.syncActionResult({
...ret.data,
elements: deletedExistingElements.concat(ret.data.elements),
appState: {
...(ret.data.appState || this.state),
isLoading: false,
},
replaceFiles: true,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
} else if (ret.type === MIME_TYPES.excalidrawlib) {
await this.library
@ -9209,6 +9275,7 @@ declare global {
setState: React.Component<any, AppState>["setState"];
app: InstanceType<typeof App>;
history: History;
store: Store;
};
}
}

@ -24,6 +24,7 @@ type ToolButtonBaseProps = {
hidden?: boolean;
visible?: boolean;
selected?: boolean;
disabled?: boolean;
className?: string;
style?: CSSProperties;
isLoading?: boolean;
@ -123,10 +124,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
type={type}
onClick={onClick}
ref={innerRef}
disabled={isLoading || props.isLoading}
disabled={isLoading || props.isLoading || !!props.disabled}
>
{(props.icon || props.label) && (
<div className="ToolIcon__icon" aria-hidden="true">
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled={!!props.disabled}
>
{props.icon || props.label}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">

@ -77,8 +77,8 @@
}
.ToolIcon_type_button,
.Modal .ToolIcon_type_button,
.ToolIcon_type_button {
.Modal .ToolIcon_type_button
{
padding: 0;
border: none;
margin: 0;
@ -101,6 +101,22 @@
background-color: var(--button-gray-3);
}
&:disabled {
cursor: default;
&:active,
&:focus-visible,
&:hover {
background-color: initial;
border: none;
box-shadow: none;
}
svg {
color: var(--color-disabled);
}
}
&--show {
visibility: visible;
}

@ -196,6 +196,7 @@ export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100;
export const ZOOM_STEP = 0.1;
export const MIN_ZOOM = 0.1;
export const MAX_ZOOM = 30.0;
export const HYPERLINK_TOOLTIP_DELAY = 300;
// Report a user inactive after IDLE_THRESHOLD milliseconds

@ -97,6 +97,8 @@
--color-gray-90: #1e1e1e;
--color-gray-100: #121212;
--color-disabled: var(--color-gray-40);
--color-warning: #fceeca;
--color-warning-dark: #f5c354;
--color-warning-darker: #f3ab2c;

@ -50,6 +50,15 @@
color: var(--color-on-primary-container);
}
}
&[aria-disabled="true"] {
background: initial;
border: none;
svg {
color: var(--color-disabled);
}
}
}
}

@ -40,6 +40,7 @@ import { trackEvent } from "../analytics";
import { useAppProps, useExcalidrawAppState } from "../components/App";
import { isEmbeddableElement } from "./typeChecks";
import { ShapeCache } from "../scene/ShapeCache";
import { StoreAction } from "../actions/types";
const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85;
@ -343,7 +344,7 @@ export const actionLink = register({
showHyperlinkPopup: "editor",
openMenu: null,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
trackEvent: { category: "hyperlink", action: "click" },

@ -17,6 +17,7 @@ import {
IframeData,
NonDeletedExcalidrawElement,
} from "./types";
import { StoreAction } from "../actions/types";
const embeddedLinkCache = new Map<string, IframeData>();
@ -286,7 +287,8 @@ export const actionSetEmbeddableAsActiveTool = register({
type: "embeddable",
}),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
});

@ -33,7 +33,6 @@ import {
InteractiveCanvasAppState,
} from "../types";
import { mutateElement } from "./mutateElement";
import History from "../history";
import Scene from "../scene/Scene";
import {
@ -48,6 +47,7 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { DRAGGING_THRESHOLD } from "../constants";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import { Store } from "../store";
const editorMidPointsCache: {
version: number | null;
@ -602,7 +602,7 @@ export class LinearElementEditor {
static handlePointerDown(
event: React.PointerEvent<HTMLElement>,
appState: AppState,
history: History,
store: Store,
scenePointer: { x: number; y: number },
linearElementEditor: LinearElementEditor,
): {
@ -654,7 +654,7 @@ export class LinearElementEditor {
});
ret.didAddPoint = true;
}
history.resumeRecording();
store.resumeCapturing();
ret.linearElementEditor = {
...linearElementEditor,
pointerDownState: {

@ -106,24 +106,27 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
export const newElementWith = <TElement extends ExcalidrawElement>(
element: TElement,
updates: ElementUpdate<TElement>,
forceUpdate: boolean = false,
): TElement => {
let didChange = false;
for (const key in updates) {
const value = (updates as any)[key];
if (typeof value !== "undefined") {
if (
(element as any)[key] === value &&
// if object, always update because its attrs could have changed
(typeof value !== "object" || value === null)
) {
continue;
if (!forceUpdate) {
let didChange = false;
for (const key in updates) {
const value = (updates as any)[key];
if (typeof value !== "undefined") {
if (
(element as any)[key] === value &&
// if object, always update because its attrs could have changed
(typeof value !== "object" || value === null)
) {
continue;
}
didChange = true;
}
didChange = true;
}
}
if (!didChange) {
return element;
if (!didChange) {
return element;
}
}
return {

@ -1,265 +1,173 @@
import { AppState } from "./types";
import { AppStateChange, ElementsChange } from "./change";
import { ExcalidrawElement } from "./element/types";
import { isLinearElement } from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement";
import { Mutable } from "./utility-types";
import { AppState } from "./types";
export interface HistoryEntry {
appState: ReturnType<typeof clearAppStatePropertiesForHistory>;
elements: ExcalidrawElement[];
}
// TODO_UNDO: think about limiting the depth of stack
export class History {
private readonly undoStack: HistoryEntry[] = [];
private readonly redoStack: HistoryEntry[] = [];
interface DehydratedExcalidrawElement {
id: string;
versionNonce: number;
}
public get isUndoStackEmpty() {
return this.undoStack.length === 0;
}
interface DehydratedHistoryEntry {
appState: string;
elements: DehydratedExcalidrawElement[];
}
public get isRedoStackEmpty() {
return this.redoStack.length === 0;
}
const clearAppStatePropertiesForHistory = (appState: AppState) => {
return {
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
viewBackgroundColor: appState.viewBackgroundColor,
editingLinearElement: appState.editingLinearElement,
editingGroupId: appState.editingGroupId,
name: appState.name,
};
};
class History {
private elementCache = new Map<string, Map<number, ExcalidrawElement>>();
private recording: boolean = true;
private stateHistory: DehydratedHistoryEntry[] = [];
private redoStack: DehydratedHistoryEntry[] = [];
private lastEntry: HistoryEntry | null = null;
private hydrateHistoryEntry({
appState,
elements,
}: DehydratedHistoryEntry): HistoryEntry {
return {
appState: JSON.parse(appState),
elements: elements.map((dehydratedExcalidrawElement) => {
const element = this.elementCache
.get(dehydratedExcalidrawElement.id)
?.get(dehydratedExcalidrawElement.versionNonce);
if (!element) {
throw new Error(
`Element not found: ${dehydratedExcalidrawElement.id}:${dehydratedExcalidrawElement.versionNonce}`,
);
}
return element;
}),
};
public clear() {
this.undoStack.length = 0;
this.redoStack.length = 0;
}
private dehydrateHistoryEntry({
appState,
elements,
}: HistoryEntry): DehydratedHistoryEntry {
return {
appState: JSON.stringify(appState),
elements: elements.map((element: ExcalidrawElement) => {
if (!this.elementCache.has(element.id)) {
this.elementCache.set(element.id, new Map());
}
const versions = this.elementCache.get(element.id)!;
if (!versions.has(element.versionNonce)) {
versions.set(element.versionNonce, deepCopyElement(element));
}
return {
id: element.id,
versionNonce: element.versionNonce,
};
}),
};
/**
* Record a local change which will go into the history
*/
public record(
elementsChange: ElementsChange,
appStateChange: AppStateChange,
) {
const entry = HistoryEntry.create(appStateChange, elementsChange);
if (!entry.isEmpty()) {
this.undoStack.push(entry);
// As a new entry was pushed, we invalidate the redo stack
this.redoStack.length = 0;
}
}
getSnapshotForTest() {
return {
recording: this.recording,
stateHistory: this.stateHistory.map((dehydratedHistoryEntry) =>
this.hydrateHistoryEntry(dehydratedHistoryEntry),
),
redoStack: this.redoStack.map((dehydratedHistoryEntry) =>
this.hydrateHistoryEntry(dehydratedHistoryEntry),
),
};
public undo(elements: Map<string, ExcalidrawElement>, appState: AppState) {
return this.perform(this.undoOnce.bind(this), elements, appState);
}
clear() {
this.stateHistory.length = 0;
this.redoStack.length = 0;
this.lastEntry = null;
this.elementCache.clear();
public redo(elements: Map<string, ExcalidrawElement>, appState: AppState) {
return this.perform(this.redoOnce.bind(this), elements, appState);
}
private generateEntry = (
private perform(
action: typeof this.undoOnce | typeof this.redoOnce,
elements: Map<string, ExcalidrawElement>,
appState: AppState,
elements: readonly ExcalidrawElement[],
): DehydratedHistoryEntry =>
this.dehydrateHistoryEntry({
appState: clearAppStatePropertiesForHistory(appState),
elements: elements.reduce((elements, element) => {
if (
isLinearElement(element) &&
appState.multiElement &&
appState.multiElement.id === element.id
) {
// don't store multi-point arrow if still has only one point
if (
appState.multiElement &&
appState.multiElement.id === element.id &&
element.points.length < 2
) {
return elements;
}
elements.push({
...element,
// don't store last point if not committed
points:
element.lastCommittedPoint !==
element.points[element.points.length - 1]
? element.points.slice(0, -1)
: element.points,
});
} else {
elements.push(element);
}
return elements;
}, [] as Mutable<typeof elements>),
});
shouldCreateEntry(nextEntry: HistoryEntry): boolean {
const { lastEntry } = this;
if (!lastEntry) {
return true;
}
): [Map<string, ExcalidrawElement>, AppState] | void {
let historyEntry = action(elements);
if (nextEntry.elements.length !== lastEntry.elements.length) {
return true;
// Nothing to undo / redo
if (historyEntry === null) {
return;
}
// loop from right to left as changes are likelier to happen on new elements
for (let i = nextEntry.elements.length - 1; i > -1; i--) {
const prev = nextEntry.elements[i];
const next = lastEntry.elements[i];
if (
!prev ||
!next ||
prev.id !== next.id ||
prev.versionNonce !== next.versionNonce
) {
return true;
}
}
let nextElements = elements;
let nextAppState = appState;
let containsVisibleChange = false;
// note: this is safe because entry's appState is guaranteed no excess props
let key: keyof typeof nextEntry.appState;
for (key in nextEntry.appState) {
if (key === "editingLinearElement") {
if (
nextEntry.appState[key]?.elementId ===
lastEntry.appState[key]?.elementId
) {
continue;
}
}
if (key === "selectedElementIds" || key === "selectedGroupIds") {
continue;
}
if (nextEntry.appState[key] !== lastEntry.appState[key]) {
return true;
}
}
// Iterate through the history entries in case they result in no visible changes
while (historyEntry) {
[nextElements, nextAppState, containsVisibleChange] =
historyEntry.applyTo(nextElements, nextAppState);
return false;
}
pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) {
const newEntryDehydrated = this.generateEntry(appState, elements);
const newEntry: HistoryEntry = this.hydrateHistoryEntry(newEntryDehydrated);
if (newEntry) {
if (!this.shouldCreateEntry(newEntry)) {
return;
// TODO_UNDO: Be very carefuly here, as we could accidentaly iterate through the whole stack
if (containsVisibleChange) {
break;
}
this.stateHistory.push(newEntryDehydrated);
this.lastEntry = newEntry;
// As a new entry was pushed, we invalidate the redo stack
this.clearRedoStack();
historyEntry = action(elements);
}
}
clearRedoStack() {
this.redoStack.splice(0, this.redoStack.length);
return [nextElements, nextAppState];
}
redoOnce(): HistoryEntry | null {
if (this.redoStack.length === 0) {
private undoOnce(
elements: Map<string, ExcalidrawElement>,
): HistoryEntry | null {
if (!this.undoStack.length) {
return null;
}
const entryToRestore = this.redoStack.pop();
const undoEntry = this.undoStack.pop();
if (undoEntry !== undefined) {
const redoEntry = undoEntry.applyLatestChanges(elements, "to");
this.redoStack.push(redoEntry);
if (entryToRestore !== undefined) {
this.stateHistory.push(entryToRestore);
return this.hydrateHistoryEntry(entryToRestore);
return undoEntry.inverse();
}
return null;
}
undoOnce(): HistoryEntry | null {
if (this.stateHistory.length === 1) {
private redoOnce(
elements: Map<string, ExcalidrawElement>,
): HistoryEntry | null {
if (!this.redoStack.length) {
return null;
}
const currentEntry = this.stateHistory.pop();
const redoEntry = this.redoStack.pop();
const entryToRestore = this.stateHistory[this.stateHistory.length - 1];
if (redoEntry !== undefined) {
const undoEntry = redoEntry.applyLatestChanges(elements, "from");
this.undoStack.push(undoEntry);
if (currentEntry !== undefined) {
this.redoStack.push(currentEntry);
return this.hydrateHistoryEntry(entryToRestore);
return redoEntry;
}
return null;
}
}
export class HistoryEntry {
private constructor(
private readonly appStateChange: AppStateChange,
private readonly elementsChange: ElementsChange,
) {}
public static create(
appStateChange: AppStateChange,
elementsChange: ElementsChange,
) {
return new HistoryEntry(appStateChange, elementsChange);
}
public inverse(): HistoryEntry {
return new HistoryEntry(
this.appStateChange.inverse(),
this.elementsChange.inverse(),
);
}
public applyTo(
elements: Map<string, ExcalidrawElement>,
appState: AppState,
): [Map<string, ExcalidrawElement>, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] =
this.elementsChange.applyTo(elements);
const [nextAppState, appStateContainsVisibleChange] =
this.appStateChange.applyTo(appState, nextElements);
const appliedVisibleChanges =
elementsContainVisibleChange || appStateContainsVisibleChange;
return [nextElements, nextAppState, appliedVisibleChanges];
}
/**
* Updates history's `lastEntry` to latest app state. This is necessary
* when doing undo/redo which itself doesn't commit to history, but updates
* app state in a way that would break `shouldCreateEntry` which relies on
* `lastEntry` to reflect last comittable history state.
* We can't update `lastEntry` from within history when calling undo/redo
* because the action potentially mutates appState/elements before storing
* it.
* Apply latest (remote) changes to the history entry, creates new instance of `HistoryEntry`.
*/
setCurrentState(appState: AppState, elements: readonly ExcalidrawElement[]) {
this.lastEntry = this.hydrateHistoryEntry(
this.generateEntry(appState, elements),
public applyLatestChanges(
elements: Map<string, ExcalidrawElement>,
modifierOptions: "from" | "to",
): HistoryEntry {
const updatedElementsChange = this.elementsChange.applyLatestChanges(
elements,
modifierOptions,
);
}
// Suspicious that this is called so many places. Seems error-prone.
resumeRecording() {
this.recording = true;
return HistoryEntry.create(this.appStateChange, updatedElementsChange);
}
record(state: AppState, elements: readonly ExcalidrawElement[]) {
if (this.recording) {
this.pushEntry(state, elements);
this.recording = false;
}
public isEmpty(): boolean {
return this.appStateChange.isEmpty() && this.elementsChange.isEmpty();
}
}
export default History;

@ -11,6 +11,17 @@ The change should be grouped under one of the below section and must contain PR
Please add the latest change on the top under the correct section.
-->
## Unreleased
### Features
- Support for multiplayer undo / redo [#7348](https://github.com/excalidraw/excalidraw/pull/7348).
### Breaking Changes
- Renamed required `updatedScene` parameter from `commitToHistory` into `commitToStore` [#7348](https://github.com/excalidraw/excalidraw/pull/7348).
- Updates of `elements` or `appState` performed through [`updateScene`](https://github.com/excalidraw/excalidraw/blob/master/src/components/App.tsx#L282) without `commitToStore` set to `true` require a new parameter `skipSnapshotUpdate` to be set to `true`, if the given update should be locally undo-able with the next user action. In other cases such a parameter shouldn't be needed, i.e. as in during multiplayer collab updates, which shouldn't should not be locally undoable.
## 0.17.0 (2023-11-14)
### Features

@ -127,6 +127,10 @@ class Scene {
return this.elements;
}
getElementsMapIncludingDeleted() {
return this.elementsMap;
}
getNonDeletedElements(): readonly NonDeletedExcalidrawElement[] {
return this.nonDeletedElements;
}

@ -0,0 +1,307 @@
import { getDefaultAppState } from "./appState";
import { AppStateChange, ElementsChange } from "./change";
import { deepCopyElement } from "./element/newElement";
import { ExcalidrawElement } from "./element/types";
import { Emitter } from "./emitter";
import { AppState, ObservedAppState } from "./types";
import { isShallowEqual } from "./utils";
const getObservedAppState = (appState: AppState): ObservedAppState => {
return {
name: appState.name,
editingGroupId: appState.editingGroupId,
viewBackgroundColor: appState.viewBackgroundColor,
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
editingLinearElement: appState.editingLinearElement,
selectedLinearElement: appState.selectedLinearElement, // TODO_UNDO: Think about these two as one level shallow equal is not enough for them (they have new reference even though they shouldn't, sometimes their id does not correspond to selectedElementId)
};
};
/**
* Store which captures the observed changes and emits them as `StoreIncrementEvent` events.
*
* For the future:
* - Store should coordinate the changes and maintain its increments cohesive between different instances.
* - Store increments should be kept as append-only events log, with additional metadata, such as the logical timestamp for conflict-free resolution of increments.
* - Store flow should be bi-directional, not only listening and capturing changes, but mainly receiving increments as commands and applying them to the state.
*
* @experimental this interface is experimental and subject to change.
*/
export interface IStore {
/**
* Capture changes to the @param elements and @param appState by diff calculation and emitting resulting changes as store increment.
* In case the property `onlyUpdatingSnapshot` is set, it will only update the store snapshot, without calculating diffs.
*
* @emits StoreIncrementEvent
*/
capture(elements: Map<string, ExcalidrawElement>, appState: AppState): void;
/**
* Listens to the store increments, emitted by the capture method.
* Suitable for consuming store increments by various system components, such as History, Collab, Storage and etc.
*
* @listens StoreIncrementEvent
*/
listen(
callback: (
elementsChange: ElementsChange,
appStateChange: AppStateChange,
) => void,
): ReturnType<Emitter<StoreIncrementEvent>["on"]>;
/**
* Clears the store instance.
*/
clear(): void;
}
/**
* Represent an increment to the Store.
*/
type StoreIncrementEvent = [
elementsChange: ElementsChange,
appStateChange: AppStateChange,
];
export class Store implements IStore {
private readonly onStoreIncrementEmitter = new Emitter<StoreIncrementEvent>();
private capturingChanges: boolean = false;
private updatingSnapshot: boolean = false;
public snapshot = Snapshot.empty();
public scheduleSnapshotUpdate() {
this.updatingSnapshot = true;
}
// Suspicious that this is called so many places. Seems error-prone.
public resumeCapturing() {
this.capturingChanges = true;
}
public capture(
elements: Map<string, ExcalidrawElement>,
appState: AppState,
): void {
// Quick exit for irrelevant changes
if (!this.capturingChanges && !this.updatingSnapshot) {
return;
}
try {
const nextSnapshot = this.snapshot.clone(elements, appState);
// Optimisation, don't continue if nothing has changed
if (this.snapshot !== nextSnapshot) {
// Calculate and record the changes based on the previous and next snapshot
if (this.capturingChanges) {
const elementsChange = nextSnapshot.meta.didElementsChange
? ElementsChange.calculate(
this.snapshot.elements,
nextSnapshot.elements,
)
: ElementsChange.empty();
const appStateChange = nextSnapshot.meta.didAppStateChange
? AppStateChange.calculate(
this.snapshot.appState,
nextSnapshot.appState,
)
: AppStateChange.empty();
if (!elementsChange.isEmpty() || !appStateChange.isEmpty()) {
// Notify listeners with the increment
this.onStoreIncrementEmitter.trigger(
elementsChange,
appStateChange,
);
}
}
// Update the snapshot
this.snapshot = nextSnapshot;
}
} finally {
// Reset props
this.updatingSnapshot = false;
this.capturingChanges = false;
}
}
public ignoreUncomittedElements(
prevElements: Map<string, ExcalidrawElement>,
nextElements: Map<string, ExcalidrawElement>,
) {
for (const [id, prevElement] of prevElements.entries()) {
const nextElement = nextElements.get(id);
if (!nextElement) {
// Nothing to care about here, elements were forcefully updated
continue;
}
const elementSnapshot = this.snapshot.elements.get(id);
// Uncomitted element's snapshot doesn't exist, or its snapshot has lower version than the local element
if (
!elementSnapshot ||
(elementSnapshot && elementSnapshot.version < prevElement.version)
) {
if (elementSnapshot) {
nextElements.set(id, elementSnapshot);
} else {
nextElements.delete(id);
}
}
}
return nextElements;
}
public listen(
callback: (
elementsChange: ElementsChange,
appStateChange: AppStateChange,
) => void,
) {
return this.onStoreIncrementEmitter.on(callback);
}
public clear(): void {
this.snapshot = Snapshot.empty();
}
public destroy(): void {
this.clear();
this.onStoreIncrementEmitter.destroy();
}
}
class Snapshot {
private constructor(
public readonly elements: Map<string, ExcalidrawElement>,
public readonly appState: ObservedAppState,
public readonly meta: {
didElementsChange: boolean;
didAppStateChange: boolean;
isEmpty?: boolean;
} = {
didElementsChange: false,
didAppStateChange: false,
isEmpty: false,
},
) {}
public static empty() {
return new Snapshot(
new Map(),
getObservedAppState(getDefaultAppState() as AppState),
{ didElementsChange: false, didAppStateChange: false, isEmpty: true },
);
}
public isEmpty() {
return this.meta.isEmpty;
}
/**
* Efficiently clone the existing snapshot.
*
* @returns same instance if there are no changes detected, new Snapshot instance otherwise.
*/
public clone(elements: Map<string, ExcalidrawElement>, appState: AppState) {
const didElementsChange = this.detectChangedElements(elements);
// Not watching over everything from app state, just the relevant props
const nextAppStateSnapshot = getObservedAppState(appState);
const didAppStateChange = this.detectChangedAppState(nextAppStateSnapshot);
// Nothing has changed, so there is no point of continuing further
if (!didElementsChange && !didAppStateChange) {
return this;
}
// Clone only if there was really a change
let nextElementsSnapshot = this.elements;
if (didElementsChange) {
nextElementsSnapshot = this.createElementsSnapshot(elements);
}
const snapshot = new Snapshot(nextElementsSnapshot, nextAppStateSnapshot, {
didElementsChange,
didAppStateChange,
});
return snapshot;
}
/**
* Detect if there any changed elements.
*
* NOTE: we shouldn't use `sceneVersionNonce` instead, as we need to calls this before the scene updates.
*/
private detectChangedElements(nextElements: Map<string, ExcalidrawElement>) {
if (this.elements === nextElements) {
return false;
}
if (this.elements.size !== nextElements.size) {
return true;
}
// loop from right to left as changes are likelier to happen on new elements
const keys = Array.from(nextElements.keys());
for (let i = keys.length - 1; i >= 0; i--) {
const prev = this.elements.get(keys[i]);
const next = nextElements.get(keys[i]);
if (
!prev ||
!next ||
prev.id !== next.id ||
prev.versionNonce !== next.versionNonce
) {
return true;
}
}
return false;
}
private detectChangedAppState(observedAppState: ObservedAppState) {
// TODO_UNDO: Linear element?
return !isShallowEqual(this.appState, observedAppState, {
selectedElementIds: isShallowEqual,
selectedGroupIds: isShallowEqual,
});
}
/**
* Perform structural clone, cloning only elements that changed.
*/
private createElementsSnapshot(nextElements: Map<string, ExcalidrawElement>) {
const clonedElements = new Map();
for (const [id, prevElement] of this.elements.entries()) {
// clone previous elements, never delete, in case nextElements would be just a subset of previous elements
// i.e. during collab, persist or whenenever isDeleted elements are cleared
clonedElements.set(id, prevElement);
}
for (const [id, nextElement] of nextElements.entries()) {
const prevElement = clonedElements.get(id);
// At this point our elements are reconcilled already, meaning the next element is always newer
if (
!prevElement || // element was added
(prevElement && prevElement.versionNonce !== nextElement.versionNonce) // element was updated
) {
clonedElements.set(id, deepCopyElement(nextElement));
}
}
return clonedElements;
}
}

@ -27,7 +27,7 @@ const checkpoint = (name: string) => {
`[${name}] number of renders`,
);
expect(h.state).toMatchSnapshot(`[${name}] appState`);
expect(h.history.getSnapshotForTest()).toMatchSnapshot(`[${name}] history`);
expect(h.history).toMatchSnapshot(`[${name}] history`);
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
h.elements.forEach((element, i) =>
expect(element).toMatchSnapshot(`[${name}] element ${i}`),

@ -66,9 +66,14 @@ export class API {
return selectedElements[0];
};
static getStateHistory = () => {
static getUndoStack = () => {
// @ts-ignore
return h.history.stateHistory;
return h.history.undoStack;
};
static getRedoStack = () => {
// @ts-ignore
return h.history.redoStack;
};
static clearSelection = () => {

@ -108,6 +108,18 @@ export class Keyboard {
Keyboard.codeDown(code);
Keyboard.codeUp(code);
};
static undo = () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress("z");
});
};
static redo = () => {
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress("z");
});
};
}
const getElementPointForSelection = (element: ExcalidrawElement): Point => {

@ -1,4 +1,4 @@
import { assertSelectedElements, render } from "./test-utils";
import { assertSelectedElements, render, togglePopover } from "./test-utils";
import { Excalidraw } from "../packages/excalidraw/index";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { API } from "./helpers/api";
@ -6,13 +6,18 @@ import { getDefaultAppState } from "../appState";
import { waitFor } from "@testing-library/react";
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { ExcalidrawImperativeAPI } from "../types";
import { resolvablePromise } from "../utils";
import { COLOR_PALETTE } from "../colors";
import { KEYS } from "../keys";
import { newElementWith } from "../element/mutateElement";
const { h } = window;
const mouse = new Pointer("mouse");
describe("history", () => {
it("initializing scene should end up with single history entry", async () => {
it("initializing scene should end up with no history entry", async () => {
await render(
<Excalidraw
initialData={{
@ -24,12 +29,15 @@ describe("history", () => {
/>,
);
await waitFor(() => expect(h.state.zenModeEnabled).toBe(true));
await waitFor(() =>
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
);
await waitFor(() => {
expect(h.state.zenModeEnabled).toBe(true);
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(h.history.isUndoStackEmpty).toBeTruthy();
});
const undoAction = createUndoAction(h.history);
const redoAction = createRedoAction(h.history);
// noop
h.app.actionManager.executeAction(undoAction);
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
@ -51,14 +59,14 @@ describe("history", () => {
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: rectangle.id, isDeleted: true }),
]);
expect(API.getStateHistory().length).toBe(1);
expect(API.getUndoStack().length).toBe(0);
h.app.actionManager.executeAction(redoAction);
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: rectangle.id, isDeleted: false }),
]);
expect(API.getStateHistory().length).toBe(2);
expect(API.getUndoStack().length).toBe(1);
});
it("scene import via drag&drop should create new history entry", async () => {
@ -94,9 +102,10 @@ describe("history", () => {
),
);
await waitFor(() => expect(API.getStateHistory().length).toBe(2));
await waitFor(() => expect(API.getUndoStack().length).toBe(1));
expect(h.state.viewBackgroundColor).toBe("#000");
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: true }),
expect.objectContaining({ id: "B", isDeleted: false }),
]);
@ -111,8 +120,8 @@ describe("history", () => {
h.app.actionManager.executeAction(redoAction);
expect(h.state.viewBackgroundColor).toBe("#000");
expect(h.elements).toEqual([
expect.objectContaining({ id: "B", isDeleted: false }),
expect.objectContaining({ id: "A", isDeleted: true }),
expect.objectContaining({ id: "B", isDeleted: false }),
]);
});
@ -189,4 +198,652 @@ describe("history", () => {
expect.objectContaining({ A: true }),
);
});
it("undo/redo should support basic element creation, selection and deletion", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const rect1 = UI.createElement("rectangle", { x: 10 });
const rect2 = UI.createElement("rectangle", { x: 20, y: 20 });
const rect3 = UI.createElement("rectangle", { x: 40, y: 40 });
mouse.select([rect2, rect3]);
Keyboard.keyDown(KEYS.DELETE);
expect(API.getUndoStack().length).toBe(6);
Keyboard.undo();
assertSelectedElements(rect2, rect3);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ id: rect3.id, isDeleted: false }),
]);
Keyboard.undo();
assertSelectedElements(rect2);
Keyboard.undo();
assertSelectedElements(rect3);
Keyboard.undo();
assertSelectedElements(rect2);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect2.id }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
Keyboard.undo();
assertSelectedElements(rect1);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect2.id, isDeleted: true }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
Keyboard.undo();
assertSelectedElements();
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: true }),
expect.objectContaining({ id: rect2.id, isDeleted: true }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
// no-op
Keyboard.undo();
assertSelectedElements();
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: true }),
expect.objectContaining({ id: rect2.id, isDeleted: true }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
Keyboard.redo();
assertSelectedElements(rect1);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect2.id, isDeleted: true }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
Keyboard.redo();
assertSelectedElements(rect2);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect2.id }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
Keyboard.redo();
assertSelectedElements(rect3);
Keyboard.redo();
assertSelectedElements(rect2);
Keyboard.redo();
assertSelectedElements(rect2, rect3);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ id: rect3.id, isDeleted: false }),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(6);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements();
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: true }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
// no-op
Keyboard.redo();
expect(API.getUndoStack().length).toBe(6);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements();
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: true }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
});
describe("multiplayer undo/redo", () => {
const transparent = COLOR_PALETTE.transparent;
const red = COLOR_PALETTE.red[1];
const blue = COLOR_PALETTE.blue[1];
const yellow = COLOR_PALETTE.yellow[1];
const violet = COLOR_PALETTE.violet[1];
let excalidrawAPI: ExcalidrawImperativeAPI;
beforeEach(async () => {
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
await render(
<Excalidraw
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
handleKeyboardGlobally={true}
/>,
);
excalidrawAPI = await excalidrawAPIPromise;
});
it("applying history entries should not override remote changes on different elements", () => {
UI.createElement("rectangle", { x: 10 });
togglePopover("Background");
UI.clickOnTestId("color-red");
expect(API.getUndoStack().length).toBe(2);
// Simulate remote update
excalidrawAPI.updateScene({
elements: [
...h.elements,
API.createElement({
type: "rectangle",
strokeColor: blue,
}),
],
});
Keyboard.undo();
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: transparent }),
expect.objectContaining({ strokeColor: blue }),
]);
Keyboard.redo();
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: red }),
expect.objectContaining({ strokeColor: blue }),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getUndoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: transparent }),
expect.objectContaining({ strokeColor: blue }),
]);
});
it("applying history entries should not override remote changes on different props", () => {
UI.createElement("rectangle", { x: 10 });
togglePopover("Background");
UI.clickOnTestId("color-red");
expect(API.getUndoStack().length).toBe(2);
// Simulate remote update
excalidrawAPI.updateScene({
elements: [
newElementWith(h.elements[0], {
strokeColor: yellow,
}),
],
});
Keyboard.undo();
expect(h.elements).toEqual([
expect.objectContaining({
backgroundColor: transparent,
strokeColor: yellow,
}),
]);
Keyboard.redo();
expect(h.elements).toEqual([
expect.objectContaining({
backgroundColor: red,
strokeColor: yellow,
}),
]);
});
// https://www.figma.com/blog/how-figmas-multiplayer-technology-works/#implementing-undo
it("history entries should get updated after remote changes on same props", async () => {
UI.createElement("rectangle", { x: 10 });
togglePopover("Background");
UI.clickOnTestId("color-red");
UI.clickOnTestId("color-blue");
// At this point we have all the history entries created, no new entries will be created, only existing entries will get inversed and updated
expect(API.getUndoStack().length).toBe(3);
Keyboard.undo();
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: red }),
]);
Keyboard.redo();
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: blue }),
]);
// Simulate remote update
excalidrawAPI.updateScene({
elements: [
newElementWith(h.elements[0], {
backgroundColor: yellow,
}),
],
});
// At this point our entry gets updated from `red` -> `blue` into `red` -> `yellow`
Keyboard.undo();
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: red }),
]);
// Simulate remote update
excalidrawAPI.updateScene({
elements: [
newElementWith(h.elements[0], {
backgroundColor: violet,
}),
],
});
// At this point our (inversed) entry gets updated from `red` -> `yellow` into `violet` -> `yellow`
Keyboard.redo();
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: yellow }),
]);
Keyboard.undo();
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: violet }),
]);
Keyboard.undo();
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: transparent }),
]);
});
it("")
it("should iterate through the history when element changes relate only to remotely deleted elements", async () => {
const rect1 = UI.createElement("rectangle", { x: 10 });
const rect2 = UI.createElement("rectangle", { x: 20 });
togglePopover("Background");
UI.clickOnTestId("color-red");
const rect3 = UI.createElement("rectangle", { x: 30, y: 30 });
mouse.downAt(35, 35);
mouse.moveTo(55, 55);
mouse.upAt(55, 55);
expect(API.getUndoStack().length).toBe(5);
// Simulate remote update
excalidrawAPI.updateScene({
elements: [
h.elements[0],
newElementWith(h.elements[1], {
isDeleted: true,
}),
newElementWith(h.elements[2], {
isDeleted: true,
}),
],
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(4);
expect(API.getSelectedElements()).toEqual([
expect.objectContaining({ id: rect1.id }),
]);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
}),
expect.objectContaining({
id: rect2.id,
isDeleted: true,
backgroundColor: transparent,
}),
expect.objectContaining({
id: rect3.id,
isDeleted: true,
x: 30,
y: 30,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSelectedElements()).toEqual([
expect.objectContaining({ id: rect3.id }),
]);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
}),
expect.objectContaining({
id: rect2.id,
isDeleted: true,
backgroundColor: red,
}),
expect.objectContaining({
id: rect3.id,
isDeleted: true,
x: 50,
y: 50,
}),
]);
});
it("should iterate through the history when selection changes relate only to remotely deleted elements", async () => {
const rect1 = API.createElement({ type: "rectangle", x: 10, y: 10 });
const rect2 = API.createElement({ type: "rectangle", x: 20, y: 20 });
const rect3 = API.createElement({ type: "rectangle", x: 30, y: 30 });
h.elements = [rect1, rect2, rect3];
mouse.select(rect1);
mouse.select([rect2, rect3]);
expect(API.getUndoStack().length).toBe(3);
// Simulate remote update
excalidrawAPI.updateScene({
elements: [
h.elements[0],
newElementWith(h.elements[1], {
isDeleted: true,
}),
newElementWith(h.elements[2], {
isDeleted: true,
}),
],
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(2);
expect(API.getSelectedElements()).toEqual([
expect.objectContaining({ id: rect1.id }),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSelectedElements()).toEqual([
expect.objectContaining({ id: rect2.id }),
expect.objectContaining({ id: rect3.id }),
]);
});
it("should iterate through the history when selection changes relate only to remotely deleted elements", async () => {
const rect1 = API.createElement({ type: "rectangle", x: 10, y: 10 });
const rect2 = API.createElement({ type: "rectangle", x: 20, y: 20 });
const rect3 = API.createElement({ type: "rectangle", x: 30, y: 30 });
h.elements = [rect1, rect2, rect3];
mouse.select(rect1);
mouse.select([rect2, rect3]);
expect(API.getUndoStack().length).toBe(3);
// Simulate remote update
excalidrawAPI.updateScene({
elements: [
h.elements[0],
newElementWith(h.elements[1], {
isDeleted: true,
}),
newElementWith(h.elements[2], {
isDeleted: true,
}),
],
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(2);
expect(API.getSelectedElements()).toEqual([
expect.objectContaining({ id: rect1.id }),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSelectedElements()).toEqual([
expect.objectContaining({ id: rect2.id }),
expect.objectContaining({ id: rect3.id }),
]);
});
it("remote update should not interfere with in progress freedraw", async () => {
UI.clickTool("freedraw");
mouse.down(10, 10);
mouse.moveTo(30, 30);
// Simulate remote update
const rect = API.createElement({
type: "rectangle",
strokeColor: blue,
});
excalidrawAPI.updateScene({
elements: [...h.elements, rect],
});
mouse.moveTo(60, 60);
mouse.up();
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
id: h.elements[0].id,
type: "freedraw",
isDeleted: true,
}),
expect.objectContaining({ ...rect }),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
id: h.elements[0].id,
type: "freedraw",
isDeleted: false,
}),
expect.objectContaining({ ...rect }),
]);
});
// TODO_UNDO: tests like this might need to go through some util, as expectations in redo / undo are duplicated
it("remote update should not interfere with in progress dragging", async () => {
const rect1 = UI.createElement("rectangle", { x: 10, y: 10 });
const rect2 = UI.createElement("rectangle", { x: 30, y: 30 });
mouse.select([rect1, rect2]);
mouse.downAt(20, 20);
mouse.moveTo(50, 50);
assertSelectedElements(rect1, rect2);
expect(API.getUndoStack().length).toBe(4);
const rect3 = API.createElement({
type: "rectangle",
strokeColor: blue,
});
// Simulate remote update
excalidrawAPI.updateScene({
elements: [...h.elements, rect3],
});
mouse.moveTo(100, 100);
mouse.up();
expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements(rect1, rect2);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
x: 90,
y: 90,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
x: 110,
y: 110,
isDeleted: false,
}),
expect.objectContaining({ ...rect3 }),
]);
Keyboard.undo();
assertSelectedElements(rect1, rect2);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
x: 10,
y: 10,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
x: 30,
y: 30,
isDeleted: false,
}),
expect.objectContaining({ ...rect3 }),
]);
Keyboard.undo();
assertSelectedElements(rect1);
Keyboard.undo();
assertSelectedElements(rect2);
Keyboard.undo();
assertSelectedElements(rect1);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
x: 10,
y: 10,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
x: 30,
y: 30,
isDeleted: true,
}),
expect.objectContaining({ ...rect3 }),
]);
Keyboard.undo();
assertSelectedElements();
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
x: 10,
y: 10,
isDeleted: true,
}),
expect.objectContaining({
id: rect2.id,
x: 30,
y: 30,
isDeleted: true,
}),
expect.objectContaining({ ...rect3 }),
]);
Keyboard.redo();
assertSelectedElements(rect1);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
x: 10,
y: 10,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
x: 30,
y: 30,
isDeleted: true,
}),
expect.objectContaining({ ...rect3 }),
]);
Keyboard.redo();
assertSelectedElements(rect2);
Keyboard.redo();
assertSelectedElements(rect1);
Keyboard.redo();
assertSelectedElements(rect1, rect2);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
x: 10,
y: 10,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
x: 30,
y: 30,
isDeleted: false,
}),
expect.objectContaining({ ...rect3 }),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements(rect1, rect2);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
x: 90,
y: 90,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
x: 110,
y: 110,
isDeleted: false,
}),
expect.objectContaining({ ...rect3 }),
]);
});
// TODO_UNDO: testing testing David' concurrency issues (dragging, image, etc.)
// TODO_UNDO: test "UPDATE" actions as they become undoable (), but are necessary for the diff calculation and unexpect during undo / redo (also test change CAPTURE)
// TODO_UNDO: testing edge cases - bound elements get often messed up (i.e. when client 1 adds it to client2 element and client 2 undos)
// TODO_UNDO: testing edge cases - empty undos - when item are already selected
// TODO_UNDO: testing z-index actions (after Ryans PR)
// TODO_UNDO: testing linear element + editor (multiple, single clients / empty undo / redos / selection)
// TODO_UNDO: testing edge cases - align actions bugs
// TODO_UNDO: testing edge cases - unit testing quick quick reference checks and exits
// TODO_UNDO: testing edge cases - add what elements should not contain (notEqual)
// TODO_UNDO: testing edge cases - clearing of redo stack
// TODO_UNDO: testing edge cases - expected reference values in deltas
// TODO_UNDO: testing edge cases - caching / cloning of snapshot and its disposal
// TODO_UNDO: testing edge cases - state of the stored increments / changes and their deltas
// TODO_UNDO: test out number of store calls in collab
});
});

@ -35,7 +35,7 @@ const checkpoint = (name: string) => {
`[${name}] number of renders`,
);
expect(h.state).toMatchSnapshot(`[${name}] appState`);
expect(h.history.getSnapshotForTest()).toMatchSnapshot(`[${name}] history`);
expect(h.history).toMatchSnapshot(`[${name}] history`);
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
h.elements.forEach((element, i) =>
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
@ -372,7 +372,7 @@ describe("regression tests", () => {
});
it("noop interaction after undo shouldn't create history entry", () => {
expect(API.getStateHistory().length).toBe(1);
expect(API.getUndoStack().length).toBe(0);
UI.clickTool("rectangle");
mouse.down(10, 10);
@ -386,35 +386,35 @@ describe("regression tests", () => {
const secondElementEndPoint = mouse.getPosition();
expect(API.getStateHistory().length).toBe(3);
expect(API.getUndoStack().length).toBe(2);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
});
expect(API.getStateHistory().length).toBe(2);
expect(API.getUndoStack().length).toBe(1);
// clicking an element shouldn't add to history
mouse.restorePosition(...firstElementEndPoint);
mouse.click();
expect(API.getStateHistory().length).toBe(2);
expect(API.getUndoStack().length).toBe(1);
Keyboard.withModifierKeys({ shift: true, ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
});
expect(API.getStateHistory().length).toBe(3);
expect(API.getUndoStack().length).toBe(2);
// clicking an element shouldn't add to history
// clicking an element should add to history
mouse.click();
expect(API.getStateHistory().length).toBe(3);
expect(API.getUndoStack().length).toBe(3);
const firstSelectedElementId = API.getSelectedElement().id;
// same for clicking the element just redo-ed
mouse.restorePosition(...secondElementEndPoint);
mouse.click();
expect(API.getStateHistory().length).toBe(3);
expect(API.getUndoStack().length).toBe(4);
expect(API.getSelectedElement().id).not.toEqual(firstSelectedElementId);
});

@ -38,6 +38,7 @@ import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
import { ContextMenuItems } from "./components/ContextMenu";
import { SnapLine } from "./snapping";
import { Merge, ValueOf } from "./utility-types";
import { IStore } from "./store";
export type Point = Readonly<RoughPoint>;
@ -172,7 +173,23 @@ export type InteractiveCanvasAppState = Readonly<
}
>;
export interface AppState {
export type ObservedAppState = ObservedStandaloneAppState &
ObservedElementsAppState;
export type ObservedStandaloneAppState = {
name: AppState["name"];
viewBackgroundColor: AppState["viewBackgroundColor"];
};
export type ObservedElementsAppState = {
editingGroupId: AppState["editingGroupId"];
selectedElementIds: AppState["selectedElementIds"];
selectedGroupIds: AppState["selectedGroupIds"];
editingLinearElement: AppState["editingLinearElement"];
selectedLinearElement: AppState["selectedLinearElement"];
};
export type AppState = {
contextMenu: {
items: ContextMenuItems;
top: number;
@ -452,7 +469,8 @@ export type SceneData = {
elements?: ImportedDataState["elements"];
appState?: ImportedDataState["appState"];
collaborators?: Map<string, Collaborator>;
commitToHistory?: boolean;
commitToStore?: boolean;
skipSnapshotUpdate?: boolean; // TODO_UNDO: this flag is weird & causing breaking change, think about inverse (might cause less isues)
};
export enum UserIdleState {
@ -630,6 +648,13 @@ export type ExcalidrawImperativeAPI = {
history: {
clear: InstanceType<typeof App>["resetHistory"];
};
/**
* @experimental this API is experimental and subject to change
*/
store: {
clear: IStore["clear"];
listen: IStore["listen"];
};
scrollToContent: InstanceType<typeof App>["scrollToContent"];
getSceneElements: InstanceType<typeof App>["getSceneElements"];
getAppState: () => InstanceType<typeof App>["state"];

Loading…
Cancel
Save