diff --git a/src/actions/actionZindex.tsx b/src/actions/actionZindex.tsx index 17ecde1a63..0ecfdcb2e6 100644 --- a/src/actions/actionZindex.tsx +++ b/src/actions/actionZindex.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { moveOneLeft, moveOneRight, diff --git a/src/data/restore.ts b/src/data/restore.ts index 76a8c32cab..4e4553577f 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -26,7 +26,6 @@ import { DEFAULT_FONT_FAMILY, DEFAULT_TEXT_ALIGN, DEFAULT_VERTICAL_ALIGN, - PRECEDING_ELEMENT_KEY, FONT_FAMILY, ROUNDNESS, DEFAULT_SIDEBAR, @@ -44,6 +43,7 @@ import { measureBaseline, } from "../element/textElement"; import { normalizeLink } from "./url"; +import { generateConsistentFractionalIndex } from "../fractionalIndex"; type RestoredAppState = Omit< AppState, @@ -101,8 +101,6 @@ const restoreElementWithProperties = < boundElementIds?: readonly ExcalidrawElement["id"][]; /** @deprecated */ strokeSharpness?: StrokeRoundness; - /** metadata that may be present in elements during collaboration */ - [PRECEDING_ELEMENT_KEY]?: string; }, K extends Pick, keyof ExcalidrawElement>>, >( @@ -115,14 +113,14 @@ const restoreElementWithProperties = < > & Partial>, ): T => { - const base: Pick & { - [PRECEDING_ELEMENT_KEY]?: string; - } = { + const base: Pick = { type: extra.type || element.type, // all elements must have version > 0 so getSceneVersion() will pick up // newly added elements version: element.version || 1, versionNonce: element.versionNonce ?? 0, + // TODO: think about this more + fractionalIndex: element.fractionalIndex ?? Infinity, isDeleted: element.isDeleted ?? false, id: element.id || randomId(), fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle, @@ -166,10 +164,6 @@ const restoreElementWithProperties = < "customData" in extra ? extra.customData : element.customData; } - if (PRECEDING_ELEMENT_KEY in element) { - base[PRECEDING_ELEMENT_KEY] = element[PRECEDING_ELEMENT_KEY]; - } - return { ...base, ...getNormalizedDimensions(base), @@ -589,7 +583,9 @@ export const restore = ( elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean }, ): RestoredDataState => { return { - elements: restoreElements(data?.elements, localElements, elementsConfig), + elements: generateConsistentFractionalIndex( + restoreElements(data?.elements, localElements, elementsConfig), + ), appState: restoreAppState(data?.appState, localAppState || null), files: data?.files || {}, }; diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 026373f88f..8d5a323a74 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -55,6 +55,7 @@ export type ElementConstructorOpts = MarkOptional< | "angle" | "groupIds" | "frameId" + | "fractionalIndex" | "boundElements" | "seed" | "version" @@ -88,6 +89,8 @@ const _newElementBase = ( angle = 0, groupIds = [], frameId = null, + // TODO: think about this more + fractionalIndex = Infinity, roundness = null, boundElements = null, link = null, @@ -113,6 +116,7 @@ const _newElementBase = ( opacity, groupIds, frameId, + fractionalIndex, roundness, seed: rest.seed ?? randomInteger(), version: rest.version || 1, diff --git a/src/element/types.ts b/src/element/types.ts index b863fb429f..1c2cab9726 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -50,6 +50,7 @@ type _ExcalidrawElementBase = Readonly<{ Used for deterministic reconciliation of updates during collaboration, in case the versions (see above) are identical. */ versionNonce: number; + fractionalIndex: number; isDeleted: boolean; /** List of groups the element belongs to. Ordered from deepest to shallowest. */ diff --git a/src/tests/fixtures/elementFixture.ts b/src/tests/fixtures/elementFixture.ts index 7f1231a834..5adffbb2b3 100644 --- a/src/tests/fixtures/elementFixture.ts +++ b/src/tests/fixtures/elementFixture.ts @@ -17,6 +17,7 @@ const elementBase: Omit = { groupIds: [], frameId: null, roundness: null, + fractionalIndex: Infinity, seed: 1041657908, version: 120, versionNonce: 1188004276, diff --git a/src/tests/helpers/api.ts b/src/tests/helpers/api.ts index 2a18805ab0..c327f5bc07 100644 --- a/src/tests/helpers/api.ts +++ b/src/tests/helpers/api.ts @@ -100,6 +100,7 @@ export class API { id?: string; isDeleted?: boolean; frameId?: ExcalidrawElement["id"] | null; + fractionalIndex: ExcalidrawElement["fractionalIndex"]; groupIds?: string[]; // generic element props strokeColor?: ExcalidrawGenericElement["strokeColor"]; @@ -167,6 +168,7 @@ export class API { x, y, frameId: rest.frameId ?? null, + fractionalIndex: rest.fractionalIndex ?? Infinity, angle: rest.angle ?? 0, strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor, backgroundColor: diff --git a/src/zindex.ts b/src/zindex.ts index a46c8b9a7c..a6a785aa2f 100644 --- a/src/zindex.ts +++ b/src/zindex.ts @@ -485,6 +485,99 @@ function shiftElementsAccountingForFrames( ); } +// fractional indexing +// ----------------------------------------------------------------------------- +const FRACTIONAL_INDEX_FLOOR = 0; +const FRACTIONAL_INDEX_CEILING = 1; + +const isFractionalIndexInValidRange = (index: number) => { + return index > FRACTIONAL_INDEX_FLOOR && index < FRACTIONAL_INDEX_CEILING; +}; + +const getFractionalIndex = ( + element: ExcalidrawElement | undefined, + fallbackValue: number, +) => { + return element && isFractionalIndexInValidRange(element.fractionalIndex) + ? element.fractionalIndex + : fallbackValue; +}; + +const isValidFractionalIndex = ( + index: number, + predecessorElement: ExcalidrawElement | undefined, + successorElement: ExcalidrawElement | undefined, +) => { + return ( + isFractionalIndexInValidRange(index) && + index > getFractionalIndex(predecessorElement, FRACTIONAL_INDEX_FLOOR) && + index < getFractionalIndex(successorElement, FRACTIONAL_INDEX_CEILING) + ); +}; + +const randomNumInBetween = (start: number, end: number) => { + return Math.random() * (end - start) + start; +}; + +export const generateFractionalIndex = ({ + start = FRACTIONAL_INDEX_FLOOR, + end = FRACTIONAL_INDEX_CEILING, +}: { + start?: number; + end?: number; +}) => { + const nextTemp = randomNumInBetween(start, end); + return ( + (randomNumInBetween(nextTemp, end) + randomNumInBetween(start, nextTemp)) / + 2 + ); +}; + +/** + * normalize the fractional indicies of the elements in the given array such that + * a. all elements have a fraction index between floor and ceiling as defined above + * b. for every element, its fractional index is greater than its predecessor's and smaller than its successor's + */ + +export const normalizeFractionalIndexing = ( + allElements: readonly ExcalidrawElement[], +) => { + let predecessor = -1; + let successor = 1; + + const normalizedElements: ExcalidrawElement[] = []; + + for (const element of allElements) { + const predecessorElement = allElements[predecessor]; + const successorElement = allElements[successor]; + + if ( + !isValidFractionalIndex( + element.fractionalIndex, + predecessorElement, + successorElement, + ) + ) { + const nextFractionalIndex = generateFractionalIndex({ + start: getFractionalIndex(predecessorElement, FRACTIONAL_INDEX_FLOOR), + end: getFractionalIndex(successorElement, FRACTIONAL_INDEX_CEILING), + }); + + normalizedElements.push({ + ...element, + fractionalIndex: nextFractionalIndex, + }); + } else { + normalizedElements.push(element); + } + + predecessor++; + successor++; + } + + return normalizedElements; +}; + // public API // ----------------------------------------------------------------------------- @@ -492,25 +585,31 @@ export const moveOneLeft = ( allElements: readonly ExcalidrawElement[], appState: AppState, ) => { - return shiftElementsByOne(allElements, appState, "left"); + return normalizeFractionalIndexing( + shiftElementsByOne(allElements, appState, "left"), + ); }; export const moveOneRight = ( allElements: readonly ExcalidrawElement[], appState: AppState, ) => { - return shiftElementsByOne(allElements, appState, "right"); + return normalizeFractionalIndexing( + shiftElementsByOne(allElements, appState, "right"), + ); }; export const moveAllLeft = ( allElements: readonly ExcalidrawElement[], appState: AppState, ) => { - return shiftElementsAccountingForFrames( - allElements, - appState, - "left", - shiftElementsToEnd, + return normalizeFractionalIndexing( + shiftElementsAccountingForFrames( + allElements, + appState, + "left", + shiftElementsToEnd, + ), ); }; @@ -518,10 +617,12 @@ export const moveAllRight = ( allElements: readonly ExcalidrawElement[], appState: AppState, ) => { - return shiftElementsAccountingForFrames( - allElements, - appState, - "right", - shiftElementsToEnd, + return normalizeFractionalIndexing( + shiftElementsAccountingForFrames( + allElements, + appState, + "right", + shiftElementsToEnd, + ), ); };