+
{props.icon || props.label}
{props.keyBindingLabel && (
diff --git a/src/components/ToolIcon.scss b/src/components/ToolIcon.scss
index 8e857dd54b..16fd979ca5 100644
--- a/src/components/ToolIcon.scss
+++ b/src/components/ToolIcon.scss
@@ -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;
}
diff --git a/src/constants.ts b/src/constants.ts
index 6cf75fb59e..e6c7a79268 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -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
diff --git a/src/css/theme.scss b/src/css/theme.scss
index 4fb8bf81f7..2a79cda213 100644
--- a/src/css/theme.scss
+++ b/src/css/theme.scss
@@ -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;
diff --git a/src/css/variables.module.scss b/src/css/variables.module.scss
index 634752dfa0..4d16d159d3 100644
--- a/src/css/variables.module.scss
+++ b/src/css/variables.module.scss
@@ -50,6 +50,15 @@
color: var(--color-on-primary-container);
}
}
+
+ &[aria-disabled="true"] {
+ background: initial;
+ border: none;
+
+ svg {
+ color: var(--color-disabled);
+ }
+ }
}
}
diff --git a/src/element/Hyperlink.tsx b/src/element/Hyperlink.tsx
index caed8fe374..812f5cc316 100644
--- a/src/element/Hyperlink.tsx
+++ b/src/element/Hyperlink.tsx
@@ -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" },
diff --git a/src/element/embeddable.ts b/src/element/embeddable.ts
index c129d39270..1e39a12813 100644
--- a/src/element/embeddable.ts
+++ b/src/element/embeddable.ts
@@ -17,6 +17,7 @@ import {
IframeData,
NonDeletedExcalidrawElement,
} from "./types";
+import { StoreAction } from "../actions/types";
const embeddedLinkCache = new Map();
@@ -286,7 +287,8 @@ export const actionSetEmbeddableAsActiveTool = register({
type: "embeddable",
}),
},
- commitToHistory: false,
+ storeAction: StoreAction.NONE,
+
};
},
});
diff --git a/src/element/linearElementEditor.ts b/src/element/linearElementEditor.ts
index 9ee490b393..7b90a4d16c 100644
--- a/src/element/linearElementEditor.ts
+++ b/src/element/linearElementEditor.ts
@@ -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,
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: {
diff --git a/src/element/mutateElement.ts b/src/element/mutateElement.ts
index d4dbd8cd25..c7f050ddc3 100644
--- a/src/element/mutateElement.ts
+++ b/src/element/mutateElement.ts
@@ -106,24 +106,27 @@ export const mutateElement = >(
export const newElementWith = (
element: TElement,
updates: ElementUpdate,
+ 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 {
diff --git a/src/history.ts b/src/history.ts
index d102a7ecc9..d45be264a2 100644
--- a/src/history.ts
+++ b/src/history.ts
@@ -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;
- 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>();
- 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, 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, appState: AppState) {
+ return this.perform(this.redoOnce.bind(this), elements, appState);
}
- private generateEntry = (
+ private perform(
+ action: typeof this.undoOnce | typeof this.redoOnce,
+ elements: Map,
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),
- });
-
- shouldCreateEntry(nextEntry: HistoryEntry): boolean {
- const { lastEntry } = this;
-
- if (!lastEntry) {
- return true;
- }
+ ): [Map, 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,
+ ): 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,
+ ): 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,
+ appState: AppState,
+ ): [Map, 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,
+ 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;
diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md
index 8110aad84e..9db2bb1eed 100644
--- a/src/packages/excalidraw/CHANGELOG.md
+++ b/src/packages/excalidraw/CHANGELOG.md
@@ -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
diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts
index e60786fc58..fe44db71fd 100644
--- a/src/scene/Scene.ts
+++ b/src/scene/Scene.ts
@@ -127,6 +127,10 @@ class Scene {
return this.elements;
}
+ getElementsMapIncludingDeleted() {
+ return this.elementsMap;
+ }
+
getNonDeletedElements(): readonly NonDeletedExcalidrawElement[] {
return this.nonDeletedElements;
}
diff --git a/src/store.ts b/src/store.ts
new file mode 100644
index 0000000000..2b1471708b
--- /dev/null
+++ b/src/store.ts
@@ -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, 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["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();
+
+ 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,
+ 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,
+ nextElements: Map,
+ ) {
+ 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,
+ 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, 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) {
+ 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) {
+ 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;
+ }
+}
diff --git a/src/tests/contextmenu.test.tsx b/src/tests/contextmenu.test.tsx
index 1b64384637..62b294002a 100644
--- a/src/tests/contextmenu.test.tsx
+++ b/src/tests/contextmenu.test.tsx
@@ -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}`),
diff --git a/src/tests/helpers/api.ts b/src/tests/helpers/api.ts
index ac95ec5bc9..7bad9eede6 100644
--- a/src/tests/helpers/api.ts
+++ b/src/tests/helpers/api.ts
@@ -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 = () => {
diff --git a/src/tests/helpers/ui.ts b/src/tests/helpers/ui.ts
index f37ac00194..d8242648b7 100644
--- a/src/tests/helpers/ui.ts
+++ b/src/tests/helpers/ui.ts
@@ -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 => {
diff --git a/src/tests/history.test.tsx b/src/tests/history.test.tsx
index aabfc7a772..1e60cc5cf0 100644
--- a/src/tests/history.test.tsx
+++ b/src/tests/history.test.tsx
@@ -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(
{
/>,
);
- 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();
+
+ 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();
+ await render(
+ 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
+ });
});
diff --git a/src/tests/regressionTests.test.tsx b/src/tests/regressionTests.test.tsx
index b0c866a8a5..252f2bc3d2 100644
--- a/src/tests/regressionTests.test.tsx
+++ b/src/tests/regressionTests.test.tsx
@@ -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);
});
diff --git a/src/types.ts b/src/types.ts
index a0690f955e..fe017e09bd 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -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;
@@ -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;
- 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["resetHistory"];
};
+ /**
+ * @experimental this API is experimental and subject to change
+ */
+ store: {
+ clear: IStore["clear"];
+ listen: IStore["listen"];
+ };
scrollToContent: InstanceType["scrollToContent"];
getSceneElements: InstanceType["getSceneElements"];
getAppState: () => InstanceType["state"];