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:
commit
ac1ad31921
@ -8,6 +8,13 @@ import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
|||||||
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
|
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
import { StoreIncrement } from "@excalidraw/element/store";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DurableIncrement,
|
||||||
|
EphemeralIncrement,
|
||||||
|
} from "@excalidraw/element/store";
|
||||||
|
|
||||||
import ExcalidrawApp from "../App";
|
import ExcalidrawApp from "../App";
|
||||||
|
|
||||||
const { h } = window;
|
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.
|
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
|
||||||
*/
|
*/
|
||||||
describe("collaboration", () => {
|
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 () => {
|
it("should allow to undo / redo even on force-deleted elements", async () => {
|
||||||
await render(<ExcalidrawApp />);
|
await render(<ExcalidrawApp />);
|
||||||
const rect1Props = {
|
const rect1Props = {
|
||||||
@ -122,7 +202,7 @@ describe("collaboration", () => {
|
|||||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history, h.store);
|
const undoAction = createUndoAction(h.history);
|
||||||
act(() => h.app.actionManager.executeAction(undoAction));
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
||||||
@ -154,7 +234,7 @@ describe("collaboration", () => {
|
|||||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const redoAction = createRedoAction(h.history, h.store);
|
const redoAction = createRedoAction(h.history);
|
||||||
act(() => h.app.actionManager.executeAction(redoAction));
|
act(() => h.app.actionManager.executeAction(redoAction));
|
||||||
|
|
||||||
// with explicit redo (as removal) we again restore the element from the snapshot!
|
// with explicit redo (as removal) we again restore the element from the snapshot!
|
||||||
|
@ -33,6 +33,7 @@
|
|||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.6.2",
|
"prettier": "2.6.2",
|
||||||
"rewire": "6.0.0",
|
"rewire": "6.0.0",
|
||||||
|
"rimraf": "^5.0.0",
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"vite": "5.0.12",
|
"vite": "5.0.12",
|
||||||
"vite-plugin-checker": "0.7.2",
|
"vite-plugin-checker": "0.7.2",
|
||||||
@ -78,8 +79,8 @@
|
|||||||
"autorelease": "node scripts/autorelease.js",
|
"autorelease": "node scripts/autorelease.js",
|
||||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||||
"release:excalidraw": "node scripts/release.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:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
|
||||||
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
|
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
|
||||||
"clean-install": "yarn rm:node_modules && yarn install"
|
"clean-install": "yarn rm:node_modules && yarn install"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
|
@ -50,7 +50,7 @@
|
|||||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||||
"repository": "https://github.com/excalidraw/excalidraw",
|
"repository": "https://github.com/excalidraw/excalidraw",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"gen:types": "rm -rf types && tsc",
|
"gen:types": "rimraf types && tsc",
|
||||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import type { UnsubscribeCallback } from "./types";
|
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
type Subscriber<T extends any[]> = (...payload: T) => void;
|
type Subscriber<T extends any[]> = (...payload: T) => void;
|
||||||
|
|
@ -9,3 +9,4 @@ export * from "./promise-pool";
|
|||||||
export * from "./random";
|
export * from "./random";
|
||||||
export * from "./url";
|
export * from "./url";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
|
export * from "./emitter";
|
||||||
|
@ -68,3 +68,12 @@ export type MaybePromise<T> = T | Promise<T>;
|
|||||||
|
|
||||||
// get union of all keys from the union of types
|
// get union of all keys from the union of types
|
||||||
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
||||||
|
|
||||||
|
/** Strip all the methods or functions from a type */
|
||||||
|
export type DTO<T> = {
|
||||||
|
[K in keyof T as T[K] extends Function ? never : K]: T[K];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapEntry<M extends Map<any, any>> = M extends Map<infer K, infer V>
|
||||||
|
? [K, V]
|
||||||
|
: never;
|
||||||
|
@ -735,6 +735,25 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
|
|||||||
return acc;
|
return acc;
|
||||||
}, [] as Node<T>[]);
|
}, [] 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 isTestEnv = () => import.meta.env.MODE === ENV.TEST;
|
||||||
|
|
||||||
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
|
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
|
||||||
|
@ -50,7 +50,7 @@
|
|||||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||||
"repository": "https://github.com/excalidraw/excalidraw",
|
"repository": "https://github.com/excalidraw/excalidraw",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"gen:types": "rm -rf types && tsc",
|
"gen:types": "rimraf types && tsc",
|
||||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,14 +6,13 @@ import {
|
|||||||
toBrandedType,
|
toBrandedType,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
isReadonlyArray,
|
toArray,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import { isNonDeletedElement } from "@excalidraw/element";
|
import { isNonDeletedElement } from "@excalidraw/element";
|
||||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||||
import { getElementsInGroup } from "@excalidraw/element/groups";
|
import { getElementsInGroup } from "@excalidraw/element/groups";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
orderByFractionalIndex,
|
|
||||||
syncInvalidIndices,
|
syncInvalidIndices,
|
||||||
syncMovedIndices,
|
syncMovedIndices,
|
||||||
validateFractionalIndices,
|
validateFractionalIndices,
|
||||||
@ -268,19 +267,13 @@ class Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
replaceAllElements(nextElements: ElementsMapOrArray) {
|
||||||
// ts doesn't like `Array.isArray` of `instanceof Map`
|
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
||||||
if (!isReadonlyArray(nextElements)) {
|
const _nextElements = toArray(nextElements);
|
||||||
// need to order by fractional indices to get the correct order
|
|
||||||
nextElements = orderByFractionalIndex(
|
|
||||||
Array.from(nextElements.values()) as OrderedExcalidrawElement[],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||||
|
|
||||||
validateIndicesThrottled(nextElements);
|
validateIndicesThrottled(_nextElements);
|
||||||
|
|
||||||
this.elements = syncInvalidIndices(nextElements);
|
this.elements = syncInvalidIndices(_nextElements);
|
||||||
this.elementsMap.clear();
|
this.elementsMap.clear();
|
||||||
this.elements.forEach((element) => {
|
this.elements.forEach((element) => {
|
||||||
if (isFrameLikeElement(element)) {
|
if (isFrameLikeElement(element)) {
|
||||||
|
@ -33,7 +33,7 @@ import type { LocalPoint, Radians } from "@excalidraw/math";
|
|||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
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 {
|
import {
|
||||||
getCenterForBounds,
|
getCenterForBounds,
|
||||||
@ -84,6 +84,7 @@ import type {
|
|||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
FixedPoint,
|
FixedPoint,
|
||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
|
PointsPositionUpdates,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export type SuggestedBinding =
|
export type SuggestedBinding =
|
||||||
@ -801,28 +802,22 @@ export const updateBoundElements = (
|
|||||||
bindableElement,
|
bindableElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (point) {
|
if (point) {
|
||||||
return {
|
return [
|
||||||
index:
|
|
||||||
bindingProp === "startBinding" ? 0 : element.points.length - 1,
|
bindingProp === "startBinding" ? 0 : element.points.length - 1,
|
||||||
point,
|
{ point },
|
||||||
};
|
] as MapEntry<PointsPositionUpdates>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
).filter(
|
).filter(
|
||||||
(
|
(update): update is MapEntry<PointsPositionUpdates> => update !== null,
|
||||||
update,
|
|
||||||
): update is NonNullable<{
|
|
||||||
index: number;
|
|
||||||
point: LocalPoint;
|
|
||||||
isDragging?: boolean;
|
|
||||||
}> => update !== null,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(element, scene, updates, {
|
LinearElementEditor.movePoints(element, scene, new Map(updates), {
|
||||||
...(changedElement.id === element.startBinding?.elementId
|
...(changedElement.id === element.startBinding?.elementId
|
||||||
? { startBinding: bindings.startBinding }
|
? { startBinding: bindings.startBinding }
|
||||||
: {}),
|
: {}),
|
||||||
@ -1171,6 +1166,48 @@ export const snapToMid = (
|
|||||||
center,
|
center,
|
||||||
angle,
|
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;
|
return p;
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
rescalePoints,
|
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
invariant,
|
invariant,
|
||||||
|
rescalePoints,
|
||||||
sizeOf,
|
sizeOf,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
degreesToRadians,
|
degreesToRadians,
|
||||||
lineSegment,
|
lineSegment,
|
||||||
pointFrom,
|
|
||||||
pointDistance,
|
pointDistance,
|
||||||
|
pointFrom,
|
||||||
pointFromArray,
|
pointFromArray,
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
@ -33,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
|||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
|
||||||
import { generateRoughOptions } from "./Shape";
|
import { generateRoughOptions } from "./Shape";
|
||||||
|
import { ShapeCache } from "./ShapeCache";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||||
import {
|
import {
|
||||||
@ -52,20 +52,20 @@ import {
|
|||||||
deconstructRectanguloidElement,
|
deconstructRectanguloidElement,
|
||||||
} from "./utils";
|
} 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 { Drawable, Op } from "roughjs/bin/core";
|
||||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
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 = {
|
export type RectangleBox = {
|
||||||
x: number;
|
x: number;
|
||||||
|
@ -5,43 +5,7 @@ import {
|
|||||||
isDevEnv,
|
isDevEnv,
|
||||||
isShallowEqual,
|
isShallowEqual,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
toBrandedType,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import {
|
|
||||||
BoundElement,
|
|
||||||
BindableElement,
|
|
||||||
bindingProperties,
|
|
||||||
updateBoundElements,
|
|
||||||
} from "@excalidraw/element/binding";
|
|
||||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
|
||||||
import {
|
|
||||||
mutateElement,
|
|
||||||
newElementWith,
|
|
||||||
} from "@excalidraw/element/mutateElement";
|
|
||||||
import {
|
|
||||||
getBoundTextElementId,
|
|
||||||
redrawTextBoundingBox,
|
|
||||||
} from "@excalidraw/element/textElement";
|
|
||||||
import {
|
|
||||||
hasBoundTextElement,
|
|
||||||
isBindableElement,
|
|
||||||
isBoundToContainer,
|
|
||||||
isImageElement,
|
|
||||||
isTextElement,
|
|
||||||
} from "@excalidraw/element/typeChecks";
|
|
||||||
|
|
||||||
import { getNonDeletedGroupIds } from "@excalidraw/element/groups";
|
|
||||||
|
|
||||||
import {
|
|
||||||
orderByFractionalIndex,
|
|
||||||
syncMovedIndices,
|
|
||||||
} from "@excalidraw/element/fractionalIndex";
|
|
||||||
|
|
||||||
import Scene from "@excalidraw/element/Scene";
|
|
||||||
|
|
||||||
import type { BindableProp, BindingProp } from "@excalidraw/element/binding";
|
|
||||||
|
|
||||||
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@ -54,16 +18,42 @@ import type {
|
|||||||
SceneElementsMap,
|
SceneElementsMap,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
|
import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { getObservedAppState } from "./store";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
ObservedAppState,
|
ObservedAppState,
|
||||||
ObservedElementsAppState,
|
ObservedElementsAppState,
|
||||||
ObservedStandaloneAppState,
|
ObservedStandaloneAppState,
|
||||||
} from "./types";
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
|
import { getObservedAppState } from "./store";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BoundElement,
|
||||||
|
BindableElement,
|
||||||
|
bindingProperties,
|
||||||
|
updateBoundElements,
|
||||||
|
} from "./binding";
|
||||||
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
|
import { mutateElement, newElementWith } from "./mutateElement";
|
||||||
|
import { getBoundTextElementId, redrawTextBoundingBox } from "./textElement";
|
||||||
|
import {
|
||||||
|
hasBoundTextElement,
|
||||||
|
isBindableElement,
|
||||||
|
isBoundToContainer,
|
||||||
|
isTextElement,
|
||||||
|
} from "./typeChecks";
|
||||||
|
|
||||||
|
import { getNonDeletedGroupIds } from "./groups";
|
||||||
|
|
||||||
|
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||||
|
|
||||||
|
import Scene from "./Scene";
|
||||||
|
|
||||||
|
import type { BindableProp, BindingProp } from "./binding";
|
||||||
|
|
||||||
|
import type { ElementUpdate } from "./mutateElement";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the difference between two objects of the same type.
|
* Represents the difference between two objects of the same type.
|
||||||
@ -74,7 +64,7 @@ import type {
|
|||||||
*
|
*
|
||||||
* Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load.
|
* Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load.
|
||||||
*/
|
*/
|
||||||
class Delta<T> {
|
export class Delta<T> {
|
||||||
private constructor(
|
private constructor(
|
||||||
public readonly deleted: Partial<T>,
|
public readonly deleted: Partial<T>,
|
||||||
public readonly inserted: Partial<T>,
|
public readonly inserted: Partial<T>,
|
||||||
@ -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 {}>(
|
public static getLeftDifferences<T extends {}>(
|
||||||
object1: T,
|
object1: T,
|
||||||
@ -335,11 +325,11 @@ class Delta<T> {
|
|||||||
) {
|
) {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
this.distinctKeysIterator("left", object1, object2, skipShallowCompare),
|
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 {}>(
|
public static getRightDifferences<T extends {}>(
|
||||||
object1: T,
|
object1: T,
|
||||||
@ -348,7 +338,7 @@ class Delta<T> {
|
|||||||
) {
|
) {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
this.distinctKeysIterator("right", object1, object2, skipShallowCompare),
|
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];
|
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether there are actually `Delta`s.
|
* Checks whether all `Delta`s are empty.
|
||||||
*/
|
*/
|
||||||
isEmpty(): boolean;
|
isEmpty(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AppStateChange implements Change<AppState> {
|
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||||
private constructor(private readonly delta: Delta<ObservedAppState>) {}
|
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
||||||
|
|
||||||
public static calculate<T extends ObservedAppState>(
|
public static calculate<T extends ObservedAppState>(
|
||||||
prevAppState: T,
|
prevAppState: T,
|
||||||
nextAppState: T,
|
nextAppState: T,
|
||||||
): AppStateChange {
|
): AppStateDelta {
|
||||||
const delta = Delta.calculate(
|
const delta = Delta.calculate(
|
||||||
prevAppState,
|
prevAppState,
|
||||||
nextAppState,
|
nextAppState,
|
||||||
undefined,
|
// making the order of keys in deltas stable for hashing purposes
|
||||||
AppStateChange.postProcess,
|
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() {
|
public static empty() {
|
||||||
return new AppStateChange(Delta.create({}, {}));
|
return new AppStateDelta(Delta.create({}, {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public inverse(): AppStateChange {
|
public inverse(): AppStateDelta {
|
||||||
const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted);
|
const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted);
|
||||||
return new AppStateChange(inversedDelta);
|
return new AppStateDelta(inversedDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
public applyTo(
|
public applyTo(
|
||||||
@ -544,40 +540,6 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
return Delta.isEmpty(this.delta);
|
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.
|
* 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 nextObservedAppState = getObservedAppState(nextAppState);
|
||||||
|
|
||||||
const containsStandaloneDifference = Delta.isRightDifferent(
|
const containsStandaloneDifference = Delta.isRightDifferent(
|
||||||
AppStateChange.stripElementsProps(prevObservedAppState),
|
AppStateDelta.stripElementsProps(prevObservedAppState),
|
||||||
AppStateChange.stripElementsProps(nextObservedAppState),
|
AppStateDelta.stripElementsProps(nextObservedAppState),
|
||||||
);
|
);
|
||||||
|
|
||||||
const containsElementsDifference = Delta.isRightDifferent(
|
const containsElementsDifference = Delta.isRightDifferent(
|
||||||
AppStateChange.stripStandaloneProps(prevObservedAppState),
|
AppStateDelta.stripStandaloneProps(prevObservedAppState),
|
||||||
AppStateChange.stripStandaloneProps(nextObservedAppState),
|
AppStateDelta.stripStandaloneProps(nextObservedAppState),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!containsStandaloneDifference && !containsElementsDifference) {
|
if (!containsStandaloneDifference && !containsElementsDifference) {
|
||||||
@ -615,8 +577,8 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
if (containsElementsDifference) {
|
if (containsElementsDifference) {
|
||||||
// filter invisible changes on each iteration
|
// filter invisible changes on each iteration
|
||||||
const changedElementsProps = Delta.getRightDifferences(
|
const changedElementsProps = Delta.getRightDifferences(
|
||||||
AppStateChange.stripStandaloneProps(prevObservedAppState),
|
AppStateDelta.stripStandaloneProps(prevObservedAppState),
|
||||||
AppStateChange.stripStandaloneProps(nextObservedAppState),
|
AppStateDelta.stripStandaloneProps(nextObservedAppState),
|
||||||
) as Array<keyof ObservedElementsAppState>;
|
) as Array<keyof ObservedElementsAppState>;
|
||||||
|
|
||||||
let nonDeletedGroupIds = new Set<string>();
|
let nonDeletedGroupIds = new Set<string>();
|
||||||
@ -633,7 +595,7 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
for (const key of changedElementsProps) {
|
for (const key of changedElementsProps) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "selectedElementIds":
|
case "selectedElementIds":
|
||||||
nextAppState[key] = AppStateChange.filterSelectedElements(
|
nextAppState[key] = AppStateDelta.filterSelectedElements(
|
||||||
nextAppState[key],
|
nextAppState[key],
|
||||||
nextElements,
|
nextElements,
|
||||||
visibleDifferenceFlag,
|
visibleDifferenceFlag,
|
||||||
@ -641,7 +603,7 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
case "selectedGroupIds":
|
case "selectedGroupIds":
|
||||||
nextAppState[key] = AppStateChange.filterSelectedGroups(
|
nextAppState[key] = AppStateDelta.filterSelectedGroups(
|
||||||
nextAppState[key],
|
nextAppState[key],
|
||||||
nonDeletedGroupIds,
|
nonDeletedGroupIds,
|
||||||
visibleDifferenceFlag,
|
visibleDifferenceFlag,
|
||||||
@ -677,7 +639,7 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
break;
|
break;
|
||||||
case "selectedLinearElementId":
|
case "selectedLinearElementId":
|
||||||
case "editingLinearElementId":
|
case "editingLinearElementId":
|
||||||
const appStateKey = AppStateChange.convertToAppStateKey(key);
|
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
||||||
const linearElement = nextAppState[appStateKey];
|
const linearElement = nextAppState[appStateKey];
|
||||||
|
|
||||||
if (!linearElement) {
|
if (!linearElement) {
|
||||||
@ -812,6 +774,51 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
ObservedElementsAppState
|
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<
|
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.
|
* Elements change is a low level primitive to capture a change between two sets of elements.
|
||||||
* It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions.
|
* It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions.
|
||||||
*/
|
*/
|
||||||
export class ElementsChange implements Change<SceneElementsMap> {
|
export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||||
private constructor(
|
private constructor(
|
||||||
private readonly added: Map<string, Delta<ElementPartial>>,
|
public readonly added: Record<string, Delta<ElementPartial>>,
|
||||||
private readonly removed: Map<string, Delta<ElementPartial>>,
|
public readonly removed: Record<string, Delta<ElementPartial>>,
|
||||||
private readonly updated: Map<string, Delta<ElementPartial>>,
|
public readonly updated: Record<string, Delta<ElementPartial>>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static create(
|
public static create(
|
||||||
added: Map<string, Delta<ElementPartial>>,
|
added: Record<string, Delta<ElementPartial>>,
|
||||||
removed: Map<string, Delta<ElementPartial>>,
|
removed: Record<string, Delta<ElementPartial>>,
|
||||||
updated: Map<string, Delta<ElementPartial>>,
|
updated: Record<string, Delta<ElementPartial>>,
|
||||||
options = { shouldRedistribute: false },
|
options: {
|
||||||
|
shouldRedistribute: boolean;
|
||||||
|
} = {
|
||||||
|
shouldRedistribute: false,
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
let change: ElementsChange;
|
let delta: ElementsDelta;
|
||||||
|
|
||||||
if (options.shouldRedistribute) {
|
if (options.shouldRedistribute) {
|
||||||
const nextAdded = new Map<string, Delta<ElementPartial>>();
|
const nextAdded: Record<string, Delta<ElementPartial>> = {};
|
||||||
const nextRemoved = new Map<string, Delta<ElementPartial>>();
|
const nextRemoved: Record<string, Delta<ElementPartial>> = {};
|
||||||
const nextUpdated = new Map<string, Delta<ElementPartial>>();
|
const nextUpdated: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
const deltas = [...added, ...removed, ...updated];
|
const deltas = [
|
||||||
|
...Object.entries(added),
|
||||||
|
...Object.entries(removed),
|
||||||
|
...Object.entries(updated),
|
||||||
|
];
|
||||||
|
|
||||||
for (const [id, delta] of deltas) {
|
for (const [id, delta] of deltas) {
|
||||||
if (this.satisfiesAddition(delta)) {
|
if (this.satisfiesAddition(delta)) {
|
||||||
nextAdded.set(id, delta);
|
nextAdded[id] = delta;
|
||||||
} else if (this.satisfiesRemoval(delta)) {
|
} else if (this.satisfiesRemoval(delta)) {
|
||||||
nextRemoved.set(id, delta);
|
nextRemoved[id] = delta;
|
||||||
} else {
|
} else {
|
||||||
nextUpdated.set(id, delta);
|
nextUpdated[id] = delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
change = new ElementsChange(nextAdded, nextRemoved, nextUpdated);
|
delta = new ElementsDelta(nextAdded, nextRemoved, nextUpdated);
|
||||||
} else {
|
} else {
|
||||||
change = new ElementsChange(added, removed, updated);
|
delta = new ElementsDelta(added, removed, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTestEnv() || isDevEnv()) {
|
if (isTestEnv() || isDevEnv()) {
|
||||||
ElementsChange.validate(change, "added", this.satisfiesAddition);
|
ElementsDelta.validate(delta, "added", this.satisfiesAddition);
|
||||||
ElementsChange.validate(change, "removed", this.satisfiesRemoval);
|
ElementsDelta.validate(delta, "removed", this.satisfiesRemoval);
|
||||||
ElementsChange.validate(change, "updated", this.satisfiesUpdate);
|
ElementsDelta.validate(delta, "updated", this.satisfiesUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return change;
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta {
|
||||||
|
const { added, removed, updated } = elementsDeltaDTO;
|
||||||
|
return ElementsDelta.create(added, removed, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static satisfiesAddition = ({
|
private static satisfiesAddition = ({
|
||||||
@ -888,17 +908,17 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
|
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
|
||||||
|
|
||||||
private static validate(
|
private static validate(
|
||||||
change: ElementsChange,
|
elementsDelta: ElementsDelta,
|
||||||
type: "added" | "removed" | "updated",
|
type: "added" | "removed" | "updated",
|
||||||
satifies: (delta: Delta<ElementPartial>) => boolean,
|
satifies: (delta: Delta<ElementPartial>) => boolean,
|
||||||
) {
|
) {
|
||||||
for (const [id, delta] of change[type].entries()) {
|
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
||||||
if (!satifies(delta)) {
|
if (!satifies(delta)) {
|
||||||
console.error(
|
console.error(
|
||||||
`Broken invariant for "${type}" delta, element "${id}", delta:`,
|
`Broken invariant for "${type}" delta, element "${id}", delta:`,
|
||||||
delta,
|
delta,
|
||||||
);
|
);
|
||||||
throw new Error(`ElementsChange invariant broken for element "${id}".`);
|
throw new Error(`ElementsDelta invariant broken for element "${id}".`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -909,19 +929,19 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
* @param prevElements - Map representing the previous state of elements.
|
* @param prevElements - Map representing the previous state of elements.
|
||||||
* @param nextElements - Map representing the next state of elements.
|
* @param nextElements - Map representing the next state of elements.
|
||||||
*
|
*
|
||||||
* @returns `ElementsChange` instance representing the `Delta` changes between the two sets of elements.
|
* @returns `ElementsDelta` instance representing the `Delta` changes between the two sets of elements.
|
||||||
*/
|
*/
|
||||||
public static calculate<T extends OrderedExcalidrawElement>(
|
public static calculate<T extends OrderedExcalidrawElement>(
|
||||||
prevElements: Map<string, T>,
|
prevElements: Map<string, T>,
|
||||||
nextElements: Map<string, T>,
|
nextElements: Map<string, T>,
|
||||||
): ElementsChange {
|
): ElementsDelta {
|
||||||
if (prevElements === nextElements) {
|
if (prevElements === nextElements) {
|
||||||
return ElementsChange.empty();
|
return ElementsDelta.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
const added = new Map<string, Delta<ElementPartial>>();
|
const added: Record<string, Delta<ElementPartial>> = {};
|
||||||
const removed = new Map<string, Delta<ElementPartial>>();
|
const removed: Record<string, Delta<ElementPartial>> = {};
|
||||||
const updated = new Map<string, Delta<ElementPartial>>();
|
const updated: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
// this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements
|
// this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements
|
||||||
for (const prevElement of prevElements.values()) {
|
for (const prevElement of prevElements.values()) {
|
||||||
@ -934,10 +954,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
const delta = Delta.create(
|
const delta = Delta.create(
|
||||||
deleted,
|
deleted,
|
||||||
inserted,
|
inserted,
|
||||||
ElementsChange.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
removed.set(prevElement.id, delta);
|
removed[prevElement.id] = delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -954,10 +974,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
const delta = Delta.create(
|
const delta = Delta.create(
|
||||||
deleted,
|
deleted,
|
||||||
inserted,
|
inserted,
|
||||||
ElementsChange.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
added.set(nextElement.id, delta);
|
added[nextElement.id] = delta;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -966,8 +986,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
const delta = Delta.calculate<ElementPartial>(
|
const delta = Delta.calculate<ElementPartial>(
|
||||||
prevElement,
|
prevElement,
|
||||||
nextElement,
|
nextElement,
|
||||||
ElementsChange.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
ElementsChange.postProcess,
|
ElementsDelta.postProcess,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -978,9 +998,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
) {
|
) {
|
||||||
// notice that other props could have been updated as well
|
// notice that other props could have been updated as well
|
||||||
if (prevElement.isDeleted && !nextElement.isDeleted) {
|
if (prevElement.isDeleted && !nextElement.isDeleted) {
|
||||||
added.set(nextElement.id, delta);
|
added[nextElement.id] = delta;
|
||||||
} else {
|
} else {
|
||||||
removed.set(nextElement.id, delta);
|
removed[nextElement.id] = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -988,24 +1008,24 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
|
|
||||||
// making sure there are at least some changes
|
// making sure there are at least some changes
|
||||||
if (!Delta.isEmpty(delta)) {
|
if (!Delta.isEmpty(delta)) {
|
||||||
updated.set(nextElement.id, delta);
|
updated[nextElement.id] = delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ElementsChange.create(added, removed, updated);
|
return ElementsDelta.create(added, removed, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static empty() {
|
public static empty() {
|
||||||
return ElementsChange.create(new Map(), new Map(), new Map());
|
return ElementsDelta.create({}, {}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public inverse(): ElementsChange {
|
public inverse(): ElementsDelta {
|
||||||
const inverseInternal = (deltas: Map<string, Delta<ElementPartial>>) => {
|
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||||
const inversedDeltas = new Map<string, Delta<ElementPartial>>();
|
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
for (const [id, delta] of deltas.entries()) {
|
for (const [id, delta] of Object.entries(deltas)) {
|
||||||
inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted));
|
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
return inversedDeltas;
|
return inversedDeltas;
|
||||||
@ -1016,14 +1036,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
const updated = inverseInternal(this.updated);
|
const updated = inverseInternal(this.updated);
|
||||||
|
|
||||||
// notice we inverse removed with added not to break the invariants
|
// notice we inverse removed with added not to break the invariants
|
||||||
return ElementsChange.create(removed, added, updated);
|
return ElementsDelta.create(removed, added, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
public isEmpty(): boolean {
|
public isEmpty(): boolean {
|
||||||
return (
|
return (
|
||||||
this.added.size === 0 &&
|
Object.keys(this.added).length === 0 &&
|
||||||
this.removed.size === 0 &&
|
Object.keys(this.removed).length === 0 &&
|
||||||
this.updated.size === 0
|
Object.keys(this.updated).length === 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1034,7 +1054,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
|
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
|
||||||
* @returns new instance with modified delta/s
|
* @returns new instance with modified delta/s
|
||||||
*/
|
*/
|
||||||
public applyLatestChanges(elements: SceneElementsMap): ElementsChange {
|
public applyLatestChanges(
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
modifierOptions: "deleted" | "inserted",
|
||||||
|
): ElementsDelta {
|
||||||
const modifier =
|
const modifier =
|
||||||
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
||||||
const latestPartial: { [key: string]: unknown } = {};
|
const latestPartial: { [key: string]: unknown } = {};
|
||||||
@ -1055,11 +1078,11 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applyLatestChangesInternal = (
|
const applyLatestChangesInternal = (
|
||||||
deltas: Map<string, Delta<ElementPartial>>,
|
deltas: Record<string, Delta<ElementPartial>>,
|
||||||
) => {
|
) => {
|
||||||
const modifiedDeltas = new Map<string, Delta<ElementPartial>>();
|
const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
for (const [id, delta] of deltas.entries()) {
|
for (const [id, delta] of Object.entries(deltas)) {
|
||||||
const existingElement = elements.get(id);
|
const existingElement = elements.get(id);
|
||||||
|
|
||||||
if (existingElement) {
|
if (existingElement) {
|
||||||
@ -1067,12 +1090,12 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
delta.deleted,
|
delta.deleted,
|
||||||
delta.inserted,
|
delta.inserted,
|
||||||
modifier(existingElement),
|
modifier(existingElement),
|
||||||
"inserted",
|
modifierOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
modifiedDeltas.set(id, modifiedDelta);
|
modifiedDeltas[id] = modifiedDelta;
|
||||||
} else {
|
} else {
|
||||||
modifiedDeltas.set(id, delta);
|
modifiedDeltas[id] = delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1083,16 +1106,16 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
const removed = applyLatestChangesInternal(this.removed);
|
const removed = applyLatestChangesInternal(this.removed);
|
||||||
const updated = applyLatestChangesInternal(this.updated);
|
const updated = applyLatestChangesInternal(this.updated);
|
||||||
|
|
||||||
return ElementsChange.create(added, removed, updated, {
|
return ElementsDelta.create(added, removed, updated, {
|
||||||
shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
|
shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public applyTo(
|
public applyTo(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
elementsSnapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
): [SceneElementsMap, boolean] {
|
): [SceneElementsMap, boolean] {
|
||||||
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
|
let nextElements = new Map(elements) as SceneElementsMap;
|
||||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||||
|
|
||||||
const flags = {
|
const flags = {
|
||||||
@ -1102,15 +1125,15 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
|
|
||||||
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||||
try {
|
try {
|
||||||
const applyDeltas = ElementsChange.createApplier(
|
const applyDeltas = ElementsDelta.createApplier(
|
||||||
nextElements,
|
nextElements,
|
||||||
snapshot,
|
elementsSnapshot,
|
||||||
flags,
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
const addedElements = applyDeltas(this.added);
|
const addedElements = applyDeltas("added", this.added);
|
||||||
const removedElements = applyDeltas(this.removed);
|
const removedElements = applyDeltas("removed", this.removed);
|
||||||
const updatedElements = applyDeltas(this.updated);
|
const updatedElements = applyDeltas("updated", this.updated);
|
||||||
|
|
||||||
const affectedElements = this.resolveConflicts(elements, nextElements);
|
const affectedElements = this.resolveConflicts(elements, nextElements);
|
||||||
|
|
||||||
@ -1122,7 +1145,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
...affectedElements,
|
...affectedElements,
|
||||||
]);
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Couldn't apply elements change`, e);
|
console.error(`Couldn't apply elements delta`, e);
|
||||||
|
|
||||||
if (isTestEnv() || isDevEnv()) {
|
if (isTestEnv() || isDevEnv()) {
|
||||||
throw e;
|
throw e;
|
||||||
@ -1138,7 +1161,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
try {
|
try {
|
||||||
// the following reorder performs also mutations, but only on new instances of changed elements
|
// the following reorder performs also mutations, but only on new instances of changed elements
|
||||||
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||||
nextElements = ElementsChange.reorderElements(
|
nextElements = ElementsDelta.reorderElements(
|
||||||
nextElements,
|
nextElements,
|
||||||
changedElements,
|
changedElements,
|
||||||
flags,
|
flags,
|
||||||
@ -1149,9 +1172,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
// so we are creating a temp scene just to query and mutate elements
|
// so we are creating a temp scene just to query and mutate elements
|
||||||
const tempScene = new Scene(nextElements);
|
const tempScene = new Scene(nextElements);
|
||||||
|
|
||||||
ElementsChange.redrawTextBoundingBoxes(tempScene, changedElements);
|
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||||
// Need ordered nextElements to avoid z-index binding issues
|
// Need ordered nextElements to avoid z-index binding issues
|
||||||
ElementsChange.redrawBoundArrows(tempScene, changedElements);
|
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
`Couldn't mutate elements after applying elements change`,
|
`Couldn't mutate elements after applying elements change`,
|
||||||
@ -1166,26 +1189,31 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static createApplier = (
|
private static createApplier =
|
||||||
|
(
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
flags: {
|
flags: {
|
||||||
containsVisibleDifference: boolean;
|
containsVisibleDifference: boolean;
|
||||||
containsZindexDifference: boolean;
|
containsZindexDifference: boolean;
|
||||||
},
|
},
|
||||||
|
) =>
|
||||||
|
(
|
||||||
|
type: "added" | "removed" | "updated",
|
||||||
|
deltas: Record<string, Delta<ElementPartial>>,
|
||||||
) => {
|
) => {
|
||||||
const getElement = ElementsChange.createGetter(
|
const getElement = ElementsDelta.createGetter(
|
||||||
|
type,
|
||||||
nextElements,
|
nextElements,
|
||||||
snapshot,
|
snapshot,
|
||||||
flags,
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (deltas: Map<string, Delta<ElementPartial>>) =>
|
return Object.entries(deltas).reduce((acc, [id, delta]) => {
|
||||||
Array.from(deltas.entries()).reduce((acc, [id, delta]) => {
|
|
||||||
const element = getElement(id, delta.inserted);
|
const element = getElement(id, delta.inserted);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
const newElement = ElementsChange.applyDelta(element, delta, flags);
|
const newElement = ElementsDelta.applyDelta(element, delta, flags);
|
||||||
nextElements.set(newElement.id, newElement);
|
nextElements.set(newElement.id, newElement);
|
||||||
acc.set(newElement.id, newElement);
|
acc.set(newElement.id, newElement);
|
||||||
}
|
}
|
||||||
@ -1196,6 +1224,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
|
|
||||||
private static createGetter =
|
private static createGetter =
|
||||||
(
|
(
|
||||||
|
type: "added" | "removed" | "updated",
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
flags: {
|
flags: {
|
||||||
@ -1221,6 +1250,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
) {
|
) {
|
||||||
flags.containsVisibleDifference = true;
|
flags.containsVisibleDifference = true;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// not in elements, not in snapshot? element might have been added remotely!
|
||||||
|
element = newElementWith(
|
||||||
|
{ id, version: 1 } as OrderedExcalidrawElement,
|
||||||
|
{
|
||||||
|
...partial,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1257,7 +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>>;
|
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
|
||||||
// we want to override `crop` only if modified so that we don't reset
|
// we want to override `crop` only if modified so that we don't reset
|
||||||
// when undoing/redoing unrelated change
|
// when undoing/redoing unrelated change
|
||||||
@ -1270,10 +1308,12 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!flags.containsVisibleDifference) {
|
if (!flags.containsVisibleDifference) {
|
||||||
// strip away fractional as even if it would be different, it doesn't have to result in visible change
|
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change
|
||||||
const { index, ...rest } = directlyApplicablePartial;
|
const { index, ...rest } = directlyApplicablePartial;
|
||||||
const containsVisibleDifference =
|
const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
|
||||||
ElementsChange.checkForVisibleDifference(element, rest);
|
element,
|
||||||
|
rest,
|
||||||
|
);
|
||||||
|
|
||||||
flags.containsVisibleDifference = containsVisibleDifference;
|
flags.containsVisibleDifference = containsVisibleDifference;
|
||||||
}
|
}
|
||||||
@ -1316,6 +1356,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
* Resolves conflicts for all previously added, removed and updated elements.
|
* Resolves conflicts for all previously added, removed and updated elements.
|
||||||
* Updates the previous deltas with all the changes after conflict resolution.
|
* Updates the previous deltas with all the changes after conflict resolution.
|
||||||
*
|
*
|
||||||
|
* // TODO: revisit since some bound arrows seem to be often redrawn incorrectly
|
||||||
|
*
|
||||||
* @returns all elements affected by the conflict resolution
|
* @returns all elements affected by the conflict resolution
|
||||||
*/
|
*/
|
||||||
private resolveConflicts(
|
private resolveConflicts(
|
||||||
@ -1346,7 +1388,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
nextElement,
|
nextElement,
|
||||||
nextElements,
|
nextElements,
|
||||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||||
) as OrderedExcalidrawElement;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
nextAffectedElements.set(affectedElement.id, affectedElement);
|
nextAffectedElements.set(affectedElement.id, affectedElement);
|
||||||
@ -1354,17 +1396,18 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
|
// removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
|
||||||
for (const [id] of this.removed) {
|
for (const id of Object.keys(this.removed)) {
|
||||||
ElementsChange.unbindAffected(prevElements, nextElements, id, updater);
|
ElementsDelta.unbindAffected(prevElements, nextElements, id, updater);
|
||||||
}
|
}
|
||||||
|
|
||||||
// added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound
|
// added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound
|
||||||
for (const [id] of this.added) {
|
for (const id of Object.keys(this.added)) {
|
||||||
ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
|
ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
|
||||||
}
|
}
|
||||||
|
|
||||||
// updated delta is affecting the binding only in case it contains changed binding or bindable property
|
// updated delta is affecting the binding only in case it contains changed binding or bindable property
|
||||||
for (const [id] of Array.from(this.updated).filter(([_, delta]) =>
|
for (const [id] of Array.from(Object.entries(this.updated)).filter(
|
||||||
|
([_, delta]) =>
|
||||||
Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
|
Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
|
||||||
bindingProperties.has(prop as BindingProp | BindableProp),
|
bindingProperties.has(prop as BindingProp | BindableProp),
|
||||||
),
|
),
|
||||||
@ -1375,7 +1418,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
|
ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
|
||||||
}
|
}
|
||||||
|
|
||||||
// filter only previous elements, which were now affected
|
// filter only previous elements, which were now affected
|
||||||
@ -1385,21 +1428,21 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
|
|
||||||
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
||||||
// technically we could do better here if perf. would become an issue
|
// technically we could do better here if perf. would become an issue
|
||||||
const { added, removed, updated } = ElementsChange.calculate(
|
const { added, removed, updated } = ElementsDelta.calculate(
|
||||||
prevAffectedElements,
|
prevAffectedElements,
|
||||||
nextAffectedElements,
|
nextAffectedElements,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [id, delta] of added) {
|
for (const [id, delta] of Object.entries(added)) {
|
||||||
this.added.set(id, delta);
|
this.added[id] = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, delta] of removed) {
|
for (const [id, delta] of Object.entries(removed)) {
|
||||||
this.removed.set(id, delta);
|
this.removed[id] = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, delta] of updated) {
|
for (const [id, delta] of Object.entries(updated)) {
|
||||||
this.updated.set(id, delta);
|
this.updated[id] = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextAffectedElements;
|
return nextAffectedElements;
|
||||||
@ -1572,7 +1615,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
|
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
|
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
|
||||||
console.error(`Couldn't postprocess elements change deltas.`);
|
console.error(`Couldn't postprocess elements delta.`);
|
||||||
|
|
||||||
if (isTestEnv() || isDevEnv()) {
|
if (isTestEnv() || isDevEnv()) {
|
||||||
throw e;
|
throw e;
|
||||||
@ -1585,8 +1628,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||||||
private static stripIrrelevantProps(
|
private static stripIrrelevantProps(
|
||||||
partial: Partial<OrderedExcalidrawElement>,
|
partial: Partial<OrderedExcalidrawElement>,
|
||||||
): ElementPartial {
|
): ElementPartial {
|
||||||
const { id, updated, version, versionNonce, seed, ...strippedPartial } =
|
const { id, updated, version, versionNonce, ...strippedPartial } = partial;
|
||||||
partial;
|
|
||||||
|
|
||||||
return strippedPartial;
|
return strippedPartial;
|
||||||
}
|
}
|
@ -462,12 +462,18 @@ const createBindingArrow = (
|
|||||||
bindingArrow as OrderedExcalidrawElement,
|
bindingArrow as OrderedExcalidrawElement,
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(bindingArrow, scene, [
|
LinearElementEditor.movePoints(
|
||||||
|
bindingArrow,
|
||||||
|
scene,
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
1,
|
||||||
{
|
{
|
||||||
index: 1,
|
|
||||||
point: bindingArrow.points[1],
|
point: bindingArrow.points[1],
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
const update = updateElbowArrowPoints(
|
const update = updateElbowArrowPoints(
|
||||||
bindingArrow,
|
bindingArrow,
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { toIterable } from "@excalidraw/common";
|
||||||
|
|
||||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||||
import { isLinearElementType } from "./typeChecks";
|
import { isLinearElementType } from "./typeChecks";
|
||||||
|
|
||||||
@ -5,6 +7,7 @@ import type {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
|
ElementsMapOrArray,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -16,12 +19,10 @@ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
|
|||||||
/**
|
/**
|
||||||
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
|
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
|
||||||
*/
|
*/
|
||||||
export const hashElementsVersion = (
|
export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
): number => {
|
|
||||||
let hash = 5381;
|
let hash = 5381;
|
||||||
for (let i = 0; i < elements.length; i++) {
|
for (const element of toIterable(elements)) {
|
||||||
hash = (hash << 5) + hash + elements[i].versionNonce;
|
hash = (hash << 5) + hash + element.versionNonce;
|
||||||
}
|
}
|
||||||
return hash >>> 0; // Ensure unsigned 32-bit integer
|
return hash >>> 0; // Ensure unsigned 32-bit integer
|
||||||
};
|
};
|
||||||
|
@ -20,7 +20,7 @@ import {
|
|||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { Store } from "@excalidraw/excalidraw/store";
|
import type { Store } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
@ -85,6 +85,7 @@ import type {
|
|||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
FixedSegment,
|
FixedSegment,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
|
PointsPositionUpdates,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const editorMidPointsCache: {
|
const editorMidPointsCache: {
|
||||||
@ -309,16 +310,22 @@ export class LinearElementEditor {
|
|||||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(element, scene, [
|
LinearElementEditor.movePoints(
|
||||||
|
element,
|
||||||
|
scene,
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
selectedIndex,
|
||||||
{
|
{
|
||||||
index: selectedIndex,
|
|
||||||
point: pointFrom(
|
point: pointFrom(
|
||||||
width + referencePoint[0],
|
width + referencePoint[0],
|
||||||
height + referencePoint[1],
|
height + referencePoint[1],
|
||||||
),
|
),
|
||||||
isDragging: selectedIndex === lastClickedPoint,
|
isDragging: selectedIndex === lastClickedPoint,
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||||
element,
|
element,
|
||||||
@ -334,6 +341,7 @@ export class LinearElementEditor {
|
|||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(
|
||||||
element,
|
element,
|
||||||
scene,
|
scene,
|
||||||
|
new Map(
|
||||||
selectedPointsIndices.map((pointIndex) => {
|
selectedPointsIndices.map((pointIndex) => {
|
||||||
const newPointPosition: LocalPoint =
|
const newPointPosition: LocalPoint =
|
||||||
pointIndex === lastClickedPoint
|
pointIndex === lastClickedPoint
|
||||||
@ -342,18 +350,23 @@ export class LinearElementEditor {
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
event[KEYS.CTRL_OR_CMD]
|
||||||
|
? null
|
||||||
|
: app.getEffectiveGridSize(),
|
||||||
)
|
)
|
||||||
: pointFrom(
|
: pointFrom(
|
||||||
element.points[pointIndex][0] + deltaX,
|
element.points[pointIndex][0] + deltaX,
|
||||||
element.points[pointIndex][1] + deltaY,
|
element.points[pointIndex][1] + deltaY,
|
||||||
);
|
);
|
||||||
return {
|
return [
|
||||||
index: pointIndex,
|
pointIndex,
|
||||||
|
{
|
||||||
point: newPointPosition,
|
point: newPointPosition,
|
||||||
isDragging: pointIndex === lastClickedPoint,
|
isDragging: pointIndex === lastClickedPoint,
|
||||||
};
|
},
|
||||||
|
];
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -466,15 +479,21 @@ export class LinearElementEditor {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
LinearElementEditor.movePoints(element, scene, [
|
LinearElementEditor.movePoints(
|
||||||
|
element,
|
||||||
|
scene,
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
selectedPoint,
|
||||||
{
|
{
|
||||||
index: selectedPoint,
|
|
||||||
point:
|
point:
|
||||||
selectedPoint === 0
|
selectedPoint === 0
|
||||||
? element.points[element.points.length - 1]
|
? element.points[element.points.length - 1]
|
||||||
: element.points[0],
|
: element.points[0],
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bindingElement = isBindingEnabled(appState)
|
const bindingElement = isBindingEnabled(appState)
|
||||||
@ -822,7 +841,7 @@ export class LinearElementEditor {
|
|||||||
});
|
});
|
||||||
ret.didAddPoint = true;
|
ret.didAddPoint = true;
|
||||||
}
|
}
|
||||||
store.shouldCaptureIncrement();
|
store.scheduleCapture();
|
||||||
ret.linearElementEditor = {
|
ret.linearElementEditor = {
|
||||||
...linearElementEditor,
|
...linearElementEditor,
|
||||||
pointerDownState: {
|
pointerDownState: {
|
||||||
@ -1001,12 +1020,18 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.movePoints(element, app.scene, [
|
LinearElementEditor.movePoints(
|
||||||
|
element,
|
||||||
|
app.scene,
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
element.points.length - 1,
|
||||||
{
|
{
|
||||||
index: element.points.length - 1,
|
|
||||||
point: newPoint,
|
point: newPoint,
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
|
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
|
||||||
}
|
}
|
||||||
@ -1240,12 +1265,16 @@ export class LinearElementEditor {
|
|||||||
// potentially expanding the bounding box
|
// potentially expanding the bounding box
|
||||||
if (pointAddedToEnd) {
|
if (pointAddedToEnd) {
|
||||||
const lastPoint = element.points[element.points.length - 1];
|
const lastPoint = element.points[element.points.length - 1];
|
||||||
LinearElementEditor.movePoints(element, scene, [
|
LinearElementEditor.movePoints(
|
||||||
{
|
element,
|
||||||
index: element.points.length - 1,
|
scene,
|
||||||
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
|
new Map([
|
||||||
},
|
[
|
||||||
]);
|
element.points.length - 1,
|
||||||
|
{ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30) },
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -1333,7 +1362,7 @@ export class LinearElementEditor {
|
|||||||
static movePoints(
|
static movePoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
|
pointUpdates: PointsPositionUpdates,
|
||||||
otherUpdates?: {
|
otherUpdates?: {
|
||||||
startBinding?: PointBinding | null;
|
startBinding?: PointBinding | null;
|
||||||
endBinding?: PointBinding | null;
|
endBinding?: PointBinding | null;
|
||||||
@ -1343,14 +1372,11 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
// Handle loop lock behavior
|
// Handle loop lock behavior
|
||||||
if (isLineElement(element) && element.loopLock) {
|
if (isLineElement(element) && element.loopLock) {
|
||||||
const firstPointUpdate = targetPoints.find(({ index }) => index === 0);
|
const firstPointUpdate = pointUpdates.get(0);
|
||||||
const lastPointUpdate = targetPoints.find(
|
const lastPointUpdate = pointUpdates.get(points.length - 1);
|
||||||
({ index }) => index === points.length - 1,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (firstPointUpdate) {
|
if (firstPointUpdate) {
|
||||||
targetPoints.push({
|
pointUpdates.set(points.length - 1, {
|
||||||
index: points.length - 1,
|
|
||||||
point: pointFrom(
|
point: pointFrom(
|
||||||
firstPointUpdate.point[0],
|
firstPointUpdate.point[0],
|
||||||
firstPointUpdate.point[1],
|
firstPointUpdate.point[1],
|
||||||
@ -1358,8 +1384,7 @@ export class LinearElementEditor {
|
|||||||
isDragging: firstPointUpdate.isDragging,
|
isDragging: firstPointUpdate.isDragging,
|
||||||
});
|
});
|
||||||
} else if (lastPointUpdate) {
|
} else if (lastPointUpdate) {
|
||||||
targetPoints.push({
|
pointUpdates.set(0, {
|
||||||
index: 0,
|
|
||||||
point: pointFrom(lastPointUpdate.point[0], lastPointUpdate.point[1]),
|
point: pointFrom(lastPointUpdate.point[0], lastPointUpdate.point[1]),
|
||||||
isDragging: lastPointUpdate.isDragging,
|
isDragging: lastPointUpdate.isDragging,
|
||||||
});
|
});
|
||||||
@ -1372,8 +1397,7 @@ export class LinearElementEditor {
|
|||||||
// offset it. We do the same with actual element.x/y position, so
|
// offset it. We do the same with actual element.x/y position, so
|
||||||
// this hacks are completely transparent to the user.
|
// this hacks are completely transparent to the user.
|
||||||
const [deltaX, deltaY] =
|
const [deltaX, deltaY] =
|
||||||
targetPoints.find(({ index }) => index === 0)?.point ??
|
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
|
||||||
pointFrom<LocalPoint>(0, 0);
|
|
||||||
const [offsetX, offsetY] = pointFrom<LocalPoint>(
|
const [offsetX, offsetY] = pointFrom<LocalPoint>(
|
||||||
deltaX - points[0][0],
|
deltaX - points[0][0],
|
||||||
deltaY - points[0][1],
|
deltaY - points[0][1],
|
||||||
@ -1381,12 +1405,12 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
const nextPoints = isElbowArrow(element)
|
const nextPoints = isElbowArrow(element)
|
||||||
? [
|
? [
|
||||||
targetPoints.find((t) => t.index === 0)?.point ?? points[0],
|
pointUpdates.get(0)?.point ?? points[0],
|
||||||
targetPoints.find((t) => t.index === points.length - 1)?.point ??
|
pointUpdates.get(points.length - 1)?.point ??
|
||||||
points[points.length - 1],
|
points[points.length - 1],
|
||||||
]
|
]
|
||||||
: points.map((p, idx) => {
|
: points.map((p, idx) => {
|
||||||
const current = targetPoints.find((t) => t.index === idx)?.point ?? p;
|
const current = pointUpdates.get(idx)?.point ?? p;
|
||||||
|
|
||||||
return pointFrom<LocalPoint>(
|
return pointFrom<LocalPoint>(
|
||||||
current[0] - offsetX,
|
current[0] - offsetX,
|
||||||
@ -1402,11 +1426,7 @@ export class LinearElementEditor {
|
|||||||
offsetY,
|
offsetY,
|
||||||
otherUpdates,
|
otherUpdates,
|
||||||
{
|
{
|
||||||
isDragging: targetPoints.reduce(
|
isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging),
|
||||||
(dragging, targetPoint): boolean =>
|
|
||||||
dragging || targetPoint.isDragging === true,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -962,11 +962,6 @@ export const resizeSingleElement = (
|
|||||||
isDragging: false,
|
isDragging: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
updateBoundElements(latestElement, scene, {
|
|
||||||
// TODO: confirm with MARK if this actually makes sense
|
|
||||||
newSize: { width: nextWidth, height: nextHeight },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (boundTextElement && boundTextFont != null) {
|
if (boundTextElement && boundTextFont != null) {
|
||||||
scene.mutateElement(boundTextElement, {
|
scene.mutateElement(boundTextElement, {
|
||||||
fontSize: boundTextFont.fontSize,
|
fontSize: boundTextFont.fontSize,
|
||||||
@ -978,6 +973,11 @@ export const resizeSingleElement = (
|
|||||||
handleDirection,
|
handleDirection,
|
||||||
shouldMaintainAspectRatio,
|
shouldMaintainAspectRatio,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
updateBoundElements(latestElement, scene, {
|
||||||
|
// TODO: confirm with MARK if this actually makes sense
|
||||||
|
newSize: { width: nextWidth, height: nextHeight },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
968
packages/element/src/store.ts
Normal file
968
packages/element/src/store.ts
Normal 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);
|
@ -296,6 +296,11 @@ export type FixedPointBinding = Merge<
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type PointsPositionUpdates = Map<
|
||||||
|
number,
|
||||||
|
{ point: LocalPoint; isDragging?: boolean }
|
||||||
|
>;
|
||||||
|
|
||||||
export type Arrowhead =
|
export type Arrowhead =
|
||||||
| "arrow"
|
| "arrow"
|
||||||
| "bar"
|
| "bar"
|
||||||
|
143
packages/element/tests/delta.test.tsx
Normal file
143
packages/element/tests/delta.test.tsx
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,8 +1,9 @@
|
|||||||
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
|
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
|
||||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -8,6 +8,8 @@ import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
|||||||
|
|
||||||
import { alignElements } from "@excalidraw/element/align";
|
import { alignElements } from "@excalidraw/element/align";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Alignment } from "@excalidraw/element/align";
|
import type { Alignment } from "@excalidraw/element/align";
|
||||||
@ -25,7 +27,6 @@ import {
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -33,6 +33,8 @@ import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
|||||||
|
|
||||||
import { newElement } from "@excalidraw/element/newElement";
|
import { newElement } from "@excalidraw/element/newElement";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
@ -44,8 +46,6 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
|||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
|
@ -17,6 +17,8 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
|||||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||||
import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds";
|
import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -44,7 +46,6 @@ import { t } from "../i18n";
|
|||||||
import { getNormalizedZoom } from "../scene";
|
import { getNormalizedZoom } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -3,6 +3,8 @@ import { getTextFromElements } from "@excalidraw/element/textElement";
|
|||||||
|
|
||||||
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
|
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
copyTextToSystemClipboard,
|
copyTextToSystemClipboard,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
@ -15,8 +17,6 @@ import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
|||||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { isImageElement } from "@excalidraw/element/typeChecks";
|
import { isImageElement } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
|
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { cropIcon } from "../components/icons";
|
import { cropIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -17,11 +17,12 @@ import {
|
|||||||
selectGroupsForSelectedElements,
|
selectGroupsForSelectedElements,
|
||||||
} from "@excalidraw/element/groups";
|
} from "@excalidraw/element/groups";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
import { TrashIcon } from "../components/icons";
|
import { TrashIcon } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
|
||||||
|
@ -8,6 +8,8 @@ import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/fra
|
|||||||
|
|
||||||
import { distributeElements } from "@excalidraw/element/distribute";
|
import { distributeElements } from "@excalidraw/element/distribute";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Distribution } from "@excalidraw/element/distribute";
|
import type { Distribution } from "@excalidraw/element/distribute";
|
||||||
@ -21,7 +23,6 @@ import {
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -18,12 +18,13 @@ import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
|||||||
|
|
||||||
import { duplicateElements } from "@excalidraw/element/duplicate";
|
import { duplicateElements } from "@excalidraw/element/duplicate";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { DuplicateIcon } from "../components/icons";
|
import { DuplicateIcon } from "../components/icons";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -4,11 +4,12 @@ import {
|
|||||||
getLinkIdAndTypeFromSelection,
|
getLinkIdAndTypeFromSelection,
|
||||||
} from "@excalidraw/element/elementLink";
|
} from "@excalidraw/element/elementLink";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { copyTextToSystemClipboard } from "../clipboard";
|
import { copyTextToSystemClipboard } from "../clipboard";
|
||||||
import { copyIcon, elementLinkIcon } from "../components/icons";
|
import { copyIcon, elementLinkIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -4,12 +4,13 @@ import { newElementWith } from "@excalidraw/element/mutateElement";
|
|||||||
|
|
||||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { updateActiveTool } from "@excalidraw/common";
|
import { updateActiveTool } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { setCursorForShape } from "../cursor";
|
import { setCursorForShape } from "../cursor";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -7,6 +7,8 @@ import {
|
|||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { Theme } from "@excalidraw/element/types";
|
import type { Theme } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { useDevice } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
@ -24,7 +26,6 @@ import { resaveAsImageWithScene } from "../data/resave";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { getExportSize } from "../scene/export";
|
import { getExportSize } from "../scene/export";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import "../components/ToolIcon.scss";
|
import "../components/ToolIcon.scss";
|
||||||
|
|
||||||
|
@ -16,11 +16,12 @@ import { isPathALoop } from "@excalidraw/element/shapes";
|
|||||||
|
|
||||||
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { resetCursor } from "../cursor";
|
import { resetCursor } from "../cursor";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -15,6 +15,8 @@ import {
|
|||||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
||||||
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
@ -24,7 +26,6 @@ import type {
|
|||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||||
|
|
||||||
|
@ -14,12 +14,13 @@ import { getElementsInGroup } from "@excalidraw/element/groups";
|
|||||||
|
|
||||||
import { getCommonBounds } from "@excalidraw/element/bounds";
|
import { getCommonBounds } from "@excalidraw/element/bounds";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { setCursorForShape } from "../cursor";
|
import { setCursorForShape } from "../cursor";
|
||||||
import { frameToolIcon } from "../components/icons";
|
import { frameToolIcon } from "../components/icons";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -28,6 +28,8 @@ import {
|
|||||||
|
|
||||||
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
@ -40,7 +42,6 @@ import { UngroupIcon, GroupIcon } from "../components/icons";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
|
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 type { SceneElementsMap } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
@ -7,10 +11,8 @@ import { UndoIcon, RedoIcon } from "../components/icons";
|
|||||||
import { HistoryChangedEvent } from "../history";
|
import { HistoryChangedEvent } from "../history";
|
||||||
import { useEmitter } from "../hooks/useEmitter";
|
import { useEmitter } from "../hooks/useEmitter";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import type { History } from "../history";
|
import type { History } from "../history";
|
||||||
import type { Store } from "../store";
|
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import type { Action, ActionResult } from "./types";
|
import type { Action, ActionResult } from "./types";
|
||||||
|
|
||||||
@ -35,7 +37,11 @@ const executeHistoryAction = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [nextElementsMap, nextAppState] = result;
|
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 {
|
return {
|
||||||
appState: nextAppState,
|
appState: nextAppState,
|
||||||
@ -47,9 +53,9 @@ const executeHistoryAction = (
|
|||||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActionCreator = (history: History, store: Store) => Action;
|
type ActionCreator = (history: History) => Action;
|
||||||
|
|
||||||
export const createUndoAction: ActionCreator = (history, store) => ({
|
export const createUndoAction: ActionCreator = (history) => ({
|
||||||
name: "undo",
|
name: "undo",
|
||||||
label: "buttons.undo",
|
label: "buttons.undo",
|
||||||
icon: UndoIcon,
|
icon: UndoIcon,
|
||||||
@ -57,11 +63,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, value, app) =>
|
perform: (elements, appState, value, app) =>
|
||||||
executeHistoryAction(app, appState, () =>
|
executeHistoryAction(app, appState, () =>
|
||||||
history.undo(
|
history.undo(arrayToMap(elements) as SceneElementsMap, appState),
|
||||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
|
||||||
appState,
|
|
||||||
store.snapshot,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
||||||
@ -88,19 +90,15 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createRedoAction: ActionCreator = (history, store) => ({
|
export const createRedoAction: ActionCreator = (history) => ({
|
||||||
name: "redo",
|
name: "redo",
|
||||||
label: "buttons.redo",
|
label: "buttons.redo",
|
||||||
icon: RedoIcon,
|
icon: RedoIcon,
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, _, app) =>
|
perform: (elements, appState, __, app) =>
|
||||||
executeHistoryAction(app, appState, () =>
|
executeHistoryAction(app, appState, () =>
|
||||||
history.redo(
|
history.redo(arrayToMap(elements) as SceneElementsMap, appState),
|
||||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
|
||||||
appState,
|
|
||||||
store.snapshot,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
||||||
|
@ -6,6 +6,8 @@ import {
|
|||||||
} from "@excalidraw/element/typeChecks";
|
} from "@excalidraw/element/typeChecks";
|
||||||
import { arrayToMap } from "@excalidraw/common";
|
import { arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawLineElement,
|
ExcalidrawLineElement,
|
||||||
@ -15,7 +17,6 @@ import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"
|
|||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { lineEditorIcon, polygonIcon } from "../components/icons";
|
import { lineEditorIcon, polygonIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { ButtonIcon } from "../components/ButtonIcon";
|
import { ButtonIcon } from "../components/ButtonIcon";
|
||||||
|
|
||||||
|
@ -2,13 +2,14 @@ import { isEmbeddableElement } from "@excalidraw/element/typeChecks";
|
|||||||
|
|
||||||
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
||||||
import { LinkIcon } from "../components/icons";
|
import { LinkIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -4,12 +4,12 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions";
|
import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { getClientColor } from "../clients";
|
import { getClientColor } from "../clients";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import {
|
import {
|
||||||
@ -8,7 +10,6 @@ import {
|
|||||||
microphoneMutedIcon,
|
microphoneMutedIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -56,6 +56,8 @@ import { hasStrokeColor } from "@excalidraw/element/comparisons";
|
|||||||
|
|
||||||
import { updateElbowArrowPoints } from "@excalidraw/element/elbowArrow";
|
import { updateElbowArrowPoints } from "@excalidraw/element/elbowArrow";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
import type { LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -72,6 +74,8 @@ import type {
|
|||||||
|
|
||||||
import type Scene from "@excalidraw/element/Scene";
|
import type Scene from "@excalidraw/element/Scene";
|
||||||
|
|
||||||
|
import type { CaptureUpdateActionType } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||||
@ -133,13 +137,11 @@ import {
|
|||||||
getTargetElements,
|
getTargetElements,
|
||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { toggleLinePolygonState } from "../../element/src/shapes";
|
import { toggleLinePolygonState } from "../../element/src/shapes";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { CaptureUpdateActionType } from "../store";
|
|
||||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
|
@ -6,9 +6,9 @@ import { arrayToMap, KEYS } from "@excalidraw/common";
|
|||||||
|
|
||||||
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
|
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "../store";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { selectAllIcon } from "../components/icons";
|
import { selectAllIcon } from "../components/icons";
|
||||||
|
|
||||||
|
@ -24,13 +24,14 @@ import {
|
|||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "@excalidraw/element/textElement";
|
} from "@excalidraw/element/textElement";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
|
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { paintIcon } from "../components/icons";
|
import { paintIcon } from "../components/icons";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -5,8 +5,9 @@ import { measureText } from "@excalidraw/element/textMeasurements";
|
|||||||
|
|
||||||
import { isTextElement } from "@excalidraw/element/typeChecks";
|
import { isTextElement } from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { CODES, KEYS } from "@excalidraw/common";
|
import { CODES, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { gridIcon } from "../components/icons";
|
import { gridIcon } from "../components/icons";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { CODES, KEYS } from "@excalidraw/common";
|
import { CODES, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { magnetIcon } from "../components/icons";
|
import { magnetIcon } from "../components/icons";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -5,8 +5,9 @@ import {
|
|||||||
DEFAULT_SIDEBAR,
|
DEFAULT_SIDEBAR,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { searchIcon } from "../components/icons";
|
import { searchIcon } from "../components/icons";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -5,7 +7,6 @@ import {
|
|||||||
convertElementTypePopupAtom,
|
convertElementTypePopupAtom,
|
||||||
} from "../components/ConvertElementTypePopup";
|
} from "../components/ConvertElementTypePopup";
|
||||||
import { editorJotaiStore } from "../editor-jotai";
|
import { editorJotaiStore } from "../editor-jotai";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { CODES, KEYS } from "@excalidraw/common";
|
import { CODES, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { abacusIcon } from "../components/icons";
|
import { abacusIcon } from "../components/icons";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { CODES, KEYS } from "@excalidraw/common";
|
import { CODES, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { eyeIcon } from "../components/icons";
|
import { eyeIcon } from "../components/icons";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { CODES, KEYS } from "@excalidraw/common";
|
import { CODES, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import { coffeeIcon } from "../components/icons";
|
import { coffeeIcon } from "../components/icons";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -7,6 +7,8 @@ import {
|
|||||||
moveAllRight,
|
moveAllRight,
|
||||||
} from "@excalidraw/element/zindex";
|
} from "@excalidraw/element/zindex";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BringForwardIcon,
|
BringForwardIcon,
|
||||||
BringToFrontIcon,
|
BringToFrontIcon,
|
||||||
@ -14,7 +16,6 @@ import {
|
|||||||
SendToBackIcon,
|
SendToBackIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CaptureUpdateAction } from "../store";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
@ -3,7 +3,8 @@ import type {
|
|||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { CaptureUpdateActionType } from "../store";
|
import type { CaptureUpdateActionType } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
AppState,
|
AppState,
|
||||||
|
@ -101,6 +101,7 @@ import {
|
|||||||
type EXPORT_IMAGE_TYPES,
|
type EXPORT_IMAGE_TYPES,
|
||||||
randomInteger,
|
randomInteger,
|
||||||
CLASSES,
|
CLASSES,
|
||||||
|
Emitter,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -303,6 +304,8 @@ import { isNonDeletedElement } from "@excalidraw/element";
|
|||||||
|
|
||||||
import Scene from "@excalidraw/element/Scene";
|
import Scene from "@excalidraw/element/Scene";
|
||||||
|
|
||||||
|
import { Store, CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
|
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
|
||||||
|
|
||||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||||
@ -331,6 +334,7 @@ import type {
|
|||||||
ExcalidrawNonSelectionElement,
|
ExcalidrawNonSelectionElement,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
|
SceneElementsMap,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
|
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
|
||||||
@ -454,9 +458,7 @@ import {
|
|||||||
resetCursor,
|
resetCursor,
|
||||||
setCursorForShape,
|
setCursorForShape,
|
||||||
} from "../cursor";
|
} from "../cursor";
|
||||||
import { Emitter } from "../emitter";
|
|
||||||
import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
|
import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
|
||||||
import { Store, CaptureUpdateAction } from "../store";
|
|
||||||
import { LaserTrails } from "../laser-trails";
|
import { LaserTrails } from "../laser-trails";
|
||||||
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
||||||
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
|
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
|
||||||
@ -761,8 +763,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.renderer = new Renderer(this.scene);
|
this.renderer = new Renderer(this.scene);
|
||||||
this.visibleElements = [];
|
this.visibleElements = [];
|
||||||
|
|
||||||
this.store = new Store();
|
this.store = new Store(this);
|
||||||
this.history = new History();
|
this.history = new History(this.store);
|
||||||
|
|
||||||
if (excalidrawAPI) {
|
if (excalidrawAPI) {
|
||||||
const api: ExcalidrawImperativeAPI = {
|
const api: ExcalidrawImperativeAPI = {
|
||||||
@ -792,6 +794,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
updateFrameRendering: this.updateFrameRendering,
|
updateFrameRendering: this.updateFrameRendering,
|
||||||
toggleSidebar: this.toggleSidebar,
|
toggleSidebar: this.toggleSidebar,
|
||||||
onChange: (cb) => this.onChangeEmitter.on(cb),
|
onChange: (cb) => this.onChangeEmitter.on(cb),
|
||||||
|
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
|
||||||
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
||||||
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
|
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
|
||||||
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
|
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
|
||||||
@ -810,15 +813,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.fonts = new Fonts(this.scene);
|
this.fonts = new Fonts(this.scene);
|
||||||
this.history = new History();
|
this.history = new History(this.store);
|
||||||
|
|
||||||
this.actionManager.registerAll(actions);
|
this.actionManager.registerAll(actions);
|
||||||
this.actionManager.registerAction(
|
this.actionManager.registerAction(createUndoAction(this.history));
|
||||||
createUndoAction(this.history, this.store),
|
this.actionManager.registerAction(createRedoAction(this.history));
|
||||||
);
|
|
||||||
this.actionManager.registerAction(
|
|
||||||
createRedoAction(this.history, this.store),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateEditorAtom = <Value, Args extends unknown[], Result>(
|
updateEditorAtom = <Value, Args extends unknown[], Result>(
|
||||||
@ -1899,6 +1898,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return this.scene.getElementsIncludingDeleted();
|
return this.scene.getElementsIncludingDeleted();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public getSceneElementsMapIncludingDeleted = () => {
|
||||||
|
return this.scene.getElementsMapIncludingDeleted();
|
||||||
|
};
|
||||||
|
|
||||||
public getSceneElements = () => {
|
public getSceneElements = () => {
|
||||||
return this.scene.getNonDeletedElements();
|
return this.scene.getNonDeletedElements();
|
||||||
};
|
};
|
||||||
@ -2215,11 +2218,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actionResult.captureUpdate === CaptureUpdateAction.NEVER) {
|
this.store.scheduleAction(actionResult.captureUpdate);
|
||||||
this.store.shouldUpdateSnapshot();
|
|
||||||
} else if (actionResult.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
|
|
||||||
this.store.shouldCaptureIncrement();
|
|
||||||
}
|
|
||||||
|
|
||||||
let didUpdate = false;
|
let didUpdate = false;
|
||||||
|
|
||||||
@ -2292,10 +2291,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
didUpdate = true;
|
didUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!didUpdate) {
|
||||||
!didUpdate &&
|
|
||||||
actionResult.captureUpdate !== CaptureUpdateAction.EVENTUALLY
|
|
||||||
) {
|
|
||||||
this.scene.triggerUpdate();
|
this.scene.triggerUpdate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -2547,10 +2543,19 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.onStoreIncrementEmitter.on((increment) => {
|
this.store.onDurableIncrementEmitter.on((increment) => {
|
||||||
this.history.record(increment.elementsChange, increment.appStateChange);
|
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.scene.onUpdate(this.triggerRender);
|
||||||
this.addEventListeners();
|
this.addEventListeners();
|
||||||
|
|
||||||
@ -2610,6 +2615,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.eraserTrail.stop();
|
this.eraserTrail.stop();
|
||||||
this.onChangeEmitter.clear();
|
this.onChangeEmitter.clear();
|
||||||
this.store.onStoreIncrementEmitter.clear();
|
this.store.onStoreIncrementEmitter.clear();
|
||||||
|
this.store.onDurableIncrementEmitter.clear();
|
||||||
ShapeCache.destroy();
|
ShapeCache.destroy();
|
||||||
SnapCache.destroy();
|
SnapCache.destroy();
|
||||||
clearTimeout(touchTimeout);
|
clearTimeout(touchTimeout);
|
||||||
@ -2903,7 +2909,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state.editingLinearElement &&
|
this.state.editingLinearElement &&
|
||||||
!this.state.selectedElementIds[this.state.editingLinearElement.elementId]
|
!this.state.selectedElementIds[this.state.editingLinearElement.elementId]
|
||||||
) {
|
) {
|
||||||
// defer so that the shouldCaptureIncrement flag isn't reset via current update
|
// defer so that the scheduleCapture flag isn't reset via current update
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// execute only if the condition still holds when the deferred callback
|
// execute only if the condition still holds when the deferred callback
|
||||||
// executes (it can be scheduled multiple times depending on how
|
// executes (it can be scheduled multiple times depending on how
|
||||||
@ -3358,7 +3364,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.addMissingFiles(opts.files);
|
this.addMissingFiles(opts.files);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
|
|
||||||
const nextElementsToSelect =
|
const nextElementsToSelect =
|
||||||
excludeElementsInFramesFromSelection(duplicatedElements);
|
excludeElementsInFramesFromSelection(duplicatedElements);
|
||||||
@ -3619,7 +3625,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
PLAIN_PASTE_TOAST_SHOWN = true;
|
PLAIN_PASTE_TOAST_SHOWN = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
setAppState: React.Component<any, AppState>["setState"] = (
|
setAppState: React.Component<any, AppState>["setState"] = (
|
||||||
@ -3975,51 +3981,37 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
*/
|
*/
|
||||||
captureUpdate?: SceneData["captureUpdate"];
|
captureUpdate?: SceneData["captureUpdate"];
|
||||||
}) => {
|
}) => {
|
||||||
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
|
const { elements, appState, collaborators, captureUpdate } = sceneData;
|
||||||
|
|
||||||
if (
|
const nextElements = elements ? syncInvalidIndices(elements) : undefined;
|
||||||
sceneData.captureUpdate &&
|
|
||||||
sceneData.captureUpdate !== CaptureUpdateAction.EVENTUALLY
|
|
||||||
) {
|
|
||||||
const prevCommittedAppState = this.store.snapshot.appState;
|
|
||||||
const prevCommittedElements = this.store.snapshot.elements;
|
|
||||||
|
|
||||||
const nextCommittedAppState = sceneData.appState
|
if (captureUpdate) {
|
||||||
? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
|
const nextElementsMap = elements
|
||||||
: prevCommittedAppState;
|
? (arrayToMap(nextElements ?? []) as SceneElementsMap)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const nextCommittedElements = sceneData.elements
|
const nextAppState = appState
|
||||||
? this.store.filterUncomittedElements(
|
? // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
|
||||||
this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements
|
Object.assign({}, this.store.snapshot.appState, appState)
|
||||||
arrayToMap(nextElements), // We expect all (already reconciled) elements
|
: undefined;
|
||||||
)
|
|
||||||
: prevCommittedElements;
|
|
||||||
|
|
||||||
// WARN: store action always performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter
|
this.store.scheduleMicroAction({
|
||||||
// do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well
|
action: captureUpdate,
|
||||||
if (sceneData.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
|
elements: nextElementsMap,
|
||||||
this.store.captureIncrement(
|
appState: nextAppState,
|
||||||
nextCommittedElements,
|
});
|
||||||
nextCommittedAppState,
|
|
||||||
);
|
|
||||||
} else if (sceneData.captureUpdate === CaptureUpdateAction.NEVER) {
|
|
||||||
this.store.updateSnapshot(
|
|
||||||
nextCommittedElements,
|
|
||||||
nextCommittedAppState,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sceneData.appState) {
|
if (appState) {
|
||||||
this.setState(sceneData.appState);
|
this.setState(appState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sceneData.elements) {
|
if (nextElements) {
|
||||||
this.scene.replaceAllElements(nextElements);
|
this.scene.replaceAllElements(nextElements);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sceneData.collaborators) {
|
if (collaborators) {
|
||||||
this.setState({ collaborators: sceneData.collaborators });
|
this.setState({ collaborators });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -4202,7 +4194,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
direction: event.shiftKey ? "left" : "right",
|
direction: event.shiftKey ? "left" : "right",
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (conversionType) {
|
if (conversionType) {
|
||||||
@ -4519,7 +4511,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state.editingLinearElement.elementId !==
|
this.state.editingLinearElement.elementId !==
|
||||||
selectedElements[0].id
|
selectedElements[0].id
|
||||||
) {
|
) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
if (!isElbowArrow(selectedElement)) {
|
if (!isElbowArrow(selectedElement)) {
|
||||||
this.setState({
|
this.setState({
|
||||||
editingLinearElement: new LinearElementEditor(
|
editingLinearElement: new LinearElementEditor(
|
||||||
@ -4845,7 +4837,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
if (nextActiveTool.type === "freedraw") {
|
if (nextActiveTool.type === "freedraw") {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextActiveTool.type === "lasso") {
|
if (nextActiveTool.type === "lasso") {
|
||||||
@ -5062,7 +5054,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
if (!isDeleted || isExistingElement) {
|
if (!isDeleted || isExistingElement) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
@ -5475,7 +5467,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private startImageCropping = (image: ExcalidrawImageElement) => {
|
private startImageCropping = (image: ExcalidrawImageElement) => {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
this.setState({
|
this.setState({
|
||||||
croppingElementId: image.id,
|
croppingElementId: image.id,
|
||||||
});
|
});
|
||||||
@ -5483,7 +5475,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
private finishImageCropping = () => {
|
private finishImageCropping = () => {
|
||||||
if (this.state.croppingElementId) {
|
if (this.state.croppingElementId) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
this.setState({
|
this.setState({
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
});
|
});
|
||||||
@ -5518,7 +5510,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
selectedElements[0].id) &&
|
selectedElements[0].id) &&
|
||||||
!isElbowArrow(selectedElements[0])
|
!isElbowArrow(selectedElements[0])
|
||||||
) {
|
) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
this.setState({
|
this.setState({
|
||||||
editingLinearElement: new LinearElementEditor(
|
editingLinearElement: new LinearElementEditor(
|
||||||
selectedElements[0],
|
selectedElements[0],
|
||||||
@ -5546,7 +5538,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
: -1;
|
: -1;
|
||||||
|
|
||||||
if (midPoint && midPoint > -1) {
|
if (midPoint && midPoint > -1) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
LinearElementEditor.deleteFixedSegment(
|
LinearElementEditor.deleteFixedSegment(
|
||||||
selectedElements[0],
|
selectedElements[0],
|
||||||
this.scene,
|
this.scene,
|
||||||
@ -5608,7 +5600,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
|
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
|
||||||
|
|
||||||
if (selectedGroupId) {
|
if (selectedGroupId) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
this.setState((prevState) => ({
|
this.setState((prevState) => ({
|
||||||
...prevState,
|
...prevState,
|
||||||
...selectGroupsForSelectedElements(
|
...selectGroupsForSelectedElements(
|
||||||
@ -9131,7 +9123,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
if (isLinearElement(newElement)) {
|
if (isLinearElement(newElement)) {
|
||||||
if (newElement!.points.length > 1) {
|
if (newElement!.points.length > 1) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
const pointerCoords = viewportCoordsToSceneCoords(
|
const pointerCoords = viewportCoordsToSceneCoords(
|
||||||
childEvent,
|
childEvent,
|
||||||
@ -9404,7 +9396,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resizingElement) {
|
if (resizingElement) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
|
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
|
||||||
@ -9744,7 +9736,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state.selectedElementIds,
|
this.state.selectedElementIds,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -9837,7 +9829,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.elementsPendingErasure = new Set();
|
this.elementsPendingErasure = new Set();
|
||||||
|
|
||||||
if (didChange) {
|
if (didChange) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
this.scene.replaceAllElements(elements);
|
this.scene.replaceAllElements(elements);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -10517,8 +10509,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
// restore the fractional indices by mutating elements
|
// restore the fractional indices by mutating elements
|
||||||
syncInvalidIndices(elements.concat(ret.data.elements));
|
syncInvalidIndices(elements.concat(ret.data.elements));
|
||||||
|
|
||||||
// update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo
|
// don't capture and only update the store snapshot for old elements,
|
||||||
this.store.updateSnapshot(arrayToMap(elements), this.state);
|
// otherwise we would end up with duplicated fractional indices on undo
|
||||||
|
this.store.scheduleMicroAction({
|
||||||
|
action: CaptureUpdateAction.NEVER,
|
||||||
|
elements: arrayToMap(elements) as SceneElementsMap,
|
||||||
|
appState: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
this.setState({ isLoading: true });
|
this.setState({ isLoading: true });
|
||||||
this.syncActionResult({
|
this.syncActionResult({
|
||||||
|
@ -5,11 +5,12 @@ import { EVENT, KEYS, cloneJSON } from "@excalidraw/common";
|
|||||||
|
|
||||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type Scene from "@excalidraw/element/Scene";
|
import type Scene from "@excalidraw/element/Scene";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "../../store";
|
|
||||||
import { useApp } from "../App";
|
import { useApp } from "../App";
|
||||||
import { InlineIcon } from "../InlineIcon";
|
import { InlineIcon } from "../InlineIcon";
|
||||||
|
|
||||||
|
@ -215,23 +215,6 @@ export const moveElement = (
|
|||||||
updateBindings(latestChildElement, scene, {
|
updateBindings(latestChildElement, scene, {
|
||||||
simultaneouslyUpdated: originalChildren,
|
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -34,6 +34,7 @@ export const TTDDialogInput = ({
|
|||||||
callbackRef.current?.();
|
callbackRef.current?.();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
textarea.focus();
|
||||||
textarea.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
textarea.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||||
return () => {
|
return () => {
|
||||||
textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||||
@ -47,7 +48,6 @@ export const TTDDialogInput = ({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
value={input}
|
value={input}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
autoFocus
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -14,6 +14,7 @@ import {
|
|||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
toValidURL,
|
toValidURL,
|
||||||
Queue,
|
Queue,
|
||||||
|
Emitter,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { hashElementsVersion, hashString } from "@excalidraw/element";
|
import { hashElementsVersion, hashString } from "@excalidraw/element";
|
||||||
@ -26,7 +27,6 @@ import type { MaybePromise } from "@excalidraw/common/utility-types";
|
|||||||
|
|
||||||
import { atom, editorJotaiStore } from "../editor-jotai";
|
import { atom, editorJotaiStore } from "../editor-jotai";
|
||||||
|
|
||||||
import { Emitter } from "../emitter";
|
|
||||||
import { AbortError } from "../errors";
|
import { AbortError } from "../errors";
|
||||||
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
|
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
|
import { Emitter } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CaptureUpdateAction,
|
||||||
|
StoreChange,
|
||||||
|
StoreDelta,
|
||||||
|
type Store,
|
||||||
|
} from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { Emitter } from "./emitter";
|
|
||||||
|
|
||||||
import type { AppStateChange, ElementsChange } from "./change";
|
|
||||||
import type { Snapshot } from "./store";
|
|
||||||
import type { AppState } from "./types";
|
import type { AppState } from "./types";
|
||||||
|
|
||||||
type HistoryStack = HistoryEntry[];
|
class HistoryEntry extends StoreDelta {}
|
||||||
|
|
||||||
export class HistoryChangedEvent {
|
export class HistoryChangedEvent {
|
||||||
constructor(
|
constructor(
|
||||||
@ -20,8 +25,8 @@ export class History {
|
|||||||
[HistoryChangedEvent]
|
[HistoryChangedEvent]
|
||||||
>();
|
>();
|
||||||
|
|
||||||
private readonly undoStack: HistoryStack = [];
|
public readonly undoStack: HistoryEntry[] = [];
|
||||||
private readonly redoStack: HistoryStack = [];
|
public readonly redoStack: HistoryEntry[] = [];
|
||||||
|
|
||||||
public get isUndoStackEmpty() {
|
public get isUndoStackEmpty() {
|
||||||
return this.undoStack.length === 0;
|
return this.undoStack.length === 0;
|
||||||
@ -31,25 +36,28 @@ export class History {
|
|||||||
return this.redoStack.length === 0;
|
return this.redoStack.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
constructor(private readonly store: Store) {}
|
||||||
|
|
||||||
public clear() {
|
public clear() {
|
||||||
this.undoStack.length = 0;
|
this.undoStack.length = 0;
|
||||||
this.redoStack.length = 0;
|
this.redoStack.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a local change which will go into the history
|
* Record a non-empty local durable increment, which will go into the undo stack..
|
||||||
|
* Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action.
|
||||||
*/
|
*/
|
||||||
public record(
|
public record(delta: StoreDelta) {
|
||||||
elementsChange: ElementsChange,
|
if (delta.isEmpty() || delta instanceof HistoryEntry) {
|
||||||
appStateChange: AppStateChange,
|
return;
|
||||||
) {
|
}
|
||||||
const entry = HistoryEntry.create(appStateChange, elementsChange);
|
|
||||||
|
|
||||||
if (!entry.isEmpty()) {
|
// construct history entry, so once it's emitted, it's not recorded again
|
||||||
// we have the latest changes, no need to `applyLatest`, which is done within `History.push`
|
const entry = HistoryEntry.inverse(delta);
|
||||||
this.undoStack.push(entry.inverse());
|
|
||||||
|
|
||||||
if (!entry.elementsChange.isEmpty()) {
|
this.undoStack.push(entry);
|
||||||
|
|
||||||
|
if (!entry.elements.isEmpty()) {
|
||||||
// don't reset redo stack on local appState changes,
|
// don't reset redo stack on local appState changes,
|
||||||
// as a simple click (unselect) could lead to losing all the redo entries
|
// as a simple click (unselect) could lead to losing all the redo entries
|
||||||
// only reset on non empty elements changes!
|
// only reset on non empty elements changes!
|
||||||
@ -60,31 +68,20 @@ export class History {
|
|||||||
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
|
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public undo(
|
public undo(elements: SceneElementsMap, appState: AppState) {
|
||||||
elements: SceneElementsMap,
|
|
||||||
appState: AppState,
|
|
||||||
snapshot: Readonly<Snapshot>,
|
|
||||||
) {
|
|
||||||
return this.perform(
|
return this.perform(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
snapshot,
|
|
||||||
() => History.pop(this.undoStack),
|
() => History.pop(this.undoStack),
|
||||||
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
|
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public redo(
|
public redo(elements: SceneElementsMap, appState: AppState) {
|
||||||
elements: SceneElementsMap,
|
|
||||||
appState: AppState,
|
|
||||||
snapshot: Readonly<Snapshot>,
|
|
||||||
) {
|
|
||||||
return this.perform(
|
return this.perform(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
snapshot,
|
|
||||||
() => History.pop(this.redoStack),
|
() => History.pop(this.redoStack),
|
||||||
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
|
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
|
||||||
);
|
);
|
||||||
@ -93,7 +90,6 @@ export class History {
|
|||||||
private perform(
|
private perform(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
snapshot: Readonly<Snapshot>,
|
|
||||||
pop: () => HistoryEntry | null,
|
pop: () => HistoryEntry | null,
|
||||||
push: (entry: HistoryEntry) => void,
|
push: (entry: HistoryEntry) => void,
|
||||||
): [SceneElementsMap, AppState] | void {
|
): [SceneElementsMap, AppState] | void {
|
||||||
@ -104,6 +100,10 @@ export class History {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const action = CaptureUpdateAction.IMMEDIATELY;
|
||||||
|
|
||||||
|
let prevSnapshot = this.store.snapshot;
|
||||||
|
|
||||||
let nextElements = elements;
|
let nextElements = elements;
|
||||||
let nextAppState = appState;
|
let nextAppState = appState;
|
||||||
let containsVisibleChange = false;
|
let containsVisibleChange = false;
|
||||||
@ -112,9 +112,29 @@ export class History {
|
|||||||
while (historyEntry) {
|
while (historyEntry) {
|
||||||
try {
|
try {
|
||||||
[nextElements, nextAppState, containsVisibleChange] =
|
[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 {
|
} finally {
|
||||||
// make sure to always push / pop, even if the increment is corrupted
|
// make sure to always push, even if the delta is corrupted
|
||||||
push(historyEntry);
|
push(historyEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,7 +155,7 @@ export class History {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static pop(stack: HistoryStack): HistoryEntry | null {
|
private static pop(stack: HistoryEntry[]): HistoryEntry | null {
|
||||||
if (!stack.length) {
|
if (!stack.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -150,63 +170,17 @@ export class History {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static push(
|
private static push(
|
||||||
stack: HistoryStack,
|
stack: HistoryEntry[],
|
||||||
entry: HistoryEntry,
|
entry: HistoryEntry,
|
||||||
prevElements: SceneElementsMap,
|
prevElements: SceneElementsMap,
|
||||||
) {
|
) {
|
||||||
const updatedEntry = entry.inverse().applyLatestChanges(prevElements);
|
const inversedEntry = HistoryEntry.inverse(entry);
|
||||||
|
const updatedEntry = HistoryEntry.applyLatestChanges(
|
||||||
|
inversedEntry,
|
||||||
|
prevElements,
|
||||||
|
"inserted",
|
||||||
|
);
|
||||||
|
|
||||||
return stack.push(updatedEntry);
|
return stack.push(updatedEntry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HistoryEntry {
|
|
||||||
private constructor(
|
|
||||||
public readonly appStateChange: AppStateChange,
|
|
||||||
public readonly elementsChange: ElementsChange,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public static create(
|
|
||||||
appStateChange: AppStateChange,
|
|
||||||
elementsChange: ElementsChange,
|
|
||||||
) {
|
|
||||||
return new HistoryEntry(appStateChange, elementsChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
public inverse(): HistoryEntry {
|
|
||||||
return new HistoryEntry(
|
|
||||||
this.appStateChange.inverse(),
|
|
||||||
this.elementsChange.inverse(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public applyTo(
|
|
||||||
elements: SceneElementsMap,
|
|
||||||
appState: AppState,
|
|
||||||
snapshot: Readonly<Snapshot>,
|
|
||||||
): [SceneElementsMap, AppState, boolean] {
|
|
||||||
const [nextElements, elementsContainVisibleChange] =
|
|
||||||
this.elementsChange.applyTo(elements, snapshot.elements);
|
|
||||||
|
|
||||||
const [nextAppState, appStateContainsVisibleChange] =
|
|
||||||
this.appStateChange.applyTo(appState, nextElements);
|
|
||||||
|
|
||||||
const appliedVisibleChanges =
|
|
||||||
elementsContainVisibleChange || appStateContainsVisibleChange;
|
|
||||||
|
|
||||||
return [nextElements, nextAppState, appliedVisibleChanges];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply latest (remote) changes to the history entry, creates new instance of `HistoryEntry`.
|
|
||||||
*/
|
|
||||||
public applyLatestChanges(elements: SceneElementsMap): HistoryEntry {
|
|
||||||
const updatedElementsChange =
|
|
||||||
this.elementsChange.applyLatestChanges(elements);
|
|
||||||
|
|
||||||
return HistoryEntry.create(this.appStateChange, updatedElementsChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
public isEmpty(): boolean {
|
|
||||||
return this.appStateChange.isEmpty() && this.elementsChange.isEmpty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { Emitter } from "../emitter";
|
import type { Emitter } from "@excalidraw/common";
|
||||||
|
|
||||||
export const useEmitter = <TEvent extends unknown>(
|
export const useEmitter = <TEvent extends unknown>(
|
||||||
emitter: Emitter<[TEvent]>,
|
emitter: Emitter<[TEvent]>,
|
||||||
|
@ -23,6 +23,7 @@ polyfill();
|
|||||||
const ExcalidrawBase = (props: ExcalidrawProps) => {
|
const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||||
const {
|
const {
|
||||||
onChange,
|
onChange,
|
||||||
|
onIncrement,
|
||||||
initialData,
|
initialData,
|
||||||
excalidrawAPI,
|
excalidrawAPI,
|
||||||
isCollaborating = false,
|
isCollaborating = false,
|
||||||
@ -114,6 +115,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
<InitializeApp langCode={langCode} theme={theme}>
|
<InitializeApp langCode={langCode} theme={theme}>
|
||||||
<App
|
<App
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onIncrement={onIncrement}
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
excalidrawAPI={excalidrawAPI}
|
excalidrawAPI={excalidrawAPI}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
@ -266,7 +268,7 @@ export {
|
|||||||
bumpVersion,
|
bumpVersion,
|
||||||
} from "@excalidraw/element/mutateElement";
|
} from "@excalidraw/element/mutateElement";
|
||||||
|
|
||||||
export { CaptureUpdateAction } from "./store";
|
export { CaptureUpdateAction } from "@excalidraw/element/store";
|
||||||
|
|
||||||
export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library";
|
export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library";
|
||||||
|
|
||||||
|
@ -129,7 +129,7 @@
|
|||||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||||
"homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw",
|
"homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"gen:types": "rm -rf types && tsc",
|
"gen:types": "rimraf types && tsc",
|
||||||
"build:esm": "rm -rf dist && node ../../scripts/buildPackage.js && yarn gen:types"
|
"build:esm": "rimraf dist && node ../../scripts/buildPackage.js && yarn gen:types"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 { StaticCanvasRenderConfig } from "../scene/types";
|
||||||
import type { StaticCanvasAppState, AppState } from "../types";
|
import type { AppState, StaticCanvasAppState } from "../types";
|
||||||
|
|
||||||
export const fillCircle = (
|
export const fillCircle = (
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
@ -75,3 +98,399 @@ export const bootstrapCanvas = ({
|
|||||||
|
|
||||||
return context;
|
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;
|
||||||
|
}
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import oc from "open-color";
|
|
||||||
import {
|
import {
|
||||||
pointFrom,
|
pointFrom,
|
||||||
pointsEqual,
|
pointsEqual,
|
||||||
@ -6,19 +5,19 @@ import {
|
|||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
type Radians,
|
type Radians,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
import oc from "open-color";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
arrayToMap,
|
||||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||||
FRAME_STYLE,
|
FRAME_STYLE,
|
||||||
THEME,
|
|
||||||
arrayToMap,
|
|
||||||
invariant,
|
invariant,
|
||||||
|
THEME,
|
||||||
throttleRAF,
|
throttleRAF,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BINDING_HIGHLIGHT_OFFSET,
|
FIXED_BINDING_DISTANCE,
|
||||||
BINDING_HIGHLIGHT_THICKNESS,
|
|
||||||
maxBindingGap,
|
maxBindingGap,
|
||||||
} from "@excalidraw/element/binding";
|
} from "@excalidraw/element/binding";
|
||||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||||
@ -37,14 +36,12 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
} from "@excalidraw/element/typeChecks";
|
} from "@excalidraw/element/typeChecks";
|
||||||
|
|
||||||
import { getCornerRadius } from "@excalidraw/element/shapes";
|
|
||||||
|
|
||||||
import { renderSelectionElement } from "@excalidraw/element/renderElement";
|
import { renderSelectionElement } from "@excalidraw/element/renderElement";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isSelectedViaGroup,
|
|
||||||
getSelectedGroupIds,
|
|
||||||
getElementsInGroup,
|
getElementsInGroup,
|
||||||
|
getSelectedGroupIds,
|
||||||
|
isSelectedViaGroup,
|
||||||
selectGroupsFromGivenElements,
|
selectGroupsFromGivenElements,
|
||||||
} from "@excalidraw/element/groups";
|
} from "@excalidraw/element/groups";
|
||||||
|
|
||||||
@ -88,8 +85,12 @@ import { getClientColor, renderRemoteCursors } from "../clients";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
bootstrapCanvas,
|
bootstrapCanvas,
|
||||||
|
drawHighlightForDiamondWithRotation,
|
||||||
|
drawHighlightForRectWithRotation,
|
||||||
fillCircle,
|
fillCircle,
|
||||||
getNormalizedCanvasDimensions,
|
getNormalizedCanvasDimensions,
|
||||||
|
strokeEllipseWithRotation,
|
||||||
|
strokeRectWithRotation,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
import type {
|
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>(
|
const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
appState: InteractiveCanvasAppState,
|
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 = (
|
const renderBindingHighlightForBindableElement = (
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
@ -267,16 +204,10 @@ const renderBindingHighlightForBindableElement = (
|
|||||||
const height = y2 - y1;
|
const height = y2 - y1;
|
||||||
|
|
||||||
context.strokeStyle = "rgba(0,0,0,.05)";
|
context.strokeStyle = "rgba(0,0,0,.05)";
|
||||||
// When zooming out, make line width greater for visibility
|
context.fillStyle = "rgba(0,0,0,.05)";
|
||||||
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;
|
|
||||||
|
|
||||||
const radius = getCornerRadius(
|
// To ensure the binding highlight doesn't overlap the element itself
|
||||||
Math.min(element.width, element.height),
|
const padding = maxBindingGap(element, element.width, element.height, zoom);
|
||||||
element,
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
@ -286,37 +217,20 @@ const renderBindingHighlightForBindableElement = (
|
|||||||
case "embeddable":
|
case "embeddable":
|
||||||
case "frame":
|
case "frame":
|
||||||
case "magicframe":
|
case "magicframe":
|
||||||
strokeRectWithRotation(
|
drawHighlightForRectWithRotation(context, element, padding);
|
||||||
context,
|
|
||||||
x1 - padding,
|
|
||||||
y1 - padding,
|
|
||||||
width + padding * 2,
|
|
||||||
height + padding * 2,
|
|
||||||
x1 + width / 2,
|
|
||||||
y1 + height / 2,
|
|
||||||
element.angle,
|
|
||||||
undefined,
|
|
||||||
radius,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case "diamond":
|
case "diamond":
|
||||||
const side = Math.hypot(width, height);
|
drawHighlightForDiamondWithRotation(context, padding, element);
|
||||||
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,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
|
context.lineWidth =
|
||||||
|
maxBindingGap(element, element.width, element.height, zoom) -
|
||||||
|
FIXED_BINDING_DISTANCE;
|
||||||
|
|
||||||
strokeEllipseWithRotation(
|
strokeEllipseWithRotation(
|
||||||
context,
|
context,
|
||||||
width + padding * 2,
|
width + padding + FIXED_BINDING_DISTANCE,
|
||||||
height + padding * 2,
|
height + padding + FIXED_BINDING_DISTANCE,
|
||||||
x1 + width / 2,
|
x1 + width / 2,
|
||||||
y1 + height / 2,
|
y1 + height / 2,
|
||||||
element.angle,
|
element.angle,
|
||||||
|
@ -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 it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@ -44,7 +44,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
|
|||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id2",
|
"id": "id4",
|
||||||
"index": "a1",
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
@ -108,7 +108,7 @@ exports[`move element > rectangles with binding arrow 5`] = `
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": [
|
"boundElements": [
|
||||||
{
|
{
|
||||||
"id": "id2",
|
"id": "id6",
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -147,7 +147,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": [
|
"boundElements": [
|
||||||
{
|
{
|
||||||
"id": "id2",
|
"id": "id6",
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -156,7 +156,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
|
|||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 300,
|
"height": 300,
|
||||||
"id": "id1",
|
"id": "id3",
|
||||||
"index": "a1",
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
@ -189,7 +189,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
|||||||
"elbowed": false,
|
"elbowed": false,
|
||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": {
|
"endBinding": {
|
||||||
"elementId": "id1",
|
"elementId": "id3",
|
||||||
"focus": "-0.46667",
|
"focus": "-0.46667",
|
||||||
"gap": 10,
|
"gap": 10,
|
||||||
},
|
},
|
||||||
@ -197,7 +197,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
|||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": "87.29887",
|
"height": "87.29887",
|
||||||
"id": "id2",
|
"id": "id6",
|
||||||
"index": "a2",
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"lastCommittedPoint": null,
|
"lastCommittedPoint": null,
|
||||||
|
@ -23,6 +23,7 @@ import {
|
|||||||
waitFor,
|
waitFor,
|
||||||
togglePopover,
|
togglePopover,
|
||||||
unmountComponent,
|
unmountComponent,
|
||||||
|
checkpointHistory,
|
||||||
} from "./test-utils";
|
} from "./test-utils";
|
||||||
|
|
||||||
import type { ShortcutName } from "../actions/shortcuts";
|
import type { ShortcutName } from "../actions/shortcuts";
|
||||||
@ -33,11 +34,12 @@ const checkpoint = (name: string) => {
|
|||||||
`[${name}] number of renders`,
|
`[${name}] number of renders`,
|
||||||
);
|
);
|
||||||
expect(h.state).toMatchSnapshot(`[${name}] appState`);
|
expect(h.state).toMatchSnapshot(`[${name}] appState`);
|
||||||
expect(h.history).toMatchSnapshot(`[${name}] history`);
|
|
||||||
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
|
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
|
||||||
h.elements.forEach((element, i) =>
|
h.elements.forEach((element, i) =>
|
||||||
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
|
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
checkpointHistory(h.history, name);
|
||||||
};
|
};
|
||||||
|
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
|
@ -23,6 +23,10 @@ import {
|
|||||||
|
|
||||||
import "@excalidraw/utils/test-utils";
|
import "@excalidraw/utils/test-utils";
|
||||||
|
|
||||||
|
import { ElementsDelta, AppStateDelta } from "@excalidraw/element/delta";
|
||||||
|
|
||||||
|
import { CaptureUpdateAction, StoreDelta } from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -46,11 +50,8 @@ import {
|
|||||||
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
||||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { HistoryEntry } from "../history";
|
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import * as StaticScene from "../renderer/staticScene";
|
import * as StaticScene from "../renderer/staticScene";
|
||||||
import { Snapshot, CaptureUpdateAction } from "../store";
|
|
||||||
import { AppStateChange, ElementsChange } from "../change";
|
|
||||||
|
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||||
@ -61,6 +62,7 @@ import {
|
|||||||
render,
|
render,
|
||||||
togglePopover,
|
togglePopover,
|
||||||
getCloneByOrigId,
|
getCloneByOrigId,
|
||||||
|
checkpointHistory,
|
||||||
} from "./test-utils";
|
} from "./test-utils";
|
||||||
|
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
@ -82,13 +84,15 @@ const checkpoint = (name: string) => {
|
|||||||
...strippedAppState
|
...strippedAppState
|
||||||
} = h.state;
|
} = h.state;
|
||||||
expect(strippedAppState).toMatchSnapshot(`[${name}] appState`);
|
expect(strippedAppState).toMatchSnapshot(`[${name}] appState`);
|
||||||
expect(h.history).toMatchSnapshot(`[${name}] history`);
|
|
||||||
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
|
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
|
||||||
|
|
||||||
h.elements
|
h.elements
|
||||||
.map(({ seed, versionNonce, ...strippedElement }) => strippedElement)
|
.map(({ seed, versionNonce, ...strippedElement }) => strippedElement)
|
||||||
.forEach((element, i) =>
|
.forEach((element, i) =>
|
||||||
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
|
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
checkpointHistory(h.history, name);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
||||||
@ -116,12 +120,12 @@ describe("history", () => {
|
|||||||
|
|
||||||
API.setElements([rect]);
|
API.setElements([rect]);
|
||||||
|
|
||||||
const corrupedEntry = HistoryEntry.create(
|
const corrupedEntry = StoreDelta.create(
|
||||||
AppStateChange.empty(),
|
ElementsDelta.empty(),
|
||||||
ElementsChange.empty(),
|
AppStateDelta.empty(),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.spyOn(corrupedEntry, "applyTo").mockImplementation(() => {
|
vi.spyOn(corrupedEntry.elements, "applyTo").mockImplementation(() => {
|
||||||
throw new Error("Oh no, I am corrupted!");
|
throw new Error("Oh no, I am corrupted!");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -136,7 +140,6 @@ describe("history", () => {
|
|||||||
h.history.undo(
|
h.history.undo(
|
||||||
arrayToMap(h.elements) as SceneElementsMap,
|
arrayToMap(h.elements) as SceneElementsMap,
|
||||||
appState,
|
appState,
|
||||||
Snapshot.empty(),
|
|
||||||
) as any,
|
) as any,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -157,7 +160,6 @@ describe("history", () => {
|
|||||||
h.history.redo(
|
h.history.redo(
|
||||||
arrayToMap(h.elements) as SceneElementsMap,
|
arrayToMap(h.elements) as SceneElementsMap,
|
||||||
appState,
|
appState,
|
||||||
Snapshot.empty(),
|
|
||||||
) as any,
|
) as any,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -454,8 +456,8 @@ describe("history", () => {
|
|||||||
expect(h.history.isUndoStackEmpty).toBeTruthy();
|
expect(h.history.isUndoStackEmpty).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history, h.store);
|
const undoAction = createUndoAction(h.history);
|
||||||
const redoAction = createRedoAction(h.history, h.store);
|
const redoAction = createRedoAction(h.history);
|
||||||
// noop
|
// noop
|
||||||
API.executeAction(undoAction);
|
API.executeAction(undoAction);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
@ -531,8 +533,8 @@ describe("history", () => {
|
|||||||
expect.objectContaining({ id: "B", isDeleted: false }),
|
expect.objectContaining({ id: "B", isDeleted: false }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history, h.store);
|
const undoAction = createUndoAction(h.history);
|
||||||
const redoAction = createRedoAction(h.history, h.store);
|
const redoAction = createRedoAction(h.history);
|
||||||
API.executeAction(undoAction);
|
API.executeAction(undoAction);
|
||||||
|
|
||||||
expect(API.getSnapshot()).toEqual([
|
expect(API.getSnapshot()).toEqual([
|
||||||
@ -1713,8 +1715,8 @@ describe("history", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history, h.store);
|
const undoAction = createUndoAction(h.history);
|
||||||
const redoAction = createRedoAction(h.history, h.store);
|
const redoAction = createRedoAction(h.history);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||||
@ -1763,7 +1765,7 @@ describe("history", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history, h.store);
|
const undoAction = createUndoAction(h.history);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||||
|
@ -1384,19 +1384,30 @@ describe("Test Linear Elements", () => {
|
|||||||
const [origStartX, origStartY] = [line.x, line.y];
|
const [origStartX, origStartY] = [line.x, line.y];
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
LinearElementEditor.movePoints(line, h.app.scene, [
|
LinearElementEditor.movePoints(
|
||||||
|
line,
|
||||||
|
h.app.scene,
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
0,
|
||||||
{
|
{
|
||||||
index: 0,
|
point: pointFrom(
|
||||||
point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),
|
line.points[0][0] + 10,
|
||||||
|
line.points[0][1] + 10,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
line.points.length - 1,
|
||||||
{
|
{
|
||||||
index: line.points.length - 1,
|
|
||||||
point: pointFrom(
|
point: pointFrom(
|
||||||
line.points[line.points.length - 1][0] - 10,
|
line.points[line.points.length - 1][0] - 10,
|
||||||
line.points[line.points.length - 1][1] - 10,
|
line.points[line.points.length - 1][1] - 10,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(line.x).toBe(origStartX + 10);
|
expect(line.x).toBe(origStartX + 10);
|
||||||
expect(line.y).toBe(origStartY + 10);
|
expect(line.y).toBe(origStartY + 10);
|
||||||
|
@ -14,6 +14,7 @@ import { API } from "./helpers/api";
|
|||||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||||
import {
|
import {
|
||||||
assertSelectedElements,
|
assertSelectedElements,
|
||||||
|
checkpointHistory,
|
||||||
fireEvent,
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
@ -39,11 +40,12 @@ const checkpoint = (name: string) => {
|
|||||||
`[${name}] number of renders`,
|
`[${name}] number of renders`,
|
||||||
);
|
);
|
||||||
expect(h.state).toMatchSnapshot(`[${name}] appState`);
|
expect(h.state).toMatchSnapshot(`[${name}] appState`);
|
||||||
expect(h.history).toMatchSnapshot(`[${name}] history`);
|
|
||||||
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
|
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
|
||||||
h.elements.forEach((element, i) =>
|
h.elements.forEach((element, i) =>
|
||||||
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
|
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
checkpointHistory(h.history, name);
|
||||||
};
|
};
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
unmountComponent();
|
unmountComponent();
|
||||||
|
@ -22,6 +22,8 @@ import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants";
|
|||||||
import { Pointer, UI } from "./helpers/ui";
|
import { Pointer, UI } from "./helpers/ui";
|
||||||
import * as toolQueries from "./queries/toolQueries";
|
import * as toolQueries from "./queries/toolQueries";
|
||||||
|
|
||||||
|
import type { History } from "../history";
|
||||||
|
|
||||||
import type { RenderResult, RenderOptions } from "@testing-library/react";
|
import type { RenderResult, RenderOptions } from "@testing-library/react";
|
||||||
|
|
||||||
import type { ImportedDataState } from "../data/types";
|
import type { ImportedDataState } from "../data/types";
|
||||||
@ -432,3 +434,45 @@ export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
|
|||||||
|
|
||||||
expect(h.state.selectedElementIds).toEqual(selectedElementIds);
|
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`);
|
||||||
|
};
|
||||||
|
@ -43,6 +43,12 @@ import type {
|
|||||||
MakeBrand,
|
MakeBrand,
|
||||||
} from "@excalidraw/common/utility-types";
|
} from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CaptureUpdateActionType,
|
||||||
|
DurableIncrement,
|
||||||
|
EphemeralIncrement,
|
||||||
|
} from "@excalidraw/element/store";
|
||||||
|
|
||||||
import type { Action } from "./actions/types";
|
import type { Action } from "./actions/types";
|
||||||
import type { Spreadsheet } from "./charts";
|
import type { Spreadsheet } from "./charts";
|
||||||
import type { ClipboardData } from "./clipboard";
|
import type { ClipboardData } from "./clipboard";
|
||||||
@ -51,7 +57,6 @@ import type Library from "./data/library";
|
|||||||
import type { FileSystemHandle } from "./data/filesystem";
|
import type { FileSystemHandle } from "./data/filesystem";
|
||||||
import type { ContextMenuItems } from "./components/ContextMenu";
|
import type { ContextMenuItems } from "./components/ContextMenu";
|
||||||
import type { SnapLine } from "./snapping";
|
import type { SnapLine } from "./snapping";
|
||||||
import type { CaptureUpdateActionType } from "./store";
|
|
||||||
import type { ImportedDataState } from "./data/types";
|
import type { ImportedDataState } from "./data/types";
|
||||||
|
|
||||||
import type { Language } from "./i18n";
|
import type { Language } from "./i18n";
|
||||||
@ -518,6 +523,7 @@ export interface ExcalidrawProps {
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
) => void;
|
) => void;
|
||||||
|
onIncrement?: (event: DurableIncrement | EphemeralIncrement) => void;
|
||||||
initialData?:
|
initialData?:
|
||||||
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
|
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
|
||||||
| MaybePromise<ExcalidrawInitialDataState | null>;
|
| MaybePromise<ExcalidrawInitialDataState | null>;
|
||||||
@ -821,6 +827,9 @@ export interface ExcalidrawImperativeAPI {
|
|||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
) => void,
|
) => void,
|
||||||
) => UnsubscribeCallback;
|
) => UnsubscribeCallback;
|
||||||
|
onIncrement: (
|
||||||
|
callback: (event: DurableIncrement | EphemeralIncrement) => void,
|
||||||
|
) => UnsubscribeCallback;
|
||||||
onPointerDown: (
|
onPointerDown: (
|
||||||
callback: (
|
callback: (
|
||||||
activeTool: AppState["activeTool"],
|
activeTool: AppState["activeTool"],
|
||||||
|
@ -54,7 +54,7 @@
|
|||||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||||
"repository": "https://github.com/excalidraw/excalidraw",
|
"repository": "https://github.com/excalidraw/excalidraw",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"gen:types": "rm -rf types && tsc",
|
"gen:types": "rimraf types && tsc",
|
||||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import type { Bounds } from "@excalidraw/element/bounds";
|
|||||||
|
|
||||||
import { isPoint, pointDistance, pointFrom } from "./point";
|
import { isPoint, pointDistance, pointFrom } from "./point";
|
||||||
import { rectangle, rectangleIntersectLineSegment } from "./rectangle";
|
import { rectangle, rectangleIntersectLineSegment } from "./rectangle";
|
||||||
|
import { vector } from "./vector";
|
||||||
|
|
||||||
import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
|
import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
|
||||||
|
|
||||||
@ -82,7 +83,7 @@ function solve(
|
|||||||
return [t0, s0];
|
return [t0, s0];
|
||||||
}
|
}
|
||||||
|
|
||||||
const bezierEquation = <Point extends GlobalPoint | LocalPoint>(
|
export const bezierEquation = <Point extends GlobalPoint | LocalPoint>(
|
||||||
c: Curve<Point>,
|
c: Curve<Point>,
|
||||||
t: number,
|
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>(
|
function curveBounds<Point extends GlobalPoint | LocalPoint>(
|
||||||
c: Curve<Point>,
|
c: Curve<Point>,
|
||||||
): Bounds {
|
): Bounds {
|
||||||
|
@ -143,3 +143,8 @@ export const vectorNormalize = (v: Vector): Vector => {
|
|||||||
|
|
||||||
return vector(v[0] / m, v[1] / m);
|
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]);
|
||||||
|
@ -69,7 +69,7 @@
|
|||||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||||
"repository": "https://github.com/excalidraw/excalidraw",
|
"repository": "https://github.com/excalidraw/excalidraw",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"gen:types": "rm -rf types && tsc",
|
"gen:types": "rimraf types && tsc",
|
||||||
"build:esm": "rm -rf dist && node ../../scripts/buildUtils.js && yarn gen:types"
|
"build:esm": "rimraf dist && node ../../scripts/buildUtils.js && yarn gen:types"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
42
yarn.lock
42
yarn.lock
@ -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"
|
resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
|
||||||
integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==
|
integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==
|
||||||
|
|
||||||
glob@^10.4.1:
|
glob@^10.3.7, glob@^10.4.1:
|
||||||
version "10.4.5"
|
version "10.4.5"
|
||||||
resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956"
|
resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956"
|
||||||
integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==
|
integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==
|
||||||
@ -8314,6 +8314,13 @@ rimraf@3.0.2, rimraf@^3.0.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
glob "^7.1.3"
|
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:
|
robust-predicates@^3.0.2:
|
||||||
version "3.0.2"
|
version "3.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
|
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"
|
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
|
||||||
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
|
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:
|
"string-width-cjs@npm:string-width@^4.2.0":
|
||||||
name string-width-cjs
|
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"
|
version "4.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||||
@ -8873,7 +8888,14 @@ stringify-object@^3.3.0:
|
|||||||
is-obj "^1.0.1"
|
is-obj "^1.0.1"
|
||||||
is-regexp "^1.0.0"
|
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"
|
version "6.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||||
@ -10006,8 +10028,7 @@ workbox-window@7.3.0, workbox-window@^7.3.0:
|
|||||||
"@types/trusted-types" "^2.0.2"
|
"@types/trusted-types" "^2.0.2"
|
||||||
workbox-core "7.3.0"
|
workbox-core "7.3.0"
|
||||||
|
|
||||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||||
name wrap-ansi-cjs
|
|
||||||
version "7.0.0"
|
version "7.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||||
@ -10025,6 +10046,15 @@ wrap-ansi@^6.2.0:
|
|||||||
string-width "^4.1.0"
|
string-width "^4.1.0"
|
||||||
strip-ansi "^6.0.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:
|
wrap-ansi@^8.1.0:
|
||||||
version "8.1.0"
|
version "8.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user