mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Deltas in a separate package [wip]
This commit is contained in:
parent
f00069be68
commit
858c65b314
16 changed files with 2362 additions and 4 deletions
|
@ -4,9 +4,7 @@
|
||||||
"packageManager": "yarn@1.22.22",
|
"packageManager": "yarn@1.22.22",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"excalidraw-app",
|
"excalidraw-app",
|
||||||
"packages/excalidraw",
|
"packages/*",
|
||||||
"packages/utils",
|
|
||||||
"packages/math",
|
|
||||||
"examples/excalidraw",
|
"examples/excalidraw",
|
||||||
"examples/excalidraw/*"
|
"examples/excalidraw/*"
|
||||||
],
|
],
|
||||||
|
|
38
packages/deltas/package.json
Normal file
38
packages/deltas/package.json
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"name": "@excalidraw/deltas",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"main": "./dist/prod/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"module": "./dist/prod/index.js",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"development": "./dist/dev/index.js",
|
||||||
|
"default": "./dist/prod/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"types": "./dist/types/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist/*"
|
||||||
|
],
|
||||||
|
"description": "Excalidraw utilities for handling deltas",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"excalidraw",
|
||||||
|
"excalidraw-deltas"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "5.0.9",
|
||||||
|
"roughjs": "4.6.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {},
|
||||||
|
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||||
|
"repository": "https://github.com/excalidraw/excalidraw",
|
||||||
|
"scripts": {
|
||||||
|
"gen:types": "rm -rf types && tsc",
|
||||||
|
"build:esm": "rm -rf dist && node ../../scripts/buildShared.js && yarn gen:types",
|
||||||
|
"pack": "yarn build:umd && yarn pack"
|
||||||
|
}
|
||||||
|
}
|
357
packages/deltas/src/common/delta.ts
Normal file
357
packages/deltas/src/common/delta.ts
Normal file
|
@ -0,0 +1,357 @@
|
||||||
|
import { arrayToObject, assertNever } from "./utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the difference between two objects of the same type.
|
||||||
|
*
|
||||||
|
* Both `deleted` and `inserted` partials represent the same set of added, removed or updated properties, where:
|
||||||
|
* - `deleted` is a set of all the deleted values
|
||||||
|
* - `inserted` is a set of all the inserted (added, updated) values
|
||||||
|
*
|
||||||
|
* Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load.
|
||||||
|
*/
|
||||||
|
export class Delta<T> {
|
||||||
|
private constructor(
|
||||||
|
public readonly deleted: Partial<T>,
|
||||||
|
public readonly inserted: Partial<T>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static create<T>(
|
||||||
|
deleted: Partial<T>,
|
||||||
|
inserted: Partial<T>,
|
||||||
|
modifier?: (delta: Partial<T>) => Partial<T>,
|
||||||
|
modifierOptions?: "deleted" | "inserted",
|
||||||
|
) {
|
||||||
|
const modifiedDeleted =
|
||||||
|
modifier && modifierOptions !== "inserted" ? modifier(deleted) : deleted;
|
||||||
|
const modifiedInserted =
|
||||||
|
modifier && modifierOptions !== "deleted" ? modifier(inserted) : inserted;
|
||||||
|
|
||||||
|
return new Delta(modifiedDeleted, modifiedInserted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the delta between two objects.
|
||||||
|
*
|
||||||
|
* @param prevObject - The previous state of the object.
|
||||||
|
* @param nextObject - The next state of the object.
|
||||||
|
*
|
||||||
|
* @returns new delta instance.
|
||||||
|
*/
|
||||||
|
public static calculate<T extends { [key: string]: any }>(
|
||||||
|
prevObject: T,
|
||||||
|
nextObject: T,
|
||||||
|
modifier?: (partial: Partial<T>) => Partial<T>,
|
||||||
|
postProcess?: (
|
||||||
|
deleted: Partial<T>,
|
||||||
|
inserted: Partial<T>,
|
||||||
|
) => [Partial<T>, Partial<T>],
|
||||||
|
): Delta<T> {
|
||||||
|
if (prevObject === nextObject) {
|
||||||
|
return Delta.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = {} as Partial<T>;
|
||||||
|
const inserted = {} as Partial<T>;
|
||||||
|
|
||||||
|
// O(n^3) here for elements, but it's not as bad as it looks:
|
||||||
|
// - we do this only on store recordings, not on every frame (not for ephemerals)
|
||||||
|
// - we do this only on previously detected changed elements
|
||||||
|
// - we do shallow compare only on the first level of properties (not going any deeper)
|
||||||
|
// - # of properties is reasonably small
|
||||||
|
for (const key of this.distinctKeysIterator(
|
||||||
|
"full",
|
||||||
|
prevObject,
|
||||||
|
nextObject,
|
||||||
|
)) {
|
||||||
|
deleted[key as keyof T] = prevObject[key];
|
||||||
|
inserted[key as keyof T] = nextObject[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [processedDeleted, processedInserted] = postProcess
|
||||||
|
? postProcess(deleted, inserted)
|
||||||
|
: [deleted, inserted];
|
||||||
|
|
||||||
|
return Delta.create(processedDeleted, processedInserted, modifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static empty() {
|
||||||
|
return new Delta({}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isEmpty<T>(delta: Delta<T>): boolean {
|
||||||
|
return (
|
||||||
|
!Object.keys(delta.deleted).length && !Object.keys(delta.inserted).length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges deleted and inserted object partials.
|
||||||
|
*/
|
||||||
|
public static mergeObjects<T extends { [key: string]: unknown }>(
|
||||||
|
prev: T,
|
||||||
|
added: T,
|
||||||
|
removed: T,
|
||||||
|
) {
|
||||||
|
const cloned = { ...prev };
|
||||||
|
|
||||||
|
for (const key of Object.keys(removed)) {
|
||||||
|
delete cloned[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...cloned, ...added };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges deleted and inserted array partials.
|
||||||
|
*/
|
||||||
|
public static mergeArrays<T>(
|
||||||
|
prev: readonly T[] | null,
|
||||||
|
added: readonly T[] | null | undefined,
|
||||||
|
removed: readonly T[] | null | undefined,
|
||||||
|
predicate?: (value: T) => string,
|
||||||
|
) {
|
||||||
|
return Object.values(
|
||||||
|
Delta.mergeObjects(
|
||||||
|
arrayToObject(prev ?? [], predicate),
|
||||||
|
arrayToObject(added ?? [], predicate),
|
||||||
|
arrayToObject(removed ?? [], predicate),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff object partials as part of the `postProcess`.
|
||||||
|
*/
|
||||||
|
public static diffObjects<T, K extends keyof T, V extends T[K][keyof T[K]]>(
|
||||||
|
deleted: Partial<T>,
|
||||||
|
inserted: Partial<T>,
|
||||||
|
property: K,
|
||||||
|
setValue: (prevValue: V | undefined) => V,
|
||||||
|
) {
|
||||||
|
if (!deleted[property] && !inserted[property]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof deleted[property] === "object" ||
|
||||||
|
typeof inserted[property] === "object"
|
||||||
|
) {
|
||||||
|
type RecordLike = Record<string, V | undefined>;
|
||||||
|
|
||||||
|
const deletedObject: RecordLike = deleted[property] ?? {};
|
||||||
|
const insertedObject: RecordLike = inserted[property] ?? {};
|
||||||
|
|
||||||
|
const deletedDifferences = Delta.getLeftDifferences(
|
||||||
|
deletedObject,
|
||||||
|
insertedObject,
|
||||||
|
).reduce((acc, curr) => {
|
||||||
|
acc[curr] = setValue(deletedObject[curr]);
|
||||||
|
return acc;
|
||||||
|
}, {} as RecordLike);
|
||||||
|
|
||||||
|
const insertedDifferences = Delta.getRightDifferences(
|
||||||
|
deletedObject,
|
||||||
|
insertedObject,
|
||||||
|
).reduce((acc, curr) => {
|
||||||
|
acc[curr] = setValue(insertedObject[curr]);
|
||||||
|
return acc;
|
||||||
|
}, {} as RecordLike);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.keys(deletedDifferences).length ||
|
||||||
|
Object.keys(insertedDifferences).length
|
||||||
|
) {
|
||||||
|
Reflect.set(deleted, property, deletedDifferences);
|
||||||
|
Reflect.set(inserted, property, insertedDifferences);
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(deleted, property);
|
||||||
|
Reflect.deleteProperty(inserted, property);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff array partials as part of the `postProcess`.
|
||||||
|
*/
|
||||||
|
public static diffArrays<T, K extends keyof T, V extends T[K]>(
|
||||||
|
deleted: Partial<T>,
|
||||||
|
inserted: Partial<T>,
|
||||||
|
property: K,
|
||||||
|
groupBy: (value: V extends ArrayLike<infer T> ? T : never) => string,
|
||||||
|
) {
|
||||||
|
if (!deleted[property] && !inserted[property]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(deleted[property]) || Array.isArray(inserted[property])) {
|
||||||
|
const deletedArray = (
|
||||||
|
Array.isArray(deleted[property]) ? deleted[property] : []
|
||||||
|
) as [];
|
||||||
|
const insertedArray = (
|
||||||
|
Array.isArray(inserted[property]) ? inserted[property] : []
|
||||||
|
) as [];
|
||||||
|
|
||||||
|
const deletedDifferences = arrayToObject(
|
||||||
|
Delta.getLeftDifferences(
|
||||||
|
arrayToObject(deletedArray, groupBy),
|
||||||
|
arrayToObject(insertedArray, groupBy),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const insertedDifferences = arrayToObject(
|
||||||
|
Delta.getRightDifferences(
|
||||||
|
arrayToObject(deletedArray, groupBy),
|
||||||
|
arrayToObject(insertedArray, groupBy),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.keys(deletedDifferences).length ||
|
||||||
|
Object.keys(insertedDifferences).length
|
||||||
|
) {
|
||||||
|
const deletedValue = deletedArray.filter(
|
||||||
|
(x) => deletedDifferences[groupBy ? groupBy(x) : String(x)],
|
||||||
|
);
|
||||||
|
const insertedValue = insertedArray.filter(
|
||||||
|
(x) => insertedDifferences[groupBy ? groupBy(x) : String(x)],
|
||||||
|
);
|
||||||
|
|
||||||
|
Reflect.set(deleted, property, deletedValue);
|
||||||
|
Reflect.set(inserted, property, insertedValue);
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(deleted, property);
|
||||||
|
Reflect.deleteProperty(inserted, property);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares if object1 contains any different value compared to the object2.
|
||||||
|
*/
|
||||||
|
public static isLeftDifferent<T extends {}>(
|
||||||
|
object1: T,
|
||||||
|
object2: T,
|
||||||
|
skipShallowCompare = false,
|
||||||
|
): boolean {
|
||||||
|
const anyDistinctKey = this.distinctKeysIterator(
|
||||||
|
"left",
|
||||||
|
object1,
|
||||||
|
object2,
|
||||||
|
skipShallowCompare,
|
||||||
|
).next().value;
|
||||||
|
|
||||||
|
return !!anyDistinctKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares if object2 contains any different value compared to the object1.
|
||||||
|
*/
|
||||||
|
public static isRightDifferent<T extends {}>(
|
||||||
|
object1: T,
|
||||||
|
object2: T,
|
||||||
|
skipShallowCompare = false,
|
||||||
|
): boolean {
|
||||||
|
const anyDistinctKey = this.distinctKeysIterator(
|
||||||
|
"right",
|
||||||
|
object1,
|
||||||
|
object2,
|
||||||
|
skipShallowCompare,
|
||||||
|
).next().value;
|
||||||
|
|
||||||
|
return !!anyDistinctKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all the object1 keys that have distinct values.
|
||||||
|
*/
|
||||||
|
public static getLeftDifferences<T extends {}>(
|
||||||
|
object1: T,
|
||||||
|
object2: T,
|
||||||
|
skipShallowCompare = false,
|
||||||
|
) {
|
||||||
|
return Array.from(
|
||||||
|
this.distinctKeysIterator("left", object1, object2, skipShallowCompare),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all the object2 keys that have distinct values.
|
||||||
|
*/
|
||||||
|
public static getRightDifferences<T extends {}>(
|
||||||
|
object1: T,
|
||||||
|
object2: T,
|
||||||
|
skipShallowCompare = false,
|
||||||
|
) {
|
||||||
|
return Array.from(
|
||||||
|
this.distinctKeysIterator("right", object1, object2, skipShallowCompare),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterator comparing values of object properties based on the passed joining strategy.
|
||||||
|
*
|
||||||
|
* @yields keys of properties with different values
|
||||||
|
*
|
||||||
|
* WARN: it's based on shallow compare performed only on the first level and doesn't go deeper than that.
|
||||||
|
*/
|
||||||
|
private static *distinctKeysIterator<T extends {}>(
|
||||||
|
join: "left" | "right" | "full",
|
||||||
|
object1: T,
|
||||||
|
object2: T,
|
||||||
|
skipShallowCompare = false,
|
||||||
|
) {
|
||||||
|
if (object1 === object2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let keys: string[] = [];
|
||||||
|
|
||||||
|
if (join === "left") {
|
||||||
|
keys = Object.keys(object1);
|
||||||
|
} else if (join === "right") {
|
||||||
|
keys = Object.keys(object2);
|
||||||
|
} else if (join === "full") {
|
||||||
|
keys = Array.from(
|
||||||
|
new Set([...Object.keys(object1), ...Object.keys(object2)]),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assertNever(join, "Unknown distinctKeysIterator's join param");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const object1Value = object1[key as keyof T];
|
||||||
|
const object2Value = object2[key as keyof T];
|
||||||
|
|
||||||
|
if (object1Value !== object2Value) {
|
||||||
|
if (
|
||||||
|
!skipShallowCompare &&
|
||||||
|
typeof object1Value === "object" &&
|
||||||
|
typeof object2Value === "object" &&
|
||||||
|
object1Value !== null &&
|
||||||
|
object2Value !== null &&
|
||||||
|
this.isShallowEqual(object1Value, object2Value)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isShallowEqual(object1: any, object2: any) {
|
||||||
|
const keys1 = Object.keys(object1);
|
||||||
|
const keys2 = Object.keys(object1);
|
||||||
|
|
||||||
|
if (keys1.length !== keys2.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keys1) {
|
||||||
|
if (object1[key] !== object2[key]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
21
packages/deltas/src/common/interfaces.ts
Normal file
21
packages/deltas/src/common/interfaces.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
/**
|
||||||
|
* Encapsulates a set of application-level `Delta`s.
|
||||||
|
*/
|
||||||
|
export interface DeltaContainer<T> {
|
||||||
|
/**
|
||||||
|
* Inverses the `Delta`s while creating a new `DeltaContainer` instance.
|
||||||
|
*/
|
||||||
|
inverse(): DeltaContainer<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the `Delta`s to the previous object.
|
||||||
|
*
|
||||||
|
* @returns a tuple of the next object `T` with applied `Delta`s, and `boolean`, indicating whether the applied deltas resulted in a visible change.
|
||||||
|
*/
|
||||||
|
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether all `Delta`s are empty.
|
||||||
|
*/
|
||||||
|
isEmpty(): boolean;
|
||||||
|
}
|
152
packages/deltas/src/common/utils.ts
Normal file
152
packages/deltas/src/common/utils.ts
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
import { Random } from "roughjs/bin/math";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AppState,
|
||||||
|
ObservedAppState,
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawElement,
|
||||||
|
ElementUpdate,
|
||||||
|
} from "../excalidraw-types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform array into an object, use only when array order is irrelevant.
|
||||||
|
*/
|
||||||
|
export const arrayToObject = <T>(
|
||||||
|
array: readonly T[],
|
||||||
|
groupBy?: (value: T) => string | number,
|
||||||
|
) =>
|
||||||
|
array.reduce((acc, value) => {
|
||||||
|
acc[groupBy ? groupBy(value) : String(value)] = value;
|
||||||
|
return acc;
|
||||||
|
}, {} as { [key: string]: T });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms array of elements with `id` property into into a Map grouped by `id`.
|
||||||
|
*/
|
||||||
|
export const elementsToMap = <T extends { id: string }>(
|
||||||
|
items: readonly T[],
|
||||||
|
) => {
|
||||||
|
return items.reduce((acc: Map<string, T>, element) => {
|
||||||
|
acc.set(element.id, element);
|
||||||
|
return acc;
|
||||||
|
}, new Map());
|
||||||
|
};
|
||||||
|
|
||||||
|
// --
|
||||||
|
|
||||||
|
// hidden non-enumerable property for runtime checks
|
||||||
|
const hiddenObservedAppStateProp = "__observedAppState";
|
||||||
|
|
||||||
|
export const getObservedAppState = (appState: AppState): ObservedAppState => {
|
||||||
|
const observedAppState = {
|
||||||
|
name: appState.name,
|
||||||
|
editingGroupId: appState.editingGroupId,
|
||||||
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
selectedGroupIds: appState.selectedGroupIds,
|
||||||
|
editingLinearElementId: appState.editingLinearElement?.elementId || null,
|
||||||
|
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
|
||||||
|
croppingElementId: appState.croppingElementId,
|
||||||
|
};
|
||||||
|
|
||||||
|
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||||
|
value: true,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return observedAppState;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
export const assertNever = (value: never, message: string): never => {
|
||||||
|
throw new Error(`${message}: "${value}".`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
export const getNonDeletedGroupIds = (elements: ElementsMap) => {
|
||||||
|
const nonDeletedGroupIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const [, element] of elements) {
|
||||||
|
// defensive check
|
||||||
|
if (element.isDeleted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// defensive fallback
|
||||||
|
for (const groupId of element.groupIds ?? []) {
|
||||||
|
nonDeletedGroupIds.add(groupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nonDeletedGroupIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
export const isTestEnv = () => import.meta.env.MODE === "test";
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
export const isDevEnv = () => import.meta.env.MODE === "development";
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
export const isServerEnv = () => import.meta.env.MODE === "server";
|
||||||
|
|
||||||
|
export const shouldThrow = () => isDevEnv() || isTestEnv() || isServerEnv();
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
let random = new Random(Date.now());
|
||||||
|
let testIdBase = 0;
|
||||||
|
|
||||||
|
export const randomInteger = () => Math.floor(random.next() * 2 ** 31);
|
||||||
|
|
||||||
|
export const reseed = (seed: number) => {
|
||||||
|
random = new Random(seed);
|
||||||
|
testIdBase = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const randomId = () => (isTestEnv() ? `id${testIdBase++}` : nanoid());
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
export const getUpdatedTimestamp = () => (isTestEnv() ? 1 : Date.now());
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||||
|
element: TElement,
|
||||||
|
updates: ElementUpdate<TElement>,
|
||||||
|
/** pass `true` to always regenerate */
|
||||||
|
force = false,
|
||||||
|
): TElement => {
|
||||||
|
let didChange = false;
|
||||||
|
for (const key in updates) {
|
||||||
|
const value = (updates as any)[key];
|
||||||
|
if (typeof value !== "undefined") {
|
||||||
|
if (
|
||||||
|
(element as any)[key] === value &&
|
||||||
|
// if object, always update because its attrs could have changed
|
||||||
|
(typeof value !== "object" || value === null)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
didChange = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!didChange && !force) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...element,
|
||||||
|
...updates,
|
||||||
|
updated: getUpdatedTimestamp(),
|
||||||
|
version: element.version + 1,
|
||||||
|
versionNonce: randomInteger(),
|
||||||
|
};
|
||||||
|
};
|
404
packages/deltas/src/containers/appstate.ts
Normal file
404
packages/deltas/src/containers/appstate.ts
Normal file
|
@ -0,0 +1,404 @@
|
||||||
|
import { Delta } from "../common/delta";
|
||||||
|
import {
|
||||||
|
assertNever,
|
||||||
|
getNonDeletedGroupIds,
|
||||||
|
getObservedAppState,
|
||||||
|
isDevEnv,
|
||||||
|
isTestEnv,
|
||||||
|
shouldThrow,
|
||||||
|
} from "../common/utils";
|
||||||
|
|
||||||
|
import type { DeltaContainer } from "../common/interfaces";
|
||||||
|
import type {
|
||||||
|
AppState,
|
||||||
|
ObservedAppState,
|
||||||
|
DTO,
|
||||||
|
SceneElementsMap,
|
||||||
|
ValueOf,
|
||||||
|
ObservedElementsAppState,
|
||||||
|
ObservedStandaloneAppState,
|
||||||
|
SubtypeOf,
|
||||||
|
} from "../excalidraw-types";
|
||||||
|
|
||||||
|
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||||
|
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
||||||
|
|
||||||
|
public static calculate<T extends ObservedAppState>(
|
||||||
|
prevAppState: T,
|
||||||
|
nextAppState: T,
|
||||||
|
): AppStateDelta {
|
||||||
|
const delta = Delta.calculate(
|
||||||
|
prevAppState,
|
||||||
|
nextAppState,
|
||||||
|
undefined,
|
||||||
|
AppStateDelta.postProcess,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new AppStateDelta(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static restore(appStateDeltaDTO: DTO<AppStateDelta>): AppStateDelta {
|
||||||
|
const { delta } = appStateDeltaDTO;
|
||||||
|
return new AppStateDelta(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static empty() {
|
||||||
|
return new AppStateDelta(Delta.create({}, {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public inverse(): AppStateDelta {
|
||||||
|
const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted);
|
||||||
|
return new AppStateDelta(inversedDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyTo(
|
||||||
|
appState: AppState,
|
||||||
|
nextElements: SceneElementsMap,
|
||||||
|
): [AppState, boolean] {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
selectedElementIds: removedSelectedElementIds = {},
|
||||||
|
selectedGroupIds: removedSelectedGroupIds = {},
|
||||||
|
} = this.delta.deleted;
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectedElementIds: addedSelectedElementIds = {},
|
||||||
|
selectedGroupIds: addedSelectedGroupIds = {},
|
||||||
|
selectedLinearElementId,
|
||||||
|
editingLinearElementId,
|
||||||
|
...directlyApplicablePartial
|
||||||
|
} = this.delta.inserted;
|
||||||
|
|
||||||
|
const mergedSelectedElementIds = Delta.mergeObjects(
|
||||||
|
appState.selectedElementIds,
|
||||||
|
addedSelectedElementIds,
|
||||||
|
removedSelectedElementIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedSelectedGroupIds = Delta.mergeObjects(
|
||||||
|
appState.selectedGroupIds,
|
||||||
|
addedSelectedGroupIds,
|
||||||
|
removedSelectedGroupIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
// const selectedLinearElement =
|
||||||
|
// selectedLinearElementId && nextElements.has(selectedLinearElementId)
|
||||||
|
// ? new LinearElementEditor(
|
||||||
|
// nextElements.get(
|
||||||
|
// selectedLinearElementId,
|
||||||
|
// ) as NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
// )
|
||||||
|
// : null;
|
||||||
|
|
||||||
|
// const editingLinearElement =
|
||||||
|
// editingLinearElementId && nextElements.has(editingLinearElementId)
|
||||||
|
// ? new LinearElementEditor(
|
||||||
|
// nextElements.get(
|
||||||
|
// editingLinearElementId,
|
||||||
|
// ) as NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
// )
|
||||||
|
// : null;
|
||||||
|
|
||||||
|
const nextAppState = {
|
||||||
|
...appState,
|
||||||
|
...directlyApplicablePartial,
|
||||||
|
selectedElementIds: mergedSelectedElementIds,
|
||||||
|
selectedGroupIds: mergedSelectedGroupIds,
|
||||||
|
// selectedLinearElement:
|
||||||
|
// typeof selectedLinearElementId !== "undefined"
|
||||||
|
// ? selectedLinearElement // element was either inserted or deleted
|
||||||
|
// : appState.selectedLinearElement, // otherwise assign what we had before
|
||||||
|
// editingLinearElement:
|
||||||
|
// typeof editingLinearElementId !== "undefined"
|
||||||
|
// ? editingLinearElement // element was either inserted or deleted
|
||||||
|
// : appState.editingLinearElement, // otherwise assign what we had before
|
||||||
|
};
|
||||||
|
|
||||||
|
const constainsVisibleChanges = this.filterInvisibleChanges(
|
||||||
|
appState,
|
||||||
|
nextAppState,
|
||||||
|
nextElements,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [nextAppState, constainsVisibleChanges];
|
||||||
|
} catch (e) {
|
||||||
|
// shouldn't really happen, but just in case
|
||||||
|
console.error(`Couldn't apply appstate delta`, e);
|
||||||
|
|
||||||
|
if (shouldThrow()) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [appState, false];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isEmpty(): boolean {
|
||||||
|
return Delta.isEmpty(this.delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It is necessary to post process the partials in case of reference values,
|
||||||
|
* for which we need to calculate the real diff between `deleted` and `inserted`.
|
||||||
|
*/
|
||||||
|
private static postProcess<T extends ObservedAppState>(
|
||||||
|
deleted: Partial<T>,
|
||||||
|
inserted: Partial<T>,
|
||||||
|
): [Partial<T>, Partial<T>] {
|
||||||
|
try {
|
||||||
|
Delta.diffObjects(
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
"selectedElementIds",
|
||||||
|
// ts language server has a bit trouble resolving this, so we are giving it a little push
|
||||||
|
(_) => true as ValueOf<T["selectedElementIds"]>,
|
||||||
|
);
|
||||||
|
Delta.diffObjects(
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
"selectedGroupIds",
|
||||||
|
(prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
||||||
|
console.error(`Couldn't postprocess appstate change deltas.`);
|
||||||
|
|
||||||
|
if (isDevEnv() || isTestEnv()) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
return [deleted, inserted];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutates `nextAppState` be filtering out state related to deleted elements.
|
||||||
|
*
|
||||||
|
* @returns `true` if a visible change is found, `false` otherwise.
|
||||||
|
*/
|
||||||
|
private filterInvisibleChanges(
|
||||||
|
prevAppState: AppState,
|
||||||
|
nextAppState: AppState,
|
||||||
|
nextElements: SceneElementsMap,
|
||||||
|
): boolean {
|
||||||
|
// TODO: #7348 we could still get an empty undo/redo, as we assume that previous appstate does not contain references to deleted elements
|
||||||
|
// which is not always true - i.e. now we do cleanup appstate during history, but we do not do it during remote updates
|
||||||
|
const prevObservedAppState = getObservedAppState(prevAppState);
|
||||||
|
const nextObservedAppState = getObservedAppState(nextAppState);
|
||||||
|
|
||||||
|
const containsStandaloneDifference = Delta.isRightDifferent(
|
||||||
|
AppStateDelta.stripElementsProps(prevObservedAppState),
|
||||||
|
AppStateDelta.stripElementsProps(nextObservedAppState),
|
||||||
|
);
|
||||||
|
|
||||||
|
const containsElementsDifference = Delta.isRightDifferent(
|
||||||
|
AppStateDelta.stripStandaloneProps(prevObservedAppState),
|
||||||
|
AppStateDelta.stripStandaloneProps(nextObservedAppState),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!containsStandaloneDifference && !containsElementsDifference) {
|
||||||
|
// no change in appstate was detected
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleDifferenceFlag = {
|
||||||
|
value: containsStandaloneDifference,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (containsElementsDifference) {
|
||||||
|
// filter invisible changes on each iteration
|
||||||
|
const changedElementsProps = Delta.getRightDifferences(
|
||||||
|
AppStateDelta.stripStandaloneProps(prevObservedAppState),
|
||||||
|
AppStateDelta.stripStandaloneProps(nextObservedAppState),
|
||||||
|
) as Array<keyof ObservedElementsAppState>;
|
||||||
|
|
||||||
|
let nonDeletedGroupIds = new Set<string>();
|
||||||
|
|
||||||
|
if (
|
||||||
|
changedElementsProps.includes("editingGroupId") ||
|
||||||
|
changedElementsProps.includes("selectedGroupIds")
|
||||||
|
) {
|
||||||
|
// this one iterates through all the non deleted elements, so make sure it's not done twice
|
||||||
|
nonDeletedGroupIds = getNonDeletedGroupIds(nextElements);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check whether delta properties are related to the existing non-deleted elements
|
||||||
|
for (const key of changedElementsProps) {
|
||||||
|
switch (key) {
|
||||||
|
case "selectedElementIds":
|
||||||
|
nextAppState[key] = AppStateDelta.filterSelectedElements(
|
||||||
|
nextAppState[key],
|
||||||
|
nextElements,
|
||||||
|
visibleDifferenceFlag,
|
||||||
|
);
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "selectedGroupIds":
|
||||||
|
nextAppState[key] = AppStateDelta.filterSelectedGroups(
|
||||||
|
nextAppState[key],
|
||||||
|
nonDeletedGroupIds,
|
||||||
|
visibleDifferenceFlag,
|
||||||
|
);
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "croppingElementId": {
|
||||||
|
const croppingElementId = nextAppState[key];
|
||||||
|
const element =
|
||||||
|
croppingElementId && nextElements.get(croppingElementId);
|
||||||
|
|
||||||
|
if (element && !element.isDeleted) {
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
} else {
|
||||||
|
nextAppState[key] = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "editingGroupId":
|
||||||
|
const editingGroupId = nextAppState[key];
|
||||||
|
|
||||||
|
if (!editingGroupId) {
|
||||||
|
// previously there was an editingGroup (assuming visible), now there is none
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
} else if (nonDeletedGroupIds.has(editingGroupId)) {
|
||||||
|
// previously there wasn't an editingGroup, now there is one which is visible
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
} else {
|
||||||
|
// there was assigned an editingGroup now, but it's related to deleted element
|
||||||
|
nextAppState[key] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "selectedLinearElementId":
|
||||||
|
case "editingLinearElementId":
|
||||||
|
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
||||||
|
const linearElement = nextAppState[appStateKey];
|
||||||
|
|
||||||
|
if (!linearElement) {
|
||||||
|
// previously there was a linear element (assuming visible), now there is none
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
} else {
|
||||||
|
const element = nextElements.get(linearElement.elementId);
|
||||||
|
|
||||||
|
if (element && !element.isDeleted) {
|
||||||
|
// previously there wasn't a linear element, now there is one which is visible
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
} else {
|
||||||
|
// there was assigned a linear element now, but it's deleted
|
||||||
|
nextAppState[appStateKey] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
assertNever(key, `Unknown ObservedElementsAppState's key "${key}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibleDifferenceFlag.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static convertToAppStateKey(
|
||||||
|
key: keyof Pick<
|
||||||
|
ObservedElementsAppState,
|
||||||
|
"selectedLinearElementId" | "editingLinearElementId"
|
||||||
|
>,
|
||||||
|
): keyof Pick<AppState, "selectedLinearElement" | "editingLinearElement"> {
|
||||||
|
switch (key) {
|
||||||
|
case "selectedLinearElementId":
|
||||||
|
return "selectedLinearElement";
|
||||||
|
case "editingLinearElementId":
|
||||||
|
return "editingLinearElement";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static filterSelectedElements(
|
||||||
|
selectedElementIds: AppState["selectedElementIds"],
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
visibleDifferenceFlag: { value: boolean },
|
||||||
|
) {
|
||||||
|
const ids = Object.keys(selectedElementIds);
|
||||||
|
|
||||||
|
if (!ids.length) {
|
||||||
|
// previously there were ids (assuming related to visible elements), now there are none
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
return selectedElementIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSelectedElementIds = { ...selectedElementIds };
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
const element = elements.get(id);
|
||||||
|
|
||||||
|
if (element && !element.isDeleted) {
|
||||||
|
// there is a selected element id related to a visible element
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
} else {
|
||||||
|
delete nextSelectedElementIds[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextSelectedElementIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static filterSelectedGroups(
|
||||||
|
selectedGroupIds: AppState["selectedGroupIds"],
|
||||||
|
nonDeletedGroupIds: Set<string>,
|
||||||
|
visibleDifferenceFlag: { value: boolean },
|
||||||
|
) {
|
||||||
|
const ids = Object.keys(selectedGroupIds);
|
||||||
|
|
||||||
|
if (!ids.length) {
|
||||||
|
// previously there were ids (assuming related to visible groups), now there are none
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
return selectedGroupIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSelectedGroupIds = { ...selectedGroupIds };
|
||||||
|
|
||||||
|
for (const id of Object.keys(nextSelectedGroupIds)) {
|
||||||
|
if (nonDeletedGroupIds.has(id)) {
|
||||||
|
// there is a selected group id related to a visible group
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
} else {
|
||||||
|
delete nextSelectedGroupIds[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextSelectedGroupIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static stripElementsProps(
|
||||||
|
delta: Partial<ObservedAppState>,
|
||||||
|
): Partial<ObservedStandaloneAppState> {
|
||||||
|
// WARN: Do not remove the type-casts as they here to ensure proper type checks
|
||||||
|
const {
|
||||||
|
editingGroupId,
|
||||||
|
selectedGroupIds,
|
||||||
|
selectedElementIds,
|
||||||
|
editingLinearElementId,
|
||||||
|
selectedLinearElementId,
|
||||||
|
croppingElementId,
|
||||||
|
...standaloneProps
|
||||||
|
} = delta as ObservedAppState;
|
||||||
|
|
||||||
|
return standaloneProps as SubtypeOf<
|
||||||
|
typeof standaloneProps,
|
||||||
|
ObservedStandaloneAppState
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static stripStandaloneProps(
|
||||||
|
delta: Partial<ObservedAppState>,
|
||||||
|
): Partial<ObservedElementsAppState> {
|
||||||
|
// WARN: Do not remove the type-casts as they here to ensure proper type checks
|
||||||
|
const { name, viewBackgroundColor, ...elementsProps } =
|
||||||
|
delta as ObservedAppState;
|
||||||
|
|
||||||
|
return elementsProps as SubtypeOf<
|
||||||
|
typeof elementsProps,
|
||||||
|
ObservedElementsAppState
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
}
|
825
packages/deltas/src/containers/elements.ts
Normal file
825
packages/deltas/src/containers/elements.ts
Normal file
|
@ -0,0 +1,825 @@
|
||||||
|
import { Delta } from "../common/delta";
|
||||||
|
import { elementsToMap, newElementWith, shouldThrow } from "../common/utils";
|
||||||
|
|
||||||
|
import type { DeltaContainer } from "../common/interfaces";
|
||||||
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ElementUpdate,
|
||||||
|
Ordered,
|
||||||
|
SceneElementsMap,
|
||||||
|
DTO,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
ExcalidrawImageElement,
|
||||||
|
} from "../excalidraw-types";
|
||||||
|
|
||||||
|
// CFDO: consider adding here (nonnullable) version & versionNonce & updated (so that we have correct versions when recunstructing from remote)
|
||||||
|
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> =
|
||||||
|
ElementUpdate<Ordered<T>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elements delta is a low level primitive to encapsulate property changes between two sets of elements.
|
||||||
|
* It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions.
|
||||||
|
*/
|
||||||
|
export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||||
|
private constructor(
|
||||||
|
public readonly added: Record<string, Delta<ElementPartial>>,
|
||||||
|
public readonly removed: Record<string, Delta<ElementPartial>>,
|
||||||
|
public readonly updated: Record<string, Delta<ElementPartial>>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static create(
|
||||||
|
added: Record<string, Delta<ElementPartial>>,
|
||||||
|
removed: Record<string, Delta<ElementPartial>>,
|
||||||
|
updated: Record<string, Delta<ElementPartial>>,
|
||||||
|
options: {
|
||||||
|
shouldRedistribute: boolean;
|
||||||
|
} = {
|
||||||
|
shouldRedistribute: false,
|
||||||
|
// CFDO: don't forget to re-enable
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { shouldRedistribute } = options;
|
||||||
|
let delta: ElementsDelta;
|
||||||
|
|
||||||
|
if (shouldRedistribute) {
|
||||||
|
const nextAdded: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
const nextRemoved: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
const nextUpdated: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
|
const deltas = [
|
||||||
|
...Object.entries(added),
|
||||||
|
...Object.entries(removed),
|
||||||
|
...Object.entries(updated),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [id, delta] of deltas) {
|
||||||
|
if (this.satisfiesAddition(delta)) {
|
||||||
|
nextAdded[id] = delta;
|
||||||
|
} else if (this.satisfiesRemoval(delta)) {
|
||||||
|
nextRemoved[id] = delta;
|
||||||
|
} else {
|
||||||
|
nextUpdated[id] = delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delta = new ElementsDelta(nextAdded, nextRemoved, nextUpdated);
|
||||||
|
} else {
|
||||||
|
delta = new ElementsDelta(added, removed, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldThrow()) {
|
||||||
|
ElementsDelta.validate(delta, "added", this.satisfiesAddition);
|
||||||
|
ElementsDelta.validate(delta, "removed", this.satisfiesRemoval);
|
||||||
|
ElementsDelta.validate(delta, "updated", this.satisfiesUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta {
|
||||||
|
const { added, removed, updated } = elementsDeltaDTO;
|
||||||
|
return ElementsDelta.create(added, removed, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static satisfiesAddition = ({
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
}: Delta<ElementPartial>) =>
|
||||||
|
// dissallowing added as "deleted", which could cause issues when resolving conflicts
|
||||||
|
deleted.isDeleted === true && !inserted.isDeleted;
|
||||||
|
|
||||||
|
private static satisfiesRemoval = ({
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
}: Delta<ElementPartial>) =>
|
||||||
|
!deleted.isDeleted && inserted.isDeleted === true;
|
||||||
|
|
||||||
|
private static satisfiesUpdate = ({
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
|
||||||
|
|
||||||
|
private static validate(
|
||||||
|
elementsDelta: ElementsDelta,
|
||||||
|
type: "added" | "removed" | "updated",
|
||||||
|
satifies: (delta: Delta<ElementPartial>) => boolean,
|
||||||
|
) {
|
||||||
|
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
||||||
|
if (!satifies(delta)) {
|
||||||
|
console.error(
|
||||||
|
`Broken invariant for "${type}" delta, element "${id}", delta:`,
|
||||||
|
delta,
|
||||||
|
);
|
||||||
|
throw new Error(`ElementsDelta invariant broken for element "${id}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the `Delta`s between the previous and next set of elements.
|
||||||
|
*
|
||||||
|
* @param prevElements - Map representing the previous state of elements.
|
||||||
|
* @param nextElements - Map representing the next state of elements.
|
||||||
|
*
|
||||||
|
* @returns `ElementsDelta` instance representing the `Delta` changes between the two sets of elements.
|
||||||
|
*/
|
||||||
|
public static calculate<T extends OrderedExcalidrawElement>(
|
||||||
|
prevElements: Map<string, T>,
|
||||||
|
nextElements: Map<string, T>,
|
||||||
|
): ElementsDelta {
|
||||||
|
if (prevElements === nextElements) {
|
||||||
|
return ElementsDelta.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
const added: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
const removed: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
const updated: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
|
// this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements
|
||||||
|
for (const prevElement of prevElements.values()) {
|
||||||
|
const nextElement = nextElements.get(prevElement.id);
|
||||||
|
|
||||||
|
if (!nextElement) {
|
||||||
|
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
|
||||||
|
const inserted = { isDeleted: true } as ElementPartial;
|
||||||
|
|
||||||
|
const delta = Delta.create(
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
ElementsDelta.stripIrrelevantProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
removed[prevElement.id] = delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nextElement of nextElements.values()) {
|
||||||
|
const prevElement = prevElements.get(nextElement.id);
|
||||||
|
|
||||||
|
if (!prevElement) {
|
||||||
|
const deleted = { isDeleted: true } as ElementPartial;
|
||||||
|
const inserted = {
|
||||||
|
...nextElement,
|
||||||
|
isDeleted: false,
|
||||||
|
} as ElementPartial;
|
||||||
|
|
||||||
|
const delta = Delta.create(
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
ElementsDelta.stripIrrelevantProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
added[nextElement.id] = delta;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevElement.versionNonce !== nextElement.versionNonce) {
|
||||||
|
const delta = Delta.calculate<ElementPartial>(
|
||||||
|
prevElement,
|
||||||
|
nextElement,
|
||||||
|
ElementsDelta.stripIrrelevantProps,
|
||||||
|
ElementsDelta.postProcess,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
// making sure we don't get here some non-boolean values (i.e. undefined, null, etc.)
|
||||||
|
typeof prevElement.isDeleted === "boolean" &&
|
||||||
|
typeof nextElement.isDeleted === "boolean" &&
|
||||||
|
prevElement.isDeleted !== nextElement.isDeleted
|
||||||
|
) {
|
||||||
|
// notice that other props could have been updated as well
|
||||||
|
if (prevElement.isDeleted && !nextElement.isDeleted) {
|
||||||
|
added[nextElement.id] = delta;
|
||||||
|
} else {
|
||||||
|
removed[nextElement.id] = delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// making sure there are at least some changes
|
||||||
|
if (!Delta.isEmpty(delta)) {
|
||||||
|
updated[nextElement.id] = delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ElementsDelta.create(added, removed, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static empty() {
|
||||||
|
return ElementsDelta.create({}, {}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public inverse(): ElementsDelta {
|
||||||
|
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||||
|
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
|
for (const [id, delta] of Object.entries(deltas)) {
|
||||||
|
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return inversedDeltas;
|
||||||
|
};
|
||||||
|
|
||||||
|
const added = inverseInternal(this.added);
|
||||||
|
const removed = inverseInternal(this.removed);
|
||||||
|
const updated = inverseInternal(this.updated);
|
||||||
|
|
||||||
|
// notice we inverse removed with added not to break the invariants
|
||||||
|
// notice we force generate a new id
|
||||||
|
return ElementsDelta.create(removed, added, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public isEmpty(): boolean {
|
||||||
|
return (
|
||||||
|
Object.keys(this.added).length === 0 &&
|
||||||
|
Object.keys(this.removed).length === 0 &&
|
||||||
|
Object.keys(this.updated).length === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update delta/s based on the existing elements.
|
||||||
|
*
|
||||||
|
* @param elements current elements
|
||||||
|
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
|
||||||
|
* @returns new instance with modified delta/s
|
||||||
|
*/
|
||||||
|
public applyLatestChanges(
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
modifierOptions: "deleted" | "inserted",
|
||||||
|
): ElementsDelta {
|
||||||
|
const modifier =
|
||||||
|
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
||||||
|
const latestPartial: { [key: string]: unknown } = {};
|
||||||
|
|
||||||
|
for (const key of Object.keys(partial) as Array<keyof typeof partial>) {
|
||||||
|
// do not update following props:
|
||||||
|
// - `boundElements`, as it is a reference value which is postprocessed to contain only deleted/inserted keys
|
||||||
|
switch (key) {
|
||||||
|
case "boundElements":
|
||||||
|
latestPartial[key] = partial[key];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
latestPartial[key] = element[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestPartial;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyLatestChangesInternal = (
|
||||||
|
deltas: Record<string, Delta<ElementPartial>>,
|
||||||
|
) => {
|
||||||
|
const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
|
for (const [id, delta] of Object.entries(deltas)) {
|
||||||
|
const existingElement = elements.get(id);
|
||||||
|
|
||||||
|
if (existingElement) {
|
||||||
|
const modifiedDelta = Delta.create(
|
||||||
|
delta.deleted,
|
||||||
|
delta.inserted,
|
||||||
|
modifier(existingElement),
|
||||||
|
modifierOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
modifiedDeltas[id] = modifiedDelta;
|
||||||
|
} else {
|
||||||
|
modifiedDeltas[id] = delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return modifiedDeltas;
|
||||||
|
};
|
||||||
|
|
||||||
|
const added = applyLatestChangesInternal(this.added);
|
||||||
|
const removed = applyLatestChangesInternal(this.removed);
|
||||||
|
const updated = applyLatestChangesInternal(this.updated);
|
||||||
|
|
||||||
|
return ElementsDelta.create(added, removed, updated, {
|
||||||
|
shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CFDO: does it make sense having a separate snapshot?
|
||||||
|
public applyTo(
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
elementsSnapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
|
): [SceneElementsMap, boolean] {
|
||||||
|
const nextElements = new Map(elements) as SceneElementsMap;
|
||||||
|
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||||
|
|
||||||
|
const flags = {
|
||||||
|
containsVisibleDifference: false,
|
||||||
|
containsZindexDifference: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||||
|
try {
|
||||||
|
const applyDeltas = ElementsDelta.createApplier(
|
||||||
|
nextElements,
|
||||||
|
elementsSnapshot,
|
||||||
|
flags,
|
||||||
|
);
|
||||||
|
|
||||||
|
const addedElements = applyDeltas("added", this.added);
|
||||||
|
const removedElements = applyDeltas("removed", this.removed);
|
||||||
|
const updatedElements = applyDeltas("updated", this.updated);
|
||||||
|
|
||||||
|
// CFDO I: don't forget to fix this part
|
||||||
|
// const affectedElements = this.resolveConflicts(elements, nextElements);
|
||||||
|
|
||||||
|
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
|
||||||
|
changedElements = new Map([
|
||||||
|
...addedElements,
|
||||||
|
...removedElements,
|
||||||
|
...updatedElements,
|
||||||
|
// ...affectedElements,
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Couldn't apply elements delta`, e);
|
||||||
|
|
||||||
|
if (shouldThrow()) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// should not really happen, but just in case we cannot apply deltas, let's return the previous elements with visible change set to `true`
|
||||||
|
// even though there is obviously no visible change, returning `false` could be dangerous, as i.e.:
|
||||||
|
// in the worst case, it could lead into iterating through the whole stack with no possibility to redo
|
||||||
|
// instead, the worst case when returning `true` is an empty undo / redo
|
||||||
|
return [elements, true];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// CFDO I: don't forget to fix this part
|
||||||
|
// // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
|
||||||
|
// ElementsDelta.redrawTextBoundingBoxes(nextElements, changedElements);
|
||||||
|
// // the following reorder performs also mutations, but only on new instances of changed elements
|
||||||
|
// // (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||||
|
// nextElements = ElementsDelta.reorderElements(
|
||||||
|
// nextElements,
|
||||||
|
// changedElements,
|
||||||
|
// flags,
|
||||||
|
// );
|
||||||
|
// // Need ordered nextElements to avoid z-index binding issues
|
||||||
|
// ElementsDelta.redrawBoundArrows(nextElements, changedElements);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Couldn't mutate elements after applying elements change`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldThrow()) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
return [nextElements, flags.containsVisibleDifference];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static createApplier =
|
||||||
|
(
|
||||||
|
nextElements: SceneElementsMap,
|
||||||
|
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
|
flags: {
|
||||||
|
containsVisibleDifference: boolean;
|
||||||
|
containsZindexDifference: boolean;
|
||||||
|
},
|
||||||
|
) =>
|
||||||
|
(
|
||||||
|
type: "added" | "removed" | "updated",
|
||||||
|
deltas: Record<string, Delta<ElementPartial>>,
|
||||||
|
) => {
|
||||||
|
const getElement = ElementsDelta.createGetter(
|
||||||
|
type,
|
||||||
|
nextElements,
|
||||||
|
snapshot,
|
||||||
|
flags,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.entries(deltas).reduce((acc, [id, delta]) => {
|
||||||
|
const element = getElement(id, delta.inserted);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
const newElement = ElementsDelta.applyDelta(element, delta, flags);
|
||||||
|
nextElements.set(newElement.id, newElement);
|
||||||
|
acc.set(newElement.id, newElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, new Map<string, OrderedExcalidrawElement>());
|
||||||
|
};
|
||||||
|
|
||||||
|
private static createGetter =
|
||||||
|
(
|
||||||
|
type: "added" | "removed" | "updated",
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||||
|
flags: {
|
||||||
|
containsVisibleDifference: boolean;
|
||||||
|
containsZindexDifference: boolean;
|
||||||
|
},
|
||||||
|
) =>
|
||||||
|
(id: string, partial: ElementPartial) => {
|
||||||
|
let element = elements.get(id);
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
// always fallback to the local snapshot, in cases when we cannot find the element in the elements array
|
||||||
|
element = snapshot.get(id);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
// as the element was brought from the snapshot, it automatically results in a possible zindex difference
|
||||||
|
flags.containsZindexDifference = true;
|
||||||
|
|
||||||
|
// as the element was force deleted, we need to check if adding it back results in a visible change
|
||||||
|
if (
|
||||||
|
partial.isDeleted === false ||
|
||||||
|
(partial.isDeleted !== true && element.isDeleted === false)
|
||||||
|
) {
|
||||||
|
flags.containsVisibleDifference = true;
|
||||||
|
}
|
||||||
|
} else if (type === "added") {
|
||||||
|
// for additions the element does not have to exist (i.e. remote update)
|
||||||
|
// CFDO II: the version itself might be different!
|
||||||
|
element = newElementWith(
|
||||||
|
{ id, version: 1 } as OrderedExcalidrawElement,
|
||||||
|
{
|
||||||
|
...partial,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
|
private static applyDelta(
|
||||||
|
element: OrderedExcalidrawElement,
|
||||||
|
delta: Delta<ElementPartial>,
|
||||||
|
flags: {
|
||||||
|
containsVisibleDifference: boolean;
|
||||||
|
containsZindexDifference: boolean;
|
||||||
|
} = {
|
||||||
|
// by default we don't care about about the flags
|
||||||
|
containsVisibleDifference: true,
|
||||||
|
containsZindexDifference: true,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { boundElements, ...directlyApplicablePartial } = delta.inserted;
|
||||||
|
|
||||||
|
if (
|
||||||
|
delta.deleted.boundElements?.length ||
|
||||||
|
delta.inserted.boundElements?.length
|
||||||
|
) {
|
||||||
|
const mergedBoundElements = Delta.mergeArrays(
|
||||||
|
element.boundElements,
|
||||||
|
delta.inserted.boundElements,
|
||||||
|
delta.deleted.boundElements,
|
||||||
|
(x) => x.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.assign(directlyApplicablePartial, {
|
||||||
|
boundElements: mergedBoundElements,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CFDO: this looks wrong
|
||||||
|
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) {
|
||||||
|
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change
|
||||||
|
const { index, ...rest } = directlyApplicablePartial;
|
||||||
|
const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
|
||||||
|
element,
|
||||||
|
rest,
|
||||||
|
);
|
||||||
|
|
||||||
|
flags.containsVisibleDifference = containsVisibleDifference;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!flags.containsZindexDifference) {
|
||||||
|
flags.containsZindexDifference =
|
||||||
|
delta.deleted.index !== delta.inserted.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newElementWith(element, directlyApplicablePartial);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for visible changes regardless of whether they were removed, added or updated.
|
||||||
|
*/
|
||||||
|
private static checkForVisibleDifference(
|
||||||
|
element: OrderedExcalidrawElement,
|
||||||
|
partial: ElementPartial,
|
||||||
|
) {
|
||||||
|
if (element.isDeleted && partial.isDeleted !== false) {
|
||||||
|
// when it's deleted and partial is not false, it cannot end up with a visible change
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.isDeleted && partial.isDeleted === false) {
|
||||||
|
// when we add an element, it results in a visible change
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.isDeleted === false && partial.isDeleted) {
|
||||||
|
// when we remove an element, it results in a visible change
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for any difference on a visible element
|
||||||
|
return Delta.isRightDifferent(element, partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Resolves conflicts for all previously added, removed and updated elements.
|
||||||
|
// * Updates the previous deltas with all the changes after conflict resolution.
|
||||||
|
// *
|
||||||
|
// * // CFDO: revisit since arrow seem often redrawn incorrectly
|
||||||
|
// *
|
||||||
|
// * @returns all elements affected by the conflict resolution
|
||||||
|
// */
|
||||||
|
// private resolveConflicts(
|
||||||
|
// prevElements: SceneElementsMap,
|
||||||
|
// nextElements: SceneElementsMap,
|
||||||
|
// ) {
|
||||||
|
// const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
||||||
|
// const updater = (
|
||||||
|
// element: ExcalidrawElement,
|
||||||
|
// updates: ElementUpdate<ExcalidrawElement>,
|
||||||
|
// ) => {
|
||||||
|
// const nextElement = nextElements.get(element.id); // only ever modify next element!
|
||||||
|
// if (!nextElement) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// let affectedElement: OrderedExcalidrawElement;
|
||||||
|
|
||||||
|
// if (prevElements.get(element.id) === nextElement) {
|
||||||
|
// // create the new element instance in case we didn't modify the element yet
|
||||||
|
// // so that we won't end up in an incosistent state in case we would fail in the middle of mutations
|
||||||
|
// affectedElement = newElementWith(
|
||||||
|
// nextElement,
|
||||||
|
// updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||||
|
// );
|
||||||
|
// } else {
|
||||||
|
// affectedElement = mutateElement(
|
||||||
|
// nextElement,
|
||||||
|
// updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// nextAffectedElements.set(affectedElement.id, affectedElement);
|
||||||
|
// nextElements.set(affectedElement.id, affectedElement);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
|
||||||
|
// for (const id of Object.keys(this.removed)) {
|
||||||
|
// ElementsDelta.unbindAffected(prevElements, nextElements, id, updater);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound
|
||||||
|
// for (const id of Object.keys(this.added)) {
|
||||||
|
// ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // updated delta is affecting the binding only in case it contains changed binding or bindable property
|
||||||
|
// for (const [id] of Array.from(Object.entries(this.updated)).filter(
|
||||||
|
// ([_, delta]) =>
|
||||||
|
// Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
|
||||||
|
// bindingProperties.has(prop as BindingProp | BindableProp),
|
||||||
|
// ),
|
||||||
|
// )) {
|
||||||
|
// const updatedElement = nextElements.get(id);
|
||||||
|
// if (!updatedElement || updatedElement.isDeleted) {
|
||||||
|
// // skip fixing bindings for updates on deleted elements
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // filter only previous elements, which were now affected
|
||||||
|
// const prevAffectedElements = new Map(
|
||||||
|
// Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
||||||
|
// );
|
||||||
|
|
||||||
|
// // calculate complete deltas for affected elements, and assign them back to all the deltas
|
||||||
|
// // technically we could do better here if perf. would become an issue
|
||||||
|
// const { added, removed, updated } = ElementsDelta.calculate(
|
||||||
|
// prevAffectedElements,
|
||||||
|
// nextAffectedElements,
|
||||||
|
// );
|
||||||
|
|
||||||
|
// for (const [id, delta] of Object.entries(added)) {
|
||||||
|
// this.added[id] = delta;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// for (const [id, delta] of Object.entries(removed)) {
|
||||||
|
// this.removed[id] = delta;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// for (const [id, delta] of Object.entries(updated)) {
|
||||||
|
// this.updated[id] = delta;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return nextAffectedElements;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Non deleted affected elements of removed elements (before and after applying delta),
|
||||||
|
// * should be unbound ~ bindings should not point from non deleted into the deleted element/s.
|
||||||
|
// */
|
||||||
|
// private static unbindAffected(
|
||||||
|
// prevElements: SceneElementsMap,
|
||||||
|
// nextElements: SceneElementsMap,
|
||||||
|
// id: string,
|
||||||
|
// updater: (
|
||||||
|
// element: ExcalidrawElement,
|
||||||
|
// updates: ElementUpdate<ExcalidrawElement>,
|
||||||
|
// ) => void,
|
||||||
|
// ) {
|
||||||
|
// // the instance could have been updated, so make sure we are passing the latest element to each function below
|
||||||
|
// const prevElement = () => prevElements.get(id); // element before removal
|
||||||
|
// const nextElement = () => nextElements.get(id); // element after removal
|
||||||
|
|
||||||
|
// BoundElement.unbindAffected(nextElements, prevElement(), updater);
|
||||||
|
// BoundElement.unbindAffected(nextElements, nextElement(), updater);
|
||||||
|
|
||||||
|
// BindableElement.unbindAffected(nextElements, prevElement(), updater);
|
||||||
|
// BindableElement.unbindAffected(nextElements, nextElement(), updater);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Non deleted affected elements of added or updated element/s (before and after applying delta),
|
||||||
|
// * should be rebound (if possible) with the current element ~ bindings should be bidirectional.
|
||||||
|
// */
|
||||||
|
// private static rebindAffected(
|
||||||
|
// prevElements: SceneElementsMap,
|
||||||
|
// nextElements: SceneElementsMap,
|
||||||
|
// id: string,
|
||||||
|
// updater: (
|
||||||
|
// element: ExcalidrawElement,
|
||||||
|
// updates: ElementUpdate<ExcalidrawElement>,
|
||||||
|
// ) => void,
|
||||||
|
// ) {
|
||||||
|
// // the instance could have been updated, so make sure we are passing the latest element to each function below
|
||||||
|
// const prevElement = () => prevElements.get(id); // element before addition / update
|
||||||
|
// const nextElement = () => nextElements.get(id); // element after addition / update
|
||||||
|
|
||||||
|
// BoundElement.unbindAffected(nextElements, prevElement(), updater);
|
||||||
|
// BoundElement.rebindAffected(nextElements, nextElement(), updater);
|
||||||
|
|
||||||
|
// BindableElement.unbindAffected(
|
||||||
|
// nextElements,
|
||||||
|
// prevElement(),
|
||||||
|
// (element, updates) => {
|
||||||
|
// // we cannot rebind arrows with bindable element so we don't unbind them at all during rebind (we still need to unbind them on removal)
|
||||||
|
// // TODO: #7348 add startBinding / endBinding to the `BoundElement` context so that we could rebind arrows and remove this condition
|
||||||
|
// if (isTextElement(element)) {
|
||||||
|
// updater(element, updates);
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
// BindableElement.rebindAffected(nextElements, nextElement(), updater);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// private static redrawTextBoundingBoxes(
|
||||||
|
// elements: SceneElementsMap,
|
||||||
|
// changed: Map<string, OrderedExcalidrawElement>,
|
||||||
|
// ) {
|
||||||
|
// const boxesToRedraw = new Map<
|
||||||
|
// string,
|
||||||
|
// { container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement }
|
||||||
|
// >();
|
||||||
|
|
||||||
|
// for (const element of changed.values()) {
|
||||||
|
// if (isBoundToContainer(element)) {
|
||||||
|
// const { containerId } = element as ExcalidrawTextElement;
|
||||||
|
// const container = containerId ? elements.get(containerId) : undefined;
|
||||||
|
|
||||||
|
// if (container) {
|
||||||
|
// boxesToRedraw.set(container.id, {
|
||||||
|
// container,
|
||||||
|
// boundText: element as ExcalidrawTextElement,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (hasBoundTextElement(element)) {
|
||||||
|
// const boundTextElementId = getBoundTextElementId(element);
|
||||||
|
// const boundText = boundTextElementId
|
||||||
|
// ? elements.get(boundTextElementId)
|
||||||
|
// : undefined;
|
||||||
|
|
||||||
|
// if (boundText) {
|
||||||
|
// boxesToRedraw.set(element.id, {
|
||||||
|
// container: element,
|
||||||
|
// boundText: boundText as ExcalidrawTextElement,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// for (const { container, boundText } of boxesToRedraw.values()) {
|
||||||
|
// if (container.isDeleted || boundText.isDeleted) {
|
||||||
|
// // skip redraw if one of them is deleted, as it would not result in a meaningful redraw
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// redrawTextBoundingBox(boundText, container, elements, false);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// private static redrawBoundArrows(
|
||||||
|
// elements: SceneElementsMap,
|
||||||
|
// changed: Map<string, OrderedExcalidrawElement>,
|
||||||
|
// ) {
|
||||||
|
// for (const element of changed.values()) {
|
||||||
|
// if (!element.isDeleted && isBindableElement(element)) {
|
||||||
|
// updateBoundElements(element, elements, {
|
||||||
|
// changedElements: changed,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// private static reorderElements(
|
||||||
|
// elements: SceneElementsMap,
|
||||||
|
// changed: Map<string, OrderedExcalidrawElement>,
|
||||||
|
// flags: {
|
||||||
|
// containsVisibleDifference: boolean;
|
||||||
|
// containsZindexDifference: boolean;
|
||||||
|
// },
|
||||||
|
// ) {
|
||||||
|
// if (!flags.containsZindexDifference) {
|
||||||
|
// return elements;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const unordered = Array.from(elements.values());
|
||||||
|
// const ordered = orderByFractionalIndex([...unordered]);
|
||||||
|
// const moved = Delta.getRightDifferences(unordered, ordered, true).reduce(
|
||||||
|
// (acc, arrayIndex) => {
|
||||||
|
// const candidate = unordered[Number(arrayIndex)];
|
||||||
|
// if (candidate && changed.has(candidate.id)) {
|
||||||
|
// acc.set(candidate.id, candidate);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return acc;
|
||||||
|
// },
|
||||||
|
// new Map(),
|
||||||
|
// );
|
||||||
|
|
||||||
|
// if (!flags.containsVisibleDifference && moved.size) {
|
||||||
|
// // we found a difference in order!
|
||||||
|
// flags.containsVisibleDifference = true;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // synchronize all elements that were actually moved
|
||||||
|
// // could fallback to synchronizing all invalid indices
|
||||||
|
// return elementsToMap(syncMovedIndices(ordered, moved)) as typeof elements;
|
||||||
|
// }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It is necessary to post process the partials in case of reference values,
|
||||||
|
* for which we need to calculate the real diff between `deleted` and `inserted`.
|
||||||
|
*/
|
||||||
|
private static postProcess(
|
||||||
|
deleted: ElementPartial,
|
||||||
|
inserted: ElementPartial,
|
||||||
|
): [ElementPartial, ElementPartial] {
|
||||||
|
try {
|
||||||
|
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
|
||||||
|
} catch (e) {
|
||||||
|
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
|
||||||
|
console.error(`Couldn't postprocess elements delta.`);
|
||||||
|
|
||||||
|
if (shouldThrow()) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
return [deleted, inserted];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static stripIrrelevantProps(
|
||||||
|
partial: Partial<OrderedExcalidrawElement>,
|
||||||
|
): ElementPartial {
|
||||||
|
const { id, updated, version, versionNonce, ...strippedPartial } = partial;
|
||||||
|
|
||||||
|
return strippedPartial;
|
||||||
|
}
|
||||||
|
}
|
26
packages/deltas/src/excalidraw-types.d.ts
vendored
Normal file
26
packages/deltas/src/excalidraw-types.d.ts
vendored
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
export type {
|
||||||
|
AppState,
|
||||||
|
ObservedElementsAppState,
|
||||||
|
ObservedStandaloneAppState,
|
||||||
|
ObservedAppState,
|
||||||
|
} from "@excalidraw/excalidraw/dist/excalidraw/types";
|
||||||
|
export type {
|
||||||
|
DTO,
|
||||||
|
SubtypeOf,
|
||||||
|
ValueOf,
|
||||||
|
} from "@excalidraw/excalidraw/dist/excalidraw/utility-types";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawImageElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
Ordered,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
SceneElementsMap,
|
||||||
|
ElementsMap,
|
||||||
|
} from "@excalidraw/excalidraw/dist/excalidraw/element/types";
|
||||||
|
export type { ElementUpdate } from "@excalidraw/excalidraw/dist/excalidraw/element/mutateElement";
|
||||||
|
export type {
|
||||||
|
BindableProp,
|
||||||
|
BindingProp,
|
||||||
|
} from "@excalidraw/excalidraw/dist/excalidraw/element/binding";
|
5
packages/deltas/src/index.ts
Normal file
5
packages/deltas/src/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export type { DeltaContainer } from "./common/interfaces";
|
||||||
|
|
||||||
|
export { Delta } from "./common/delta";
|
||||||
|
export { ElementsDelta } from "./containers/elements";
|
||||||
|
export { AppStateDelta } from "./containers/appstate";
|
19
packages/deltas/tsconfig.json
Normal file
19
packages/deltas/tsconfig.json
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"strict": true,
|
||||||
|
"outDir": "dist/types",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"**/*.test.*",
|
||||||
|
"**/tests/*",
|
||||||
|
"types",
|
||||||
|
"dist",
|
||||||
|
],
|
||||||
|
}
|
|
@ -43,7 +43,7 @@ import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types";
|
||||||
import type {
|
import type {
|
||||||
DurableStoreIncrement,
|
DurableStoreIncrement,
|
||||||
EphemeralStoreIncrement,
|
EphemeralStoreIncrement,
|
||||||
StoreActionType as StoreActionType,
|
StoreActionType,
|
||||||
} from "./store";
|
} from "./store";
|
||||||
|
|
||||||
export type SocketId = string & { _brand: "SocketId" };
|
export type SocketId = string & { _brand: "SocketId" };
|
||||||
|
|
37
packages/fractional-index/package.json
Normal file
37
packages/fractional-index/package.json
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"name": "@excalidraw/fractional-index",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"main": "./dist/prod/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"module": "./dist/prod/index.js",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"development": "./dist/dev/index.js",
|
||||||
|
"default": "./dist/prod/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"types": "./dist/types/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist/*"
|
||||||
|
],
|
||||||
|
"description": "Excalidraw logic related to fractional indices",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"excalidraw",
|
||||||
|
"fractional-index"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"fractional-indexing": "3.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {},
|
||||||
|
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||||
|
"repository": "https://github.com/excalidraw/excalidraw",
|
||||||
|
"scripts": {
|
||||||
|
"gen:types": "rm -rf types && tsc",
|
||||||
|
"build:esm": "rm -rf dist && node ../../scripts/buildShared.js && yarn gen:types",
|
||||||
|
"pack": "yarn build:umd && yarn pack"
|
||||||
|
}
|
||||||
|
}
|
412
packages/fractional-index/src/fractionalIndex.ts
Normal file
412
packages/fractional-index/src/fractionalIndex.ts
Normal file
|
@ -0,0 +1,412 @@
|
||||||
|
import { generateNKeysBetween } from "fractional-indexing";
|
||||||
|
|
||||||
|
// how can I re-use these things?
|
||||||
|
// - they should be part of a shared package (could be utils, but with a different export)
|
||||||
|
import { mutateElement } from "../../excalidraw/element/mutateElement";
|
||||||
|
// import { hasBoundTextElement } from "./element/typeChecks";
|
||||||
|
// import { getBoundTextElement } from "./element/textElement";
|
||||||
|
// import { arrayToMap } from "./utils";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
FractionalIndex,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
} from "../../excalidraw/element/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envisioned relation between array order and fractional indices:
|
||||||
|
*
|
||||||
|
* 1) Array (or array-like ordered data structure) should be used as a cache of elements order, hiding the internal fractional indices implementation.
|
||||||
|
* - it's undesirable to perform reorder for each related operation, therefore it's necessary to cache the order defined by fractional indices into an ordered data structure
|
||||||
|
* - it's easy enough to define the order of the elements from the outside (boundaries), without worrying about the underlying structure of fractional indices (especially for the host apps)
|
||||||
|
* - it's necessary to always keep the array support for backwards compatibility (restore) - old scenes, old libraries, supporting multiple excalidraw versions etc.
|
||||||
|
* - it's necessary to always keep the fractional indices in sync with the array order
|
||||||
|
* - elements with invalid indices should be detected and synced, without altering the already valid indices
|
||||||
|
*
|
||||||
|
* 2) Fractional indices should be used to reorder the elements, whenever the cached order is expected to be invalidated.
|
||||||
|
* - as the fractional indices are encoded as part of the elements, it opens up possibilities for incremental-like APIs
|
||||||
|
* - re-order based on fractional indices should be part of (multiplayer) operations such as reconciliation & undo/redo
|
||||||
|
* - technically all the z-index actions could perform also re-order based on fractional indices,but in current state it would not bring much benefits,
|
||||||
|
* as it's faster & more efficient to perform re-order based on array manipulation and later synchronisation of moved indices with the array order
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure that all elements have valid fractional indices.
|
||||||
|
*
|
||||||
|
* @throws if invalid index is detected.
|
||||||
|
*/
|
||||||
|
export function validateFractionalIndices(
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
{
|
||||||
|
shouldThrow = false,
|
||||||
|
includeBoundTextValidation = false,
|
||||||
|
ignoreLogs,
|
||||||
|
reconciliationContext,
|
||||||
|
}: {
|
||||||
|
shouldThrow: boolean;
|
||||||
|
includeBoundTextValidation: boolean;
|
||||||
|
ignoreLogs?: true;
|
||||||
|
reconciliationContext?: {
|
||||||
|
localElements: ReadonlyArray<ExcalidrawElement>;
|
||||||
|
remoteElements: ReadonlyArray<ExcalidrawElement>;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const errorMessages = [];
|
||||||
|
const stringifyElement = (element: ExcalidrawElement | void) =>
|
||||||
|
`${element?.index}:${element?.id}:${element?.type}:${element?.isDeleted}:${element?.version}:${element?.versionNonce}`;
|
||||||
|
|
||||||
|
const indices = elements.map((x) => x.index);
|
||||||
|
for (const [i, index] of indices.entries()) {
|
||||||
|
const predecessorIndex = indices[i - 1];
|
||||||
|
const successorIndex = indices[i + 1];
|
||||||
|
|
||||||
|
if (!isValidFractionalIndex(index, predecessorIndex, successorIndex)) {
|
||||||
|
errorMessages.push(
|
||||||
|
`Fractional indices invariant has been compromised: "${stringifyElement(
|
||||||
|
elements[i - 1],
|
||||||
|
)}", "${stringifyElement(elements[i])}", "${stringifyElement(
|
||||||
|
elements[i + 1],
|
||||||
|
)}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// disabled by default, as we don't fix it
|
||||||
|
// if (includeBoundTextValidation && hasBoundTextElement(elements[i])) {
|
||||||
|
// const container = elements[i];
|
||||||
|
// const text = getBoundTextElement(container, arrayToMap(elements));
|
||||||
|
|
||||||
|
// if (text && text.index! <= container.index!) {
|
||||||
|
// errorMessages.push(
|
||||||
|
// `Fractional indices invariant for bound elements has been compromised: "${stringifyElement(
|
||||||
|
// text,
|
||||||
|
// )}", "${stringifyElement(container)}"`,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (errorMessages.length) {
|
||||||
|
const error = new Error("Invalid fractional indices");
|
||||||
|
const additionalContext = [];
|
||||||
|
|
||||||
|
if (reconciliationContext) {
|
||||||
|
additionalContext.push("Additional reconciliation context:");
|
||||||
|
additionalContext.push(
|
||||||
|
reconciliationContext.localElements.map((x) => stringifyElement(x)),
|
||||||
|
);
|
||||||
|
additionalContext.push(
|
||||||
|
reconciliationContext.remoteElements.map((x) => stringifyElement(x)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ignoreLogs) {
|
||||||
|
// report just once and with the stacktrace
|
||||||
|
console.error(
|
||||||
|
errorMessages.join("\n\n"),
|
||||||
|
error.stack,
|
||||||
|
elements.map((x) => stringifyElement(x)),
|
||||||
|
...additionalContext,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldThrow) {
|
||||||
|
// if enabled, gather all the errors first, throw once
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order the elements based on the fractional indices.
|
||||||
|
* - when fractional indices are identical, break the tie based on the element id
|
||||||
|
* - when there is no fractional index in one of the elements, respect the order of the array
|
||||||
|
*/
|
||||||
|
export function orderByFractionalIndex(elements: OrderedExcalidrawElement[]) {
|
||||||
|
return elements.sort((a, b) => {
|
||||||
|
// in case the indices are not the defined at runtime
|
||||||
|
if (isOrderedElement(a) && isOrderedElement(b)) {
|
||||||
|
if (a.index < b.index) {
|
||||||
|
return -1;
|
||||||
|
} else if (a.index > b.index) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// break ties based on the element id
|
||||||
|
return a.id < b.id ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// defensively keep the array order
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronizes invalid fractional indices of moved elements with the array order by mutating passed elements.
|
||||||
|
* If the synchronization fails or the result is invalid, it fallbacks to `syncInvalidIndices`.
|
||||||
|
*/
|
||||||
|
export function syncMovedIndices(
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
movedElements: Map<string, ExcalidrawElement>,
|
||||||
|
): OrderedExcalidrawElement[] {
|
||||||
|
try {
|
||||||
|
const indicesGroups = getMovedIndicesGroups(elements, movedElements);
|
||||||
|
|
||||||
|
// try generatating indices, throws on invalid movedElements
|
||||||
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
|
const elementsCandidates = elements.map((x) =>
|
||||||
|
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ensure next indices are valid before mutation, throws on invalid ones
|
||||||
|
validateFractionalIndices(
|
||||||
|
elementsCandidates,
|
||||||
|
// we don't autofix invalid bound text indices, hence don't include it in the validation
|
||||||
|
{
|
||||||
|
includeBoundTextValidation: false,
|
||||||
|
shouldThrow: true,
|
||||||
|
ignoreLogs: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// split mutation so we don't end up in an incosistent state
|
||||||
|
for (const [element, update] of elementsUpdates) {
|
||||||
|
mutateElement(element, update, false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// fallback to default sync
|
||||||
|
syncInvalidIndices(elements);
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements as OrderedExcalidrawElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronizes all invalid fractional indices with the array order by mutating passed elements.
|
||||||
|
*
|
||||||
|
* 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 function syncInvalidIndices(
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
): OrderedExcalidrawElement[] {
|
||||||
|
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||||
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
|
for (const [element, update] of elementsUpdates) {
|
||||||
|
mutateElement(element, update, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements as OrderedExcalidrawElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get contiguous groups of indices of passed moved elements.
|
||||||
|
*
|
||||||
|
* NOTE: First and last elements within the groups are indices of lower and upper bounds.
|
||||||
|
*/
|
||||||
|
function getMovedIndicesGroups(
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
movedElements: Map<string, ExcalidrawElement>,
|
||||||
|
) {
|
||||||
|
const indicesGroups: number[][] = [];
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < elements.length) {
|
||||||
|
if (movedElements.has(elements[i].id)) {
|
||||||
|
const indicesGroup = [i - 1, i]; // push the lower bound index as the first item
|
||||||
|
|
||||||
|
while (++i < elements.length) {
|
||||||
|
if (!movedElements.has(elements[i].id)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
indicesGroup.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
indicesGroup.push(i); // push the upper bound index as the last item
|
||||||
|
indicesGroups.push(indicesGroup);
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return indicesGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets contiguous groups of all invalid indices automatically detected inside the elements array.
|
||||||
|
*
|
||||||
|
* WARN: First and last items within the groups do NOT have to be contiguous, those are the found lower and upper bounds!
|
||||||
|
*/
|
||||||
|
function getInvalidIndicesGroups(elements: readonly ExcalidrawElement[]) {
|
||||||
|
const indicesGroups: number[][] = [];
|
||||||
|
|
||||||
|
// once we find lowerBound / upperBound, it cannot be lower than that, so we cache it for better perf.
|
||||||
|
let lowerBound: ExcalidrawElement["index"] | undefined = undefined;
|
||||||
|
let upperBound: ExcalidrawElement["index"] | undefined = undefined;
|
||||||
|
let lowerBoundIndex: number = -1;
|
||||||
|
let upperBoundIndex: number = 0;
|
||||||
|
|
||||||
|
/** @returns maybe valid lowerBound */
|
||||||
|
const getLowerBound = (
|
||||||
|
index: number,
|
||||||
|
): [ExcalidrawElement["index"] | undefined, number] => {
|
||||||
|
const lowerBound = elements[lowerBoundIndex]
|
||||||
|
? elements[lowerBoundIndex].index
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// we are already iterating left to right, therefore there is no need for additional looping
|
||||||
|
const candidate = elements[index - 1]?.index;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(!lowerBound && candidate) || // first lowerBound
|
||||||
|
(lowerBound && candidate && candidate > lowerBound) // next lowerBound
|
||||||
|
) {
|
||||||
|
// WARN: candidate's index could be higher or same as the current element's index
|
||||||
|
return [candidate, index - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// cache hit! take the last lower bound
|
||||||
|
return [lowerBound, lowerBoundIndex];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @returns always valid upperBound */
|
||||||
|
const getUpperBound = (
|
||||||
|
index: number,
|
||||||
|
): [ExcalidrawElement["index"] | undefined, number] => {
|
||||||
|
const upperBound = elements[upperBoundIndex]
|
||||||
|
? elements[upperBoundIndex].index
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// cache hit! don't let it find the upper bound again
|
||||||
|
if (upperBound && index < upperBoundIndex) {
|
||||||
|
return [upperBound, upperBoundIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
// set the current upperBoundIndex as the starting point
|
||||||
|
let i = upperBoundIndex;
|
||||||
|
while (++i < elements.length) {
|
||||||
|
const candidate = elements[i]?.index;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(!upperBound && candidate) || // first upperBound
|
||||||
|
(upperBound && candidate && candidate > upperBound) // next upperBound
|
||||||
|
) {
|
||||||
|
return [candidate, i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// we reached the end, sky is the limit
|
||||||
|
return [undefined, i];
|
||||||
|
};
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < elements.length) {
|
||||||
|
const current = elements[i].index;
|
||||||
|
[lowerBound, lowerBoundIndex] = getLowerBound(i);
|
||||||
|
[upperBound, upperBoundIndex] = getUpperBound(i);
|
||||||
|
|
||||||
|
if (!isValidFractionalIndex(current, lowerBound, upperBound)) {
|
||||||
|
// push the lower bound index as the first item
|
||||||
|
const indicesGroup = [lowerBoundIndex, i];
|
||||||
|
|
||||||
|
while (++i < elements.length) {
|
||||||
|
const current = elements[i].index;
|
||||||
|
const [nextLowerBound, nextLowerBoundIndex] = getLowerBound(i);
|
||||||
|
const [nextUpperBound, nextUpperBoundIndex] = getUpperBound(i);
|
||||||
|
|
||||||
|
if (isValidFractionalIndex(current, nextLowerBound, nextUpperBound)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// assign bounds only for the moved elements
|
||||||
|
[lowerBound, lowerBoundIndex] = [nextLowerBound, nextLowerBoundIndex];
|
||||||
|
[upperBound, upperBoundIndex] = [nextUpperBound, nextUpperBoundIndex];
|
||||||
|
|
||||||
|
indicesGroup.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// push the upper bound index as the last item
|
||||||
|
indicesGroup.push(upperBoundIndex);
|
||||||
|
indicesGroups.push(indicesGroup);
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return indicesGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidFractionalIndex(
|
||||||
|
index: ExcalidrawElement["index"] | undefined,
|
||||||
|
predecessor: ExcalidrawElement["index"] | undefined,
|
||||||
|
successor: ExcalidrawElement["index"] | undefined,
|
||||||
|
) {
|
||||||
|
if (!index) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (predecessor && successor) {
|
||||||
|
return predecessor < index && index < successor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!predecessor && successor) {
|
||||||
|
// first element
|
||||||
|
return index < successor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (predecessor && !successor) {
|
||||||
|
// last element
|
||||||
|
return predecessor < index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// only element in the array
|
||||||
|
return !!index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateIndices(
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
indicesGroups: number[][],
|
||||||
|
) {
|
||||||
|
const elementsUpdates = new Map<
|
||||||
|
ExcalidrawElement,
|
||||||
|
{ index: FractionalIndex }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const indices of indicesGroups) {
|
||||||
|
const lowerBoundIndex = indices.shift()!;
|
||||||
|
const upperBoundIndex = indices.pop()!;
|
||||||
|
|
||||||
|
const fractionalIndices = generateNKeysBetween(
|
||||||
|
elements[lowerBoundIndex]?.index,
|
||||||
|
elements[upperBoundIndex]?.index,
|
||||||
|
indices.length,
|
||||||
|
) as FractionalIndex[];
|
||||||
|
|
||||||
|
for (let i = 0; i < indices.length; i++) {
|
||||||
|
const element = elements[indices[i]];
|
||||||
|
|
||||||
|
elementsUpdates.set(element, {
|
||||||
|
index: fractionalIndices[i],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return elementsUpdates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOrderedElement(
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
): element is OrderedExcalidrawElement {
|
||||||
|
// for now it's sufficient whether the index is there
|
||||||
|
// meaning, the element was already ordered in the past
|
||||||
|
// meaning, it is not a newly inserted element, not an unrestored element, etc.
|
||||||
|
// it does not have to mean that the index itself is valid
|
||||||
|
if (element.index) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
6
packages/fractional-index/src/index.ts
Normal file
6
packages/fractional-index/src/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export {
|
||||||
|
validateFractionalIndices,
|
||||||
|
orderByFractionalIndex,
|
||||||
|
syncMovedIndices,
|
||||||
|
syncInvalidIndices,
|
||||||
|
} from "./fractionalIndex";
|
19
packages/fractional-index/tsconfig.json
Normal file
19
packages/fractional-index/tsconfig.json
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"strict": true,
|
||||||
|
"outDir": "dist/types",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"**/*.test.*",
|
||||||
|
"**/tests/*",
|
||||||
|
"types",
|
||||||
|
"dist",
|
||||||
|
],
|
||||||
|
}
|
39
scripts/buildShared.js
Normal file
39
scripts/buildShared.js
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
const fs = require("fs");
|
||||||
|
const { build } = require("esbuild");
|
||||||
|
|
||||||
|
const rawConfig = {
|
||||||
|
entryPoints: ["src/index.ts"],
|
||||||
|
bundle: true,
|
||||||
|
format: "esm",
|
||||||
|
metafile: true,
|
||||||
|
treeShaking: true,
|
||||||
|
external: ["*.scss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const createESMRawBuild = async () => {
|
||||||
|
// Development unminified build with source maps
|
||||||
|
const dev = await build({
|
||||||
|
...rawConfig,
|
||||||
|
outdir: "dist/dev",
|
||||||
|
sourcemap: true,
|
||||||
|
define: {
|
||||||
|
"import.meta.env": JSON.stringify({ DEV: true }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync("meta-dev.json", JSON.stringify(dev.metafile));
|
||||||
|
|
||||||
|
// production minified build without sourcemaps
|
||||||
|
const prod = await build({
|
||||||
|
...rawConfig,
|
||||||
|
outdir: "dist/prod",
|
||||||
|
minify: true,
|
||||||
|
define: {
|
||||||
|
"import.meta.env": JSON.stringify({ PROD: true }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync("meta-prod.json", JSON.stringify(prod.metafile));
|
||||||
|
};
|
||||||
|
|
||||||
|
createESMRawBuild();
|
Loading…
Add table
Add a link
Reference in a new issue