@tldraw/utils
Version:
tldraw infinite canvas SDK (private utilities).
289 lines (275 loc) • 9.15 kB
text/typescript
/**
* Rotate the contents of an array by a specified offset.
*
* Creates a new array with elements shifted to the left by the specified number of positions.
* Both positive and negative offsets result in left shifts (elements move left, with elements
* from the front wrapping to the back).
*
* @param arr - The array to rotate
* @param offset - The number of positions to shift left (both positive and negative values shift left)
* @returns A new array with elements shifted left by the specified offset
*
* @example
* ```ts
* rotateArray([1, 2, 3, 4], 1) // [2, 3, 4, 1]
* rotateArray([1, 2, 3, 4], -1) // [2, 3, 4, 1]
* rotateArray(['a', 'b', 'c'], 2) // ['c', 'a', 'b']
* ```
* @public
*/
export function rotateArray<T>(arr: T[], offset: number): T[] {
if (arr.length === 0) return []
// Based on the test expectations, both positive and negative offsets
// should rotate left (shift elements to the left)
const normalizedOffset = ((Math.abs(offset) % arr.length) + arr.length) % arr.length
// Slice the array at the offset point and concatenate
return [...arr.slice(normalizedOffset), ...arr.slice(0, normalizedOffset)]
}
/**
* Remove duplicate items from an array.
*
* Creates a new array with duplicate items removed. Uses strict equality by default,
* or a custom equality function if provided. Order of first occurrence is preserved.
*
* @param input - The array to deduplicate
* @param equals - Optional custom equality function to compare items (defaults to strict equality)
* @returns A new array with duplicate items removed
*
* @example
* ```ts
* dedupe([1, 2, 2, 3, 1]) // [1, 2, 3]
* dedupe(['a', 'b', 'a', 'c']) // ['a', 'b', 'c']
*
* // With custom equality function
* const objects = [{id: 1}, {id: 2}, {id: 1}]
* dedupe(objects, (a, b) => a.id === b.id) // [{id: 1}, {id: 2}]
* ```
* @public
*/
export function dedupe<T>(input: T[], equals?: (a: any, b: any) => boolean): T[] {
const result: T[] = []
mainLoop: for (const item of input) {
for (const existing of result) {
if (equals ? equals(item, existing) : item === existing) {
continue mainLoop
}
}
result.push(item)
}
return result
}
/**
* Remove null and undefined values from an array.
*
* Creates a new array with all null and undefined values filtered out.
* The resulting array has a refined type that excludes null and undefined.
*
* @param arr - The array to compact
* @returns A new array with null and undefined values removed
*
* @example
* ```ts
* compact([1, null, 2, undefined, 3]) // [1, 2, 3]
* compact(['a', null, 'b', undefined]) // ['a', 'b']
* ```
* @internal
*/
export function compact<T>(arr: T[]): NonNullable<T>[] {
return arr.filter((i) => i !== undefined && i !== null) as any
}
/**
* Get the last element of an array.
*
* Returns the last element of an array, or undefined if the array is empty.
* Works with readonly arrays and preserves the element type.
*
* @param arr - The array to get the last element from
* @returns The last element of the array, or undefined if the array is empty
*
* @example
* ```ts
* last([1, 2, 3]) // 3
* last(['a', 'b', 'c']) // 'c'
* last([]) // undefined
* ```
* @internal
*/
export function last<T>(arr: readonly T[]): T | undefined {
return arr[arr.length - 1]
}
/**
* Find the item in an array with the minimum value according to a function.
*
* Finds the array item that produces the smallest value when passed through
* the provided function. Returns undefined for empty arrays.
*
* @param arr - The array to search
* @param fn - Function to compute the comparison value for each item
* @returns The item with the minimum value, or undefined if the array is empty
*
* @example
* ```ts
* const people = [{name: 'Alice', age: 30}, {name: 'Bob', age: 25}]
* minBy(people, p => p.age) // {name: 'Bob', age: 25}
*
* minBy([3, 1, 4, 1, 5], x => x) // 1
* minBy([], x => x) // undefined
* ```
* @internal
*/
export function minBy<T>(arr: readonly T[], fn: (item: T) => number): T | undefined {
let min: T | undefined
let minVal = Infinity
for (const item of arr) {
const val = fn(item)
if (val < minVal) {
min = item
minVal = val
}
}
return min
}
/**
* Find the item in an array with the maximum value according to a function.
*
* Finds the array item that produces the largest value when passed through
* the provided function. Returns undefined for empty arrays.
*
* @param arr - The array to search
* @param fn - Function to compute the comparison value for each item
* @returns The item with the maximum value, or undefined if the array is empty
*
* @example
* ```ts
* const people = [{name: 'Alice', age: 30}, {name: 'Bob', age: 25}]
* maxBy(people, p => p.age) // {name: 'Alice', age: 30}
*
* maxBy([3, 1, 4, 1, 5], x => x) // 5
* maxBy([], x => x) // undefined
* ```
* @internal
*/
export function maxBy<T>(arr: readonly T[], fn: (item: T) => number): T | undefined {
let max: T | undefined
let maxVal: number = -Infinity
for (const item of arr) {
const val = fn(item)
if (val > maxVal) {
max = item
maxVal = val
}
}
return max
}
/**
* Split an array into two arrays based on a predicate function.
*
* Partitions an array into two arrays: one containing items that satisfy
* the predicate, and another containing items that do not. The original array order is preserved.
*
* @param arr - The array to partition
* @param predicate - The predicate function to test each item
* @returns A tuple of two arrays: [satisfying items, non-satisfying items]
*
* @example
* ```ts
* const [evens, odds] = partition([1, 2, 3, 4, 5], x => x % 2 === 0)
* // evens: [2, 4], odds: [1, 3, 5]
*
* const [adults, minors] = partition(
* [{name: 'Alice', age: 30}, {name: 'Bob', age: 17}],
* person => person.age >= 18
* )
* // adults: [{name: 'Alice', age: 30}], minors: [{name: 'Bob', age: 17}]
* ```
* @internal
*/
export function partition<T>(arr: T[], predicate: (item: T) => boolean): [T[], T[]] {
const satisfies: T[] = []
const doesNotSatisfy: T[] = []
for (const item of arr) {
if (predicate(item)) {
satisfies.push(item)
} else {
doesNotSatisfy.push(item)
}
}
return [satisfies, doesNotSatisfy]
}
/**
* Check if two arrays are shallow equal.
*
* Compares two arrays for shallow equality by checking if they have the same length
* and the same elements at each index using Object.is comparison. Returns true if arrays are
* the same reference, have different lengths, or any elements differ.
*
* @param arr1 - First array to compare
* @param arr2 - Second array to compare
* @returns True if arrays are shallow equal, false otherwise
*
* @example
* ```ts
* areArraysShallowEqual([1, 2, 3], [1, 2, 3]) // true
* areArraysShallowEqual([1, 2, 3], [1, 2, 4]) // false
* areArraysShallowEqual(['a', 'b'], ['a', 'b']) // true
* areArraysShallowEqual([1, 2], [1, 2, 3]) // false
*
* const obj = {x: 1}
* areArraysShallowEqual([obj], [obj]) // true (same reference)
* areArraysShallowEqual([{x: 1}], [{x: 1}]) // false (different objects)
* ```
* @internal
*/
export function areArraysShallowEqual<T>(arr1: readonly T[], arr2: readonly T[]): boolean {
if (arr1 === arr2) return true
if (arr1.length !== arr2.length) return false
for (let i = 0; i < arr1.length; i++) {
if (!Object.is(arr1[i], arr2[i])) {
return false
}
}
return true
}
/**
* Merge custom entries with defaults, replacing defaults that have matching keys.
*
* Combines two arrays by keeping all custom entries and only the default entries
* that don't have a matching key in the custom entries. Custom entries always override defaults.
* The result contains remaining defaults first, followed by all custom entries.
*
* @param key - The property name to use as the unique identifier
* @param customEntries - Array of custom entries that will override defaults
* @param defaults - Array of default entries
* @returns A new array with defaults filtered out where custom entries exist, plus all custom entries
*
* @example
* ```ts
* const defaults = [{type: 'text', value: 'default'}, {type: 'number', value: 0}]
* const custom = [{type: 'text', value: 'custom'}]
*
* mergeArraysAndReplaceDefaults('type', custom, defaults)
* // Result: [{type: 'number', value: 0}, {type: 'text', value: 'custom'}]
*
* const tools = [{id: 'select', name: 'Select'}, {id: 'draw', name: 'Draw'}]
* const customTools = [{id: 'select', name: 'Custom Select'}]
*
* mergeArraysAndReplaceDefaults('id', customTools, tools)
* // Result: [{id: 'draw', name: 'Draw'}, {id: 'select', name: 'Custom Select'}]
* ```
* @internal
*/
export function mergeArraysAndReplaceDefaults<
const Key extends string,
T extends { [K in Key]: string },
>(key: Key, customEntries: readonly T[], defaults: readonly T[]) {
const overrideTypes = new Set(customEntries.map((entry) => entry[key]))
const result = []
for (const defaultEntry of defaults) {
if (overrideTypes.has(defaultEntry[key])) continue
result.push(defaultEntry)
}
for (const customEntry of customEntries) {
result.push(customEntry)
}
return result
}