Merge branch 'master' into zsviczian-loop-lock

# Conflicts:
#	packages/element/src/linearElementEditor.ts
#	packages/excalidraw/actions/actionLinearEditor.tsx
#	packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
This commit is contained in:
dwelle 2025-05-08 17:09:49 +02:00
commit ac1ad31921
81 changed files with 12585 additions and 11585 deletions

View File

@ -8,6 +8,13 @@ import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
import { vi } from "vitest";
import { StoreIncrement } from "@excalidraw/element/store";
import type {
DurableIncrement,
EphemeralIncrement,
} from "@excalidraw/element/store";
import ExcalidrawApp from "../App";
const { h } = window;
@ -65,6 +72,79 @@ vi.mock("socket.io-client", () => {
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/
describe("collaboration", () => {
it("should emit two ephemeral increments even though updates get batched", async () => {
const durableIncrements: DurableIncrement[] = [];
const ephemeralIncrements: EphemeralIncrement[] = [];
await render(<ExcalidrawApp />);
h.store.onStoreIncrementEmitter.on((increment) => {
if (StoreIncrement.isDurable(increment)) {
durableIncrements.push(increment);
} else {
ephemeralIncrements.push(increment);
}
});
// eslint-disable-next-line dot-notation
expect(h.store["scheduledMicroActions"].length).toBe(0);
expect(durableIncrements.length).toBe(0);
expect(ephemeralIncrements.length).toBe(0);
const rectProps = {
type: "rectangle",
id: "A",
height: 200,
width: 100,
x: 0,
y: 0,
} as const;
const rect = API.createElement({ ...rectProps });
API.updateScene({
elements: [rect],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
await waitFor(() => {
// expect(commitSpy).toHaveBeenCalledTimes(1);
expect(durableIncrements.length).toBe(1);
});
// simulate two batched remote updates
act(() => {
h.app.updateScene({
elements: [newElementWith(h.elements[0], { x: 100 })],
captureUpdate: CaptureUpdateAction.NEVER,
});
h.app.updateScene({
elements: [newElementWith(h.elements[0], { x: 200 })],
captureUpdate: CaptureUpdateAction.NEVER,
});
// we scheduled two micro actions,
// which confirms they are going to be executed as part of one batched component update
// eslint-disable-next-line dot-notation
expect(h.store["scheduledMicroActions"].length).toBe(2);
});
await waitFor(() => {
// altough the updates get batched,
// we expect two ephemeral increments for each update,
// and each such update should have the expected change
expect(ephemeralIncrements.length).toBe(2);
expect(ephemeralIncrements[0].change.elements.A).toEqual(
expect.objectContaining({ x: 100 }),
);
expect(ephemeralIncrements[1].change.elements.A).toEqual(
expect.objectContaining({ x: 200 }),
);
// eslint-disable-next-line dot-notation
expect(h.store["scheduledMicroActions"].length).toBe(0);
});
});
it("should allow to undo / redo even on force-deleted elements", async () => {
await render(<ExcalidrawApp />);
const rect1Props = {
@ -122,7 +202,7 @@ describe("collaboration", () => {
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const undoAction = createUndoAction(h.history, h.store);
const undoAction = createUndoAction(h.history);
act(() => h.app.actionManager.executeAction(undoAction));
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
@ -154,7 +234,7 @@ describe("collaboration", () => {
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const redoAction = createRedoAction(h.history, h.store);
const redoAction = createRedoAction(h.history);
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as removal) we again restore the element from the snapshot!

View File

@ -33,6 +33,7 @@
"pepjs": "0.5.3",
"prettier": "2.6.2",
"rewire": "6.0.0",
"rimraf": "^5.0.0",
"typescript": "4.9.4",
"vite": "5.0.12",
"vite-plugin-checker": "0.7.2",
@ -78,8 +79,8 @@
"autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js",
"release:excalidraw": "node scripts/release.js",
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/{build,dist}",
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
"rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
"clean-install": "yarn rm:node_modules && yarn install"
},
"resolutions": {

View File

@ -50,7 +50,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}

View File

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

View File

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

View File

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

View File

@ -735,6 +735,25 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
return acc;
}, [] as Node<T>[]);
/**
* Converts a readonly array or map into an iterable.
* Useful for avoiding entry allocations when iterating object / map on each iteration.
*/
export const toIterable = <T>(
values: readonly T[] | ReadonlyMap<string, T>,
): Iterable<T> => {
return Array.isArray(values) ? values : values.values();
};
/**
* Converts a readonly array or map into an array.
*/
export const toArray = <T>(
values: readonly T[] | ReadonlyMap<string, T>,
): T[] => {
return Array.isArray(values) ? values : Array.from(toIterable(values));
};
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;

View File

@ -50,7 +50,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}

View File

@ -6,14 +6,13 @@ import {
toBrandedType,
isDevEnv,
isTestEnv,
isReadonlyArray,
toArray,
} from "@excalidraw/common";
import { isNonDeletedElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { getElementsInGroup } from "@excalidraw/element/groups";
import {
orderByFractionalIndex,
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
@ -268,19 +267,13 @@ class Scene {
}
replaceAllElements(nextElements: ElementsMapOrArray) {
// ts doesn't like `Array.isArray` of `instanceof Map`
if (!isReadonlyArray(nextElements)) {
// need to order by fractional indices to get the correct order
nextElements = orderByFractionalIndex(
Array.from(nextElements.values()) as OrderedExcalidrawElement[],
);
}
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
const _nextElements = toArray(nextElements);
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(nextElements);
validateIndicesThrottled(_nextElements);
this.elements = syncInvalidIndices(nextElements);
this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear();
this.elements.forEach((element) => {
if (isFrameLikeElement(element)) {

View File

@ -33,7 +33,7 @@ import type { LocalPoint, Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
import {
getCenterForBounds,
@ -84,6 +84,7 @@ import type {
ExcalidrawElbowArrowElement,
FixedPoint,
FixedPointBinding,
PointsPositionUpdates,
} from "./types";
export type SuggestedBinding =
@ -801,28 +802,22 @@ export const updateBoundElements = (
bindableElement,
elementsMap,
);
if (point) {
return {
index:
bindingProp === "startBinding" ? 0 : element.points.length - 1,
point,
};
return [
bindingProp === "startBinding" ? 0 : element.points.length - 1,
{ point },
] as MapEntry<PointsPositionUpdates>;
}
}
return null;
},
).filter(
(
update,
): update is NonNullable<{
index: number;
point: LocalPoint;
isDragging?: boolean;
}> => update !== null,
(update): update is MapEntry<PointsPositionUpdates> => update !== null,
);
LinearElementEditor.movePoints(element, scene, updates, {
LinearElementEditor.movePoints(element, scene, new Map(updates), {
...(changedElement.id === element.startBinding?.elementId
? { startBinding: bindings.startBinding }
: {}),
@ -1171,6 +1166,48 @@ export const snapToMid = (
center,
angle,
);
} else if (element.type === "diamond") {
const distance = FIXED_BINDING_DISTANCE - 1;
const topLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + height / 4 - distance,
);
const topRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + height / 4 - distance,
);
const bottomLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + (3 * height) / 4 + distance,
);
const bottomRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + (3 * height) / 4 + distance,
);
if (
pointDistance(topLeft, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold)
) {
return pointRotateRads(topLeft, center, angle);
}
if (
pointDistance(topRight, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold)
) {
return pointRotateRads(topRight, center, angle);
}
if (
pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold)
) {
return pointRotateRads(bottomLeft, center, angle);
}
if (
pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold)
) {
return pointRotateRads(bottomRight, center, angle);
}
}
return p;

View File

@ -1,17 +1,17 @@
import rough from "roughjs/bin/rough";
import {
rescalePoints,
arrayToMap,
invariant,
rescalePoints,
sizeOf,
} from "@excalidraw/common";
import {
degreesToRadians,
lineSegment,
pointFrom,
pointDistance,
pointFrom,
pointFromArray,
pointRotateRads,
} from "@excalidraw/math";
@ -33,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache";
import { generateRoughOptions } from "./Shape";
import { ShapeCache } from "./ShapeCache";
import { LinearElementEditor } from "./linearElementEditor";
import { getBoundTextElement, getContainerElement } from "./textElement";
import {
@ -52,20 +52,20 @@ import {
deconstructRectanguloidElement,
} from "./utils";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
NonDeleted,
ExcalidrawTextElementWithContainer,
ElementsMap,
ExcalidrawRectanguloidElement,
ExcalidrawEllipseElement,
ElementsMapOrArray,
} from "./types";
import type { Drawable, Op } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
import type {
Arrowhead,
ElementsMap,
ElementsMapOrArray,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
ExcalidrawTextElementWithContainer,
NonDeleted,
} from "./types";
export type RectangleBox = {
x: number;

View File

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

View File

@ -462,12 +462,18 @@ const createBindingArrow = (
bindingArrow as OrderedExcalidrawElement,
);
LinearElementEditor.movePoints(bindingArrow, scene, [
{
index: 1,
point: bindingArrow.points[1],
},
]);
LinearElementEditor.movePoints(
bindingArrow,
scene,
new Map([
[
1,
{
point: bindingArrow.points[1],
},
],
]),
);
const update = updateElbowArrowPoints(
bindingArrow,

View File

@ -1,3 +1,5 @@
import { toIterable } from "@excalidraw/common";
import { isInvisiblySmallElement } from "./sizeHelpers";
import { isLinearElementType } from "./typeChecks";
@ -5,6 +7,7 @@ import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeleted,
ElementsMapOrArray,
} from "./types";
/**
@ -16,12 +19,10 @@ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
/**
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
*/
export const hashElementsVersion = (
elements: readonly ExcalidrawElement[],
): number => {
export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
let hash = 5381;
for (let i = 0; i < elements.length; i++) {
hash = (hash << 5) + hash + elements[i].versionNonce;
for (const element of toIterable(elements)) {
hash = (hash << 5) + hash + element.versionNonce;
}
return hash >>> 0; // Ensure unsigned 32-bit integer
};

View File

@ -20,7 +20,7 @@ import {
tupleToCoors,
} from "@excalidraw/common";
import type { Store } from "@excalidraw/excalidraw/store";
import type { Store } from "@excalidraw/element/store";
import type { Radians } from "@excalidraw/math";
@ -85,6 +85,7 @@ import type {
FixedPointBinding,
FixedSegment,
ExcalidrawElbowArrowElement,
PointsPositionUpdates,
} from "./types";
const editorMidPointsCache: {
@ -309,16 +310,22 @@ export class LinearElementEditor {
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
LinearElementEditor.movePoints(element, scene, [
{
index: selectedIndex,
point: pointFrom(
width + referencePoint[0],
height + referencePoint[1],
),
isDragging: selectedIndex === lastClickedPoint,
},
]);
LinearElementEditor.movePoints(
element,
scene,
new Map([
[
selectedIndex,
{
point: pointFrom(
width + referencePoint[0],
height + referencePoint[1],
),
isDragging: selectedIndex === lastClickedPoint,
},
],
]),
);
} else {
const newDraggingPointPosition = LinearElementEditor.createPointAt(
element,
@ -334,26 +341,32 @@ export class LinearElementEditor {
LinearElementEditor.movePoints(
element,
scene,
selectedPointsIndices.map((pointIndex) => {
const newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt(
element,
elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
)
: pointFrom(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
);
return {
index: pointIndex,
point: newPointPosition,
isDragging: pointIndex === lastClickedPoint,
};
}),
new Map(
selectedPointsIndices.map((pointIndex) => {
const newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt(
element,
elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD]
? null
: app.getEffectiveGridSize(),
)
: pointFrom(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
);
return [
pointIndex,
{
point: newPointPosition,
isDragging: pointIndex === lastClickedPoint,
},
];
}),
),
);
}
@ -466,15 +479,21 @@ export class LinearElementEditor {
},
);
}
LinearElementEditor.movePoints(element, scene, [
{
index: selectedPoint,
point:
selectedPoint === 0
? element.points[element.points.length - 1]
: element.points[0],
},
]);
LinearElementEditor.movePoints(
element,
scene,
new Map([
[
selectedPoint,
{
point:
selectedPoint === 0
? element.points[element.points.length - 1]
: element.points[0],
},
],
]),
);
}
const bindingElement = isBindingEnabled(appState)
@ -822,7 +841,7 @@ export class LinearElementEditor {
});
ret.didAddPoint = true;
}
store.shouldCaptureIncrement();
store.scheduleCapture();
ret.linearElementEditor = {
...linearElementEditor,
pointerDownState: {
@ -1001,12 +1020,18 @@ export class LinearElementEditor {
}
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints(element, app.scene, [
{
index: element.points.length - 1,
point: newPoint,
},
]);
LinearElementEditor.movePoints(
element,
app.scene,
new Map([
[
element.points.length - 1,
{
point: newPoint,
},
],
]),
);
} else {
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
}
@ -1240,12 +1265,16 @@ export class LinearElementEditor {
// potentially expanding the bounding box
if (pointAddedToEnd) {
const lastPoint = element.points[element.points.length - 1];
LinearElementEditor.movePoints(element, scene, [
{
index: element.points.length - 1,
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
},
]);
LinearElementEditor.movePoints(
element,
scene,
new Map([
[
element.points.length - 1,
{ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30) },
],
]),
);
}
return {
@ -1333,7 +1362,7 @@ export class LinearElementEditor {
static movePoints(
element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene,
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
pointUpdates: PointsPositionUpdates,
otherUpdates?: {
startBinding?: PointBinding | null;
endBinding?: PointBinding | null;
@ -1343,14 +1372,11 @@ export class LinearElementEditor {
// Handle loop lock behavior
if (isLineElement(element) && element.loopLock) {
const firstPointUpdate = targetPoints.find(({ index }) => index === 0);
const lastPointUpdate = targetPoints.find(
({ index }) => index === points.length - 1,
);
const firstPointUpdate = pointUpdates.get(0);
const lastPointUpdate = pointUpdates.get(points.length - 1);
if (firstPointUpdate) {
targetPoints.push({
index: points.length - 1,
pointUpdates.set(points.length - 1, {
point: pointFrom(
firstPointUpdate.point[0],
firstPointUpdate.point[1],
@ -1358,8 +1384,7 @@ export class LinearElementEditor {
isDragging: firstPointUpdate.isDragging,
});
} else if (lastPointUpdate) {
targetPoints.push({
index: 0,
pointUpdates.set(0, {
point: pointFrom(lastPointUpdate.point[0], lastPointUpdate.point[1]),
isDragging: lastPointUpdate.isDragging,
});
@ -1372,8 +1397,7 @@ export class LinearElementEditor {
// offset it. We do the same with actual element.x/y position, so
// this hacks are completely transparent to the user.
const [deltaX, deltaY] =
targetPoints.find(({ index }) => index === 0)?.point ??
pointFrom<LocalPoint>(0, 0);
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
const [offsetX, offsetY] = pointFrom<LocalPoint>(
deltaX - points[0][0],
deltaY - points[0][1],
@ -1381,12 +1405,12 @@ export class LinearElementEditor {
const nextPoints = isElbowArrow(element)
? [
targetPoints.find((t) => t.index === 0)?.point ?? points[0],
targetPoints.find((t) => t.index === points.length - 1)?.point ??
pointUpdates.get(0)?.point ?? points[0],
pointUpdates.get(points.length - 1)?.point ??
points[points.length - 1],
]
: points.map((p, idx) => {
const current = targetPoints.find((t) => t.index === idx)?.point ?? p;
const current = pointUpdates.get(idx)?.point ?? p;
return pointFrom<LocalPoint>(
current[0] - offsetX,
@ -1402,11 +1426,7 @@ export class LinearElementEditor {
offsetY,
otherUpdates,
{
isDragging: targetPoints.reduce(
(dragging, targetPoint): boolean =>
dragging || targetPoint.isDragging === true,
false,
),
isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging),
},
);
}

View File

@ -962,11 +962,6 @@ export const resizeSingleElement = (
isDragging: false,
});
updateBoundElements(latestElement, scene, {
// TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight },
});
if (boundTextElement && boundTextFont != null) {
scene.mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
@ -978,6 +973,11 @@ export const resizeSingleElement = (
handleDirection,
shouldMaintainAspectRatio,
);
updateBoundElements(latestElement, scene, {
// TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight },
});
}
};

View File

@ -0,0 +1,968 @@
import {
assertNever,
COLOR_PALETTE,
isDevEnv,
isTestEnv,
randomId,
Emitter,
toIterable,
} from "@excalidraw/common";
import type App from "@excalidraw/excalidraw/components/App";
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 { ElementsDelta, AppStateDelta, Delta } from "./delta";
import { hashElementsVersion, hashString } from "./index";
import type { 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 {
// internally used by history
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
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;
}
constructor(private readonly app: App) {}
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(
params:
| {
action: CaptureUpdateActionType;
elements: SceneElementsMap | undefined;
appState: AppState | ObservedAppState | undefined;
}
| {
action: typeof CaptureUpdateAction.IMMEDIATELY;
change: StoreChange;
delta: StoreDelta;
}
| {
action:
| typeof CaptureUpdateAction.NEVER
| typeof CaptureUpdateAction.EVENTUALLY;
change: StoreChange;
},
) {
const { action } = params;
let change: StoreChange;
if ("change" in params) {
change = params.change;
} else {
// immediately create an immutable change of the scheduled updates,
// compared to the current state, so that they won't mutate later on during batching
const currentSnapshot = StoreSnapshot.create(
this.app.scene.getElementsMapIncludingDeleted(),
this.app.state,
);
const scheduledSnapshot = currentSnapshot.maybeClone(
action,
params.elements,
params.appState,
);
change = StoreChange.create(currentSnapshot, scheduledSnapshot);
}
const delta = "delta" in params ? params.delta : undefined;
this.scheduledMicroActions.push(() =>
this.processAction({
action,
change,
delta,
}),
);
}
/**
* Performs the incoming `CaptureUpdateAction` and emits the corresponding `StoreIncrement`.
* Emits `DurableStoreIncrement` when action is "capture", emits `EphemeralStoreIncrement` otherwise.
*
* @emits StoreIncrement
*/
public commit(
elements: SceneElementsMap | undefined,
appState: AppState | ObservedAppState | undefined,
): void {
// execute all scheduled micro actions first
// similar to microTasks, there can be many
this.flushMicroActions();
try {
// execute a single scheduled "macro" function
// similar to macro tasks, there can be only one within a single commit (loop)
const action = this.getScheduledMacroAction();
this.processAction({ action, elements, appState });
} finally {
this.satisfiesScheduledActionsInvariant();
// defensively reset all scheduled "macro" actions, possibly cleans up other runtime garbage
this.scheduledMacroActions = new Set();
}
}
/**
* Clears the store instance.
*/
public clear(): void {
this.snapshot = StoreSnapshot.empty();
this.scheduledMacroActions = new Set();
}
/**
* Performs delta & change calculation and emits a durable increment.
*
* @emits StoreIncrement.
*/
private emitDurableIncrement(
snapshot: StoreSnapshot,
change: StoreChange | undefined = undefined,
delta: StoreDelta | undefined = undefined,
) {
const prevSnapshot = this.snapshot;
let storeChange: StoreChange;
let storeDelta: StoreDelta;
if (change) {
storeChange = change;
} else {
storeChange = StoreChange.create(prevSnapshot, snapshot);
}
if (delta) {
// we might have the delta already (i.e. when applying history entry), thus we don't need to calculate it again
// using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again
storeDelta = delta;
} else {
// calculate the deltas based on the previous and next snapshot
const elementsDelta = snapshot.metadata.didElementsChange
? ElementsDelta.calculate(prevSnapshot.elements, snapshot.elements)
: ElementsDelta.empty();
const appStateDelta = snapshot.metadata.didAppStateChange
? AppStateDelta.calculate(prevSnapshot.appState, snapshot.appState)
: AppStateDelta.empty();
storeDelta = StoreDelta.create(elementsDelta, appStateDelta);
}
if (!storeDelta.isEmpty()) {
const increment = new DurableIncrement(storeChange, storeDelta);
// Notify listeners with the increment
this.onDurableIncrementEmitter.trigger(increment);
this.onStoreIncrementEmitter.trigger(increment);
}
}
/**
* Performs change calculation and emits an ephemeral increment.
*
* @emits EphemeralStoreIncrement
*/
private emitEphemeralIncrement(
snapshot: StoreSnapshot,
change: StoreChange | undefined = undefined,
) {
let storeChange: StoreChange;
if (change) {
storeChange = change;
} else {
const prevSnapshot = this.snapshot;
storeChange = StoreChange.create(prevSnapshot, snapshot);
}
const increment = new EphemeralIncrement(storeChange);
// Notify listeners with the increment
this.onStoreIncrementEmitter.trigger(increment);
}
private applyChangeToSnapshot(change: StoreChange) {
const prevSnapshot = this.snapshot;
const nextSnapshot = this.snapshot.applyChange(change);
if (prevSnapshot === nextSnapshot) {
return null;
}
return nextSnapshot;
}
/**
* Clones the snapshot if there are changes detected.
*/
private maybeCloneSnapshot(
action: CaptureUpdateActionType,
elements: SceneElementsMap | 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() {
for (const microAction of this.scheduledMicroActions) {
try {
microAction();
} catch (error) {
console.error(`Failed to execute scheduled micro action`, error);
}
}
this.scheduledMicroActions = [];
}
private processAction(
params:
| {
action: CaptureUpdateActionType;
elements: SceneElementsMap | undefined;
appState: AppState | ObservedAppState | undefined;
}
| {
action: CaptureUpdateActionType;
change: StoreChange;
delta: StoreDelta | undefined;
},
) {
const { action } = params;
// perf. optimisation, since "EVENTUALLY" does not update the snapshot,
// so if nobody is listening for increments, we don't need to even clone the snapshot
// as it's only needed for `StoreChange` computation inside `EphemeralIncrement`
if (
action === CaptureUpdateAction.EVENTUALLY &&
!this.onStoreIncrementEmitter.subscribers.length
) {
return;
}
let nextSnapshot: StoreSnapshot | null;
if ("change" in params) {
nextSnapshot = this.applyChangeToSnapshot(params.change);
} else {
nextSnapshot = this.maybeCloneSnapshot(
action,
params.elements,
params.appState,
);
}
if (!nextSnapshot) {
// don't continue if there is not change detected
return;
}
const change = "change" in params ? params.change : undefined;
const delta = "delta" in params ? params.delta : undefined;
try {
switch (action) {
// only immediately emits a durable increment
case CaptureUpdateAction.IMMEDIATELY:
this.emitDurableIncrement(nextSnapshot, change, delta);
break;
// both never and eventually emit an ephemeral increment
case CaptureUpdateAction.NEVER:
case CaptureUpdateAction.EVENTUALLY:
this.emitEphemeralIncrement(nextSnapshot, change);
break;
default:
assertNever(action, `Unknown store action`);
}
} finally {
// update the snapshot 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 = nextSnapshot;
break;
}
}
}
/**
* 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
// TODO: we might need to have binary files here as well, in order to be drop-in replacement for `onChange`
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,
},
);
}
/**
* Apply the delta to the passed elements and appState, does not modify the snapshot.
*/
public static applyTo(
delta: StoreDelta,
elements: SceneElementsMap,
appState: AppState,
prevSnapshot: StoreSnapshot = StoreSnapshot.empty(),
): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
elements,
prevSnapshot.elements,
);
const [nextAppState, appStateContainsVisibleChange] =
delta.appState.applyTo(appState, nextElements);
const appliedVisibleChanges =
elementsContainVisibleChange || appStateContainsVisibleChange;
return [nextElements, nextAppState, appliedVisibleChanges];
}
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: SceneElementsMap,
public readonly appState: ObservedAppState,
public readonly metadata: {
didElementsChange: boolean;
didAppStateChange: boolean;
isEmpty?: boolean;
} = {
didElementsChange: false,
didAppStateChange: false,
isEmpty: false,
},
) {}
public static create(
elements: SceneElementsMap,
appState: AppState | ObservedAppState,
metadata: {
didElementsChange: boolean;
didAppStateChange: boolean;
} = {
didElementsChange: false,
didAppStateChange: false,
},
) {
return new StoreSnapshot(
elements,
isObservedAppState(appState) ? appState : getObservedAppState(appState),
metadata,
);
}
public static empty() {
return new StoreSnapshot(
new Map() as SceneElementsMap,
getDefaultObservedAppState(),
{
didElementsChange: false,
didAppStateChange: false,
isEmpty: true,
},
);
}
public getChangedElements(prevSnapshot: StoreSnapshot) {
const changedElements: Record<string, OrderedExcalidrawElement> = {};
for (const prevElement of toIterable(prevSnapshot.elements)) {
const nextElement = this.elements.get(prevElement.id);
if (!nextElement) {
changedElements[prevElement.id] = newElementWith(prevElement, {
isDeleted: true,
});
}
}
for (const nextElement of toIterable(this.elements)) {
// Due to the structural clone inside `maybeClone`, we can perform just these reference checks
if (prevSnapshot.elements.get(nextElement.id) !== nextElement) {
changedElements[nextElement.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;
}
/**
* Apply the change and return a new snapshot instance.
*/
public applyChange(change: StoreChange): StoreSnapshot {
const nextElements = new Map(this.elements) as SceneElementsMap;
for (const [id, changedElement] of Object.entries(change.elements)) {
nextElements.set(id, changedElement);
}
const nextAppState = Object.assign(
{},
this.appState,
change.appState,
) as ObservedAppState;
return StoreSnapshot.create(nextElements, nextAppState, {
// by default we assume that change is different from what we have in the snapshot
// so that we trigger the delta calculation and if it isn't different, delta will be empty
didElementsChange: Object.keys(change.elements).length > 0,
didAppStateChange: Object.keys(change.appState).length > 0,
});
}
/**
* 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: SceneElementsMap | 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,
},
): ObservedAppState {
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,
options,
);
if (!didAppStateChange) {
return this.appState;
}
return nextAppStateSnapshot;
}
private maybeCreateElementsSnapshot(
elements: SceneElementsMap | undefined,
options: {
shouldCompareHashes: boolean;
} = {
shouldCompareHashes: false,
},
): SceneElementsMap {
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,
},
): boolean | undefined {
if (this.appState === nextObservedAppState) {
return;
}
const didAppStateChange = Delta.isRightDifferent(
this.appState,
nextObservedAppState,
);
if (!didAppStateChange) {
return;
}
const changedAppStateHash = hashString(
JSON.stringify(nextObservedAppState),
);
if (
options.shouldCompareHashes &&
this._lastChangedAppStateHash === changedAppStateHash
) {
return;
}
this._lastChangedAppStateHash = changedAppStateHash;
return didAppStateChange;
}
/**
* Detect if there any changed elements.
*/
private detectChangedElements(
nextElements: SceneElementsMap,
options: {
shouldCompareHashes: boolean;
} = {
shouldCompareHashes: false,
},
): SceneElementsMap | undefined {
if (this.elements === nextElements) {
return;
}
const changedElements: SceneElementsMap = new Map() as SceneElementsMap;
for (const prevElement of toIterable(this.elements)) {
const nextElement = nextElements.get(prevElement.id);
if (!nextElement) {
// element was deleted
changedElements.set(
prevElement.id,
newElementWith(prevElement, { isDeleted: true }),
);
}
}
for (const nextElement of toIterable(nextElements)) {
const prevElement = this.elements.get(nextElement.id);
if (
!prevElement || // element was added
prevElement.version < nextElement.version // element was updated
) {
changedElements.set(nextElement.id, nextElement);
}
}
if (!changedElements.size) {
return;
}
const changedElementsHash = hashElementsVersion(changedElements);
if (
options.shouldCompareHashes &&
this._lastChangedElementsHash === changedElementsHash
) {
return;
}
this._lastChangedElementsHash = changedElementsHash;
return changedElements;
}
/**
* Perform structural clone, deep cloning only elements that changed.
*/
private createElementsSnapshot(changedElements: SceneElementsMap) {
const clonedElements = new Map() as SceneElementsMap;
for (const prevElement of toIterable(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(prevElement.id, prevElement);
}
for (const changedElement of toIterable(changedElements)) {
// TODO: consider just creating new instance, once we can ensure that all reference properties on every element are immutable
// TODO: consider creating a lazy deep clone, having a one-time-usage proxy over the snapshotted element and deep cloning only if it gets mutated
clonedElements.set(changedElement.id, deepCopyElement(changedElement));
}
return clonedElements;
}
}
// hidden non-enumerable property for runtime checks
const hiddenObservedAppStateProp = "__observedAppState";
const getDefaultObservedAppState = (): ObservedAppState => {
return {
name: null,
editingGroupId: null,
viewBackgroundColor: COLOR_PALETTE.white,
selectedElementIds: {},
selectedGroupIds: {},
editingLinearElementId: null,
selectedLinearElementId: null,
croppingElementId: null,
};
};
export const getObservedAppState = (appState: AppState): ObservedAppState => {
const observedAppState = {
name: appState.name,
editingGroupId: appState.editingGroupId,
viewBackgroundColor: appState.viewBackgroundColor,
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
editingLinearElementId: appState.editingLinearElement?.elementId || null,
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
croppingElementId: appState.croppingElementId,
};
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
value: true,
enumerable: false,
});
return observedAppState;
};
const isObservedAppState = (
appState: AppState | ObservedAppState,
): appState is ObservedAppState =>
!!Reflect.get(appState, hiddenObservedAppStateProp);

View File

@ -296,6 +296,11 @@ export type FixedPointBinding = Merge<
}
>;
export type PointsPositionUpdates = Map<
number,
{ point: LocalPoint; isDragging?: boolean }
>;
export type Arrowhead =
| "arrow"
| "bar"

View File

@ -0,0 +1,143 @@
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
import type { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { AppStateDelta } from "../src/delta";
describe("AppStateDelta", () => {
describe("ensure stable delta properties order", () => {
it("should maintain stable order for root properties", () => {
const name = "untitled scene";
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
const commonAppState = {
viewBackgroundColor: "#ffffff",
selectedElementIds: {},
selectedGroupIds: {},
editingGroupId: null,
croppingElementId: null,
editingLinearElementId: null,
};
const prevAppState1: ObservedAppState = {
...commonAppState,
name: "",
selectedLinearElementId: null,
};
const nextAppState1: ObservedAppState = {
...commonAppState,
name,
selectedLinearElementId,
};
const prevAppState2: ObservedAppState = {
selectedLinearElementId: null,
name: "",
...commonAppState,
};
const nextAppState2: ObservedAppState = {
selectedLinearElementId,
name,
...commonAppState,
};
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
it("should maintain stable order for selectedElementIds", () => {
const commonAppState = {
name: "",
viewBackgroundColor: "#ffffff",
selectedGroupIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElementId: null,
editingLinearElementId: null,
};
const prevAppState1: ObservedAppState = {
...commonAppState,
selectedElementIds: { id5: true, id2: true, id4: true },
};
const nextAppState1: ObservedAppState = {
...commonAppState,
selectedElementIds: {
id1: true,
id2: true,
id3: true,
},
};
const prevAppState2: ObservedAppState = {
...commonAppState,
selectedElementIds: { id4: true, id2: true, id5: true },
};
const nextAppState2: ObservedAppState = {
...commonAppState,
selectedElementIds: {
id3: true,
id2: true,
id1: true,
},
};
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
it("should maintain stable order for selectedGroupIds", () => {
const commonAppState = {
name: "",
viewBackgroundColor: "#ffffff",
selectedElementIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElementId: null,
editingLinearElementId: null,
};
const prevAppState1: ObservedAppState = {
...commonAppState,
selectedGroupIds: { id5: false, id2: true, id4: true, id0: true },
};
const nextAppState1: ObservedAppState = {
...commonAppState,
selectedGroupIds: {
id0: true,
id1: true,
id2: false,
id3: true,
},
};
const prevAppState2: ObservedAppState = {
...commonAppState,
selectedGroupIds: { id0: true, id4: true, id2: true, id5: false },
};
const nextAppState2: ObservedAppState = {
...commonAppState,
selectedGroupIds: {
id3: true,
id2: false,
id1: true,
id0: true,
},
};
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,8 @@ import {
} from "@excalidraw/element/typeChecks";
import { arrayToMap } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type {
ExcalidrawLinearElement,
ExcalidrawLineElement,
@ -15,7 +17,6 @@ import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"
import { ToolButton } from "../components/ToolButton";
import { lineEditorIcon, polygonIcon } from "../components/icons";
import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { ButtonIcon } from "../components/ButtonIcon";

View File

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

View File

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

View File

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

View File

@ -56,6 +56,8 @@ import { hasStrokeColor } from "@excalidraw/element/comparisons";
import { updateElbowArrowPoints } from "@excalidraw/element/elbowArrow";
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { LocalPoint } from "@excalidraw/math";
import type {
@ -72,6 +74,8 @@ import type {
import type Scene from "@excalidraw/element/Scene";
import type { CaptureUpdateActionType } from "@excalidraw/element/store";
import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
@ -133,13 +137,11 @@ import {
getTargetElements,
isSomeElementSelected,
} from "../scene";
import { CaptureUpdateAction } from "../store";
import { toggleLinePolygonState } from "../../element/src/shapes";
import { register } from "./register";
import type { CaptureUpdateActionType } from "../store";
import type { AppClassProperties, AppState, Primitive } from "../types";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
import { CaptureUpdateAction } from "@excalidraw/element/store";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import {
@ -5,7 +7,6 @@ import {
convertElementTypePopupAtom,
} from "../components/ConvertElementTypePopup";
import { editorJotaiStore } from "../editor-jotai";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -101,6 +101,7 @@ import {
type EXPORT_IMAGE_TYPES,
randomInteger,
CLASSES,
Emitter,
} from "@excalidraw/common";
import {
@ -303,6 +304,8 @@ import { isNonDeletedElement } from "@excalidraw/element";
import Scene from "@excalidraw/element/Scene";
import { Store, CaptureUpdateAction } from "@excalidraw/element/store";
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
import type { LocalPoint, Radians } from "@excalidraw/math";
@ -331,6 +334,7 @@ import type {
ExcalidrawNonSelectionElement,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
SceneElementsMap,
} from "@excalidraw/element/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
@ -454,9 +458,7 @@ import {
resetCursor,
setCursorForShape,
} from "../cursor";
import { Emitter } from "../emitter";
import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
import { Store, CaptureUpdateAction } from "../store";
import { LaserTrails } from "../laser-trails";
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
@ -761,8 +763,8 @@ class App extends React.Component<AppProps, AppState> {
this.renderer = new Renderer(this.scene);
this.visibleElements = [];
this.store = new Store();
this.history = new History();
this.store = new Store(this);
this.history = new History(this.store);
if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = {
@ -792,6 +794,7 @@ class App extends React.Component<AppProps, AppState> {
updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb),
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
@ -810,15 +813,11 @@ class App extends React.Component<AppProps, AppState> {
};
this.fonts = new Fonts(this.scene);
this.history = new History();
this.history = new History(this.store);
this.actionManager.registerAll(actions);
this.actionManager.registerAction(
createUndoAction(this.history, this.store),
);
this.actionManager.registerAction(
createRedoAction(this.history, this.store),
);
this.actionManager.registerAction(createUndoAction(this.history));
this.actionManager.registerAction(createRedoAction(this.history));
}
updateEditorAtom = <Value, Args extends unknown[], Result>(
@ -1899,6 +1898,10 @@ class App extends React.Component<AppProps, AppState> {
return this.scene.getElementsIncludingDeleted();
};
public getSceneElementsMapIncludingDeleted = () => {
return this.scene.getElementsMapIncludingDeleted();
};
public getSceneElements = () => {
return this.scene.getNonDeletedElements();
};
@ -2215,11 +2218,7 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (actionResult.captureUpdate === CaptureUpdateAction.NEVER) {
this.store.shouldUpdateSnapshot();
} else if (actionResult.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
this.store.shouldCaptureIncrement();
}
this.store.scheduleAction(actionResult.captureUpdate);
let didUpdate = false;
@ -2292,10 +2291,7 @@ class App extends React.Component<AppProps, AppState> {
didUpdate = true;
}
if (
!didUpdate &&
actionResult.captureUpdate !== CaptureUpdateAction.EVENTUALLY
) {
if (!didUpdate) {
this.scene.triggerUpdate();
}
});
@ -2547,10 +2543,19 @@ class App extends React.Component<AppProps, AppState> {
});
}
this.store.onStoreIncrementEmitter.on((increment) => {
this.history.record(increment.elementsChange, increment.appStateChange);
this.store.onDurableIncrementEmitter.on((increment) => {
this.history.record(increment.delta);
});
const { onIncrement } = this.props;
// per. optimmisation, only subscribe if there is the `onIncrement` prop registered, to avoid unnecessary computation
if (onIncrement) {
this.store.onStoreIncrementEmitter.on((increment) => {
onIncrement(increment);
});
}
this.scene.onUpdate(this.triggerRender);
this.addEventListeners();
@ -2610,6 +2615,7 @@ class App extends React.Component<AppProps, AppState> {
this.eraserTrail.stop();
this.onChangeEmitter.clear();
this.store.onStoreIncrementEmitter.clear();
this.store.onDurableIncrementEmitter.clear();
ShapeCache.destroy();
SnapCache.destroy();
clearTimeout(touchTimeout);
@ -2903,7 +2909,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement &&
!this.state.selectedElementIds[this.state.editingLinearElement.elementId]
) {
// defer so that the shouldCaptureIncrement flag isn't reset via current update
// defer so that the scheduleCapture flag isn't reset via current update
setTimeout(() => {
// execute only if the condition still holds when the deferred callback
// executes (it can be scheduled multiple times depending on how
@ -3358,7 +3364,7 @@ class App extends React.Component<AppProps, AppState> {
this.addMissingFiles(opts.files);
}
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
const nextElementsToSelect =
excludeElementsInFramesFromSelection(duplicatedElements);
@ -3619,7 +3625,7 @@ class App extends React.Component<AppProps, AppState> {
PLAIN_PASTE_TOAST_SHOWN = true;
}
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
setAppState: React.Component<any, AppState>["setState"] = (
@ -3975,51 +3981,37 @@ class App extends React.Component<AppProps, AppState> {
*/
captureUpdate?: SceneData["captureUpdate"];
}) => {
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
const { elements, appState, collaborators, captureUpdate } = sceneData;
if (
sceneData.captureUpdate &&
sceneData.captureUpdate !== CaptureUpdateAction.EVENTUALLY
) {
const prevCommittedAppState = this.store.snapshot.appState;
const prevCommittedElements = this.store.snapshot.elements;
const nextElements = elements ? syncInvalidIndices(elements) : undefined;
const nextCommittedAppState = sceneData.appState
? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
: prevCommittedAppState;
if (captureUpdate) {
const nextElementsMap = elements
? (arrayToMap(nextElements ?? []) as SceneElementsMap)
: undefined;
const nextCommittedElements = sceneData.elements
? this.store.filterUncomittedElements(
this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements
arrayToMap(nextElements), // We expect all (already reconciled) elements
)
: prevCommittedElements;
const nextAppState = appState
? // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
Object.assign({}, this.store.snapshot.appState, appState)
: undefined;
// WARN: store action always performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter
// do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well
if (sceneData.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
this.store.captureIncrement(
nextCommittedElements,
nextCommittedAppState,
);
} else if (sceneData.captureUpdate === CaptureUpdateAction.NEVER) {
this.store.updateSnapshot(
nextCommittedElements,
nextCommittedAppState,
);
}
this.store.scheduleMicroAction({
action: captureUpdate,
elements: nextElementsMap,
appState: nextAppState,
});
}
if (sceneData.appState) {
this.setState(sceneData.appState);
if (appState) {
this.setState(appState);
}
if (sceneData.elements) {
if (nextElements) {
this.scene.replaceAllElements(nextElements);
}
if (sceneData.collaborators) {
this.setState({ collaborators: sceneData.collaborators });
if (collaborators) {
this.setState({ collaborators });
}
},
);
@ -4202,7 +4194,7 @@ class App extends React.Component<AppProps, AppState> {
direction: event.shiftKey ? "left" : "right",
})
) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
}
if (conversionType) {
@ -4519,7 +4511,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement.elementId !==
selectedElements[0].id
) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
if (!isElbowArrow(selectedElement)) {
this.setState({
editingLinearElement: new LinearElementEditor(
@ -4845,7 +4837,7 @@ class App extends React.Component<AppProps, AppState> {
} as const;
if (nextActiveTool.type === "freedraw") {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
if (nextActiveTool.type === "lasso") {
@ -5062,7 +5054,7 @@ class App extends React.Component<AppProps, AppState> {
]);
}
if (!isDeleted || isExistingElement) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
flushSync(() => {
@ -5475,7 +5467,7 @@ class App extends React.Component<AppProps, AppState> {
};
private startImageCropping = (image: ExcalidrawImageElement) => {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
this.setState({
croppingElementId: image.id,
});
@ -5483,7 +5475,7 @@ class App extends React.Component<AppProps, AppState> {
private finishImageCropping = () => {
if (this.state.croppingElementId) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
this.setState({
croppingElementId: null,
});
@ -5518,7 +5510,7 @@ class App extends React.Component<AppProps, AppState> {
selectedElements[0].id) &&
!isElbowArrow(selectedElements[0])
) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
this.setState({
editingLinearElement: new LinearElementEditor(
selectedElements[0],
@ -5546,7 +5538,7 @@ class App extends React.Component<AppProps, AppState> {
: -1;
if (midPoint && midPoint > -1) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
LinearElementEditor.deleteFixedSegment(
selectedElements[0],
this.scene,
@ -5608,7 +5600,7 @@ class App extends React.Component<AppProps, AppState> {
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
if (selectedGroupId) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
this.setState((prevState) => ({
...prevState,
...selectGroupsForSelectedElements(
@ -9131,7 +9123,7 @@ class App extends React.Component<AppProps, AppState> {
if (isLinearElement(newElement)) {
if (newElement!.points.length > 1) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
const pointerCoords = viewportCoordsToSceneCoords(
childEvent,
@ -9404,7 +9396,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (resizingElement) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
@ -9744,7 +9736,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedElementIds,
)
) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
}
if (
@ -9837,7 +9829,7 @@ class App extends React.Component<AppProps, AppState> {
this.elementsPendingErasure = new Set();
if (didChange) {
this.store.shouldCaptureIncrement();
this.store.scheduleCapture();
this.scene.replaceAllElements(elements);
}
};
@ -10517,8 +10509,13 @@ class App extends React.Component<AppProps, AppState> {
// restore the fractional indices by mutating elements
syncInvalidIndices(elements.concat(ret.data.elements));
// update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo
this.store.updateSnapshot(arrayToMap(elements), this.state);
// don't capture and only update the store snapshot for old elements,
// otherwise we would end up with duplicated fractional indices on undo
this.store.scheduleMicroAction({
action: CaptureUpdateAction.NEVER,
elements: arrayToMap(elements) as SceneElementsMap,
appState: undefined,
});
this.setState({ isLoading: true });
this.syncActionResult({

View File

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

View File

@ -215,23 +215,6 @@ export const moveElement = (
updateBindings(latestChildElement, scene, {
simultaneouslyUpdated: originalChildren,
});
const boundTextElement = getBoundTextElement(
latestChildElement,
originalElementsMap,
);
if (boundTextElement) {
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
latestBoundTextElement &&
scene.mutateElement(
latestBoundTextElement,
{
x: boundTextElement.x + changeInX,
y: boundTextElement.y + changeInY,
},
{ informMutation: shouldInformMutation, isDragging: false },
);
}
});
}
};

View File

@ -34,6 +34,7 @@ export const TTDDialogInput = ({
callbackRef.current?.();
}
};
textarea.focus();
textarea.addEventListener(EVENT.KEYDOWN, handleKeyDown);
return () => {
textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
@ -47,7 +48,6 @@ export const TTDDialogInput = ({
onChange={onChange}
value={input}
placeholder={placeholder}
autoFocus
ref={ref}
/>
);

View File

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

View File

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

View File

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

View File

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

View File

@ -129,7 +129,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildPackage.js && yarn gen:types"
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildPackage.js && yarn gen:types"
}
}

View File

@ -1,7 +1,30 @@
import { THEME, THEME_FILTER } from "@excalidraw/common";
import { elementCenterPoint, THEME, THEME_FILTER } from "@excalidraw/common";
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element/binding";
import { getDiamondPoints } from "@excalidraw/element/bounds";
import { getCornerRadius } from "@excalidraw/element/shapes";
import {
bezierEquation,
curve,
curveTangent,
type GlobalPoint,
pointFrom,
pointFromVector,
pointRotateRads,
vector,
vectorNormal,
vectorNormalize,
vectorScale,
} from "@excalidraw/math";
import type {
ExcalidrawDiamondElement,
ExcalidrawRectanguloidElement,
} from "@excalidraw/element/types";
import type { StaticCanvasRenderConfig } from "../scene/types";
import type { StaticCanvasAppState, AppState } from "../types";
import type { AppState, StaticCanvasAppState } from "../types";
export const fillCircle = (
context: CanvasRenderingContext2D,
@ -75,3 +98,399 @@ export const bootstrapCanvas = ({
return context;
};
function drawCatmullRomQuadraticApprox(
ctx: CanvasRenderingContext2D,
points: GlobalPoint[],
segments = 20,
) {
ctx.lineTo(points[0][0], points[0][1]);
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1 < 0 ? 0 : i - 1];
const p1 = points[i];
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
for (let t = 0; t <= 1; t += 1 / segments) {
const t2 = t * t;
const x =
(1 - t) * (1 - t) * p0[0] + 2 * (1 - t) * t * p1[0] + t2 * p2[0];
const y =
(1 - t) * (1 - t) * p0[1] + 2 * (1 - t) * t * p1[1] + t2 * p2[1];
ctx.lineTo(x, y);
}
}
}
function drawCatmullRomCubicApprox(
ctx: CanvasRenderingContext2D,
points: GlobalPoint[],
segments = 20,
) {
ctx.lineTo(points[0][0], points[0][1]);
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1 < 0 ? 0 : i - 1];
const p1 = points[i];
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2];
for (let t = 0; t <= 1; t += 1 / segments) {
const t2 = t * t;
const t3 = t2 * t;
const x =
0.5 *
(2 * p1[0] +
(-p0[0] + p2[0]) * t +
(2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 +
(-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3);
const y =
0.5 *
(2 * p1[1] +
(-p0[1] + p2[1]) * t +
(2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 +
(-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3);
ctx.lineTo(x, y);
}
}
}
export const drawHighlightForRectWithRotation = (
context: CanvasRenderingContext2D,
element: ExcalidrawRectanguloidElement,
padding: number,
) => {
const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element),
element.angle,
);
context.save();
context.translate(x, y);
context.rotate(element.angle);
let radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
if (radius === 0) {
radius = 0.01;
}
context.beginPath();
{
const topLeftApprox = offsetQuadraticBezier(
pointFrom(0, 0 + radius),
pointFrom(0, 0),
pointFrom(0 + radius, 0),
padding,
);
const topRightApprox = offsetQuadraticBezier(
pointFrom(element.width - radius, 0),
pointFrom(element.width, 0),
pointFrom(element.width, radius),
padding,
);
const bottomRightApprox = offsetQuadraticBezier(
pointFrom(element.width, element.height - radius),
pointFrom(element.width, element.height),
pointFrom(element.width - radius, element.height),
padding,
);
const bottomLeftApprox = offsetQuadraticBezier(
pointFrom(radius, element.height),
pointFrom(0, element.height),
pointFrom(0, element.height - radius),
padding,
);
context.moveTo(
topLeftApprox[topLeftApprox.length - 1][0],
topLeftApprox[topLeftApprox.length - 1][1],
);
context.lineTo(topRightApprox[0][0], topRightApprox[0][1]);
drawCatmullRomQuadraticApprox(context, topRightApprox);
context.lineTo(bottomRightApprox[0][0], bottomRightApprox[0][1]);
drawCatmullRomQuadraticApprox(context, bottomRightApprox);
context.lineTo(bottomLeftApprox[0][0], bottomLeftApprox[0][1]);
drawCatmullRomQuadraticApprox(context, bottomLeftApprox);
context.lineTo(topLeftApprox[0][0], topLeftApprox[0][1]);
drawCatmullRomQuadraticApprox(context, topLeftApprox);
}
// Counter-clockwise for the cutout in the middle. We need to have an "inverse
// mask" on a filled shape for the diamond highlight, because stroking creates
// sharp inset edges on line joins < 90 degrees.
{
const topLeftApprox = offsetQuadraticBezier(
pointFrom(0 + radius, 0),
pointFrom(0, 0),
pointFrom(0, 0 + radius),
-FIXED_BINDING_DISTANCE,
);
const topRightApprox = offsetQuadraticBezier(
pointFrom(element.width, radius),
pointFrom(element.width, 0),
pointFrom(element.width - radius, 0),
-FIXED_BINDING_DISTANCE,
);
const bottomRightApprox = offsetQuadraticBezier(
pointFrom(element.width - radius, element.height),
pointFrom(element.width, element.height),
pointFrom(element.width, element.height - radius),
-FIXED_BINDING_DISTANCE,
);
const bottomLeftApprox = offsetQuadraticBezier(
pointFrom(0, element.height - radius),
pointFrom(0, element.height),
pointFrom(radius, element.height),
-FIXED_BINDING_DISTANCE,
);
context.moveTo(
topLeftApprox[topLeftApprox.length - 1][0],
topLeftApprox[topLeftApprox.length - 1][1],
);
context.lineTo(bottomLeftApprox[0][0], bottomLeftApprox[0][1]);
drawCatmullRomQuadraticApprox(context, bottomLeftApprox);
context.lineTo(bottomRightApprox[0][0], bottomRightApprox[0][1]);
drawCatmullRomQuadraticApprox(context, bottomRightApprox);
context.lineTo(topRightApprox[0][0], topRightApprox[0][1]);
drawCatmullRomQuadraticApprox(context, topRightApprox);
context.lineTo(topLeftApprox[0][0], topLeftApprox[0][1]);
drawCatmullRomQuadraticApprox(context, topLeftApprox);
}
context.closePath();
context.fill();
context.restore();
};
export const strokeEllipseWithRotation = (
context: CanvasRenderingContext2D,
width: number,
height: number,
cx: number,
cy: number,
angle: number,
) => {
context.beginPath();
context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2);
context.stroke();
};
export const strokeRectWithRotation = (
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
cx: number,
cy: number,
angle: number,
fill: boolean = false,
/** should account for zoom */
radius: number = 0,
) => {
context.save();
context.translate(cx, cy);
context.rotate(angle);
if (fill) {
context.fillRect(x - cx, y - cy, width, height);
}
if (radius && context.roundRect) {
context.beginPath();
context.roundRect(x - cx, y - cy, width, height, radius);
context.stroke();
context.closePath();
} else {
context.strokeRect(x - cx, y - cy, width, height);
}
context.restore();
};
export const drawHighlightForDiamondWithRotation = (
context: CanvasRenderingContext2D,
padding: number,
element: ExcalidrawDiamondElement,
) => {
const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element),
element.angle,
);
context.save();
context.translate(x, y);
context.rotate(element.angle);
{
context.beginPath();
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
? getCornerRadius(Math.abs(topX - leftX), element)
: (topX - leftX) * 0.01;
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const topApprox = offsetCubicBezier(
pointFrom(topX - verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY),
pointFrom(topX, topY),
pointFrom(topX + verticalRadius, topY + horizontalRadius),
padding,
);
const rightApprox = offsetCubicBezier(
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
pointFrom(rightX, rightY),
pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
padding,
);
const bottomApprox = offsetCubicBezier(
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY),
pointFrom(bottomX, bottomY),
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
padding,
);
const leftApprox = offsetCubicBezier(
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
pointFrom(leftX, leftY),
pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
padding,
);
context.moveTo(
topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1],
);
context.lineTo(rightApprox[0][0], rightApprox[0][1]);
drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(leftApprox[0][0], leftApprox[0][1]);
drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(topApprox[0][0], topApprox[0][1]);
drawCatmullRomCubicApprox(context, topApprox);
}
// Counter-clockwise for the cutout in the middle. We need to have an "inverse
// mask" on a filled shape for the diamond highlight, because stroking creates
// sharp inset edges on line joins < 90 degrees.
{
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
? getCornerRadius(Math.abs(topX - leftX), element)
: (topX - leftX) * 0.01;
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const topApprox = offsetCubicBezier(
pointFrom(topX + verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY),
pointFrom(topX, topY),
pointFrom(topX - verticalRadius, topY + horizontalRadius),
-FIXED_BINDING_DISTANCE,
);
const rightApprox = offsetCubicBezier(
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
pointFrom(rightX, rightY),
pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
-FIXED_BINDING_DISTANCE,
);
const bottomApprox = offsetCubicBezier(
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY),
pointFrom(bottomX, bottomY),
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
-FIXED_BINDING_DISTANCE,
);
const leftApprox = offsetCubicBezier(
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
pointFrom(leftX, leftY),
pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
-FIXED_BINDING_DISTANCE,
);
context.moveTo(
topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1],
);
context.lineTo(leftApprox[0][0], leftApprox[0][1]);
drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(rightApprox[0][0], rightApprox[0][1]);
drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(topApprox[0][0], topApprox[0][1]);
drawCatmullRomCubicApprox(context, topApprox);
}
context.closePath();
context.fill();
context.restore();
};
function offsetCubicBezier(
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
p3: GlobalPoint,
offsetDist: number,
steps = 20,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const c = curve(p0, p1, p2, p3);
const point = bezierEquation(c, t);
const tangent = vectorNormalize(curveTangent(c, t));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
}
return offsetPoints;
}
function offsetQuadraticBezier(
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
offsetDist: number,
steps = 20,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const t1 = 1 - t;
const point = pointFrom<GlobalPoint>(
t1 * t1 * p0[0] + 2 * t1 * t * p1[0] + t * t * p2[0],
t1 * t1 * p0[1] + 2 * t1 * t * p1[1] + t * t * p2[1],
);
const tangentX = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0]);
const tangentY = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1]);
const tangent = vectorNormalize(vector(tangentX, tangentY));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
}
return offsetPoints;
}

View File

@ -1,4 +1,3 @@
import oc from "open-color";
import {
pointFrom,
pointsEqual,
@ -6,19 +5,19 @@ import {
type LocalPoint,
type Radians,
} from "@excalidraw/math";
import oc from "open-color";
import {
arrayToMap,
DEFAULT_TRANSFORM_HANDLE_SPACING,
FRAME_STYLE,
THEME,
arrayToMap,
invariant,
THEME,
throttleRAF,
} from "@excalidraw/common";
import {
BINDING_HIGHLIGHT_OFFSET,
BINDING_HIGHLIGHT_THICKNESS,
FIXED_BINDING_DISTANCE,
maxBindingGap,
} from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
@ -37,14 +36,12 @@ import {
isTextElement,
} from "@excalidraw/element/typeChecks";
import { getCornerRadius } from "@excalidraw/element/shapes";
import { renderSelectionElement } from "@excalidraw/element/renderElement";
import {
isSelectedViaGroup,
getSelectedGroupIds,
getElementsInGroup,
getSelectedGroupIds,
isSelectedViaGroup,
selectGroupsFromGivenElements,
} from "@excalidraw/element/groups";
@ -88,8 +85,12 @@ import { getClientColor, renderRemoteCursors } from "../clients";
import {
bootstrapCanvas,
drawHighlightForDiamondWithRotation,
drawHighlightForRectWithRotation,
fillCircle,
getNormalizedCanvasDimensions,
strokeEllipseWithRotation,
strokeRectWithRotation,
} from "./helpers";
import type {
@ -162,57 +163,6 @@ const highlightPoint = <Point extends LocalPoint | GlobalPoint>(
);
};
const strokeRectWithRotation = (
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
cx: number,
cy: number,
angle: number,
fill: boolean = false,
/** should account for zoom */
radius: number = 0,
) => {
context.save();
context.translate(cx, cy);
context.rotate(angle);
if (fill) {
context.fillRect(x - cx, y - cy, width, height);
}
if (radius && context.roundRect) {
context.beginPath();
context.roundRect(x - cx, y - cy, width, height, radius);
context.stroke();
context.closePath();
} else {
context.strokeRect(x - cx, y - cy, width, height);
}
context.restore();
};
const strokeDiamondWithRotation = (
context: CanvasRenderingContext2D,
width: number,
height: number,
cx: number,
cy: number,
angle: number,
) => {
context.save();
context.translate(cx, cy);
context.rotate(angle);
context.beginPath();
context.moveTo(0, height / 2);
context.lineTo(width / 2, 0);
context.lineTo(0, -height / 2);
context.lineTo(-width / 2, 0);
context.closePath();
context.stroke();
context.restore();
};
const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
@ -243,19 +193,6 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
);
};
const strokeEllipseWithRotation = (
context: CanvasRenderingContext2D,
width: number,
height: number,
cx: number,
cy: number,
angle: number,
) => {
context.beginPath();
context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2);
context.stroke();
};
const renderBindingHighlightForBindableElement = (
context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement,
@ -267,16 +204,10 @@ const renderBindingHighlightForBindableElement = (
const height = y2 - y1;
context.strokeStyle = "rgba(0,0,0,.05)";
// When zooming out, make line width greater for visibility
const zoomValue = zoom.value < 1 ? zoom.value : 1;
context.lineWidth = BINDING_HIGHLIGHT_THICKNESS / zoomValue;
// To ensure the binding highlight doesn't overlap the element itself
const padding = context.lineWidth / 2 + BINDING_HIGHLIGHT_OFFSET;
context.fillStyle = "rgba(0,0,0,.05)";
const radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
// To ensure the binding highlight doesn't overlap the element itself
const padding = maxBindingGap(element, element.width, element.height, zoom);
switch (element.type) {
case "rectangle":
@ -286,37 +217,20 @@ const renderBindingHighlightForBindableElement = (
case "embeddable":
case "frame":
case "magicframe":
strokeRectWithRotation(
context,
x1 - padding,
y1 - padding,
width + padding * 2,
height + padding * 2,
x1 + width / 2,
y1 + height / 2,
element.angle,
undefined,
radius,
);
drawHighlightForRectWithRotation(context, element, padding);
break;
case "diamond":
const side = Math.hypot(width, height);
const wPadding = (padding * side) / height;
const hPadding = (padding * side) / width;
strokeDiamondWithRotation(
context,
width + wPadding * 2,
height + hPadding * 2,
x1 + width / 2,
y1 + height / 2,
element.angle,
);
drawHighlightForDiamondWithRotation(context, padding, element);
break;
case "ellipse":
context.lineWidth =
maxBindingGap(element, element.width, element.height, zoom) -
FIXED_BINDING_DISTANCE;
strokeEllipseWithRotation(
context,
width + padding * 2,
height + padding * 2,
width + padding + FIXED_BINDING_DISTANCE,
height + padding + FIXED_BINDING_DISTANCE,
x1 + width / 2,
y1 + height / 2,
element.angle,

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -44,7 +44,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
"frameId": null,
"groupIds": [],
"height": 50,
"id": "id2",
"id": "id4",
"index": "a1",
"isDeleted": false,
"link": null,
@ -108,7 +108,7 @@ exports[`move element > rectangles with binding arrow 5`] = `
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id2",
"id": "id6",
"type": "arrow",
},
],
@ -147,7 +147,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id2",
"id": "id6",
"type": "arrow",
},
],
@ -156,7 +156,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
"frameId": null,
"groupIds": [],
"height": 300,
"id": "id1",
"id": "id3",
"index": "a1",
"isDeleted": false,
"link": null,
@ -189,7 +189,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id1",
"elementId": "id3",
"focus": "-0.46667",
"gap": 10,
},
@ -197,7 +197,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"frameId": null,
"groupIds": [],
"height": "87.29887",
"id": "id2",
"id": "id6",
"index": "a2",
"isDeleted": false,
"lastCommittedPoint": null,

View File

@ -23,6 +23,7 @@ import {
waitFor,
togglePopover,
unmountComponent,
checkpointHistory,
} from "./test-utils";
import type { ShortcutName } from "../actions/shortcuts";
@ -33,11 +34,12 @@ const checkpoint = (name: string) => {
`[${name}] number of renders`,
);
expect(h.state).toMatchSnapshot(`[${name}] appState`);
expect(h.history).toMatchSnapshot(`[${name}] history`);
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
h.elements.forEach((element, i) =>
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
);
checkpointHistory(h.history, name);
};
const mouse = new Pointer("mouse");

View File

@ -23,6 +23,10 @@ import {
import "@excalidraw/utils/test-utils";
import { ElementsDelta, AppStateDelta } from "@excalidraw/element/delta";
import { CaptureUpdateAction, StoreDelta } from "@excalidraw/element/store";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type {
@ -46,11 +50,8 @@ import {
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import { getDefaultAppState } from "../appState";
import { HistoryEntry } from "../history";
import { Excalidraw } from "../index";
import * as StaticScene from "../renderer/staticScene";
import { Snapshot, CaptureUpdateAction } from "../store";
import { AppStateChange, ElementsChange } from "../change";
import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
@ -61,6 +62,7 @@ import {
render,
togglePopover,
getCloneByOrigId,
checkpointHistory,
} from "./test-utils";
import type { AppState } from "../types";
@ -82,13 +84,15 @@ const checkpoint = (name: string) => {
...strippedAppState
} = h.state;
expect(strippedAppState).toMatchSnapshot(`[${name}] appState`);
expect(h.history).toMatchSnapshot(`[${name}] history`);
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
h.elements
.map(({ seed, versionNonce, ...strippedElement }) => strippedElement)
.forEach((element, i) =>
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
);
checkpointHistory(h.history, name);
};
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
@ -116,12 +120,12 @@ describe("history", () => {
API.setElements([rect]);
const corrupedEntry = HistoryEntry.create(
AppStateChange.empty(),
ElementsChange.empty(),
const corrupedEntry = StoreDelta.create(
ElementsDelta.empty(),
AppStateDelta.empty(),
);
vi.spyOn(corrupedEntry, "applyTo").mockImplementation(() => {
vi.spyOn(corrupedEntry.elements, "applyTo").mockImplementation(() => {
throw new Error("Oh no, I am corrupted!");
});
@ -136,7 +140,6 @@ describe("history", () => {
h.history.undo(
arrayToMap(h.elements) as SceneElementsMap,
appState,
Snapshot.empty(),
) as any,
);
} catch (e) {
@ -157,7 +160,6 @@ describe("history", () => {
h.history.redo(
arrayToMap(h.elements) as SceneElementsMap,
appState,
Snapshot.empty(),
) as any,
);
} catch (e) {
@ -454,8 +456,8 @@ describe("history", () => {
expect(h.history.isUndoStackEmpty).toBeTruthy();
});
const undoAction = createUndoAction(h.history, h.store);
const redoAction = createRedoAction(h.history, h.store);
const undoAction = createUndoAction(h.history);
const redoAction = createRedoAction(h.history);
// noop
API.executeAction(undoAction);
expect(h.elements).toEqual([
@ -531,8 +533,8 @@ describe("history", () => {
expect.objectContaining({ id: "B", isDeleted: false }),
]);
const undoAction = createUndoAction(h.history, h.store);
const redoAction = createRedoAction(h.history, h.store);
const undoAction = createUndoAction(h.history);
const redoAction = createRedoAction(h.history);
API.executeAction(undoAction);
expect(API.getSnapshot()).toEqual([
@ -1713,8 +1715,8 @@ describe("history", () => {
/>,
);
const undoAction = createUndoAction(h.history, h.store);
const redoAction = createRedoAction(h.history, h.store);
const undoAction = createUndoAction(h.history);
const redoAction = createRedoAction(h.history);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
@ -1763,7 +1765,7 @@ describe("history", () => {
/>,
);
const undoAction = createUndoAction(h.history, h.store);
const undoAction = createUndoAction(h.history);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);

View File

@ -1384,19 +1384,30 @@ describe("Test Linear Elements", () => {
const [origStartX, origStartY] = [line.x, line.y];
act(() => {
LinearElementEditor.movePoints(line, h.app.scene, [
{
index: 0,
point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),
},
{
index: line.points.length - 1,
point: pointFrom(
line.points[line.points.length - 1][0] - 10,
line.points[line.points.length - 1][1] - 10,
),
},
]);
LinearElementEditor.movePoints(
line,
h.app.scene,
new Map([
[
0,
{
point: pointFrom(
line.points[0][0] + 10,
line.points[0][1] + 10,
),
},
],
[
line.points.length - 1,
{
point: pointFrom(
line.points[line.points.length - 1][0] - 10,
line.points[line.points.length - 1][1] - 10,
),
},
],
]),
);
});
expect(line.x).toBe(origStartX + 10);
expect(line.y).toBe(origStartY + 10);

View File

@ -14,6 +14,7 @@ import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import {
assertSelectedElements,
checkpointHistory,
fireEvent,
render,
screen,
@ -39,11 +40,12 @@ const checkpoint = (name: string) => {
`[${name}] number of renders`,
);
expect(h.state).toMatchSnapshot(`[${name}] appState`);
expect(h.history).toMatchSnapshot(`[${name}] history`);
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
h.elements.forEach((element, i) =>
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
);
checkpointHistory(h.history, name);
};
beforeEach(async () => {
unmountComponent();

View File

@ -22,6 +22,8 @@ import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants";
import { Pointer, UI } from "./helpers/ui";
import * as toolQueries from "./queries/toolQueries";
import type { History } from "../history";
import type { RenderResult, RenderOptions } from "@testing-library/react";
import type { ImportedDataState } from "../data/types";
@ -432,3 +434,45 @@ export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
expect(h.state.selectedElementIds).toEqual(selectedElementIds);
};
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>);
export const checkpointHistory = (history: History, name: string) => {
expect(
history.undoStack.map((x) => ({
...x,
elements: {
...x.elements,
added: stripSeed(x.elements.added),
removed: stripSeed(x.elements.removed),
updated: stripSeed(x.elements.updated),
},
})),
).toMatchSnapshot(`[${name}] undo stack`);
expect(
history.redoStack.map((x) => ({
...x,
elements: {
...x.elements,
added: stripSeed(x.elements.added),
removed: stripSeed(x.elements.removed),
updated: stripSeed(x.elements.updated),
},
})),
).toMatchSnapshot(`[${name}] redo stack`);
};

View File

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

View File

@ -54,7 +54,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}

View File

@ -2,6 +2,7 @@ import type { Bounds } from "@excalidraw/element/bounds";
import { isPoint, pointDistance, pointFrom } from "./point";
import { rectangle, rectangleIntersectLineSegment } from "./rectangle";
import { vector } from "./vector";
import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
@ -82,7 +83,7 @@ function solve(
return [t0, s0];
}
const bezierEquation = <Point extends GlobalPoint | LocalPoint>(
export const bezierEquation = <Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>,
t: number,
) =>
@ -274,6 +275,26 @@ export function isCurve<P extends GlobalPoint | LocalPoint>(
);
}
export function curveTangent<Point extends GlobalPoint | LocalPoint>(
[p0, p1, p2, p3]: Curve<Point>,
t: number,
) {
return vector(
-3 * (1 - t) * (1 - t) * p0[0] +
3 * (1 - t) * (1 - t) * p1[0] -
6 * t * (1 - t) * p1[0] -
3 * t * t * p2[0] +
6 * t * (1 - t) * p2[0] +
3 * t * t * p3[0],
-3 * (1 - t) * (1 - t) * p0[1] +
3 * (1 - t) * (1 - t) * p1[1] -
6 * t * (1 - t) * p1[1] -
3 * t * t * p2[1] +
6 * t * (1 - t) * p2[1] +
3 * t * t * p3[1],
);
}
function curveBounds<Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>,
): Bounds {

View File

@ -143,3 +143,8 @@ export const vectorNormalize = (v: Vector): Vector => {
return vector(v[0] / m, v[1] / m);
};
/**
* Calculate the right-hand normal of the vector.
*/
export const vectorNormal = (v: Vector): Vector => vector(v[1], -v[0]);

View File

@ -69,7 +69,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildUtils.js && yarn gen:types"
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildUtils.js && yarn gen:types"
}
}

View File

@ -5945,7 +5945,7 @@ glob-to-regexp@^0.4.1:
resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==
glob@^10.4.1:
glob@^10.3.7, glob@^10.4.1:
version "10.4.5"
resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956"
integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==
@ -8314,6 +8314,13 @@ rimraf@3.0.2, rimraf@^3.0.2:
dependencies:
glob "^7.1.3"
rimraf@^5.0.0:
version "5.0.10"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.10.tgz#23b9843d3dc92db71f96e1a2ce92e39fd2a8221c"
integrity sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==
dependencies:
glob "^10.3.7"
robust-predicates@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
@ -8770,8 +8777,16 @@ string-natural-compare@^3.0.1:
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
name string-width-cjs
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -8873,7 +8888,14 @@ stringify-object@^3.3.0:
is-obj "^1.0.1"
is-regexp "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -10006,8 +10028,7 @@ workbox-window@7.3.0, workbox-window@^7.3.0:
"@types/trusted-types" "^2.0.2"
workbox-core "7.3.0"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
name wrap-ansi-cjs
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -10025,6 +10046,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"