diff --git a/package.json b/package.json index 3bd87196e8..b4c255df82 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,7 @@ "packageManager": "yarn@1.22.22", "workspaces": [ "excalidraw-app", - "packages/excalidraw", - "packages/utils", - "packages/math", + "packages/*", "examples/excalidraw", "examples/excalidraw/*" ], diff --git a/packages/deltas/package.json b/packages/deltas/package.json new file mode 100644 index 0000000000..c5ace29313 --- /dev/null +++ b/packages/deltas/package.json @@ -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" + } +} diff --git a/packages/deltas/src/common/delta.ts b/packages/deltas/src/common/delta.ts new file mode 100644 index 0000000000..799f359bd9 --- /dev/null +++ b/packages/deltas/src/common/delta.ts @@ -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 { + private constructor( + public readonly deleted: Partial, + public readonly inserted: Partial, + ) {} + + public static create( + deleted: Partial, + inserted: Partial, + modifier?: (delta: Partial) => Partial, + 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( + prevObject: T, + nextObject: T, + modifier?: (partial: Partial) => Partial, + postProcess?: ( + deleted: Partial, + inserted: Partial, + ) => [Partial, Partial], + ): Delta { + if (prevObject === nextObject) { + return Delta.empty(); + } + + const deleted = {} as Partial; + const inserted = {} as Partial; + + // 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(delta: Delta): boolean { + return ( + !Object.keys(delta.deleted).length && !Object.keys(delta.inserted).length + ); + } + + /** + * Merges deleted and inserted object partials. + */ + public static mergeObjects( + 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( + 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( + deleted: Partial, + inserted: Partial, + 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; + + 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( + deleted: Partial, + inserted: Partial, + property: K, + groupBy: (value: V extends ArrayLike ? 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( + 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( + 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( + 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( + 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( + 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; + } +} diff --git a/packages/deltas/src/common/interfaces.ts b/packages/deltas/src/common/interfaces.ts new file mode 100644 index 0000000000..ca75d755e1 --- /dev/null +++ b/packages/deltas/src/common/interfaces.ts @@ -0,0 +1,21 @@ +/** + * Encapsulates a set of application-level `Delta`s. + */ +export interface DeltaContainer { + /** + * Inverses the `Delta`s while creating a new `DeltaContainer` instance. + */ + inverse(): DeltaContainer; + + /** + * 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; +} diff --git a/packages/deltas/src/common/utils.ts b/packages/deltas/src/common/utils.ts new file mode 100644 index 0000000000..597014f613 --- /dev/null +++ b/packages/deltas/src/common/utils.ts @@ -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 = ( + 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 = ( + items: readonly T[], +) => { + return items.reduce((acc: Map, 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(); + + 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 = ( + element: TElement, + updates: ElementUpdate, + /** 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(), + }; +}; diff --git a/packages/deltas/src/containers/appstate.ts b/packages/deltas/src/containers/appstate.ts new file mode 100644 index 0000000000..c070d3414b --- /dev/null +++ b/packages/deltas/src/containers/appstate.ts @@ -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 { + private constructor(public readonly delta: Delta) {} + + public static calculate( + prevAppState: T, + nextAppState: T, + ): AppStateDelta { + const delta = Delta.calculate( + prevAppState, + nextAppState, + undefined, + AppStateDelta.postProcess, + ); + + return new AppStateDelta(delta); + } + + public static restore(appStateDeltaDTO: DTO): 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, + // ) + // : null; + + // const editingLinearElement = + // editingLinearElementId && nextElements.has(editingLinearElementId) + // ? new LinearElementEditor( + // nextElements.get( + // editingLinearElementId, + // ) as NonDeleted, + // ) + // : 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( + deleted: Partial, + inserted: Partial, + ): [Partial, Partial] { + 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, + ); + Delta.diffObjects( + deleted, + inserted, + "selectedGroupIds", + (prevValue) => (prevValue ?? false) as ValueOf, + ); + } 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; + + let nonDeletedGroupIds = new Set(); + + 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 { + 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, + 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, + ): Partial { + // 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, + ): Partial { + // 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 + >; + } +} diff --git a/packages/deltas/src/containers/elements.ts b/packages/deltas/src/containers/elements.ts new file mode 100644 index 0000000000..77846d9f6a --- /dev/null +++ b/packages/deltas/src/containers/elements.ts @@ -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 = + ElementUpdate>; + +/** + * 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 { + private constructor( + public readonly added: Record>, + public readonly removed: Record>, + public readonly updated: Record>, + ) {} + + public static create( + added: Record>, + removed: Record>, + updated: Record>, + options: { + shouldRedistribute: boolean; + } = { + shouldRedistribute: false, + // CFDO: don't forget to re-enable + }, + ) { + const { shouldRedistribute } = options; + let delta: ElementsDelta; + + if (shouldRedistribute) { + const nextAdded: Record> = {}; + const nextRemoved: Record> = {}; + const nextUpdated: Record> = {}; + + 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 { + const { added, removed, updated } = elementsDeltaDTO; + return ElementsDelta.create(added, removed, updated); + } + + private static satisfiesAddition = ({ + deleted, + inserted, + }: Delta) => + // dissallowing added as "deleted", which could cause issues when resolving conflicts + deleted.isDeleted === true && !inserted.isDeleted; + + private static satisfiesRemoval = ({ + deleted, + inserted, + }: Delta) => + !deleted.isDeleted && inserted.isDeleted === true; + + private static satisfiesUpdate = ({ + deleted, + inserted, + }: Delta) => !!deleted.isDeleted === !!inserted.isDeleted; + + private static validate( + elementsDelta: ElementsDelta, + type: "added" | "removed" | "updated", + satifies: (delta: Delta) => 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( + prevElements: Map, + nextElements: Map, + ): ElementsDelta { + if (prevElements === nextElements) { + return ElementsDelta.empty(); + } + + const added: Record> = {}; + const removed: Record> = {}; + const updated: Record> = {}; + + // 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( + 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>) => { + const inversedDeltas: Record> = {}; + + 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) { + // 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>, + ) => { + const modifiedDeltas: Record> = {}; + + 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, + ): [SceneElementsMap, boolean] { + const nextElements = new Map(elements) as SceneElementsMap; + let changedElements: Map; + + 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, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + }, + ) => + ( + type: "added" | "removed" | "updated", + deltas: Record>, + ) => { + 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()); + }; + + private static createGetter = + ( + type: "added" | "removed" | "updated", + elements: SceneElementsMap, + snapshot: Map, + 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, + 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>; + // 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(); + // const updater = ( + // element: ExcalidrawElement, + // updates: ElementUpdate, + // ) => { + // 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, + // ); + // } else { + // affectedElement = mutateElement( + // nextElement, + // updates as ElementUpdate, + // ); + // } + + // 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, + // ) => 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, + // ) => 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, + // ) { + // 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, + // ) { + // for (const element of changed.values()) { + // if (!element.isDeleted && isBindableElement(element)) { + // updateBoundElements(element, elements, { + // changedElements: changed, + // }); + // } + // } + // } + + // private static reorderElements( + // elements: SceneElementsMap, + // changed: Map, + // 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, + ): ElementPartial { + const { id, updated, version, versionNonce, ...strippedPartial } = partial; + + return strippedPartial; + } +} diff --git a/packages/deltas/src/excalidraw-types.d.ts b/packages/deltas/src/excalidraw-types.d.ts new file mode 100644 index 0000000000..0ceca702c5 --- /dev/null +++ b/packages/deltas/src/excalidraw-types.d.ts @@ -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"; diff --git a/packages/deltas/src/index.ts b/packages/deltas/src/index.ts new file mode 100644 index 0000000000..5e32491f94 --- /dev/null +++ b/packages/deltas/src/index.ts @@ -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"; diff --git a/packages/deltas/tsconfig.json b/packages/deltas/tsconfig.json new file mode 100644 index 0000000000..5fb40d38ca --- /dev/null +++ b/packages/deltas/tsconfig.json @@ -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", + ], +} diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index e127999240..11ef683fe3 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -43,7 +43,7 @@ import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types"; import type { DurableStoreIncrement, EphemeralStoreIncrement, - StoreActionType as StoreActionType, + StoreActionType, } from "./store"; export type SocketId = string & { _brand: "SocketId" }; diff --git a/packages/fractional-index/package.json b/packages/fractional-index/package.json new file mode 100644 index 0000000000..a1d4f980a1 --- /dev/null +++ b/packages/fractional-index/package.json @@ -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" + } +} diff --git a/packages/fractional-index/src/fractionalIndex.ts b/packages/fractional-index/src/fractionalIndex.ts new file mode 100644 index 0000000000..597631253b --- /dev/null +++ b/packages/fractional-index/src/fractionalIndex.ts @@ -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; + remoteElements: ReadonlyArray; + }; + }, +) { + 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, +): 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, +) { + 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; +} diff --git a/packages/fractional-index/src/index.ts b/packages/fractional-index/src/index.ts new file mode 100644 index 0000000000..249e4f1006 --- /dev/null +++ b/packages/fractional-index/src/index.ts @@ -0,0 +1,6 @@ +export { + validateFractionalIndices, + orderByFractionalIndex, + syncMovedIndices, + syncInvalidIndices, +} from "./fractionalIndex"; diff --git a/packages/fractional-index/tsconfig.json b/packages/fractional-index/tsconfig.json new file mode 100644 index 0000000000..5fb40d38ca --- /dev/null +++ b/packages/fractional-index/tsconfig.json @@ -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", + ], +} diff --git a/scripts/buildShared.js b/scripts/buildShared.js new file mode 100644 index 0000000000..2e4e179d96 --- /dev/null +++ b/scripts/buildShared.js @@ -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();