2024-04-04 20:51:11 +08:00
import { generateNKeysBetween } from "fractional-indexing" ;
2025-03-12 15:23:31 +01:00
2025-03-26 15:24:59 +01:00
import { arrayToMap } from "@excalidraw/common" ;
import { mutateElement } from "./mutateElement" ;
import { getBoundTextElement } from "./textElement" ;
import { hasBoundTextElement } from "./typeChecks" ;
2025-03-12 15:23:31 +01:00
2024-05-08 14:21:50 +05:30
import type {
2024-04-04 20:51:11 +08:00
ExcalidrawElement ,
FractionalIndex ,
OrderedExcalidrawElement ,
2025-03-26 15:24:59 +01:00
} from "./types" ;
export class InvalidFractionalIndexError extends Error {
public code = "ELEMENT_HAS_INVALID_INDEX" as const ;
}
2024-04-04 20:51:11 +08:00
/ * *
* 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 .
2024-07-23 16:56:55 +08:00
* - 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
2024-04-04 20:51:11 +08:00
* - 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 .
2024-07-23 16:56:55 +08:00
* - 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
2024-04-04 20:51:11 +08:00
* - 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 ` InvalidFractionalIndexError ` if invalid index is detected .
* /
export const validateFractionalIndices = (
2024-07-30 10:03:27 +02:00
elements : readonly ExcalidrawElement [ ] ,
{
shouldThrow = false ,
includeBoundTextValidation = false ,
2024-08-02 11:55:15 +02:00
ignoreLogs ,
2024-07-30 10:03:27 +02:00
reconciliationContext ,
} : {
shouldThrow : boolean ;
includeBoundTextValidation : boolean ;
2024-08-02 11:55:15 +02:00
ignoreLogs? : true ;
2024-07-30 10:03:27 +02:00
reconciliationContext ? : {
localElements : ReadonlyArray < ExcalidrawElement > ;
remoteElements : ReadonlyArray < ExcalidrawElement > ;
} ;
} ,
2024-04-04 20:51:11 +08:00
) = > {
2024-07-30 10:03:27 +02:00
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 ) ;
2024-04-04 20:51:11 +08:00
for ( const [ i , index ] of indices . entries ( ) ) {
const predecessorIndex = indices [ i - 1 ] ;
const successorIndex = indices [ i + 1 ] ;
if ( ! isValidFractionalIndex ( index , predecessorIndex , successorIndex ) ) {
2024-07-30 10:03:27 +02:00
errorMessages . push (
` Fractional indices invariant has been compromised: " ${ stringifyElement (
elements [ i - 1 ] ,
) } ", " $ { stringifyElement ( elements [ i ] ) } ", " $ { stringifyElement (
elements [ i + 1 ] ,
) } " ` ,
2024-04-04 20:51:11 +08:00
) ;
}
2024-07-30 10:03:27 +02:00
// 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 InvalidFractionalIndexError ( ) ;
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 ) ) ,
) ;
}
2024-08-02 11:55:15 +02:00
if ( ! ignoreLogs ) {
// report just once and with the stacktrace
console . error (
errorMessages . join ( "\n\n" ) ,
error . stack ,
elements . map ( ( x ) = > stringifyElement ( x ) ) ,
. . . additionalContext ,
) ;
}
2024-07-30 10:03:27 +02:00
if ( shouldThrow ) {
// if enabled, gather all the errors first, throw once
throw error ;
}
2024-04-04 20:51:11 +08:00
}
} ;
/ * *
* 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 const 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 const 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 ) ;
2024-07-30 10:03:27 +02:00
const elementsCandidates = elements . map ( ( x ) = >
elementsUpdates . has ( x ) ? { . . . x , . . . elementsUpdates . get ( x ) } : x ,
) ;
2024-04-04 20:51:11 +08:00
// ensure next indices are valid before mutation, throws on invalid ones
validateFractionalIndices (
2024-07-30 10:03:27 +02:00
elementsCandidates ,
// we don't autofix invalid bound text indices, hence don't include it in the validation
2024-08-02 11:55:15 +02:00
{
includeBoundTextValidation : false ,
shouldThrow : true ,
ignoreLogs : true ,
} ,
2024-04-04 20:51:11 +08:00
) ;
// 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 const 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 .
* /
const getMovedIndicesGroups = (
elements : readonly ExcalidrawElement [ ] ,
movedElements : Map < string , ExcalidrawElement > ,
) = > {
const indicesGroups : number [ ] [ ] = [ ] ;
let i = 0 ;
while ( i < elements . length ) {
2024-05-20 22:23:42 +01:00
if ( movedElements . has ( elements [ i ] . id ) ) {
2024-04-04 20:51:11 +08:00
const indicesGroup = [ i - 1 , i ] ; // push the lower bound index as the first item
while ( ++ i < elements . length ) {
2024-05-20 22:23:42 +01:00
if ( ! movedElements . has ( elements [ i ] . id ) ) {
2024-04-04 20:51:11 +08:00
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 !
* /
const 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 ;
} ;
const 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 ;
} ;
const 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 ;
} ;
const 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 ;
} ;