mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
First steps towards onIncrement API
This commit is contained in:
parent
192c4e7658
commit
0c21f1ae07
51 changed files with 1358 additions and 870 deletions
|
@ -807,6 +807,9 @@ const ExcalidrawWrapper = () => {
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
excalidrawAPI={excalidrawRefCallback}
|
excalidrawAPI={excalidrawRefCallback}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onIncrement={(increment) => {
|
||||||
|
console.log(increment);
|
||||||
|
}}
|
||||||
initialData={initialStatePromiseRef.current.promise}
|
initialData={initialStatePromiseRef.current.promise}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||||
|
|
|
@ -122,7 +122,7 @@ describe("collaboration", () => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
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));
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
// 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)]);
|
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));
|
act(() => h.app.actionManager.executeAction(redoAction));
|
||||||
|
|
||||||
// with explicit redo (as removal) we again restore the element from the snapshot!
|
// with explicit redo (as removal) we again restore the element from the snapshot!
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { UnsubscribeCallback } from "./types";
|
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
type Subscriber<T extends any[]> = (...payload: T) => void;
|
type Subscriber<T extends any[]> = (...payload: T) => void;
|
||||||
|
|
|
@ -9,3 +9,4 @@ export * from "./promise-pool";
|
||||||
export * from "./random";
|
export * from "./random";
|
||||||
export * from "./url";
|
export * from "./url";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
|
export * from "./emitter";
|
||||||
|
|
|
@ -68,3 +68,8 @@ export type MaybePromise<T> = T | Promise<T>;
|
||||||
|
|
||||||
// get union of all keys from the union of types
|
// get union of all keys from the union of types
|
||||||
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
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];
|
||||||
|
};
|
||||||
|
|
|
@ -5,43 +5,7 @@ import {
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
isShallowEqual,
|
isShallowEqual,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
toBrandedType,
|
|
||||||
} from "@excalidraw/common";
|
} 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 {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
|
@ -54,16 +18,42 @@ import type {
|
||||||
SceneElementsMap,
|
SceneElementsMap,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
|
import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { getObservedAppState } from "./store";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
ObservedAppState,
|
ObservedAppState,
|
||||||
ObservedElementsAppState,
|
ObservedElementsAppState,
|
||||||
ObservedStandaloneAppState,
|
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.
|
* 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.
|
* 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(
|
private constructor(
|
||||||
public readonly deleted: Partial<T>,
|
public readonly deleted: Partial<T>,
|
||||||
public readonly inserted: 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];
|
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether there are actually `Delta`s.
|
* Checks whether all `Delta`s are empty.
|
||||||
*/
|
*/
|
||||||
isEmpty(): boolean;
|
isEmpty(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AppStateChange implements Change<AppState> {
|
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||||
private constructor(private readonly delta: Delta<ObservedAppState>) {}
|
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
||||||
|
|
||||||
public static calculate<T extends ObservedAppState>(
|
public static calculate<T extends ObservedAppState>(
|
||||||
prevAppState: T,
|
prevAppState: T,
|
||||||
nextAppState: T,
|
nextAppState: T,
|
||||||
): AppStateChange {
|
): AppStateDelta {
|
||||||
const delta = Delta.calculate(
|
const delta = Delta.calculate(
|
||||||
prevAppState,
|
prevAppState,
|
||||||
nextAppState,
|
nextAppState,
|
||||||
undefined,
|
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() {
|
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);
|
const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted);
|
||||||
return new AppStateChange(inversedDelta);
|
return new AppStateDelta(inversedDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
public applyTo(
|
public applyTo(
|
||||||
|
@ -594,13 +589,13 @@ export class AppStateChange implements Change<AppState> {
|
||||||
const nextObservedAppState = getObservedAppState(nextAppState);
|
const nextObservedAppState = getObservedAppState(nextAppState);
|
||||||
|
|
||||||
const containsStandaloneDifference = Delta.isRightDifferent(
|
const containsStandaloneDifference = Delta.isRightDifferent(
|
||||||
AppStateChange.stripElementsProps(prevObservedAppState),
|
AppStateDelta.stripElementsProps(prevObservedAppState),
|
||||||
AppStateChange.stripElementsProps(nextObservedAppState),
|
AppStateDelta.stripElementsProps(nextObservedAppState),
|
||||||
);
|
);
|
||||||
|
|
||||||
const containsElementsDifference = Delta.isRightDifferent(
|
const containsElementsDifference = Delta.isRightDifferent(
|
||||||
AppStateChange.stripStandaloneProps(prevObservedAppState),
|
AppStateDelta.stripStandaloneProps(prevObservedAppState),
|
||||||
AppStateChange.stripStandaloneProps(nextObservedAppState),
|
AppStateDelta.stripStandaloneProps(nextObservedAppState),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!containsStandaloneDifference && !containsElementsDifference) {
|
if (!containsStandaloneDifference && !containsElementsDifference) {
|
||||||
|
@ -615,8 +610,8 @@ export class AppStateChange implements Change<AppState> {
|
||||||
if (containsElementsDifference) {
|
if (containsElementsDifference) {
|
||||||
// filter invisible changes on each iteration
|
// filter invisible changes on each iteration
|
||||||
const changedElementsProps = Delta.getRightDifferences(
|
const changedElementsProps = Delta.getRightDifferences(
|
||||||
AppStateChange.stripStandaloneProps(prevObservedAppState),
|
AppStateDelta.stripStandaloneProps(prevObservedAppState),
|
||||||
AppStateChange.stripStandaloneProps(nextObservedAppState),
|
AppStateDelta.stripStandaloneProps(nextObservedAppState),
|
||||||
) as Array<keyof ObservedElementsAppState>;
|
) as Array<keyof ObservedElementsAppState>;
|
||||||
|
|
||||||
let nonDeletedGroupIds = new Set<string>();
|
let nonDeletedGroupIds = new Set<string>();
|
||||||
|
@ -633,7 +628,7 @@ export class AppStateChange implements Change<AppState> {
|
||||||
for (const key of changedElementsProps) {
|
for (const key of changedElementsProps) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "selectedElementIds":
|
case "selectedElementIds":
|
||||||
nextAppState[key] = AppStateChange.filterSelectedElements(
|
nextAppState[key] = AppStateDelta.filterSelectedElements(
|
||||||
nextAppState[key],
|
nextAppState[key],
|
||||||
nextElements,
|
nextElements,
|
||||||
visibleDifferenceFlag,
|
visibleDifferenceFlag,
|
||||||
|
@ -641,7 +636,7 @@ export class AppStateChange implements Change<AppState> {
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case "selectedGroupIds":
|
case "selectedGroupIds":
|
||||||
nextAppState[key] = AppStateChange.filterSelectedGroups(
|
nextAppState[key] = AppStateDelta.filterSelectedGroups(
|
||||||
nextAppState[key],
|
nextAppState[key],
|
||||||
nonDeletedGroupIds,
|
nonDeletedGroupIds,
|
||||||
visibleDifferenceFlag,
|
visibleDifferenceFlag,
|
||||||
|
@ -677,7 +672,7 @@ export class AppStateChange implements Change<AppState> {
|
||||||
break;
|
break;
|
||||||
case "selectedLinearElementId":
|
case "selectedLinearElementId":
|
||||||
case "editingLinearElementId":
|
case "editingLinearElementId":
|
||||||
const appStateKey = AppStateChange.convertToAppStateKey(key);
|
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
||||||
const linearElement = nextAppState[appStateKey];
|
const linearElement = nextAppState[appStateKey];
|
||||||
|
|
||||||
if (!linearElement) {
|
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.
|
* 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.
|
* 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 constructor(
|
||||||
private readonly added: Map<string, Delta<ElementPartial>>,
|
public readonly added: Record<string, Delta<ElementPartial>>,
|
||||||
private readonly removed: Map<string, Delta<ElementPartial>>,
|
public readonly removed: Record<string, Delta<ElementPartial>>,
|
||||||
private readonly updated: Map<string, Delta<ElementPartial>>,
|
public readonly updated: Record<string, Delta<ElementPartial>>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static create(
|
public static create(
|
||||||
added: Map<string, Delta<ElementPartial>>,
|
added: Record<string, Delta<ElementPartial>>,
|
||||||
removed: Map<string, Delta<ElementPartial>>,
|
removed: Record<string, Delta<ElementPartial>>,
|
||||||
updated: Map<string, Delta<ElementPartial>>,
|
updated: Record<string, Delta<ElementPartial>>,
|
||||||
options = { shouldRedistribute: false },
|
options: {
|
||||||
|
shouldRedistribute: boolean;
|
||||||
|
} = {
|
||||||
|
shouldRedistribute: false,
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
let change: ElementsChange;
|
let delta: ElementsDelta;
|
||||||
|
|
||||||
if (options.shouldRedistribute) {
|
if (options.shouldRedistribute) {
|
||||||
const nextAdded = new Map<string, Delta<ElementPartial>>();
|
const nextAdded: Record<string, Delta<ElementPartial>> = {};
|
||||||
const nextRemoved = new Map<string, Delta<ElementPartial>>();
|
const nextRemoved: Record<string, Delta<ElementPartial>> = {};
|
||||||
const nextUpdated = new Map<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) {
|
for (const [id, delta] of deltas) {
|
||||||
if (this.satisfiesAddition(delta)) {
|
if (this.satisfiesAddition(delta)) {
|
||||||
nextAdded.set(id, delta);
|
nextAdded[id] = delta;
|
||||||
} else if (this.satisfiesRemoval(delta)) {
|
} else if (this.satisfiesRemoval(delta)) {
|
||||||
nextRemoved.set(id, delta);
|
nextRemoved[id] = delta;
|
||||||
} else {
|
} else {
|
||||||
nextUpdated.set(id, delta);
|
nextUpdated[id] = delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
change = new ElementsChange(nextAdded, nextRemoved, nextUpdated);
|
delta = new ElementsDelta(nextAdded, nextRemoved, nextUpdated);
|
||||||
} else {
|
} else {
|
||||||
change = new ElementsChange(added, removed, updated);
|
delta = new ElementsDelta(added, removed, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTestEnv() || isDevEnv()) {
|
if (isTestEnv() || isDevEnv()) {
|
||||||
ElementsChange.validate(change, "added", this.satisfiesAddition);
|
ElementsDelta.validate(delta, "added", this.satisfiesAddition);
|
||||||
ElementsChange.validate(change, "removed", this.satisfiesRemoval);
|
ElementsDelta.validate(delta, "removed", this.satisfiesRemoval);
|
||||||
ElementsChange.validate(change, "updated", this.satisfiesUpdate);
|
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 = ({
|
private static satisfiesAddition = ({
|
||||||
|
@ -888,17 +896,17 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
|
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
|
||||||
|
|
||||||
private static validate(
|
private static validate(
|
||||||
change: ElementsChange,
|
elementsDelta: ElementsDelta,
|
||||||
type: "added" | "removed" | "updated",
|
type: "added" | "removed" | "updated",
|
||||||
satifies: (delta: Delta<ElementPartial>) => boolean,
|
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)) {
|
if (!satifies(delta)) {
|
||||||
console.error(
|
console.error(
|
||||||
`Broken invariant for "${type}" delta, element "${id}", delta:`,
|
`Broken invariant for "${type}" delta, element "${id}", delta:`,
|
||||||
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 prevElements - Map representing the previous state of elements.
|
||||||
* @param nextElements - Map representing the next 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>(
|
public static calculate<T extends OrderedExcalidrawElement>(
|
||||||
prevElements: Map<string, T>,
|
prevElements: Map<string, T>,
|
||||||
nextElements: Map<string, T>,
|
nextElements: Map<string, T>,
|
||||||
): ElementsChange {
|
): ElementsDelta {
|
||||||
if (prevElements === nextElements) {
|
if (prevElements === nextElements) {
|
||||||
return ElementsChange.empty();
|
return ElementsDelta.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
const added = new Map<string, Delta<ElementPartial>>();
|
const added: Record<string, Delta<ElementPartial>> = {};
|
||||||
const removed = new Map<string, Delta<ElementPartial>>();
|
const removed: Record<string, Delta<ElementPartial>> = {};
|
||||||
const updated = new Map<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
|
// 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()) {
|
for (const prevElement of prevElements.values()) {
|
||||||
|
@ -934,10 +942,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
const delta = Delta.create(
|
const delta = Delta.create(
|
||||||
deleted,
|
deleted,
|
||||||
inserted,
|
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(
|
const delta = Delta.create(
|
||||||
deleted,
|
deleted,
|
||||||
inserted,
|
inserted,
|
||||||
ElementsChange.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
added.set(nextElement.id, delta);
|
added[nextElement.id] = delta;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -966,8 +974,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
const delta = Delta.calculate<ElementPartial>(
|
const delta = Delta.calculate<ElementPartial>(
|
||||||
prevElement,
|
prevElement,
|
||||||
nextElement,
|
nextElement,
|
||||||
ElementsChange.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
ElementsChange.postProcess,
|
ElementsDelta.postProcess,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -978,9 +986,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
) {
|
) {
|
||||||
// notice that other props could have been updated as well
|
// notice that other props could have been updated as well
|
||||||
if (prevElement.isDeleted && !nextElement.isDeleted) {
|
if (prevElement.isDeleted && !nextElement.isDeleted) {
|
||||||
added.set(nextElement.id, delta);
|
added[nextElement.id] = delta;
|
||||||
} else {
|
} else {
|
||||||
removed.set(nextElement.id, delta);
|
removed[nextElement.id] = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
@ -988,24 +996,24 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
|
|
||||||
// making sure there are at least some changes
|
// making sure there are at least some changes
|
||||||
if (!Delta.isEmpty(delta)) {
|
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() {
|
public static empty() {
|
||||||
return ElementsChange.create(new Map(), new Map(), new Map());
|
return ElementsDelta.create({}, {}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public inverse(): ElementsChange {
|
public inverse(): ElementsDelta {
|
||||||
const inverseInternal = (deltas: Map<string, Delta<ElementPartial>>) => {
|
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||||
const inversedDeltas = new Map<string, Delta<ElementPartial>>();
|
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
for (const [id, delta] of deltas.entries()) {
|
for (const [id, delta] of Object.entries(deltas)) {
|
||||||
inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted));
|
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
return inversedDeltas;
|
return inversedDeltas;
|
||||||
|
@ -1016,14 +1024,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
const updated = inverseInternal(this.updated);
|
const updated = inverseInternal(this.updated);
|
||||||
|
|
||||||
// notice we inverse removed with added not to break the invariants
|
// 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 {
|
public isEmpty(): boolean {
|
||||||
return (
|
return (
|
||||||
this.added.size === 0 &&
|
Object.keys(this.added).length === 0 &&
|
||||||
this.removed.size === 0 &&
|
Object.keys(this.removed).length === 0 &&
|
||||||
this.updated.size === 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
|
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
|
||||||
* @returns new instance with modified delta/s
|
* @returns new instance with modified delta/s
|
||||||
*/
|
*/
|
||||||
public applyLatestChanges(elements: SceneElementsMap): ElementsChange {
|
public applyLatestChanges(
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
modifierOptions: "deleted" | "inserted",
|
||||||
|
): ElementsDelta {
|
||||||
const modifier =
|
const modifier =
|
||||||
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
||||||
const latestPartial: { [key: string]: unknown } = {};
|
const latestPartial: { [key: string]: unknown } = {};
|
||||||
|
@ -1055,11 +1066,11 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyLatestChangesInternal = (
|
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);
|
const existingElement = elements.get(id);
|
||||||
|
|
||||||
if (existingElement) {
|
if (existingElement) {
|
||||||
|
@ -1067,12 +1078,12 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
delta.deleted,
|
delta.deleted,
|
||||||
delta.inserted,
|
delta.inserted,
|
||||||
modifier(existingElement),
|
modifier(existingElement),
|
||||||
"inserted",
|
modifierOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
modifiedDeltas.set(id, modifiedDelta);
|
modifiedDeltas[id] = modifiedDelta;
|
||||||
} else {
|
} else {
|
||||||
modifiedDeltas.set(id, delta);
|
modifiedDeltas[id] = delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1083,16 +1094,16 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
const removed = applyLatestChangesInternal(this.removed);
|
const removed = applyLatestChangesInternal(this.removed);
|
||||||
const updated = applyLatestChangesInternal(this.updated);
|
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
|
shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public applyTo(
|
public applyTo(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
elementsSnapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
): [SceneElementsMap, boolean] {
|
): [SceneElementsMap, boolean] {
|
||||||
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
|
let nextElements = new Map(elements) as SceneElementsMap;
|
||||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||||
|
|
||||||
const flags = {
|
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)
|
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||||
try {
|
try {
|
||||||
const applyDeltas = ElementsChange.createApplier(
|
const applyDeltas = ElementsDelta.createApplier(
|
||||||
nextElements,
|
nextElements,
|
||||||
snapshot,
|
elementsSnapshot,
|
||||||
flags,
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
const addedElements = applyDeltas(this.added);
|
const addedElements = applyDeltas("added", this.added);
|
||||||
const removedElements = applyDeltas(this.removed);
|
const removedElements = applyDeltas("removed", this.removed);
|
||||||
const updatedElements = applyDeltas(this.updated);
|
const updatedElements = applyDeltas("updated", this.updated);
|
||||||
|
|
||||||
const affectedElements = this.resolveConflicts(elements, nextElements);
|
const affectedElements = this.resolveConflicts(elements, nextElements);
|
||||||
|
|
||||||
|
@ -1122,7 +1133,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
...affectedElements,
|
...affectedElements,
|
||||||
]);
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Couldn't apply elements change`, e);
|
console.error(`Couldn't apply elements delta`, e);
|
||||||
|
|
||||||
if (isTestEnv() || isDevEnv()) {
|
if (isTestEnv() || isDevEnv()) {
|
||||||
throw e;
|
throw e;
|
||||||
|
@ -1138,7 +1149,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
try {
|
try {
|
||||||
// the following reorder performs also mutations, but only on new instances of changed elements
|
// 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)
|
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||||
nextElements = ElementsChange.reorderElements(
|
nextElements = ElementsDelta.reorderElements(
|
||||||
nextElements,
|
nextElements,
|
||||||
changedElements,
|
changedElements,
|
||||||
flags,
|
flags,
|
||||||
|
@ -1149,9 +1160,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
// so we are creating a temp scene just to query and mutate elements
|
// so we are creating a temp scene just to query and mutate elements
|
||||||
const tempScene = new Scene(nextElements);
|
const tempScene = new Scene(nextElements);
|
||||||
|
|
||||||
ElementsChange.redrawTextBoundingBoxes(tempScene, changedElements);
|
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||||
// Need ordered nextElements to avoid z-index binding issues
|
// Need ordered nextElements to avoid z-index binding issues
|
||||||
ElementsChange.redrawBoundArrows(tempScene, changedElements);
|
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
`Couldn't mutate elements after applying elements change`,
|
`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,
|
nextElements: SceneElementsMap,
|
||||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
flags: {
|
flags: {
|
||||||
containsVisibleDifference: boolean;
|
containsVisibleDifference: boolean;
|
||||||
containsZindexDifference: boolean;
|
containsZindexDifference: boolean;
|
||||||
},
|
},
|
||||||
|
) =>
|
||||||
|
(
|
||||||
|
type: "added" | "removed" | "updated",
|
||||||
|
deltas: Record<string, Delta<ElementPartial>>,
|
||||||
) => {
|
) => {
|
||||||
const getElement = ElementsChange.createGetter(
|
const getElement = ElementsDelta.createGetter(
|
||||||
|
type,
|
||||||
nextElements,
|
nextElements,
|
||||||
snapshot,
|
snapshot,
|
||||||
flags,
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (deltas: Map<string, Delta<ElementPartial>>) =>
|
return Object.entries(deltas).reduce((acc, [id, delta]) => {
|
||||||
Array.from(deltas.entries()).reduce((acc, [id, delta]) => {
|
|
||||||
const element = getElement(id, delta.inserted);
|
const element = getElement(id, delta.inserted);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
const newElement = ElementsChange.applyDelta(element, delta, flags);
|
const newElement = ElementsDelta.applyDelta(element, delta, flags);
|
||||||
nextElements.set(newElement.id, newElement);
|
nextElements.set(newElement.id, newElement);
|
||||||
acc.set(newElement.id, newElement);
|
acc.set(newElement.id, newElement);
|
||||||
}
|
}
|
||||||
|
@ -1196,6 +1212,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
|
|
||||||
private static createGetter =
|
private static createGetter =
|
||||||
(
|
(
|
||||||
|
type: "added" | "removed" | "updated",
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
flags: {
|
flags: {
|
||||||
|
@ -1221,6 +1238,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
) {
|
) {
|
||||||
flags.containsVisibleDifference = true;
|
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>>;
|
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
|
||||||
// we want to override `crop` only if modified so that we don't reset
|
// we want to override `crop` only if modified so that we don't reset
|
||||||
// when undoing/redoing unrelated change
|
// when undoing/redoing unrelated change
|
||||||
|
@ -1270,10 +1296,12 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!flags.containsVisibleDifference) {
|
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 { index, ...rest } = directlyApplicablePartial;
|
||||||
const containsVisibleDifference =
|
const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
|
||||||
ElementsChange.checkForVisibleDifference(element, rest);
|
element,
|
||||||
|
rest,
|
||||||
|
);
|
||||||
|
|
||||||
flags.containsVisibleDifference = containsVisibleDifference;
|
flags.containsVisibleDifference = containsVisibleDifference;
|
||||||
}
|
}
|
||||||
|
@ -1316,6 +1344,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
* Resolves conflicts for all previously added, removed and updated elements.
|
* Resolves conflicts for all previously added, removed and updated elements.
|
||||||
* Updates the previous deltas with all the changes after conflict resolution.
|
* 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
|
* @returns all elements affected by the conflict resolution
|
||||||
*/
|
*/
|
||||||
private resolveConflicts(
|
private resolveConflicts(
|
||||||
|
@ -1346,7 +1376,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
nextElement,
|
nextElement,
|
||||||
nextElements,
|
nextElements,
|
||||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||||
) as OrderedExcalidrawElement;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
nextAffectedElements.set(affectedElement.id, affectedElement);
|
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
|
// 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) {
|
for (const id of Object.keys(this.removed)) {
|
||||||
ElementsChange.unbindAffected(prevElements, nextElements, id, updater);
|
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
|
// 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) {
|
for (const id of Object.keys(this.added)) {
|
||||||
ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
|
ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
|
||||||
}
|
}
|
||||||
|
|
||||||
// updated delta is affecting the binding only in case it contains changed binding or bindable property
|
// 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) =>
|
Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
|
||||||
bindingProperties.has(prop as BindingProp | BindableProp),
|
bindingProperties.has(prop as BindingProp | BindableProp),
|
||||||
),
|
),
|
||||||
|
@ -1375,7 +1406,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
|
ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
|
||||||
}
|
}
|
||||||
|
|
||||||
// filter only previous elements, which were now affected
|
// 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
|
// 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
|
// 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,
|
prevAffectedElements,
|
||||||
nextAffectedElements,
|
nextAffectedElements,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [id, delta] of added) {
|
for (const [id, delta] of Object.entries(added)) {
|
||||||
this.added.set(id, delta);
|
this.added[id] = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, delta] of removed) {
|
for (const [id, delta] of Object.entries(removed)) {
|
||||||
this.removed.set(id, delta);
|
this.removed[id] = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, delta] of updated) {
|
for (const [id, delta] of Object.entries(updated)) {
|
||||||
this.updated.set(id, delta);
|
this.updated[id] = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextAffectedElements;
|
return nextAffectedElements;
|
||||||
|
@ -1572,7 +1603,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
|
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
|
// 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()) {
|
if (isTestEnv() || isDevEnv()) {
|
||||||
throw e;
|
throw e;
|
||||||
|
@ -1585,8 +1616,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||||
private static stripIrrelevantProps(
|
private static stripIrrelevantProps(
|
||||||
partial: Partial<OrderedExcalidrawElement>,
|
partial: Partial<OrderedExcalidrawElement>,
|
||||||
): ElementPartial {
|
): ElementPartial {
|
||||||
const { id, updated, version, versionNonce, seed, ...strippedPartial } =
|
const { id, updated, version, versionNonce, ...strippedPartial } = partial;
|
||||||
partial;
|
|
||||||
|
|
||||||
return strippedPartial;
|
return strippedPartial;
|
||||||
}
|
}
|
|
@ -20,7 +20,7 @@ import {
|
||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { Store } from "@excalidraw/excalidraw/store";
|
import type { Store } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
|
@ -807,7 +807,7 @@ export class LinearElementEditor {
|
||||||
});
|
});
|
||||||
ret.didAddPoint = true;
|
ret.didAddPoint = true;
|
||||||
}
|
}
|
||||||
store.shouldCaptureIncrement();
|
store.scheduleCapture();
|
||||||
ret.linearElementEditor = {
|
ret.linearElementEditor = {
|
||||||
...linearElementEditor,
|
...linearElementEditor,
|
||||||
pointerDownState: {
|
pointerDownState: {
|
||||||
|
|
867
packages/element/src/store.ts
Normal file
867
packages/element/src/store.ts
Normal 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);
|
|
@ -1,8 +1,9 @@
|
||||||
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
|
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
|
||||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,8 @@ import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||||
|
|
||||||
import { alignElements } from "@excalidraw/element/align";
|
import { alignElements } from "@excalidraw/element/align";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Alignment } from "@excalidraw/element/align";
|
import type { Alignment } from "@excalidraw/element/align";
|
||||||
|
@ -25,7 +27,6 @@ import {
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -33,6 +33,8 @@ import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
||||||
|
|
||||||
import { newElement } from "@excalidraw/element/newElement";
|
import { newElement } from "@excalidraw/element/newElement";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
|
@ -44,8 +46,6 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
|
|
|
@ -17,6 +17,8 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||||
import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds";
|
import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -44,7 +46,6 @@ import { t } from "../i18n";
|
||||||
import { getNormalizedZoom } from "../scene";
|
import { getNormalizedZoom } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,8 @@ import { getTextFromElements } from "@excalidraw/element/textElement";
|
||||||
|
|
||||||
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
|
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
copyTextToSystemClipboard,
|
copyTextToSystemClipboard,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
|
@ -15,8 +17,6 @@ import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { isImageElement } from "@excalidraw/element/typeChecks";
|
import { isImageElement } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
|
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { cropIcon } from "../components/icons";
|
import { cropIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -17,11 +17,12 @@ import {
|
||||||
selectGroupsForSelectedElements,
|
selectGroupsForSelectedElements,
|
||||||
} from "@excalidraw/element/groups";
|
} from "@excalidraw/element/groups";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
import { TrashIcon } from "../components/icons";
|
import { TrashIcon } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,8 @@ import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/fra
|
||||||
|
|
||||||
import { distributeElements } from "@excalidraw/element/distribute";
|
import { distributeElements } from "@excalidraw/element/distribute";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Distribution } from "@excalidraw/element/distribute";
|
import type { Distribution } from "@excalidraw/element/distribute";
|
||||||
|
@ -21,7 +23,6 @@ import {
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -18,12 +18,13 @@ import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
||||||
|
|
||||||
import { duplicateElements } from "@excalidraw/element/duplicate";
|
import { duplicateElements } from "@excalidraw/element/duplicate";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { DuplicateIcon } from "../components/icons";
|
import { DuplicateIcon } from "../components/icons";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -4,11 +4,12 @@ import {
|
||||||
getLinkIdAndTypeFromSelection,
|
getLinkIdAndTypeFromSelection,
|
||||||
} from "@excalidraw/element/elementLink";
|
} from "@excalidraw/element/elementLink";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { copyTextToSystemClipboard } from "../clipboard";
|
import { copyTextToSystemClipboard } from "../clipboard";
|
||||||
import { copyIcon, elementLinkIcon } from "../components/icons";
|
import { copyIcon, elementLinkIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -4,12 +4,13 @@ import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||||
|
|
||||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { updateActiveTool } from "@excalidraw/common";
|
import { updateActiveTool } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { setCursorForShape } from "../cursor";
|
import { setCursorForShape } from "../cursor";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,8 @@ import {
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { Theme } from "@excalidraw/element/types";
|
import type { Theme } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { useDevice } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
|
@ -24,7 +26,6 @@ import { resaveAsImageWithScene } from "../data/resave";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { getExportSize } from "../scene/export";
|
import { getExportSize } from "../scene/export";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import "../components/ToolIcon.scss";
|
import "../components/ToolIcon.scss";
|
||||||
|
|
||||||
|
|
|
@ -16,11 +16,12 @@ import { isPathALoop } from "@excalidraw/element/shapes";
|
||||||
|
|
||||||
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { resetCursor } from "../cursor";
|
import { resetCursor } from "../cursor";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,8 @@ import {
|
||||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
||||||
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
|
@ -24,7 +26,6 @@ import type {
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||||
|
|
||||||
|
|
|
@ -14,12 +14,13 @@ import { getElementsInGroup } from "@excalidraw/element/groups";
|
||||||
|
|
||||||
import { getCommonBounds } from "@excalidraw/element/bounds";
|
import { getCommonBounds } from "@excalidraw/element/bounds";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { setCursorForShape } from "../cursor";
|
import { setCursorForShape } from "../cursor";
|
||||||
import { frameToolIcon } from "../components/icons";
|
import { frameToolIcon } from "../components/icons";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,8 @@ import {
|
||||||
|
|
||||||
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
|
@ -40,7 +42,6 @@ import { UngroupIcon, GroupIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
|
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
@ -7,10 +9,8 @@ import { UndoIcon, RedoIcon } from "../components/icons";
|
||||||
import { HistoryChangedEvent } from "../history";
|
import { HistoryChangedEvent } from "../history";
|
||||||
import { useEmitter } from "../hooks/useEmitter";
|
import { useEmitter } from "../hooks/useEmitter";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import type { History } from "../history";
|
import type { History } from "../history";
|
||||||
import type { Store } from "../store";
|
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import type { Action, ActionResult } from "./types";
|
import type { Action, ActionResult } from "./types";
|
||||||
|
|
||||||
|
@ -47,9 +47,9 @@ const executeHistoryAction = (
|
||||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
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",
|
name: "undo",
|
||||||
label: "buttons.undo",
|
label: "buttons.undo",
|
||||||
icon: UndoIcon,
|
icon: UndoIcon,
|
||||||
|
@ -57,11 +57,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, value, app) =>
|
perform: (elements, appState, value, app) =>
|
||||||
executeHistoryAction(app, appState, () =>
|
executeHistoryAction(app, appState, () =>
|
||||||
history.undo(
|
history.undo(arrayToMap(elements) as SceneElementsMap, appState),
|
||||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
|
||||||
appState,
|
|
||||||
store.snapshot,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
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",
|
name: "redo",
|
||||||
label: "buttons.redo",
|
label: "buttons.redo",
|
||||||
icon: RedoIcon,
|
icon: RedoIcon,
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, _, app) =>
|
perform: (elements, appState, __, app) =>
|
||||||
executeHistoryAction(app, appState, () =>
|
executeHistoryAction(app, appState, () =>
|
||||||
history.redo(
|
history.redo(arrayToMap(elements) as SceneElementsMap, appState),
|
||||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
|
||||||
appState,
|
|
||||||
store.snapshot,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
||||||
|
|
|
@ -4,6 +4,8 @@ import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import { arrayToMap } from "@excalidraw/common";
|
import { arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
|
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||||
|
@ -11,7 +13,6 @@ import { ToolButton } from "../components/ToolButton";
|
||||||
import { lineEditorIcon } from "../components/icons";
|
import { lineEditorIcon } from "../components/icons";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -2,13 +2,14 @@ import { isEmbeddableElement } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
||||||
import { LinkIcon } from "../components/icons";
|
import { LinkIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -4,12 +4,12 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions";
|
import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { getClientColor } from "../clients";
|
import { getClientColor } from "../clients";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import {
|
import {
|
||||||
|
@ -8,7 +10,6 @@ import {
|
||||||
microphoneMutedIcon,
|
microphoneMutedIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -54,6 +54,8 @@ import { hasStrokeColor } from "@excalidraw/element/comparisons";
|
||||||
|
|
||||||
import { updateElbowArrowPoints } from "@excalidraw/element/elbowArrow";
|
import { updateElbowArrowPoints } from "@excalidraw/element/elbowArrow";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
import type { LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
@ -70,6 +72,8 @@ import type {
|
||||||
|
|
||||||
import type Scene from "@excalidraw/element/Scene";
|
import type Scene from "@excalidraw/element/Scene";
|
||||||
|
|
||||||
|
import type { CaptureUpdateActionType } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||||
|
@ -131,11 +135,9 @@ import {
|
||||||
getTargetElements,
|
getTargetElements,
|
||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { CaptureUpdateActionType } from "../store";
|
|
||||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
|
|
|
@ -6,9 +6,9 @@ import { arrayToMap, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
|
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";
|
import { selectAllIcon } from "../components/icons";
|
||||||
|
|
||||||
|
|
|
@ -24,13 +24,14 @@ import {
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "@excalidraw/element/textElement";
|
} from "@excalidraw/element/textElement";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
|
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { paintIcon } from "../components/icons";
|
import { paintIcon } from "../components/icons";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -5,8 +5,9 @@ import { measureText } from "@excalidraw/element/textMeasurements";
|
||||||
|
|
||||||
import { isTextElement } from "@excalidraw/element/typeChecks";
|
import { isTextElement } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { CODES, KEYS } from "@excalidraw/common";
|
import { CODES, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { gridIcon } from "../components/icons";
|
import { gridIcon } from "../components/icons";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { CODES, KEYS } from "@excalidraw/common";
|
import { CODES, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { magnetIcon } from "../components/icons";
|
import { magnetIcon } from "../components/icons";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -5,8 +5,9 @@ import {
|
||||||
DEFAULT_SIDEBAR,
|
DEFAULT_SIDEBAR,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { searchIcon } from "../components/icons";
|
import { searchIcon } from "../components/icons";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { CODES, KEYS } from "@excalidraw/common";
|
import { CODES, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { abacusIcon } from "../components/icons";
|
import { abacusIcon } from "../components/icons";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { CODES, KEYS } from "@excalidraw/common";
|
import { CODES, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { eyeIcon } from "../components/icons";
|
import { eyeIcon } from "../components/icons";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { CODES, KEYS } from "@excalidraw/common";
|
import { CODES, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { coffeeIcon } from "../components/icons";
|
import { coffeeIcon } from "../components/icons";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,8 @@ import {
|
||||||
moveAllRight,
|
moveAllRight,
|
||||||
} from "@excalidraw/element/zindex";
|
} from "@excalidraw/element/zindex";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BringForwardIcon,
|
BringForwardIcon,
|
||||||
BringToFrontIcon,
|
BringToFrontIcon,
|
||||||
|
@ -14,7 +16,6 @@ import {
|
||||||
SendToBackIcon,
|
SendToBackIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,8 @@ import type {
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { CaptureUpdateActionType } from "../store";
|
import type { CaptureUpdateActionType } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
AppState,
|
AppState,
|
||||||
|
|
|
@ -101,6 +101,7 @@ import {
|
||||||
type EXPORT_IMAGE_TYPES,
|
type EXPORT_IMAGE_TYPES,
|
||||||
randomInteger,
|
randomInteger,
|
||||||
CLASSES,
|
CLASSES,
|
||||||
|
Emitter,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -303,6 +304,12 @@ import { isNonDeletedElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import Scene from "@excalidraw/element/Scene";
|
import Scene from "@excalidraw/element/Scene";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Store,
|
||||||
|
CaptureUpdateAction,
|
||||||
|
StoreIncrement,
|
||||||
|
} from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
|
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
|
||||||
|
|
||||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||||
|
@ -454,9 +461,7 @@ import {
|
||||||
resetCursor,
|
resetCursor,
|
||||||
setCursorForShape,
|
setCursorForShape,
|
||||||
} from "../cursor";
|
} from "../cursor";
|
||||||
import { Emitter } from "../emitter";
|
|
||||||
import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
|
import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
|
||||||
import { Store, CaptureUpdateAction } from "../store";
|
|
||||||
import { LaserTrails } from "../laser-trails";
|
import { LaserTrails } from "../laser-trails";
|
||||||
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
||||||
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
|
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
|
||||||
|
@ -762,7 +767,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
this.visibleElements = [];
|
this.visibleElements = [];
|
||||||
|
|
||||||
this.store = new Store();
|
this.store = new Store();
|
||||||
this.history = new History();
|
this.history = new History(this.store);
|
||||||
|
|
||||||
if (excalidrawAPI) {
|
if (excalidrawAPI) {
|
||||||
const api: ExcalidrawImperativeAPI = {
|
const api: ExcalidrawImperativeAPI = {
|
||||||
|
@ -772,6 +777,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
addFiles: this.addFiles,
|
addFiles: this.addFiles,
|
||||||
resetScene: this.resetScene,
|
resetScene: this.resetScene,
|
||||||
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
|
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
|
||||||
|
store: this.store,
|
||||||
history: {
|
history: {
|
||||||
clear: this.resetHistory,
|
clear: this.resetHistory,
|
||||||
},
|
},
|
||||||
|
@ -792,6 +798,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
updateFrameRendering: this.updateFrameRendering,
|
updateFrameRendering: this.updateFrameRendering,
|
||||||
toggleSidebar: this.toggleSidebar,
|
toggleSidebar: this.toggleSidebar,
|
||||||
onChange: (cb) => this.onChangeEmitter.on(cb),
|
onChange: (cb) => this.onChangeEmitter.on(cb),
|
||||||
|
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
|
||||||
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
||||||
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
|
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
|
||||||
onScrollChange: (cb) => this.onScrollChangeEmitter.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.fonts = new Fonts(this.scene);
|
||||||
this.history = new History();
|
this.history = new History(this.store);
|
||||||
|
|
||||||
this.actionManager.registerAll(actions);
|
this.actionManager.registerAll(actions);
|
||||||
this.actionManager.registerAction(
|
this.actionManager.registerAction(createUndoAction(this.history));
|
||||||
createUndoAction(this.history, this.store),
|
this.actionManager.registerAction(createRedoAction(this.history));
|
||||||
);
|
|
||||||
this.actionManager.registerAction(
|
|
||||||
createRedoAction(this.history, this.store),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateEditorAtom = <Value, Args extends unknown[], Result>(
|
updateEditorAtom = <Value, Args extends unknown[], Result>(
|
||||||
|
@ -1899,6 +1902,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
return this.scene.getElementsIncludingDeleted();
|
return this.scene.getElementsIncludingDeleted();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public getSceneElementsMapIncludingDeleted = () => {
|
||||||
|
return this.scene.getElementsMapIncludingDeleted();
|
||||||
|
};
|
||||||
|
|
||||||
public getSceneElements = () => {
|
public getSceneElements = () => {
|
||||||
return this.scene.getNonDeletedElements();
|
return this.scene.getNonDeletedElements();
|
||||||
};
|
};
|
||||||
|
@ -2215,11 +2222,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actionResult.captureUpdate === CaptureUpdateAction.NEVER) {
|
this.store.scheduleAction(actionResult.captureUpdate);
|
||||||
this.store.shouldUpdateSnapshot();
|
|
||||||
} else if (actionResult.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
|
|
||||||
this.store.shouldCaptureIncrement();
|
|
||||||
}
|
|
||||||
|
|
||||||
let didUpdate = false;
|
let didUpdate = false;
|
||||||
|
|
||||||
|
@ -2292,10 +2295,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
didUpdate = true;
|
didUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!didUpdate) {
|
||||||
!didUpdate &&
|
|
||||||
actionResult.captureUpdate !== CaptureUpdateAction.EVENTUALLY
|
|
||||||
) {
|
|
||||||
this.scene.triggerUpdate();
|
this.scene.triggerUpdate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -2548,7 +2548,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.onStoreIncrementEmitter.on((increment) => {
|
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);
|
this.scene.onUpdate(this.triggerRender);
|
||||||
|
@ -2903,7 +2907,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
this.state.editingLinearElement &&
|
this.state.editingLinearElement &&
|
||||||
!this.state.selectedElementIds[this.state.editingLinearElement.elementId]
|
!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(() => {
|
setTimeout(() => {
|
||||||
// execute only if the condition still holds when the deferred callback
|
// execute only if the condition still holds when the deferred callback
|
||||||
// executes (it can be scheduled multiple times depending on how
|
// 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.addMissingFiles(opts.files);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
|
|
||||||
const nextElementsToSelect =
|
const nextElementsToSelect =
|
||||||
excludeElementsInFramesFromSelection(duplicatedElements);
|
excludeElementsInFramesFromSelection(duplicatedElements);
|
||||||
|
@ -3619,7 +3623,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
PLAIN_PASTE_TOAST_SHOWN = true;
|
PLAIN_PASTE_TOAST_SHOWN = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
setAppState: React.Component<any, AppState>["setState"] = (
|
setAppState: React.Component<any, AppState>["setState"] = (
|
||||||
|
@ -3975,51 +3979,42 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
*/
|
*/
|
||||||
captureUpdate?: SceneData["captureUpdate"];
|
captureUpdate?: SceneData["captureUpdate"];
|
||||||
}) => {
|
}) => {
|
||||||
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
|
const { elements, appState, collaborators, captureUpdate } = sceneData;
|
||||||
|
|
||||||
if (
|
const nextElements = elements ? syncInvalidIndices(elements) : undefined;
|
||||||
sceneData.captureUpdate &&
|
|
||||||
sceneData.captureUpdate !== CaptureUpdateAction.EVENTUALLY
|
if (captureUpdate) {
|
||||||
) {
|
|
||||||
const prevCommittedAppState = this.store.snapshot.appState;
|
const prevCommittedAppState = this.store.snapshot.appState;
|
||||||
const prevCommittedElements = this.store.snapshot.elements;
|
const prevCommittedElements = this.store.snapshot.elements;
|
||||||
|
|
||||||
const nextCommittedAppState = sceneData.appState
|
const nextCommittedAppState = appState
|
||||||
? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
|
? Object.assign({}, prevCommittedAppState, appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
|
||||||
: prevCommittedAppState;
|
: prevCommittedAppState;
|
||||||
|
|
||||||
const nextCommittedElements = sceneData.elements
|
const nextCommittedElements = elements
|
||||||
? this.store.filterUncomittedElements(
|
? this.store.filterUncomittedElements(
|
||||||
this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements
|
this.scene.getElementsMapIncludingDeleted(), // only used to detect uncomitted local elements
|
||||||
arrayToMap(nextElements), // We expect all (already reconciled) elements
|
arrayToMap(nextElements ?? []), // we expect all (already reconciled) elements
|
||||||
)
|
)
|
||||||
: prevCommittedElements;
|
: prevCommittedElements;
|
||||||
|
|
||||||
// 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
|
this.store.scheduleMicroAction(
|
||||||
// do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well
|
captureUpdate,
|
||||||
if (sceneData.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
|
|
||||||
this.store.captureIncrement(
|
|
||||||
nextCommittedElements,
|
|
||||||
nextCommittedAppState,
|
|
||||||
);
|
|
||||||
} else if (sceneData.captureUpdate === CaptureUpdateAction.NEVER) {
|
|
||||||
this.store.updateSnapshot(
|
|
||||||
nextCommittedElements,
|
nextCommittedElements,
|
||||||
nextCommittedAppState,
|
nextCommittedAppState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appState) {
|
||||||
|
this.setState(appState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sceneData.appState) {
|
if (nextElements) {
|
||||||
this.setState(sceneData.appState);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sceneData.elements) {
|
|
||||||
this.scene.replaceAllElements(nextElements);
|
this.scene.replaceAllElements(nextElements);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sceneData.collaborators) {
|
if (collaborators) {
|
||||||
this.setState({ collaborators: sceneData.collaborators });
|
this.setState({ collaborators });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -4202,7 +4197,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
direction: event.shiftKey ? "left" : "right",
|
direction: event.shiftKey ? "left" : "right",
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (conversionType) {
|
if (conversionType) {
|
||||||
|
@ -4519,7 +4514,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
this.state.editingLinearElement.elementId !==
|
this.state.editingLinearElement.elementId !==
|
||||||
selectedElements[0].id
|
selectedElements[0].id
|
||||||
) {
|
) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
if (!isElbowArrow(selectedElement)) {
|
if (!isElbowArrow(selectedElement)) {
|
||||||
this.setState({
|
this.setState({
|
||||||
editingLinearElement: new LinearElementEditor(
|
editingLinearElement: new LinearElementEditor(
|
||||||
|
@ -4845,7 +4840,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
if (nextActiveTool.type === "freedraw") {
|
if (nextActiveTool.type === "freedraw") {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextActiveTool.type === "lasso") {
|
if (nextActiveTool.type === "lasso") {
|
||||||
|
@ -5062,7 +5057,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
if (!isDeleted || isExistingElement) {
|
if (!isDeleted || isExistingElement) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
|
@ -5475,7 +5470,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
};
|
};
|
||||||
|
|
||||||
private startImageCropping = (image: ExcalidrawImageElement) => {
|
private startImageCropping = (image: ExcalidrawImageElement) => {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
this.setState({
|
this.setState({
|
||||||
croppingElementId: image.id,
|
croppingElementId: image.id,
|
||||||
});
|
});
|
||||||
|
@ -5483,7 +5478,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
|
|
||||||
private finishImageCropping = () => {
|
private finishImageCropping = () => {
|
||||||
if (this.state.croppingElementId) {
|
if (this.state.croppingElementId) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
this.setState({
|
this.setState({
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
});
|
});
|
||||||
|
@ -5518,7 +5513,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
selectedElements[0].id) &&
|
selectedElements[0].id) &&
|
||||||
!isElbowArrow(selectedElements[0])
|
!isElbowArrow(selectedElements[0])
|
||||||
) {
|
) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
this.setState({
|
this.setState({
|
||||||
editingLinearElement: new LinearElementEditor(
|
editingLinearElement: new LinearElementEditor(
|
||||||
selectedElements[0],
|
selectedElements[0],
|
||||||
|
@ -5546,7 +5541,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
: -1;
|
: -1;
|
||||||
|
|
||||||
if (midPoint && midPoint > -1) {
|
if (midPoint && midPoint > -1) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
LinearElementEditor.deleteFixedSegment(
|
LinearElementEditor.deleteFixedSegment(
|
||||||
selectedElements[0],
|
selectedElements[0],
|
||||||
this.scene,
|
this.scene,
|
||||||
|
@ -5608,7 +5603,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
|
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
|
||||||
|
|
||||||
if (selectedGroupId) {
|
if (selectedGroupId) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
this.setState((prevState) => ({
|
this.setState((prevState) => ({
|
||||||
...prevState,
|
...prevState,
|
||||||
...selectGroupsForSelectedElements(
|
...selectGroupsForSelectedElements(
|
||||||
|
@ -9131,7 +9126,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
|
|
||||||
if (isLinearElement(newElement)) {
|
if (isLinearElement(newElement)) {
|
||||||
if (newElement!.points.length > 1) {
|
if (newElement!.points.length > 1) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
const pointerCoords = viewportCoordsToSceneCoords(
|
const pointerCoords = viewportCoordsToSceneCoords(
|
||||||
childEvent,
|
childEvent,
|
||||||
|
@ -9404,7 +9399,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resizingElement) {
|
if (resizingElement) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
|
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
|
||||||
|
@ -9744,7 +9739,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
this.state.selectedElementIds,
|
this.state.selectedElementIds,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -9837,7 +9832,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
this.elementsPendingErasure = new Set();
|
this.elementsPendingErasure = new Set();
|
||||||
|
|
||||||
if (didChange) {
|
if (didChange) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
this.scene.replaceAllElements(elements);
|
this.scene.replaceAllElements(elements);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -10517,8 +10512,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
// restore the fractional indices by mutating elements
|
// restore the fractional indices by mutating elements
|
||||||
syncInvalidIndices(elements.concat(ret.data.elements));
|
syncInvalidIndices(elements.concat(ret.data.elements));
|
||||||
|
|
||||||
// update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo
|
// don't capture and only update the store snapshot for old elements,
|
||||||
this.store.updateSnapshot(arrayToMap(elements), this.state);
|
// otherwise we would end up with duplicated fractional indices on undo
|
||||||
|
this.store.scheduleMicroAction(
|
||||||
|
CaptureUpdateAction.NEVER,
|
||||||
|
arrayToMap(elements),
|
||||||
|
);
|
||||||
|
|
||||||
this.setState({ isLoading: true });
|
this.setState({ isLoading: true });
|
||||||
this.syncActionResult({
|
this.syncActionResult({
|
||||||
|
|
|
@ -5,11 +5,12 @@ import { EVENT, KEYS, cloneJSON } from "@excalidraw/common";
|
||||||
|
|
||||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type Scene from "@excalidraw/element/Scene";
|
import type Scene from "@excalidraw/element/Scene";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "../../store";
|
|
||||||
import { useApp } from "../App";
|
import { useApp } from "../App";
|
||||||
import { InlineIcon } from "../InlineIcon";
|
import { InlineIcon } from "../InlineIcon";
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
toValidURL,
|
toValidURL,
|
||||||
Queue,
|
Queue,
|
||||||
|
Emitter,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { hashElementsVersion, hashString } from "@excalidraw/element";
|
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 { atom, editorJotaiStore } from "../editor-jotai";
|
||||||
|
|
||||||
import { Emitter } from "../emitter";
|
|
||||||
import { AbortError } from "../errors";
|
import { AbortError } from "../errors";
|
||||||
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
|
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
|
@ -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 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";
|
import type { AppState } from "./types";
|
||||||
|
|
||||||
type HistoryStack = HistoryEntry[];
|
class HistoryEntry extends StoreDelta {}
|
||||||
|
|
||||||
export class HistoryChangedEvent {
|
export class HistoryChangedEvent {
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -20,8 +24,8 @@ export class History {
|
||||||
[HistoryChangedEvent]
|
[HistoryChangedEvent]
|
||||||
>();
|
>();
|
||||||
|
|
||||||
private readonly undoStack: HistoryStack = [];
|
public readonly undoStack: HistoryEntry[] = [];
|
||||||
private readonly redoStack: HistoryStack = [];
|
public readonly redoStack: HistoryEntry[] = [];
|
||||||
|
|
||||||
public get isUndoStackEmpty() {
|
public get isUndoStackEmpty() {
|
||||||
return this.undoStack.length === 0;
|
return this.undoStack.length === 0;
|
||||||
|
@ -31,25 +35,28 @@ export class History {
|
||||||
return this.redoStack.length === 0;
|
return this.redoStack.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
constructor(private readonly store: Store) {}
|
||||||
|
|
||||||
public clear() {
|
public clear() {
|
||||||
this.undoStack.length = 0;
|
this.undoStack.length = 0;
|
||||||
this.redoStack.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(
|
public record(delta: StoreDelta) {
|
||||||
elementsChange: ElementsChange,
|
if (delta.isEmpty() || delta instanceof HistoryEntry) {
|
||||||
appStateChange: AppStateChange,
|
return;
|
||||||
) {
|
}
|
||||||
const entry = HistoryEntry.create(appStateChange, elementsChange);
|
|
||||||
|
|
||||||
if (!entry.isEmpty()) {
|
// construct history entry, so once it's emitted, it's not recorded again
|
||||||
// we have the latest changes, no need to `applyLatest`, which is done within `History.push`
|
const entry = HistoryEntry.inverse(delta);
|
||||||
this.undoStack.push(entry.inverse());
|
|
||||||
|
|
||||||
if (!entry.elementsChange.isEmpty()) {
|
this.undoStack.push(entry);
|
||||||
|
|
||||||
|
if (!entry.elements.isEmpty()) {
|
||||||
// don't reset redo stack on local appState changes,
|
// don't reset redo stack on local appState changes,
|
||||||
// as a simple click (unselect) could lead to losing all the redo entries
|
// as a simple click (unselect) could lead to losing all the redo entries
|
||||||
// only reset on non empty elements changes!
|
// only reset on non empty elements changes!
|
||||||
|
@ -60,31 +67,20 @@ export class History {
|
||||||
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
|
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public undo(
|
public undo(elements: SceneElementsMap, appState: AppState) {
|
||||||
elements: SceneElementsMap,
|
|
||||||
appState: AppState,
|
|
||||||
snapshot: Readonly<Snapshot>,
|
|
||||||
) {
|
|
||||||
return this.perform(
|
return this.perform(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
snapshot,
|
|
||||||
() => History.pop(this.undoStack),
|
() => History.pop(this.undoStack),
|
||||||
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
|
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public redo(
|
public redo(elements: SceneElementsMap, appState: AppState) {
|
||||||
elements: SceneElementsMap,
|
|
||||||
appState: AppState,
|
|
||||||
snapshot: Readonly<Snapshot>,
|
|
||||||
) {
|
|
||||||
return this.perform(
|
return this.perform(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
snapshot,
|
|
||||||
() => History.pop(this.redoStack),
|
() => History.pop(this.redoStack),
|
||||||
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
|
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
|
||||||
);
|
);
|
||||||
|
@ -93,7 +89,6 @@ export class History {
|
||||||
private perform(
|
private perform(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
snapshot: Readonly<Snapshot>,
|
|
||||||
pop: () => HistoryEntry | null,
|
pop: () => HistoryEntry | null,
|
||||||
push: (entry: HistoryEntry) => void,
|
push: (entry: HistoryEntry) => void,
|
||||||
): [SceneElementsMap, AppState] | void {
|
): [SceneElementsMap, AppState] | void {
|
||||||
|
@ -111,10 +106,20 @@ export class History {
|
||||||
// iterate through the history entries in case they result in no visible changes
|
// iterate through the history entries in case they result in no visible changes
|
||||||
while (historyEntry) {
|
while (historyEntry) {
|
||||||
try {
|
try {
|
||||||
|
// creating iteration-scoped variables, so that we can use them in the unstable_scheduleCallback
|
||||||
[nextElements, nextAppState, containsVisibleChange] =
|
[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 {
|
} 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);
|
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) {
|
if (!stack.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -150,63 +155,17 @@ export class History {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static push(
|
private static push(
|
||||||
stack: HistoryStack,
|
stack: HistoryEntry[],
|
||||||
entry: HistoryEntry,
|
entry: HistoryEntry,
|
||||||
prevElements: SceneElementsMap,
|
prevElements: SceneElementsMap,
|
||||||
) {
|
) {
|
||||||
const updatedEntry = entry.inverse().applyLatestChanges(prevElements);
|
const inversedEntry = HistoryEntry.inverse(entry);
|
||||||
|
const updatedEntry = HistoryEntry.applyLatestChanges(
|
||||||
|
inversedEntry,
|
||||||
|
prevElements,
|
||||||
|
"inserted",
|
||||||
|
);
|
||||||
|
|
||||||
return stack.push(updatedEntry);
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { Emitter } from "../emitter";
|
import type { Emitter } from "@excalidraw/common";
|
||||||
|
|
||||||
export const useEmitter = <TEvent extends unknown>(
|
export const useEmitter = <TEvent extends unknown>(
|
||||||
emitter: Emitter<[TEvent]>,
|
emitter: Emitter<[TEvent]>,
|
||||||
|
|
|
@ -23,6 +23,7 @@ polyfill();
|
||||||
const ExcalidrawBase = (props: ExcalidrawProps) => {
|
const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||||
const {
|
const {
|
||||||
onChange,
|
onChange,
|
||||||
|
onIncrement,
|
||||||
initialData,
|
initialData,
|
||||||
excalidrawAPI,
|
excalidrawAPI,
|
||||||
isCollaborating = false,
|
isCollaborating = false,
|
||||||
|
@ -114,6 +115,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||||
<InitializeApp langCode={langCode} theme={theme}>
|
<InitializeApp langCode={langCode} theme={theme}>
|
||||||
<App
|
<App
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onIncrement={onIncrement}
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
excalidrawAPI={excalidrawAPI}
|
excalidrawAPI={excalidrawAPI}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
|
@ -266,7 +268,7 @@ export {
|
||||||
bumpVersion,
|
bumpVersion,
|
||||||
} from "@excalidraw/element/mutateElement";
|
} from "@excalidraw/element/mutateElement";
|
||||||
|
|
||||||
export { CaptureUpdateAction } from "./store";
|
export { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library";
|
export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library";
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -23,6 +23,10 @@ import {
|
||||||
|
|
||||||
import "@excalidraw/utils/test-utils";
|
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 { LocalPoint, Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
@ -46,11 +50,8 @@ import {
|
||||||
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
||||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { HistoryEntry } from "../history";
|
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import * as StaticScene from "../renderer/staticScene";
|
import * as StaticScene from "../renderer/staticScene";
|
||||||
import { Snapshot, CaptureUpdateAction } from "../store";
|
|
||||||
import { AppStateChange, ElementsChange } from "../change";
|
|
||||||
|
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||||
|
@ -82,13 +83,52 @@ const checkpoint = (name: string) => {
|
||||||
...strippedAppState
|
...strippedAppState
|
||||||
} = h.state;
|
} = h.state;
|
||||||
expect(strippedAppState).toMatchSnapshot(`[${name}] appState`);
|
expect(strippedAppState).toMatchSnapshot(`[${name}] appState`);
|
||||||
expect(h.history).toMatchSnapshot(`[${name}] history`);
|
|
||||||
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
|
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
|
||||||
h.elements
|
h.elements
|
||||||
.map(({ seed, versionNonce, ...strippedElement }) => strippedElement)
|
.map(({ seed, versionNonce, ...strippedElement }) => strippedElement)
|
||||||
.forEach((element, i) =>
|
.forEach((element, i) =>
|
||||||
expect(element).toMatchSnapshot(`[${name}] 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");
|
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
||||||
|
@ -116,12 +156,12 @@ describe("history", () => {
|
||||||
|
|
||||||
API.setElements([rect]);
|
API.setElements([rect]);
|
||||||
|
|
||||||
const corrupedEntry = HistoryEntry.create(
|
const corrupedEntry = StoreDelta.create(
|
||||||
AppStateChange.empty(),
|
ElementsDelta.empty(),
|
||||||
ElementsChange.empty(),
|
AppStateDelta.empty(),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.spyOn(corrupedEntry, "applyTo").mockImplementation(() => {
|
vi.spyOn(corrupedEntry.elements, "applyTo").mockImplementation(() => {
|
||||||
throw new Error("Oh no, I am corrupted!");
|
throw new Error("Oh no, I am corrupted!");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -136,7 +176,6 @@ describe("history", () => {
|
||||||
h.history.undo(
|
h.history.undo(
|
||||||
arrayToMap(h.elements) as SceneElementsMap,
|
arrayToMap(h.elements) as SceneElementsMap,
|
||||||
appState,
|
appState,
|
||||||
Snapshot.empty(),
|
|
||||||
) as any,
|
) as any,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -157,7 +196,6 @@ describe("history", () => {
|
||||||
h.history.redo(
|
h.history.redo(
|
||||||
arrayToMap(h.elements) as SceneElementsMap,
|
arrayToMap(h.elements) as SceneElementsMap,
|
||||||
appState,
|
appState,
|
||||||
Snapshot.empty(),
|
|
||||||
) as any,
|
) as any,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -454,8 +492,8 @@ describe("history", () => {
|
||||||
expect(h.history.isUndoStackEmpty).toBeTruthy();
|
expect(h.history.isUndoStackEmpty).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history, h.store);
|
const undoAction = createUndoAction(h.history);
|
||||||
const redoAction = createRedoAction(h.history, h.store);
|
const redoAction = createRedoAction(h.history);
|
||||||
// noop
|
// noop
|
||||||
API.executeAction(undoAction);
|
API.executeAction(undoAction);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
|
@ -531,8 +569,8 @@ describe("history", () => {
|
||||||
expect.objectContaining({ id: "B", isDeleted: false }),
|
expect.objectContaining({ id: "B", isDeleted: false }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history, h.store);
|
const undoAction = createUndoAction(h.history);
|
||||||
const redoAction = createRedoAction(h.history, h.store);
|
const redoAction = createRedoAction(h.history);
|
||||||
API.executeAction(undoAction);
|
API.executeAction(undoAction);
|
||||||
|
|
||||||
expect(API.getSnapshot()).toEqual([
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
@ -1713,8 +1751,8 @@ describe("history", () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history, h.store);
|
const undoAction = createUndoAction(h.history);
|
||||||
const redoAction = createRedoAction(h.history, h.store);
|
const redoAction = createRedoAction(h.history);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||||
|
|
|
@ -43,6 +43,12 @@ import type {
|
||||||
MakeBrand,
|
MakeBrand,
|
||||||
} from "@excalidraw/common/utility-types";
|
} from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CaptureUpdateActionType,
|
||||||
|
DurableIncrement,
|
||||||
|
EphemeralIncrement,
|
||||||
|
} from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { Action } from "./actions/types";
|
import type { Action } from "./actions/types";
|
||||||
import type { Spreadsheet } from "./charts";
|
import type { Spreadsheet } from "./charts";
|
||||||
import type { ClipboardData } from "./clipboard";
|
import type { ClipboardData } from "./clipboard";
|
||||||
|
@ -51,7 +57,6 @@ import type Library from "./data/library";
|
||||||
import type { FileSystemHandle } from "./data/filesystem";
|
import type { FileSystemHandle } from "./data/filesystem";
|
||||||
import type { ContextMenuItems } from "./components/ContextMenu";
|
import type { ContextMenuItems } from "./components/ContextMenu";
|
||||||
import type { SnapLine } from "./snapping";
|
import type { SnapLine } from "./snapping";
|
||||||
import type { CaptureUpdateActionType } from "./store";
|
|
||||||
import type { ImportedDataState } from "./data/types";
|
import type { ImportedDataState } from "./data/types";
|
||||||
|
|
||||||
import type { Language } from "./i18n";
|
import type { Language } from "./i18n";
|
||||||
|
@ -518,6 +523,7 @@ export interface ExcalidrawProps {
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
) => void;
|
) => void;
|
||||||
|
onIncrement?: (event: DurableIncrement | EphemeralIncrement) => void;
|
||||||
initialData?:
|
initialData?:
|
||||||
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
|
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
|
||||||
| MaybePromise<ExcalidrawInitialDataState | null>;
|
| MaybePromise<ExcalidrawInitialDataState | null>;
|
||||||
|
@ -794,6 +800,7 @@ export interface ExcalidrawImperativeAPI {
|
||||||
history: {
|
history: {
|
||||||
clear: InstanceType<typeof App>["resetHistory"];
|
clear: InstanceType<typeof App>["resetHistory"];
|
||||||
};
|
};
|
||||||
|
store: InstanceType<typeof App>["store"];
|
||||||
getSceneElements: InstanceType<typeof App>["getSceneElements"];
|
getSceneElements: InstanceType<typeof App>["getSceneElements"];
|
||||||
getAppState: () => InstanceType<typeof App>["state"];
|
getAppState: () => InstanceType<typeof App>["state"];
|
||||||
getFiles: () => InstanceType<typeof App>["files"];
|
getFiles: () => InstanceType<typeof App>["files"];
|
||||||
|
@ -821,6 +828,9 @@ export interface ExcalidrawImperativeAPI {
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
) => void,
|
) => void,
|
||||||
) => UnsubscribeCallback;
|
) => UnsubscribeCallback;
|
||||||
|
onIncrement: (
|
||||||
|
callback: (event: DurableIncrement | EphemeralIncrement) => void,
|
||||||
|
) => UnsubscribeCallback;
|
||||||
onPointerDown: (
|
onPointerDown: (
|
||||||
callback: (
|
callback: (
|
||||||
activeTool: AppState["activeTool"],
|
activeTool: AppState["activeTool"],
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue