Compare commits
1 Commits
mrazator/v
...
master
Author | SHA1 | Date | |
---|---|---|---|
5639bb8e87 |
@ -1,5 +1,3 @@
|
|||||||
MODE="development"
|
|
||||||
|
|
||||||
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||||
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||||
|
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
MODE="production"
|
VITE_APP_BACKEND_V2_GET_URL=https://ex.dylanbanta.com/api/v2/scenes/
|
||||||
|
VITE_APP_BACKEND_V2_POST_URL=https://ex.dylanbanta.com/api/v2/scenes/
|
||||||
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
|
||||||
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
|
||||||
|
|
||||||
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||||
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||||
|
@ -34,9 +34,6 @@
|
|||||||
<a href="https://discord.gg/UexuTaE">
|
<a href="https://discord.gg/UexuTaE">
|
||||||
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
|
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://deepwiki.com/excalidraw/excalidraw">
|
|
||||||
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" />
|
|
||||||
</a>
|
|
||||||
<a href="https://twitter.com/excalidraw">
|
<a href="https://twitter.com/excalidraw">
|
||||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
|
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
|
||||||
</a>
|
</a>
|
||||||
|
@ -19,7 +19,7 @@ services:
|
|||||||
- ./:/opt/node_app/app:delegated
|
- ./:/opt/node_app/app:delegated
|
||||||
- ./package.json:/opt/node_app/package.json
|
- ./package.json:/opt/node_app/package.json
|
||||||
- ./yarn.lock:/opt/node_app/yarn.lock
|
- ./yarn.lock:/opt/node_app/yarn.lock
|
||||||
- notused:/opt/node_app/app/node_modules
|
# - notused:/opt/node_app/app/node_modules
|
||||||
|
|
||||||
volumes:
|
# volumes:
|
||||||
notused:
|
# notused:
|
||||||
|
@ -926,16 +926,21 @@ const ExcalidrawWrapper = () => {
|
|||||||
<ShareDialog
|
<ShareDialog
|
||||||
collabAPI={collabAPI}
|
collabAPI={collabAPI}
|
||||||
onExportToBackend={async () => {
|
onExportToBackend={async () => {
|
||||||
if (excalidrawAPI) {
|
if (!excalidrawAPI) {
|
||||||
try {
|
return;
|
||||||
await onExportToBackend(
|
}
|
||||||
excalidrawAPI.getSceneElements(),
|
try {
|
||||||
excalidrawAPI.getAppState(),
|
const { url, errorMessage } = await exportToBackend(
|
||||||
excalidrawAPI.getFiles(),
|
excalidrawAPI.getSceneElements(),
|
||||||
);
|
excalidrawAPI.getAppState(),
|
||||||
} catch (error: any) {
|
excalidrawAPI.getFiles(),
|
||||||
setErrorMessage(error.message);
|
);
|
||||||
|
if (errorMessage) {
|
||||||
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
setLatestShareableLink(url);
|
||||||
|
} catch (error: any) {
|
||||||
|
setErrorMessage(error.message);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -41,8 +41,8 @@
|
|||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
|
"build:app:docker": "vite build",
|
||||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
|
"build:app": "vite build",
|
||||||
"build:version": "node ../scripts/build-version.js",
|
"build:version": "node ../scripts/build-version.js",
|
||||||
"build": "yarn build:app && yarn build:version",
|
"build": "yarn build:app && yarn build:version",
|
||||||
"start": "yarn && vite",
|
"start": "yarn && vite",
|
||||||
|
@ -205,7 +205,6 @@ describe("collaboration", () => {
|
|||||||
// 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!
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
expect(API.getRedoStack().length).toBe(1);
|
|
||||||
expect(API.getSnapshot()).toEqual([
|
expect(API.getSnapshot()).toEqual([
|
||||||
expect.objectContaining(rect1Props),
|
expect.objectContaining(rect1Props),
|
||||||
expect.objectContaining({ ...rect2Props, isDeleted: false }),
|
expect.objectContaining({ ...rect2Props, isDeleted: false }),
|
||||||
@ -237,7 +236,7 @@ describe("collaboration", () => {
|
|||||||
|
|
||||||
// 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!
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
expect(API.getSnapshot()).toEqual([
|
expect(API.getSnapshot()).toEqual([
|
||||||
expect.objectContaining(rect1Props),
|
expect.objectContaining(rect1Props),
|
||||||
@ -249,11 +248,13 @@ describe("collaboration", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
// simulate local update
|
// simulate local update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: syncInvalidIndices([
|
elements: syncInvalidIndices([
|
||||||
h.elements[0],
|
h.elements[0],
|
||||||
newElementWith(h.elements[1], { x: 100, isDeleted: false }),
|
newElementWith(h.elements[1], { x: 100 }),
|
||||||
]),
|
]),
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
});
|
});
|
||||||
@ -270,5 +271,55 @@ describe("collaboration", () => {
|
|||||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
|
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
|
// we expect to iterate the stack to the first visible change
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// simulate force deleting the element remotely
|
||||||
|
API.updateScene({
|
||||||
|
elements: syncInvalidIndices([rect1]),
|
||||||
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
|
});
|
||||||
|
|
||||||
|
// snapshot was correctly updated and marked the element as deleted
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => h.app.actionManager.executeAction(redoAction));
|
||||||
|
|
||||||
|
// with explicit redo (as update) we again restored the element from the snapshot!
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||||
|
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
|
||||||
|
]);
|
||||||
|
expect(h.history.isRedoStackEmpty).toBeTruthy();
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||||
|
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -712,8 +712,8 @@ export const arrayToObject = <T>(
|
|||||||
array: readonly T[],
|
array: readonly T[],
|
||||||
groupBy?: (value: T) => string | number,
|
groupBy?: (value: T) => string | number,
|
||||||
) =>
|
) =>
|
||||||
array.reduce((acc, value, idx) => {
|
array.reduce((acc, value) => {
|
||||||
acc[groupBy ? groupBy(value) : idx] = value;
|
acc[groupBy ? groupBy(value) : String(value)] = value;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as { [key: string]: T });
|
}, {} as { [key: string]: T });
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawImageElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
@ -18,12 +18,7 @@ import type {
|
|||||||
SceneElementsMap,
|
SceneElementsMap,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type {
|
import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
|
||||||
DTO,
|
|
||||||
Mutable,
|
|
||||||
SubtypeOf,
|
|
||||||
ValueOf,
|
|
||||||
} from "@excalidraw/common/utility-types";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
@ -56,8 +51,6 @@ import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
|||||||
|
|
||||||
import { Scene } from "./Scene";
|
import { Scene } from "./Scene";
|
||||||
|
|
||||||
import { StoreSnapshot } from "./store";
|
|
||||||
|
|
||||||
import type { BindableProp, BindingProp } from "./binding";
|
import type { BindableProp, BindingProp } from "./binding";
|
||||||
|
|
||||||
import type { ElementUpdate } from "./mutateElement";
|
import type { ElementUpdate } from "./mutateElement";
|
||||||
@ -263,14 +256,12 @@ export class Delta<T> {
|
|||||||
arrayToObject(deletedArray, groupBy),
|
arrayToObject(deletedArray, groupBy),
|
||||||
arrayToObject(insertedArray, groupBy),
|
arrayToObject(insertedArray, groupBy),
|
||||||
),
|
),
|
||||||
(x) => x,
|
|
||||||
);
|
);
|
||||||
const insertedDifferences = arrayToObject(
|
const insertedDifferences = arrayToObject(
|
||||||
Delta.getRightDifferences(
|
Delta.getRightDifferences(
|
||||||
arrayToObject(deletedArray, groupBy),
|
arrayToObject(deletedArray, groupBy),
|
||||||
arrayToObject(insertedArray, groupBy),
|
arrayToObject(insertedArray, groupBy),
|
||||||
),
|
),
|
||||||
(x) => x,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -867,17 +858,10 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
|
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
|
||||||
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
|
ElementUpdate<Ordered<T>>,
|
||||||
|
"seed"
|
||||||
export type ApplyToOptions = {
|
>;
|
||||||
excludedProperties: Set<keyof ElementPartial>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ApplyToFlags = {
|
|
||||||
containsVisibleDifference: boolean;
|
|
||||||
containsZindexDifference: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
@ -1166,15 +1150,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
|
|
||||||
public applyTo(
|
public applyTo(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
|
elementsSnapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
options: ApplyToOptions = {
|
|
||||||
excludedProperties: new Set(),
|
|
||||||
},
|
|
||||||
): [SceneElementsMap, boolean] {
|
): [SceneElementsMap, boolean] {
|
||||||
let nextElements = new Map(elements) as SceneElementsMap;
|
let nextElements = new Map(elements) as SceneElementsMap;
|
||||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||||
|
|
||||||
const flags: ApplyToFlags = {
|
const flags = {
|
||||||
containsVisibleDifference: false,
|
containsVisibleDifference: false,
|
||||||
containsZindexDifference: false,
|
containsZindexDifference: false,
|
||||||
};
|
};
|
||||||
@ -1183,14 +1164,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
try {
|
try {
|
||||||
const applyDeltas = ElementsDelta.createApplier(
|
const applyDeltas = ElementsDelta.createApplier(
|
||||||
nextElements,
|
nextElements,
|
||||||
snapshot,
|
elementsSnapshot,
|
||||||
options,
|
|
||||||
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);
|
||||||
|
|
||||||
@ -1249,12 +1229,18 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
private static createApplier =
|
private static createApplier =
|
||||||
(
|
(
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
snapshot: StoreSnapshot["elements"],
|
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
options: ApplyToOptions,
|
flags: {
|
||||||
flags: ApplyToFlags,
|
containsVisibleDifference: boolean;
|
||||||
|
containsZindexDifference: boolean;
|
||||||
|
},
|
||||||
) =>
|
) =>
|
||||||
(deltas: Record<string, Delta<ElementPartial>>) => {
|
(
|
||||||
|
type: "added" | "removed" | "updated",
|
||||||
|
deltas: Record<string, Delta<ElementPartial>>,
|
||||||
|
) => {
|
||||||
const getElement = ElementsDelta.createGetter(
|
const getElement = ElementsDelta.createGetter(
|
||||||
|
type,
|
||||||
nextElements,
|
nextElements,
|
||||||
snapshot,
|
snapshot,
|
||||||
flags,
|
flags,
|
||||||
@ -1264,13 +1250,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const element = getElement(id, delta.inserted);
|
const element = getElement(id, delta.inserted);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
const newElement = ElementsDelta.applyDelta(
|
const newElement = ElementsDelta.applyDelta(element, delta, flags);
|
||||||
element,
|
|
||||||
delta,
|
|
||||||
options,
|
|
||||||
flags,
|
|
||||||
);
|
|
||||||
|
|
||||||
nextElements.set(newElement.id, newElement);
|
nextElements.set(newElement.id, newElement);
|
||||||
acc.set(newElement.id, newElement);
|
acc.set(newElement.id, newElement);
|
||||||
}
|
}
|
||||||
@ -1281,9 +1261,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
|
|
||||||
private static createGetter =
|
private static createGetter =
|
||||||
(
|
(
|
||||||
|
type: "added" | "removed" | "updated",
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
snapshot: StoreSnapshot["elements"],
|
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
flags: ApplyToFlags,
|
flags: {
|
||||||
|
containsVisibleDifference: boolean;
|
||||||
|
containsZindexDifference: boolean;
|
||||||
|
},
|
||||||
) =>
|
) =>
|
||||||
(id: string, partial: ElementPartial) => {
|
(id: string, partial: ElementPartial) => {
|
||||||
let element = elements.get(id);
|
let element = elements.get(id);
|
||||||
@ -1297,7 +1281,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
flags.containsZindexDifference = true;
|
flags.containsZindexDifference = true;
|
||||||
|
|
||||||
// as the element was force deleted, we need to check if adding it back results in a visible change
|
// as the element was force deleted, we need to check if adding it back results in a visible change
|
||||||
if (!partial.isDeleted || (partial.isDeleted && !element.isDeleted)) {
|
if (
|
||||||
|
partial.isDeleted === false ||
|
||||||
|
(partial.isDeleted !== true && element.isDeleted === false)
|
||||||
|
) {
|
||||||
flags.containsVisibleDifference = true;
|
flags.containsVisibleDifference = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -1317,28 +1304,16 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
private static applyDelta(
|
private static applyDelta(
|
||||||
element: OrderedExcalidrawElement,
|
element: OrderedExcalidrawElement,
|
||||||
delta: Delta<ElementPartial>,
|
delta: Delta<ElementPartial>,
|
||||||
options: ApplyToOptions,
|
flags: {
|
||||||
flags: ApplyToFlags,
|
containsVisibleDifference: boolean;
|
||||||
|
containsZindexDifference: boolean;
|
||||||
|
} = {
|
||||||
|
// by default we don't care about about the flags
|
||||||
|
containsVisibleDifference: true,
|
||||||
|
containsZindexDifference: true,
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const directlyApplicablePartial: Mutable<ElementPartial> = {};
|
const { boundElements, ...directlyApplicablePartial } = delta.inserted;
|
||||||
|
|
||||||
// some properties are not directly applicable, such as:
|
|
||||||
// - boundElements which contains only diff)
|
|
||||||
// - version & versionNonce, if we don't want to return to previous versions
|
|
||||||
for (const key of Object.keys(delta.inserted) as Array<
|
|
||||||
keyof typeof delta.inserted
|
|
||||||
>) {
|
|
||||||
if (key === "boundElements") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.excludedProperties.has(key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = delta.inserted[key];
|
|
||||||
Reflect.set(directlyApplicablePartial, key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
delta.deleted.boundElements?.length ||
|
delta.deleted.boundElements?.length ||
|
||||||
@ -1356,6 +1331,19 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: this looks wrong, shouldn't be here
|
||||||
|
if (element.type === "image") {
|
||||||
|
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
|
||||||
|
// we want to override `crop` only if modified so that we don't reset
|
||||||
|
// when undoing/redoing unrelated change
|
||||||
|
if (_delta.deleted.crop || _delta.inserted.crop) {
|
||||||
|
Object.assign(directlyApplicablePartial, {
|
||||||
|
// apply change verbatim
|
||||||
|
crop: _delta.inserted.crop ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!flags.containsVisibleDifference) {
|
if (!flags.containsVisibleDifference) {
|
||||||
// strip away fractional index, 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;
|
||||||
@ -1662,32 +1650,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
): [ElementPartial, ElementPartial] {
|
): [ElementPartial, ElementPartial] {
|
||||||
try {
|
try {
|
||||||
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
|
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
|
||||||
|
|
||||||
// don't diff the points as:
|
|
||||||
// - we can't ensure the multiplayer order consistency without fractional index on each point
|
|
||||||
// - we prefer to not merge the points, as it might just lead to unexpected / incosistent results
|
|
||||||
const deletedPoints =
|
|
||||||
(
|
|
||||||
deleted as ElementPartial<
|
|
||||||
ExcalidrawFreeDrawElement | ExcalidrawLinearElement
|
|
||||||
>
|
|
||||||
).points ?? [];
|
|
||||||
|
|
||||||
const insertedPoints =
|
|
||||||
(
|
|
||||||
inserted as ElementPartial<
|
|
||||||
ExcalidrawFreeDrawElement | ExcalidrawLinearElement
|
|
||||||
>
|
|
||||||
).points ?? [];
|
|
||||||
|
|
||||||
if (
|
|
||||||
!Delta.isLeftDifferent(deletedPoints, insertedPoints) &&
|
|
||||||
!Delta.isRightDifferent(deletedPoints, insertedPoints)
|
|
||||||
) {
|
|
||||||
// delete the points from delta if there is no difference, otherwise leave them as they were captured due to consistency
|
|
||||||
Reflect.deleteProperty(deleted, "points");
|
|
||||||
Reflect.deleteProperty(inserted, "points");
|
|
||||||
}
|
|
||||||
} 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 delta.`);
|
console.error(`Couldn't postprocess elements delta.`);
|
||||||
@ -1703,7 +1665,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
private static stripIrrelevantProps(
|
private static stripIrrelevantProps(
|
||||||
partial: Partial<OrderedExcalidrawElement>,
|
partial: Partial<OrderedExcalidrawElement>,
|
||||||
): ElementPartial {
|
): ElementPartial {
|
||||||
const { id, updated, ...strippedPartial } = partial;
|
const { id, updated, version, versionNonce, ...strippedPartial } = partial;
|
||||||
|
|
||||||
return strippedPartial;
|
return strippedPartial;
|
||||||
}
|
}
|
||||||
|
@ -974,25 +974,6 @@ export const updateElbowArrowPoints = (
|
|||||||
),
|
),
|
||||||
"Elbow arrow segments must be either horizontal or vertical",
|
"Elbow arrow segments must be either horizontal or vertical",
|
||||||
);
|
);
|
||||||
|
|
||||||
invariant(
|
|
||||||
updates.fixedSegments?.find(
|
|
||||||
(segment) =>
|
|
||||||
segment.index === 1 &&
|
|
||||||
pointsEqual(segment.start, (updates.points ?? arrow.points)[0]),
|
|
||||||
) == null &&
|
|
||||||
updates.fixedSegments?.find(
|
|
||||||
(segment) =>
|
|
||||||
segment.index === (updates.points ?? arrow.points).length - 1 &&
|
|
||||||
pointsEqual(
|
|
||||||
segment.end,
|
|
||||||
(updates.points ?? arrow.points)[
|
|
||||||
(updates.points ?? arrow.points).length - 1
|
|
||||||
],
|
|
||||||
),
|
|
||||||
) == null,
|
|
||||||
"The first and last segments cannot be fixed",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
|
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
|
||||||
|
@ -2,9 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing";
|
|||||||
|
|
||||||
import { arrayToMap } from "@excalidraw/common";
|
import { arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import { mutateElement } from "./mutateElement";
|
||||||
|
|
||||||
import { mutateElement, newElementWith } from "./mutateElement";
|
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement } from "./textElement";
|
||||||
import { hasBoundTextElement } from "./typeChecks";
|
import { hasBoundTextElement } from "./typeChecks";
|
||||||
|
|
||||||
@ -163,15 +161,9 @@ export const syncMovedIndices = (
|
|||||||
|
|
||||||
// try generatating indices, throws on invalid movedElements
|
// try generatating indices, throws on invalid movedElements
|
||||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
const elementsCandidates = elements.map((x) => {
|
const elementsCandidates = elements.map((x) =>
|
||||||
const elementUpdates = elementsUpdates.get(x);
|
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x,
|
||||||
|
);
|
||||||
if (elementUpdates) {
|
|
||||||
return { ...x, index: elementUpdates.index };
|
|
||||||
}
|
|
||||||
|
|
||||||
return x;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ensure next indices are valid before mutation, throws on invalid ones
|
// ensure next indices are valid before mutation, throws on invalid ones
|
||||||
validateFractionalIndices(
|
validateFractionalIndices(
|
||||||
@ -185,8 +177,8 @@ export const syncMovedIndices = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
// split mutation so we don't end up in an incosistent state
|
// split mutation so we don't end up in an incosistent state
|
||||||
for (const [element, { index }] of elementsUpdates) {
|
for (const [element, update] of elementsUpdates) {
|
||||||
mutateElement(element, elementsMap, { index });
|
mutateElement(element, elementsMap, update);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// fallback to default sync
|
// fallback to default sync
|
||||||
@ -197,33 +189,19 @@ export const syncMovedIndices = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronizes all invalid fractional indices with the array order by mutating passed elements array.
|
* Synchronizes all invalid fractional indices with the array order by mutating passed elements.
|
||||||
*
|
|
||||||
* When `shouldCreateNewInstances` is true, it creates new instances of the elements, instead of mutating the existing ones.
|
|
||||||
*
|
*
|
||||||
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
||||||
*/
|
*/
|
||||||
export const syncInvalidIndices = (
|
export const syncInvalidIndices = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
{
|
|
||||||
shouldCreateNewInstances = false,
|
|
||||||
}: {
|
|
||||||
shouldCreateNewInstances?: boolean;
|
|
||||||
} = {},
|
|
||||||
): OrderedExcalidrawElement[] => {
|
): OrderedExcalidrawElement[] => {
|
||||||
|
const elementsMap = arrayToMap(elements);
|
||||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
|
|
||||||
for (const [element, { index, arrayIndex }] of elementsUpdates) {
|
for (const [element, update] of elementsUpdates) {
|
||||||
if (shouldCreateNewInstances) {
|
mutateElement(element, elementsMap, update);
|
||||||
const updatedElement = newElementWith(element, { index });
|
|
||||||
|
|
||||||
// mutate the element in the array with the new updated instance
|
|
||||||
(elements as Mutable<typeof elements>)[arrayIndex] = updatedElement;
|
|
||||||
} else {
|
|
||||||
const elementsMap = arrayToMap(elements);
|
|
||||||
mutateElement(element, elementsMap, { index });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return elements as OrderedExcalidrawElement[];
|
return elements as OrderedExcalidrawElement[];
|
||||||
@ -402,7 +380,7 @@ const generateIndices = (
|
|||||||
) => {
|
) => {
|
||||||
const elementsUpdates = new Map<
|
const elementsUpdates = new Map<
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
{ index: FractionalIndex; arrayIndex: number }
|
{ index: FractionalIndex }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
for (const indices of indicesGroups) {
|
for (const indices of indicesGroups) {
|
||||||
@ -420,7 +398,6 @@ const generateIndices = (
|
|||||||
|
|
||||||
elementsUpdates.set(element, {
|
elementsUpdates.set(element, {
|
||||||
index: fractionalIndices[i],
|
index: fractionalIndices[i],
|
||||||
arrayIndex: indices[i],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -213,7 +213,16 @@ export class Store {
|
|||||||
// using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta 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;
|
storeDelta = delta;
|
||||||
} else {
|
} else {
|
||||||
storeDelta = StoreDelta.calculate(prevSnapshot, snapshot);
|
// 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()) {
|
if (!storeDelta.isEmpty()) {
|
||||||
@ -496,24 +505,6 @@ export class StoreDelta {
|
|||||||
return new this(opts.id, elements, appState);
|
return new this(opts.id, elements, appState);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the delta between the previous and next snapshot.
|
|
||||||
*/
|
|
||||||
public static calculate(
|
|
||||||
prevSnapshot: StoreSnapshot,
|
|
||||||
nextSnapshot: StoreSnapshot,
|
|
||||||
) {
|
|
||||||
const elementsDelta = nextSnapshot.metadata.didElementsChange
|
|
||||||
? ElementsDelta.calculate(prevSnapshot.elements, nextSnapshot.elements)
|
|
||||||
: ElementsDelta.empty();
|
|
||||||
|
|
||||||
const appStateDelta = nextSnapshot.metadata.didAppStateChange
|
|
||||||
? AppStateDelta.calculate(prevSnapshot.appState, nextSnapshot.appState)
|
|
||||||
: AppStateDelta.empty();
|
|
||||||
|
|
||||||
return this.create(elementsDelta, appStateDelta);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore a store delta instance from a DTO.
|
* Restore a store delta instance from a DTO.
|
||||||
*/
|
*/
|
||||||
@ -533,7 +524,9 @@ export class StoreDelta {
|
|||||||
id,
|
id,
|
||||||
elements: { added, removed, updated },
|
elements: { added, removed, updated },
|
||||||
}: DTO<StoreDelta>) {
|
}: DTO<StoreDelta>) {
|
||||||
const elements = ElementsDelta.create(added, removed, updated);
|
const elements = ElementsDelta.create(added, removed, updated, {
|
||||||
|
shouldRedistribute: false,
|
||||||
|
});
|
||||||
|
|
||||||
return new this(id, elements, AppStateDelta.empty());
|
return new this(id, elements, AppStateDelta.empty());
|
||||||
}
|
}
|
||||||
@ -541,10 +534,27 @@ export class StoreDelta {
|
|||||||
/**
|
/**
|
||||||
* Inverse store delta, creates new instance of `StoreDelta`.
|
* Inverse store delta, creates new instance of `StoreDelta`.
|
||||||
*/
|
*/
|
||||||
public static inverse(delta: StoreDelta) {
|
public static inverse(delta: StoreDelta): StoreDelta {
|
||||||
return this.create(delta.elements.inverse(), delta.appState.inverse());
|
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.
|
* Apply the delta to the passed elements and appState, does not modify the snapshot.
|
||||||
*/
|
*/
|
||||||
@ -552,9 +562,12 @@ export class StoreDelta {
|
|||||||
delta: StoreDelta,
|
delta: StoreDelta,
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
prevSnapshot: StoreSnapshot = StoreSnapshot.empty(),
|
||||||
): [SceneElementsMap, AppState, boolean] {
|
): [SceneElementsMap, AppState, boolean] {
|
||||||
const [nextElements, elementsContainVisibleChange] =
|
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||||
delta.elements.applyTo(elements);
|
elements,
|
||||||
|
prevSnapshot.elements,
|
||||||
|
);
|
||||||
|
|
||||||
const [nextAppState, appStateContainsVisibleChange] =
|
const [nextAppState, appStateContainsVisibleChange] =
|
||||||
delta.appState.applyTo(appState, nextElements);
|
delta.appState.applyTo(appState, nextElements);
|
||||||
@ -674,10 +687,11 @@ export class StoreSnapshot {
|
|||||||
nextElements.set(id, changedElement);
|
nextElements.set(id, changedElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAppState = getObservedAppState({
|
const nextAppState = Object.assign(
|
||||||
...this.appState,
|
{},
|
||||||
...change.appState,
|
this.appState,
|
||||||
});
|
change.appState,
|
||||||
|
) as ObservedAppState;
|
||||||
|
|
||||||
return StoreSnapshot.create(nextElements, nextAppState, {
|
return StoreSnapshot.create(nextElements, nextAppState, {
|
||||||
// by default we assume that change is different from what we have in the snapshot
|
// by default we assume that change is different from what we have in the snapshot
|
||||||
@ -930,26 +944,18 @@ const getDefaultObservedAppState = (): ObservedAppState => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getObservedAppState = (
|
export const getObservedAppState = (appState: AppState): ObservedAppState => {
|
||||||
appState: AppState | ObservedAppState,
|
|
||||||
): ObservedAppState => {
|
|
||||||
const observedAppState = {
|
const observedAppState = {
|
||||||
name: appState.name,
|
name: appState.name,
|
||||||
editingGroupId: appState.editingGroupId,
|
editingGroupId: appState.editingGroupId,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
selectedGroupIds: appState.selectedGroupIds,
|
selectedGroupIds: appState.selectedGroupIds,
|
||||||
|
editingLinearElementId: appState.editingLinearElement?.elementId || null,
|
||||||
|
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
|
||||||
croppingElementId: appState.croppingElementId,
|
croppingElementId: appState.croppingElementId,
|
||||||
activeLockedId: appState.activeLockedId,
|
activeLockedId: appState.activeLockedId,
|
||||||
lockedMultiSelections: appState.lockedMultiSelections,
|
lockedMultiSelections: appState.lockedMultiSelections,
|
||||||
editingLinearElementId:
|
|
||||||
(appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer
|
|
||||||
(appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot
|
|
||||||
null,
|
|
||||||
selectedLinearElementId:
|
|
||||||
(appState as AppState).selectedLinearElement?.elementId ??
|
|
||||||
(appState as ObservedAppState).selectedLinearElementId ??
|
|
||||||
null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||||
|
@ -505,6 +505,8 @@ describe("group-related duplication", () => {
|
|||||||
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
|
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// console.log(h.elements);
|
||||||
|
|
||||||
assertElements(h.elements, [
|
assertElements(h.elements, [
|
||||||
{ id: frame.id },
|
{ id: frame.id },
|
||||||
{ id: rectangle1.id, frameId: frame.id },
|
{ id: rectangle1.id, frameId: frame.id },
|
||||||
|
@ -1483,13 +1483,13 @@ const getArrowheadOptions = (flip: boolean) => {
|
|||||||
value: "crowfoot_one",
|
value: "crowfoot_one",
|
||||||
text: t("labels.arrowhead_crowfoot_one"),
|
text: t("labels.arrowhead_crowfoot_one"),
|
||||||
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
|
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
|
||||||
keyBinding: "x",
|
keyBinding: "c",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "crowfoot_many",
|
value: "crowfoot_many",
|
||||||
text: t("labels.arrowhead_crowfoot_many"),
|
text: t("labels.arrowhead_crowfoot_many"),
|
||||||
icon: <ArrowheadCrowfootIcon flip={flip} />,
|
icon: <ArrowheadCrowfootIcon flip={flip} />,
|
||||||
keyBinding: "c",
|
keyBinding: "x",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "crowfoot_one_or_many",
|
value: "crowfoot_one_or_many",
|
||||||
|
@ -104,11 +104,7 @@ import {
|
|||||||
Emitter,
|
Emitter,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element";
|
||||||
getCommonBounds,
|
|
||||||
getElementAbsoluteCoords,
|
|
||||||
getObservedAppState,
|
|
||||||
} from "@excalidraw/element";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
@ -3997,30 +3993,22 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}) => {
|
}) => {
|
||||||
const { elements, appState, collaborators, captureUpdate } = sceneData;
|
const { elements, appState, collaborators, captureUpdate } = sceneData;
|
||||||
|
|
||||||
const nextElements = elements
|
const nextElements = elements ? syncInvalidIndices(elements) : undefined;
|
||||||
? syncInvalidIndices(elements, {
|
|
||||||
// we have to create new instances here, otherwise scheduled micro action below won't be able to
|
|
||||||
// detect the fractional index change and won't update the store snapshot
|
|
||||||
shouldCreateNewInstances: true,
|
|
||||||
})
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (captureUpdate) {
|
if (captureUpdate) {
|
||||||
const nextElementsMap = elements
|
const nextElementsMap = elements
|
||||||
? (arrayToMap(nextElements ?? []) as SceneElementsMap)
|
? (arrayToMap(nextElements ?? []) as SceneElementsMap)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const nextObservedAppState = appState
|
const nextAppState = appState
|
||||||
? getObservedAppState({
|
? // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
|
||||||
...this.store.snapshot.appState,
|
Object.assign({}, this.store.snapshot.appState, appState)
|
||||||
...appState,
|
|
||||||
})
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
this.store.scheduleMicroAction({
|
this.store.scheduleMicroAction({
|
||||||
action: captureUpdate,
|
action: captureUpdate,
|
||||||
elements: nextElementsMap,
|
elements: nextElementsMap,
|
||||||
appState: nextObservedAppState,
|
appState: nextAppState,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -564,7 +564,7 @@ export const convertElementTypes = (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const fixedSegments: FixedSegment[] = [];
|
const fixedSegments: FixedSegment[] = [];
|
||||||
for (let i = 1; i < nextPoints.length - 2; i++) {
|
for (let i = 0; i < nextPoints.length - 1; i++) {
|
||||||
fixedSegments.push({
|
fixedSegments.push({
|
||||||
start: nextPoints[i],
|
start: nextPoints[i],
|
||||||
end: nextPoints[i + 1],
|
end: nextPoints[i + 1],
|
||||||
@ -581,7 +581,6 @@ export const convertElementTypes = (
|
|||||||
);
|
);
|
||||||
mutateElement(element, app.scene.getNonDeletedElementsMap(), {
|
mutateElement(element, app.scene.getNonDeletedElementsMap(), {
|
||||||
...updates,
|
...updates,
|
||||||
endArrowhead: "arrow",
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// if we're converting to non-elbow linear element, check if
|
// if we're converting to non-elbow linear element, check if
|
||||||
|
@ -7,62 +7,11 @@ import {
|
|||||||
type Store,
|
type Store,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type { StoreSnapshot } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { AppState } from "./types";
|
import type { AppState } from "./types";
|
||||||
|
|
||||||
export class HistoryDelta extends StoreDelta {
|
class HistoryEntry extends StoreDelta {}
|
||||||
/**
|
|
||||||
* Apply the delta to the passed elements and appState, does not modify the snapshot.
|
|
||||||
*/
|
|
||||||
public applyTo(
|
|
||||||
elements: SceneElementsMap,
|
|
||||||
appState: AppState,
|
|
||||||
snapshot: StoreSnapshot,
|
|
||||||
): [SceneElementsMap, AppState, boolean] {
|
|
||||||
const [nextElements, elementsContainVisibleChange] = this.elements.applyTo(
|
|
||||||
elements,
|
|
||||||
// used to fallback into local snapshot in case we couldn't apply the delta
|
|
||||||
// due to a missing elements in the scene (force deleted)
|
|
||||||
snapshot.elements,
|
|
||||||
// we don't want to apply the `version` and `versionNonce` properties for history
|
|
||||||
// as we always need to end up with a new version due to collaboration,
|
|
||||||
// approaching each undo / redo as a new user operation
|
|
||||||
{
|
|
||||||
excludedProperties: new Set(["version", "versionNonce"]),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const [nextAppState, appStateContainsVisibleChange] = this.appState.applyTo(
|
|
||||||
appState,
|
|
||||||
nextElements,
|
|
||||||
);
|
|
||||||
|
|
||||||
const appliedVisibleChanges =
|
|
||||||
elementsContainVisibleChange || appStateContainsVisibleChange;
|
|
||||||
|
|
||||||
return [nextElements, nextAppState, appliedVisibleChanges];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overriding once to avoid type casting everywhere.
|
|
||||||
*/
|
|
||||||
public static override calculate(
|
|
||||||
prevSnapshot: StoreSnapshot,
|
|
||||||
nextSnapshot: StoreSnapshot,
|
|
||||||
) {
|
|
||||||
return super.calculate(prevSnapshot, nextSnapshot) as HistoryDelta;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overriding once to avoid type casting everywhere.
|
|
||||||
*/
|
|
||||||
public static override inverse(delta: StoreDelta): HistoryDelta {
|
|
||||||
return super.inverse(delta) as HistoryDelta;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HistoryChangedEvent {
|
export class HistoryChangedEvent {
|
||||||
constructor(
|
constructor(
|
||||||
@ -76,8 +25,8 @@ export class History {
|
|||||||
[HistoryChangedEvent]
|
[HistoryChangedEvent]
|
||||||
>();
|
>();
|
||||||
|
|
||||||
public readonly undoStack: HistoryDelta[] = [];
|
public readonly undoStack: HistoryEntry[] = [];
|
||||||
public readonly redoStack: HistoryDelta[] = [];
|
public readonly redoStack: HistoryEntry[] = [];
|
||||||
|
|
||||||
public get isUndoStackEmpty() {
|
public get isUndoStackEmpty() {
|
||||||
return this.undoStack.length === 0;
|
return this.undoStack.length === 0;
|
||||||
@ -99,16 +48,16 @@ export class History {
|
|||||||
* Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action.
|
* Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action.
|
||||||
*/
|
*/
|
||||||
public record(delta: StoreDelta) {
|
public record(delta: StoreDelta) {
|
||||||
if (delta.isEmpty() || delta instanceof HistoryDelta) {
|
if (delta.isEmpty() || delta instanceof HistoryEntry) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// construct history entry, so once it's emitted, it's not recorded again
|
// construct history entry, so once it's emitted, it's not recorded again
|
||||||
const historyDelta = HistoryDelta.inverse(delta);
|
const entry = HistoryEntry.inverse(delta);
|
||||||
|
|
||||||
this.undoStack.push(historyDelta);
|
this.undoStack.push(entry);
|
||||||
|
|
||||||
if (!historyDelta.elements.isEmpty()) {
|
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!
|
||||||
@ -125,7 +74,7 @@ export class History {
|
|||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
() => History.pop(this.undoStack),
|
() => History.pop(this.undoStack),
|
||||||
(entry: HistoryDelta) => History.push(this.redoStack, entry),
|
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,20 +83,20 @@ export class History {
|
|||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
() => History.pop(this.redoStack),
|
() => History.pop(this.redoStack),
|
||||||
(entry: HistoryDelta) => History.push(this.undoStack, entry),
|
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private perform(
|
private perform(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
pop: () => HistoryDelta | null,
|
pop: () => HistoryEntry | null,
|
||||||
push: (entry: HistoryDelta) => void,
|
push: (entry: HistoryEntry) => void,
|
||||||
): [SceneElementsMap, AppState] | void {
|
): [SceneElementsMap, AppState] | void {
|
||||||
try {
|
try {
|
||||||
let historyDelta = pop();
|
let historyEntry = pop();
|
||||||
|
|
||||||
if (historyDelta === null) {
|
if (historyEntry === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,10 +109,15 @@ export class History {
|
|||||||
let containsVisibleChange = false;
|
let containsVisibleChange = false;
|
||||||
|
|
||||||
// iterate through the history entries in case they result in no visible changes
|
// iterate through the history entries in case they result in no visible changes
|
||||||
while (historyDelta) {
|
while (historyEntry) {
|
||||||
try {
|
try {
|
||||||
[nextElements, nextAppState, containsVisibleChange] =
|
[nextElements, nextAppState, containsVisibleChange] =
|
||||||
historyDelta.applyTo(nextElements, nextAppState, prevSnapshot);
|
StoreDelta.applyTo(
|
||||||
|
historyEntry,
|
||||||
|
nextElements,
|
||||||
|
nextAppState,
|
||||||
|
prevSnapshot,
|
||||||
|
);
|
||||||
|
|
||||||
const nextSnapshot = prevSnapshot.maybeClone(
|
const nextSnapshot = prevSnapshot.maybeClone(
|
||||||
action,
|
action,
|
||||||
@ -171,31 +125,24 @@ export class History {
|
|||||||
nextAppState,
|
nextAppState,
|
||||||
);
|
);
|
||||||
|
|
||||||
const change = StoreChange.create(prevSnapshot, nextSnapshot);
|
|
||||||
|
|
||||||
// update the history entry, so that it's the a new history entry instance
|
|
||||||
// and so that it contains the latest changes in both inserted and deleted partials
|
|
||||||
// including version and versionNonce or properties which were in the meantime updated by a remote client
|
|
||||||
historyDelta = HistoryDelta.calculate(prevSnapshot, nextSnapshot);
|
|
||||||
|
|
||||||
// schedule immediate capture, so that it's emitted for the sync purposes
|
// schedule immediate capture, so that it's emitted for the sync purposes
|
||||||
this.store.scheduleMicroAction({
|
this.store.scheduleMicroAction({
|
||||||
action,
|
action,
|
||||||
change,
|
change: StoreChange.create(prevSnapshot, nextSnapshot),
|
||||||
delta: historyDelta,
|
delta: historyEntry,
|
||||||
});
|
});
|
||||||
|
|
||||||
prevSnapshot = nextSnapshot;
|
prevSnapshot = nextSnapshot;
|
||||||
} finally {
|
} finally {
|
||||||
// make sure to always push, even if the delta is corrupted
|
// make sure to always push, even if the delta is corrupted
|
||||||
push(historyDelta);
|
push(historyEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (containsVisibleChange) {
|
if (containsVisibleChange) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
historyDelta = pop();
|
historyEntry = pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
return [nextElements, nextAppState];
|
return [nextElements, nextAppState];
|
||||||
@ -208,7 +155,7 @@ export class History {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static pop(stack: HistoryDelta[]): HistoryDelta | null {
|
private static pop(stack: HistoryEntry[]): HistoryEntry | null {
|
||||||
if (!stack.length) {
|
if (!stack.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -222,12 +169,18 @@ export class History {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static push(stack: HistoryDelta[], entry: HistoryDelta) {
|
private static push(
|
||||||
if (entry.isEmpty()) {
|
stack: HistoryEntry[],
|
||||||
return;
|
entry: HistoryEntry,
|
||||||
}
|
prevElements: SceneElementsMap,
|
||||||
|
) {
|
||||||
|
const inversedEntry = HistoryEntry.inverse(entry);
|
||||||
|
const updatedEntry = HistoryEntry.applyLatestChanges(
|
||||||
|
inversedEntry,
|
||||||
|
prevElements,
|
||||||
|
"inserted",
|
||||||
|
);
|
||||||
|
|
||||||
const inversedEntry = HistoryDelta.inverse(entry);
|
return stack.push(updatedEntry);
|
||||||
return stack.push(inversedEntry);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1277,7 +1277,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@ -1526,7 +1525,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@ -1581,7 +1579,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
@ -1617,11 +1614,9 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
|||||||
"id0": {
|
"id0": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"index": "a2",
|
"index": "a2",
|
||||||
"version": 4,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"index": "a0",
|
"index": "a0",
|
||||||
"version": 3,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -1863,7 +1858,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@ -1918,7 +1912,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
@ -1954,11 +1947,9 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
|||||||
"id0": {
|
"id0": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"index": "a2",
|
"index": "a2",
|
||||||
"version": 4,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"index": "a0",
|
"index": "a0",
|
||||||
"version": 3,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -2168,7 +2159,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@ -2381,7 +2371,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@ -2413,11 +2402,9 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
|||||||
"id0": {
|
"id0": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"isDeleted": true,
|
"isDeleted": true,
|
||||||
"version": 4,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"version": 3,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -2661,7 +2648,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@ -2716,7 +2702,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 5,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
@ -2974,7 +2959,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@ -3029,7 +3013,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
@ -3085,11 +3068,9 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
|||||||
"groupIds": [
|
"groupIds": [
|
||||||
"id9",
|
"id9",
|
||||||
],
|
],
|
||||||
"version": 4,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"version": 3,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"id3": {
|
"id3": {
|
||||||
@ -3097,11 +3078,9 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
|||||||
"groupIds": [
|
"groupIds": [
|
||||||
"id9",
|
"id9",
|
||||||
],
|
],
|
||||||
"version": 4,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"version": 3,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -3345,7 +3324,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@ -3400,7 +3378,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
@ -3428,11 +3405,9 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
|||||||
"id3": {
|
"id3": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"strokeColor": "#e03131",
|
"strokeColor": "#e03131",
|
||||||
"version": 4,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"version": 3,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -3453,11 +3428,9 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
|||||||
"id3": {
|
"id3": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"backgroundColor": "#a5d8ff",
|
"backgroundColor": "#a5d8ff",
|
||||||
"version": 5,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"version": 4,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -3478,11 +3451,9 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
|||||||
"id3": {
|
"id3": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"fillStyle": "cross-hatch",
|
"fillStyle": "cross-hatch",
|
||||||
"version": 6,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"version": 5,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -3503,11 +3474,9 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
|||||||
"id3": {
|
"id3": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"strokeStyle": "dotted",
|
"strokeStyle": "dotted",
|
||||||
"version": 7,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"version": 6,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -3528,11 +3497,9 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
|||||||
"id3": {
|
"id3": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"roughness": 2,
|
"roughness": 2,
|
||||||
"version": 8,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"version": 7,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -3553,11 +3520,9 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
|||||||
"id3": {
|
"id3": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"opacity": 60,
|
"opacity": 60,
|
||||||
"version": 9,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
"version": 8,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -3591,7 +3556,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
|||||||
"roughness": 2,
|
"roughness": 2,
|
||||||
"strokeColor": "#e03131",
|
"strokeColor": "#e03131",
|
||||||
"strokeStyle": "dotted",
|
"strokeStyle": "dotted",
|
||||||
"version": 4,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
@ -3600,7 +3564,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
|||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"version": 3,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -3842,7 +3805,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@ -3897,7 +3859,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
@ -3925,11 +3886,9 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
|||||||
"id3": {
|
"id3": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"index": "Zz",
|
"index": "Zz",
|
||||||
"version": 4,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"index": "a1",
|
"index": "a1",
|
||||||
"version": 3,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -4171,7 +4130,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@ -4226,7 +4184,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
@ -4254,11 +4211,9 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
|||||||
"id3": {
|
"id3": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"index": "Zz",
|
"index": "Zz",
|
||||||
"version": 4,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"index": "a1",
|
"index": "a1",
|
||||||
"version": 3,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -4503,7 +4458,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@ -4558,7 +4512,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
@ -4614,11 +4567,9 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
|||||||
"groupIds": [
|
"groupIds": [
|
||||||
"id9",
|
"id9",
|
||||||
],
|
],
|
||||||
"version": 4,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"version": 3,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"id3": {
|
"id3": {
|
||||||
@ -4626,11 +4577,9 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
|||||||
"groupIds": [
|
"groupIds": [
|
||||||
"id9",
|
"id9",
|
||||||
],
|
],
|
||||||
"version": 4,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"version": 3,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -4657,25 +4606,21 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
|||||||
"id0": {
|
"id0": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"version": 5,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"groupIds": [
|
"groupIds": [
|
||||||
"id9",
|
"id9",
|
||||||
],
|
],
|
||||||
"version": 4,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"id3": {
|
"id3": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"version": 5,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"groupIds": [
|
"groupIds": [
|
||||||
"id9",
|
"id9",
|
||||||
],
|
],
|
||||||
"version": 4,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -5794,7 +5739,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@ -5849,7 +5793,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": 12,
|
"x": 12,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@ -7023,7 +6966,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@ -7078,7 +7020,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": 12,
|
"x": 12,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@ -7156,11 +7097,9 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
"groupIds": [
|
"groupIds": [
|
||||||
"id12",
|
"id12",
|
||||||
],
|
],
|
||||||
"version": 4,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"version": 3,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"id3": {
|
"id3": {
|
||||||
@ -7168,11 +7107,9 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
"groupIds": [
|
"groupIds": [
|
||||||
"id12",
|
"id12",
|
||||||
],
|
],
|
||||||
"version": 4,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"version": 3,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -9989,7 +9926,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] un
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"version": 3,
|
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -25,7 +25,7 @@ import "@excalidraw/utils/test-utils";
|
|||||||
|
|
||||||
import { ElementsDelta, AppStateDelta } from "@excalidraw/element";
|
import { ElementsDelta, AppStateDelta } from "@excalidraw/element";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction, StoreDelta } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
@ -34,7 +34,6 @@ import type {
|
|||||||
ExcalidrawFrameElement,
|
ExcalidrawFrameElement,
|
||||||
ExcalidrawGenericElement,
|
ExcalidrawGenericElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawRectangleElement,
|
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
FractionalIndex,
|
FractionalIndex,
|
||||||
@ -54,8 +53,6 @@ import { getDefaultAppState } from "../appState";
|
|||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import * as StaticScene from "../renderer/staticScene";
|
import * as StaticScene from "../renderer/staticScene";
|
||||||
|
|
||||||
import { HistoryDelta } from "../history";
|
|
||||||
|
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||||
import {
|
import {
|
||||||
@ -123,19 +120,9 @@ describe("history", () => {
|
|||||||
|
|
||||||
API.setElements([rect]);
|
API.setElements([rect]);
|
||||||
|
|
||||||
const corrupedEntry = HistoryDelta.create(
|
const corrupedEntry = StoreDelta.create(
|
||||||
ElementsDelta.empty(),
|
ElementsDelta.empty(),
|
||||||
// delta can't be empty, otherwise it won't be pushed into the undo stack
|
AppStateDelta.empty(),
|
||||||
AppStateDelta.restore({
|
|
||||||
delta: {
|
|
||||||
inserted: {
|
|
||||||
selectedElementIds: {},
|
|
||||||
},
|
|
||||||
deleted: {
|
|
||||||
selectedElementIds: { [rect.id]: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.spyOn(corrupedEntry.elements, "applyTo").mockImplementation(() => {
|
vi.spyOn(corrupedEntry.elements, "applyTo").mockImplementation(() => {
|
||||||
@ -402,7 +389,7 @@ describe("history", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not push into redo stack when selection changes dooes not produce a visible change", async () => {
|
it("should iterate through the history when selection changes do not produce visible change", async () => {
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
const rect = UI.createElement("rectangle", { x: 10 });
|
const rect = UI.createElement("rectangle", { x: 10 });
|
||||||
@ -433,18 +420,18 @@ describe("history", () => {
|
|||||||
expect(API.getSelectedElements().length).toBe(0);
|
expect(API.getSelectedElements().length).toBe(0);
|
||||||
|
|
||||||
Keyboard.redo(); // acceptable empty redo
|
Keyboard.redo(); // acceptable empty redo
|
||||||
expect(API.getUndoStack().length).toBe(2); // empty change, nothing goes into undo stack
|
expect(API.getUndoStack().length).toBe(3);
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
expect(API.getSelectedElements().length).toBe(0);
|
expect(API.getSelectedElements().length).toBe(0);
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
expect(API.getRedoStack().length).toBe(1);
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
assertSelectedElements(rect);
|
assertSelectedElements(rect);
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
expect(API.getUndoStack().length).toBe(0);
|
expect(API.getUndoStack().length).toBe(0); // now we iterated through the same undos!
|
||||||
expect(API.getRedoStack().length).toBe(2);
|
expect(API.getRedoStack().length).toBe(3);
|
||||||
expect(API.getSelectedElements().length).toBe(0);
|
expect(API.getSelectedElements().length).toBe(0);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({ id: rect.id, isDeleted: true }),
|
expect.objectContaining({ id: rect.id, isDeleted: true }),
|
||||||
@ -1326,10 +1313,6 @@ describe("history", () => {
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
});
|
});
|
||||||
|
|
||||||
rect1 = h.elements[0] as ExcalidrawRectangleElement;
|
|
||||||
text = h.elements[1] as ExcalidrawTextElement;
|
|
||||||
rect2 = h.elements[2] as ExcalidrawRectangleElement;
|
|
||||||
|
|
||||||
// bind text1 to rect1
|
// bind text1 to rect1
|
||||||
mouse.select([rect1, text]);
|
mouse.select([rect1, text]);
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas);
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas);
|
||||||
@ -2379,10 +2362,10 @@ describe("history", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// We reached the bottom, again we iterate through invisible changes and reach the top
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
assertSelectedElements();
|
assertSelectedElements();
|
||||||
// We reached the bottom, now there is only one non-empty history delta, which will be pushed to the undo stack
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@ -2447,31 +2430,7 @@ describe("history", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
expect(API.getUndoStack().length).toBe(5);
|
||||||
expect(API.getRedoStack().length).toBe(3);
|
|
||||||
// visible change detected, so we don't iterate up again
|
|
||||||
expect(API.getSelectedElements()).toEqual([]);
|
|
||||||
expect(h.elements).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
id: rect1.id,
|
|
||||||
isDeleted: false,
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
id: rect2.id,
|
|
||||||
isDeleted: true,
|
|
||||||
backgroundColor: transparent,
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
id: rect3.id,
|
|
||||||
isDeleted: true,
|
|
||||||
x: 30,
|
|
||||||
y: 30,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Keyboard.redo();
|
|
||||||
// we iterate all the way up, as there are no visible changes
|
|
||||||
expect(API.getUndoStack().length).toBe(4);
|
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
expect(API.getSelectedElements()).toEqual([]);
|
expect(API.getSelectedElements()).toEqual([]);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
@ -2530,8 +2489,8 @@ describe("history", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
expect(API.getUndoStack().length).toBe(3);
|
||||||
expect(API.getRedoStack().length).toBe(1);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
// do not expect any selectedElementIds, as all relate to deleted elements
|
// do not expect any selectedElementIds, as all relate to deleted elements
|
||||||
expect(API.getSelectedElements()).toEqual([]);
|
expect(API.getSelectedElements()).toEqual([]);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
@ -2547,6 +2506,7 @@ describe("history", () => {
|
|||||||
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Simulate remote update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [
|
elements: [
|
||||||
h.elements[0],
|
h.elements[0],
|
||||||
@ -2563,13 +2523,14 @@ describe("history", () => {
|
|||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
expect(API.getRedoStack().length).toBe(1);
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
// redo entry was calculated again with the latest undo, which goes back to nothing being selected
|
expect(API.getSelectedElements()).toEqual([
|
||||||
expect(API.getSelectedElements()).toEqual([]);
|
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
||||||
|
]);
|
||||||
|
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
expect(API.getUndoStack().length).toBe(3);
|
expect(API.getUndoStack().length).toBe(3);
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
// now we again expect these as selected, as they got restored remotely and this redo entry was calculated in the beginning
|
// now we again expect these as selected, as they got restored remotely
|
||||||
expect(API.getSelectedElements()).toEqual([
|
expect(API.getSelectedElements()).toEqual([
|
||||||
expect.objectContaining({ id: rect2.id }),
|
expect.objectContaining({ id: rect2.id }),
|
||||||
expect.objectContaining({ id: rect3.id }),
|
expect.objectContaining({ id: rect3.id }),
|
||||||
@ -2638,14 +2599,16 @@ describe("history", () => {
|
|||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
expect(API.getUndoStack().length).toBe(0);
|
expect(API.getUndoStack().length).toBe(0);
|
||||||
expect(API.getRedoStack().length).toBe(1); // iterated two steps back and reduce two empty entries into one!
|
expect(API.getRedoStack().length).toBe(2); // iterated two steps back!
|
||||||
expect(h.state.selectedGroupIds).toEqual({});
|
expect(h.state.selectedGroupIds).toEqual({});
|
||||||
|
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
expect(API.getUndoStack().length).toBe(0); // no changes applied, so there is no new entry
|
expect(API.getUndoStack().length).toBe(2); // iterated two steps forward!
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
expect(h.state.selectedGroupIds).toEqual({});
|
expect(h.state.selectedGroupIds).toEqual({});
|
||||||
|
|
||||||
|
Keyboard.undo();
|
||||||
|
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [
|
elements: [
|
||||||
@ -2658,6 +2621,22 @@ describe("history", () => {
|
|||||||
],
|
],
|
||||||
captureUpdate: CaptureUpdateAction.NEVER,
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Keyboard.redo();
|
||||||
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
|
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
||||||
|
|
||||||
|
// Simulate remote update
|
||||||
|
API.updateScene({
|
||||||
|
elements: [h.elements[0], h.elements[1], rect3, rect4],
|
||||||
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
|
});
|
||||||
|
|
||||||
|
Keyboard.redo();
|
||||||
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
|
expect(h.state.selectedGroupIds).toEqual({ A: true, B: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should iterate through the history when editing group contains only remotely deleted elements", async () => {
|
it("should iterate through the history when editing group contains only remotely deleted elements", async () => {
|
||||||
@ -2702,7 +2681,33 @@ describe("history", () => {
|
|||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
expect(API.getUndoStack().length).toBe(0);
|
expect(API.getUndoStack().length).toBe(0);
|
||||||
expect(API.getRedoStack().length).toBe(0); // all changes relate to remotely deleted elements, there is no change and thus nothing to redo
|
expect(API.getRedoStack().length).toBe(3);
|
||||||
|
expect(h.state.editingGroupId).toBeNull();
|
||||||
|
|
||||||
|
Keyboard.redo();
|
||||||
|
expect(API.getUndoStack().length).toBe(3);
|
||||||
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
|
expect(h.state.editingGroupId).toBeNull();
|
||||||
|
|
||||||
|
// Simulate remote update
|
||||||
|
API.updateScene({
|
||||||
|
elements: [
|
||||||
|
newElementWith(h.elements[0], {
|
||||||
|
isDeleted: false,
|
||||||
|
}),
|
||||||
|
h.elements[1],
|
||||||
|
],
|
||||||
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
|
});
|
||||||
|
|
||||||
|
Keyboard.undo();
|
||||||
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
|
expect(h.state.editingGroupId).toBe("A");
|
||||||
|
|
||||||
|
Keyboard.redo();
|
||||||
|
expect(API.getUndoStack().length).toBe(3);
|
||||||
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
expect(h.state.editingGroupId).toBeNull();
|
expect(h.state.editingGroupId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -2739,13 +2744,13 @@ describe("history", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
expect(API.getUndoStack().length).toBe(1); // iterated few entries back
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
expect(API.getRedoStack().length).toBe(1); // added just one non-empty entry into redo stack
|
expect(API.getRedoStack().length).toBe(3);
|
||||||
expect(h.state.editingLinearElement).toBeNull();
|
expect(h.state.editingLinearElement).toBeNull();
|
||||||
expect(h.state.selectedLinearElement).toBeNull();
|
expect(h.state.selectedLinearElement).toBeNull();
|
||||||
|
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
expect(API.getUndoStack().length).toBe(4);
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
expect(h.state.editingLinearElement).toBeNull();
|
expect(h.state.editingLinearElement).toBeNull();
|
||||||
expect(h.state.selectedLinearElement).toBeNull();
|
expect(h.state.selectedLinearElement).toBeNull();
|
||||||
@ -2833,7 +2838,7 @@ describe("history", () => {
|
|||||||
// We iterated two steps as there was no change in order!
|
// We iterated two steps as there was no change in order!
|
||||||
expect(API.getUndoStack().length).toBe(0);
|
expect(API.getUndoStack().length).toBe(0);
|
||||||
expect(API.getRedoStack().length).toBe(2);
|
expect(API.getRedoStack().length).toBe(2);
|
||||||
assertSelectedElements([]);
|
expect(API.getSelectedElements().length).toBe(0);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({ id: rect1.id }), // a "Zx"
|
expect.objectContaining({ id: rect1.id }), // a "Zx"
|
||||||
expect.objectContaining({ id: rect3.id }), // c "Zy"
|
expect.objectContaining({ id: rect3.id }), // c "Zy"
|
||||||
@ -2841,7 +2846,7 @@ describe("history", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should tolerate remote z-index changes with incorrect fractional indices", async () => {
|
it("should iterate through the history when z-index changes do not produce visible change and we synced all indices", async () => {
|
||||||
const rect1 = API.createElement({ type: "rectangle", x: 10, y: 10 });
|
const rect1 = API.createElement({ type: "rectangle", x: 10, y: 10 });
|
||||||
const rect2 = API.createElement({ type: "rectangle", x: 20, y: 20 });
|
const rect2 = API.createElement({ type: "rectangle", x: 20, y: 20 });
|
||||||
const rect3 = API.createElement({ type: "rectangle", x: 30, y: 30 });
|
const rect3 = API.createElement({ type: "rectangle", x: 30, y: 30 });
|
||||||
@ -2894,32 +2899,22 @@ describe("history", () => {
|
|||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [
|
elements: [
|
||||||
|
h.elements[1], // rect2
|
||||||
h.elements[0], // rect3
|
h.elements[0], // rect3
|
||||||
h.elements[2], // rect1
|
h.elements[2], // rect1
|
||||||
h.elements[1], // rect2
|
|
||||||
],
|
],
|
||||||
captureUpdate: CaptureUpdateAction.NEVER,
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
expect(API.getUndoStack().length).toBe(0);
|
||||||
expect(API.getRedoStack().length).toBe(1);
|
expect(API.getRedoStack().length).toBe(2); // now we iterated two steps back!
|
||||||
assertSelectedElements([rect2]);
|
assertSelectedElements([]);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({ id: rect2.id }),
|
expect.objectContaining({ id: rect2.id }),
|
||||||
expect.objectContaining({ id: rect3.id }),
|
expect.objectContaining({ id: rect3.id }),
|
||||||
expect.objectContaining({ id: rect1.id }),
|
expect.objectContaining({ id: rect1.id }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Keyboard.redo();
|
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
|
||||||
assertSelectedElements([rect2]);
|
|
||||||
expect(h.elements).toEqual([
|
|
||||||
expect.objectContaining({ id: rect3.id }),
|
|
||||||
expect.objectContaining({ id: rect1.id }),
|
|
||||||
expect.objectContaining({ id: rect2.id }),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not let remote changes to interfere with in progress freedraw", async () => {
|
it("should not let remote changes to interfere with in progress freedraw", async () => {
|
||||||
@ -3729,52 +3724,54 @@ describe("history", () => {
|
|||||||
captureUpdate: CaptureUpdateAction.NEVER,
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.redo();
|
runTwice(() => {
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
Keyboard.redo();
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
expect(h.elements).toEqual([
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
expect.objectContaining({
|
expect(h.elements).toEqual([
|
||||||
id: container.id,
|
expect.objectContaining({
|
||||||
// previously bound text is preserved
|
id: container.id,
|
||||||
// text bindings are not duplicated
|
// previously bound text is preserved
|
||||||
boundElements: [{ id: remoteText.id, type: "text" }],
|
// text bindings are not duplicated
|
||||||
isDeleted: false,
|
boundElements: [{ id: remoteText.id, type: "text" }],
|
||||||
}),
|
isDeleted: false,
|
||||||
expect.objectContaining({
|
}),
|
||||||
id: text.id,
|
expect.objectContaining({
|
||||||
// unbound
|
id: text.id,
|
||||||
containerId: null,
|
// unbound
|
||||||
isDeleted: false,
|
containerId: null,
|
||||||
}),
|
isDeleted: false,
|
||||||
expect.objectContaining({
|
}),
|
||||||
id: remoteText.id,
|
expect.objectContaining({
|
||||||
// preserved existing binding!
|
id: remoteText.id,
|
||||||
containerId: container.id,
|
// preserved existing binding!
|
||||||
isDeleted: false,
|
containerId: container.id,
|
||||||
}),
|
isDeleted: false,
|
||||||
]);
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
expect(API.getUndoStack().length).toBe(0);
|
expect(API.getUndoStack().length).toBe(0);
|
||||||
expect(API.getRedoStack().length).toBe(1);
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: container.id,
|
id: container.id,
|
||||||
boundElements: [{ id: remoteText.id, type: "text" }],
|
boundElements: [{ id: remoteText.id, type: "text" }],
|
||||||
isDeleted: false, // isDeleted got remotely updated to false
|
isDeleted: false, // isDeleted got remotely updated to false
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: text.id,
|
id: text.id,
|
||||||
containerId: null,
|
containerId: null,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: remoteText.id,
|
id: remoteText.id,
|
||||||
// unbound
|
// unbound
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should preserve latest remotely added binding and unbind previous one when the text is added through history", async () => {
|
it("should preserve latest remotely added binding and unbind previous one when the text is added through history", async () => {
|
||||||
|
@ -435,17 +435,12 @@ export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
|
|||||||
expect(h.state.selectedElementIds).toEqual(selectedElementIds);
|
expect(h.state.selectedElementIds).toEqual(selectedElementIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stripProps = (
|
const stripSeed = (deltas: Record<string, { deleted: any; inserted: any }>) =>
|
||||||
deltas: Record<string, { deleted: any; inserted: any }>,
|
|
||||||
props: string[],
|
|
||||||
) =>
|
|
||||||
Object.entries(deltas).reduce((acc, curr) => {
|
Object.entries(deltas).reduce((acc, curr) => {
|
||||||
const { inserted, deleted, ...rest } = curr[1];
|
const { inserted, deleted, ...rest } = curr[1];
|
||||||
|
|
||||||
for (const prop of props) {
|
delete inserted.seed;
|
||||||
delete inserted[prop];
|
delete deleted.seed;
|
||||||
delete deleted[prop];
|
|
||||||
}
|
|
||||||
|
|
||||||
acc[curr[0]] = {
|
acc[curr[0]] = {
|
||||||
inserted,
|
inserted,
|
||||||
@ -462,9 +457,9 @@ export const checkpointHistory = (history: History, name: string) => {
|
|||||||
...x,
|
...x,
|
||||||
elements: {
|
elements: {
|
||||||
...x.elements,
|
...x.elements,
|
||||||
added: stripProps(x.elements.added, ["seed", "versionNonce"]),
|
added: stripSeed(x.elements.added),
|
||||||
removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
|
removed: stripSeed(x.elements.removed),
|
||||||
updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
|
updated: stripSeed(x.elements.updated),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
).toMatchSnapshot(`[${name}] undo stack`);
|
).toMatchSnapshot(`[${name}] undo stack`);
|
||||||
@ -474,9 +469,9 @@ export const checkpointHistory = (history: History, name: string) => {
|
|||||||
...x,
|
...x,
|
||||||
elements: {
|
elements: {
|
||||||
...x.elements,
|
...x.elements,
|
||||||
added: stripProps(x.elements.added, ["seed", "versionNonce"]),
|
added: stripSeed(x.elements.added),
|
||||||
removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
|
removed: stripSeed(x.elements.removed),
|
||||||
updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
|
updated: stripSeed(x.elements.updated),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
).toMatchSnapshot(`[${name}] redo stack`);
|
).toMatchSnapshot(`[${name}] redo stack`);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user