First steps towards onIncrement API

This commit is contained in:
Marcel Mraz 2025-04-25 22:14:39 +02:00
parent 192c4e7658
commit 0c21f1ae07
No known key found for this signature in database
GPG key ID: 4EBD6E62DC830CD2
51 changed files with 1358 additions and 870 deletions

View file

@ -807,6 +807,9 @@ const ExcalidrawWrapper = () => {
<Excalidraw
excalidrawAPI={excalidrawRefCallback}
onChange={onChange}
onIncrement={(increment) => {
console.log(increment);
}}
initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate}

View file

@ -122,7 +122,7 @@ describe("collaboration", () => {
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const undoAction = createUndoAction(h.history, h.store);
const undoAction = createUndoAction(h.history);
act(() => h.app.actionManager.executeAction(undoAction));
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
@ -154,7 +154,7 @@ describe("collaboration", () => {
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const redoAction = createRedoAction(h.history, h.store);
const redoAction = createRedoAction(h.history);
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as removal) we again restore the element from the snapshot!

View file

@ -1,4 +1,4 @@
import type { UnsubscribeCallback } from "./types";
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
type Subscriber<T extends any[]> = (...payload: T) => void;

View file

@ -9,3 +9,4 @@ export * from "./promise-pool";
export * from "./random";
export * from "./url";
export * from "./utils";
export * from "./emitter";

View file

@ -68,3 +68,8 @@ export type MaybePromise<T> = T | Promise<T>;
// get union of all keys from the union of types
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
/** Strip all the methods or functions from a type */
export type DTO<T> = {
[K in keyof T as T[K] extends Function ? never : K]: T[K];
};

View file

@ -5,43 +5,7 @@ import {
isDevEnv,
isShallowEqual,
isTestEnv,
toBrandedType,
} from "@excalidraw/common";
import {
BoundElement,
BindableElement,
bindingProperties,
updateBoundElements,
} from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import {
mutateElement,
newElementWith,
} from "@excalidraw/element/mutateElement";
import {
getBoundTextElementId,
redrawTextBoundingBox,
} from "@excalidraw/element/textElement";
import {
hasBoundTextElement,
isBindableElement,
isBoundToContainer,
isImageElement,
isTextElement,
} from "@excalidraw/element/typeChecks";
import { getNonDeletedGroupIds } from "@excalidraw/element/groups";
import {
orderByFractionalIndex,
syncMovedIndices,
} from "@excalidraw/element/fractionalIndex";
import Scene from "@excalidraw/element/Scene";
import type { BindableProp, BindingProp } from "@excalidraw/element/binding";
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
import type {
ExcalidrawElement,
@ -54,16 +18,42 @@ import type {
SceneElementsMap,
} from "@excalidraw/element/types";
import type { SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
import { getObservedAppState } from "./store";
import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
import type {
AppState,
ObservedAppState,
ObservedElementsAppState,
ObservedStandaloneAppState,
} from "./types";
} from "@excalidraw/excalidraw/types";
import { getObservedAppState } from "./store";
import {
BoundElement,
BindableElement,
bindingProperties,
updateBoundElements,
} from "./binding";
import { LinearElementEditor } from "./linearElementEditor";
import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElementId, redrawTextBoundingBox } from "./textElement";
import {
hasBoundTextElement,
isBindableElement,
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { getNonDeletedGroupIds } from "./groups";
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import Scene from "./Scene";
import type { BindableProp, BindingProp } from "./binding";
import type { ElementUpdate } from "./mutateElement";
/**
* Represents the difference between two objects of the same type.
@ -74,7 +64,7 @@ import type {
*
* Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load.
*/
class Delta<T> {
export class Delta<T> {
private constructor(
public readonly deleted: Partial<T>,
public readonly inserted: Partial<T>,
@ -409,51 +399,56 @@ class Delta<T> {
}
/**
* Encapsulates the modifications captured as `Delta`/s.
* Encapsulates a set of application-level `Delta`s.
*/
interface Change<T> {
export interface DeltaContainer<T> {
/**
* Inverses the `Delta`s inside while creating a new `Change`.
* Inverses the `Delta`s while creating a new `DeltaContainer` instance.
*/
inverse(): Change<T>;
inverse(): DeltaContainer<T>;
/**
* Applies the `Change` to the previous object.
* Applies the `Delta`s to the previous object.
*
* @returns a tuple of the next object `T` with applied change, and `boolean`, indicating whether the applied change resulted in a visible change.
* @returns a tuple of the next object `T` with applied `Delta`s, and `boolean`, indicating whether the applied deltas resulted in a visible change.
*/
applyTo(previous: T, ...options: unknown[]): [T, boolean];
/**
* Checks whether there are actually `Delta`s.
* Checks whether all `Delta`s are empty.
*/
isEmpty(): boolean;
}
export class AppStateChange implements Change<AppState> {
private constructor(private readonly delta: Delta<ObservedAppState>) {}
export class AppStateDelta implements DeltaContainer<AppState> {
private constructor(public readonly delta: Delta<ObservedAppState>) {}
public static calculate<T extends ObservedAppState>(
prevAppState: T,
nextAppState: T,
): AppStateChange {
): AppStateDelta {
const delta = Delta.calculate(
prevAppState,
nextAppState,
undefined,
AppStateChange.postProcess,
AppStateDelta.postProcess,
);
return new AppStateChange(delta);
return new AppStateDelta(delta);
}
public static restore(appStateDeltaDTO: DTO<AppStateDelta>): AppStateDelta {
const { delta } = appStateDeltaDTO;
return new AppStateDelta(delta);
}
public static empty() {
return new AppStateChange(Delta.create({}, {}));
return new AppStateDelta(Delta.create({}, {}));
}
public inverse(): AppStateChange {
public inverse(): AppStateDelta {
const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted);
return new AppStateChange(inversedDelta);
return new AppStateDelta(inversedDelta);
}
public applyTo(
@ -594,13 +589,13 @@ export class AppStateChange implements Change<AppState> {
const nextObservedAppState = getObservedAppState(nextAppState);
const containsStandaloneDifference = Delta.isRightDifferent(
AppStateChange.stripElementsProps(prevObservedAppState),
AppStateChange.stripElementsProps(nextObservedAppState),
AppStateDelta.stripElementsProps(prevObservedAppState),
AppStateDelta.stripElementsProps(nextObservedAppState),
);
const containsElementsDifference = Delta.isRightDifferent(
AppStateChange.stripStandaloneProps(prevObservedAppState),
AppStateChange.stripStandaloneProps(nextObservedAppState),
AppStateDelta.stripStandaloneProps(prevObservedAppState),
AppStateDelta.stripStandaloneProps(nextObservedAppState),
);
if (!containsStandaloneDifference && !containsElementsDifference) {
@ -615,8 +610,8 @@ export class AppStateChange implements Change<AppState> {
if (containsElementsDifference) {
// filter invisible changes on each iteration
const changedElementsProps = Delta.getRightDifferences(
AppStateChange.stripStandaloneProps(prevObservedAppState),
AppStateChange.stripStandaloneProps(nextObservedAppState),
AppStateDelta.stripStandaloneProps(prevObservedAppState),
AppStateDelta.stripStandaloneProps(nextObservedAppState),
) as Array<keyof ObservedElementsAppState>;
let nonDeletedGroupIds = new Set<string>();
@ -633,7 +628,7 @@ export class AppStateChange implements Change<AppState> {
for (const key of changedElementsProps) {
switch (key) {
case "selectedElementIds":
nextAppState[key] = AppStateChange.filterSelectedElements(
nextAppState[key] = AppStateDelta.filterSelectedElements(
nextAppState[key],
nextElements,
visibleDifferenceFlag,
@ -641,7 +636,7 @@ export class AppStateChange implements Change<AppState> {
break;
case "selectedGroupIds":
nextAppState[key] = AppStateChange.filterSelectedGroups(
nextAppState[key] = AppStateDelta.filterSelectedGroups(
nextAppState[key],
nonDeletedGroupIds,
visibleDifferenceFlag,
@ -677,7 +672,7 @@ export class AppStateChange implements Change<AppState> {
break;
case "selectedLinearElementId":
case "editingLinearElementId":
const appStateKey = AppStateChange.convertToAppStateKey(key);
const appStateKey = AppStateDelta.convertToAppStateKey(key);
const linearElement = nextAppState[appStateKey];
if (!linearElement) {
@ -823,50 +818,63 @@ type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
* 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, allowing to time-travel in both directions.
*/
export class ElementsChange implements Change<SceneElementsMap> {
export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private constructor(
private readonly added: Map<string, Delta<ElementPartial>>,
private readonly removed: Map<string, Delta<ElementPartial>>,
private readonly updated: Map<string, Delta<ElementPartial>>,
public readonly added: Record<string, Delta<ElementPartial>>,
public readonly removed: Record<string, Delta<ElementPartial>>,
public readonly updated: Record<string, Delta<ElementPartial>>,
) {}
public static create(
added: Map<string, Delta<ElementPartial>>,
removed: Map<string, Delta<ElementPartial>>,
updated: Map<string, Delta<ElementPartial>>,
options = { shouldRedistribute: false },
added: Record<string, Delta<ElementPartial>>,
removed: Record<string, Delta<ElementPartial>>,
updated: Record<string, Delta<ElementPartial>>,
options: {
shouldRedistribute: boolean;
} = {
shouldRedistribute: false,
},
) {
let change: ElementsChange;
let delta: ElementsDelta;
if (options.shouldRedistribute) {
const nextAdded = new Map<string, Delta<ElementPartial>>();
const nextRemoved = new Map<string, Delta<ElementPartial>>();
const nextUpdated = new Map<string, Delta<ElementPartial>>();
const nextAdded: Record<string, Delta<ElementPartial>> = {};
const nextRemoved: Record<string, Delta<ElementPartial>> = {};
const nextUpdated: Record<string, Delta<ElementPartial>> = {};
const deltas = [...added, ...removed, ...updated];
const deltas = [
...Object.entries(added),
...Object.entries(removed),
...Object.entries(updated),
];
for (const [id, delta] of deltas) {
if (this.satisfiesAddition(delta)) {
nextAdded.set(id, delta);
nextAdded[id] = delta;
} else if (this.satisfiesRemoval(delta)) {
nextRemoved.set(id, delta);
nextRemoved[id] = delta;
} else {
nextUpdated.set(id, delta);
nextUpdated[id] = delta;
}
}
change = new ElementsChange(nextAdded, nextRemoved, nextUpdated);
delta = new ElementsDelta(nextAdded, nextRemoved, nextUpdated);
} else {
change = new ElementsChange(added, removed, updated);
delta = new ElementsDelta(added, removed, updated);
}
if (isTestEnv() || isDevEnv()) {
ElementsChange.validate(change, "added", this.satisfiesAddition);
ElementsChange.validate(change, "removed", this.satisfiesRemoval);
ElementsChange.validate(change, "updated", this.satisfiesUpdate);
ElementsDelta.validate(delta, "added", this.satisfiesAddition);
ElementsDelta.validate(delta, "removed", this.satisfiesRemoval);
ElementsDelta.validate(delta, "updated", this.satisfiesUpdate);
}
return change;
return delta;
}
public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta {
const { added, removed, updated } = elementsDeltaDTO;
return ElementsDelta.create(added, removed, updated);
}
private static satisfiesAddition = ({
@ -888,17 +896,17 @@ export class ElementsChange implements Change<SceneElementsMap> {
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
private static validate(
change: ElementsChange,
elementsDelta: ElementsDelta,
type: "added" | "removed" | "updated",
satifies: (delta: Delta<ElementPartial>) => boolean,
) {
for (const [id, delta] of change[type].entries()) {
for (const [id, delta] of Object.entries(elementsDelta[type])) {
if (!satifies(delta)) {
console.error(
`Broken invariant for "${type}" delta, element "${id}", delta:`,
delta,
);
throw new Error(`ElementsChange invariant broken for element "${id}".`);
throw new Error(`ElementsDelta invariant broken for element "${id}".`);
}
}
}
@ -909,19 +917,19 @@ export class ElementsChange implements Change<SceneElementsMap> {
* @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.
* @returns `ElementsDelta` instance representing the `Delta` changes between the two sets of elements.
*/
public static calculate<T extends OrderedExcalidrawElement>(
prevElements: Map<string, T>,
nextElements: Map<string, T>,
): ElementsChange {
): ElementsDelta {
if (prevElements === nextElements) {
return ElementsChange.empty();
return ElementsDelta.empty();
}
const added = new Map<string, Delta<ElementPartial>>();
const removed = new Map<string, Delta<ElementPartial>>();
const updated = new Map<string, Delta<ElementPartial>>();
const added: Record<string, Delta<ElementPartial>> = {};
const removed: Record<string, Delta<ElementPartial>> = {};
const updated: Record<string, Delta<ElementPartial>> = {};
// this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements
for (const prevElement of prevElements.values()) {
@ -934,10 +942,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
const delta = Delta.create(
deleted,
inserted,
ElementsChange.stripIrrelevantProps,
ElementsDelta.stripIrrelevantProps,
);
removed.set(prevElement.id, delta);
removed[prevElement.id] = delta;
}
}
@ -954,10 +962,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
const delta = Delta.create(
deleted,
inserted,
ElementsChange.stripIrrelevantProps,
ElementsDelta.stripIrrelevantProps,
);
added.set(nextElement.id, delta);
added[nextElement.id] = delta;
continue;
}
@ -966,8 +974,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
const delta = Delta.calculate<ElementPartial>(
prevElement,
nextElement,
ElementsChange.stripIrrelevantProps,
ElementsChange.postProcess,
ElementsDelta.stripIrrelevantProps,
ElementsDelta.postProcess,
);
if (
@ -978,9 +986,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
) {
// notice that other props could have been updated as well
if (prevElement.isDeleted && !nextElement.isDeleted) {
added.set(nextElement.id, delta);
added[nextElement.id] = delta;
} else {
removed.set(nextElement.id, delta);
removed[nextElement.id] = delta;
}
continue;
@ -988,24 +996,24 @@ export class ElementsChange implements Change<SceneElementsMap> {
// making sure there are at least some changes
if (!Delta.isEmpty(delta)) {
updated.set(nextElement.id, delta);
updated[nextElement.id] = delta;
}
}
}
return ElementsChange.create(added, removed, updated);
return ElementsDelta.create(added, removed, updated);
}
public static empty() {
return ElementsChange.create(new Map(), new Map(), new Map());
return ElementsDelta.create({}, {}, {});
}
public inverse(): ElementsChange {
const inverseInternal = (deltas: Map<string, Delta<ElementPartial>>) => {
const inversedDeltas = new Map<string, Delta<ElementPartial>>();
public inverse(): ElementsDelta {
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of deltas.entries()) {
inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted));
for (const [id, delta] of Object.entries(deltas)) {
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
}
return inversedDeltas;
@ -1016,14 +1024,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
const updated = inverseInternal(this.updated);
// notice we inverse removed with added not to break the invariants
return ElementsChange.create(removed, added, updated);
return ElementsDelta.create(removed, added, updated);
}
public isEmpty(): boolean {
return (
this.added.size === 0 &&
this.removed.size === 0 &&
this.updated.size === 0
Object.keys(this.added).length === 0 &&
Object.keys(this.removed).length === 0 &&
Object.keys(this.updated).length === 0
);
}
@ -1034,7 +1042,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
* @returns new instance with modified delta/s
*/
public applyLatestChanges(elements: SceneElementsMap): ElementsChange {
public applyLatestChanges(
elements: SceneElementsMap,
modifierOptions: "deleted" | "inserted",
): ElementsDelta {
const modifier =
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
const latestPartial: { [key: string]: unknown } = {};
@ -1055,11 +1066,11 @@ export class ElementsChange implements Change<SceneElementsMap> {
};
const applyLatestChangesInternal = (
deltas: Map<string, Delta<ElementPartial>>,
deltas: Record<string, Delta<ElementPartial>>,
) => {
const modifiedDeltas = new Map<string, Delta<ElementPartial>>();
const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of deltas.entries()) {
for (const [id, delta] of Object.entries(deltas)) {
const existingElement = elements.get(id);
if (existingElement) {
@ -1067,12 +1078,12 @@ export class ElementsChange implements Change<SceneElementsMap> {
delta.deleted,
delta.inserted,
modifier(existingElement),
"inserted",
modifierOptions,
);
modifiedDeltas.set(id, modifiedDelta);
modifiedDeltas[id] = modifiedDelta;
} else {
modifiedDeltas.set(id, delta);
modifiedDeltas[id] = delta;
}
}
@ -1083,16 +1094,16 @@ export class ElementsChange implements Change<SceneElementsMap> {
const removed = applyLatestChangesInternal(this.removed);
const updated = applyLatestChangesInternal(this.updated);
return ElementsChange.create(added, removed, updated, {
return ElementsDelta.create(added, removed, updated, {
shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
});
}
public applyTo(
elements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>,
elementsSnapshot: Map<string, OrderedExcalidrawElement>,
): [SceneElementsMap, boolean] {
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
let nextElements = new Map(elements) as SceneElementsMap;
let changedElements: Map<string, OrderedExcalidrawElement>;
const flags = {
@ -1102,15 +1113,15 @@ export class ElementsChange implements Change<SceneElementsMap> {
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
try {
const applyDeltas = ElementsChange.createApplier(
const applyDeltas = ElementsDelta.createApplier(
nextElements,
snapshot,
elementsSnapshot,
flags,
);
const addedElements = applyDeltas(this.added);
const removedElements = applyDeltas(this.removed);
const updatedElements = applyDeltas(this.updated);
const addedElements = applyDeltas("added", this.added);
const removedElements = applyDeltas("removed", this.removed);
const updatedElements = applyDeltas("updated", this.updated);
const affectedElements = this.resolveConflicts(elements, nextElements);
@ -1122,7 +1133,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
...affectedElements,
]);
} catch (e) {
console.error(`Couldn't apply elements change`, e);
console.error(`Couldn't apply elements delta`, e);
if (isTestEnv() || isDevEnv()) {
throw e;
@ -1138,7 +1149,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
try {
// the following reorder performs also mutations, but only on new instances of changed elements
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
nextElements = ElementsChange.reorderElements(
nextElements = ElementsDelta.reorderElements(
nextElements,
changedElements,
flags,
@ -1149,9 +1160,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
// so we are creating a temp scene just to query and mutate elements
const tempScene = new Scene(nextElements);
ElementsChange.redrawTextBoundingBoxes(tempScene, changedElements);
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
// Need ordered nextElements to avoid z-index binding issues
ElementsChange.redrawBoundArrows(tempScene, changedElements);
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
} catch (e) {
console.error(
`Couldn't mutate elements after applying elements change`,
@ -1166,26 +1177,31 @@ export class ElementsChange implements Change<SceneElementsMap> {
}
}
private static createApplier = (
private static createApplier =
(
nextElements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>,
flags: {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
},
) =>
(
type: "added" | "removed" | "updated",
deltas: Record<string, Delta<ElementPartial>>,
) => {
const getElement = ElementsChange.createGetter(
const getElement = ElementsDelta.createGetter(
type,
nextElements,
snapshot,
flags,
);
return (deltas: Map<string, Delta<ElementPartial>>) =>
Array.from(deltas.entries()).reduce((acc, [id, delta]) => {
return Object.entries(deltas).reduce((acc, [id, delta]) => {
const element = getElement(id, delta.inserted);
if (element) {
const newElement = ElementsChange.applyDelta(element, delta, flags);
const newElement = ElementsDelta.applyDelta(element, delta, flags);
nextElements.set(newElement.id, newElement);
acc.set(newElement.id, newElement);
}
@ -1196,6 +1212,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
private static createGetter =
(
type: "added" | "removed" | "updated",
elements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>,
flags: {
@ -1221,6 +1238,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
) {
flags.containsVisibleDifference = true;
}
} else {
// not in elements, not in snapshot? element might have been added remotely!
element = newElementWith(
{ id, version: 1 } as OrderedExcalidrawElement,
{
...partial,
},
);
}
}
@ -1257,7 +1282,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
});
}
if (isImageElement(element)) {
// TODO: this looks wrong, shouldn't be here
if (element.type === "image") {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset
// when undoing/redoing unrelated change
@ -1270,10 +1296,12 @@ export class ElementsChange implements Change<SceneElementsMap> {
}
if (!flags.containsVisibleDifference) {
// strip away fractional as even if it would be different, it doesn't have to result in visible change
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial;
const containsVisibleDifference =
ElementsChange.checkForVisibleDifference(element, rest);
const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
element,
rest,
);
flags.containsVisibleDifference = containsVisibleDifference;
}
@ -1316,6 +1344,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
* Resolves conflicts for all previously added, removed and updated elements.
* Updates the previous deltas with all the changes after conflict resolution.
*
* // TODO: revisit since some bound arrows seem to be often redrawn incorrectly
*
* @returns all elements affected by the conflict resolution
*/
private resolveConflicts(
@ -1346,7 +1376,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
nextElement,
nextElements,
updates as ElementUpdate<OrderedExcalidrawElement>,
) as OrderedExcalidrawElement;
);
}
nextAffectedElements.set(affectedElement.id, affectedElement);
@ -1354,17 +1384,18 @@ export class ElementsChange implements Change<SceneElementsMap> {
};
// removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
for (const [id] of this.removed) {
ElementsChange.unbindAffected(prevElements, nextElements, id, updater);
for (const id of Object.keys(this.removed)) {
ElementsDelta.unbindAffected(prevElements, nextElements, id, updater);
}
// added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound
for (const [id] of this.added) {
ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
for (const id of Object.keys(this.added)) {
ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
}
// updated delta is affecting the binding only in case it contains changed binding or bindable property
for (const [id] of Array.from(this.updated).filter(([_, delta]) =>
for (const [id] of Array.from(Object.entries(this.updated)).filter(
([_, delta]) =>
Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
bindingProperties.has(prop as BindingProp | BindableProp),
),
@ -1375,7 +1406,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
continue;
}
ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
}
// filter only previous elements, which were now affected
@ -1385,21 +1416,21 @@ export class ElementsChange implements Change<SceneElementsMap> {
// calculate complete deltas for affected elements, and assign them back to all the deltas
// technically we could do better here if perf. would become an issue
const { added, removed, updated } = ElementsChange.calculate(
const { added, removed, updated } = ElementsDelta.calculate(
prevAffectedElements,
nextAffectedElements,
);
for (const [id, delta] of added) {
this.added.set(id, delta);
for (const [id, delta] of Object.entries(added)) {
this.added[id] = delta;
}
for (const [id, delta] of removed) {
this.removed.set(id, delta);
for (const [id, delta] of Object.entries(removed)) {
this.removed[id] = delta;
}
for (const [id, delta] of updated) {
this.updated.set(id, delta);
for (const [id, delta] of Object.entries(updated)) {
this.updated[id] = delta;
}
return nextAffectedElements;
@ -1572,7 +1603,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
} catch (e) {
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess elements change deltas.`);
console.error(`Couldn't postprocess elements delta.`);
if (isTestEnv() || isDevEnv()) {
throw e;
@ -1585,8 +1616,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
private static stripIrrelevantProps(
partial: Partial<OrderedExcalidrawElement>,
): ElementPartial {
const { id, updated, version, versionNonce, seed, ...strippedPartial } =
partial;
const { id, updated, version, versionNonce, ...strippedPartial } = partial;
return strippedPartial;
}

View file

@ -20,7 +20,7 @@ import {
tupleToCoors,
} from "@excalidraw/common";
import type { Store } from "@excalidraw/excalidraw/store";
import type { Store } from "@excalidraw/element/store";
import type { Radians } from "@excalidraw/math";
@ -807,7 +807,7 @@ export class LinearElementEditor {
});
ret.didAddPoint = true;
}
store.shouldCaptureIncrement();
store.scheduleCapture();
ret.linearElementEditor = {
...linearElementEditor,
pointerDownState: {

View file

@ -0,0 +1,867 @@
import {
arrayToMap,
assertNever,
COLOR_PALETTE,
isDevEnv,
isTestEnv,
randomId,
Emitter,
} from "@excalidraw/common";
import type { DTO, ValueOf } from "@excalidraw/common/utility-types";
import type { AppState, ObservedAppState } from "@excalidraw/excalidraw/types";
import { deepCopyElement } from "./duplicate";
import { newElementWith } from "./mutateElement";
import { syncMovedIndices } from "./fractionalIndex";
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
import { hashElementsVersion, hashString } from "./index";
import type {
ExcalidrawElement,
OrderedExcalidrawElement,
SceneElementsMap,
} from "./types";
export const CaptureUpdateAction = {
/**
* Immediately undoable.
*
* Use for updates which should be captured.
* Should be used for most of the local updates, except ephemerals such as dragging or resizing.
*
* These updates will _immediately_ make it to the local undo / redo stacks.
*/
IMMEDIATELY: "IMMEDIATELY",
/**
* Never undoable.
*
* Use for updates which should never be recorded, such as remote updates
* or scene initialization.
*
* These updates will _never_ make it to the local undo / redo stacks.
*/
NEVER: "NEVER",
/**
* Eventually undoable.
*
* Use for updates which should not be captured immediately - likely
* exceptions which are part of some async multi-step process. Otherwise, all
* such updates would end up being captured with the next
* `CaptureUpdateAction.IMMEDIATELY` - triggered either by the next `updateScene`
* or internally by the editor.
*
* These updates will _eventually_ make it to the local undo / redo stacks.
*/
EVENTUALLY: "EVENTUALLY",
} as const;
export type CaptureUpdateActionType = ValueOf<typeof CaptureUpdateAction>;
type MicroActionsQueue = (() => void)[];
/**
* Store which captures the observed changes and emits them as `StoreIncrement` events.
*/
export class Store {
public readonly onStoreIncrementEmitter = new Emitter<
[DurableIncrement | EphemeralIncrement]
>();
private scheduledMacroActions: Set<CaptureUpdateActionType> = new Set();
private scheduledMicroActions: MicroActionsQueue = [];
private _snapshot = StoreSnapshot.empty();
public get snapshot() {
return this._snapshot;
}
public set snapshot(snapshot: StoreSnapshot) {
this._snapshot = snapshot;
}
public scheduleAction(action: CaptureUpdateActionType) {
this.scheduledMacroActions.add(action);
this.satisfiesScheduledActionsInvariant();
}
/**
* Use to schedule a delta calculation, which will consquentially be emitted as `DurableStoreIncrement` and pushed in the undo stack.
*/
// TODO: Suspicious that this is called so many places. Seems error-prone.
public scheduleCapture() {
this.scheduleAction(CaptureUpdateAction.IMMEDIATELY);
}
/**
* Schedule special "micro" actions, to-be executed before the next commit, before it executes a scheduled "macro" action.
*/
public scheduleMicroAction(
action: CaptureUpdateActionType,
elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined = undefined,
/** delta is only relevant for `CaptureUpdateAction.IMMEDIATELY`, as it's the only action producing `DurableStoreIncrement` containing a delta */
delta: StoreDelta | undefined = undefined,
) {
// create a snapshot first, so that it couldn't mutate in the meantime
const snapshot = this.maybeCloneSnapshot(action, elements, appState);
if (!snapshot) {
return;
}
this.scheduledMicroActions.push(() =>
this.executeAction(action, snapshot, delta),
);
}
/**
* Performs the incoming `CaptureUpdateAction` and emits the corresponding `StoreIncrement`.
* Emits `DurableStoreIncrement` when action is "capture", emits `EphemeralStoreIncrement` otherwise.
*
* @emits StoreIncrement
*/
public commit(
elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined,
): void {
// execute all scheduled micro actions first
// similar to microTasks, there can be many
this.flushMicroActions();
try {
const macroAction = this.getScheduledMacroAction();
const nextSnapshot = this.maybeCloneSnapshot(
macroAction,
elements,
appState,
);
if (!nextSnapshot) {
// don't continue if there is not change detected
return;
}
// execute a single scheduled "macro" function
// similar to macro tasks, there can be only one within a single commit
this.executeAction(macroAction, nextSnapshot);
} finally {
this.satisfiesScheduledActionsInvariant();
// defensively reset all scheduled "macro" actions, possibly cleans up other runtime garbage
this.scheduledMacroActions = new Set();
}
}
/**
* Apply the increment to the passed elements and appState, does not modify the snapshot.
*/
public applyDeltaTo(
delta: StoreDelta,
elements: SceneElementsMap,
appState: AppState,
): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
elements,
this.snapshot.elements,
);
const [nextAppState, appStateContainsVisibleChange] =
delta.appState.applyTo(appState, nextElements);
const appliedVisibleChanges =
elementsContainVisibleChange || appStateContainsVisibleChange;
return [nextElements, nextAppState, appliedVisibleChanges];
}
/**
* Filters out yet uncomitted elements from `nextElements`, which are part of in-progress local async actions (ephemerals) and thus were not yet commited to the snapshot.
*
* This is necessary in updates in which we receive reconciled elements, already containing elements which were not yet captured by the local store (i.e. collab).
*/
public filterUncomittedElements(
prevElements: Map<string, ExcalidrawElement>,
nextElements: Map<string, ExcalidrawElement>,
): Map<string, OrderedExcalidrawElement> {
const movedElements = new Map<string, ExcalidrawElement>();
for (const [id, prevElement] of prevElements.entries()) {
const nextElement = nextElements.get(id);
if (!nextElement) {
// Nothing to care about here, element was forcefully deleted
continue;
}
const elementSnapshot = this.snapshot.elements.get(id);
// Checks for in progress async user action
if (!elementSnapshot) {
// Detected yet uncomitted local element
nextElements.delete(id);
} else if (elementSnapshot.version < prevElement.version) {
// Element was already commited, but the snapshot version is lower than current local version
nextElements.set(id, elementSnapshot);
// Mark the element as potentially moved, as it could have
movedElements.set(id, elementSnapshot);
}
}
// Make sure to sync only potentially invalid indices for all elements restored from the snapshot
const syncedElements = syncMovedIndices(
Array.from(nextElements.values()),
movedElements,
);
return arrayToMap(syncedElements);
}
/**
* Clears the store instance.
*/
public clear(): void {
this.snapshot = StoreSnapshot.empty();
this.scheduledMacroActions = new Set();
}
/**
* Executes the incoming `CaptureUpdateAction`, emits the corresponding `StoreIncrement` and maybe updates the snapshot.
*
* @emits StoreIncrement
*/
private executeAction(
action: CaptureUpdateActionType,
snapshot: StoreSnapshot,
delta: StoreDelta | undefined = undefined,
) {
try {
switch (action) {
// only immediately emits a durable increment
case CaptureUpdateAction.IMMEDIATELY:
this.emitDurableIncrement(snapshot, delta);
break;
// both never and eventually emit an ephemeral increment
case CaptureUpdateAction.NEVER:
case CaptureUpdateAction.EVENTUALLY:
this.emitEphemeralIncrement(snapshot);
break;
default:
assertNever(action, `Unknown store action`);
}
} finally {
// update the snpashot no-matter what, as it would mess up with the next action
switch (action) {
// both immediately and never update the snapshot, unlike eventually
case CaptureUpdateAction.IMMEDIATELY:
case CaptureUpdateAction.NEVER:
this.snapshot = snapshot;
break;
}
}
}
/**
* Performs delta & change calculation and emits a durable increment.
*
* @emits StoreIncrement.
*/
private emitDurableIncrement(
snapshot: StoreSnapshot,
delta: StoreDelta | undefined = undefined,
) {
const prevSnapshot = this.snapshot;
// Calculate the deltas based on the previous and next snapshot
// We might have the delta already (i.e. when applying history entry), thus we don't need to calculate it again
const elementsDelta = delta
? delta.elements
: snapshot.metadata.didElementsChange
? ElementsDelta.calculate(prevSnapshot.elements, snapshot.elements)
: ElementsDelta.empty();
const appStateDelta = delta
? delta.appState
: snapshot.metadata.didAppStateChange
? AppStateDelta.calculate(prevSnapshot.appState, snapshot.appState)
: AppStateDelta.empty();
if (!elementsDelta.isEmpty() || !appStateDelta.isEmpty()) {
const delta = StoreDelta.create(elementsDelta, appStateDelta);
const change = StoreChange.create(prevSnapshot, snapshot);
const increment = new DurableIncrement(change, delta);
// Notify listeners with the increment
this.onStoreIncrementEmitter.trigger(increment);
}
}
/**
* Performs change calculation and emits an ephemeral increment.
*
* @emits EphemeralStoreIncrement
*/
private emitEphemeralIncrement(snapshot: StoreSnapshot) {
const prevSnapshot = this.snapshot;
const change = StoreChange.create(prevSnapshot, snapshot);
const increment = new EphemeralIncrement(change);
// Notify listeners with the increment
this.onStoreIncrementEmitter.trigger(increment);
}
/**
* Clones the snapshot if there are changes detected.
*/
private maybeCloneSnapshot(
action: CaptureUpdateActionType,
elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined,
) {
if (!elements && !appState) {
return null;
}
const prevSnapshot = this.snapshot;
const nextSnapshot = this.snapshot.maybeClone(action, elements, appState);
if (prevSnapshot === nextSnapshot) {
return null;
}
return nextSnapshot;
}
private flushMicroActions() {
const microActions = [...this.scheduledMicroActions];
// clear the queue first, in case it mutates in the meantime
this.scheduledMicroActions = [];
for (const microAction of microActions) {
try {
microAction();
} catch (error) {
console.error(`Failed to execute scheduled micro action`, error);
}
}
}
/**
* Returns the scheduled macro action.
*/
private getScheduledMacroAction() {
let scheduledAction: CaptureUpdateActionType;
if (this.scheduledMacroActions.has(CaptureUpdateAction.IMMEDIATELY)) {
// Capture has a precedence over update, since it also performs snapshot update
scheduledAction = CaptureUpdateAction.IMMEDIATELY;
} else if (this.scheduledMacroActions.has(CaptureUpdateAction.NEVER)) {
// Update has a precedence over none, since it also emits an (ephemeral) increment
scheduledAction = CaptureUpdateAction.NEVER;
} else {
// Default is to emit ephemeral increment and don't update the snapshot
scheduledAction = CaptureUpdateAction.EVENTUALLY;
}
return scheduledAction;
}
/**
* Ensures that the scheduled actions invariant is satisfied.
*/
private satisfiesScheduledActionsInvariant() {
if (
!(
this.scheduledMacroActions.size >= 0 &&
this.scheduledMacroActions.size <=
Object.keys(CaptureUpdateAction).length
)
) {
const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledMacroActions.size}".`;
console.error(message, this.scheduledMacroActions.values());
if (isTestEnv() || isDevEnv()) {
throw new Error(message);
}
}
}
}
/**
* Repsents a change to the store containing changed elements and appState.
*/
export class StoreChange {
// so figuring out what has changed should ideally be just quick reference checks
private constructor(
public readonly elements: Record<string, OrderedExcalidrawElement>,
public readonly appState: Partial<ObservedAppState>,
) {}
public static create(
prevSnapshot: StoreSnapshot,
nextSnapshot: StoreSnapshot,
) {
const changedElements = nextSnapshot.getChangedElements(prevSnapshot);
const changedAppState = nextSnapshot.getChangedAppState(prevSnapshot);
return new StoreChange(changedElements, changedAppState);
}
}
/**
* Encpasulates any change to the store (durable or ephemeral).
*/
export abstract class StoreIncrement {
protected constructor(
public readonly type: "durable" | "ephemeral",
public readonly change: StoreChange,
) {}
public static isDurable(
increment: StoreIncrement,
): increment is DurableIncrement {
return increment.type === "durable";
}
public static isEphemeral(
increment: StoreIncrement,
): increment is EphemeralIncrement {
return increment.type === "ephemeral";
}
}
/**
* Represents a durable change to the store.
*/
export class DurableIncrement extends StoreIncrement {
constructor(
public readonly change: StoreChange,
public readonly delta: StoreDelta,
) {
super("durable", change);
}
}
/**
* Represents an ephemeral change to the store.
*/
export class EphemeralIncrement extends StoreIncrement {
constructor(public readonly change: StoreChange) {
super("ephemeral", change);
}
}
/**
* Represents a captured delta by the Store.
*/
export class StoreDelta {
protected constructor(
public readonly id: string,
public readonly elements: ElementsDelta,
public readonly appState: AppStateDelta,
) {}
/**
* Create a new instance of `StoreDelta`.
*/
public static create(
elements: ElementsDelta,
appState: AppStateDelta,
opts: {
id: string;
} = {
id: randomId(),
},
) {
return new this(opts.id, elements, appState);
}
/**
* Restore a store delta instance from a DTO.
*/
public static restore(storeDeltaDTO: DTO<StoreDelta>) {
const { id, elements, appState } = storeDeltaDTO;
return new this(
id,
ElementsDelta.restore(elements),
AppStateDelta.restore(appState),
);
}
/**
* Parse and load the delta from the remote payload.
*/
public static load({
id,
elements: { added, removed, updated },
}: DTO<StoreDelta>) {
const elements = ElementsDelta.create(added, removed, updated, {
shouldRedistribute: false,
});
return new this(id, elements, AppStateDelta.empty());
}
/**
* Inverse store delta, creates new instance of `StoreDelta`.
*/
public static inverse(delta: StoreDelta): StoreDelta {
return this.create(delta.elements.inverse(), delta.appState.inverse());
}
/**
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
*/
public static applyLatestChanges(
delta: StoreDelta,
elements: SceneElementsMap,
modifierOptions: "deleted" | "inserted",
): StoreDelta {
return this.create(
delta.elements.applyLatestChanges(elements, modifierOptions),
delta.appState,
{
id: delta.id,
},
);
}
public isEmpty() {
return this.elements.isEmpty() && this.appState.isEmpty();
}
}
/**
* Represents a snapshot of the captured or updated changes in the store,
* used for producing deltas and emitting `DurableStoreIncrement`s.
*/
export class StoreSnapshot {
private _lastChangedElementsHash: number = 0;
private _lastChangedAppStateHash: number = 0;
private constructor(
public readonly elements: Map<string, OrderedExcalidrawElement>,
public readonly appState: ObservedAppState,
public readonly metadata: {
didElementsChange: boolean;
didAppStateChange: boolean;
isEmpty?: boolean;
} = {
didElementsChange: false,
didAppStateChange: false,
isEmpty: false,
},
) {}
public static empty() {
return new StoreSnapshot(new Map(), getDefaultObservedAppState(), {
didElementsChange: false,
didAppStateChange: false,
isEmpty: true,
});
}
public getChangedElements(prevSnapshot: StoreSnapshot) {
const changedElements: Record<string, OrderedExcalidrawElement> = {};
for (const [id, nextElement] of this.elements.entries()) {
// Due to the structural clone inside `maybeClone`, we can perform just these reference checks
if (prevSnapshot.elements.get(id) !== nextElement) {
changedElements[id] = nextElement;
}
}
return changedElements;
}
public getChangedAppState(
prevSnapshot: StoreSnapshot,
): Partial<ObservedAppState> {
return Delta.getRightDifferences(
prevSnapshot.appState,
this.appState,
).reduce(
(acc, key) =>
Object.assign(acc, {
[key]: this.appState[key as keyof ObservedAppState],
}),
{} as Partial<ObservedAppState>,
);
}
public isEmpty() {
return this.metadata.isEmpty;
}
/**
* Efficiently clone the existing snapshot, only if we detected changes.
*
* @returns same instance if there are no changes detected, new instance otherwise.
*/
public maybeClone(
action: CaptureUpdateActionType,
elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined,
) {
const options = {
shouldCompareHashes: false,
};
if (action === CaptureUpdateAction.EVENTUALLY) {
// actions that do not update the snapshot immediately, must be additionally checked for changes against the latest hash
// as we are always comparing against the latest snapshot, so they would emit elements or appState as changed on every component update
// instead of just the first time the elements or appState actually changed
options.shouldCompareHashes = true;
}
const nextElementsSnapshot = this.maybeCreateElementsSnapshot(
elements,
options,
);
const nextAppStateSnapshot = this.maybeCreateAppStateSnapshot(
appState,
options,
);
let didElementsChange = false;
let didAppStateChange = false;
if (this.elements !== nextElementsSnapshot) {
didElementsChange = true;
}
if (this.appState !== nextAppStateSnapshot) {
didAppStateChange = true;
}
if (!didElementsChange && !didAppStateChange) {
return this;
}
const snapshot = new StoreSnapshot(
nextElementsSnapshot,
nextAppStateSnapshot,
{
didElementsChange,
didAppStateChange,
},
);
return snapshot;
}
private maybeCreateAppStateSnapshot(
appState: AppState | ObservedAppState | undefined,
options: {
shouldCompareHashes: boolean;
} = {
shouldCompareHashes: false,
},
) {
if (!appState) {
return this.appState;
}
// Not watching over everything from the app state, just the relevant props
const nextAppStateSnapshot = !isObservedAppState(appState)
? getObservedAppState(appState)
: appState;
const changedAppState = this.detectChangedAppState(
nextAppStateSnapshot,
options,
);
if (!changedAppState) {
return this.appState;
}
return nextAppStateSnapshot;
}
private maybeCreateElementsSnapshot(
elements: Map<string, OrderedExcalidrawElement> | undefined,
options: {
shouldCompareHashes: boolean;
} = {
shouldCompareHashes: false,
},
) {
if (!elements) {
return this.elements;
}
const changedElements = this.detectChangedElements(elements, options);
if (!changedElements?.size) {
return this.elements;
}
const elementsSnapshot = this.createElementsSnapshot(changedElements);
return elementsSnapshot;
}
private detectChangedAppState(
nextObservedAppState: ObservedAppState,
options: {
shouldCompareHashes: boolean;
} = {
shouldCompareHashes: false,
},
) {
if (this.appState === nextObservedAppState) {
return;
}
const changedAppState = Delta.getRightDifferences(
this.appState,
nextObservedAppState,
);
if (!changedAppState.length) {
return;
}
const changedAppStateHash = hashString(
JSON.stringify(nextObservedAppState),
);
if (
options.shouldCompareHashes &&
this._lastChangedAppStateHash === changedAppStateHash
) {
return;
}
this._lastChangedElementsHash = changedAppStateHash;
return changedAppState;
}
/**
* Detect if there any changed elements.
*
* NOTE: we shouldn't just use `sceneVersionNonce` instead, as we need to call this before the scene updates.
*/
private detectChangedElements(
nextElements: Map<string, OrderedExcalidrawElement>,
options: {
shouldCompareHashes: boolean;
} = {
shouldCompareHashes: false,
},
) {
if (this.elements === nextElements) {
return;
}
const changedElements: Map<string, OrderedExcalidrawElement> = new Map();
for (const [id, prevElement] of this.elements) {
const nextElement = nextElements.get(id);
if (!nextElement) {
// element was deleted
changedElements.set(
id,
newElementWith(prevElement, { isDeleted: true }),
);
}
}
for (const [id, nextElement] of nextElements) {
const prevElement = this.elements.get(id);
if (
!prevElement || // element was added
prevElement.version < nextElement.version // element was updated
) {
changedElements.set(id, nextElement);
}
}
if (!changedElements.size) {
return;
}
const changedElementsHash = hashElementsVersion(
Array.from(changedElements.values()),
);
if (
options.shouldCompareHashes &&
this._lastChangedElementsHash === changedElementsHash
) {
return;
}
this._lastChangedElementsHash = changedElementsHash;
return changedElements;
}
/**
* Perform structural clone, deep cloning only elements that changed.
*/
private createElementsSnapshot(
changedElements: Map<string, OrderedExcalidrawElement>,
) {
const clonedElements = new Map();
for (const [id, prevElement] of this.elements) {
// Clone previous elements, never delete, in case nextElements would be just a subset of previous elements
// i.e. during collab, persist or whenenever isDeleted elements get cleared
clonedElements.set(id, prevElement);
}
for (const [id, changedElement] of changedElements) {
clonedElements.set(id, deepCopyElement(changedElement));
}
return clonedElements;
}
}
// hidden non-enumerable property for runtime checks
const hiddenObservedAppStateProp = "__observedAppState";
const getDefaultObservedAppState = (): ObservedAppState => {
return {
name: null,
editingGroupId: null,
viewBackgroundColor: COLOR_PALETTE.white,
selectedElementIds: {},
selectedGroupIds: {},
editingLinearElementId: null,
selectedLinearElementId: null,
croppingElementId: null,
};
};
export const getObservedAppState = (appState: AppState): ObservedAppState => {
const observedAppState = {
name: appState.name,
editingGroupId: appState.editingGroupId,
viewBackgroundColor: appState.viewBackgroundColor,
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
editingLinearElementId: appState.editingLinearElement?.elementId || null,
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
croppingElementId: appState.croppingElementId,
};
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
value: true,
enumerable: false,
});
return observedAppState;
};
const isObservedAppState = (
appState: AppState | ObservedAppState,
): appState is ObservedAppState =>
!!Reflect.get(appState, hiddenObservedAppStateProp);

View file

@ -1,8 +1,9 @@
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
import { deepCopyElement } from "@excalidraw/element/duplicate";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -8,6 +8,8 @@ import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { alignElements } from "@excalidraw/element/align";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Alignment } from "@excalidraw/element/align";
@ -25,7 +27,6 @@ import {
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -33,6 +33,8 @@ import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { newElement } from "@excalidraw/element/newElement";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@ -44,8 +46,6 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
import type { AppState } from "../types";

View file

@ -17,6 +17,8 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element/mutateElement";
import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import {
@ -44,7 +46,6 @@ import { t } from "../i18n";
import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -3,6 +3,8 @@ import { getTextFromElements } from "@excalidraw/element/textElement";
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import {
copyTextToSystemClipboard,
copyToClipboard,
@ -15,8 +17,6 @@ import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
import { exportCanvas, prepareElementsForExport } from "../data/index";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { register } from "./register";

View file

@ -1,11 +1,12 @@
import { isImageElement } from "@excalidraw/element/typeChecks";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
import { ToolButton } from "../components/ToolButton";
import { cropIcon } from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -17,11 +17,12 @@ import {
selectGroupsForSelectedElements,
} from "@excalidraw/element/groups";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { TrashIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";

View file

@ -8,6 +8,8 @@ import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/fra
import { distributeElements } from "@excalidraw/element/distribute";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Distribution } from "@excalidraw/element/distribute";
@ -21,7 +23,6 @@ import {
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -18,12 +18,13 @@ import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { duplicateElements } from "@excalidraw/element/duplicate";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { ToolButton } from "../components/ToolButton";
import { DuplicateIcon } from "../components/icons";
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -4,11 +4,12 @@ import {
getLinkIdAndTypeFromSelection,
} from "@excalidraw/element/elementLink";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { copyTextToSystemClipboard } from "../clipboard";
import { copyIcon, elementLinkIcon } from "../components/icons";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -4,12 +4,13 @@ import { newElementWith } from "@excalidraw/element/mutateElement";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { LockedIcon, UnlockedIcon } from "../components/icons";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -1,7 +1,8 @@
import { updateActiveTool } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { setCursorForShape } from "../cursor";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -7,6 +7,8 @@ import {
import { getNonDeletedElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { Theme } from "@excalidraw/element/types";
import { useDevice } from "../components/App";
@ -24,7 +26,6 @@ import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getExportSize } from "../scene/export";
import { CaptureUpdateAction } from "../store";
import "../components/ToolIcon.scss";

View file

@ -16,11 +16,12 @@ import { isPathALoop } from "@excalidraw/element/shapes";
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { t } from "../i18n";
import { resetCursor } from "../cursor";
import { done } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -15,6 +15,8 @@ import {
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
@ -24,7 +26,6 @@ import type {
} from "@excalidraw/element/types";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { flipHorizontal, flipVertical } from "../components/icons";

View file

@ -14,12 +14,13 @@ import { getElementsInGroup } from "@excalidraw/element/groups";
import { getCommonBounds } from "@excalidraw/element/bounds";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { setCursorForShape } from "../cursor";
import { frameToolIcon } from "../components/icons";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -28,6 +28,8 @@ import {
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
@ -40,7 +42,6 @@ import { UngroupIcon, GroupIcon } from "../components/icons";
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -1,5 +1,7 @@
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { SceneElementsMap } from "@excalidraw/element/types";
import { ToolButton } from "../components/ToolButton";
@ -7,10 +9,8 @@ import { UndoIcon, RedoIcon } from "../components/icons";
import { HistoryChangedEvent } from "../history";
import { useEmitter } from "../hooks/useEmitter";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import type { History } from "../history";
import type { Store } from "../store";
import type { AppClassProperties, AppState } from "../types";
import type { Action, ActionResult } from "./types";
@ -47,9 +47,9 @@ const executeHistoryAction = (
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
};
type ActionCreator = (history: History, store: Store) => Action;
type ActionCreator = (history: History) => Action;
export const createUndoAction: ActionCreator = (history, store) => ({
export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
label: "buttons.undo",
icon: UndoIcon,
@ -57,11 +57,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
viewMode: false,
perform: (elements, appState, value, app) =>
executeHistoryAction(app, appState, () =>
history.undo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
),
history.undo(arrayToMap(elements) as SceneElementsMap, appState),
),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
@ -88,19 +84,15 @@ export const createUndoAction: ActionCreator = (history, store) => ({
},
});
export const createRedoAction: ActionCreator = (history, store) => ({
export const createRedoAction: ActionCreator = (history) => ({
name: "redo",
label: "buttons.redo",
icon: RedoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState, _, app) =>
perform: (elements, appState, __, app) =>
executeHistoryAction(app, appState, () =>
history.redo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
),
history.redo(arrayToMap(elements) as SceneElementsMap, appState),
),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||

View file

@ -4,6 +4,8 @@ import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks";
import { arrayToMap } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
@ -11,7 +13,6 @@ import { ToolButton } from "../components/ToolButton";
import { lineEditorIcon } from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -2,13 +2,14 @@ import { isEmbeddableElement } from "@excalidraw/element/typeChecks";
import { KEYS, getShortcutKey } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { ToolButton } from "../components/ToolButton";
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
import { LinkIcon } from "../components/icons";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -4,12 +4,12 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { ToolButton } from "../components/ToolButton";
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
export const actionToggleCanvasMenu = register({

View file

@ -1,5 +1,7 @@
import clsx from "clsx";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar";
import {
@ -8,7 +10,6 @@ import {
microphoneMutedIcon,
} from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -54,6 +54,8 @@ import { hasStrokeColor } from "@excalidraw/element/comparisons";
import { updateElbowArrowPoints } from "@excalidraw/element/elbowArrow";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { LocalPoint } from "@excalidraw/math";
import type {
@ -70,6 +72,8 @@ import type {
import type Scene from "@excalidraw/element/Scene";
import type { CaptureUpdateActionType } from "@excalidraw/element/store";
import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
@ -131,11 +135,9 @@ import {
getTargetElements,
isSomeElementSelected,
} from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
import type { CaptureUpdateActionType } from "../store";
import type { AppClassProperties, AppState, Primitive } from "../types";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;

View file

@ -6,9 +6,9 @@ import { arrayToMap, KEYS } from "@excalidraw/common";
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { CaptureUpdateAction } from "../store";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { selectAllIcon } from "../components/icons";

View file

@ -24,13 +24,14 @@ import {
redrawTextBoundingBox,
} from "@excalidraw/element/textElement";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
import { paintIcon } from "../components/icons";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -5,8 +5,9 @@ import { measureText } from "@excalidraw/element/textMeasurements";
import { isTextElement } from "@excalidraw/element/typeChecks";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -1,7 +1,8 @@
import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { gridIcon } from "../components/icons";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -1,7 +1,8 @@
import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { magnetIcon } from "../components/icons";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -5,8 +5,9 @@ import {
DEFAULT_SIDEBAR,
} from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { searchIcon } from "../components/icons";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -1,7 +1,8 @@
import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { abacusIcon } from "../components/icons";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -1,7 +1,8 @@
import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { eyeIcon } from "../components/icons";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -1,7 +1,8 @@
import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import { coffeeIcon } from "../components/icons";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -7,6 +7,8 @@ import {
moveAllRight,
} from "@excalidraw/element/zindex";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import {
BringForwardIcon,
BringToFrontIcon,
@ -14,7 +16,6 @@ import {
SendToBackIcon,
} from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View file

@ -3,7 +3,8 @@ import type {
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import type { CaptureUpdateActionType } from "../store";
import type { CaptureUpdateActionType } from "@excalidraw/element/store";
import type {
AppClassProperties,
AppState,

View file

@ -101,6 +101,7 @@ import {
type EXPORT_IMAGE_TYPES,
randomInteger,
CLASSES,
Emitter,
} from "@excalidraw/common";
import {
@ -303,6 +304,12 @@ import { isNonDeletedElement } from "@excalidraw/element";
import Scene from "@excalidraw/element/Scene";
import {
Store,
CaptureUpdateAction,
StoreIncrement,
} from "@excalidraw/element/store";
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
import type { LocalPoint, Radians } from "@excalidraw/math";
@ -454,9 +461,7 @@ import {
resetCursor,
setCursorForShape,
} from "../cursor";
import { Emitter } from "../emitter";
import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
import { Store, CaptureUpdateAction } from "../store";
import { LaserTrails } from "../laser-trails";
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
@ -762,7 +767,7 @@ class App extends React.Component<AppProps, AppState> {
this.visibleElements = [];
this.store = new Store();
this.history = new History();
this.history = new History(this.store);
if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = {
@ -772,6 +777,7 @@ class App extends React.Component<AppProps, AppState> {
addFiles: this.addFiles,
resetScene: this.resetScene,
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
store: this.store,
history: {
clear: this.resetHistory,
},
@ -792,6 +798,7 @@ class App extends React.Component<AppProps, AppState> {
updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb),
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
@ -810,15 +817,11 @@ class App extends React.Component<AppProps, AppState> {
};
this.fonts = new Fonts(this.scene);
this.history = new History();
this.history = new History(this.store);
this.actionManager.registerAll(actions);
this.actionManager.registerAction(
createUndoAction(this.history, this.store),
);
this.actionManager.registerAction(
createRedoAction(this.history, this.store),
);
this.actionManager.registerAction(createUndoAction(this.history));
this.actionManager.registerAction(createRedoAction(this.history));
}
updateEditorAtom = <Value, Args extends unknown[], Result>(
@ -1899,6 +1902,10 @@ class App extends React.Component<AppProps, AppState> {
return this.scene.getElementsIncludingDeleted();
};
public getSceneElementsMapIncludingDeleted = () => {
return this.scene.getElementsMapIncludingDeleted();
};
public getSceneElements = () => {
return this.scene.getNonDeletedElements();
};
@ -2215,11 +2222,7 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (actionResult.captureUpdate === CaptureUpdateAction.NEVER) {
this.store.shouldUpdateSnapshot();
} else if (actionResult.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
this.store.shouldCaptureIncrement();
}
this.store.scheduleAction(actionResult.captureUpdate);
let didUpdate = false;
@ -2292,10 +2295,7 @@ class App extends React.Component<AppProps, AppState> {
didUpdate = true;
}
if (
!didUpdate &&
actionResult.captureUpdate !== CaptureUpdateAction.EVENTUALLY
) {
if (!didUpdate) {
this.scene.triggerUpdate();
}
});
@ -2548,7 +2548,11 @@ class App extends React.Component<AppProps, AppState> {
}
this.store.onStoreIncrementEmitter.on((increment) => {
this.history.record(increment.elementsChange, increment.appStateChange);
if (StoreIncrement.isDurable(increment)) {
this.history.record(increment.delta);
}
this.props.onIncrement?.(increment);
});
this.scene.onUpdate(this.triggerRender);
@ -2903,7 +2907,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement &&
!this.state.selectedElementIds[this.state.editingLinearElement.elementId]
) {
// defer so that the shouldCaptureIncrement flag isn't reset via current update
// defer so that the scheduleCapture 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
@ -3358,7 +3362,7 @@ class App extends React.Component<AppProps, AppState> {
this.addMissingFiles(opts.files);
}
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
const nextElementsToSelect =
excludeElementsInFramesFromSelection(duplicatedElements);
@ -3619,7 +3623,7 @@ class App extends React.Component<AppProps, AppState> {
PLAIN_PASTE_TOAST_SHOWN = true;
}
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
setAppState: React.Component<any, AppState>["setState"] = (
@ -3975,51 +3979,42 @@ class App extends React.Component<AppProps, AppState> {
*/
captureUpdate?: SceneData["captureUpdate"];
}) => {
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
const { elements, appState, collaborators, captureUpdate } = sceneData;
if (
sceneData.captureUpdate &&
sceneData.captureUpdate !== CaptureUpdateAction.EVENTUALLY
) {
const nextElements = elements ? syncInvalidIndices(elements) : undefined;
if (captureUpdate) {
const prevCommittedAppState = this.store.snapshot.appState;
const prevCommittedElements = this.store.snapshot.elements;
const nextCommittedAppState = sceneData.appState
? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
const nextCommittedAppState = appState
? Object.assign({}, prevCommittedAppState, appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
: prevCommittedAppState;
const nextCommittedElements = sceneData.elements
const nextCommittedElements = elements
? this.store.filterUncomittedElements(
this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements
arrayToMap(nextElements), // We expect all (already reconciled) elements
this.scene.getElementsMapIncludingDeleted(), // only used to detect uncomitted local elements
arrayToMap(nextElements ?? []), // we expect all (already reconciled) elements
)
: prevCommittedElements;
// WARN: store action always performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter
// do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well
if (sceneData.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
this.store.captureIncrement(
nextCommittedElements,
nextCommittedAppState,
);
} else if (sceneData.captureUpdate === CaptureUpdateAction.NEVER) {
this.store.updateSnapshot(
this.store.scheduleMicroAction(
captureUpdate,
nextCommittedElements,
nextCommittedAppState,
);
}
if (appState) {
this.setState(appState);
}
if (sceneData.appState) {
this.setState(sceneData.appState);
}
if (sceneData.elements) {
if (nextElements) {
this.scene.replaceAllElements(nextElements);
}
if (sceneData.collaborators) {
this.setState({ collaborators: sceneData.collaborators });
if (collaborators) {
this.setState({ collaborators });
}
},
);
@ -4202,7 +4197,7 @@ class App extends React.Component<AppProps, AppState> {
direction: event.shiftKey ? "left" : "right",
})
) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
}
if (conversionType) {
@ -4519,7 +4514,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement.elementId !==
selectedElements[0].id
) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
if (!isElbowArrow(selectedElement)) {
this.setState({
editingLinearElement: new LinearElementEditor(
@ -4845,7 +4840,7 @@ class App extends React.Component<AppProps, AppState> {
} as const;
if (nextActiveTool.type === "freedraw") {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
if (nextActiveTool.type === "lasso") {
@ -5062,7 +5057,7 @@ class App extends React.Component<AppProps, AppState> {
]);
}
if (!isDeleted || isExistingElement) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
flushSync(() => {
@ -5475,7 +5470,7 @@ class App extends React.Component<AppProps, AppState> {
};
private startImageCropping = (image: ExcalidrawImageElement) => {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
this.setState({
croppingElementId: image.id,
});
@ -5483,7 +5478,7 @@ class App extends React.Component<AppProps, AppState> {
private finishImageCropping = () => {
if (this.state.croppingElementId) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
this.setState({
croppingElementId: null,
});
@ -5518,7 +5513,7 @@ class App extends React.Component<AppProps, AppState> {
selectedElements[0].id) &&
!isElbowArrow(selectedElements[0])
) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
this.setState({
editingLinearElement: new LinearElementEditor(
selectedElements[0],
@ -5546,7 +5541,7 @@ class App extends React.Component<AppProps, AppState> {
: -1;
if (midPoint && midPoint > -1) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
LinearElementEditor.deleteFixedSegment(
selectedElements[0],
this.scene,
@ -5608,7 +5603,7 @@ class App extends React.Component<AppProps, AppState> {
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
if (selectedGroupId) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
this.setState((prevState) => ({
...prevState,
...selectGroupsForSelectedElements(
@ -9131,7 +9126,7 @@ class App extends React.Component<AppProps, AppState> {
if (isLinearElement(newElement)) {
if (newElement!.points.length > 1) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
const pointerCoords = viewportCoordsToSceneCoords(
childEvent,
@ -9404,7 +9399,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (resizingElement) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
@ -9744,7 +9739,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedElementIds,
)
) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
if (
@ -9837,7 +9832,7 @@ class App extends React.Component<AppProps, AppState> {
this.elementsPendingErasure = new Set();
if (didChange) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
this.scene.replaceAllElements(elements);
}
};
@ -10517,8 +10512,12 @@ class App extends React.Component<AppProps, AppState> {
// restore the fractional indices by mutating elements
syncInvalidIndices(elements.concat(ret.data.elements));
// update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo
this.store.updateSnapshot(arrayToMap(elements), this.state);
// don't capture and only update the store snapshot for old elements,
// otherwise we would end up with duplicated fractional indices on undo
this.store.scheduleMicroAction(
CaptureUpdateAction.NEVER,
arrayToMap(elements),
);
this.setState({ isLoading: true });
this.syncActionResult({

View file

@ -5,11 +5,12 @@ import { EVENT, KEYS, cloneJSON } from "@excalidraw/common";
import { deepCopyElement } from "@excalidraw/element/duplicate";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { CaptureUpdateAction } from "../../store";
import { useApp } from "../App";
import { InlineIcon } from "../InlineIcon";

View file

@ -14,6 +14,7 @@ import {
resolvablePromise,
toValidURL,
Queue,
Emitter,
} from "@excalidraw/common";
import { hashElementsVersion, hashString } from "@excalidraw/element";
@ -26,7 +27,6 @@ import type { MaybePromise } from "@excalidraw/common/utility-types";
import { atom, editorJotaiStore } from "../editor-jotai";
import { Emitter } from "../emitter";
import { AbortError } from "../errors";
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
import { t } from "../i18n";

View file

@ -1,12 +1,16 @@
import { Emitter } from "@excalidraw/common";
import {
CaptureUpdateAction,
type Store,
StoreDelta,
} from "@excalidraw/element/store";
import type { SceneElementsMap } from "@excalidraw/element/types";
import { Emitter } from "./emitter";
import type { AppStateChange, ElementsChange } from "./change";
import type { Snapshot } from "./store";
import type { AppState } from "./types";
type HistoryStack = HistoryEntry[];
class HistoryEntry extends StoreDelta {}
export class HistoryChangedEvent {
constructor(
@ -20,8 +24,8 @@ export class History {
[HistoryChangedEvent]
>();
private readonly undoStack: HistoryStack = [];
private readonly redoStack: HistoryStack = [];
public readonly undoStack: HistoryEntry[] = [];
public readonly redoStack: HistoryEntry[] = [];
public get isUndoStackEmpty() {
return this.undoStack.length === 0;
@ -31,25 +35,28 @@ export class History {
return this.redoStack.length === 0;
}
constructor(private readonly store: Store) {}
public clear() {
this.undoStack.length = 0;
this.redoStack.length = 0;
}
/**
* Record a local change which will go into the history
* Record a non-empty local durable increment, which will go into the undo stack..
* Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action.
*/
public record(
elementsChange: ElementsChange,
appStateChange: AppStateChange,
) {
const entry = HistoryEntry.create(appStateChange, elementsChange);
public record(delta: StoreDelta) {
if (delta.isEmpty() || delta instanceof HistoryEntry) {
return;
}
if (!entry.isEmpty()) {
// we have the latest changes, no need to `applyLatest`, which is done within `History.push`
this.undoStack.push(entry.inverse());
// construct history entry, so once it's emitted, it's not recorded again
const entry = HistoryEntry.inverse(delta);
if (!entry.elementsChange.isEmpty()) {
this.undoStack.push(entry);
if (!entry.elements.isEmpty()) {
// don't reset redo stack on local appState changes,
// as a simple click (unselect) could lead to losing all the redo entries
// only reset on non empty elements changes!
@ -60,31 +67,20 @@ export class History {
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
}
}
public undo(
elements: SceneElementsMap,
appState: AppState,
snapshot: Readonly<Snapshot>,
) {
public undo(elements: SceneElementsMap, appState: AppState) {
return this.perform(
elements,
appState,
snapshot,
() => History.pop(this.undoStack),
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
);
}
public redo(
elements: SceneElementsMap,
appState: AppState,
snapshot: Readonly<Snapshot>,
) {
public redo(elements: SceneElementsMap, appState: AppState) {
return this.perform(
elements,
appState,
snapshot,
() => History.pop(this.redoStack),
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
);
@ -93,7 +89,6 @@ export class History {
private perform(
elements: SceneElementsMap,
appState: AppState,
snapshot: Readonly<Snapshot>,
pop: () => HistoryEntry | null,
push: (entry: HistoryEntry) => void,
): [SceneElementsMap, AppState] | void {
@ -111,10 +106,20 @@ export class History {
// iterate through the history entries in case they result in no visible changes
while (historyEntry) {
try {
// creating iteration-scoped variables, so that we can use them in the unstable_scheduleCallback
[nextElements, nextAppState, containsVisibleChange] =
historyEntry.applyTo(nextElements, nextAppState, snapshot);
this.store.applyDeltaTo(historyEntry, nextElements, nextAppState);
// schedule immediate capture, so that it's emitted for the sync purposes
this.store.scheduleMicroAction(
CaptureUpdateAction.IMMEDIATELY,
nextElements,
nextAppState,
// create a new instance of the history entry, so that it's not mutated in the meantime
HistoryEntry.restore(historyEntry),
);
} finally {
// make sure to always push / pop, even if the increment is corrupted
// make sure to always push, even if the delta is corrupted
push(historyEntry);
}
@ -135,7 +140,7 @@ export class History {
}
}
private static pop(stack: HistoryStack): HistoryEntry | null {
private static pop(stack: HistoryEntry[]): HistoryEntry | null {
if (!stack.length) {
return null;
}
@ -150,63 +155,17 @@ export class History {
}
private static push(
stack: HistoryStack,
stack: HistoryEntry[],
entry: HistoryEntry,
prevElements: SceneElementsMap,
) {
const updatedEntry = entry.inverse().applyLatestChanges(prevElements);
const inversedEntry = HistoryEntry.inverse(entry);
const updatedEntry = HistoryEntry.applyLatestChanges(
inversedEntry,
prevElements,
"inserted",
);
return stack.push(updatedEntry);
}
}
export class HistoryEntry {
private constructor(
public readonly appStateChange: AppStateChange,
public 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: SceneElementsMap,
appState: AppState,
snapshot: Readonly<Snapshot>,
): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] =
this.elementsChange.applyTo(elements, snapshot.elements);
const [nextAppState, appStateContainsVisibleChange] =
this.appStateChange.applyTo(appState, nextElements);
const appliedVisibleChanges =
elementsContainVisibleChange || appStateContainsVisibleChange;
return [nextElements, nextAppState, appliedVisibleChanges];
}
/**
* Apply latest (remote) changes to the history entry, creates new instance of `HistoryEntry`.
*/
public applyLatestChanges(elements: SceneElementsMap): HistoryEntry {
const updatedElementsChange =
this.elementsChange.applyLatestChanges(elements);
return HistoryEntry.create(this.appStateChange, updatedElementsChange);
}
public isEmpty(): boolean {
return this.appStateChange.isEmpty() && this.elementsChange.isEmpty();
}
}

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import type { Emitter } from "../emitter";
import type { Emitter } from "@excalidraw/common";
export const useEmitter = <TEvent extends unknown>(
emitter: Emitter<[TEvent]>,

View file

@ -23,6 +23,7 @@ polyfill();
const ExcalidrawBase = (props: ExcalidrawProps) => {
const {
onChange,
onIncrement,
initialData,
excalidrawAPI,
isCollaborating = false,
@ -114,6 +115,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
<InitializeApp langCode={langCode} theme={theme}>
<App
onChange={onChange}
onIncrement={onIncrement}
initialData={initialData}
excalidrawAPI={excalidrawAPI}
isCollaborating={isCollaborating}
@ -266,7 +268,7 @@ export {
bumpVersion,
} from "@excalidraw/element/mutateElement";
export { CaptureUpdateAction } from "./store";
export { CaptureUpdateAction } from "@excalidraw/element/store";
export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library";

View file

@ -1,449 +0,0 @@
import { isDevEnv, isShallowEqual, isTestEnv } from "@excalidraw/common";
import { deepCopyElement } from "@excalidraw/element/duplicate";
import { newElementWith } from "@excalidraw/element/mutateElement";
import type { OrderedExcalidrawElement } from "@excalidraw/element/types";
import type { ValueOf } from "@excalidraw/common/utility-types";
import { getDefaultAppState } from "./appState";
import { AppStateChange, ElementsChange } from "./change";
import { Emitter } from "./emitter";
import type { AppState, ObservedAppState } from "./types";
// hidden non-enumerable property for runtime checks
const hiddenObservedAppStateProp = "__observedAppState";
export const getObservedAppState = (appState: AppState): ObservedAppState => {
const observedAppState = {
name: appState.name,
editingGroupId: appState.editingGroupId,
viewBackgroundColor: appState.viewBackgroundColor,
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
editingLinearElementId: appState.editingLinearElement?.elementId || null,
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
croppingElementId: appState.croppingElementId,
};
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
value: true,
enumerable: false,
});
return observedAppState;
};
const isObservedAppState = (
appState: AppState | ObservedAppState,
): appState is ObservedAppState =>
!!Reflect.get(appState, hiddenObservedAppStateProp);
export const CaptureUpdateAction = {
/**
* Immediately undoable.
*
* Use for updates which should be captured.
* Should be used for most of the local updates.
*
* These updates will _immediately_ make it to the local undo / redo stacks.
*/
IMMEDIATELY: "IMMEDIATELY",
/**
* Never undoable.
*
* Use for updates which should never be recorded, such as remote updates
* or scene initialization.
*
* These updates will _never_ make it to the local undo / redo stacks.
*/
NEVER: "NEVER",
/**
* Eventually undoable.
*
* Use for updates which should not be captured immediately - likely
* exceptions which are part of some async multi-step process. Otherwise, all
* such updates would end up being captured with the next
* `CaptureUpdateAction.IMMEDIATELY` - triggered either by the next `updateScene`
* or internally by the editor.
*
* These updates will _eventually_ make it to the local undo / redo stacks.
*/
EVENTUALLY: "EVENTUALLY",
} as const;
export type CaptureUpdateActionType = ValueOf<typeof CaptureUpdateAction>;
/**
* Represent an increment to the Store.
*/
class StoreIncrementEvent {
constructor(
public readonly elementsChange: ElementsChange,
public readonly appStateChange: AppStateChange,
) {}
}
/**
* Store which captures the observed changes and emits them as `StoreIncrementEvent` events.
*
* @experimental this interface is experimental and subject to change.
*/
export interface IStore {
onStoreIncrementEmitter: Emitter<[StoreIncrementEvent]>;
get snapshot(): Snapshot;
set snapshot(snapshot: Snapshot);
/**
* Use to schedule update of the snapshot, useful on updates for which we don't need to calculate increments (i.e. remote updates).
*/
shouldUpdateSnapshot(): void;
/**
* Use to schedule calculation of a store increment.
*/
shouldCaptureIncrement(): void;
/**
* Based on the scheduled operation, either only updates store snapshot or also calculates increment and emits the result as a `StoreIncrementEvent`.
*
* @emits StoreIncrementEvent when increment is calculated.
*/
commit(
elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined,
): void;
/**
* Clears the store instance.
*/
clear(): void;
/**
* Filters out yet uncomitted elements from `nextElements`, which are part of in-progress local async actions (ephemerals) and thus were not yet commited to the snapshot.
*
* This is necessary in updates in which we receive reconciled elements, already containing elements which were not yet captured by the local store (i.e. collab).
*/
filterUncomittedElements(
prevElements: Map<string, OrderedExcalidrawElement>,
nextElements: Map<string, OrderedExcalidrawElement>,
): Map<string, OrderedExcalidrawElement>;
}
export class Store implements IStore {
public readonly onStoreIncrementEmitter = new Emitter<
[StoreIncrementEvent]
>();
private scheduledActions: Set<CaptureUpdateActionType> = new Set();
private _snapshot = Snapshot.empty();
public get snapshot() {
return this._snapshot;
}
public set snapshot(snapshot: Snapshot) {
this._snapshot = snapshot;
}
// TODO: Suspicious that this is called so many places. Seems error-prone.
public shouldCaptureIncrement = () => {
this.scheduleAction(CaptureUpdateAction.IMMEDIATELY);
};
public shouldUpdateSnapshot = () => {
this.scheduleAction(CaptureUpdateAction.NEVER);
};
private scheduleAction = (action: CaptureUpdateActionType) => {
this.scheduledActions.add(action);
this.satisfiesScheduledActionsInvariant();
};
public commit = (
elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined,
): void => {
try {
// Capture has precedence since it also performs update
if (this.scheduledActions.has(CaptureUpdateAction.IMMEDIATELY)) {
this.captureIncrement(elements, appState);
} else if (this.scheduledActions.has(CaptureUpdateAction.NEVER)) {
this.updateSnapshot(elements, appState);
}
} finally {
this.satisfiesScheduledActionsInvariant();
// Defensively reset all scheduled actions, potentially cleans up other runtime garbage
this.scheduledActions = new Set();
}
};
public captureIncrement = (
elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined,
) => {
const prevSnapshot = this.snapshot;
const nextSnapshot = this.snapshot.maybeClone(elements, appState);
// Optimisation, don't continue if nothing has changed
if (prevSnapshot !== nextSnapshot) {
// Calculate and record the changes based on the previous and next snapshot
const elementsChange = nextSnapshot.meta.didElementsChange
? ElementsChange.calculate(prevSnapshot.elements, nextSnapshot.elements)
: ElementsChange.empty();
const appStateChange = nextSnapshot.meta.didAppStateChange
? AppStateChange.calculate(prevSnapshot.appState, nextSnapshot.appState)
: AppStateChange.empty();
if (!elementsChange.isEmpty() || !appStateChange.isEmpty()) {
// Notify listeners with the increment
this.onStoreIncrementEmitter.trigger(
new StoreIncrementEvent(elementsChange, appStateChange),
);
}
// Update snapshot
this.snapshot = nextSnapshot;
}
};
public updateSnapshot = (
elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined,
) => {
const nextSnapshot = this.snapshot.maybeClone(elements, appState);
if (this.snapshot !== nextSnapshot) {
// Update snapshot
this.snapshot = nextSnapshot;
}
};
public filterUncomittedElements = (
prevElements: Map<string, OrderedExcalidrawElement>,
nextElements: Map<string, OrderedExcalidrawElement>,
) => {
for (const [id, prevElement] of prevElements.entries()) {
const nextElement = nextElements.get(id);
if (!nextElement) {
// Nothing to care about here, elements were forcefully deleted
continue;
}
const elementSnapshot = this.snapshot.elements.get(id);
// Checks for in progress async user action
if (!elementSnapshot) {
// Detected yet uncomitted local element
nextElements.delete(id);
} else if (elementSnapshot.version < prevElement.version) {
// Element was already commited, but the snapshot version is lower than current current local version
nextElements.set(id, elementSnapshot);
}
}
return nextElements;
};
public clear = (): void => {
this.snapshot = Snapshot.empty();
this.scheduledActions = new Set();
};
private satisfiesScheduledActionsInvariant = () => {
if (!(this.scheduledActions.size >= 0 && this.scheduledActions.size <= 3)) {
const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledActions.size}".`;
console.error(message, this.scheduledActions.values());
if (isTestEnv() || isDevEnv()) {
throw new Error(message);
}
}
};
}
export class Snapshot {
private constructor(
public readonly elements: Map<string, OrderedExcalidrawElement>,
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, only if we detected changes.
*
* @returns same instance if there are no changes detected, new instance otherwise.
*/
public maybeClone(
elements: Map<string, OrderedExcalidrawElement> | undefined,
appState: AppState | ObservedAppState | undefined,
) {
const nextElementsSnapshot = this.maybeCreateElementsSnapshot(elements);
const nextAppStateSnapshot = this.maybeCreateAppStateSnapshot(appState);
let didElementsChange = false;
let didAppStateChange = false;
if (this.elements !== nextElementsSnapshot) {
didElementsChange = true;
}
if (this.appState !== nextAppStateSnapshot) {
didAppStateChange = true;
}
if (!didElementsChange && !didAppStateChange) {
return this;
}
const snapshot = new Snapshot(nextElementsSnapshot, nextAppStateSnapshot, {
didElementsChange,
didAppStateChange,
});
return snapshot;
}
private maybeCreateAppStateSnapshot(
appState: AppState | ObservedAppState | undefined,
) {
if (!appState) {
return this.appState;
}
// Not watching over everything from the app state, just the relevant props
const nextAppStateSnapshot = !isObservedAppState(appState)
? getObservedAppState(appState)
: appState;
const didAppStateChange = this.detectChangedAppState(nextAppStateSnapshot);
if (!didAppStateChange) {
return this.appState;
}
return nextAppStateSnapshot;
}
private detectChangedAppState(nextObservedAppState: ObservedAppState) {
return !isShallowEqual(this.appState, nextObservedAppState, {
selectedElementIds: isShallowEqual,
selectedGroupIds: isShallowEqual,
});
}
private maybeCreateElementsSnapshot(
elements: Map<string, OrderedExcalidrawElement> | undefined,
) {
if (!elements) {
return this.elements;
}
const didElementsChange = this.detectChangedElements(elements);
if (!didElementsChange) {
return this.elements;
}
const elementsSnapshot = this.createElementsSnapshot(elements);
return elementsSnapshot;
}
/**
* Detect if there any changed elements.
*
* NOTE: we shouldn't just use `sceneVersionNonce` instead, as we need to call this before the scene updates.
*/
private detectChangedElements(
nextElements: Map<string, OrderedExcalidrawElement>,
) {
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;
}
/**
* Perform structural clone, cloning only elements that changed.
*/
private createElementsSnapshot(
nextElements: Map<string, OrderedExcalidrawElement>,
) {
const clonedElements = new Map();
for (const [id, prevElement] of this.elements.entries()) {
// Clone previous elements, never delete, in case nextElements would be just a subset of previous elements
// i.e. during collab, persist or whenenever isDeleted elements get cleared
if (!nextElements.get(id)) {
// When we cannot find the prev element in the next elements, we mark it as deleted
clonedElements.set(
id,
newElementWith(prevElement, { isDeleted: true }),
);
} else {
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;
}
}

View file

@ -23,6 +23,10 @@ import {
import "@excalidraw/utils/test-utils";
import { ElementsDelta, AppStateDelta } from "@excalidraw/element/delta";
import { CaptureUpdateAction, StoreDelta } from "@excalidraw/element/store";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type {
@ -46,11 +50,8 @@ import {
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import { getDefaultAppState } from "../appState";
import { HistoryEntry } from "../history";
import { Excalidraw } from "../index";
import * as StaticScene from "../renderer/staticScene";
import { Snapshot, CaptureUpdateAction } from "../store";
import { AppStateChange, ElementsChange } from "../change";
import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
@ -82,13 +83,52 @@ const checkpoint = (name: string) => {
...strippedAppState
} = h.state;
expect(strippedAppState).toMatchSnapshot(`[${name}] appState`);
expect(h.history).toMatchSnapshot(`[${name}] history`);
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
h.elements
.map(({ seed, versionNonce, ...strippedElement }) => strippedElement)
.forEach((element, i) =>
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
);
const stripSeed = (deltas: Record<string, { deleted: any; inserted: any }>) =>
Object.entries(deltas).reduce((acc, curr) => {
const { inserted, deleted, ...rest } = curr[1];
delete inserted.seed;
delete deleted.seed;
acc[curr[0]] = {
inserted,
deleted,
...rest,
};
return acc;
}, {} as Record<string, any>);
expect(
h.history.undoStack.map((x) => ({
...x,
elementsChange: {
...x.elements,
added: stripSeed(x.elements.added),
removed: stripSeed(x.elements.updated),
updated: stripSeed(x.elements.removed),
},
})),
).toMatchSnapshot(`[${name}] undo stack`);
expect(
h.history.redoStack.map((x) => ({
...x,
elementsChange: {
...x.elements,
added: stripSeed(x.elements.added),
removed: stripSeed(x.elements.updated),
updated: stripSeed(x.elements.removed),
},
})),
).toMatchSnapshot(`[${name}] redo stack`);
};
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
@ -116,12 +156,12 @@ describe("history", () => {
API.setElements([rect]);
const corrupedEntry = HistoryEntry.create(
AppStateChange.empty(),
ElementsChange.empty(),
const corrupedEntry = StoreDelta.create(
ElementsDelta.empty(),
AppStateDelta.empty(),
);
vi.spyOn(corrupedEntry, "applyTo").mockImplementation(() => {
vi.spyOn(corrupedEntry.elements, "applyTo").mockImplementation(() => {
throw new Error("Oh no, I am corrupted!");
});
@ -136,7 +176,6 @@ describe("history", () => {
h.history.undo(
arrayToMap(h.elements) as SceneElementsMap,
appState,
Snapshot.empty(),
) as any,
);
} catch (e) {
@ -157,7 +196,6 @@ describe("history", () => {
h.history.redo(
arrayToMap(h.elements) as SceneElementsMap,
appState,
Snapshot.empty(),
) as any,
);
} catch (e) {
@ -454,8 +492,8 @@ describe("history", () => {
expect(h.history.isUndoStackEmpty).toBeTruthy();
});
const undoAction = createUndoAction(h.history, h.store);
const redoAction = createRedoAction(h.history, h.store);
const undoAction = createUndoAction(h.history);
const redoAction = createRedoAction(h.history);
// noop
API.executeAction(undoAction);
expect(h.elements).toEqual([
@ -531,8 +569,8 @@ describe("history", () => {
expect.objectContaining({ id: "B", isDeleted: false }),
]);
const undoAction = createUndoAction(h.history, h.store);
const redoAction = createRedoAction(h.history, h.store);
const undoAction = createUndoAction(h.history);
const redoAction = createRedoAction(h.history);
API.executeAction(undoAction);
expect(API.getSnapshot()).toEqual([
@ -1713,8 +1751,8 @@ describe("history", () => {
/>,
);
const undoAction = createUndoAction(h.history, h.store);
const redoAction = createRedoAction(h.history, h.store);
const undoAction = createUndoAction(h.history);
const redoAction = createRedoAction(h.history);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
@ -1763,7 +1801,7 @@ describe("history", () => {
/>,
);
const undoAction = createUndoAction(h.history, h.store);
const undoAction = createUndoAction(h.history);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);

View file

@ -43,6 +43,12 @@ import type {
MakeBrand,
} from "@excalidraw/common/utility-types";
import type {
CaptureUpdateActionType,
DurableIncrement,
EphemeralIncrement,
} from "@excalidraw/element/store";
import type { Action } from "./actions/types";
import type { Spreadsheet } from "./charts";
import type { ClipboardData } from "./clipboard";
@ -51,7 +57,6 @@ import type Library from "./data/library";
import type { FileSystemHandle } from "./data/filesystem";
import type { ContextMenuItems } from "./components/ContextMenu";
import type { SnapLine } from "./snapping";
import type { CaptureUpdateActionType } from "./store";
import type { ImportedDataState } from "./data/types";
import type { Language } from "./i18n";
@ -518,6 +523,7 @@ export interface ExcalidrawProps {
appState: AppState,
files: BinaryFiles,
) => void;
onIncrement?: (event: DurableIncrement | EphemeralIncrement) => void;
initialData?:
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
| MaybePromise<ExcalidrawInitialDataState | null>;
@ -794,6 +800,7 @@ export interface ExcalidrawImperativeAPI {
history: {
clear: InstanceType<typeof App>["resetHistory"];
};
store: InstanceType<typeof App>["store"];
getSceneElements: InstanceType<typeof App>["getSceneElements"];
getAppState: () => InstanceType<typeof App>["state"];
getFiles: () => InstanceType<typeof App>["files"];
@ -821,6 +828,9 @@ export interface ExcalidrawImperativeAPI {
files: BinaryFiles,
) => void,
) => UnsubscribeCallback;
onIncrement: (
callback: (event: DurableIncrement | EphemeralIncrement) => void,
) => UnsubscribeCallback;
onPointerDown: (
callback: (
activeTool: AppState["activeTool"],