snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
930
packages/utils/DOCS.md
Normal file
930
packages/utils/DOCS.md
Normal file
@@ -0,0 +1,930 @@
|
||||
# @tldraw/utils
|
||||
|
||||
The `@tldraw/utils` package provides foundational utility functions that power the tldraw SDK. It contains pure, reusable helper functions for common programming tasks including array manipulation, object operations, error handling, performance optimization, and media processing.
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
The utils package serves as the foundation of the tldraw ecosystem, providing battle-tested utilities that other tldraw packages depend on. Every function is designed with type safety, performance, and cross-platform compatibility in mind.
|
||||
|
||||
You import utilities directly as named exports:
|
||||
|
||||
```ts
|
||||
import { dedupe, rotateArray, partition } from '@tldraw/utils'
|
||||
import { ExecutionQueue, Result, assert } from '@tldraw/utils'
|
||||
import { WeakCache, MediaHelpers } from '@tldraw/utils'
|
||||
```
|
||||
|
||||
The package is completely self-contained with no dependencies on other `@tldraw/*` packages, making it safe to use in any JavaScript environment.
|
||||
|
||||
> Important: Many utilities in this package are marked as `@internal` in the source code. These are implementation details used within the tldraw ecosystem. While they're exported for internal tldraw packages to use, they may change without notice in minor versions. Focus on the `@public` APIs for stable external usage.
|
||||
|
||||
## 2. Core Utilities
|
||||
|
||||
### Array Operations: Transforming Collections
|
||||
|
||||
Arrays are everywhere in tldraw - from managing shapes and tools to handling selections and ordering. The array utilities provide type-safe operations that preserve your data's integrity.
|
||||
|
||||
#### Deduplication and Uniqueness
|
||||
|
||||
You deduplicate arrays using the `dedupe` function:
|
||||
|
||||
```ts
|
||||
import { dedupe } from '@tldraw/utils'
|
||||
|
||||
const shapes = [
|
||||
{ id: 'a', type: 'rect' },
|
||||
{ id: 'b', type: 'circle' },
|
||||
{ id: 'a', type: 'rect' }, // duplicate
|
||||
]
|
||||
|
||||
const uniqueShapes = dedupe(shapes, (a, b) => a.id === b.id)
|
||||
console.log(uniqueShapes) // [{ id: 'a', type: 'rect' }, { id: 'b', type: 'circle' }]
|
||||
```
|
||||
|
||||
For simple value arrays, you can omit the equality function:
|
||||
|
||||
```ts
|
||||
const ids = ['a', 'b', 'c', 'a', 'b']
|
||||
const uniqueIds = dedupe(ids)
|
||||
console.log(uniqueIds) // ['a', 'b', 'c']
|
||||
```
|
||||
|
||||
#### Rotating and Reordering
|
||||
|
||||
You can rotate array contents with `rotateArray`:
|
||||
|
||||
```ts
|
||||
import { rotateArray } from '@tldraw/utils'
|
||||
|
||||
const tools = ['select', 'draw', 'eraser', 'text']
|
||||
const rotated = rotateArray(tools, 1)
|
||||
console.log(rotated) // ['text', 'select', 'draw', 'eraser']
|
||||
```
|
||||
|
||||
This is particularly useful for cycling through tools or shifting z-order arrangements.
|
||||
|
||||
#### Splitting Collections
|
||||
|
||||
You partition arrays based on conditions using `partition`:
|
||||
|
||||
```ts
|
||||
import { partition } from '@tldraw/utils'
|
||||
|
||||
const shapes = [
|
||||
{ id: 'a', selected: true },
|
||||
{ id: 'b', selected: false },
|
||||
{ id: 'c', selected: true },
|
||||
]
|
||||
|
||||
const [selected, unselected] = partition(shapes, (shape) => shape.selected)
|
||||
console.log(selected) // [{ id: 'a', selected: true }, { id: 'c', selected: true }]
|
||||
console.log(unselected) // [{ id: 'b', selected: false }]
|
||||
```
|
||||
|
||||
> Note: `partition` is marked as `@internal` in the source code but is exported for use. It may change without notice in minor versions.
|
||||
|
||||
### Error Handling: The Result Pattern
|
||||
|
||||
Instead of throwing exceptions, tldraw uses the `Result` pattern for predictable error handling. This approach makes errors explicit and prevents unexpected crashes.
|
||||
|
||||
#### Creating Results
|
||||
|
||||
You create successful results with `Result.ok()` and errors with `Result.err()`:
|
||||
|
||||
```ts
|
||||
import { Result } from '@tldraw/utils'
|
||||
|
||||
function parseShape(data: unknown): Result<Shape, string> {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return Result.err('Invalid data: not an object')
|
||||
}
|
||||
|
||||
// Type checking logic...
|
||||
return Result.ok(validShape)
|
||||
}
|
||||
```
|
||||
|
||||
#### Handling Results
|
||||
|
||||
You check results using the `ok` property:
|
||||
|
||||
```ts
|
||||
const result = parseShape(unknownData)
|
||||
|
||||
if (result.ok) {
|
||||
// TypeScript knows result.value is a Shape
|
||||
console.log(`Shape type: ${result.value.type}`)
|
||||
} else {
|
||||
// TypeScript knows result.error is a string
|
||||
console.error(`Parse failed: ${result.error}`)
|
||||
}
|
||||
```
|
||||
|
||||
#### Chaining Operations
|
||||
|
||||
Results compose well for sequential operations:
|
||||
|
||||
```ts
|
||||
function validateAndCreateShape(data: unknown): Result<ProcessedShape, string> {
|
||||
const parseResult = parseShape(data)
|
||||
if (!parseResult.ok) {
|
||||
return parseResult // Pass through the error
|
||||
}
|
||||
|
||||
const processResult = processShape(parseResult.value)
|
||||
return processResult
|
||||
}
|
||||
```
|
||||
|
||||
### Assertions: Runtime Type Checking
|
||||
|
||||
When you need to verify assumptions at runtime, use the assertion functions. These provide both runtime safety and TypeScript type narrowing.
|
||||
|
||||
#### Basic Assertions
|
||||
|
||||
The `assert` function throws if a condition is false:
|
||||
|
||||
```ts
|
||||
import { assert } from '@tldraw/utils'
|
||||
|
||||
function processShape(shape: unknown) {
|
||||
assert(shape && typeof shape === 'object', 'Shape must be an object')
|
||||
// TypeScript now knows shape is object & not null
|
||||
|
||||
assert('type' in shape, 'Shape must have a type property')
|
||||
// TypeScript knows shape has a type property
|
||||
}
|
||||
```
|
||||
|
||||
#### Existence Checking
|
||||
|
||||
Use `assertExists` to verify values aren't null or undefined:
|
||||
|
||||
```ts
|
||||
import { assertExists } from '@tldraw/utils'
|
||||
|
||||
function findShapeById(id: string): Shape {
|
||||
const shape = shapes.find((s) => s.id === id)
|
||||
assertExists(shape, `Shape with id ${id} not found`)
|
||||
// TypeScript knows shape is not undefined
|
||||
return shape
|
||||
}
|
||||
```
|
||||
|
||||
> Tip: Assertions are removed from production builds in most bundlers, but the type narrowing still helps during development.
|
||||
|
||||
## 3. Advanced Features
|
||||
|
||||
### ExecutionQueue: Sequential Task Processing
|
||||
|
||||
When you need to ensure operations happen in order, use `ExecutionQueue`. This is particularly important for database writes, file operations, or any sequence where order matters.
|
||||
|
||||
#### Basic Queue Usage
|
||||
|
||||
You create a queue and push tasks to it:
|
||||
|
||||
```ts
|
||||
import { ExecutionQueue } from '@tldraw/utils'
|
||||
|
||||
const saveQueue = new ExecutionQueue()
|
||||
|
||||
// These will execute in order, not parallel
|
||||
const save1 = saveQueue.push(() => saveToDatabase(data1))
|
||||
const save2 = saveQueue.push(() => saveToDatabase(data2))
|
||||
const save3 = saveQueue.push(() => saveToDatabase(data3))
|
||||
|
||||
// All saves complete in order
|
||||
await Promise.all([save1, save2, save3])
|
||||
```
|
||||
|
||||
#### Task Timing and Cleanup
|
||||
|
||||
You can add a timeout between tasks and clean up when needed:
|
||||
|
||||
```ts
|
||||
// 500ms timeout between tasks
|
||||
const queue = new ExecutionQueue(500)
|
||||
|
||||
// Queue some operations
|
||||
await queue.push(() => heavyComputation1())
|
||||
// 500ms delay automatically added
|
||||
await queue.push(() => heavyComputation2())
|
||||
|
||||
// Clean up when done
|
||||
queue.close()
|
||||
```
|
||||
|
||||
> Note: ExecutionQueue ensures sequential execution even if you don't await individual tasks immediately.
|
||||
|
||||
### WeakCache: Memory-Efficient Caching
|
||||
|
||||
When you need to cache expensive computations tied to object lifecycles, `WeakCache` automatically cleans up when objects are garbage collected.
|
||||
|
||||
#### Caching Expensive Computations
|
||||
|
||||
You cache results tied to specific objects:
|
||||
|
||||
```ts
|
||||
import { WeakCache } from '@tldraw/utils'
|
||||
|
||||
const boundingBoxCache = new WeakCache<Shape, BoundingBox>()
|
||||
|
||||
function getBoundingBox(shape: Shape): BoundingBox {
|
||||
return boundingBoxCache.get(shape, (s) => computeBoundingBox(s))
|
||||
// Expensive computation only runs once per shape
|
||||
}
|
||||
```
|
||||
|
||||
Each time you call `getBoundingBox` with the same shape object, it returns the cached result. When the shape object is garbage collected, the cache entry is automatically cleaned up by the underlying WeakMap.
|
||||
|
||||
#### Multiple Cache Layers
|
||||
|
||||
You can create specialized caches for different computations:
|
||||
|
||||
```ts
|
||||
const geometryCache = new WeakCache<Shape, Geometry>()
|
||||
const selectionCache = new WeakCache<Shape, SelectionBounds>()
|
||||
|
||||
function getGeometry(shape: Shape): Geometry {
|
||||
return geometryCache.get(shape, computeGeometry)
|
||||
}
|
||||
|
||||
function getSelectionBounds(shape: Shape): SelectionBounds {
|
||||
return selectionCache.get(shape, computeSelectionBounds)
|
||||
}
|
||||
```
|
||||
|
||||
> Tip: WeakCache is perfect for any computation where the result depends only on the input object and the object reference acts as a natural cache key.
|
||||
|
||||
### IndexKey System: Fractional Ordering
|
||||
|
||||
The tldraw editor needs to maintain stable ordering of shapes, even when inserting items between existing ones. The IndexKey system provides fractional indexing for this purpose.
|
||||
|
||||
#### Understanding IndexKeys
|
||||
|
||||
An `IndexKey` is a special string that maintains lexicographic order:
|
||||
|
||||
```ts
|
||||
import { ZERO_INDEX_KEY, getIndexBetween, getIndexAbove } from '@tldraw/utils'
|
||||
|
||||
// Start with the zero index
|
||||
let firstIndex = ZERO_INDEX_KEY // 'a0'
|
||||
|
||||
// Get an index above it
|
||||
let secondIndex = getIndexAbove(firstIndex) // 'a1'
|
||||
|
||||
// Insert between them
|
||||
let middleIndex = getIndexBetween(firstIndex, secondIndex) // 'a0V'
|
||||
```
|
||||
|
||||
#### Maintaining Shape Order
|
||||
|
||||
When you need to reorder shapes, use the IndexKey system:
|
||||
|
||||
```ts
|
||||
import { getIndicesBetween, getIndicesAbove, getIndicesBelow, sortByIndex } from '@tldraw/utils'
|
||||
|
||||
// Insert multiple shapes between two existing ones
|
||||
const newIndices = getIndicesBetween(belowShape?.index ?? null, aboveShape?.index ?? null, 3)
|
||||
|
||||
// Get multiple indices above a shape
|
||||
const indicesAbove = getIndicesAbove(lastShape?.index ?? null, 3)
|
||||
|
||||
// Get multiple indices below a shape
|
||||
const indicesBelow = getIndicesBelow(firstShape?.index ?? null, 3)
|
||||
|
||||
const newShapes = shapeData.map((data, i) => ({
|
||||
...data,
|
||||
index: newIndices[i],
|
||||
}))
|
||||
|
||||
// Sort all shapes by their indices
|
||||
const sortedShapes = allShapes.sort(sortByIndex)
|
||||
```
|
||||
|
||||
You can also generate a sequence of indices starting from a specific point:
|
||||
|
||||
```ts
|
||||
import { getIndices } from '@tldraw/utils'
|
||||
|
||||
// Generate 5 indices starting from 'a1' (returns start + n indices)
|
||||
const indices = getIndices(5, 'a1') // ['a1', 'a2', 'a3', 'a4', 'a5', 'a6']
|
||||
```
|
||||
|
||||
The IndexKey system ensures that insertion operations always succeed, even with complex reordering scenarios.
|
||||
|
||||
### Media Helpers: File Processing
|
||||
|
||||
Working with images and videos requires careful handling of formats, dimensions, and browser compatibility. The MediaHelpers provide robust utilities for common media operations.
|
||||
|
||||
The package also exports constants for supported media types:
|
||||
|
||||
```ts
|
||||
import {
|
||||
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||
DEFAULT_SUPPORTED_MEDIA_TYPES,
|
||||
DEFAULT_SUPPORTED_MEDIA_TYPE_LIST,
|
||||
} from '@tldraw/utils'
|
||||
|
||||
console.log(DEFAULT_SUPPORTED_IMAGE_TYPES)
|
||||
// ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml', 'image/gif', 'image/apng', 'image/avif']
|
||||
|
||||
console.log(DEFAULT_SUPPORT_VIDEO_TYPES)
|
||||
// ['video/mp4', 'video/webm', 'video/quicktime']
|
||||
|
||||
console.log(DEFAULT_SUPPORTED_MEDIA_TYPE_LIST)
|
||||
// Comma-separated string of all supported types
|
||||
```
|
||||
|
||||
#### Image Processing
|
||||
|
||||
You can get image dimensions and metadata:
|
||||
|
||||
```ts
|
||||
import { MediaHelpers } from '@tldraw/utils'
|
||||
|
||||
// Get image dimensions from a Blob
|
||||
const { w, h } = await MediaHelpers.getImageSize(imageFile)
|
||||
console.log(`Image is ${w}x${h} pixels`)
|
||||
|
||||
// Load image from URL and get dimensions together
|
||||
const { image, w: width, h: height } = await MediaHelpers.getImageAndDimensions(imageUrl)
|
||||
// Use the loaded image element and dimensions
|
||||
```
|
||||
|
||||
#### Format Detection
|
||||
|
||||
Check media formats and capabilities:
|
||||
|
||||
```ts
|
||||
const isImage = MediaHelpers.isImageType('image/png') // true
|
||||
const isStatic = MediaHelpers.isStaticImageType('image/gif') // false
|
||||
const isAnimated = MediaHelpers.isAnimatedImageType('image/gif') // true
|
||||
const isVector = MediaHelpers.isVectorImageType('image/svg+xml') // true
|
||||
|
||||
// Check if a specific file is animated
|
||||
const animated = await MediaHelpers.isAnimated(gifFile)
|
||||
```
|
||||
|
||||
#### Video Operations
|
||||
|
||||
Process video files and extract frames:
|
||||
|
||||
```ts
|
||||
// Get video dimensions from a Blob
|
||||
const { w, h } = await MediaHelpers.getVideoSize(videoFile)
|
||||
|
||||
// Load a video from URL
|
||||
const videoElement = await MediaHelpers.loadVideo(videoUrl)
|
||||
|
||||
// Extract a frame as a data URL from loaded video element
|
||||
const frameDataUrl = await MediaHelpers.getVideoFrameAsDataUrl(videoElement, 0)
|
||||
```
|
||||
|
||||
## 4. Performance and Optimization
|
||||
|
||||
### Throttling and Debouncing
|
||||
|
||||
High-frequency events like mouse moves and resize events need careful handling to maintain smooth performance.
|
||||
|
||||
#### Frame-Rate Throttling
|
||||
|
||||
Use `fpsThrottle` for smooth 60fps updates:
|
||||
|
||||
```ts
|
||||
import { fpsThrottle } from '@tldraw/utils'
|
||||
|
||||
const updateCanvas = fpsThrottle(() => {
|
||||
// This will run at most once per frame (16.67ms)
|
||||
redrawCanvas()
|
||||
})
|
||||
|
||||
// Call as often as you want - it's automatically throttled
|
||||
document.addEventListener('mousemove', updateCanvas)
|
||||
```
|
||||
|
||||
#### Next-Frame Throttling
|
||||
|
||||
For less critical updates, use `throttleToNextFrame`:
|
||||
|
||||
```ts
|
||||
import { throttleToNextFrame } from '@tldraw/utils'
|
||||
|
||||
const updateUI = throttleToNextFrame(() => {
|
||||
// Batches multiple calls into the next animation frame
|
||||
updateStatusBar()
|
||||
})
|
||||
|
||||
// Returns a cancel function
|
||||
const cancel = updateUI()
|
||||
// Call cancel() to prevent execution if needed
|
||||
```
|
||||
|
||||
> Note: `throttleToNextFrame` batches multiple calls to the same function and executes it only once on the next frame.
|
||||
|
||||
#### Debouncing User Input
|
||||
|
||||
Use `debounce` for operations that should only happen after user input stops:
|
||||
|
||||
```ts
|
||||
import { debounce } from '@tldraw/utils'
|
||||
|
||||
const saveDocument = debounce(async () => {
|
||||
await saveToServer(document)
|
||||
console.log('Document saved!')
|
||||
}, 1000)
|
||||
|
||||
// Call whenever document changes
|
||||
document.addEventListener('input', saveDocument)
|
||||
// Only saves 1 second after user stops typing
|
||||
```
|
||||
|
||||
The debounced function returns a promise and includes a `cancel` method:
|
||||
|
||||
```ts
|
||||
const debouncedSave = debounce(saveToServer, 1000)
|
||||
|
||||
// Start a save operation
|
||||
const savePromise = debouncedSave()
|
||||
|
||||
// Cancel if needed
|
||||
debouncedSave.cancel()
|
||||
```
|
||||
|
||||
### Performance Measurement
|
||||
|
||||
Understanding where time is spent helps optimize tldraw applications.
|
||||
|
||||
#### Performance Tracking
|
||||
|
||||
Use `PerformanceTracker` for detailed timing analysis:
|
||||
|
||||
```ts
|
||||
import { PerformanceTracker } from '@tldraw/utils'
|
||||
|
||||
const tracker = new PerformanceTracker()
|
||||
|
||||
tracker.start('render')
|
||||
renderShapes()
|
||||
tracker.stop()
|
||||
|
||||
tracker.start('interaction')
|
||||
handleUserInteraction()
|
||||
tracker.stop()
|
||||
```
|
||||
|
||||
> Tip: Performance measurements integrate with browser DevTools Performance tab for detailed analysis.
|
||||
|
||||
### Mathematical Operations
|
||||
|
||||
The utils package includes mathematical helpers for interpolation and deterministic randomness.
|
||||
|
||||
#### Linear Interpolation
|
||||
|
||||
Use `lerp` and `invLerp` for smooth transitions:
|
||||
|
||||
```ts
|
||||
import { lerp, invLerp } from '@tldraw/utils'
|
||||
|
||||
// Linear interpolate between two values
|
||||
const interpolated = lerp(0, 100, 0.5) // 50
|
||||
|
||||
// Inverse interpolation - find t given result
|
||||
const t = invLerp(0, 100, 25) // 0.25
|
||||
```
|
||||
|
||||
#### Value Mapping
|
||||
|
||||
Use `modulate` to map values between different ranges:
|
||||
|
||||
```ts
|
||||
import { modulate } from '@tldraw/utils'
|
||||
|
||||
// Map a value from one range to another
|
||||
const result = modulate(5, [0, 10], [0, 100]) // 50
|
||||
|
||||
// With clamping to prevent out-of-bounds results
|
||||
const clamped = modulate(15, [0, 10], [0, 100], true) // 100 (clamped)
|
||||
```
|
||||
|
||||
#### Deterministic Random Numbers
|
||||
|
||||
Use `rng` for repeatable pseudo-random sequences:
|
||||
|
||||
```ts
|
||||
import { rng } from '@tldraw/utils'
|
||||
|
||||
// Create a seeded random number generator
|
||||
const random = rng('my-seed')
|
||||
|
||||
const num1 = random() // Always the same for this seed
|
||||
const num2 = random() // Next number in sequence
|
||||
|
||||
// Different seed produces different sequence
|
||||
const otherRandom = rng('other-seed')
|
||||
const different = otherRandom() // Different value
|
||||
```
|
||||
|
||||
> Tip: The `rng` function returns values between -1 and 1, making it useful for generating consistent random variations. You can normalize to other ranges as needed.
|
||||
|
||||
## 5. Cross-Platform Compatibility
|
||||
|
||||
### Storage Operations
|
||||
|
||||
Browser storage operations need careful error handling for quota limits and privacy modes.
|
||||
|
||||
#### LocalStorage with Error Handling
|
||||
|
||||
The storage utilities handle errors gracefully (note that these functions are marked as `@internal` but are exported for use):
|
||||
|
||||
```ts
|
||||
import { getFromLocalStorage, setInLocalStorage, clearLocalStorage } from '@tldraw/utils'
|
||||
|
||||
// These handle quota exceeded errors and privacy mode
|
||||
setInLocalStorage('user-preferences', JSON.stringify(preferences))
|
||||
const saved = getFromLocalStorage('user-preferences')
|
||||
|
||||
// Clear all data when needed
|
||||
clearLocalStorage()
|
||||
```
|
||||
|
||||
#### SessionStorage Operations
|
||||
|
||||
Session storage works identically:
|
||||
|
||||
```ts
|
||||
import { getFromSessionStorage, setInSessionStorage, clearSessionStorage } from '@tldraw/utils'
|
||||
|
||||
// Temporary data for the current session
|
||||
setInSessionStorage('current-tool', 'select')
|
||||
const currentTool = getFromSessionStorage('current-tool')
|
||||
|
||||
// Clear all session data when needed
|
||||
clearSessionStorage()
|
||||
```
|
||||
|
||||
### File Operations
|
||||
|
||||
The `FileHelpers` class provides utilities for working with files and data conversion.
|
||||
|
||||
#### Data URL Conversion
|
||||
|
||||
Convert between different file formats:
|
||||
|
||||
```ts
|
||||
import { FileHelpers } from '@tldraw/utils'
|
||||
|
||||
// Convert blob to data URL
|
||||
const dataUrl = await FileHelpers.blobToDataUrl(imageBlob)
|
||||
console.log(dataUrl) // "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
|
||||
|
||||
// Convert blob to text
|
||||
const textContent = await FileHelpers.blobToText(textBlob)
|
||||
|
||||
// Fetch URL and convert to different formats
|
||||
const buffer = await FileHelpers.urlToArrayBuffer('https://example.com/image.png')
|
||||
const blob = await FileHelpers.urlToBlob('https://example.com/data.json')
|
||||
const urlAsDataUrl = await FileHelpers.urlToDataUrl('https://example.com/image.svg')
|
||||
```
|
||||
|
||||
#### MIME Type Management
|
||||
|
||||
Modify file MIME types while preserving content:
|
||||
|
||||
```ts
|
||||
// Change MIME type of a Blob
|
||||
const newBlob = FileHelpers.rewriteMimeType(originalBlob, 'image/webp')
|
||||
|
||||
// Change MIME type of a File (preserves filename)
|
||||
const newFile = FileHelpers.rewriteMimeType(originalFile, 'application/json')
|
||||
```
|
||||
|
||||
### URL Processing
|
||||
|
||||
Parsing URLs from user input requires careful validation:
|
||||
|
||||
```ts
|
||||
import { safeParseUrl } from '@tldraw/utils'
|
||||
|
||||
function handleUserUrl(input: string) {
|
||||
const url = safeParseUrl(input)
|
||||
if (url) {
|
||||
console.log(`Valid URL: ${url.href}`)
|
||||
return url
|
||||
} else {
|
||||
console.log('Invalid URL provided')
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Note: `safeParseUrl` returns `undefined` for invalid URLs instead of throwing exceptions.
|
||||
|
||||
## 6. Debugging and Development
|
||||
|
||||
### Timer Management
|
||||
|
||||
The `Timers` class helps manage timeouts and intervals with automatic cleanup:
|
||||
|
||||
```ts
|
||||
import { Timers } from '@tldraw/utils'
|
||||
|
||||
class MyComponent {
|
||||
private timers = new Timers()
|
||||
|
||||
startPeriodicUpdate() {
|
||||
// Set timers with context IDs for organization
|
||||
this.timers.setTimeout('component', () => this.autoSave(), 5000)
|
||||
this.timers.setInterval('component', () => this.refresh(), 1000)
|
||||
this.timers.requestAnimationFrame('component', () => this.render())
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
// Clears all timers for this context
|
||||
this.timers.dispose('component')
|
||||
// Or dispose all contexts
|
||||
this.timers.disposeAll()
|
||||
}
|
||||
|
||||
// You can also get context-bound timer functions
|
||||
getContextTimers() {
|
||||
return this.timers.forContext('component')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Annotation
|
||||
|
||||
You can add debugging context to errors:
|
||||
|
||||
```ts
|
||||
import { annotateError, getErrorAnnotations } from '@tldraw/utils'
|
||||
|
||||
try {
|
||||
performRiskyOperation()
|
||||
} catch (error) {
|
||||
annotateError(error, {
|
||||
tags: { operation: 'shape-creation' },
|
||||
extras: { shapeId: 'shape-123' },
|
||||
})
|
||||
|
||||
// Later, retrieve the context
|
||||
const annotations = getErrorAnnotations(error)
|
||||
console.log('Error context:', annotations)
|
||||
|
||||
throw error // Re-throw with added context
|
||||
}
|
||||
```
|
||||
|
||||
### Utility Functions
|
||||
|
||||
Several utility functions provide common functionality:
|
||||
|
||||
#### Unique ID Generation
|
||||
|
||||
Generate unique identifiers for objects:
|
||||
|
||||
```ts
|
||||
import { uniqueId, mockUniqueId, restoreUniqueId } from '@tldraw/utils'
|
||||
|
||||
// Generate a unique ID
|
||||
const id = uniqueId() // 'VxhUYo3k8GsLmWkjhGq9e'
|
||||
|
||||
// Mock IDs for testing (returns predictable sequence)
|
||||
mockUniqueId(() => 'mock-id-0')
|
||||
const testId1 = uniqueId() // 'mock-id-0'
|
||||
const testId2 = uniqueId() // 'mock-id-0'
|
||||
|
||||
// Restore normal ID generation
|
||||
restoreUniqueId()
|
||||
```
|
||||
|
||||
#### Content Hashing
|
||||
|
||||
Generate consistent hashes for deduplication and caching:
|
||||
|
||||
```ts
|
||||
import { getHashForString, getHashForObject, getHashForBuffer, lns } from '@tldraw/utils'
|
||||
|
||||
// Hash a string
|
||||
const stringHash = getHashForString('hello world') // '1794106052'
|
||||
|
||||
// Hash an object (uses JSON.stringify internally)
|
||||
const objectHash = getHashForObject({ name: 'Alice', age: 30 })
|
||||
|
||||
// Hash binary data
|
||||
const buffer = new ArrayBuffer(8)
|
||||
const bufferHash = getHashForBuffer(buffer)
|
||||
|
||||
// Locale-normalized string for consistent hashing across cultures
|
||||
const normalized = lns('Café') // Handles unicode normalization
|
||||
```
|
||||
|
||||
#### Sorting Utilities
|
||||
|
||||
Sort objects by common properties:
|
||||
|
||||
```ts
|
||||
import { sortById } from '@tldraw/utils'
|
||||
|
||||
const items = [
|
||||
{ id: 'c', name: 'Charlie' },
|
||||
{ id: 'a', name: 'Alice' },
|
||||
{ id: 'b', name: 'Bob' },
|
||||
]
|
||||
|
||||
const sorted = items.sort(sortById)
|
||||
// [{ id: 'a', name: 'Alice' }, { id: 'b', name: 'Bob' }, { id: 'c', name: 'Charlie' }]
|
||||
```
|
||||
|
||||
#### Collection Utilities
|
||||
|
||||
Extract values from iterables:
|
||||
|
||||
```ts
|
||||
import { getFirstFromIterable } from '@tldraw/utils'
|
||||
|
||||
const set = new Set([1, 2, 3])
|
||||
const first = getFirstFromIterable(set)
|
||||
|
||||
const map = new Map([
|
||||
['a', 1],
|
||||
['b', 2],
|
||||
])
|
||||
const firstValue = getFirstFromIterable(map)
|
||||
```
|
||||
|
||||
#### Method Binding Decorator
|
||||
|
||||
The `@bind` decorator ensures methods are properly bound to their class instance:
|
||||
|
||||
```ts
|
||||
import { bind } from '@tldraw/utils'
|
||||
|
||||
class EventHandler {
|
||||
name = 'MyHandler'
|
||||
|
||||
@bind
|
||||
handleClick(event: MouseEvent) {
|
||||
console.log(`${this.name} handled click`) // 'this' is always correct
|
||||
}
|
||||
}
|
||||
|
||||
const handler = new EventHandler()
|
||||
// Safe to use as callback - 'this' binding preserved
|
||||
element.addEventListener('click', handler.handleClick)
|
||||
```
|
||||
|
||||
### Development Helpers
|
||||
|
||||
Some utilities are particularly helpful during development:
|
||||
|
||||
```ts
|
||||
import { warnOnce, exhaustiveSwitchError } from '@tldraw/utils'
|
||||
|
||||
// Warn about deprecated usage, but only once
|
||||
function oldFunction() {
|
||||
warnOnce('oldFunction is deprecated, use newFunction instead')
|
||||
// Continue with implementation...
|
||||
}
|
||||
|
||||
// Ensure switch statements are exhaustive
|
||||
function handleShapeType(shape: Shape) {
|
||||
switch (shape.type) {
|
||||
case 'rect':
|
||||
return handleRect(shape)
|
||||
case 'circle':
|
||||
return handleCircle(shape)
|
||||
default:
|
||||
// TypeScript error if new shape types are added but not handled
|
||||
throw exhaustiveSwitchError(shape)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Value Validation
|
||||
|
||||
Type guards provide runtime checking with TypeScript integration:
|
||||
|
||||
```ts
|
||||
import { isDefined, isNonNull, isNonNullish } from '@tldraw/utils'
|
||||
|
||||
function processUserInput(data: unknown) {
|
||||
if (isDefined(data)) {
|
||||
// TypeScript knows data is not undefined
|
||||
console.log('Data provided:', data)
|
||||
}
|
||||
|
||||
if (isNonNullish(data)) {
|
||||
// TypeScript knows data is not null or undefined
|
||||
return processData(data)
|
||||
}
|
||||
|
||||
throw new Error('Invalid input data')
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Integration Patterns
|
||||
|
||||
### Using Utils in Custom Shapes
|
||||
|
||||
When creating custom shapes, utils provide essential building blocks:
|
||||
|
||||
```ts
|
||||
import { WeakCache, Result, assert, getIndexBetween } from '@tldraw/utils'
|
||||
|
||||
class CustomShapeUtil extends BaseBoxShapeUtil<CustomShape> {
|
||||
private geometryCache = new WeakCache<CustomShape, Geometry>()
|
||||
|
||||
getGeometry(shape: CustomShape): Geometry {
|
||||
return this.geometryCache.get(shape, (s) => {
|
||||
return this.computeComplexGeometry(s)
|
||||
})
|
||||
}
|
||||
|
||||
canReceiveNewChildIndex(shape: CustomShape, droppingShape: Shape): boolean {
|
||||
// Use Result pattern for complex validation
|
||||
const validation = this.validateChildShape(droppingShape)
|
||||
return validation.ok
|
||||
}
|
||||
|
||||
private validateChildShape(shape: Shape): Result<true, string> {
|
||||
if (!this.isCompatibleChild(shape)) {
|
||||
return Result.err(`${shape.type} cannot be a child of CustomShape`)
|
||||
}
|
||||
return Result.ok(true)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Tool Development
|
||||
|
||||
Tools benefit from utils for state management and performance:
|
||||
|
||||
```ts
|
||||
import { debounce, throttleToNextFrame, ExecutionQueue, partition } from '@tldraw/utils'
|
||||
|
||||
class CustomTool extends StateNode {
|
||||
private updateQueue = new ExecutionQueue(16) // 60fps limit
|
||||
private debouncedSave = debounce(() => this.saveToolState(), 1000)
|
||||
|
||||
onPointerMove = throttleToNextFrame((info: TLPointerEventInfo) => {
|
||||
this.updateQueue.push(() => this.handleMove(info))
|
||||
this.debouncedSave()
|
||||
})
|
||||
|
||||
private handleMove(info: TLPointerEventInfo) {
|
||||
const shapes = this.editor.getCurrentPageShapes()
|
||||
const [movingShapes, staticShapes] = partition(shapes, (shape) => this.isShapeMoving(shape))
|
||||
|
||||
// Update only the shapes that need it
|
||||
this.updateMovingShapes(movingShapes)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling in Applications
|
||||
|
||||
Robust applications use Result patterns throughout:
|
||||
|
||||
```ts
|
||||
import { Result, assertExists } from '@tldraw/utils'
|
||||
|
||||
class DocumentManager {
|
||||
async loadDocument(id: string): Promise<Result<Document, string>> {
|
||||
try {
|
||||
const data = await this.storage.load(id)
|
||||
assertExists(data, `Document ${id} not found`)
|
||||
|
||||
const parseResult = this.parseDocument(data)
|
||||
if (!parseResult.ok) {
|
||||
return Result.err(`Failed to parse document: ${parseResult.error}`)
|
||||
}
|
||||
|
||||
return Result.ok(parseResult.value)
|
||||
} catch (error) {
|
||||
return Result.err(`Storage error: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
private parseDocument(data: unknown): Result<Document, string> {
|
||||
// Detailed parsing with Result pattern...
|
||||
return Result.ok(validDocument)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Benefits
|
||||
|
||||
The `@tldraw/utils` package provides:
|
||||
|
||||
**Type Safety**: Every utility maintains and enhances TypeScript's type information, preventing runtime errors and improving developer experience.
|
||||
|
||||
**Performance**: Optimized implementations with caching, throttling, and memory management prevent performance bottlenecks in complex applications.
|
||||
|
||||
**Reliability**: Comprehensive error handling with Result patterns and assertions creates predictable, debuggable applications.
|
||||
|
||||
**Cross-Platform**: Consistent behavior across browsers, Node.js, and other JavaScript environments with appropriate polyfills and fallbacks.
|
||||
|
||||
These utilities form the foundation that makes tldraw's complex canvas operations feel smooth and reliable. Whether you're building custom shapes, tools, or integrating tldraw into larger applications, these utilities provide the building blocks for professional-grade functionality.
|
||||
9
packages/utils/LICENSE.md
Normal file
9
packages/utils/LICENSE.md
Normal file
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 tldraw Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
31
packages/utils/README.md
Normal file
31
packages/utils/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
## @tldraw/utils
|
||||
|
||||
Utility functions used by tldraw.
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation for the most recent release can be found on [tldraw.dev/docs](https://tldraw.dev/docs), including [reference docs](https://tldraw.dev/reference/editor/Editor). Our release notes can be found [here](https://tldraw.dev/releases).
|
||||
|
||||
For more agent-friendly docs, see our [LLMs.txt](https://tldraw.dev/llms.txt).
|
||||
|
||||
A `DOCS.md` file is included alongside this README in the published package, with detailed API documentation and usage examples.
|
||||
|
||||
## Contribution
|
||||
|
||||
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
||||
|
||||
## License
|
||||
|
||||
This project is provided under the MIT License found [here](https://github.com/tldraw/tldraw/blob/main/packages/utils/LICENSE.md). The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
|
||||
|
||||
## Trademarks
|
||||
|
||||
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
|
||||
|
||||
## Contact
|
||||
|
||||
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
|
||||
|
||||
## Community
|
||||
|
||||
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
|
||||
4
packages/utils/api-extractor.json
Normal file
4
packages/utils/api-extractor.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
"extends": "../../internal/config/api-extractor.json"
|
||||
}
|
||||
573
packages/utils/api-report.api.md
Normal file
573
packages/utils/api-report.api.md
Normal file
@@ -0,0 +1,573 @@
|
||||
## API Report File for "@tldraw/utils"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
|
||||
import { default as isEqual } from 'lodash.isequal';
|
||||
import { default as isEqualWith } from 'lodash.isequalwith';
|
||||
import { default as throttle } from 'lodash.throttle';
|
||||
import { default as uniq } from 'lodash.uniq';
|
||||
|
||||
// @internal
|
||||
export function annotateError(error: unknown, annotations: Partial<ErrorAnnotations>): void;
|
||||
|
||||
// @internal
|
||||
export function areArraysShallowEqual<T>(arr1: readonly T[], arr2: readonly T[]): boolean;
|
||||
|
||||
// @internal
|
||||
export function areObjectsShallowEqual<T extends object>(obj1: T, obj2: T): boolean;
|
||||
|
||||
// @internal
|
||||
const assert_2: (value: unknown, message?: string) => asserts value;
|
||||
export { assert_2 as assert }
|
||||
|
||||
// @internal
|
||||
export const assertExists: <T>(value: T, message?: string | undefined) => NonNullable<T>;
|
||||
|
||||
// @public
|
||||
export type Awaitable<T> = PromiseLike<T> | T;
|
||||
|
||||
// @public
|
||||
export function bind<T extends (...args: any[]) => any>(target: object, propertyKey: string, descriptor: TypedPropertyDescriptor<T>): TypedPropertyDescriptor<T>;
|
||||
|
||||
// @public
|
||||
export function bind<This extends object, T extends (...args: any[]) => any>(originalMethod: T, context: ClassMethodDecoratorContext<This, T>): void;
|
||||
|
||||
// @internal
|
||||
export function clearLocalStorage(): void;
|
||||
|
||||
// @internal
|
||||
export function clearSessionStorage(): void;
|
||||
|
||||
// @internal
|
||||
export function compact<T>(arr: T[]): NonNullable<T>[];
|
||||
|
||||
// @public
|
||||
export function debounce<T extends unknown[], U>(callback: (...args: T) => Awaitable<U>, wait: number): {
|
||||
(...args: T): Promise<U>;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function dedupe<T>(input: T[], equals?: (a: any, b: any) => boolean): T[];
|
||||
|
||||
// @public
|
||||
export const DEFAULT_SUPPORT_VIDEO_TYPES: readonly ("video/mp4" | "video/quicktime" | "video/webm")[];
|
||||
|
||||
// @public
|
||||
export const DEFAULT_SUPPORTED_IMAGE_TYPES: readonly ("image/apng" | "image/avif" | "image/gif" | "image/jpeg" | "image/png" | "image/svg+xml" | "image/webp")[];
|
||||
|
||||
// @public
|
||||
export const DEFAULT_SUPPORTED_MEDIA_TYPE_LIST: string;
|
||||
|
||||
// @public
|
||||
export const DEFAULT_SUPPORTED_MEDIA_TYPES: readonly ("image/apng" | "image/avif" | "image/gif" | "image/jpeg" | "image/png" | "image/svg+xml" | "image/webp" | "video/mp4" | "video/quicktime" | "video/webm")[];
|
||||
|
||||
// @internal
|
||||
export function deleteFromLocalStorage(key: string): void;
|
||||
|
||||
// @internal
|
||||
export function deleteFromSessionStorage(key: string): void;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ErrorAnnotations {
|
||||
// (undocumented)
|
||||
extras: Record<string, unknown>;
|
||||
// (undocumented)
|
||||
tags: Record<string, bigint | boolean | null | number | string | symbol | undefined>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface ErrorResult<E> {
|
||||
// (undocumented)
|
||||
readonly error: E;
|
||||
// (undocumented)
|
||||
readonly ok: false;
|
||||
}
|
||||
|
||||
// @internal
|
||||
export class ExecutionQueue {
|
||||
constructor(timeout?: number | undefined);
|
||||
close(): void;
|
||||
isEmpty(): boolean;
|
||||
push<T>(task: () => T): Promise<Awaited<T>>;
|
||||
}
|
||||
|
||||
// @internal
|
||||
export function exhaustiveSwitchError(value: never, property?: string): never;
|
||||
|
||||
// @public
|
||||
export type Expand<T> = T extends infer O ? {
|
||||
[K in keyof O]: O[K];
|
||||
} : never;
|
||||
|
||||
// @internal
|
||||
function fetch_2(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
||||
export { fetch_2 as fetch }
|
||||
|
||||
// @public
|
||||
export class FileHelpers {
|
||||
static blobToDataUrl(file: Blob): Promise<string>;
|
||||
static blobToText(file: Blob): Promise<string>;
|
||||
static rewriteMimeType(blob: Blob, newMimeType: string): Blob;
|
||||
// (undocumented)
|
||||
static rewriteMimeType(blob: File, newMimeType: string): File;
|
||||
static urlToArrayBuffer(url: string): Promise<ArrayBuffer>;
|
||||
static urlToBlob(url: string): Promise<Blob>;
|
||||
static urlToDataUrl(url: string): Promise<string>;
|
||||
}
|
||||
|
||||
// @internal
|
||||
export function filterEntries<Key extends string, Value>(object: {
|
||||
[K in Key]: Value;
|
||||
}, predicate: (key: Key, value: Value) => boolean): {
|
||||
[K in Key]: Value;
|
||||
};
|
||||
|
||||
// @public
|
||||
export class FpsScheduler {
|
||||
constructor(targetFps?: number);
|
||||
fpsThrottle(fn: {
|
||||
(): void;
|
||||
cancel?(): void;
|
||||
}): {
|
||||
(): void;
|
||||
cancel?(): void;
|
||||
};
|
||||
throttleToNextFrame(fn: () => void): () => void;
|
||||
// (undocumented)
|
||||
updateTargetFps(targetFps: number): void;
|
||||
}
|
||||
|
||||
// @internal
|
||||
export function fpsThrottle(fn: {
|
||||
(): void;
|
||||
cancel?(): void;
|
||||
}): {
|
||||
(): void;
|
||||
cancel?(): void;
|
||||
};
|
||||
|
||||
// @internal
|
||||
export function getChangedKeys<T extends object>(obj1: T, obj2: T): (keyof T)[];
|
||||
|
||||
// @internal
|
||||
export function getErrorAnnotations(error: Error): ErrorAnnotations;
|
||||
|
||||
// @public
|
||||
export function getFirstCharacter(str: string): string;
|
||||
|
||||
// @public
|
||||
export function getFirstFromIterable<T = unknown>(set: Map<any, T> | Set<T>): T;
|
||||
|
||||
// @internal
|
||||
export function getFromLocalStorage(key: string): null | string;
|
||||
|
||||
// @internal
|
||||
export function getFromSessionStorage(key: string): null | string;
|
||||
|
||||
// @public
|
||||
export function getHashForBuffer(buffer: ArrayBuffer): string;
|
||||
|
||||
// @public
|
||||
export function getHashForObject(obj: any): string;
|
||||
|
||||
// @public
|
||||
export function getHashForString(string: string): string;
|
||||
|
||||
// @public
|
||||
export function getIndexAbove(below?: IndexKey | null | undefined): IndexKey;
|
||||
|
||||
// @public
|
||||
export function getIndexBelow(above?: IndexKey | null | undefined): IndexKey;
|
||||
|
||||
// @public
|
||||
export function getIndexBetween(below: IndexKey | null | undefined, above: IndexKey | null | undefined): IndexKey;
|
||||
|
||||
// @public
|
||||
export function getIndices(n: number, start?: IndexKey): IndexKey[];
|
||||
|
||||
// @public
|
||||
export function getIndicesAbove(below: IndexKey | null | undefined, n: number): IndexKey[];
|
||||
|
||||
// @public
|
||||
export function getIndicesBelow(above: IndexKey | null | undefined, n: number): IndexKey[];
|
||||
|
||||
// @public
|
||||
export function getIndicesBetween(below: IndexKey | null | undefined, above: IndexKey | null | undefined, n: number): IndexKey[];
|
||||
|
||||
// @internal
|
||||
export function getOwnProperty<K extends string, V>(obj: Partial<Record<K, V>>, key: K): undefined | V;
|
||||
|
||||
// @internal (undocumented)
|
||||
export function getOwnProperty<O extends object>(obj: O, key: string): O[keyof O] | undefined;
|
||||
|
||||
// @internal (undocumented)
|
||||
export function getOwnProperty(obj: object, key: string): unknown;
|
||||
|
||||
// @internal
|
||||
export function groupBy<K extends string, V>(array: ReadonlyArray<V>, keySelector: (value: V) => K): Record<K, V[]>;
|
||||
|
||||
// @internal
|
||||
export function hasOwnProperty(obj: object, key: string): boolean;
|
||||
|
||||
// @internal
|
||||
function Image_2(width?: number, height?: number): HTMLImageElement;
|
||||
export { Image_2 as Image }
|
||||
|
||||
// @public
|
||||
export type IndexKey = string & {
|
||||
__brand: 'indexKey';
|
||||
};
|
||||
|
||||
// @public
|
||||
export function invLerp(a: number, b: number, t: number): number;
|
||||
|
||||
// @public
|
||||
export function isDefined<T>(value: T): value is typeof value extends undefined ? never : T;
|
||||
|
||||
export { isEqual }
|
||||
|
||||
// @internal
|
||||
export function isEqualAllowingForFloatingPointErrors(obj1: object, obj2: object, threshold?: number): boolean;
|
||||
|
||||
export { isEqualWith }
|
||||
|
||||
// @internal
|
||||
export const isNativeStructuredClone: boolean;
|
||||
|
||||
// @public
|
||||
export function isNonNull<T>(value: T): value is typeof value extends null ? never : T;
|
||||
|
||||
// @public
|
||||
export function isNonNullish<T>(value: T): value is typeof value extends undefined ? never : typeof value extends null ? never : T;
|
||||
|
||||
// @public
|
||||
export function iterateGraphemes(str: string): IterableIterator<string>;
|
||||
|
||||
// @public
|
||||
export type JsonArray = JsonValue[];
|
||||
|
||||
// @public
|
||||
export interface JsonObject {
|
||||
// (undocumented)
|
||||
[key: string]: JsonValue | undefined;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type JsonPrimitive = boolean | null | number | string;
|
||||
|
||||
// @public
|
||||
export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
|
||||
|
||||
// @internal
|
||||
export function last<T>(arr: readonly T[]): T | undefined;
|
||||
|
||||
// @public
|
||||
export function lerp(a: number, b: number, t: number): number;
|
||||
|
||||
// @public
|
||||
export function lns(str: string): string;
|
||||
|
||||
// @public
|
||||
export class LruCache<K, V> {
|
||||
constructor(maxSize: number);
|
||||
// (undocumented)
|
||||
delete(key: K): boolean;
|
||||
// (undocumented)
|
||||
get(key: K): undefined | V;
|
||||
// (undocumented)
|
||||
has(key: K): boolean;
|
||||
// (undocumented)
|
||||
set(key: K, value: V): void;
|
||||
// (undocumented)
|
||||
get size(): number;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type MakeUndefinedOptional<T extends object> = Expand<{
|
||||
[P in {
|
||||
[K in keyof T]: undefined extends T[K] ? never : K;
|
||||
}[keyof T]]: T[P];
|
||||
} & {
|
||||
[P in {
|
||||
[K in keyof T]: undefined extends T[K] ? K : never;
|
||||
}[keyof T]]?: T[P];
|
||||
}>;
|
||||
|
||||
// @internal
|
||||
export function mapObjectMapValues<Key extends string, ValueBefore, ValueAfter>(object: {
|
||||
readonly [K in Key]: ValueBefore;
|
||||
}, mapper: (key: Key, value: ValueBefore) => ValueAfter): {
|
||||
[K in Key]: ValueAfter;
|
||||
};
|
||||
|
||||
// @internal
|
||||
export function maxBy<T>(arr: readonly T[], fn: (item: T) => number): T | undefined;
|
||||
|
||||
// @internal
|
||||
export function measureAverageDuration(_target: any, propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor;
|
||||
|
||||
// @internal
|
||||
export function measureCbDuration(name: string, cb: () => any): any;
|
||||
|
||||
// @internal
|
||||
export function measureDuration(_target: any, propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor;
|
||||
|
||||
// @public
|
||||
export class MediaHelpers {
|
||||
static getImageAndDimensions(src: string, doc?: Document): Promise<{
|
||||
h: number;
|
||||
image: HTMLImageElement;
|
||||
w: number;
|
||||
}>;
|
||||
static getImageSize(blob: Blob, doc?: Document): Promise<{
|
||||
h: number;
|
||||
pixelRatio: number;
|
||||
w: number;
|
||||
}>;
|
||||
static getVideoFrameAsDataUrl(video: HTMLVideoElement, time?: number): Promise<string>;
|
||||
static getVideoSize(blob: Blob, doc?: Document): Promise<{
|
||||
h: number;
|
||||
w: number;
|
||||
}>;
|
||||
static isAnimated(file: Blob): Promise<boolean>;
|
||||
static isAnimatedImageType(mimeType: null | string): boolean;
|
||||
static isImageType(mimeType: string): boolean;
|
||||
static isStaticImageType(mimeType: null | string): boolean;
|
||||
static isVectorImageType(mimeType: null | string): boolean;
|
||||
static loadVideo(src: string, doc?: Document): Promise<HTMLVideoElement>;
|
||||
static usingObjectURL<T>(blob: Blob, fn: (url: string) => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
// @internal
|
||||
export function mergeArraysAndReplaceDefaults<const Key extends string, T extends {
|
||||
[K in Key]: string;
|
||||
}>(key: Key, customEntries: readonly T[], defaults: readonly T[]): T[];
|
||||
|
||||
// @internal
|
||||
export function minBy<T>(arr: readonly T[], fn: (item: T) => number): T | undefined;
|
||||
|
||||
// @internal
|
||||
export function mockUniqueId(fn: (size?: number) => string): void;
|
||||
|
||||
// @public
|
||||
export function modulate(value: number, rangeA: number[], rangeB: number[], clamp?: boolean): number;
|
||||
|
||||
// @internal
|
||||
export function noop(): void;
|
||||
|
||||
// @internal
|
||||
export function objectMapEntries<Obj extends object>(object: Obj): Array<[keyof Obj, Obj[keyof Obj]]>;
|
||||
|
||||
// @internal
|
||||
export function objectMapEntriesIterable<Key extends string, Value>(object: {
|
||||
[K in Key]: Value;
|
||||
}): IterableIterator<[Key, Value]>;
|
||||
|
||||
// @internal
|
||||
export function objectMapFromEntries<Key extends string, Value>(entries: ReadonlyArray<readonly [Key, Value]>): {
|
||||
[K in Key]: Value;
|
||||
};
|
||||
|
||||
// @internal
|
||||
export function objectMapKeys<Key extends string>(object: {
|
||||
readonly [K in Key]: unknown;
|
||||
}): Array<Key>;
|
||||
|
||||
// @internal
|
||||
export function objectMapValues<Key extends string, Value>(object: {
|
||||
[K in Key]: Value;
|
||||
}): Array<Value>;
|
||||
|
||||
// @public
|
||||
export interface OkResult<T> {
|
||||
// (undocumented)
|
||||
readonly ok: true;
|
||||
// (undocumented)
|
||||
readonly value: T;
|
||||
}
|
||||
|
||||
// @internal
|
||||
export function omit(obj: Record<string, unknown>, keys: ReadonlyArray<string>): Record<string, unknown>;
|
||||
|
||||
// @internal
|
||||
export function omitFromStackTrace<Args extends Array<unknown>, Return>(fn: (...args: Args) => Return): (...args: Args) => Return;
|
||||
|
||||
// @internal
|
||||
export function partition<T>(arr: T[], predicate: (item: T) => boolean): [T[], T[]];
|
||||
|
||||
// @public
|
||||
export class PerformanceTracker {
|
||||
isStarted(): boolean;
|
||||
recordFrame: () => void;
|
||||
start(name: string): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class PngHelpers {
|
||||
static findChunk(view: DataView, type: string): {
|
||||
dataOffset: number;
|
||||
size: number;
|
||||
start: number;
|
||||
};
|
||||
static getChunkType(view: DataView, offset: number): string;
|
||||
static isPng(view: DataView, offset: number): boolean;
|
||||
static parsePhys(view: DataView, offset: number): {
|
||||
ppux: number;
|
||||
ppuy: number;
|
||||
unit: number;
|
||||
};
|
||||
static readChunks(view: DataView, offset?: number): Record<string, {
|
||||
dataOffset: number;
|
||||
size: number;
|
||||
start: number;
|
||||
}>;
|
||||
static setPhysChunk(view: DataView, dpr?: number, options?: BlobPropertyBag): Blob;
|
||||
}
|
||||
|
||||
// @internal
|
||||
export function promiseWithResolve<T>(): Promise<T> & {
|
||||
reject(reason?: any): void;
|
||||
resolve(value: T): void;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type RecursivePartial<T> = {
|
||||
[P in keyof T]?: RecursivePartial<T[P]>;
|
||||
};
|
||||
|
||||
// @internal
|
||||
export function registerTldrawLibraryVersion(name?: string, version?: string, modules?: string): void;
|
||||
|
||||
// @internal
|
||||
type Required_2<T, K extends keyof T> = Expand<Omit<T, K> & {
|
||||
[P in K]-?: T[P];
|
||||
}>;
|
||||
export { Required_2 as Required }
|
||||
|
||||
// @internal
|
||||
export function restoreUniqueId(): void;
|
||||
|
||||
// @public
|
||||
export type Result<T, E> = ErrorResult<E> | OkResult<T>;
|
||||
|
||||
// @public
|
||||
export const Result: {
|
||||
err<E>(error: E): ErrorResult<E>;
|
||||
ok<T>(value: T): OkResult<T>;
|
||||
all<T>(results: Result<T, any>[]): Result<T[], any>;
|
||||
};
|
||||
|
||||
// @internal
|
||||
export function retry<T>(fn: (args: {
|
||||
attempt: number;
|
||||
remaining: number;
|
||||
total: number;
|
||||
}) => Promise<T>, { attempts, waitDuration, abortSignal, matchError, }?: {
|
||||
abortSignal?: AbortSignal;
|
||||
attempts?: number;
|
||||
matchError?(error: unknown): boolean;
|
||||
waitDuration?: number;
|
||||
}): Promise<T>;
|
||||
|
||||
// @public
|
||||
export function rng(seed?: string): () => number;
|
||||
|
||||
// @public
|
||||
export function rotateArray<T>(arr: T[], offset: number): T[];
|
||||
|
||||
// @public
|
||||
export function safeParseUrl(url: string, baseUrl?: string | URL): undefined | URL;
|
||||
|
||||
// @internal
|
||||
export function setInLocalStorage(key: string, value: string): void;
|
||||
|
||||
// @internal
|
||||
export function setInSessionStorage(key: string, value: string): void;
|
||||
|
||||
// @internal
|
||||
export function sleep(ms: number): Promise<void>;
|
||||
|
||||
// @public
|
||||
export function sortById<T extends {
|
||||
id: any;
|
||||
}>(a: T, b: T): -1 | 1;
|
||||
|
||||
// @public
|
||||
export function sortByIndex<T extends {
|
||||
index: IndexKey;
|
||||
}>(a: T, b: T): -1 | 0 | 1;
|
||||
|
||||
// @public
|
||||
export function sortByMaybeIndex<T extends {
|
||||
index?: IndexKey | null;
|
||||
}>(a: T, b: T): -1 | 0 | 1;
|
||||
|
||||
// @internal
|
||||
export function stringEnum<T extends string>(...values: T[]): {
|
||||
[K in T]: K;
|
||||
};
|
||||
|
||||
// @internal
|
||||
export const STRUCTURED_CLONE_OBJECT_PROTOTYPE: any;
|
||||
|
||||
// @public
|
||||
const structuredClone_2: <T>(i: T) => T;
|
||||
export { structuredClone_2 as structuredClone }
|
||||
|
||||
export { throttle }
|
||||
|
||||
// @internal
|
||||
export function throttleToNextFrame(fn: () => void): () => void;
|
||||
|
||||
// @public
|
||||
export class Timers {
|
||||
constructor();
|
||||
cancelAnimationFrame(contextId: string, id: number | undefined): void;
|
||||
clearInterval(contextId: string, id: number | undefined): void;
|
||||
clearTimeout(contextId: string, id: number | undefined): void;
|
||||
dispose(contextId: string): void;
|
||||
disposeAll(): void;
|
||||
forContext(contextId: string): {
|
||||
cancelAnimationFrame: (id: number | undefined) => void;
|
||||
clearInterval: (id: number | undefined) => void;
|
||||
clearTimeout: (id: number | undefined) => void;
|
||||
dispose: () => void;
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => number;
|
||||
setInterval: (handler: TimerHandler, timeout?: number, ...args: any[]) => number;
|
||||
setTimeout: (handler: TimerHandler, timeout?: number, ...args: any[]) => number;
|
||||
};
|
||||
requestAnimationFrame(contextId: string, callback: FrameRequestCallback): number;
|
||||
setInterval(contextId: string, handler: TimerHandler, timeout?: number, ...args: any[]): number;
|
||||
setTimeout(contextId: string, handler: TimerHandler, timeout?: number, ...args: any[]): number;
|
||||
}
|
||||
|
||||
export { uniq }
|
||||
|
||||
// @public
|
||||
export function uniqueId(size?: number): string;
|
||||
|
||||
// @internal
|
||||
export function validateIndexKey(index: string): asserts index is IndexKey;
|
||||
|
||||
// @internal
|
||||
export function warnDeprecatedGetter(name: string): void;
|
||||
|
||||
// @internal
|
||||
export function warnOnce(message: string): void;
|
||||
|
||||
// @public
|
||||
export class WeakCache<K extends object, V> {
|
||||
get<P extends K>(item: P, cb: (item: P) => V): NonNullable<V>;
|
||||
items: WeakMap<K, V>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const ZERO_INDEX_KEY: IndexKey;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
||||
```
|
||||
68
packages/utils/package.json
Normal file
68
packages/utils/package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@tldraw/utils",
|
||||
"description": "tldraw infinite canvas SDK (private utilities).",
|
||||
"version": "5.4.2",
|
||||
"author": {
|
||||
"name": "tldraw Inc.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"homepage": "https://tldraw.dev",
|
||||
"license": "MIT",
|
||||
"tldraw_product": {
|
||||
"stableId": "tldraw:sdk-core",
|
||||
"name": "SDK",
|
||||
"type": "product",
|
||||
"premium": false
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tldraw/tldraw"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/tldraw/tldraw/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"tldraw",
|
||||
"sdk",
|
||||
"drawing",
|
||||
"app",
|
||||
"development",
|
||||
"whiteboard",
|
||||
"canvas",
|
||||
"infinite"
|
||||
],
|
||||
"/* NOTE */": "These `main` and `types` fields are rewritten by the build script. They are not the actual values we publish",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./.tsbuild/index.d.ts",
|
||||
"type": "module",
|
||||
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
|
||||
"files": [],
|
||||
"scripts": {
|
||||
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
||||
"test": "yarn run -T vitest --passWithNoTests",
|
||||
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
|
||||
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
|
||||
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
|
||||
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
|
||||
"postpack": "../../internal/scripts/postpack.sh",
|
||||
"pack-tarball": "yarn pack",
|
||||
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lodash.isequalwith": "^4.4.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"lodash.uniq": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash.isequal": "^4.5.8",
|
||||
"@types/lodash.isequalwith": "^4.4.9",
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/lodash.uniq": "^4.5.9",
|
||||
"lazyrepo": "0.0.0-alpha.27",
|
||||
"vitest": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
}
|
||||
3
packages/utils/setupTests.js
Normal file
3
packages/utils/setupTests.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Text encoding/decoding polyfills
|
||||
global.TextEncoder = require('util').TextEncoder
|
||||
global.TextDecoder = require('util').TextDecoder
|
||||
3
packages/utils/setupVitest.js
Normal file
3
packages/utils/setupVitest.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Text encoding/decoding polyfills
|
||||
global.TextEncoder = require('util').TextEncoder
|
||||
global.TextDecoder = require('util').TextDecoder
|
||||
121
packages/utils/src/index.ts
Normal file
121
packages/utils/src/index.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { registerTldrawLibraryVersion } from './lib/version'
|
||||
|
||||
export { default as isEqual } from 'lodash.isequal'
|
||||
export { default as isEqualWith } from 'lodash.isequalwith'
|
||||
export { default as throttle } from 'lodash.throttle'
|
||||
export { default as uniq } from 'lodash.uniq'
|
||||
export {
|
||||
areArraysShallowEqual,
|
||||
compact,
|
||||
dedupe,
|
||||
last,
|
||||
maxBy,
|
||||
mergeArraysAndReplaceDefaults,
|
||||
minBy,
|
||||
partition,
|
||||
rotateArray,
|
||||
} from './lib/array'
|
||||
export { bind } from './lib/bind'
|
||||
export { WeakCache } from './lib/cache'
|
||||
export {
|
||||
assert,
|
||||
assertExists,
|
||||
exhaustiveSwitchError,
|
||||
promiseWithResolve,
|
||||
Result,
|
||||
sleep,
|
||||
type ErrorResult,
|
||||
type OkResult,
|
||||
} from './lib/control'
|
||||
export { debounce } from './lib/debounce'
|
||||
export { annotateError, getErrorAnnotations, type ErrorAnnotations } from './lib/error'
|
||||
export { ExecutionQueue } from './lib/ExecutionQueue'
|
||||
export { FileHelpers } from './lib/file'
|
||||
export { noop, omitFromStackTrace } from './lib/function'
|
||||
export { getHashForBuffer, getHashForObject, getHashForString, lns } from './lib/hash'
|
||||
export { mockUniqueId, restoreUniqueId, uniqueId } from './lib/id'
|
||||
export { getFirstFromIterable } from './lib/iterable'
|
||||
export { LruCache } from './lib/LruCache'
|
||||
export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from './lib/json-value'
|
||||
export {
|
||||
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||
DEFAULT_SUPPORTED_MEDIA_TYPE_LIST,
|
||||
DEFAULT_SUPPORTED_MEDIA_TYPES,
|
||||
MediaHelpers,
|
||||
} from './lib/media/media'
|
||||
export { PngHelpers } from './lib/media/png'
|
||||
export { fetch, Image } from './lib/network'
|
||||
export { invLerp, lerp, modulate, rng } from './lib/number'
|
||||
export {
|
||||
areObjectsShallowEqual,
|
||||
filterEntries,
|
||||
getChangedKeys,
|
||||
getOwnProperty,
|
||||
groupBy,
|
||||
hasOwnProperty,
|
||||
isEqualAllowingForFloatingPointErrors,
|
||||
mapObjectMapValues,
|
||||
objectMapEntries,
|
||||
objectMapEntriesIterable,
|
||||
objectMapFromEntries,
|
||||
objectMapKeys,
|
||||
objectMapValues,
|
||||
omit,
|
||||
} from './lib/object'
|
||||
export { measureAverageDuration, measureCbDuration, measureDuration } from './lib/perf'
|
||||
export { PerformanceTracker } from './lib/PerformanceTracker'
|
||||
export {
|
||||
getIndexAbove,
|
||||
getIndexBelow,
|
||||
getIndexBetween,
|
||||
getIndices,
|
||||
getIndicesAbove,
|
||||
getIndicesBelow,
|
||||
getIndicesBetween,
|
||||
sortByIndex,
|
||||
sortByMaybeIndex,
|
||||
validateIndexKey,
|
||||
ZERO_INDEX_KEY,
|
||||
type IndexKey,
|
||||
} from './lib/reordering'
|
||||
export { retry } from './lib/retry'
|
||||
export { sortById } from './lib/sort'
|
||||
export {
|
||||
clearLocalStorage,
|
||||
clearSessionStorage,
|
||||
deleteFromLocalStorage,
|
||||
deleteFromSessionStorage,
|
||||
getFromLocalStorage,
|
||||
getFromSessionStorage,
|
||||
setInLocalStorage,
|
||||
setInSessionStorage,
|
||||
} from './lib/storage'
|
||||
export { getFirstCharacter, iterateGraphemes } from './lib/string'
|
||||
export { stringEnum } from './lib/stringEnum'
|
||||
export { FpsScheduler, fpsThrottle, throttleToNextFrame } from './lib/throttle'
|
||||
export { Timers } from './lib/timers'
|
||||
export {
|
||||
type Awaitable,
|
||||
type Expand,
|
||||
type MakeUndefinedOptional,
|
||||
type RecursivePartial,
|
||||
type Required,
|
||||
} from './lib/types'
|
||||
export { safeParseUrl } from './lib/url'
|
||||
export {
|
||||
isDefined,
|
||||
isNativeStructuredClone,
|
||||
isNonNull,
|
||||
isNonNullish,
|
||||
STRUCTURED_CLONE_OBJECT_PROTOTYPE,
|
||||
structuredClone,
|
||||
} from './lib/value'
|
||||
export { registerTldrawLibraryVersion } from './lib/version'
|
||||
export { warnDeprecatedGetter, warnOnce } from './lib/warn'
|
||||
|
||||
registerTldrawLibraryVersion(
|
||||
(globalThis as any).TLDRAW_LIBRARY_NAME,
|
||||
(globalThis as any).TLDRAW_LIBRARY_VERSION,
|
||||
(globalThis as any).TLDRAW_LIBRARY_MODULES
|
||||
)
|
||||
220
packages/utils/src/lib/ExecutionQueue.test.ts
Normal file
220
packages/utils/src/lib/ExecutionQueue.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { promiseWithResolve, sleep } from './control'
|
||||
import { ExecutionQueue } from './ExecutionQueue'
|
||||
|
||||
const tick = () => Promise.resolve()
|
||||
|
||||
describe('ExecutionQueue', () => {
|
||||
it('executes tasks sequentially', async () => {
|
||||
const queue = new ExecutionQueue()
|
||||
let numExecutions = 0
|
||||
|
||||
await queue.push(async () => {
|
||||
await sleep(1)
|
||||
numExecutions++
|
||||
})
|
||||
|
||||
//number of exeutions is 1 because we await the first task
|
||||
expect(numExecutions).toBe(1)
|
||||
|
||||
// push without awaiting
|
||||
queue.push(async () => {
|
||||
await sleep(1)
|
||||
numExecutions++
|
||||
})
|
||||
|
||||
// number of exeutions is still 1 because we didn't await the second task
|
||||
expect(numExecutions).toBe(1)
|
||||
|
||||
await sleep(5) // wait for the second task to finish
|
||||
|
||||
// number of exeutions is 2 because we waited for the second task to finish
|
||||
expect(numExecutions).toBe(2)
|
||||
})
|
||||
|
||||
it('queues tasks when one is already executing', async () => {
|
||||
const completions: string[] = []
|
||||
const promise = promiseWithResolve()
|
||||
const queue = new ExecutionQueue()
|
||||
|
||||
const persistPromiseA = queue.push(async () => {
|
||||
await promise
|
||||
completions.push('A')
|
||||
})
|
||||
|
||||
const persistPromiseB = queue.push(async () => {
|
||||
completions.push('B')
|
||||
})
|
||||
|
||||
await tick()
|
||||
expect(completions).toEqual([])
|
||||
|
||||
promise.resolve(undefined)
|
||||
await persistPromiseA
|
||||
expect(completions).toEqual(['A'])
|
||||
await persistPromiseB
|
||||
expect(completions).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('propagates errors from tasks', async () => {
|
||||
const queue = new ExecutionQueue()
|
||||
const promise = promiseWithResolve()
|
||||
|
||||
const result = queue.push(async () => {
|
||||
await promise
|
||||
})
|
||||
|
||||
promise.reject(new Error('oh no'))
|
||||
|
||||
await expect(result).rejects.toThrow('oh no')
|
||||
})
|
||||
|
||||
it('handles constructor with timeout', async () => {
|
||||
const queue = new ExecutionQueue(10) // 10ms delay
|
||||
const executionOrder: string[] = []
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
// Add first task
|
||||
queue.push(async () => {
|
||||
executionOrder.push('A')
|
||||
})
|
||||
|
||||
// Add second task
|
||||
queue.push(async () => {
|
||||
executionOrder.push('B')
|
||||
})
|
||||
|
||||
// Wait for both tasks to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(executionOrder).toEqual(['A', 'B'])
|
||||
// Should have taken at least 10ms due to timeout delay
|
||||
expect(Date.now() - startTime).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('isEmpty returns correct state', async () => {
|
||||
const queue = new ExecutionQueue()
|
||||
|
||||
// Initially empty
|
||||
expect(queue.isEmpty()).toBe(true)
|
||||
|
||||
// After adding a task, should not be empty
|
||||
const taskPromise = queue.push(async () => {
|
||||
await sleep(1)
|
||||
})
|
||||
|
||||
expect(queue.isEmpty()).toBe(false)
|
||||
|
||||
// Resolve the task and wait a tick for it to finish
|
||||
await taskPromise
|
||||
await tick()
|
||||
|
||||
// Should be empty again
|
||||
expect(queue.isEmpty()).toBe(true)
|
||||
})
|
||||
|
||||
it('close clears pending tasks', async () => {
|
||||
const queue = new ExecutionQueue()
|
||||
const executionOrder: string[] = []
|
||||
const promise = promiseWithResolve()
|
||||
|
||||
// Add first task that will run
|
||||
queue.push(async () => {
|
||||
await promise
|
||||
executionOrder.push('A')
|
||||
})
|
||||
|
||||
// Add second task that should be cleared
|
||||
queue.push(async () => {
|
||||
executionOrder.push('B')
|
||||
})
|
||||
|
||||
// Add third task that should be cleared
|
||||
queue.push(async () => {
|
||||
executionOrder.push('C')
|
||||
})
|
||||
|
||||
// Close the queue - this should clear pending tasks
|
||||
queue.close()
|
||||
|
||||
// Resolve the first task
|
||||
promise.resolve(undefined)
|
||||
await sleep(10)
|
||||
|
||||
// Only the first task should have executed
|
||||
expect(executionOrder).toEqual(['A'])
|
||||
})
|
||||
|
||||
it('allows adding tasks after close', async () => {
|
||||
const queue = new ExecutionQueue()
|
||||
const executionOrder: string[] = []
|
||||
|
||||
// Close the queue
|
||||
queue.close()
|
||||
|
||||
// Add tasks after closing
|
||||
queue.push(async () => {
|
||||
executionOrder.push('A')
|
||||
})
|
||||
|
||||
queue.push(async () => {
|
||||
executionOrder.push('B')
|
||||
})
|
||||
|
||||
await sleep(10)
|
||||
|
||||
// Tasks should still execute
|
||||
expect(executionOrder).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('handles multiple rapid push calls', async () => {
|
||||
const queue = new ExecutionQueue()
|
||||
const executionOrder: number[] = []
|
||||
|
||||
// Add multiple tasks rapidly without awaiting
|
||||
const promises = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
promises.push(
|
||||
queue.push(async () => {
|
||||
executionOrder.push(i)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for all tasks to complete
|
||||
await Promise.all(promises)
|
||||
|
||||
// Should execute in order
|
||||
expect(executionOrder).toEqual([0, 1, 2, 3, 4])
|
||||
})
|
||||
|
||||
it('handles close while task is running', async () => {
|
||||
const queue = new ExecutionQueue()
|
||||
const executionOrder: string[] = []
|
||||
const promise = promiseWithResolve()
|
||||
|
||||
// Start a task that will take time
|
||||
const runningTask = queue.push(async () => {
|
||||
await promise
|
||||
executionOrder.push('running')
|
||||
})
|
||||
|
||||
// Add a task that will be queued
|
||||
queue.push(async () => {
|
||||
executionOrder.push('queued')
|
||||
})
|
||||
|
||||
// Close while first task is still running
|
||||
queue.close()
|
||||
|
||||
// Resolve the running task
|
||||
promise.resolve(undefined)
|
||||
await runningTask
|
||||
|
||||
// Wait a bit to ensure no other tasks run
|
||||
await sleep(10)
|
||||
|
||||
// Only the running task should have executed
|
||||
expect(executionOrder).toEqual(['running'])
|
||||
})
|
||||
})
|
||||
151
packages/utils/src/lib/ExecutionQueue.ts
Normal file
151
packages/utils/src/lib/ExecutionQueue.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { sleep } from './control'
|
||||
|
||||
/**
|
||||
* A queue that executes tasks sequentially with optional delay between tasks.
|
||||
*
|
||||
* ExecutionQueue ensures that tasks are executed one at a time in the order they were added,
|
||||
* with an optional timeout delay between each task execution. This is useful for rate limiting,
|
||||
* preventing race conditions, or controlling the flow of asynchronous operations.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Create a queue with 100ms delay between tasks
|
||||
* const queue = new ExecutionQueue(100)
|
||||
*
|
||||
* // Add tasks to the queue
|
||||
* const result1 = await queue.push(() => fetch('/api/data'))
|
||||
* const result2 = await queue.push(async () => {
|
||||
* const data = await processData()
|
||||
* return data
|
||||
* })
|
||||
*
|
||||
* // Check if queue is empty
|
||||
* if (queue.isEmpty()) {
|
||||
* console.log('All tasks completed')
|
||||
* }
|
||||
*
|
||||
* // Clean up
|
||||
* queue.close()
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class ExecutionQueue {
|
||||
private queue: (() => Promise<any>)[] = []
|
||||
private running = false
|
||||
|
||||
/**
|
||||
* Creates a new ExecutionQueue.
|
||||
*
|
||||
* Creates a new execution queue that will process tasks sequentially.
|
||||
* If a timeout is provided, there will be a delay between each task execution,
|
||||
* which is useful for rate limiting or controlling execution flow.
|
||||
*
|
||||
* timeout - Optional delay in milliseconds between task executions
|
||||
* @example
|
||||
* ```ts
|
||||
* // Create queue without delay
|
||||
* const fastQueue = new ExecutionQueue()
|
||||
*
|
||||
* // Create queue with 500ms delay between tasks
|
||||
* const slowQueue = new ExecutionQueue(500)
|
||||
* ```
|
||||
*/
|
||||
constructor(private readonly timeout?: number) {}
|
||||
|
||||
/**
|
||||
* Checks if the queue is empty and not currently running a task.
|
||||
*
|
||||
* Determines whether the execution queue has completed all tasks and is idle.
|
||||
* Returns true only when there are no pending tasks in the queue AND no task is currently being executed.
|
||||
*
|
||||
* @returns True if the queue has no pending tasks and is not currently executing
|
||||
* @example
|
||||
* ```ts
|
||||
* const queue = new ExecutionQueue()
|
||||
*
|
||||
* console.log(queue.isEmpty()) // true - queue is empty
|
||||
*
|
||||
* queue.push(() => console.log('task'))
|
||||
* console.log(queue.isEmpty()) // false - task is running/pending
|
||||
* ```
|
||||
*/
|
||||
isEmpty() {
|
||||
return this.queue.length === 0 && !this.running
|
||||
}
|
||||
|
||||
private async run() {
|
||||
if (this.running) return
|
||||
try {
|
||||
this.running = true
|
||||
while (this.queue.length) {
|
||||
const task = this.queue.shift()!
|
||||
await task()
|
||||
if (this.timeout) {
|
||||
await sleep(this.timeout)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// this try/finally should not be needed because the tasks don't throw
|
||||
// but better safe than sorry
|
||||
// console.log('\n\n\nrunning false\n\n\n')
|
||||
this.running = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a task to the queue and returns a promise that resolves with the task's result.
|
||||
*
|
||||
* Enqueues a task for sequential execution. The task will be executed after all
|
||||
* previously queued tasks have completed. If a timeout was specified in the constructor,
|
||||
* there will be a delay between this task and the next one.
|
||||
*
|
||||
* @param task - The function to execute (can be sync or async)
|
||||
* @returns Promise that resolves with the task's return value
|
||||
* @example
|
||||
* ```ts
|
||||
* const queue = new ExecutionQueue(100)
|
||||
*
|
||||
* // Add async task
|
||||
* const result = await queue.push(async () => {
|
||||
* const response = await fetch('/api/data')
|
||||
* return response.json()
|
||||
* })
|
||||
*
|
||||
* // Add sync task
|
||||
* const number = await queue.push(() => 42)
|
||||
* ```
|
||||
*/
|
||||
async push<T>(task: () => T): Promise<Awaited<T>> {
|
||||
return new Promise<Awaited<T>>((resolve, reject) => {
|
||||
this.queue.push(() => Promise.resolve(task()).then(resolve).catch(reject))
|
||||
this.run()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all pending tasks from the queue.
|
||||
*
|
||||
* Immediately removes all pending tasks from the queue. Any currently
|
||||
* running task will complete normally, but no additional tasks will be executed.
|
||||
* This method does not wait for the current task to finish.
|
||||
*
|
||||
* @returns void
|
||||
* @example
|
||||
* ```ts
|
||||
* const queue = new ExecutionQueue()
|
||||
*
|
||||
* // Add several tasks
|
||||
* queue.push(() => console.log('task 1'))
|
||||
* queue.push(() => console.log('task 2'))
|
||||
* queue.push(() => console.log('task 3'))
|
||||
*
|
||||
* // Clear all pending tasks
|
||||
* queue.close()
|
||||
* // Only 'task 1' will execute if it was already running
|
||||
* ```
|
||||
*/
|
||||
close() {
|
||||
this.queue = []
|
||||
}
|
||||
}
|
||||
101
packages/utils/src/lib/LruCache.test.ts
Normal file
101
packages/utils/src/lib/LruCache.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { LruCache } from './LruCache'
|
||||
|
||||
describe('LruCache', () => {
|
||||
it('stores and retrieves values', () => {
|
||||
const cache = new LruCache<string, number>(3)
|
||||
cache.set('a', 1)
|
||||
cache.set('b', 2)
|
||||
expect(cache.get('a')).toBe(1)
|
||||
expect(cache.get('b')).toBe(2)
|
||||
expect(cache.get('c')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports size', () => {
|
||||
const cache = new LruCache<string, number>(5)
|
||||
expect(cache.size).toBe(0)
|
||||
cache.set('a', 1)
|
||||
expect(cache.size).toBe(1)
|
||||
cache.set('b', 2)
|
||||
expect(cache.size).toBe(2)
|
||||
})
|
||||
|
||||
it('delete() removes an entry and frees its slot', () => {
|
||||
const cache = new LruCache<string, number>(2)
|
||||
cache.set('a', 1)
|
||||
cache.set('b', 2)
|
||||
expect(cache.delete('a')).toBe(true)
|
||||
expect(cache.delete('a')).toBe(false)
|
||||
expect(cache.has('a')).toBe(false)
|
||||
cache.set('c', 3)
|
||||
expect(cache.get('b')).toBe(2)
|
||||
expect(cache.size).toBe(2)
|
||||
})
|
||||
|
||||
it('has() checks existence without promoting', () => {
|
||||
const cache = new LruCache<string, number>(2)
|
||||
cache.set('a', 1)
|
||||
cache.set('b', 2)
|
||||
expect(cache.has('a')).toBe(true)
|
||||
expect(cache.has('z')).toBe(false)
|
||||
|
||||
// 'a' was not promoted by has(), so adding 'c' should evict 'a'
|
||||
cache.set('c', 3)
|
||||
expect(cache.has('a')).toBe(false)
|
||||
})
|
||||
|
||||
it('evicts the oldest entry when exceeding capacity', () => {
|
||||
const cache = new LruCache<string, number>(2)
|
||||
cache.set('a', 1)
|
||||
cache.set('b', 2)
|
||||
cache.set('c', 3) // should evict 'a'
|
||||
|
||||
expect(cache.get('a')).toBeUndefined()
|
||||
expect(cache.get('b')).toBe(2)
|
||||
expect(cache.get('c')).toBe(3)
|
||||
expect(cache.size).toBe(2)
|
||||
})
|
||||
|
||||
it('get() promotes entry so it is not evicted next', () => {
|
||||
const cache = new LruCache<string, number>(2)
|
||||
cache.set('a', 1)
|
||||
cache.set('b', 2)
|
||||
|
||||
// Access 'a' to promote it; now 'b' is oldest
|
||||
cache.get('a')
|
||||
cache.set('c', 3) // should evict 'b', not 'a'
|
||||
|
||||
expect(cache.get('b')).toBeUndefined()
|
||||
expect(cache.get('a')).toBe(1)
|
||||
expect(cache.get('c')).toBe(3)
|
||||
})
|
||||
|
||||
it('set() on existing key updates value and promotes it', () => {
|
||||
const cache = new LruCache<string, number>(2)
|
||||
cache.set('a', 1)
|
||||
cache.set('b', 2)
|
||||
|
||||
// Update 'a' — promotes it, 'b' becomes oldest
|
||||
cache.set('a', 10)
|
||||
expect(cache.get('a')).toBe(10)
|
||||
|
||||
cache.set('c', 3) // should evict 'b'
|
||||
expect(cache.get('b')).toBeUndefined()
|
||||
expect(cache.get('a')).toBe(10)
|
||||
expect(cache.size).toBe(2)
|
||||
})
|
||||
|
||||
it('evicts entries in insertion order across many inserts', () => {
|
||||
const cache = new LruCache<number, number>(3)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
cache.set(i, i * 10)
|
||||
}
|
||||
// Only the last 3 should remain
|
||||
expect(cache.size).toBe(3)
|
||||
expect(cache.get(7)).toBe(70)
|
||||
expect(cache.get(8)).toBe(80)
|
||||
expect(cache.get(9)).toBe(90)
|
||||
for (let i = 0; i < 7; i++) {
|
||||
expect(cache.has(i)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
36
packages/utils/src/lib/LruCache.ts
Normal file
36
packages/utils/src/lib/LruCache.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/** Simple LRU cache backed by a Map's insertion-order iteration. @public */
|
||||
export class LruCache<K, V> {
|
||||
private map = new Map<K, V>()
|
||||
constructor(private maxSize: number) {}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
if (!this.map.has(key)) return undefined
|
||||
const value = this.map.get(key)!
|
||||
// Move to most-recent position
|
||||
this.map.delete(key)
|
||||
this.map.set(key, value)
|
||||
return value
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
if (this.map.has(key)) this.map.delete(key)
|
||||
this.map.set(key, value)
|
||||
if (this.map.size > this.maxSize) {
|
||||
// Evict oldest entry
|
||||
this.map.delete(this.map.keys().next().value!)
|
||||
}
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
return this.map.has(key)
|
||||
}
|
||||
|
||||
delete(key: K): boolean {
|
||||
return this.map.delete(key)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line tldraw/no-setter-getter
|
||||
get size(): number {
|
||||
return this.map.size
|
||||
}
|
||||
}
|
||||
123
packages/utils/src/lib/PerformanceTracker.test.ts
Normal file
123
packages/utils/src/lib/PerformanceTracker.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'
|
||||
import { PERFORMANCE_COLORS, PERFORMANCE_PREFIX_COLOR } from './perf'
|
||||
import { PerformanceTracker } from './PerformanceTracker'
|
||||
|
||||
describe('PerformanceTracker', () => {
|
||||
let tracker: PerformanceTracker
|
||||
let mockPerformanceNow: ReturnType<typeof vi.fn>
|
||||
let mockRequestAnimationFrame: ReturnType<typeof vi.fn>
|
||||
let mockCancelAnimationFrame: ReturnType<typeof vi.fn>
|
||||
let mockConsoleDebug: MockInstance<typeof console.debug>
|
||||
let frameId = 1
|
||||
|
||||
beforeEach(() => {
|
||||
tracker = new PerformanceTracker()
|
||||
|
||||
// Mock performance.now
|
||||
mockPerformanceNow = vi.fn()
|
||||
vi.stubGlobal('performance', { now: mockPerformanceNow })
|
||||
|
||||
// Mock requestAnimationFrame and cancelAnimationFrame
|
||||
mockRequestAnimationFrame = vi.fn().mockImplementation(() => frameId++)
|
||||
mockCancelAnimationFrame = vi.fn()
|
||||
vi.stubGlobal('requestAnimationFrame', mockRequestAnimationFrame)
|
||||
vi.stubGlobal('cancelAnimationFrame', mockCancelAnimationFrame)
|
||||
|
||||
// Mock console.debug
|
||||
mockConsoleDebug = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
frameId = 1
|
||||
})
|
||||
|
||||
it('tracks start/stop state correctly', () => {
|
||||
expect(tracker.isStarted()).toBe(false)
|
||||
|
||||
mockPerformanceNow.mockReturnValue(100)
|
||||
tracker.start('test')
|
||||
expect(tracker.isStarted()).toBe(true)
|
||||
|
||||
mockPerformanceNow.mockReturnValue(200)
|
||||
tracker.stop()
|
||||
expect(tracker.isStarted()).toBe(false)
|
||||
})
|
||||
|
||||
it('calculates and logs FPS correctly', () => {
|
||||
// Setup: 1 second duration with 60 frames
|
||||
mockPerformanceNow.mockReturnValueOnce(0).mockReturnValueOnce(1000)
|
||||
|
||||
tracker.start('render')
|
||||
const recordFrame = mockRequestAnimationFrame.mock.calls[0][0]
|
||||
|
||||
// Simulate 60 frames
|
||||
for (let i = 0; i < 60; i++) {
|
||||
recordFrame()
|
||||
}
|
||||
|
||||
tracker.stop()
|
||||
|
||||
expect(mockConsoleDebug).toHaveBeenCalledWith(
|
||||
'%cPerf%c Render %c60%c fps',
|
||||
`color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`,
|
||||
'font-weight: normal',
|
||||
`font-weight: bold; padding: 2px; background: ${PERFORMANCE_COLORS.Good};color: white;`,
|
||||
'font-weight: normal'
|
||||
)
|
||||
})
|
||||
|
||||
it('resets frame count between tracking sessions', () => {
|
||||
mockPerformanceNow.mockReturnValueOnce(100).mockReturnValueOnce(200)
|
||||
|
||||
// First session with frames
|
||||
tracker.start('test')
|
||||
const recordFrame = mockRequestAnimationFrame.mock.calls[0][0]
|
||||
recordFrame() // simulate frame
|
||||
recordFrame() // simulate frame
|
||||
tracker.stop()
|
||||
|
||||
// Second session should reset frame count
|
||||
mockPerformanceNow.mockReturnValueOnce(300).mockReturnValueOnce(400)
|
||||
tracker.start('test2')
|
||||
tracker.stop()
|
||||
|
||||
// Should show 0 fps for second session (0 frames in 0.1 seconds)
|
||||
expect(mockConsoleDebug).toHaveBeenLastCalledWith(
|
||||
'%cPerf%c Test2 %c0%c fps',
|
||||
expect.any(String),
|
||||
'font-weight: normal',
|
||||
expect.any(String),
|
||||
'font-weight: normal'
|
||||
)
|
||||
})
|
||||
|
||||
it('records frames only while tracking is active', () => {
|
||||
mockPerformanceNow.mockReturnValue(100)
|
||||
|
||||
tracker.start('test')
|
||||
const recordFrame = mockRequestAnimationFrame.mock.calls[0][0]
|
||||
|
||||
tracker.stop()
|
||||
mockRequestAnimationFrame.mockClear()
|
||||
|
||||
// Frame recording should stop after stop() is called
|
||||
recordFrame()
|
||||
expect(mockRequestAnimationFrame).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles zero duration gracefully', () => {
|
||||
// Zero duration should not crash
|
||||
mockPerformanceNow.mockReturnValue(100)
|
||||
tracker.start('instant')
|
||||
tracker.stop()
|
||||
expect(mockConsoleDebug).toHaveBeenCalledWith(
|
||||
'%cPerf%c Instant %c0%c fps',
|
||||
expect.any(String),
|
||||
'font-weight: normal',
|
||||
expect.any(String),
|
||||
'font-weight: normal'
|
||||
)
|
||||
})
|
||||
})
|
||||
117
packages/utils/src/lib/PerformanceTracker.ts
Normal file
117
packages/utils/src/lib/PerformanceTracker.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { PERFORMANCE_COLORS, PERFORMANCE_PREFIX_COLOR } from './perf'
|
||||
|
||||
/**
|
||||
* A utility class for measuring and tracking frame rate performance during operations.
|
||||
* Provides visual feedback in the browser console with color-coded FPS indicators.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const tracker = new PerformanceTracker()
|
||||
*
|
||||
* tracker.start('render')
|
||||
* renderShapes()
|
||||
* tracker.stop() // Logs performance info to console
|
||||
*
|
||||
* // Check if tracking is active
|
||||
* if (tracker.isStarted()) {
|
||||
* console.log('Still tracking performance')
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class PerformanceTracker {
|
||||
private startTime = 0
|
||||
private name = ''
|
||||
private frames = 0
|
||||
private started = false
|
||||
private frame: number | null = null
|
||||
|
||||
/**
|
||||
* Records animation frames to calculate frame rate.
|
||||
* Called automatically during performance tracking.
|
||||
*/
|
||||
// eslint-disable-next-line tldraw/prefer-class-methods
|
||||
recordFrame = () => {
|
||||
this.frames++
|
||||
if (!this.started) return
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
this.frame = requestAnimationFrame(this.recordFrame)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts performance tracking for a named operation.
|
||||
*
|
||||
* @param name - A descriptive name for the operation being tracked
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* tracker.start('canvas-render')
|
||||
* // ... perform rendering operations
|
||||
* tracker.stop()
|
||||
* ```
|
||||
*/
|
||||
start(name: string) {
|
||||
this.name = name
|
||||
this.frames = 0
|
||||
this.started = true
|
||||
if (this.frame !== null) cancelAnimationFrame(this.frame)
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
this.frame = requestAnimationFrame(this.recordFrame)
|
||||
this.startTime = performance.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops performance tracking and logs results to the console.
|
||||
*
|
||||
* Displays the operation name, frame rate, and uses color coding:
|
||||
* - Green background: \> 55 FPS (good performance)
|
||||
* - Yellow background: 30-55 FPS (moderate performance)
|
||||
* - Red background: \< 30 FPS (poor performance)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* tracker.start('interaction')
|
||||
* handleUserInteraction()
|
||||
* tracker.stop() // Logs: "Perf Interaction 60 fps"
|
||||
* ```
|
||||
*/
|
||||
stop() {
|
||||
this.started = false
|
||||
if (this.frame !== null) cancelAnimationFrame(this.frame)
|
||||
const duration = (performance.now() - this.startTime) / 1000
|
||||
const fps = duration === 0 ? 0 : Math.floor(this.frames / duration)
|
||||
const background =
|
||||
fps > 55
|
||||
? PERFORMANCE_COLORS.Good
|
||||
: fps > 30
|
||||
? PERFORMANCE_COLORS.Mid
|
||||
: PERFORMANCE_COLORS.Poor
|
||||
const color = background === PERFORMANCE_COLORS.Mid ? 'black' : 'white'
|
||||
const capitalized = this.name[0].toUpperCase() + this.name.slice(1)
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(
|
||||
`%cPerf%c ${capitalized} %c${fps}%c fps`,
|
||||
`color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`,
|
||||
'font-weight: normal',
|
||||
`font-weight: bold; padding: 2px; background: ${background};color: ${color};`,
|
||||
'font-weight: normal'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether performance tracking is currently active.
|
||||
*
|
||||
* @returns True if tracking is in progress, false otherwise
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* if (!tracker.isStarted()) {
|
||||
* tracker.start('new-operation')
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
isStarted() {
|
||||
return this.started
|
||||
}
|
||||
}
|
||||
339
packages/utils/src/lib/array.test.ts
Normal file
339
packages/utils/src/lib/array.test.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
areArraysShallowEqual,
|
||||
compact,
|
||||
dedupe,
|
||||
last,
|
||||
maxBy,
|
||||
mergeArraysAndReplaceDefaults,
|
||||
minBy,
|
||||
partition,
|
||||
rotateArray,
|
||||
} from './array'
|
||||
|
||||
describe('rotateArray', () => {
|
||||
test('should rotate array to the left with positive offset', () => {
|
||||
// Based on JSDoc examples, this is the expected behavior
|
||||
expect(rotateArray([1, 2, 3, 4], 1)).toEqual([2, 3, 4, 1])
|
||||
expect(rotateArray([1, 2, 3, 4], 2)).toEqual([3, 4, 1, 2])
|
||||
})
|
||||
|
||||
test('should rotate array to the left with negative offset', () => {
|
||||
// Based on JSDoc examples, this is the expected behavior
|
||||
expect(rotateArray([1, 2, 3, 4], -1)).toEqual([2, 3, 4, 1])
|
||||
expect(rotateArray([1, 2, 3, 4], -2)).toEqual([3, 4, 1, 2])
|
||||
})
|
||||
|
||||
it('should handle zero offset', () => {
|
||||
expect(rotateArray([1, 2, 3, 4], 0)).toEqual([1, 2, 3, 4])
|
||||
})
|
||||
|
||||
test('should handle offset larger than array length', () => {
|
||||
// Based on understanding of rotation: offset 5 on length 3 should be same as offset 2
|
||||
expect(rotateArray([1, 2, 3], 5)).toEqual([3, 1, 2])
|
||||
// offset -5 on length 3 should be same as offset -2 which should be same as offset 2
|
||||
expect(rotateArray([1, 2, 3], -5)).toEqual([3, 1, 2])
|
||||
})
|
||||
|
||||
it('should handle empty array', () => {
|
||||
expect(rotateArray([], 1)).toEqual([])
|
||||
})
|
||||
|
||||
test('should work with different types', () => {
|
||||
// Based on JSDoc examples, this is the expected behavior
|
||||
expect(rotateArray(['a', 'b', 'c'], 2)).toEqual(['c', 'a', 'b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('dedupe', () => {
|
||||
it('should remove duplicate primitives', () => {
|
||||
expect(dedupe([1, 2, 2, 3, 1])).toEqual([1, 2, 3])
|
||||
expect(dedupe(['a', 'b', 'a', 'c'])).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('should preserve order of first occurrence', () => {
|
||||
expect(dedupe([3, 1, 2, 1, 3])).toEqual([3, 1, 2])
|
||||
})
|
||||
|
||||
it('should handle empty array', () => {
|
||||
expect(dedupe([])).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle array with no duplicates', () => {
|
||||
expect(dedupe([1, 2, 3])).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('should use custom equality function', () => {
|
||||
const objects = [
|
||||
{ id: 1, name: 'a' },
|
||||
{ id: 2, name: 'b' },
|
||||
{ id: 1, name: 'c' },
|
||||
]
|
||||
expect(dedupe(objects, (a, b) => a.id === b.id)).toEqual([
|
||||
{ id: 1, name: 'a' },
|
||||
{ id: 2, name: 'b' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle objects without custom equality', () => {
|
||||
const obj1 = { id: 1 }
|
||||
const obj2 = { id: 2 }
|
||||
expect(dedupe([obj1, obj2, obj1])).toEqual([obj1, obj2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('compact', () => {
|
||||
it('should remove null and undefined values', () => {
|
||||
expect(compact([1, null, 2, undefined, 3])).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('should preserve falsy values that are not null/undefined', () => {
|
||||
expect(compact([0, false, '', null, undefined, 'hello'])).toEqual([0, false, '', 'hello'])
|
||||
})
|
||||
|
||||
it('should handle empty array', () => {
|
||||
expect(compact([])).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle array with only null/undefined', () => {
|
||||
expect(compact([null, undefined, null])).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle array with no null/undefined', () => {
|
||||
expect(compact([1, 2, 3])).toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
|
||||
describe('last', () => {
|
||||
it('should return last element of array', () => {
|
||||
expect(last([1, 2, 3])).toBe(3)
|
||||
expect(last(['a', 'b', 'c'])).toBe('c')
|
||||
})
|
||||
|
||||
it('should return undefined for empty array', () => {
|
||||
expect(last([])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should work with single element array', () => {
|
||||
expect(last([42])).toBe(42)
|
||||
})
|
||||
|
||||
it('should work with readonly arrays', () => {
|
||||
const readonlyArr: readonly number[] = [1, 2, 3]
|
||||
expect(last(readonlyArr)).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('minBy', () => {
|
||||
it('should find item with minimum value', () => {
|
||||
const people = [
|
||||
{ name: 'Alice', age: 30 },
|
||||
{ name: 'Bob', age: 25 },
|
||||
{ name: 'Charlie', age: 35 },
|
||||
]
|
||||
expect(minBy(people, (p) => p.age)).toEqual({ name: 'Bob', age: 25 })
|
||||
})
|
||||
|
||||
it('should work with numbers', () => {
|
||||
expect(minBy([3, 1, 4, 1, 5], (x) => x)).toBe(1)
|
||||
})
|
||||
|
||||
it('should return undefined for empty array', () => {
|
||||
expect(minBy([], (x) => x)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle ties by returning first occurrence', () => {
|
||||
const items = [{ val: 5 }, { val: 3 }, { val: 3 }, { val: 7 }]
|
||||
expect(minBy(items, (x) => x.val)).toEqual({ val: 3 })
|
||||
})
|
||||
|
||||
it('should work with negative values', () => {
|
||||
expect(minBy([-1, -5, -3], (x) => x)).toBe(-5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('maxBy', () => {
|
||||
it('should find item with maximum value', () => {
|
||||
const people = [
|
||||
{ name: 'Alice', age: 30 },
|
||||
{ name: 'Bob', age: 25 },
|
||||
{ name: 'Charlie', age: 35 },
|
||||
]
|
||||
expect(maxBy(people, (p) => p.age)).toEqual({ name: 'Charlie', age: 35 })
|
||||
})
|
||||
|
||||
it('should work with numbers', () => {
|
||||
expect(maxBy([3, 1, 4, 1, 5], (x) => x)).toBe(5)
|
||||
})
|
||||
|
||||
it('should return undefined for empty array', () => {
|
||||
expect(maxBy([], (x) => x)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle ties by returning first occurrence', () => {
|
||||
const items = [{ val: 5 }, { val: 7 }, { val: 7 }, { val: 3 }]
|
||||
expect(maxBy(items, (x) => x.val)).toEqual({ val: 7 })
|
||||
})
|
||||
|
||||
it('should work with negative values', () => {
|
||||
expect(maxBy([-1, -5, -3], (x) => x)).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('partition', () => {
|
||||
it('should split array based on predicate', () => {
|
||||
const [evens, odds] = partition([1, 2, 3, 4, 5], (x) => x % 2 === 0)
|
||||
expect(evens).toEqual([2, 4])
|
||||
expect(odds).toEqual([1, 3, 5])
|
||||
})
|
||||
|
||||
it('should preserve order within partitions', () => {
|
||||
const [adults, minors] = partition(
|
||||
[
|
||||
{ name: 'Alice', age: 30 },
|
||||
{ name: 'Bob', age: 17 },
|
||||
{ name: 'Charlie', age: 25 },
|
||||
],
|
||||
(person) => person.age >= 18
|
||||
)
|
||||
expect(adults).toEqual([
|
||||
{ name: 'Alice', age: 30 },
|
||||
{ name: 'Charlie', age: 25 },
|
||||
])
|
||||
expect(minors).toEqual([{ name: 'Bob', age: 17 }])
|
||||
})
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const [satisfies, doesNotSatisfy] = partition([], (x) => x > 0)
|
||||
expect(satisfies).toEqual([])
|
||||
expect(doesNotSatisfy).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle all items satisfying predicate', () => {
|
||||
const [satisfies, doesNotSatisfy] = partition([2, 4, 6], (x) => x % 2 === 0)
|
||||
expect(satisfies).toEqual([2, 4, 6])
|
||||
expect(doesNotSatisfy).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle no items satisfying predicate', () => {
|
||||
const [satisfies, doesNotSatisfy] = partition([1, 3, 5], (x) => x % 2 === 0)
|
||||
expect(satisfies).toEqual([])
|
||||
expect(doesNotSatisfy).toEqual([1, 3, 5])
|
||||
})
|
||||
})
|
||||
|
||||
describe('areArraysShallowEqual', () => {
|
||||
it('should return true for identical arrays', () => {
|
||||
expect(areArraysShallowEqual([1, 2, 3], [1, 2, 3])).toBe(true)
|
||||
expect(areArraysShallowEqual(['a', 'b'], ['a', 'b'])).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for same reference', () => {
|
||||
const arr = [1, 2, 3]
|
||||
expect(areArraysShallowEqual(arr, arr)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for different lengths', () => {
|
||||
expect(areArraysShallowEqual([1, 2], [1, 2, 3])).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for different elements', () => {
|
||||
expect(areArraysShallowEqual([1, 2, 3], [1, 2, 4])).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true for empty arrays', () => {
|
||||
expect(areArraysShallowEqual([], [])).toBe(true)
|
||||
})
|
||||
|
||||
it('should use Object.is for comparison', () => {
|
||||
expect(areArraysShallowEqual([NaN], [NaN])).toBe(true)
|
||||
expect(areArraysShallowEqual([0], [-0])).toBe(false)
|
||||
})
|
||||
|
||||
it('should work with object references', () => {
|
||||
const obj = { x: 1 }
|
||||
expect(areArraysShallowEqual([obj], [obj])).toBe(true)
|
||||
expect(areArraysShallowEqual([{ x: 1 }], [{ x: 1 }])).toBe(false)
|
||||
})
|
||||
|
||||
it('should work with readonly arrays', () => {
|
||||
const arr1: readonly number[] = [1, 2, 3]
|
||||
const arr2: readonly number[] = [1, 2, 3]
|
||||
expect(areArraysShallowEqual(arr1, arr2)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeArraysAndReplaceDefaults', () => {
|
||||
it('should merge custom entries with defaults, allowing custom entries to override defaults', () => {
|
||||
const defaults = [
|
||||
{ id: 'select', name: 'Default Select' },
|
||||
{ id: 'draw', name: 'Default Draw' },
|
||||
{ id: 'eraser', name: 'Default Eraser' },
|
||||
]
|
||||
|
||||
const customEntries = [
|
||||
{ id: 'select', name: 'Custom Select' },
|
||||
{ id: 'custom-tool', name: 'Custom Tool' },
|
||||
]
|
||||
|
||||
const result = mergeArraysAndReplaceDefaults('id', customEntries, defaults)
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: 'draw', name: 'Default Draw' },
|
||||
{ id: 'eraser', name: 'Default Eraser' },
|
||||
{ id: 'select', name: 'Custom Select' },
|
||||
{ id: 'custom-tool', name: 'Custom Tool' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle empty custom entries', () => {
|
||||
const defaults = [
|
||||
{ id: 'select', name: 'Default Select' },
|
||||
{ id: 'draw', name: 'Default Draw' },
|
||||
]
|
||||
|
||||
const customEntries: typeof defaults = []
|
||||
|
||||
const result = mergeArraysAndReplaceDefaults('id', customEntries, defaults)
|
||||
|
||||
expect(result).toEqual(defaults)
|
||||
})
|
||||
|
||||
it('should handle empty defaults', () => {
|
||||
const defaults: Array<{ id: string; name: string }> = []
|
||||
|
||||
const customEntries = [{ id: 'custom-tool', name: 'Custom Tool' }]
|
||||
|
||||
const result = mergeArraysAndReplaceDefaults('id', customEntries, defaults)
|
||||
|
||||
expect(result).toEqual(customEntries)
|
||||
})
|
||||
|
||||
it('should handle both empty arrays', () => {
|
||||
const defaults: Array<{ id: string; name: string }> = []
|
||||
const customEntries: Array<{ id: string; name: string }> = []
|
||||
|
||||
const result = mergeArraysAndReplaceDefaults('id', customEntries, defaults)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should work with different key names', () => {
|
||||
const defaults = [
|
||||
{ type: 'text', name: 'Default Text' },
|
||||
{ type: 'geo', name: 'Default Geo' },
|
||||
]
|
||||
|
||||
const customEntries = [
|
||||
{ type: 'text', name: 'Custom Text' },
|
||||
{ type: 'custom', name: 'Custom Shape' },
|
||||
]
|
||||
|
||||
const result = mergeArraysAndReplaceDefaults('type', customEntries, defaults)
|
||||
|
||||
expect(result).toEqual([
|
||||
{ type: 'geo', name: 'Default Geo' },
|
||||
{ type: 'text', name: 'Custom Text' },
|
||||
{ type: 'custom', name: 'Custom Shape' },
|
||||
])
|
||||
})
|
||||
})
|
||||
288
packages/utils/src/lib/array.ts
Normal file
288
packages/utils/src/lib/array.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
47
packages/utils/src/lib/bind.test.ts
Normal file
47
packages/utils/src/lib/bind.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bind } from './bind'
|
||||
|
||||
describe('bind decorator', () => {
|
||||
describe('legacy TypeScript decorator format', () => {
|
||||
it('should maintain method binding when extracted', () => {
|
||||
class TestClass {
|
||||
value = 42
|
||||
|
||||
@bind
|
||||
getValue() {
|
||||
return this.value
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestClass()
|
||||
const { getValue } = instance
|
||||
|
||||
expect(getValue()).toBe(42)
|
||||
})
|
||||
|
||||
it('should work with methods that modify instance state', () => {
|
||||
class TestClass {
|
||||
count = 0
|
||||
|
||||
@bind
|
||||
increment() {
|
||||
this.count++
|
||||
return this.count
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestClass()
|
||||
const { increment } = instance
|
||||
|
||||
expect(increment()).toBe(1)
|
||||
expect(increment()).toBe(2)
|
||||
expect(instance.count).toBe(2)
|
||||
})
|
||||
|
||||
it('should throw error when decorating non-method', () => {
|
||||
expect(() => {
|
||||
bind({}, 'notAMethod', { value: 'not a function', configurable: true } as any)
|
||||
}).toThrow('Only methods can be decorated with @bind. <notAMethod> is not a method!')
|
||||
})
|
||||
})
|
||||
})
|
||||
136
packages/utils/src/lib/bind.ts
Normal file
136
packages/utils/src/lib/bind.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/*!
|
||||
* MIT License: https://github.com/NoHomey/bind-decorator/blob/master/License
|
||||
* Copyright (c) 2016 Ivo Stratev
|
||||
*/
|
||||
|
||||
import { assert } from './control'
|
||||
|
||||
/**
|
||||
* Decorator that binds a method to its class instance (legacy stage-2 TypeScript decorators).
|
||||
* When applied to a class method, ensures `this` always refers to the class instance,
|
||||
* even when the method is called as a callback or event handler.
|
||||
*
|
||||
* @param target - The prototype of the class being decorated
|
||||
* @param propertyKey - The name of the method being decorated
|
||||
* @param descriptor - The property descriptor for the method being decorated
|
||||
* @returns The modified property descriptor with bound method access
|
||||
* @example
|
||||
* ```typescript
|
||||
* class MyClass {
|
||||
* name = 'example';
|
||||
*
|
||||
* @bind
|
||||
* getName() {
|
||||
* return this.name;
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* const instance = new MyClass();
|
||||
* const callback = instance.getName;
|
||||
* console.log(callback()); // 'example' (this is properly bound)
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function bind<T extends (...args: any[]) => any>(
|
||||
target: object,
|
||||
propertyKey: string,
|
||||
descriptor: TypedPropertyDescriptor<T>
|
||||
): TypedPropertyDescriptor<T>
|
||||
|
||||
/**
|
||||
* Decorator that binds a method to its class instance (TC39 decorators standard).
|
||||
* When applied to a class method, ensures `this` always refers to the class instance,
|
||||
* even when the method is called as a callback or event handler.
|
||||
*
|
||||
* @param originalMethod - The original method being decorated
|
||||
* @param context - The decorator context containing metadata about the method
|
||||
* @example
|
||||
* ```typescript
|
||||
* class EventHandler {
|
||||
* message = 'Hello World';
|
||||
*
|
||||
* @bind
|
||||
* handleClick() {
|
||||
* console.log(this.message);
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* const handler = new EventHandler();
|
||||
* document.addEventListener('click', handler.handleClick); // 'this' is properly bound
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function bind<This extends object, T extends (...args: any[]) => any>(
|
||||
originalMethod: T,
|
||||
context: ClassMethodDecoratorContext<This, T>
|
||||
): void
|
||||
|
||||
/**
|
||||
* Universal decorator implementation that handles both legacy stage-2 and TC39 decorator formats.
|
||||
* Automatically detects the decorator format based on the number of arguments and binds the
|
||||
* decorated method to the class instance, preventing common `this` context issues.
|
||||
*
|
||||
* @param args - Either legacy decorator arguments (target, propertyKey, descriptor) or TC39 decorator arguments (originalMethod, context)
|
||||
* @returns Property descriptor for legacy decorators, or void for TC39 decorators
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Works with both decorator formats
|
||||
* class Calculator {
|
||||
* multiplier = 2;
|
||||
*
|
||||
* @bind
|
||||
* multiply(value: number) {
|
||||
* return value * this.multiplier;
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* const calc = new Calculator();
|
||||
* const multiplyFn = calc.multiply;
|
||||
* console.log(multiplyFn(5)); // 10 (this.multiplier is accessible)
|
||||
*
|
||||
* // Useful for event handlers and callbacks
|
||||
* setTimeout(calc.multiply, 100, 3); // 6
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function bind(
|
||||
...args: // legacy stage-2 typescript decorators
|
||||
| [_target: object, propertyKey: string, descriptor: PropertyDescriptor]
|
||||
// TC39 decorators
|
||||
| [originalMethod: (...args: any[]) => any, context: ClassMemberDecoratorContext]
|
||||
): PropertyDescriptor | void {
|
||||
if (args.length === 2) {
|
||||
const [originalMethod, context] = args
|
||||
context.addInitializer(function initializeMethod(this: any) {
|
||||
assert(Reflect.isExtensible(this), 'Cannot bind to a non-extensible class.')
|
||||
const value = originalMethod.bind(this)
|
||||
const ok = Reflect.defineProperty(this, context.name, {
|
||||
value,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
assert(ok, 'Cannot bind a non-configurable class method.')
|
||||
})
|
||||
} else {
|
||||
const [_target, propertyKey, descriptor] = args
|
||||
if (!descriptor || typeof descriptor.value !== 'function') {
|
||||
throw new TypeError(
|
||||
`Only methods can be decorated with @bind. <${propertyKey}> is not a method!`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
configurable: true,
|
||||
get(this: any): any {
|
||||
const bound = descriptor.value!.bind(this)
|
||||
// Credits to https://github.com/andreypopp/autobind-decorator for memoizing the result of bind against a symbol on the instance.
|
||||
Object.defineProperty(this, propertyKey, {
|
||||
value: bound,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
return bound
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
73
packages/utils/src/lib/cache.test.ts
Normal file
73
packages/utils/src/lib/cache.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { vi } from 'vitest'
|
||||
import { WeakCache } from './cache'
|
||||
|
||||
describe('WeakCache', () => {
|
||||
it('should compute and cache value on first call, return cached value on subsequent calls', () => {
|
||||
const cache = new WeakCache<{ id: number }, string>()
|
||||
const key = { id: 1 }
|
||||
const callback = vi.fn((item: { id: number }) => `value-${item.id}`)
|
||||
|
||||
const result1 = cache.get(key, callback)
|
||||
const result2 = cache.get(key, callback)
|
||||
|
||||
expect(result1).toBe('value-1')
|
||||
expect(result2).toBe('value-1')
|
||||
expect(result1).toBe(result2) // Same reference
|
||||
expect(callback).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle different callbacks for the same key', () => {
|
||||
const cache = new WeakCache<{ id: number }, string>()
|
||||
const key = { id: 1 }
|
||||
const callback1 = vi.fn(() => 'first-computation')
|
||||
const callback2 = vi.fn(() => 'second-computation')
|
||||
|
||||
const result1 = cache.get(key, callback1)
|
||||
const result2 = cache.get(key, callback2)
|
||||
|
||||
expect(result1).toBe('first-computation')
|
||||
expect(result2).toBe('first-computation')
|
||||
expect(callback1).toHaveBeenCalledTimes(1)
|
||||
expect(callback2).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should maintain separate cache entries for different object references', () => {
|
||||
const cache = new WeakCache<{ id: number }, string>()
|
||||
const key1 = { id: 1 }
|
||||
const key2 = { id: 1 } // Different object with same properties
|
||||
const callback = vi.fn((item: { id: number }) => `value-${item.id}`)
|
||||
|
||||
const _result1 = cache.get(key1, callback)
|
||||
const _result2 = cache.get(key2, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should handle null and undefined return values', () => {
|
||||
const cache = new WeakCache<object, string | null | undefined>()
|
||||
const key1 = { type: 'null' }
|
||||
const key2 = { type: 'undefined' }
|
||||
|
||||
const result1 = cache.get(key1, () => null)
|
||||
const result2 = cache.get(key2, () => undefined)
|
||||
const result3 = cache.get(key1, () => 'should-not-be-called')
|
||||
const result4 = cache.get(key2, () => 'should-not-be-called')
|
||||
|
||||
expect(result1).toBe(null)
|
||||
expect(result2).toBe(undefined)
|
||||
expect(result3).toBe(null)
|
||||
expect(result4).toBe(undefined)
|
||||
})
|
||||
|
||||
it('should not cache errors - callbacks that throw should be re-executed', () => {
|
||||
const cache = new WeakCache<object, string>()
|
||||
const key = { id: 1 }
|
||||
const errorCallback = vi.fn(() => {
|
||||
throw new Error('Computation failed')
|
||||
})
|
||||
|
||||
expect(() => cache.get(key, errorCallback)).toThrow('Computation failed')
|
||||
expect(() => cache.get(key, errorCallback)).toThrow('Computation failed')
|
||||
expect(errorCallback).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
64
packages/utils/src/lib/cache.ts
Normal file
64
packages/utils/src/lib/cache.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* A lightweight cache implementation using WeakMap for storing key-value pairs.
|
||||
*
|
||||
* A micro cache that stores computed values associated with object keys.
|
||||
* Uses WeakMap internally, which means keys can be garbage collected when no other
|
||||
* references exist, and only object keys are supported. Provides lazy computation
|
||||
* with memoization.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const cache = new WeakCache<User, string>()
|
||||
* const user = { id: 1, name: 'Alice' }
|
||||
*
|
||||
* // Get cached value, computing it if not present
|
||||
* const displayName = cache.get(user, (u) => `${u.name} (#${u.id})`)
|
||||
* // Returns 'Alice (#1)'
|
||||
*
|
||||
* // Subsequent calls return cached value
|
||||
* const sameName = cache.get(user, (u) => `${u.name} (#${u.id})`)
|
||||
* // Returns 'Alice (#1)' without recomputing
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export class WeakCache<K extends object, V> {
|
||||
/**
|
||||
* The internal WeakMap storage for cached key-value pairs.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
items = new WeakMap<K, V>()
|
||||
|
||||
/**
|
||||
* Get the cached value for a given key, computing it if not already cached.
|
||||
*
|
||||
* Retrieves the cached value associated with the given key. If no cached
|
||||
* value exists, calls the provided callback function to compute the value, stores it
|
||||
* in the cache, and returns it. Subsequent calls with the same key will return the
|
||||
* cached value without recomputation.
|
||||
*
|
||||
* @param item - The object key to retrieve the cached value for
|
||||
* @param cb - Callback function that computes the value when not already cached
|
||||
* @returns The cached value if it exists, otherwise the newly computed value from the callback
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const cache = new WeakCache<HTMLElement, DOMRect>()
|
||||
* const element = document.getElementById('my-element')!
|
||||
*
|
||||
* // First call computes and caches the bounding rect
|
||||
* const rect1 = cache.get(element, (el) => el.getBoundingClientRect())
|
||||
*
|
||||
* // Second call returns cached value
|
||||
* const rect2 = cache.get(element, (el) => el.getBoundingClientRect())
|
||||
* // rect1 and rect2 are the same object
|
||||
* ```
|
||||
*/
|
||||
get<P extends K>(item: P, cb: (item: P) => V) {
|
||||
if (!this.items.has(item)) {
|
||||
this.items.set(item, cb(item))
|
||||
}
|
||||
|
||||
return this.items.get(item)!
|
||||
}
|
||||
}
|
||||
50
packages/utils/src/lib/control.test.ts
Normal file
50
packages/utils/src/lib/control.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assert, assertExists, exhaustiveSwitchError, promiseWithResolve } from './control'
|
||||
|
||||
describe('exhaustiveSwitchError', () => {
|
||||
it('should throw an error with the unhandled value', () => {
|
||||
expect(() => exhaustiveSwitchError('unhandled' as never)).toThrow(
|
||||
'Unknown switch case unhandled'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw with property value when provided', () => {
|
||||
const value = { type: 'unknown', data: 'test' } as never
|
||||
expect(() => exhaustiveSwitchError(value, 'type')).toThrow('Unknown switch case unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('assert', () => {
|
||||
it('should throw with custom message when provided', () => {
|
||||
expect(() => assert(false, 'Custom error message')).toThrow('Custom error message')
|
||||
expect(() => assert(null, 'Value cannot be null')).toThrow('Value cannot be null')
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertExists', () => {
|
||||
it('should throw with custom message when provided', () => {
|
||||
expect(() => assertExists(null, 'Custom null message')).toThrow('Custom null message')
|
||||
expect(() => assertExists(undefined, 'Custom undefined message')).toThrow(
|
||||
'Custom undefined message'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('promiseWithResolve', () => {
|
||||
it('should resolve when resolve is called', async () => {
|
||||
const deferred = promiseWithResolve<string>()
|
||||
|
||||
setTimeout(() => deferred.resolve('resolved value'), 0)
|
||||
|
||||
const result = await deferred
|
||||
expect(result).toBe('resolved value')
|
||||
})
|
||||
|
||||
it('should reject when reject is called', async () => {
|
||||
const deferred = promiseWithResolve<string>()
|
||||
|
||||
setTimeout(() => deferred.reject('rejected reason'), 0)
|
||||
|
||||
await expect(deferred).rejects.toBe('rejected reason')
|
||||
})
|
||||
})
|
||||
275
packages/utils/src/lib/control.ts
Normal file
275
packages/utils/src/lib/control.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { omitFromStackTrace } from './function'
|
||||
|
||||
/**
|
||||
* Represents a successful result containing a value.
|
||||
*
|
||||
* Interface for the success case of a Result type, containing the computed value.
|
||||
* Used in conjunction with ErrorResult to create a discriminated union for error handling.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const success: OkResult<string> = { ok: true, value: 'Hello World' }
|
||||
* if (success.ok) {
|
||||
* console.log(success.value) // 'Hello World'
|
||||
* }
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export interface OkResult<T> {
|
||||
readonly ok: true
|
||||
readonly value: T
|
||||
}
|
||||
/**
|
||||
* Represents a failed result containing an error.
|
||||
*
|
||||
* Interface for the error case of a Result type, containing the error information.
|
||||
* Used in conjunction with OkResult to create a discriminated union for error handling.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const failure: ErrorResult<string> = { ok: false, error: 'Something went wrong' }
|
||||
* if (!failure.ok) {
|
||||
* console.error(failure.error) // 'Something went wrong'
|
||||
* }
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export interface ErrorResult<E> {
|
||||
readonly ok: false
|
||||
readonly error: E
|
||||
}
|
||||
/**
|
||||
* A discriminated union type for handling success and error cases.
|
||||
*
|
||||
* Represents either a successful result with a value or a failed result with an error.
|
||||
* This pattern provides type-safe error handling without throwing exceptions. The 'ok' property
|
||||
* serves as the discriminant for type narrowing.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* function divide(a: number, b: number): Result<number, string> {
|
||||
* if (b === 0) {
|
||||
* return Result.err('Division by zero')
|
||||
* }
|
||||
* return Result.ok(a / b)
|
||||
* }
|
||||
*
|
||||
* const result = divide(10, 2)
|
||||
* if (result.ok) {
|
||||
* console.log(`Result: ${result.value}`) // Result: 5
|
||||
* } else {
|
||||
* console.error(`Error: ${result.error}`)
|
||||
* }
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export type Result<T, E> = OkResult<T> | ErrorResult<E>
|
||||
|
||||
/**
|
||||
* Utility object for creating Result instances.
|
||||
*
|
||||
* Provides factory methods for creating OkResult and ErrorResult instances.
|
||||
* This is the preferred way to construct Result values for consistent structure.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Create success result
|
||||
* const success = Result.ok(42)
|
||||
* // success: OkResult<number> = { ok: true, value: 42 }
|
||||
*
|
||||
* // Create error result
|
||||
* const failure = Result.err('Invalid input')
|
||||
* // failure: ErrorResult<string> = { ok: false, error: 'Invalid input' }
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export const Result = {
|
||||
/**
|
||||
* Create a successful result containing a value.
|
||||
*
|
||||
* @param value - The success value to wrap
|
||||
* @returns An OkResult containing the value
|
||||
*/
|
||||
ok<T>(value: T): OkResult<T> {
|
||||
return { ok: true, value }
|
||||
},
|
||||
/**
|
||||
* Create a failed result containing an error.
|
||||
*
|
||||
* @param error - The error value to wrap
|
||||
* @returns An ErrorResult containing the error
|
||||
*/
|
||||
err<E>(error: E): ErrorResult<E> {
|
||||
return { ok: false, error }
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a successful result containing an array of values.
|
||||
*
|
||||
* If any of the results are errors, the returned result will be an error containing the first error.
|
||||
*
|
||||
* @param results - The array of results to wrap
|
||||
* @returns An OkResult containing the array of values
|
||||
*/
|
||||
all<T>(results: Result<T, any>[]): Result<T[], any> {
|
||||
return results.every((result) => result.ok)
|
||||
? Result.ok(results.map((result) => result.value))
|
||||
: Result.err(results.find((result) => !result.ok)?.error)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an error for unhandled switch cases in exhaustive switch statements.
|
||||
*
|
||||
* Utility function to ensure exhaustive handling of discriminated unions in switch
|
||||
* statements. When called, it indicates a programming error where a case was not handled.
|
||||
* The TypeScript 'never' type ensures this function is only reachable if all cases aren't covered.
|
||||
*
|
||||
* @param value - The unhandled value (typed as 'never' for exhaustiveness checking)
|
||||
* @param property - Optional property name to extract from the value for better error messages
|
||||
* @returns Never returns (always throws)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* type Shape = 'circle' | 'square' | 'triangle'
|
||||
*
|
||||
* function getArea(shape: Shape): number {
|
||||
* switch (shape) {
|
||||
* case 'circle': return Math.PI * 5 * 5
|
||||
* case 'square': return 10 * 10
|
||||
* case 'triangle': return 0.5 * 10 * 8
|
||||
* default: return exhaustiveSwitchError(shape)
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function exhaustiveSwitchError(value: never, property?: string): never {
|
||||
const debugValue =
|
||||
property && value && typeof value === 'object' && property in value ? value[property] : value
|
||||
throw new Error(`Unknown switch case ${debugValue}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a value is truthy, throwing an error if it's not.
|
||||
*
|
||||
* TypeScript assertion function that throws an error if the provided value is falsy.
|
||||
* After this function executes successfully, TypeScript narrows the type to exclude falsy values.
|
||||
* Stack trace is omitted from the error for cleaner debugging.
|
||||
*
|
||||
* @param value - The value to assert as truthy
|
||||
* @param message - Optional custom error message
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const user = getUser() // User | null
|
||||
* assert(user, 'User must be logged in')
|
||||
* // TypeScript now knows user is non-null
|
||||
* console.log(user.name) // Safe to access properties
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export const assert: (value: unknown, message?: string) => asserts value = omitFromStackTrace(
|
||||
(value, message) => {
|
||||
if (!value) {
|
||||
throw new Error(message || 'Assertion Error')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Assert that a value is not null or undefined.
|
||||
*
|
||||
* Throws an error if the value is null or undefined, otherwise returns the value
|
||||
* with a refined type that excludes null and undefined. Stack trace is omitted for cleaner debugging.
|
||||
*
|
||||
* @param value - The value to check for null/undefined
|
||||
* @param message - Optional custom error message
|
||||
* @returns The value with null and undefined excluded from the type
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const element = document.getElementById('my-id') // HTMLElement | null
|
||||
* const safeElement = assertExists(element, 'Element not found')
|
||||
* // TypeScript now knows safeElement is HTMLElement (not null)
|
||||
* safeElement.addEventListener('click', handler) // Safe to call methods
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export const assertExists = omitFromStackTrace(<T>(value: T, message?: string): NonNullable<T> => {
|
||||
// note that value == null is equivalent to value === null || value === undefined
|
||||
if (value == null) {
|
||||
throw new Error(message ?? 'value must be defined')
|
||||
}
|
||||
return value as NonNullable<T>
|
||||
})
|
||||
|
||||
/**
|
||||
* Create a Promise with externally accessible resolve and reject functions.
|
||||
*
|
||||
* Creates a Promise along with its resolve and reject functions exposed as
|
||||
* properties on the returned object. This allows external code to control when the
|
||||
* Promise resolves or rejects, useful for coordination between async operations.
|
||||
*
|
||||
* @returns A Promise object with additional resolve and reject methods
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const deferred = promiseWithResolve<string>()
|
||||
*
|
||||
* // Set up the promise consumer
|
||||
* deferred.then(value => console.log(`Resolved: ${value}`))
|
||||
* deferred.catch(error => console.error(`Rejected: ${error}`))
|
||||
*
|
||||
* // Later, resolve from external code
|
||||
* setTimeout(() => {
|
||||
* deferred.resolve('Hello World')
|
||||
* }, 1000)
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function promiseWithResolve<T>(): Promise<T> & {
|
||||
resolve(value: T): void
|
||||
reject(reason?: any): void
|
||||
} {
|
||||
let resolve: (value: T) => void
|
||||
let reject: (reason?: any) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return Object.assign(promise, {
|
||||
resolve: resolve!,
|
||||
reject: reject!,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Promise that resolves after a specified delay.
|
||||
*
|
||||
* Utility function for introducing delays in async code. Returns a Promise
|
||||
* that resolves with undefined after the specified number of milliseconds. Useful for
|
||||
* implementing timeouts, rate limiting, or adding delays in testing scenarios.
|
||||
*
|
||||
* @param ms - The delay in milliseconds
|
||||
* @returns A Promise that resolves after the specified delay
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* async function delayedOperation() {
|
||||
* console.log('Starting...')
|
||||
* await sleep(1000) // Wait 1 second
|
||||
* console.log('Done!')
|
||||
* }
|
||||
*
|
||||
* // Can also be used with .then()
|
||||
* sleep(500).then(() => {
|
||||
* console.log('Half second has passed')
|
||||
* })
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
128
packages/utils/src/lib/debounce.test.ts
Normal file
128
packages/utils/src/lib/debounce.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { vi } from 'vitest'
|
||||
import { debounce } from './debounce'
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
describe(debounce, () => {
|
||||
it('should debounce a function', async () => {
|
||||
const fn = vi.fn()
|
||||
const debounced = debounce(fn, 100)
|
||||
debounced()
|
||||
debounced()
|
||||
debounced()
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should debounce a function with arguments', async () => {
|
||||
const fn = vi.fn()
|
||||
const debounced = debounce(fn, 100)
|
||||
debounced('a', 'b')
|
||||
debounced('a', 'b')
|
||||
debounced('a', 'b')
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
expect(fn).toHaveBeenCalledWith('a', 'b')
|
||||
})
|
||||
|
||||
it('should debounce a function with arguments and return a promise', async () => {
|
||||
const fn = vi.fn((a, b) => a + b)
|
||||
const debounced = debounce(fn, 100)
|
||||
const promiseA = debounced('a', 'b')
|
||||
const promiseB = debounced('c', 'd')
|
||||
const promiseC = debounced('e', 'f')
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
const results = await Promise.all([promiseA, promiseB, promiseC])
|
||||
|
||||
expect(results).toEqual(['ef', 'ef', 'ef'])
|
||||
})
|
||||
|
||||
it('can be called across multiple debounce windows', async () => {
|
||||
const fn = vi.fn((a, b) => a + b)
|
||||
const debounced = debounce(fn, 100)
|
||||
const promiseA = debounced('a', 'b')
|
||||
const promiseB = debounced('c', 'd')
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(await Promise.all([promiseA, promiseB])).toEqual(['cd', 'cd'])
|
||||
|
||||
const promiseC = debounced('e', 'f')
|
||||
const promiseD = debounced('g', 'h')
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.advanceTimersByTime(200)
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
expect(await Promise.all([promiseC, promiseD])).toEqual(['gh', 'gh'])
|
||||
})
|
||||
|
||||
it('rejects the pending promise when cancelled', async () => {
|
||||
const fn = vi.fn()
|
||||
const debounced = debounce(fn, 100)
|
||||
const promiseA = debounced()
|
||||
const promiseB = debounced()
|
||||
|
||||
debounced.cancel()
|
||||
vi.advanceTimersByTime(200)
|
||||
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
await expect(promiseA).rejects.toThrow('Debounced function was cancelled')
|
||||
await expect(promiseB).rejects.toThrow('Debounced function was cancelled')
|
||||
})
|
||||
|
||||
it('starts a fresh promise after cancel', async () => {
|
||||
const fn = vi.fn((a, b) => a + b)
|
||||
const debounced = debounce(fn, 100)
|
||||
const cancelledPromise = debounced('a', 'b')
|
||||
|
||||
debounced.cancel()
|
||||
await expect(cancelledPromise).rejects.toThrow('Debounced function was cancelled')
|
||||
|
||||
const freshPromise = debounced('c', 'd')
|
||||
expect(freshPromise).not.toBe(cancelledPromise)
|
||||
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
expect(fn).toHaveBeenCalledWith('c', 'd')
|
||||
expect(await freshPromise).toBe('cd')
|
||||
})
|
||||
|
||||
it('cancel is a no-op when no call is pending', async () => {
|
||||
const fn = vi.fn()
|
||||
const debounced = debounce(fn, 100)
|
||||
expect(() => debounced.cancel()).not.toThrow()
|
||||
|
||||
const promise = debounced()
|
||||
debounced.cancel()
|
||||
await expect(promise).rejects.toThrow('Debounced function was cancelled')
|
||||
|
||||
expect(() => debounced.cancel()).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not emit an unhandledrejection when the promise is discarded on cancel', async () => {
|
||||
const handler = vi.fn()
|
||||
process.on('unhandledRejection', handler)
|
||||
try {
|
||||
const fn = vi.fn()
|
||||
const debounced = debounce(fn, 100)
|
||||
debounced()
|
||||
debounced.cancel()
|
||||
|
||||
await new Promise(process.nextTick)
|
||||
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
process.off('unhandledRejection', handler)
|
||||
}
|
||||
})
|
||||
})
|
||||
103
packages/utils/src/lib/debounce.ts
Normal file
103
packages/utils/src/lib/debounce.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { noop } from './function'
|
||||
import type { Awaitable } from './types'
|
||||
|
||||
/**
|
||||
* Create a debounced version of a function that delays execution until after a specified wait time.
|
||||
*
|
||||
* Debouncing ensures that a function is only executed once after a specified delay,
|
||||
* even if called multiple times in rapid succession. Each new call resets the timer. The debounced
|
||||
* function returns a Promise that resolves with the result of the original function. Includes a
|
||||
* cancel method to prevent execution if needed.
|
||||
*
|
||||
* Calling `cancel()` while a call is pending rejects the returned promise with an `Error`
|
||||
* whose message is `'Debounced function was cancelled'`. Callers that discard the promise
|
||||
* are not affected — internal handling suppresses the unhandled-rejection warning — but
|
||||
* any code that `await`s or chains `.then()` on the promise must be prepared to handle the
|
||||
* rejection.
|
||||
*
|
||||
* @param callback - The function to debounce (can be sync or async)
|
||||
* @param wait - The delay in milliseconds before executing the function
|
||||
* @returns A debounced function that returns a Promise and includes a cancel method
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Debounce a search function
|
||||
* const searchAPI = (query: string) => fetch(`/search?q=${query}`)
|
||||
* const debouncedSearch = debounce(searchAPI, 300)
|
||||
*
|
||||
* // Multiple rapid calls will only execute the last one after 300ms
|
||||
* debouncedSearch('react').then(result => console.log(result))
|
||||
* debouncedSearch('react hooks') // This cancels the previous call
|
||||
* debouncedSearch('react typescript') // Only this will execute
|
||||
*
|
||||
* // Cancel pending execution — any in-flight promise rejects
|
||||
* debouncedSearch.cancel()
|
||||
*
|
||||
* // With async/await — wrap in try/catch if you might cancel
|
||||
* const saveData = debounce(async (data: any) => {
|
||||
* return await api.save(data)
|
||||
* }, 1000)
|
||||
*
|
||||
* try {
|
||||
* const result = await saveData({name: 'John'})
|
||||
* } catch (err) {
|
||||
* // handle cancellation or callback error
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
* @see source - https://gist.github.com/ca0v/73a31f57b397606c9813472f7493a940
|
||||
*/
|
||||
export function debounce<T extends unknown[], U>(
|
||||
callback: (...args: T) => Awaitable<U>,
|
||||
wait: number
|
||||
) {
|
||||
let state:
|
||||
| undefined
|
||||
| {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
promise: Promise<U>
|
||||
resolve(value: U | PromiseLike<U>): void
|
||||
reject(value: any): void
|
||||
latestArgs: T
|
||||
} = undefined
|
||||
|
||||
const fn = (...args: T): Promise<U> => {
|
||||
if (!state) {
|
||||
state = {} as any
|
||||
state!.promise = new Promise((resolve, reject) => {
|
||||
state!.resolve = resolve
|
||||
state!.reject = reject
|
||||
})
|
||||
}
|
||||
clearTimeout(state!.timeout)
|
||||
state!.latestArgs = args
|
||||
// It's up to the consumer of debounce to call `cancel`
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
state!.timeout = setTimeout(() => {
|
||||
const s = state!
|
||||
state = undefined
|
||||
try {
|
||||
s.resolve(callback(...s.latestArgs))
|
||||
} catch (e) {
|
||||
s.reject(e)
|
||||
}
|
||||
}, wait)
|
||||
|
||||
return state!.promise
|
||||
}
|
||||
fn.cancel = () => {
|
||||
if (!state) return
|
||||
clearTimeout(state.timeout)
|
||||
const s = state
|
||||
state = undefined
|
||||
// Attach a no-op handler so callers that discard the promise don't
|
||||
// trigger an unhandled-rejection warning. Consumers that did `.then`,
|
||||
// `.catch`, or `await` on the returned promise still observe the
|
||||
// rejection through their own chain.
|
||||
s.promise.catch(noop)
|
||||
s.reject(new Error('Debounced function was cancelled'))
|
||||
}
|
||||
return fn
|
||||
}
|
||||
60
packages/utils/src/lib/error.test.ts
Normal file
60
packages/utils/src/lib/error.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { annotateError, getErrorAnnotations } from './error'
|
||||
|
||||
describe('annotateError', () => {
|
||||
it('should annotate an Error object with tags and extras', () => {
|
||||
const error = new Error('test error')
|
||||
annotateError(error, {
|
||||
tags: { userId: '123', operation: 'save' },
|
||||
extras: { timestamp: 1234567890, data: { key: 'value' } },
|
||||
})
|
||||
|
||||
const annotations = getErrorAnnotations(error)
|
||||
expect(annotations.tags.userId).toBe('123')
|
||||
expect(annotations.tags.operation).toBe('save')
|
||||
expect(annotations.extras.timestamp).toBe(1234567890)
|
||||
expect(annotations.extras.data).toEqual({ key: 'value' })
|
||||
})
|
||||
|
||||
it('should merge annotations from multiple calls', () => {
|
||||
const error = new Error('test error')
|
||||
|
||||
annotateError(error, {
|
||||
tags: { userId: '123', operation: 'save' },
|
||||
extras: { timestamp: 1234567890 },
|
||||
})
|
||||
|
||||
annotateError(error, {
|
||||
tags: { userId: '456', component: 'auth' }, // userId should be overwritten
|
||||
extras: { requestId: 'req456' },
|
||||
})
|
||||
|
||||
const annotations = getErrorAnnotations(error)
|
||||
expect(annotations.tags).toEqual({
|
||||
userId: '456', // overwritten
|
||||
operation: 'save', // preserved
|
||||
component: 'auth', // new
|
||||
})
|
||||
expect(annotations.extras).toEqual({
|
||||
timestamp: 1234567890, // preserved
|
||||
requestId: 'req456', // new
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle non-object inputs gracefully', () => {
|
||||
expect(() => annotateError(null, { tags: { test: 'value' } })).not.toThrow()
|
||||
expect(() => annotateError('string error', { tags: { test: 'value' } })).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getErrorAnnotations', () => {
|
||||
it('should return empty annotations for unannotated errors', () => {
|
||||
const error = new Error('test error')
|
||||
const annotations = getErrorAnnotations(error)
|
||||
|
||||
expect(annotations).toEqual({
|
||||
tags: {},
|
||||
extras: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
67
packages/utils/src/lib/error.ts
Normal file
67
packages/utils/src/lib/error.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/** @public */
|
||||
export interface ErrorAnnotations {
|
||||
tags: Record<string, number | string | boolean | bigint | symbol | null | undefined>
|
||||
extras: Record<string, unknown>
|
||||
}
|
||||
|
||||
const annotationsByError = new WeakMap<object, ErrorAnnotations>()
|
||||
|
||||
/**
|
||||
* Annotate an error with tags and additional data. Annotations won't overwrite existing ones.
|
||||
* Retrieve them with `getErrorAnnotations`.
|
||||
*
|
||||
* @param error - The error object to annotate
|
||||
* @param annotations - Partial annotations to add (tags and/or extras)
|
||||
* @returns void
|
||||
* @example
|
||||
* ```ts
|
||||
* const error = new Error('Something went wrong')
|
||||
* annotateError(error, {
|
||||
* tags: { userId: '123', operation: 'save' },
|
||||
* extras: { timestamp: Date.now() }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function annotateError(error: unknown, annotations: Partial<ErrorAnnotations>) {
|
||||
if (typeof error !== 'object' || error === null) return
|
||||
|
||||
let currentAnnotations = annotationsByError.get(error)
|
||||
if (!currentAnnotations) {
|
||||
currentAnnotations = { tags: {}, extras: {} }
|
||||
annotationsByError.set(error, currentAnnotations)
|
||||
}
|
||||
|
||||
if (annotations.tags) {
|
||||
currentAnnotations.tags = {
|
||||
...currentAnnotations.tags,
|
||||
...annotations.tags,
|
||||
}
|
||||
}
|
||||
if (annotations.extras) {
|
||||
currentAnnotations.extras = {
|
||||
...currentAnnotations.extras,
|
||||
...annotations.extras,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve annotations that have been added to an error object.
|
||||
*
|
||||
* @param error - The error object to get annotations from
|
||||
* @returns The error annotations (tags and extras) or empty objects if none exist
|
||||
* @example
|
||||
* ```ts
|
||||
* const error = new Error('Something went wrong')
|
||||
* annotateError(error, { tags: { userId: '123' } })
|
||||
* const annotations = getErrorAnnotations(error)
|
||||
* console.log(annotations.tags.userId) // '123'
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function getErrorAnnotations(error: Error): ErrorAnnotations {
|
||||
return annotationsByError.get(error) ?? { tags: {}, extras: {} }
|
||||
}
|
||||
49
packages/utils/src/lib/file.test.ts
Normal file
49
packages/utils/src/lib/file.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FileHelpers } from './file'
|
||||
|
||||
describe('FileHelpers', () => {
|
||||
describe('urlToDataUrl', () => {
|
||||
it('should return data URL unchanged if already a data URL', async () => {
|
||||
const dataUrl = 'data:text/plain;base64,SGVsbG8gV29ybGQ='
|
||||
const result = await FileHelpers.urlToDataUrl(dataUrl)
|
||||
|
||||
expect(result).toBe(dataUrl)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewriteMimeType', () => {
|
||||
it('should return the same blob if MIME type matches', () => {
|
||||
const blob = new Blob(['content'], { type: 'text/plain' })
|
||||
const result = FileHelpers.rewriteMimeType(blob, 'text/plain')
|
||||
|
||||
expect(result).toBe(blob)
|
||||
})
|
||||
|
||||
it('should create new blob with different MIME type', () => {
|
||||
const blob = new Blob(['content'], { type: 'text/plain' })
|
||||
const result = FileHelpers.rewriteMimeType(blob, 'application/json')
|
||||
|
||||
expect(result).not.toBe(blob)
|
||||
expect(result.type).toBe('application/json')
|
||||
expect(result.size).toBe(blob.size)
|
||||
})
|
||||
|
||||
it('should return the same file if MIME type matches', () => {
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
const result = FileHelpers.rewriteMimeType(file, 'text/plain')
|
||||
|
||||
expect(result).toBe(file)
|
||||
})
|
||||
|
||||
it('should create new file with different MIME type and preserve name', () => {
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
const result = FileHelpers.rewriteMimeType(file, 'application/json') as File
|
||||
|
||||
expect(result).not.toBe(file)
|
||||
expect(result.type).toBe('application/json')
|
||||
expect(result.name).toBe('test.txt')
|
||||
expect(result.size).toBe(file.size)
|
||||
expect(result).toBeInstanceOf(File)
|
||||
})
|
||||
})
|
||||
})
|
||||
188
packages/utils/src/lib/file.ts
Normal file
188
packages/utils/src/lib/file.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { fetch } from './network'
|
||||
|
||||
/**
|
||||
* Utility class providing helper methods for file and blob operations.
|
||||
*
|
||||
* FileHelpers contains static methods for common file operations including
|
||||
* URL fetching, format conversion, and MIME type manipulation. All methods work with
|
||||
* web APIs like fetch, FileReader, and Blob/File objects.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Fetch and convert a remote image to data URL
|
||||
* const dataUrl = await FileHelpers.urlToDataUrl('https://example.com/image.png')
|
||||
*
|
||||
* // Convert user-selected file to text
|
||||
* const text = await FileHelpers.blobToText(userFile)
|
||||
*
|
||||
* // Change file MIME type
|
||||
* const newFile = FileHelpers.rewriteMimeType(originalFile, 'application/json')
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class FileHelpers {
|
||||
/**
|
||||
* Converts a URL to an ArrayBuffer by fetching the resource.
|
||||
*
|
||||
* Fetches the resource at the given URL and returns its content as an ArrayBuffer.
|
||||
* This is useful for loading binary data like images, videos, or other file types.
|
||||
*
|
||||
* @param url - The URL of the file to fetch
|
||||
* @returns Promise that resolves to the file content as an ArrayBuffer
|
||||
* @example
|
||||
* ```ts
|
||||
* const buffer = await FileHelpers.urlToArrayBuffer('https://example.com/image.png')
|
||||
* console.log(buffer.byteLength) // Size of the file in bytes
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static async urlToArrayBuffer(url: string) {
|
||||
const response = await fetch(url)
|
||||
return await response.arrayBuffer()
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a URL to a Blob by fetching the resource.
|
||||
*
|
||||
* Fetches the resource at the given URL and returns its content as a Blob object.
|
||||
* Blobs are useful for handling file data in web applications.
|
||||
*
|
||||
* @param url - The URL of the file to fetch
|
||||
* @returns Promise that resolves to the file content as a Blob
|
||||
* @example
|
||||
* ```ts
|
||||
* const blob = await FileHelpers.urlToBlob('https://example.com/document.pdf')
|
||||
* console.log(blob.type) // 'application/pdf'
|
||||
* console.log(blob.size) // Size in bytes
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static async urlToBlob(url: string) {
|
||||
const response = await fetch(url)
|
||||
return await response.blob()
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a URL to a data URL by fetching the resource.
|
||||
*
|
||||
* Fetches the resource at the given URL and converts it to a base64-encoded data URL.
|
||||
* If the URL is already a data URL, it returns the URL unchanged. This is useful for embedding
|
||||
* resources directly in HTML or CSS.
|
||||
*
|
||||
* @param url - The URL of the file to convert, or an existing data URL
|
||||
* @returns Promise that resolves to a data URL string
|
||||
* @example
|
||||
* ```ts
|
||||
* const dataUrl = await FileHelpers.urlToDataUrl('https://example.com/image.jpg')
|
||||
* // Returns: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEA...'
|
||||
*
|
||||
* const existing = await FileHelpers.urlToDataUrl('data:text/plain;base64,SGVsbG8=')
|
||||
* // Returns the same data URL unchanged
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static async urlToDataUrl(url: string) {
|
||||
if (url.startsWith('data:')) return url
|
||||
const blob = await FileHelpers.urlToBlob(url)
|
||||
return await FileHelpers.blobToDataUrl(blob)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Blob to a base64 encoded data URL.
|
||||
*
|
||||
* Converts a Blob object to a base64-encoded data URL using the FileReader API.
|
||||
* This is useful for displaying images or embedding file content directly in HTML.
|
||||
*
|
||||
* @param file - The Blob object to convert
|
||||
* @returns Promise that resolves to a base64-encoded data URL string
|
||||
* @example
|
||||
* ```ts
|
||||
* const blob = new Blob(['Hello World'], { type: 'text/plain' })
|
||||
* const dataUrl = await FileHelpers.blobToDataUrl(blob)
|
||||
* // Returns: 'data:text/plain;base64,SGVsbG8gV29ybGQ='
|
||||
*
|
||||
* // With an image file
|
||||
* const imageDataUrl = await FileHelpers.blobToDataUrl(myImageFile)
|
||||
* // Can be used directly in img src attribute
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static async blobToDataUrl(file: Blob): Promise<string> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = (error) => reject(error)
|
||||
reader.onabort = (error) => reject(error)
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Blob to a unicode text string.
|
||||
*
|
||||
* Reads the content of a Blob object as a UTF-8 text string using the FileReader API.
|
||||
* This is useful for reading text files or extracting text content from blobs.
|
||||
*
|
||||
* @param file - The Blob object to convert to text
|
||||
* @returns Promise that resolves to the text content as a string
|
||||
* @example
|
||||
* ```ts
|
||||
* const textBlob = new Blob(['Hello World'], { type: 'text/plain' })
|
||||
* const text = await FileHelpers.blobToText(textBlob)
|
||||
* console.log(text) // 'Hello World'
|
||||
*
|
||||
* // With a text file from user input
|
||||
* const content = await FileHelpers.blobToText(myTextFile)
|
||||
* console.log(content) // File content as string
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static async blobToText(file: Blob): Promise<string> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = (error) => reject(error)
|
||||
reader.onabort = (error) => reject(error)
|
||||
reader.readAsText(file)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Blob or File with a different MIME type.
|
||||
*
|
||||
* Creates a copy of the given Blob or File with a new MIME type while preserving
|
||||
* all other properties. If the current MIME type already matches the new one, returns the
|
||||
* original object unchanged. For File objects, preserves the filename.
|
||||
*
|
||||
* @param blob - The Blob or File object to modify
|
||||
* @param newMimeType - The new MIME type to assign
|
||||
* @returns A new Blob or File with the updated MIME type
|
||||
* @example
|
||||
* ```ts
|
||||
* // Change a generic blob to a specific image type
|
||||
* const blob = new Blob([imageData])
|
||||
* const imageBlob = FileHelpers.rewriteMimeType(blob, 'image/png')
|
||||
*
|
||||
* // Change a file's MIME type while preserving filename
|
||||
* const file = new File([data], 'document.txt', { type: 'text/plain' })
|
||||
* const jsonFile = FileHelpers.rewriteMimeType(file, 'application/json')
|
||||
* console.log(jsonFile.name) // 'document.txt' (preserved)
|
||||
* console.log(jsonFile.type) // 'application/json' (updated)
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static rewriteMimeType(blob: Blob, newMimeType: string): Blob
|
||||
static rewriteMimeType(blob: File, newMimeType: string): File
|
||||
static rewriteMimeType(blob: Blob | File, newMimeType: string): Blob | File {
|
||||
if (blob.type === newMimeType) return blob
|
||||
if (blob instanceof File) {
|
||||
return new File([blob], blob.name, { type: newMimeType })
|
||||
}
|
||||
return new Blob([blob], { type: newMimeType })
|
||||
}
|
||||
}
|
||||
138
packages/utils/src/lib/fractionalIndexing.test.ts
Normal file
138
packages/utils/src/lib/fractionalIndexing.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
generateNJitteredKeysBetween,
|
||||
generateNKeysBetween,
|
||||
validateOrderKey,
|
||||
} from './fractionalIndexing'
|
||||
|
||||
// Single-key helper: the non-jittered generator with n=1.
|
||||
const keyBetween = (a: string | null, b: string | null) => generateNKeysBetween(a, b, 1)[0]
|
||||
|
||||
describe('fractionalIndexing', () => {
|
||||
// Golden vectors from the upstream `fractional-indexing` README. These pin the
|
||||
// exact base-62 output, proving the vendored/specialized core stays
|
||||
// byte-for-byte compatible with the original library.
|
||||
describe('generateKeyBetween (golden vectors)', () => {
|
||||
it.each([
|
||||
[null, null, 'a0'],
|
||||
['a0', null, 'a1'],
|
||||
['a1', null, 'a2'],
|
||||
[null, 'a0', 'Zz'],
|
||||
['a0', 'a1', 'a0V'],
|
||||
['a1', 'a2', 'a1V'],
|
||||
])('between %o and %o is %o', (a, b, expected) => {
|
||||
expect(keyBetween(a, b)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateNKeysBetween (golden vectors)', () => {
|
||||
it.each<[string | null, string | null, number, string[]]>([
|
||||
[null, null, 2, ['a0', 'a1']],
|
||||
['a1', null, 2, ['a2', 'a3']],
|
||||
[null, 'a0', 2, ['Zy', 'Zz']],
|
||||
['a0', 'a1', 2, ['a0G', 'a0V']],
|
||||
[null, null, 5, ['a0', 'a1', 'a2', 'a3', 'a4']],
|
||||
])('%o keys between %o and %o', (a, b, n, expected) => {
|
||||
expect(generateNKeysBetween(a, b, n)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('returns an empty array for n = 0', () => {
|
||||
expect(generateNKeysBetween(null, null, 0)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('errors', () => {
|
||||
it('throws when a >= b', () => {
|
||||
expect(() => keyBetween('a1', 'a0')).toThrow()
|
||||
expect(() => keyBetween('a0', 'a0')).toThrow()
|
||||
})
|
||||
|
||||
it('throws on invalid input keys', () => {
|
||||
expect(() => keyBetween('a00', null)).toThrow() // trailing zero
|
||||
expect(() => keyBetween('', null)).toThrow()
|
||||
expect(() => keyBetween('foo', null)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateOrderKey', () => {
|
||||
it.each(['a0', 'a1', 'a0V', 'Zz', 'a0G', 'b127'])('accepts %s', (key) => {
|
||||
expect(() => validateOrderKey(key)).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'', // empty
|
||||
'a', // integer part too short
|
||||
'1', // invalid head
|
||||
'a00', // trailing zero in fraction
|
||||
'foo', // invalid head
|
||||
'A' + '0'.repeat(26), // the reserved smallest-integer key
|
||||
])('rejects %o', (key) => {
|
||||
expect(() => validateOrderKey(key)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// Invariant tests: independent of exact output, these catch ordering or
|
||||
// digit-mapping regressions across the whole alphabet (e.g. a bad charCode
|
||||
// offset in digitIndex, or carry/borrow bugs when integers roll over).
|
||||
describe('invariants', () => {
|
||||
it('generateNKeysBetween yields strictly increasing, valid, in-bounds keys', () => {
|
||||
const keys = generateNKeysBetween('a0', 'a1', 200)
|
||||
expect(keys).toHaveLength(200)
|
||||
let prev = 'a0'
|
||||
for (const key of keys) {
|
||||
expect(() => validateOrderKey(key)).not.toThrow()
|
||||
expect(key > prev).toBe(true)
|
||||
expect(key < 'a1').toBe(true)
|
||||
prev = key
|
||||
}
|
||||
})
|
||||
|
||||
it('stays ordered while repeatedly inserting after the last key (integer carries)', () => {
|
||||
// 200 sequential keys force the integer part to roll over many digits,
|
||||
// exercising incrementInteger across the full base-62 alphabet.
|
||||
let key = keyBetween(null, null)
|
||||
let prev = ''
|
||||
for (let i = 0; i < 200; i++) {
|
||||
expect(() => validateOrderKey(key)).not.toThrow()
|
||||
expect(key > prev).toBe(true)
|
||||
prev = key
|
||||
key = keyBetween(key, null)
|
||||
}
|
||||
})
|
||||
|
||||
it('stays ordered while repeatedly inserting before the first key (integer borrows)', () => {
|
||||
let key = keyBetween(null, null)
|
||||
let next = 'zzzzz'
|
||||
for (let i = 0; i < 200; i++) {
|
||||
expect(() => validateOrderKey(key)).not.toThrow()
|
||||
expect(key < next).toBe(true)
|
||||
next = key
|
||||
key = keyBetween(null, key)
|
||||
}
|
||||
})
|
||||
|
||||
it('generateNJitteredKeysBetween yields ordered, valid, in-bounds, distinct keys', () => {
|
||||
const keys = generateNJitteredKeysBetween('a0', 'a5', 100)
|
||||
expect(keys).toHaveLength(100)
|
||||
expect(new Set(keys).size).toBe(100)
|
||||
let prev = 'a0'
|
||||
for (const key of keys) {
|
||||
expect(() => validateOrderKey(key)).not.toThrow()
|
||||
expect(key > prev).toBe(true)
|
||||
expect(key < 'a5').toBe(true)
|
||||
prev = key
|
||||
}
|
||||
})
|
||||
|
||||
it('jittered keys land strictly between their neighbours', () => {
|
||||
const a = keyBetween(null, null)
|
||||
const b = keyBetween(a, null)
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const mid = generateNJitteredKeysBetween(a, b, 1)[0]
|
||||
expect(mid > a).toBe(true)
|
||||
expect(mid < b).toBe(true)
|
||||
expect(() => validateOrderKey(mid)).not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
275
packages/utils/src/lib/fractionalIndexing.ts
Normal file
275
packages/utils/src/lib/fractionalIndexing.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
// Fractional indexing, specialized to tldraw's needs.
|
||||
//
|
||||
// This is a vendored and trimmed version of the `fractional-indexing` (by
|
||||
// arv@rocicorp.dev) and `jittered-fractional-indexing` (by
|
||||
// github@nathanhleung.com) packages, both released under CC0-1.0 (public
|
||||
// domain). See https://observablehq.com/@dgreensp/implementing-fractional-indexing
|
||||
// for the original algorithm.
|
||||
//
|
||||
// We only ever use the default base-62 alphabet, so the `digits` parameter has
|
||||
// been specialized away. This lets us hoist the per-call allocations that the
|
||||
// upstream `validateOrderKey` made (notably `digits[0].repeat(26)`) into module
|
||||
// constants, and skip redundant re-validation inside the jitter loop, which are
|
||||
// both on hot paths (every index generation and every IndexKey validation).
|
||||
|
||||
// Digits must be in ascending character-code order.
|
||||
const DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
const ZERO = DIGITS[0] // '0'
|
||||
const LARGEST_DIGIT = DIGITS[DIGITS.length - 1] // 'z'
|
||||
// The reserved "smallest integer" key, which has no valid predecessor.
|
||||
const SMALLEST_INTEGER = 'A' + ZERO.repeat(26)
|
||||
// Number of random bisections used to spread concurrently-generated keys apart,
|
||||
// reducing the chance of collisions when multiple clients insert into the same
|
||||
// gap at once. Each bit costs one extra key generation and ~0.17 characters of
|
||||
// key length, so this trades collision resistance against compute and key size.
|
||||
// 16 keeps collisions below ~0.1% even for ~10 simultaneous same-position
|
||||
// inserts, which is ample headroom for real multiplayer use.
|
||||
const JITTER_BITS = 16
|
||||
|
||||
function getIntegerLength(head: string): number {
|
||||
if (head >= 'a' && head <= 'z') {
|
||||
return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2
|
||||
} else if (head >= 'A' && head <= 'Z') {
|
||||
return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2
|
||||
}
|
||||
throw new Error('invalid order key head: ' + head)
|
||||
}
|
||||
|
||||
function getIntegerPart(key: string): string {
|
||||
const integerPartLength = getIntegerLength(key[0])
|
||||
if (integerPartLength > key.length) {
|
||||
throw new Error('invalid order key: ' + key)
|
||||
}
|
||||
return key.slice(0, integerPartLength)
|
||||
}
|
||||
|
||||
// Map a base-62 digit to its value. Faster than DIGITS.indexOf() (which scans
|
||||
// the alphabet) on the hot key-generation path. DIGITS is in char-code order:
|
||||
// '0'-'9' -> 0-9, 'A'-'Z' -> 10-35, 'a'-'z' -> 36-61.
|
||||
function digitIndex(char: string): number {
|
||||
const code = char.charCodeAt(0)
|
||||
if (code <= 57) return code - 48
|
||||
if (code <= 90) return code - 55
|
||||
return code - 61
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws if `key` is not a canonical order key. Allocation-free in the common
|
||||
* case: no `repeat`/`slice` per call.
|
||||
*/
|
||||
export function validateOrderKey(key: string): void {
|
||||
if (key === SMALLEST_INTEGER) {
|
||||
throw new Error('invalid order key: ' + key)
|
||||
}
|
||||
// getIntegerPart throws if the first character is bad or the key is too short.
|
||||
const i = getIntegerPart(key)
|
||||
// The fractional part (everything after the integer part) must not end in the
|
||||
// smallest digit, as a trailing zero is a non-canonical representation.
|
||||
if (key.length > i.length && key[key.length - 1] === ZERO) {
|
||||
throw new Error('invalid order key: ' + key)
|
||||
}
|
||||
}
|
||||
|
||||
// `a` may be empty string, `b` is null or non-empty string.
|
||||
// `a < b` lexicographically if `b` is non-null. No trailing zeros allowed.
|
||||
function midpoint(a: string, b: string | null): string {
|
||||
if (b != null && a >= b) {
|
||||
throw new Error(a + ' >= ' + b)
|
||||
}
|
||||
if (a.slice(-1) === ZERO || (b && b.slice(-1) === ZERO)) {
|
||||
throw new Error('trailing zero')
|
||||
}
|
||||
if (b) {
|
||||
// Remove the longest common prefix, padding `a` with zeros as we go. We
|
||||
// don't need to pad `b`, because it can't end before `a` while traversing
|
||||
// the common prefix.
|
||||
let n = 0
|
||||
while ((a[n] || ZERO) === b[n]) {
|
||||
n++
|
||||
}
|
||||
if (n > 0) {
|
||||
return b.slice(0, n) + midpoint(a.slice(n), b.slice(n))
|
||||
}
|
||||
}
|
||||
// First digits (or lack of digit) are different.
|
||||
const digitA = a ? digitIndex(a[0]) : 0
|
||||
const digitB = b != null ? digitIndex(b[0]) : DIGITS.length
|
||||
if (digitB - digitA > 1) {
|
||||
const midDigit = Math.round(0.5 * (digitA + digitB))
|
||||
return DIGITS[midDigit]
|
||||
}
|
||||
// First digits are consecutive.
|
||||
if (b && b.length > 1) {
|
||||
return b.slice(0, 1)
|
||||
}
|
||||
// `b` is null or has length 1 (a single digit). The first digit of `a` is the
|
||||
// previous digit to `b`, or the largest digit if `b` is null.
|
||||
return DIGITS[digitA] + midpoint(a.slice(1), null)
|
||||
}
|
||||
|
||||
// May return null, as there is a largest integer.
|
||||
function incrementInteger(x: string): string | null {
|
||||
const [head, ...digs] = x.split('')
|
||||
let carry = true
|
||||
for (let i = digs.length - 1; carry && i >= 0; i--) {
|
||||
const d = digitIndex(digs[i]) + 1
|
||||
if (d === DIGITS.length) {
|
||||
digs[i] = ZERO
|
||||
} else {
|
||||
digs[i] = DIGITS[d]
|
||||
carry = false
|
||||
}
|
||||
}
|
||||
if (carry) {
|
||||
if (head === 'Z') return 'a' + ZERO
|
||||
if (head === 'z') return null
|
||||
const h = String.fromCharCode(head.charCodeAt(0) + 1)
|
||||
if (h > 'a') {
|
||||
digs.push(ZERO)
|
||||
} else {
|
||||
digs.pop()
|
||||
}
|
||||
return h + digs.join('')
|
||||
}
|
||||
return head + digs.join('')
|
||||
}
|
||||
|
||||
// May return null, as there is a smallest integer.
|
||||
function decrementInteger(x: string): string | null {
|
||||
const [head, ...digs] = x.split('')
|
||||
let borrow = true
|
||||
for (let i = digs.length - 1; borrow && i >= 0; i--) {
|
||||
const d = digitIndex(digs[i]) - 1
|
||||
if (d === -1) {
|
||||
digs[i] = LARGEST_DIGIT
|
||||
} else {
|
||||
digs[i] = DIGITS[d]
|
||||
borrow = false
|
||||
}
|
||||
}
|
||||
if (borrow) {
|
||||
if (head === 'a') return 'Z' + LARGEST_DIGIT
|
||||
if (head === 'A') return null
|
||||
const h = String.fromCharCode(head.charCodeAt(0) - 1)
|
||||
if (h < 'Z') {
|
||||
digs.push(LARGEST_DIGIT)
|
||||
} else {
|
||||
digs.pop()
|
||||
}
|
||||
return h + digs.join('')
|
||||
}
|
||||
return head + digs.join('')
|
||||
}
|
||||
|
||||
// Generate a key strictly between `a` and `b`, without validating the inputs.
|
||||
// `a`/`b` are order keys or null (START/END). `a < b` if both are non-null.
|
||||
function keyBetweenUnchecked(a: string | null, b: string | null): string {
|
||||
if (a != null && b != null && a >= b) {
|
||||
throw new Error(a + ' >= ' + b)
|
||||
}
|
||||
if (a == null) {
|
||||
if (b == null) return 'a' + ZERO
|
||||
const ib = getIntegerPart(b)
|
||||
const fb = b.slice(ib.length)
|
||||
if (ib === SMALLEST_INTEGER) {
|
||||
return ib + midpoint('', fb)
|
||||
}
|
||||
if (ib < b) return ib
|
||||
const res = decrementInteger(ib)
|
||||
if (res == null) throw new Error('cannot decrement any more')
|
||||
return res
|
||||
}
|
||||
if (b == null) {
|
||||
const ia = getIntegerPart(a)
|
||||
const fa = a.slice(ia.length)
|
||||
const i = incrementInteger(ia)
|
||||
return i == null ? ia + midpoint(fa, null) : i
|
||||
}
|
||||
const ia = getIntegerPart(a)
|
||||
const fa = a.slice(ia.length)
|
||||
const ib = getIntegerPart(b)
|
||||
const fb = b.slice(ib.length)
|
||||
if (ia === ib) {
|
||||
return ia + midpoint(fa, fb)
|
||||
}
|
||||
const i = incrementInteger(ia)
|
||||
if (i == null) throw new Error('cannot increment any more')
|
||||
if (i < b) return i
|
||||
return ia + midpoint(fa, null)
|
||||
}
|
||||
|
||||
// Generate `n` keys between `a` and `b`, without validating the inputs.
|
||||
// Intermediate keys are valid by construction, so they're never re-validated.
|
||||
function nKeysBetweenUnchecked(a: string | null, b: string | null, n: number): string[] {
|
||||
if (n === 0) return []
|
||||
if (n === 1) return [keyBetweenUnchecked(a, b)]
|
||||
if (b == null) {
|
||||
let c = keyBetweenUnchecked(a, b)
|
||||
const result = [c]
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
c = keyBetweenUnchecked(c, b)
|
||||
result.push(c)
|
||||
}
|
||||
return result
|
||||
}
|
||||
if (a == null) {
|
||||
let c = keyBetweenUnchecked(a, b)
|
||||
const result = [c]
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
c = keyBetweenUnchecked(a, c)
|
||||
result.push(c)
|
||||
}
|
||||
result.reverse()
|
||||
return result
|
||||
}
|
||||
const mid = Math.floor(n / 2)
|
||||
const c = keyBetweenUnchecked(a, b)
|
||||
return [...nKeysBetweenUnchecked(a, c, mid), c, ...nKeysBetweenUnchecked(c, b, n - mid - 1)]
|
||||
}
|
||||
|
||||
// Bisect `[low, high]` JITTER_BITS times, following a random walk, so that keys
|
||||
// generated concurrently land in different sub-ranges. `low`/`high` must already
|
||||
// be valid (or null); the intermediate midpoints are valid by construction.
|
||||
function jitterBetween(low: string | null, high: string | null): string {
|
||||
let mid = keyBetweenUnchecked(low, high)
|
||||
for (let i = 0; i < JITTER_BITS; i++) {
|
||||
if (Math.random() < 0.5) {
|
||||
low = mid
|
||||
} else {
|
||||
high = mid
|
||||
}
|
||||
mid = keyBetweenUnchecked(low, high)
|
||||
}
|
||||
return mid
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate `n` jittered keys evenly spaced between `a` and `b`. Inputs are
|
||||
* validated once; everything downstream is generated, hence trusted.
|
||||
*/
|
||||
export function generateNJitteredKeysBetween(
|
||||
a: string | null,
|
||||
b: string | null,
|
||||
n: number
|
||||
): string[] {
|
||||
if (n === 0) return []
|
||||
if (a != null) validateOrderKey(a)
|
||||
if (b != null) validateOrderKey(b)
|
||||
const keys = nKeysBetweenUnchecked(a, b, n + 1)
|
||||
const result = new Array<string>(n)
|
||||
for (let i = 0; i < n; i++) {
|
||||
result[i] = jitterBetween(keys[i], keys[i + 1])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate `n` keys evenly spaced between `a` and `b`, without jitter. Used in
|
||||
* tests for deterministic output.
|
||||
*/
|
||||
export function generateNKeysBetween(a: string | null, b: string | null, n: number): string[] {
|
||||
if (n === 0) return []
|
||||
if (a != null) validateOrderKey(a)
|
||||
if (b != null) validateOrderKey(b)
|
||||
return nKeysBetweenUnchecked(a, b, n)
|
||||
}
|
||||
43
packages/utils/src/lib/function.ts
Normal file
43
packages/utils/src/lib/function.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* When a function is wrapped in `omitFromStackTrace`, if it throws an error the stack trace won't
|
||||
* include the function itself or any stack frames above it. Useful for assertion-style function
|
||||
* where the error will ideally originate from the call-site rather than within the implementation
|
||||
* of the assert fn.
|
||||
*
|
||||
* Only works in platforms that support `Error.captureStackTrace` (ie v8).
|
||||
*
|
||||
* @param fn - The function to wrap and exclude from stack traces
|
||||
* @returns A wrapped version of the function that omits itself from error stack traces
|
||||
* @example
|
||||
* ```ts
|
||||
* const assertPositive = omitFromStackTrace((value: number) => {
|
||||
* if (value <= 0) throw new Error('Value must be positive')
|
||||
* return value
|
||||
* })
|
||||
*
|
||||
* assertPositive(-1) // Error stack trace will point to this line, not inside assertPositive
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function omitFromStackTrace<Args extends Array<unknown>, Return>(
|
||||
fn: (...args: Args) => Return
|
||||
): (...args: Args) => Return {
|
||||
const wrappedFn = (...args: Args) => {
|
||||
try {
|
||||
return fn(...args)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && Error.captureStackTrace) {
|
||||
Error.captureStackTrace(error, wrappedFn)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return wrappedFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Does nothing, but it's really really good at it.
|
||||
* @internal
|
||||
*/
|
||||
export function noop(): void {}
|
||||
99
packages/utils/src/lib/hash.test.ts
Normal file
99
packages/utils/src/lib/hash.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getHashForBuffer, getHashForObject, getHashForString, lns } from './hash'
|
||||
|
||||
describe('getHashForString', () => {
|
||||
it('should produce consistent hashes for the same input', () => {
|
||||
const input = 'hello world'
|
||||
const hash1 = getHashForString(input)
|
||||
const hash2 = getHashForString(input)
|
||||
|
||||
expect(hash1).toBe(hash2)
|
||||
})
|
||||
|
||||
it('should produce different hashes for different inputs', () => {
|
||||
const hash1 = getHashForString('hello')
|
||||
const hash2 = getHashForString('world')
|
||||
|
||||
expect(hash1).not.toBe(hash2)
|
||||
})
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
const result = getHashForString('')
|
||||
expect(result).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getHashForObject', () => {
|
||||
it('should produce consistent hashes for equivalent objects', () => {
|
||||
const obj1 = { name: 'John', age: 30 }
|
||||
const obj2 = { name: 'John', age: 30 }
|
||||
|
||||
const hash1 = getHashForObject(obj1)
|
||||
const hash2 = getHashForObject(obj2)
|
||||
|
||||
expect(hash1).toBe(hash2)
|
||||
})
|
||||
|
||||
it('should be sensitive to property order', () => {
|
||||
const obj1 = { a: 1, b: 2 }
|
||||
const obj2 = { b: 2, a: 1 }
|
||||
|
||||
const hash1 = getHashForObject(obj1)
|
||||
const hash2 = getHashForObject(obj2)
|
||||
|
||||
expect(hash1).not.toBe(hash2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getHashForBuffer', () => {
|
||||
it('should produce consistent hashes for equivalent buffers', () => {
|
||||
const buffer1 = new ArrayBuffer(4)
|
||||
const buffer2 = new ArrayBuffer(4)
|
||||
const view1 = new DataView(buffer1)
|
||||
const view2 = new DataView(buffer2)
|
||||
|
||||
view1.setUint32(0, 0x12345678)
|
||||
view2.setUint32(0, 0x12345678)
|
||||
|
||||
const hash1 = getHashForBuffer(buffer1)
|
||||
const hash2 = getHashForBuffer(buffer2)
|
||||
|
||||
expect(hash1).toBe(hash2)
|
||||
})
|
||||
|
||||
it('should produce different hashes for different buffer contents', () => {
|
||||
const buffer1 = new ArrayBuffer(4)
|
||||
const buffer2 = new ArrayBuffer(4)
|
||||
const view1 = new DataView(buffer1)
|
||||
const view2 = new DataView(buffer2)
|
||||
|
||||
view1.setUint32(0, 0x12345678)
|
||||
view2.setUint32(0, 0x87654321)
|
||||
|
||||
const hash1 = getHashForBuffer(buffer1)
|
||||
const hash2 = getHashForBuffer(buffer2)
|
||||
|
||||
expect(hash1).not.toBe(hash2)
|
||||
})
|
||||
|
||||
it('should handle empty buffers', () => {
|
||||
const buffer = new ArrayBuffer(0)
|
||||
const result = getHashForBuffer(buffer)
|
||||
expect(result).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('lns', () => {
|
||||
it('should produce consistent results for the same input', () => {
|
||||
const input = 'test123'
|
||||
const result1 = lns(input)
|
||||
const result2 = lns(input)
|
||||
|
||||
expect(result1).toBe(result2)
|
||||
})
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
const result = lns('')
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
117
packages/utils/src/lib/hash.ts
Normal file
117
packages/utils/src/lib/hash.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Hash a string using the FNV-1a algorithm.
|
||||
*
|
||||
* Generates a deterministic hash value for a given string using a variant of the FNV-1a
|
||||
* (Fowler-Noll-Vo) algorithm. The hash is returned as a string representation of a 32-bit integer.
|
||||
*
|
||||
* @param string - The input string to hash
|
||||
* @returns A string representation of the 32-bit hash value
|
||||
* @example
|
||||
* ```ts
|
||||
* const hash = getHashForString('hello world')
|
||||
* console.log(hash) // '-862545276'
|
||||
*
|
||||
* // Same input always produces same hash
|
||||
* const hash2 = getHashForString('hello world')
|
||||
* console.log(hash === hash2) // true
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function getHashForString(string: string) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
hash = (hash << 5) - hash + string.charCodeAt(i)
|
||||
hash |= 0 // Convert to 32bit integer
|
||||
}
|
||||
return hash + ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash an object by converting it to JSON and then hashing the resulting string.
|
||||
*
|
||||
* Converts the object to a JSON string using JSON.stringify and then applies the same
|
||||
* hashing algorithm as getHashForString. Useful for creating consistent hash values
|
||||
* for objects, though the hash depends on JSON serialization order.
|
||||
*
|
||||
* @param obj - The object to hash (any JSON-serializable value)
|
||||
* @returns A string representation of the 32-bit hash value
|
||||
* @example
|
||||
* ```ts
|
||||
* const hash1 = getHashForObject({ name: 'John', age: 30 })
|
||||
* const hash2 = getHashForObject({ name: 'John', age: 30 })
|
||||
* console.log(hash1 === hash2) // true
|
||||
*
|
||||
* // Arrays work too
|
||||
* const arrayHash = getHashForObject([1, 2, 3, 'hello'])
|
||||
* console.log(arrayHash) // '-123456789'
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function getHashForObject(obj: any) {
|
||||
return getHashForString(JSON.stringify(obj))
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash an ArrayBuffer using the FNV-1a algorithm.
|
||||
*
|
||||
* Generates a deterministic hash value for binary data stored in an ArrayBuffer.
|
||||
* Processes the buffer byte by byte using the same hashing algorithm as getHashForString.
|
||||
* Useful for creating consistent identifiers for binary data like images or files.
|
||||
*
|
||||
* @param buffer - The ArrayBuffer containing binary data to hash
|
||||
* @returns A string representation of the 32-bit hash value
|
||||
* @example
|
||||
* ```ts
|
||||
* // Hash some binary data
|
||||
* const data = new Uint8Array([1, 2, 3, 4, 5])
|
||||
* const hash = getHashForBuffer(data.buffer)
|
||||
* console.log(hash) // '123456789'
|
||||
*
|
||||
* // Hash image file data
|
||||
* const fileBuffer = await file.arrayBuffer()
|
||||
* const fileHash = getHashForBuffer(fileBuffer)
|
||||
* console.log(fileHash) // Unique hash for the file
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function getHashForBuffer(buffer: ArrayBuffer) {
|
||||
const view = new DataView(buffer)
|
||||
let hash = 0
|
||||
for (let i = 0; i < view.byteLength; i++) {
|
||||
hash = (hash << 5) - hash + view.getUint8(i)
|
||||
hash |= 0 // Convert to 32bit integer
|
||||
}
|
||||
return hash + ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a string transformation algorithm that rearranges and modifies characters.
|
||||
*
|
||||
* Performs a series of character manipulations on the input string including
|
||||
* character repositioning through splicing operations and numeric character transformations.
|
||||
* This appears to be a custom encoding/obfuscation function.
|
||||
*
|
||||
* @param str - The input string to transform
|
||||
* @returns The transformed string after applying all manipulations
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = lns('hello123')
|
||||
* console.log(result) // Transformed string (exact output depends on algorithm)
|
||||
*
|
||||
* // Can be used for simple string obfuscation
|
||||
* const obfuscated = lns('sensitive-data')
|
||||
* console.log(obfuscated) // Obfuscated version
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function lns(str: string) {
|
||||
const result = str.split('')
|
||||
result.push(...result.splice(0, Math.round(result.length / 5)))
|
||||
result.push(...result.splice(0, Math.round(result.length / 4)))
|
||||
result.push(...result.splice(0, Math.round(result.length / 3)))
|
||||
result.push(...result.splice(0, Math.round(result.length / 2)))
|
||||
return result
|
||||
.reverse()
|
||||
.map((n) => (+n ? (+n < 5 ? 5 + +n : +n > 5 ? +n - 5 : n) : n))
|
||||
.join('')
|
||||
}
|
||||
32
packages/utils/src/lib/id.test.ts
Normal file
32
packages/utils/src/lib/id.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { afterEach, describe, expect, test } from 'vitest'
|
||||
import { mockUniqueId, restoreUniqueId, uniqueId } from './id'
|
||||
|
||||
describe('mockUniqueId', () => {
|
||||
afterEach(() => {
|
||||
restoreUniqueId()
|
||||
})
|
||||
|
||||
test('replaces uniqueId with custom implementation', () => {
|
||||
mockUniqueId(() => 'test-id')
|
||||
|
||||
expect(uniqueId()).toBe('test-id')
|
||||
expect(uniqueId(10)).toBe('test-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreUniqueId', () => {
|
||||
test('restores original uniqueId behavior after mocking', () => {
|
||||
mockUniqueId(() => 'mocked-id')
|
||||
expect(uniqueId()).toBe('mocked-id')
|
||||
|
||||
restoreUniqueId()
|
||||
|
||||
const id1 = uniqueId()
|
||||
const id2 = uniqueId()
|
||||
|
||||
expect(id1).not.toBe('mocked-id')
|
||||
expect(id2).not.toBe('mocked-id')
|
||||
expect(id1).not.toBe(id2)
|
||||
expect(id1).toHaveLength(21)
|
||||
})
|
||||
})
|
||||
129
packages/utils/src/lib/id.ts
Normal file
129
packages/utils/src/lib/id.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/*!
|
||||
* MIT License: https://github.com/ai/nanoid/blob/main/LICENSE
|
||||
* Modified code originally from <https://github.com/ai/nanoid>
|
||||
* Copyright 2017 Andrey Sitnik <andrey@sitnik.ru>
|
||||
*
|
||||
* `nanoid` is currently only distributed as an ES module. Some tools (jest, playwright) don't
|
||||
* properly support ESM-only code yet, and tldraw itself is distributed as both an ES module and a
|
||||
* CommonJS module. By including nanoid here, we can make sure it works well in every environment
|
||||
* where tldraw is used. We can also remove some unused features like custom alphabets.
|
||||
*/
|
||||
|
||||
// all environments that tldraw runs in (browser, workers, recent node versions) have global
|
||||
// `crypto`
|
||||
const crypto = globalThis.crypto
|
||||
|
||||
// This alphabet uses `A-Za-z0-9_-` symbols.
|
||||
// The order of characters is optimized for better gzip and brotli compression.
|
||||
// Same as in non-secure/index.js
|
||||
const urlAlphabet = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict'
|
||||
|
||||
// It is best to make fewer, larger requests to the crypto module to
|
||||
// avoid system call overhead. So, random numbers are generated in a
|
||||
// pool. The pool is a Buffer that is larger than the initial random
|
||||
// request size by this multiplier. The pool is enlarged if subsequent
|
||||
// requests exceed the maximum buffer size.
|
||||
const POOL_SIZE_MULTIPLIER = 128
|
||||
let pool: Uint8Array<ArrayBuffer>, poolOffset: number
|
||||
|
||||
function fillPool(bytes: number) {
|
||||
if (!pool || pool.length < bytes) {
|
||||
pool = new Uint8Array(bytes * POOL_SIZE_MULTIPLIER)
|
||||
crypto.getRandomValues(pool)
|
||||
poolOffset = 0
|
||||
} else if (poolOffset + bytes > pool.length) {
|
||||
crypto.getRandomValues(pool)
|
||||
poolOffset = 0
|
||||
}
|
||||
poolOffset += bytes
|
||||
}
|
||||
|
||||
function nanoid(size = 21) {
|
||||
// `-=` convert `size` to number to prevent `valueOf` abusing
|
||||
fillPool((size -= 0))
|
||||
let id = ''
|
||||
// We are reading directly from the random pool to avoid creating new array
|
||||
for (let i = poolOffset - size; i < poolOffset; i++) {
|
||||
// It is incorrect to use bytes exceeding the alphabet size.
|
||||
// The following mask reduces the random byte in the 0-255 value
|
||||
// range to the 0-63 value range. Therefore, adding hacks, such
|
||||
// as empty string fallback or magic numbers, is unnecessary because
|
||||
// the bitmask trims bytes down to the alphabet size.
|
||||
id += urlAlphabet[pool[i] & 63]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
let impl = nanoid
|
||||
/**
|
||||
* Mock the unique ID generator with a custom implementation for testing.
|
||||
*
|
||||
* Replaces the internal ID generation function with a custom one. This is useful
|
||||
* for testing scenarios where you need predictable or deterministic IDs.
|
||||
*
|
||||
* @param fn - The mock function that should return a string ID. Takes optional size parameter.
|
||||
* @example
|
||||
* ```ts
|
||||
* // Mock with predictable IDs for testing
|
||||
* mockUniqueId((size = 21) => 'test-id-' + size)
|
||||
* console.log(uniqueId()) // 'test-id-21'
|
||||
* console.log(uniqueId(10)) // 'test-id-10'
|
||||
*
|
||||
* // Restore original implementation when done
|
||||
* restoreUniqueId()
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function mockUniqueId(fn: (size?: number) => string) {
|
||||
impl = fn
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the original unique ID generator after mocking.
|
||||
*
|
||||
* Resets the ID generation function back to the original nanoid implementation.
|
||||
* This should be called after testing to restore normal ID generation behavior.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // After mocking for tests
|
||||
* mockUniqueId(() => 'mock-id')
|
||||
*
|
||||
* // Restore original behavior
|
||||
* restoreUniqueId()
|
||||
* console.log(uniqueId()) // Now generates real random IDs again
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function restoreUniqueId() {
|
||||
impl = nanoid
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID using a modified nanoid algorithm.
|
||||
*
|
||||
* Generates a cryptographically secure random string ID using URL-safe characters.
|
||||
* The default size is 21 characters, which provides a good balance of uniqueness
|
||||
* and brevity. Uses the global crypto API for secure random number generation.
|
||||
*
|
||||
* @param size - Optional length of the generated ID (defaults to 21 characters)
|
||||
* @returns A unique string identifier
|
||||
* @example
|
||||
* ```ts
|
||||
* // Generate default 21-character ID
|
||||
* const id = uniqueId()
|
||||
* console.log(id) // 'V1StGXR8_Z5jdHi6B-myT'
|
||||
*
|
||||
* // Generate shorter ID
|
||||
* const shortId = uniqueId(10)
|
||||
* console.log(shortId) // 'V1StGXR8_Z'
|
||||
*
|
||||
* // Generate longer ID
|
||||
* const longId = uniqueId(32)
|
||||
* console.log(longId) // 'V1StGXR8_Z5jdHi6B-myTVKahvjdx...'
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function uniqueId(size?: number): string {
|
||||
return impl(size)
|
||||
}
|
||||
25
packages/utils/src/lib/iterable.test.ts
Normal file
25
packages/utils/src/lib/iterable.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getFirstFromIterable } from './iterable'
|
||||
|
||||
describe('getFirstFromIterable', () => {
|
||||
it('should get first item from Set', () => {
|
||||
const set = new Set([1, 2, 3])
|
||||
expect(getFirstFromIterable(set)).toBe(1)
|
||||
})
|
||||
|
||||
it('should get first value from Map', () => {
|
||||
const map = new Map([
|
||||
['a', 1],
|
||||
['b', 2],
|
||||
])
|
||||
expect(getFirstFromIterable(map)).toBe(1)
|
||||
})
|
||||
|
||||
it('should preserve insertion order', () => {
|
||||
const set = new Set()
|
||||
set.add('third')
|
||||
set.add('first')
|
||||
set.add('second')
|
||||
expect(getFirstFromIterable(set)).toBe('third')
|
||||
})
|
||||
})
|
||||
20
packages/utils/src/lib/iterable.ts
Normal file
20
packages/utils/src/lib/iterable.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Get the first item from an iterable Set or Map.
|
||||
*
|
||||
* @param value - The iterable Set or Map to get the first item from
|
||||
* @returns The first value from the Set or Map
|
||||
* @example
|
||||
* ```ts
|
||||
* const A = getFirstFromIterable(new Set([1, 2, 3])) // 1
|
||||
* const B = getFirstFromIterable(
|
||||
* new Map([
|
||||
* ['a', 1],
|
||||
* ['b', 2],
|
||||
* ])
|
||||
* ) // 1
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function getFirstFromIterable<T = unknown>(set: Set<T> | Map<any, T>): T {
|
||||
return set.values().next().value!
|
||||
}
|
||||
77
packages/utils/src/lib/json-value.ts
Normal file
77
packages/utils/src/lib/json-value.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* A type that represents any valid JSON value. This includes primitives (boolean, null, string, number),
|
||||
* arrays of JSON values, and objects with string keys and JSON values.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const jsonData: JsonValue = {
|
||||
* name: "Alice",
|
||||
* age: 30,
|
||||
* active: true,
|
||||
* tags: ["user", "premium"],
|
||||
* metadata: null
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type JsonValue = JsonPrimitive | JsonArray | JsonObject
|
||||
|
||||
/**
|
||||
* A type representing JSON primitive values: boolean, null, string, or number.
|
||||
* These are the atomic values that can appear in JSON data.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const primitives: JsonPrimitive[] = [
|
||||
* true,
|
||||
* null,
|
||||
* "hello",
|
||||
* 42
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type JsonPrimitive = boolean | null | string | number
|
||||
|
||||
/**
|
||||
* A type representing a JSON array containing any valid JSON values.
|
||||
* Arrays can contain mixed types of JSON values including nested arrays and objects.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const jsonArray: JsonArray = [
|
||||
* "text",
|
||||
* 123,
|
||||
* true,
|
||||
* { nested: "object" },
|
||||
* [1, 2, 3]
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type JsonArray = JsonValue[]
|
||||
|
||||
/**
|
||||
* A type representing a JSON object with string keys and JSON values.
|
||||
* Object values can be undefined to handle optional properties safely.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const jsonObject: JsonObject = {
|
||||
* required: "value",
|
||||
* optional: undefined,
|
||||
* nested: {
|
||||
* deep: "property"
|
||||
* },
|
||||
* array: [1, 2, 3]
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface JsonObject {
|
||||
[key: string]: JsonValue | undefined
|
||||
}
|
||||
67
packages/utils/src/lib/media/apng.test.ts
Normal file
67
packages/utils/src/lib/media/apng.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isApngAnimated } from './apng'
|
||||
|
||||
describe('isApngAnimated', () => {
|
||||
it('returns false for empty ArrayBuffer', () => {
|
||||
const buffer = new ArrayBuffer(0)
|
||||
expect(isApngAnimated(buffer)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for buffer too small to be PNG', () => {
|
||||
const buffer = new ArrayBuffer(8)
|
||||
expect(isApngAnimated(buffer)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for non-PNG data', () => {
|
||||
const buffer = new ArrayBuffer(20)
|
||||
const view = new Uint8Array(buffer)
|
||||
// JPEG signature instead
|
||||
view.set([0xff, 0xd8, 0xff, 0xe0])
|
||||
expect(isApngAnimated(buffer)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for regular PNG with IDAT but no acTL', () => {
|
||||
const buffer = new ArrayBuffer(100)
|
||||
const view = new Uint8Array(buffer)
|
||||
|
||||
// PNG signature
|
||||
view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0)
|
||||
|
||||
// Add IDAT chunk at position 20
|
||||
view.set(new TextEncoder().encode('IDAT'), 20)
|
||||
|
||||
expect(isApngAnimated(buffer)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when acTL comes after IDAT', () => {
|
||||
const buffer = new ArrayBuffer(100)
|
||||
const view = new Uint8Array(buffer)
|
||||
|
||||
// PNG signature
|
||||
view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0)
|
||||
|
||||
// IDAT chunk at position 20
|
||||
view.set(new TextEncoder().encode('IDAT'), 20)
|
||||
|
||||
// acTL chunk after IDAT - should not count as animated
|
||||
view.set(new TextEncoder().encode('acTL'), 30)
|
||||
|
||||
expect(isApngAnimated(buffer)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for APNG with acTL before IDAT', () => {
|
||||
const buffer = new ArrayBuffer(100)
|
||||
const view = new Uint8Array(buffer)
|
||||
|
||||
// PNG signature
|
||||
view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0)
|
||||
|
||||
// acTL chunk before IDAT
|
||||
view.set(new TextEncoder().encode('acTL'), 15)
|
||||
|
||||
// IDAT chunk
|
||||
view.set(new TextEncoder().encode('IDAT'), 30)
|
||||
|
||||
expect(isApngAnimated(buffer)).toBe(true)
|
||||
})
|
||||
})
|
||||
167
packages/utils/src/lib/media/apng.ts
Normal file
167
packages/utils/src/lib/media/apng.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/*!
|
||||
* MIT License: https://github.com/vHeemstra/is-apng/blob/main/license
|
||||
* Copyright (c) Philip van Heemstra
|
||||
*/
|
||||
|
||||
/**
|
||||
* Determines whether an ArrayBuffer contains an animated PNG (APNG) image.
|
||||
*
|
||||
* This function checks if the provided buffer contains a valid PNG file with animation
|
||||
* control chunks (acTL) that precede the image data chunks (IDAT), which indicates
|
||||
* it's an animated PNG rather than a static PNG.
|
||||
*
|
||||
* @param buffer - The ArrayBuffer containing the image data to analyze
|
||||
* @returns True if the buffer contains an animated PNG, false otherwise
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Check if an uploaded file contains an animated PNG
|
||||
* if (file.type === 'image/apng') {
|
||||
* const isAnimated = isApngAnimated(await file.arrayBuffer())
|
||||
* console.log(isAnimated ? 'Animated PNG' : 'Static PNG')
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Use with fetch to check remote images
|
||||
* const response = await fetch('image.png')
|
||||
* const buffer = await response.arrayBuffer()
|
||||
* const hasAnimation = isApngAnimated(buffer)
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function isApngAnimated(buffer: ArrayBuffer): boolean {
|
||||
const view = new Uint8Array(buffer)
|
||||
|
||||
if (
|
||||
!view ||
|
||||
!((typeof Buffer !== 'undefined' && Buffer.isBuffer(view)) || view instanceof Uint8Array) ||
|
||||
view.length < 16
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isPNG =
|
||||
view[0] === 0x89 &&
|
||||
view[1] === 0x50 &&
|
||||
view[2] === 0x4e &&
|
||||
view[3] === 0x47 &&
|
||||
view[4] === 0x0d &&
|
||||
view[5] === 0x0a &&
|
||||
view[6] === 0x1a &&
|
||||
view[7] === 0x0a
|
||||
|
||||
if (!isPNG) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the first occurrence of a string pattern in a Uint8Array, or -1 if not found.
|
||||
*
|
||||
* Searches for a string pattern by decoding chunks of the byte array to UTF-8 text and using
|
||||
* regular expression matching. Handles cases where the pattern might be split across chunk boundaries.
|
||||
*
|
||||
* @param haystack - The Uint8Array to search in
|
||||
* @param needle - The string or RegExp pattern to locate
|
||||
* @param fromIndex - The array index at which to begin the search
|
||||
* @param upToIndex - The array index up to which to search (optional, defaults to array end)
|
||||
* @param chunksize - Size of the chunks used when searching (default 1024 bytes)
|
||||
* @returns The index position of the first match, or -1 if not found
|
||||
*/
|
||||
function indexOfSubstring(
|
||||
haystack: Uint8Array,
|
||||
needle: string | RegExp,
|
||||
fromIndex: number,
|
||||
upToIndex?: number,
|
||||
chunksize = 1024 /* Bytes */
|
||||
) {
|
||||
/**
|
||||
* Adopted from: https://stackoverflow.com/a/67771214/2142071
|
||||
*/
|
||||
|
||||
if (!needle) {
|
||||
return -1
|
||||
}
|
||||
needle = new RegExp(needle, 'g')
|
||||
|
||||
// The needle could get split over two chunks.
|
||||
// So, at every chunk we prepend the last few characters
|
||||
// of the last chunk.
|
||||
const needle_length = needle.source.length
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
// Handle search offset in line with
|
||||
// `Array.prototype.indexOf()` and `TypedArray.prototype.subarray()`.
|
||||
const full_haystack_length = haystack.length
|
||||
if (typeof upToIndex === 'undefined') {
|
||||
upToIndex = full_haystack_length
|
||||
}
|
||||
if (fromIndex >= full_haystack_length || upToIndex <= 0 || fromIndex >= upToIndex) {
|
||||
return -1
|
||||
}
|
||||
haystack = haystack.subarray(fromIndex, upToIndex)
|
||||
|
||||
let position = -1
|
||||
let current_index = 0
|
||||
let full_length = 0
|
||||
let needle_buffer = ''
|
||||
|
||||
outer: while (current_index < haystack.length) {
|
||||
const next_index = current_index + chunksize
|
||||
// subarray doesn't copy
|
||||
const chunk = haystack.subarray(current_index, next_index)
|
||||
const decoded = decoder.decode(chunk, { stream: true })
|
||||
|
||||
const text = needle_buffer + decoded
|
||||
|
||||
let match: RegExpExecArray | null
|
||||
let last_index = -1
|
||||
while ((match = needle.exec(text)) !== null) {
|
||||
last_index = match.index - needle_buffer.length
|
||||
position = full_length + last_index
|
||||
break outer
|
||||
}
|
||||
|
||||
current_index = next_index
|
||||
full_length += decoded.length
|
||||
|
||||
// Check that the buffer doesn't itself include the needle
|
||||
// this would cause duplicate finds (we could also use a Set to avoid that).
|
||||
const needle_index =
|
||||
last_index > -1 ? last_index + needle_length : decoded.length - needle_length
|
||||
needle_buffer = decoded.slice(needle_index)
|
||||
}
|
||||
|
||||
// Correct for search offset.
|
||||
if (position >= 0) {
|
||||
position += fromIndex >= 0 ? fromIndex : full_haystack_length + fromIndex
|
||||
}
|
||||
|
||||
return position
|
||||
}
|
||||
|
||||
// APNGs have an animation control chunk ('acTL') preceding the IDATs.
|
||||
// See: https://en.wikipedia.org/wiki/APNG#File_format
|
||||
const idatIdx = indexOfSubstring(view, 'IDAT', 12)
|
||||
if (idatIdx >= 12) {
|
||||
const actlIdx = indexOfSubstring(view, 'acTL', 8, idatIdx)
|
||||
return actlIdx >= 8
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// globalThis.isApng = isApng
|
||||
|
||||
// (new TextEncoder()).encode('IDAT')
|
||||
// Decimal: [73, 68, 65, 84]
|
||||
// Hex: [0x49, 0x44, 0x41, 0x54]
|
||||
|
||||
// (new TextEncoder()).encode('acTL')
|
||||
// Decimal: [97, 99, 84, 76]
|
||||
// Hex: [0x61, 0x63, 0x54, 0x4C]
|
||||
|
||||
// const idatIdx = buffer.indexOf('IDAT')
|
||||
// const actlIdx = buffer.indexOf('acTL')
|
||||
26
packages/utils/src/lib/media/avif.test.ts
Normal file
26
packages/utils/src/lib/media/avif.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isAvifAnimated } from './avif'
|
||||
|
||||
describe('isAvifAnimated', () => {
|
||||
it('returns true when 4th byte equals 44', () => {
|
||||
const buffer = new ArrayBuffer(10)
|
||||
const view = new Uint8Array(buffer)
|
||||
view[3] = 44
|
||||
|
||||
expect(isAvifAnimated(buffer)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when 4th byte is not 44', () => {
|
||||
const buffer = new ArrayBuffer(10)
|
||||
const view = new Uint8Array(buffer)
|
||||
view[3] = 43
|
||||
|
||||
expect(isAvifAnimated(buffer)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for buffer smaller than 4 bytes', () => {
|
||||
const buffer = new ArrayBuffer(3)
|
||||
// Accessing index 3 should return undefined, which !== 44
|
||||
expect(isAvifAnimated(buffer)).toBe(false)
|
||||
})
|
||||
})
|
||||
38
packages/utils/src/lib/media/avif.ts
Normal file
38
packages/utils/src/lib/media/avif.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Determines whether an ArrayBuffer contains an animated AVIF image.
|
||||
*
|
||||
* This function performs a simple check by examining the 4th byte of the buffer.
|
||||
* AVIF animation is indicated when the byte at index 3 equals 44.
|
||||
*
|
||||
* @param buffer - The ArrayBuffer containing the AVIF image data to analyze
|
||||
* @returns True if the buffer contains an animated AVIF, false otherwise
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Check if an AVIF file is animated
|
||||
* const response = await fetch('image.avif')
|
||||
* const buffer = await response.arrayBuffer()
|
||||
* const isAnimated = isAvifAnimated(buffer)
|
||||
* if (isAnimated) {
|
||||
* console.log('This AVIF contains animation!')
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Use with file input
|
||||
* const fileInput = document.querySelector('input[type="file"]')
|
||||
* fileInput.addEventListener('change', async (event) => {
|
||||
* const file = event.target.files[0]
|
||||
* const buffer = await file.arrayBuffer()
|
||||
* const hasAnimation = isAvifAnimated(buffer)
|
||||
* console.log(hasAnimation ? 'Animated AVIF' : 'Static AVIF')
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function isAvifAnimated(buffer: ArrayBuffer) {
|
||||
const view = new Uint8Array(buffer)
|
||||
return view[3] === 44
|
||||
}
|
||||
52
packages/utils/src/lib/media/gif.test.ts
Normal file
52
packages/utils/src/lib/media/gif.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isGIF, isGifAnimated } from './gif'
|
||||
|
||||
describe('isGIF', () => {
|
||||
it('should return true for valid GIF header', () => {
|
||||
const buffer = new ArrayBuffer(10)
|
||||
const view = new Uint8Array(buffer)
|
||||
|
||||
// Set GIF header "GIF"
|
||||
view[0] = 71 // G
|
||||
view[1] = 73 // I
|
||||
view[2] = 70 // F
|
||||
|
||||
expect(isGIF(buffer)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for non-GIF header', () => {
|
||||
const buffer = new ArrayBuffer(10)
|
||||
const view = new Uint8Array(buffer)
|
||||
|
||||
// Set PNG header instead
|
||||
view[0] = 137 // PNG signature
|
||||
view[1] = 80 // P
|
||||
view[2] = 78 // N
|
||||
|
||||
expect(isGIF(buffer)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for empty buffer', () => {
|
||||
const buffer = new ArrayBuffer(0)
|
||||
expect(isGIF(buffer)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGifAnimated', () => {
|
||||
it('should return false for non-GIF data', () => {
|
||||
const buffer = new ArrayBuffer(20)
|
||||
const view = new Uint8Array(buffer)
|
||||
|
||||
// Set non-GIF header
|
||||
view[0] = 80 // P
|
||||
view[1] = 78 // N
|
||||
view[2] = 71 // G
|
||||
|
||||
expect(isGifAnimated(buffer)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for empty buffer', () => {
|
||||
const buffer = new ArrayBuffer(0)
|
||||
expect(isGifAnimated(buffer)).toBe(false)
|
||||
})
|
||||
})
|
||||
121
packages/utils/src/lib/media/gif.ts
Normal file
121
packages/utils/src/lib/media/gif.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/*!
|
||||
* MIT License
|
||||
* Modified code originally from <https://github.com/qzb/is-animated>
|
||||
* Copyright (c) 2016 Józef Sokołowski <j.k.sokolowski@gmail.com>
|
||||
*/
|
||||
|
||||
/** Returns total length of data blocks sequence */
|
||||
function getDataBlocksLength(buffer: Uint8Array, offset: number): number {
|
||||
let length = 0
|
||||
|
||||
while (buffer[offset + length]) {
|
||||
length += buffer[offset + length] + 1
|
||||
}
|
||||
|
||||
return length + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if buffer contains GIF image by examining the file header.
|
||||
*
|
||||
* @param buffer - The ArrayBuffer containing the image data to check
|
||||
* @returns True if the buffer contains a GIF image, false otherwise
|
||||
* @example
|
||||
* ```ts
|
||||
* // Check a file from user input
|
||||
* const file = event.target.files[0]
|
||||
* const buffer = await file.arrayBuffer()
|
||||
* const isGif = isGIF(buffer)
|
||||
* console.log(isGif ? 'GIF image' : 'Not a GIF')
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function isGIF(buffer: ArrayBuffer): boolean {
|
||||
const enc = new TextDecoder('ascii')
|
||||
const header = enc.decode(buffer.slice(0, 3))
|
||||
return header === 'GIF'
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if buffer contains animated GIF image by parsing the GIF structure and counting image descriptors.
|
||||
* A GIF is considered animated if it contains more than one image descriptor block.
|
||||
*
|
||||
* @param buffer - The ArrayBuffer containing the GIF image data
|
||||
* @returns True if the GIF is animated (contains multiple frames), false otherwise
|
||||
* @example
|
||||
* ```ts
|
||||
* // Check if a GIF file is animated
|
||||
* const file = event.target.files[0]
|
||||
* if (file.type === 'image/gif') {
|
||||
* const buffer = await file.arrayBuffer()
|
||||
* const animated = isGifAnimated(buffer)
|
||||
* console.log(animated ? 'Animated GIF' : 'Static GIF')
|
||||
* }
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function isGifAnimated(buffer: ArrayBuffer): boolean {
|
||||
const view = new Uint8Array(buffer)
|
||||
let hasColorTable, colorTableSize
|
||||
let offset = 0
|
||||
let imagesCount = 0
|
||||
|
||||
// Check if this is this image has valid GIF header.
|
||||
// If not return false. Chrome, FF and IE doesn't handle GIFs with invalid version.
|
||||
if (!isGIF(buffer)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip header, logical screen descriptor and global color table
|
||||
|
||||
hasColorTable = view[10] & 0x80 // 0b10000000
|
||||
colorTableSize = view[10] & 0x07 // 0b00000111
|
||||
|
||||
offset += 6 // skip header
|
||||
offset += 7 // skip logical screen descriptor
|
||||
offset += hasColorTable ? 3 * Math.pow(2, colorTableSize + 1) : 0 // skip global color table
|
||||
|
||||
// Find if there is more than one image descriptor
|
||||
|
||||
while (imagesCount < 2 && offset < view.length) {
|
||||
switch (view[offset]) {
|
||||
// Image descriptor block. According to specification there could be any
|
||||
// number of these blocks (even zero). When there is more than one image
|
||||
// descriptor browsers will display animation (they shouldn't when there
|
||||
// is no delays defined, but they do it anyway).
|
||||
case 0x2c:
|
||||
imagesCount += 1
|
||||
|
||||
hasColorTable = view[offset + 9] & 0x80 // 0b10000000
|
||||
colorTableSize = view[offset + 9] & 0x07 // 0b00000111
|
||||
|
||||
offset += 10 // skip image descriptor
|
||||
offset += hasColorTable ? 3 * Math.pow(2, colorTableSize + 1) : 0 // skip local color table
|
||||
offset += getDataBlocksLength(view, offset + 1) + 1 // skip image data
|
||||
|
||||
break
|
||||
|
||||
// Skip all extension blocks. In theory this "plain text extension" blocks
|
||||
// could be frames of animation, but no browser renders them.
|
||||
case 0x21:
|
||||
offset += 2 // skip introducer and label
|
||||
offset += getDataBlocksLength(view, offset) // skip this block and following data blocks
|
||||
|
||||
break
|
||||
|
||||
// Stop processing on trailer block,
|
||||
// all data after this point will is ignored by decoders
|
||||
case 0x3b:
|
||||
offset = view.length // fast forward to end of buffer
|
||||
break
|
||||
|
||||
// Oops! This GIF seems to be invalid
|
||||
default:
|
||||
// fast forward to end of buffer
|
||||
offset = view.length
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return imagesCount > 1
|
||||
}
|
||||
58
packages/utils/src/lib/media/media.test.ts
Normal file
58
packages/utils/src/lib/media/media.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MediaHelpers } from './media'
|
||||
|
||||
describe('MediaHelpers', () => {
|
||||
// Note: Most MediaHelpers methods are DOM-dependent and already tested through
|
||||
// integration tests in packages that use them. The complex mocking required
|
||||
// for these tests doesn't add value and makes tests brittle.
|
||||
|
||||
describe('type checking methods', () => {
|
||||
it('should correctly identify animated image types', () => {
|
||||
expect(MediaHelpers.isAnimatedImageType('image/gif')).toBe(true)
|
||||
expect(MediaHelpers.isAnimatedImageType('image/apng')).toBe(true)
|
||||
expect(MediaHelpers.isAnimatedImageType('image/avif')).toBe(true)
|
||||
expect(MediaHelpers.isAnimatedImageType('image/webp')).toBe(false) // webp can be static
|
||||
})
|
||||
|
||||
it('should correctly identify static image types', () => {
|
||||
expect(MediaHelpers.isStaticImageType('image/jpeg')).toBe(true)
|
||||
expect(MediaHelpers.isStaticImageType('image/png')).toBe(true)
|
||||
expect(MediaHelpers.isStaticImageType('image/webp')).toBe(true)
|
||||
expect(MediaHelpers.isStaticImageType('image/gif')).toBe(false)
|
||||
})
|
||||
|
||||
it('should correctly identify vector image types', () => {
|
||||
expect(MediaHelpers.isVectorImageType('image/svg+xml')).toBe(true)
|
||||
expect(MediaHelpers.isVectorImageType('image/jpeg')).toBe(false)
|
||||
expect(MediaHelpers.isVectorImageType('image/gif')).toBe(false)
|
||||
})
|
||||
|
||||
it('should correctly identify all image types', () => {
|
||||
expect(MediaHelpers.isImageType('image/jpeg')).toBe(true)
|
||||
expect(MediaHelpers.isImageType('image/png')).toBe(true)
|
||||
expect(MediaHelpers.isImageType('image/gif')).toBe(true)
|
||||
expect(MediaHelpers.isImageType('image/svg+xml')).toBe(true)
|
||||
expect(MediaHelpers.isImageType('video/mp4')).toBe(false)
|
||||
expect(MediaHelpers.isImageType('text/plain')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle null and undefined gracefully', () => {
|
||||
expect(MediaHelpers.isAnimatedImageType(null)).toBe(false)
|
||||
expect(MediaHelpers.isStaticImageType(null)).toBe(false)
|
||||
expect(MediaHelpers.isVectorImageType(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Note: The following methods are not tested here as they require extensive DOM mocking
|
||||
// that would test the mocking framework more than the actual business logic:
|
||||
// - loadVideo: Complex DOM video element mocking
|
||||
// - getVideoFrameAsDataUrl: Canvas and video interaction mocking
|
||||
// - getImageAndDimensions: Image element and DOM manipulation mocking
|
||||
// - getVideoSize: Depends on loadVideo
|
||||
// - getImageSize: Complex PNG parsing logic with extensive mocking
|
||||
// - isAnimated: Delegates to format-specific functions already tested elsewhere
|
||||
// - usingObjectURL: Simple wrapper around URL APIs with cleanup
|
||||
|
||||
// These methods are better tested through integration tests in the consuming packages
|
||||
// (like @tldraw/tldraw) where they're used in realistic scenarios.
|
||||
})
|
||||
488
packages/utils/src/lib/media/media.ts
Normal file
488
packages/utils/src/lib/media/media.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
import { promiseWithResolve } from '../control'
|
||||
import { Image } from '../network'
|
||||
import { isApngAnimated } from './apng'
|
||||
import { isAvifAnimated } from './avif'
|
||||
import { isGifAnimated } from './gif'
|
||||
import { PngHelpers } from './png'
|
||||
import { isWebpAnimated } from './webp'
|
||||
|
||||
/**
|
||||
* Array of supported vector image MIME types.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES } from '@tldraw/utils'
|
||||
*
|
||||
* const isSvg = DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES.includes('image/svg+xml')
|
||||
* console.log(isSvg) // true
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export const DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES = Object.freeze(['image/svg+xml' as const])
|
||||
/**
|
||||
* Array of supported static (non-animated) image MIME types.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES } from '@tldraw/utils'
|
||||
*
|
||||
* const isStatic = DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES.includes('image/jpeg')
|
||||
* console.log(isStatic) // true
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export const DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES = Object.freeze([
|
||||
'image/jpeg' as const,
|
||||
'image/png' as const,
|
||||
'image/webp' as const,
|
||||
])
|
||||
/**
|
||||
* Array of supported animated image MIME types.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES } from '@tldraw/utils'
|
||||
*
|
||||
* const isAnimated = DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES.includes('image/gif')
|
||||
* console.log(isAnimated) // true
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export const DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES = Object.freeze([
|
||||
'image/gif' as const,
|
||||
'image/apng' as const,
|
||||
'image/avif' as const,
|
||||
])
|
||||
/**
|
||||
* Array of all supported image MIME types, combining static, vector, and animated types.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { DEFAULT_SUPPORTED_IMAGE_TYPES } from '@tldraw/utils'
|
||||
*
|
||||
* const isSupported = DEFAULT_SUPPORTED_IMAGE_TYPES.includes('image/png')
|
||||
* console.log(isSupported) // true
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export const DEFAULT_SUPPORTED_IMAGE_TYPES = Object.freeze([
|
||||
...DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES,
|
||||
...DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES,
|
||||
...DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES,
|
||||
])
|
||||
/**
|
||||
* Array of supported video MIME types.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { DEFAULT_SUPPORT_VIDEO_TYPES } from '@tldraw/utils'
|
||||
*
|
||||
* const isVideo = DEFAULT_SUPPORT_VIDEO_TYPES.includes('video/mp4')
|
||||
* console.log(isVideo) // true
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export const DEFAULT_SUPPORT_VIDEO_TYPES = Object.freeze([
|
||||
'video/mp4' as const,
|
||||
'video/webm' as const,
|
||||
'video/quicktime' as const,
|
||||
])
|
||||
/**
|
||||
* Array of all supported media MIME types, combining images and videos.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { DEFAULT_SUPPORTED_MEDIA_TYPES } from '@tldraw/utils'
|
||||
*
|
||||
* const isMediaFile = DEFAULT_SUPPORTED_MEDIA_TYPES.includes('video/mp4')
|
||||
* console.log(isMediaFile) // true
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export const DEFAULT_SUPPORTED_MEDIA_TYPES = Object.freeze([
|
||||
...DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||
...DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||
])
|
||||
/**
|
||||
* Comma-separated string of all supported media MIME types, useful for HTML file input accept attributes.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { DEFAULT_SUPPORTED_MEDIA_TYPE_LIST } from '@tldraw/utils'
|
||||
*
|
||||
* // Use in HTML file input for media uploads
|
||||
* const input = document.createElement('input')
|
||||
* input.type = 'file'
|
||||
* input.accept = DEFAULT_SUPPORTED_MEDIA_TYPE_LIST
|
||||
* input.addEventListener('change', (e) => {
|
||||
* const files = (e.target as HTMLInputElement).files
|
||||
* if (files) console.log(`Selected ${files.length} file(s)`)
|
||||
* })
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export const DEFAULT_SUPPORTED_MEDIA_TYPE_LIST = DEFAULT_SUPPORTED_MEDIA_TYPES.join(',')
|
||||
|
||||
/**
|
||||
* Helpers for media
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class MediaHelpers {
|
||||
/**
|
||||
* Load a video element from a URL with cross-origin support.
|
||||
*
|
||||
* @param src - The URL of the video to load
|
||||
* @param doc - Optional document to create the video element in
|
||||
* @returns Promise that resolves to the loaded HTMLVideoElement
|
||||
* @example
|
||||
* ```ts
|
||||
* const video = await MediaHelpers.loadVideo('https://example.com/video.mp4')
|
||||
* console.log(`Video dimensions: ${video.videoWidth}x${video.videoHeight}`)
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static loadVideo(src: string, doc?: Document): Promise<HTMLVideoElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
const video = (doc ?? document).createElement('video')
|
||||
video.onloadeddata = () => resolve(video)
|
||||
video.onerror = (e) => {
|
||||
console.error(e)
|
||||
reject(new Error('Could not load video'))
|
||||
}
|
||||
video.crossOrigin = 'anonymous'
|
||||
video.src = src
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a frame from a video element as a data URL.
|
||||
*
|
||||
* @param video - The HTMLVideoElement to extract frame from
|
||||
* @param time - The time in seconds to extract the frame from (default: 0)
|
||||
* @returns Promise that resolves to a data URL of the video frame
|
||||
* @example
|
||||
* ```ts
|
||||
* const video = await MediaHelpers.loadVideo('https://example.com/video.mp4')
|
||||
* const frameDataUrl = await MediaHelpers.getVideoFrameAsDataUrl(video, 5.0)
|
||||
* // Use frameDataUrl as image thumbnail
|
||||
* const img = document.createElement('img')
|
||||
* img.src = frameDataUrl
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static async getVideoFrameAsDataUrl(video: HTMLVideoElement, time = 0): Promise<string> {
|
||||
const promise = promiseWithResolve<string>()
|
||||
let didSetTime = false
|
||||
|
||||
const onReadyStateChanged = () => {
|
||||
if (!didSetTime) {
|
||||
if (video.readyState >= video.HAVE_METADATA) {
|
||||
didSetTime = true
|
||||
video.currentTime = time
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (video.readyState >= video.HAVE_CURRENT_DATA) {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
const canvas = (video.ownerDocument ?? document).createElement('canvas')
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
throw new Error('Could not get 2d context')
|
||||
}
|
||||
ctx.drawImage(video, 0, 0)
|
||||
promise.resolve(canvas.toDataURL())
|
||||
}
|
||||
}
|
||||
const onError = (e: Event) => {
|
||||
console.error(e)
|
||||
promise.reject(new Error('Could not get video frame'))
|
||||
}
|
||||
|
||||
video.addEventListener('loadedmetadata', onReadyStateChanged)
|
||||
video.addEventListener('loadeddata', onReadyStateChanged)
|
||||
video.addEventListener('canplay', onReadyStateChanged)
|
||||
video.addEventListener('seeked', onReadyStateChanged)
|
||||
|
||||
video.addEventListener('error', onError)
|
||||
video.addEventListener('stalled', onError)
|
||||
|
||||
onReadyStateChanged()
|
||||
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
video.removeEventListener('loadedmetadata', onReadyStateChanged)
|
||||
video.removeEventListener('loadeddata', onReadyStateChanged)
|
||||
video.removeEventListener('canplay', onReadyStateChanged)
|
||||
video.removeEventListener('seeked', onReadyStateChanged)
|
||||
|
||||
video.removeEventListener('error', onError)
|
||||
video.removeEventListener('stalled', onError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an image from a URL and get its dimensions along with the image element.
|
||||
*
|
||||
* @param src - The URL of the image to load
|
||||
* @param doc - Optional document to use for DOM operations (e.g. measuring SVG dimensions)
|
||||
* @returns Promise that resolves to an object with width, height, and the image element
|
||||
* @example
|
||||
* ```ts
|
||||
* const { w, h, image } = await MediaHelpers.getImageAndDimensions('https://example.com/image.png')
|
||||
* console.log(`Image size: ${w}x${h}`)
|
||||
* // Image is ready to use
|
||||
* document.body.appendChild(image)
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static getImageAndDimensions(
|
||||
src: string,
|
||||
doc?: Document
|
||||
): Promise<{ w: number; h: number; image: HTMLImageElement }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = Image()
|
||||
img.onload = () => {
|
||||
let dimensions
|
||||
if (img.naturalWidth) {
|
||||
dimensions = {
|
||||
w: img.naturalWidth,
|
||||
h: img.naturalHeight,
|
||||
}
|
||||
} else {
|
||||
// Sigh, Firefox doesn't have naturalWidth or naturalHeight for SVGs. :-/
|
||||
// We have to attach to dom and use clientWidth/clientHeight.
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
const body = (doc ?? document).body
|
||||
body.appendChild(img)
|
||||
dimensions = {
|
||||
w: img.clientWidth,
|
||||
h: img.clientHeight,
|
||||
}
|
||||
body.removeChild(img)
|
||||
}
|
||||
resolve({ ...dimensions, image: img })
|
||||
}
|
||||
img.onerror = (e) => {
|
||||
console.error(e)
|
||||
reject(new Error('Could not load image'))
|
||||
}
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.referrerPolicy = 'strict-origin-when-cross-origin'
|
||||
img.style.visibility = 'hidden'
|
||||
img.style.position = 'absolute'
|
||||
img.style.opacity = '0'
|
||||
img.style.zIndex = '-9999'
|
||||
img.src = src
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of a video blob
|
||||
*
|
||||
* @param blob - A Blob containing the video
|
||||
* @param doc - Optional document to create elements in
|
||||
* @returns Promise that resolves to an object with width and height properties
|
||||
* @example
|
||||
* ```ts
|
||||
* const file = new File([...], 'video.mp4', { type: 'video/mp4' })
|
||||
* const { w, h } = await MediaHelpers.getVideoSize(file)
|
||||
* console.log(`Video dimensions: ${w}x${h}`)
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static async getVideoSize(blob: Blob, doc?: Document): Promise<{ w: number; h: number }> {
|
||||
return MediaHelpers.usingObjectURL(blob, async (url) => {
|
||||
const video = await MediaHelpers.loadVideo(url, doc)
|
||||
return { w: video.videoWidth, h: video.videoHeight }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of an image blob
|
||||
*
|
||||
* @param blob - A Blob containing the image
|
||||
* @param doc - Optional document to use for DOM operations
|
||||
* @returns Promise that resolves to an object with width and height properties
|
||||
* @example
|
||||
* ```ts
|
||||
* const file = new File([...], 'image.png', { type: 'image/png' })
|
||||
* const { w, h } = await MediaHelpers.getImageSize(file)
|
||||
* console.log(`Image dimensions: ${w}x${h}`)
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static async getImageSize(
|
||||
blob: Blob,
|
||||
doc?: Document
|
||||
): Promise<{ w: number; h: number; pixelRatio: number }> {
|
||||
const { w, h } = await MediaHelpers.usingObjectURL(blob, (url) =>
|
||||
MediaHelpers.getImageAndDimensions(url, doc)
|
||||
)
|
||||
|
||||
try {
|
||||
if (blob.type === 'image/png') {
|
||||
const view = new DataView(await blob.arrayBuffer())
|
||||
if (PngHelpers.isPng(view, 0)) {
|
||||
const physChunk = PngHelpers.findChunk(view, 'pHYs')
|
||||
if (physChunk) {
|
||||
const physData = PngHelpers.parsePhys(view, physChunk.dataOffset)
|
||||
if (physData.unit === 1 && physData.ppux === physData.ppuy) {
|
||||
const dpi = Math.round(physData.ppux * 0.0254)
|
||||
// Try both standard baselines: Windows/web = 96, macOS = 72.
|
||||
// Pick whichever yields a clean integer ratio > 1.
|
||||
const r96 = dpi / 96
|
||||
const r72 = dpi / 72
|
||||
let pixelRatio = 1
|
||||
if (Number.isInteger(r96) && r96 > 1) {
|
||||
pixelRatio = r96
|
||||
} else if (Number.isInteger(r72) && r72 > 1) {
|
||||
pixelRatio = r72
|
||||
}
|
||||
if (pixelRatio > 1) {
|
||||
return {
|
||||
w: Math.ceil(w / pixelRatio),
|
||||
h: Math.ceil(h / pixelRatio),
|
||||
pixelRatio,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return { w, h, pixelRatio: 1 }
|
||||
}
|
||||
return { w, h, pixelRatio: 1 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a media file blob contains animation data.
|
||||
*
|
||||
* @param file - The Blob to check for animation
|
||||
* @returns Promise that resolves to true if the file is animated, false otherwise
|
||||
* @example
|
||||
* ```ts
|
||||
* const file = new File([...], 'animation.gif', { type: 'image/gif' })
|
||||
* const animated = await MediaHelpers.isAnimated(file)
|
||||
* console.log(animated ? 'Animated' : 'Static')
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static async isAnimated(file: Blob): Promise<boolean> {
|
||||
if (file.type === 'image/gif') {
|
||||
return isGifAnimated(await file.arrayBuffer())
|
||||
}
|
||||
|
||||
if (file.type === 'image/avif') {
|
||||
return isAvifAnimated(await file.arrayBuffer())
|
||||
}
|
||||
|
||||
if (file.type === 'image/webp') {
|
||||
return isWebpAnimated(await file.arrayBuffer())
|
||||
}
|
||||
|
||||
if (file.type === 'image/apng') {
|
||||
return isApngAnimated(await file.arrayBuffer())
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type represents an animated image format.
|
||||
*
|
||||
* @param mimeType - The MIME type to check
|
||||
* @returns True if the MIME type is an animated image format, false otherwise
|
||||
* @example
|
||||
* ```ts
|
||||
* const isAnimated = MediaHelpers.isAnimatedImageType('image/gif')
|
||||
* console.log(isAnimated) // true
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static isAnimatedImageType(mimeType: string | null): boolean {
|
||||
return DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES.includes((mimeType as any) || '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type represents a static (non-animated) image format.
|
||||
*
|
||||
* @param mimeType - The MIME type to check
|
||||
* @returns True if the MIME type is a static image format, false otherwise
|
||||
* @example
|
||||
* ```ts
|
||||
* const isStatic = MediaHelpers.isStaticImageType('image/jpeg')
|
||||
* console.log(isStatic) // true
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static isStaticImageType(mimeType: string | null): boolean {
|
||||
return DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES.includes((mimeType as any) || '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type represents a vector image format.
|
||||
*
|
||||
* @param mimeType - The MIME type to check
|
||||
* @returns True if the MIME type is a vector image format, false otherwise
|
||||
* @example
|
||||
* ```ts
|
||||
* const isVector = MediaHelpers.isVectorImageType('image/svg+xml')
|
||||
* console.log(isVector) // true
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static isVectorImageType(mimeType: string | null): boolean {
|
||||
return DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES.includes((mimeType as any) || '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type represents any supported image format (static, animated, or vector).
|
||||
*
|
||||
* @param mimeType - The MIME type to check
|
||||
* @returns True if the MIME type is a supported image format, false otherwise
|
||||
* @example
|
||||
* ```ts
|
||||
* const isImage = MediaHelpers.isImageType('image/png')
|
||||
* console.log(isImage) // true
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static isImageType(mimeType: string): boolean {
|
||||
return DEFAULT_SUPPORTED_IMAGE_TYPES.includes((mimeType as any) || '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to create an object URL from a blob, execute a function with it, and automatically clean it up.
|
||||
*
|
||||
* @param blob - The Blob to create an object URL for
|
||||
* @param fn - Function to execute with the object URL
|
||||
* @returns Promise that resolves to the result of the function
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = await MediaHelpers.usingObjectURL(imageBlob, async (url) => {
|
||||
* const { w, h } = await MediaHelpers.getImageAndDimensions(url)
|
||||
* return { width: w, height: h }
|
||||
* })
|
||||
* // Object URL is automatically revoked after function completes
|
||||
* console.log(`Image dimensions: ${result.width}x${result.height}`)
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
static async usingObjectURL<T>(blob: Blob, fn: (url: string) => Promise<T>): Promise<T> {
|
||||
const url = URL.createObjectURL(blob)
|
||||
try {
|
||||
return await fn(url)
|
||||
} finally {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
338
packages/utils/src/lib/media/png.ts
Normal file
338
packages/utils/src/lib/media/png.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
/*!
|
||||
* MIT License: https://github.com/alexgorbatchev/crc/blob/master/LICENSE
|
||||
* Copyright: 2014 Alex Gorbatchev
|
||||
* Code: crc32, https://github.com/alexgorbatchev/crc/blob/master/src/calculators/crc32.ts
|
||||
*/
|
||||
|
||||
type BufferInput = string | ArrayBuffer | Buffer
|
||||
|
||||
interface CRCCalculator<T = BufferInput | Uint8Array> {
|
||||
(value: T, previous?: number): number
|
||||
}
|
||||
|
||||
let TABLE: Array<number> | Int32Array = [
|
||||
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3,
|
||||
0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91,
|
||||
0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7,
|
||||
0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5,
|
||||
0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b,
|
||||
0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59,
|
||||
0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f,
|
||||
0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d,
|
||||
0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433,
|
||||
0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01,
|
||||
0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457,
|
||||
0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65,
|
||||
0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb,
|
||||
0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9,
|
||||
0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f,
|
||||
0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad,
|
||||
0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, 0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683,
|
||||
0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1,
|
||||
0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7,
|
||||
0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5,
|
||||
0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b,
|
||||
0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79,
|
||||
0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f,
|
||||
0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d,
|
||||
0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713,
|
||||
0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21,
|
||||
0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777,
|
||||
0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45,
|
||||
0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db,
|
||||
0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9,
|
||||
0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf,
|
||||
0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d,
|
||||
]
|
||||
|
||||
if (typeof Int32Array !== 'undefined') {
|
||||
TABLE = new Int32Array(TABLE)
|
||||
}
|
||||
|
||||
const crc: CRCCalculator<Uint8Array> = (current, previous) => {
|
||||
let crc = previous === 0 ? 0 : ~~previous! ^ -1
|
||||
|
||||
for (let index = 0; index < current.length; index++) {
|
||||
crc = TABLE[(crc ^ current[index]) & 0xff] ^ (crc >>> 8)
|
||||
}
|
||||
|
||||
return crc ^ -1
|
||||
}
|
||||
|
||||
const LEN_SIZE = 4
|
||||
const CRC_SIZE = 4
|
||||
|
||||
/**
|
||||
* Utility class for reading and manipulating PNG image files.
|
||||
* Provides methods for parsing PNG chunks, validating PNG format, and modifying PNG metadata.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Validate PNG file from blob
|
||||
* const blob = new Blob([pngData], { type: 'image/png' })
|
||||
* const view = new DataView(await blob.arrayBuffer())
|
||||
* const isPng = PngHelpers.isPng(view, 0)
|
||||
*
|
||||
* // Parse PNG metadata for image processing
|
||||
* const chunks = PngHelpers.readChunks(view)
|
||||
* const physChunk = PngHelpers.findChunk(view, 'pHYs')
|
||||
*
|
||||
* // Create high-DPI PNG for export
|
||||
* const highDpiBlob = PngHelpers.setPhysChunk(view, 2, { type: 'image/png' })
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class PngHelpers {
|
||||
/**
|
||||
* Checks if binary data at the specified offset contains a valid PNG file signature.
|
||||
* Validates the 8-byte PNG signature: 89 50 4E 47 0D 0A 1A 0A.
|
||||
*
|
||||
* @param view - DataView containing the binary data to check
|
||||
* @param offset - Byte offset where the PNG signature should start
|
||||
* @returns True if the data contains a valid PNG signature, false otherwise
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Validate PNG from file upload
|
||||
* const file = event.target.files[0]
|
||||
* const buffer = await file.arrayBuffer()
|
||||
* const view = new DataView(buffer)
|
||||
*
|
||||
* if (PngHelpers.isPng(view, 0)) {
|
||||
* console.log('Valid PNG file detected')
|
||||
* // Process PNG file...
|
||||
* } else {
|
||||
* console.error('Not a valid PNG file')
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static isPng(view: DataView, offset: number) {
|
||||
if (
|
||||
view.getUint8(offset + 0) === 0x89 &&
|
||||
view.getUint8(offset + 1) === 0x50 &&
|
||||
view.getUint8(offset + 2) === 0x4e &&
|
||||
view.getUint8(offset + 3) === 0x47 &&
|
||||
view.getUint8(offset + 4) === 0x0d &&
|
||||
view.getUint8(offset + 5) === 0x0a &&
|
||||
view.getUint8(offset + 6) === 0x1a &&
|
||||
view.getUint8(offset + 7) === 0x0a
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the 4-character chunk type identifier from a PNG chunk header.
|
||||
*
|
||||
* @param view - DataView containing the PNG data
|
||||
* @param offset - Byte offset of the chunk type field (after length field)
|
||||
* @returns 4-character string representing the chunk type (e.g., 'IHDR', 'IDAT', 'IEND')
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Read chunk type from PNG header (after 8-byte signature)
|
||||
* const chunkType = PngHelpers.getChunkType(dataView, 8)
|
||||
* console.log(chunkType) // 'IHDR' (Image Header)
|
||||
*
|
||||
* // Read chunk type at a specific position during parsing
|
||||
* let offset = 8 // Skip PNG signature
|
||||
* const chunkLength = dataView.getUint32(offset)
|
||||
* const type = PngHelpers.getChunkType(dataView, offset + 4)
|
||||
* ```
|
||||
*/
|
||||
static getChunkType(view: DataView, offset: number) {
|
||||
return [
|
||||
String.fromCharCode(view.getUint8(offset)),
|
||||
String.fromCharCode(view.getUint8(offset + 1)),
|
||||
String.fromCharCode(view.getUint8(offset + 2)),
|
||||
String.fromCharCode(view.getUint8(offset + 3)),
|
||||
].join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses all chunks in a PNG file and returns their metadata.
|
||||
* Skips duplicate IDAT chunks but includes all other chunk types.
|
||||
*
|
||||
* @param view - DataView containing the complete PNG file data
|
||||
* @param offset - Starting byte offset (defaults to 0)
|
||||
* @returns Record mapping chunk types to their metadata (start position, data offset, and size)
|
||||
* @throws Error if the data is not a valid PNG file
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Parse PNG structure for metadata extraction
|
||||
* const view = new DataView(await blob.arrayBuffer())
|
||||
* const chunks = PngHelpers.readChunks(view)
|
||||
*
|
||||
* // Check for specific chunks
|
||||
* const ihdrChunk = chunks['IHDR']
|
||||
* const physChunk = chunks['pHYs']
|
||||
*
|
||||
* if (physChunk) {
|
||||
* console.log(`Found pixel density info at byte ${physChunk.start}`)
|
||||
* } else {
|
||||
* console.log('No pixel density information found')
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static readChunks(view: DataView, offset = 0) {
|
||||
const chunks: Record<string, { dataOffset: number; size: number; start: number }> = {}
|
||||
if (!PngHelpers.isPng(view, offset)) {
|
||||
throw new Error('Not a PNG')
|
||||
}
|
||||
offset += 8
|
||||
|
||||
while (offset <= view.buffer.byteLength) {
|
||||
const start = offset
|
||||
const len = view.getInt32(offset)
|
||||
offset += 4
|
||||
const chunkType = PngHelpers.getChunkType(view, offset)
|
||||
|
||||
if (chunkType === 'IDAT' && chunks[chunkType]) {
|
||||
offset += len + LEN_SIZE + CRC_SIZE
|
||||
continue
|
||||
}
|
||||
|
||||
if (chunkType === 'IEND') {
|
||||
break
|
||||
}
|
||||
|
||||
chunks[chunkType] = {
|
||||
start,
|
||||
dataOffset: offset + 4,
|
||||
size: len,
|
||||
}
|
||||
offset += len + LEN_SIZE + CRC_SIZE
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the pHYs (physical pixel dimensions) chunk data.
|
||||
* Reads pixels per unit for X and Y axes, and the unit specifier.
|
||||
*
|
||||
* @param view - DataView containing the PNG data
|
||||
* @param offset - Byte offset of the pHYs chunk data
|
||||
* @returns Object with ppux (pixels per unit X), ppuy (pixels per unit Y), and unit specifier
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Extract pixel density information for DPI calculation
|
||||
* const physChunk = PngHelpers.findChunk(dataView, 'pHYs')
|
||||
* if (physChunk) {
|
||||
* const physData = PngHelpers.parsePhys(dataView, physChunk.dataOffset)
|
||||
*
|
||||
* if (physData.unit === 1) { // meters
|
||||
* const dpiX = Math.round(physData.ppux * 0.0254)
|
||||
* const dpiY = Math.round(physData.ppuy * 0.0254)
|
||||
* console.log(`DPI: ${dpiX} x ${dpiY}`)
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static parsePhys(view: DataView, offset: number) {
|
||||
return {
|
||||
ppux: view.getUint32(offset),
|
||||
ppuy: view.getUint32(offset + 4),
|
||||
unit: view.getUint8(offset + 8),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a specific chunk type in the PNG file and returns its metadata.
|
||||
*
|
||||
* @param view - DataView containing the PNG file data
|
||||
* @param type - 4-character chunk type to search for (e.g., 'pHYs', 'IDAT')
|
||||
* @returns Chunk metadata object if found, undefined otherwise
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Look for pixel density information in PNG
|
||||
* const physChunk = PngHelpers.findChunk(dataView, 'pHYs')
|
||||
* if (physChunk) {
|
||||
* const physData = PngHelpers.parsePhys(dataView, physChunk.dataOffset)
|
||||
* console.log(`Found pHYs chunk with ${physData.ppux} x ${physData.ppuy} pixels per unit`)
|
||||
* }
|
||||
*
|
||||
* // Check for text metadata
|
||||
* const textChunk = PngHelpers.findChunk(dataView, 'tEXt')
|
||||
* if (textChunk) {
|
||||
* console.log(`Found text metadata at byte ${textChunk.start}`)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static findChunk(view: DataView, type: string) {
|
||||
const chunks = PngHelpers.readChunks(view)
|
||||
return chunks[type]
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or replaces a pHYs chunk in a PNG file to set pixel density for high-DPI displays.
|
||||
* The method determines insertion point by prioritizing IDAT chunk position over existing pHYs,
|
||||
* creates a properly formatted pHYs chunk with CRC validation, and returns a new Blob.
|
||||
*
|
||||
* @param view - DataView containing the original PNG file data
|
||||
* @param dpr - Device pixel ratio multiplier (defaults to 1)
|
||||
* @param options - Optional Blob constructor options for MIME type and other properties
|
||||
* @returns New Blob containing the PNG with updated pixel density information
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Export PNG with proper pixel density for high-DPI displays
|
||||
* const canvas = document.createElement('canvas')
|
||||
* const ctx = canvas.getContext('2d')
|
||||
* // ... draw content to canvas ...
|
||||
*
|
||||
* canvas.toBlob(async (blob) => {
|
||||
* if (blob) {
|
||||
* const view = new DataView(await blob.arrayBuffer())
|
||||
* // Create 2x DPI version for Retina displays
|
||||
* const highDpiBlob = PngHelpers.setPhysChunk(view, 2, { type: 'image/png' })
|
||||
* // Download or use the blob...
|
||||
* }
|
||||
* }, 'image/png')
|
||||
* ```
|
||||
*/
|
||||
static setPhysChunk(view: DataView, dpr = 1, options?: BlobPropertyBag) {
|
||||
let offset = 46
|
||||
let size = 0
|
||||
const res1 = PngHelpers.findChunk(view, 'pHYs')
|
||||
if (res1) {
|
||||
offset = res1.start
|
||||
size = res1.size
|
||||
}
|
||||
|
||||
const res2 = PngHelpers.findChunk(view, 'IDAT')
|
||||
if (res2) {
|
||||
offset = res2.start
|
||||
size = 0
|
||||
}
|
||||
|
||||
const pHYsData = new ArrayBuffer(21)
|
||||
const pHYsDataView = new DataView(pHYsData)
|
||||
|
||||
pHYsDataView.setUint32(0, 9)
|
||||
|
||||
pHYsDataView.setUint8(4, 'p'.charCodeAt(0))
|
||||
pHYsDataView.setUint8(5, 'H'.charCodeAt(0))
|
||||
pHYsDataView.setUint8(6, 'Y'.charCodeAt(0))
|
||||
pHYsDataView.setUint8(7, 's'.charCodeAt(0))
|
||||
|
||||
const DPI_72 = 2835.5
|
||||
|
||||
pHYsDataView.setInt32(8, DPI_72 * dpr)
|
||||
pHYsDataView.setInt32(12, DPI_72 * dpr)
|
||||
pHYsDataView.setInt8(16, 1)
|
||||
|
||||
const crcBit = new Uint8Array(pHYsData.slice(4, 17))
|
||||
pHYsDataView.setInt32(17, crc(crcBit))
|
||||
|
||||
const startBuf = view.buffer.slice(0, offset)
|
||||
const endBuf = view.buffer.slice(offset + size)
|
||||
|
||||
return new Blob([startBuf as ArrayBuffer, pHYsData, endBuf as ArrayBuffer], options)
|
||||
}
|
||||
}
|
||||
81
packages/utils/src/lib/media/webp.test.ts
Normal file
81
packages/utils/src/lib/media/webp.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isWebpAnimated } from './webp'
|
||||
|
||||
// Helper function to create a buffer with WebP signature
|
||||
function createWebpBuffer(size: number, animated = false): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(Math.max(size, 21))
|
||||
const view = new Uint8Array(buffer)
|
||||
|
||||
// Set RIFF signature (bytes 0-3: 'RIFF')
|
||||
view[0] = 0x52 // R
|
||||
view[1] = 0x49 // I
|
||||
view[2] = 0x46 // F
|
||||
view[3] = 0x46 // F
|
||||
|
||||
// Set file size (bytes 4-7) - little endian
|
||||
const fileSize = size - 8
|
||||
view[4] = fileSize & 0xff
|
||||
view[5] = (fileSize >> 8) & 0xff
|
||||
view[6] = (fileSize >> 16) & 0xff
|
||||
view[7] = (fileSize >> 24) & 0xff
|
||||
|
||||
// Set WebP signature (bytes 8-11: 'WEBP')
|
||||
view[8] = 87 // W
|
||||
view[9] = 69 // E
|
||||
view[10] = 66 // B
|
||||
view[11] = 80 // P
|
||||
|
||||
// Set VP8X chunk header for extended format (bytes 12-19)
|
||||
view[12] = 86 // V
|
||||
view[13] = 80 // P
|
||||
view[14] = 56 // 8
|
||||
view[15] = 88 // X
|
||||
|
||||
// Set VP8X chunk size (bytes 16-19) - 10 bytes
|
||||
view[16] = 10
|
||||
view[17] = 0
|
||||
view[18] = 0
|
||||
view[19] = 0
|
||||
|
||||
// Set VP8X flags (byte 20) - bit 1 is animation flag
|
||||
if (animated) {
|
||||
view[20] = 0b00000010 // Animation flag set
|
||||
} else {
|
||||
view[20] = 0b00000000 // No animation flag
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
describe('isWebpAnimated', () => {
|
||||
it('should return false for non-WebP data', () => {
|
||||
const buffer = new ArrayBuffer(25)
|
||||
const view = new Uint8Array(buffer)
|
||||
view[8] = 80 // Not WebP signature
|
||||
view[9] = 78
|
||||
view[10] = 71
|
||||
view[11] = 0
|
||||
|
||||
expect(isWebpAnimated(buffer)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for buffer too small for animation check', () => {
|
||||
const buffer = new ArrayBuffer(10)
|
||||
expect(isWebpAnimated(buffer)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for static WebP', () => {
|
||||
const buffer = createWebpBuffer(100, false)
|
||||
expect(isWebpAnimated(buffer)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true for animated WebP', () => {
|
||||
const buffer = createWebpBuffer(100, true)
|
||||
expect(isWebpAnimated(buffer)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for empty buffer', () => {
|
||||
const buffer = new ArrayBuffer(0)
|
||||
expect(isWebpAnimated(buffer)).toBe(false)
|
||||
})
|
||||
})
|
||||
57
packages/utils/src/lib/media/webp.ts
Normal file
57
packages/utils/src/lib/media/webp.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/*!
|
||||
* MIT License: https://github.com/sindresorhus/is-webp/blob/main/license
|
||||
* Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Determines whether a byte array represents a WebP image by checking the WebP file signature.
|
||||
*
|
||||
* @param view - The Uint8Array containing the potential WebP image data
|
||||
* @returns True if the byte array is a valid WebP image, false otherwise
|
||||
* @example
|
||||
* ```ts
|
||||
* // Check if file data is WebP format
|
||||
* const file = new File([...], 'image.webp', { type: 'image/webp' })
|
||||
* const buffer = await file.arrayBuffer()
|
||||
* const view = new Uint8Array(buffer)
|
||||
* const isWebPImage = isWebp(view)
|
||||
* console.log(isWebPImage ? 'Valid WebP' : 'Not WebP')
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function isWebp(view: Uint8Array) {
|
||||
if (!view || view.length < 12) {
|
||||
return false
|
||||
}
|
||||
|
||||
return view[8] === 87 && view[9] === 69 && view[10] === 66 && view[11] === 80
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a WebP image file contains animation data by checking the animation flag in the WebP VP8X chunk.
|
||||
*
|
||||
* @param buffer - The ArrayBuffer containing the WebP image data
|
||||
* @returns True if the WebP image is animated, false otherwise
|
||||
* @example
|
||||
* ```ts
|
||||
* // Check if a WebP file from user input is animated
|
||||
* const file = new File([...], 'image.webp', { type: 'image/webp' })
|
||||
* const buffer = await file.arrayBuffer()
|
||||
* const animated = isWebpAnimated(buffer)
|
||||
* console.log(animated ? 'Animated WebP' : 'Static WebP')
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function isWebpAnimated(buffer: ArrayBuffer) {
|
||||
const view = new Uint8Array(buffer)
|
||||
|
||||
if (!isWebp(view)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!view || view.length < 21) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ((view[20] >> 1) & 1) === 1
|
||||
}
|
||||
38
packages/utils/src/lib/network.test.ts
Normal file
38
packages/utils/src/lib/network.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fetch, Image } from './network'
|
||||
|
||||
describe('fetch', () => {
|
||||
it('should call window.fetch with referrerPolicy set to strict-origin-when-cross-origin', async () => {
|
||||
const mockFetch = vi.spyOn(window, 'fetch').mockResolvedValue(new Response('test'))
|
||||
|
||||
await fetch('https://example.com')
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://example.com', {
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
})
|
||||
mockFetch.mockRestore()
|
||||
})
|
||||
|
||||
it('should override referrerPolicy if provided in init options', async () => {
|
||||
const mockFetch = vi.spyOn(window, 'fetch').mockResolvedValue(new Response('test'))
|
||||
|
||||
const initOptions: RequestInit = {
|
||||
referrerPolicy: 'no-referrer',
|
||||
}
|
||||
|
||||
await fetch('https://example.com', initOptions)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://example.com', {
|
||||
referrerPolicy: 'no-referrer',
|
||||
})
|
||||
mockFetch.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Image', () => {
|
||||
it('should create an image with referrerPolicy set to strict-origin-when-cross-origin', () => {
|
||||
const img = Image()
|
||||
|
||||
expect(img.referrerPolicy).toBe('strict-origin-when-cross-origin')
|
||||
})
|
||||
})
|
||||
32
packages/utils/src/lib/network.ts
Normal file
32
packages/utils/src/lib/network.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Just a wrapper around `window.fetch` that sets the `referrerPolicy` to `strict-origin-when-cross-origin`.
|
||||
*
|
||||
* @param input - A Request object or string containing the URL to fetch
|
||||
* @param init - Optional request initialization options
|
||||
* @returns Promise that resolves to the Response object
|
||||
* @internal
|
||||
*/
|
||||
export async function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
// eslint-disable-next-line tldraw/no-restricted-properties
|
||||
return window.fetch(input, {
|
||||
// We want to make sure that the referrer is not sent to other domains.
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
...init,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Just a wrapper around `new Image`, and yeah, it's a bit strange that it's in the network.ts file
|
||||
* but the main concern here is the referrerPolicy and setting it correctly.
|
||||
*
|
||||
* @param width - Optional width for the image element
|
||||
* @param height - Optional height for the image element
|
||||
* @returns HTMLImageElement with referrerPolicy set to 'strict-origin-when-cross-origin'
|
||||
* @internal
|
||||
*/
|
||||
export function Image(width?: number, height?: number) {
|
||||
// eslint-disable-next-line tldraw/no-restricted-properties
|
||||
const img = new window.Image(width, height)
|
||||
img.referrerPolicy = 'strict-origin-when-cross-origin'
|
||||
return img
|
||||
}
|
||||
74
packages/utils/src/lib/number.test.ts
Normal file
74
packages/utils/src/lib/number.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { invLerp, lerp, modulate, rng } from './number'
|
||||
|
||||
describe('lerp', () => {
|
||||
test('should interpolate between two values correctly', () => {
|
||||
expect(lerp(0, 100, 0.5)).toBe(50)
|
||||
expect(lerp(10, 20, 0.25)).toBe(12.5)
|
||||
expect(lerp(-10, -5, 0.5)).toBe(-7.5)
|
||||
})
|
||||
|
||||
test('should extrapolate when t is outside 0-1 range', () => {
|
||||
expect(lerp(0, 10, 1.5)).toBe(15)
|
||||
expect(lerp(0, 10, -0.5)).toBe(-5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invLerp', () => {
|
||||
test('should find normalized position correctly', () => {
|
||||
expect(invLerp(0, 100, 25)).toBe(0.25)
|
||||
expect(invLerp(10, 20, 15)).toBe(0.5)
|
||||
expect(invLerp(-10, -5, -7.5)).toBe(0.5)
|
||||
})
|
||||
|
||||
test('should handle values outside the range', () => {
|
||||
expect(invLerp(0, 10, 15)).toBe(1.5)
|
||||
expect(invLerp(0, 10, -5)).toBe(-0.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rng', () => {
|
||||
test('should generate deterministic results for same seed', () => {
|
||||
const random1 = rng('same-seed')
|
||||
const random2 = rng('same-seed')
|
||||
|
||||
const values1 = [random1(), random1(), random1()]
|
||||
const values2 = [random2(), random2(), random2()]
|
||||
|
||||
expect(values1).toEqual(values2)
|
||||
})
|
||||
|
||||
test('should generate different results for different seeds', () => {
|
||||
const random1 = rng('seed-1')
|
||||
const random2 = rng('seed-2')
|
||||
|
||||
const values1 = [random1(), random1(), random1()]
|
||||
const values2 = [random2(), random2(), random2()]
|
||||
|
||||
expect(values1).not.toEqual(values2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('modulate', () => {
|
||||
test('should map value between ranges correctly', () => {
|
||||
expect(modulate(0, [0, 1], [0, 100])).toBe(0)
|
||||
expect(modulate(0.5, [0, 1], [0, 100])).toBe(50)
|
||||
expect(modulate(1, [0, 1], [0, 100])).toBe(100)
|
||||
expect(modulate(30, [10, 50], [100, 200])).toBe(150)
|
||||
})
|
||||
|
||||
test('should extrapolate without clamping by default', () => {
|
||||
expect(modulate(2, [0, 1], [0, 100])).toBe(200)
|
||||
expect(modulate(-1, [0, 1], [0, 100])).toBe(-100)
|
||||
})
|
||||
|
||||
test('should clamp results when clamp is true', () => {
|
||||
expect(modulate(2, [0, 1], [0, 100], true)).toBe(100)
|
||||
expect(modulate(-1, [0, 1], [0, 100], true)).toBe(0)
|
||||
})
|
||||
|
||||
test('should handle clamping with reversed target range', () => {
|
||||
expect(modulate(2, [0, 1], [100, 0], true)).toBe(0)
|
||||
expect(modulate(-1, [0, 1], [100, 0], true)).toBe(100)
|
||||
})
|
||||
})
|
||||
108
packages/utils/src/lib/number.ts
Normal file
108
packages/utils/src/lib/number.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Linear interpolate between two values.
|
||||
*
|
||||
* @param a - The start value
|
||||
* @param b - The end value
|
||||
* @param t - The interpolation factor (0-1)
|
||||
* @returns The interpolated value
|
||||
* @example
|
||||
* ```ts
|
||||
* const halfway = lerp(0, 100, 0.5) // 50
|
||||
* const quarter = lerp(10, 20, 0.25) // 12.5
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function lerp(a: number, b: number, t: number) {
|
||||
return a + (b - a) * t
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse lerp between two values. Given a value `t` in the range [a, b], returns a number between
|
||||
* 0 and 1.
|
||||
*
|
||||
* @param a - The start value of the range
|
||||
* @param b - The end value of the range
|
||||
* @param t - The value within the range [a, b]
|
||||
* @returns The normalized position (0-1) of t within the range [a, b]
|
||||
* @example
|
||||
* ```ts
|
||||
* const position = invLerp(0, 100, 25) // 0.25
|
||||
* const normalized = invLerp(10, 20, 15) // 0.5
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function invLerp(a: number, b: number, t: number) {
|
||||
return (t - a) / (b - a)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeded random number generator, using [xorshift](https://en.wikipedia.org/wiki/Xorshift). The
|
||||
* result will always be between -1 and 1.
|
||||
*
|
||||
* Adapted from [seedrandom](https://github.com/davidbau/seedrandom).
|
||||
*
|
||||
* @param seed - The seed string for deterministic random generation (defaults to empty string)
|
||||
* @returns A function that will return a random number between -1 and 1 each time it is called
|
||||
* @example
|
||||
* ```ts
|
||||
* const random = rng('my-seed')
|
||||
* const num1 = random() // Always the same for this seed
|
||||
* const num2 = random() // Next number in sequence
|
||||
*
|
||||
* // Different seed produces different sequence
|
||||
* const otherRandom = rng('other-seed')
|
||||
* const different = otherRandom() // Different value
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function rng(seed = '') {
|
||||
let x = 0
|
||||
let y = 0
|
||||
let z = 0
|
||||
let w = 0
|
||||
|
||||
function next() {
|
||||
const t = x ^ (x << 11)
|
||||
x = y
|
||||
y = z
|
||||
z = w
|
||||
w ^= ((w >>> 19) ^ t ^ (t >>> 8)) >>> 0
|
||||
return (w / 0x100000000) * 2
|
||||
}
|
||||
|
||||
for (let k = 0; k < seed.length + 64; k++) {
|
||||
x ^= seed.charCodeAt(k) | 0
|
||||
next()
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Modulate a value between two ranges.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* const A = modulate(0, [0, 1], [0, 100]) // 0
|
||||
* const B = modulate(0.5, [0, 1], [0, 100]) // 50
|
||||
* const C = modulate(1, [0, 1], [0, 100]) // 100
|
||||
* ```
|
||||
*
|
||||
* @param value - The interpolation value.
|
||||
* @param rangeA - From [low, high]
|
||||
* @param rangeB - To [low, high]
|
||||
* @param clamp - Whether to clamp the the result to [low, high]
|
||||
* @public
|
||||
*/
|
||||
export function modulate(value: number, rangeA: number[], rangeB: number[], clamp = false): number {
|
||||
const [fromLow, fromHigh] = rangeA
|
||||
const [v0, v1] = rangeB
|
||||
const result = v0 + ((value - fromLow) / (fromHigh - fromLow)) * (v1 - v0)
|
||||
|
||||
return clamp
|
||||
? v0 < v1
|
||||
? Math.max(Math.min(result, v1), v0)
|
||||
: Math.max(Math.min(result, v0), v1)
|
||||
: result
|
||||
}
|
||||
236
packages/utils/src/lib/object.test.ts
Normal file
236
packages/utils/src/lib/object.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
areObjectsShallowEqual,
|
||||
filterEntries,
|
||||
getChangedKeys,
|
||||
groupBy,
|
||||
hasOwnProperty,
|
||||
isEqualAllowingForFloatingPointErrors,
|
||||
mapObjectMapValues,
|
||||
objectMapEntries,
|
||||
objectMapEntriesIterable,
|
||||
objectMapFromEntries,
|
||||
objectMapKeys,
|
||||
objectMapValues,
|
||||
omit,
|
||||
} from './object'
|
||||
|
||||
describe('hasOwnProperty', () => {
|
||||
it('should work with objects that override hasOwnProperty', () => {
|
||||
const obj = {
|
||||
name: 'Alice',
|
||||
hasOwnProperty: () => false,
|
||||
}
|
||||
expect(hasOwnProperty(obj, 'name')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('objectMapKeys', () => {
|
||||
it('should return typed keys preserving order', () => {
|
||||
const obj = { z: 1, a: 2, m: 3 }
|
||||
const keys = objectMapKeys(obj)
|
||||
expect(keys).toEqual(['z', 'a', 'm'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('objectMapValues', () => {
|
||||
it('should return typed values preserving order', () => {
|
||||
const obj = { first: 'a', second: 'b', third: 'c' }
|
||||
const values = objectMapValues(obj)
|
||||
expect(values).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('objectMapEntries', () => {
|
||||
it('should return typed entries preserving order', () => {
|
||||
const obj = { z: 1, a: 2 }
|
||||
const entries = objectMapEntries(obj)
|
||||
expect(entries).toEqual([
|
||||
['z', 1],
|
||||
['a', 2],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('objectMapEntriesIterable', () => {
|
||||
it('should skip inherited properties and work as iterator', () => {
|
||||
const parent = { inherited: 'value' }
|
||||
const child = Object.create(parent)
|
||||
child.own = 'ownValue'
|
||||
|
||||
const entries = Array.from(objectMapEntriesIterable(child))
|
||||
expect(entries).toEqual([['own', 'ownValue']])
|
||||
})
|
||||
|
||||
it('should work in for...of loops', () => {
|
||||
const obj = { x: 10, y: 20 }
|
||||
const collected: Array<[string, number]> = []
|
||||
|
||||
for (const [key, value] of objectMapEntriesIterable(obj)) {
|
||||
collected.push([key, value])
|
||||
}
|
||||
|
||||
expect(collected).toEqual([
|
||||
['x', 10],
|
||||
['y', 20],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('objectMapFromEntries', () => {
|
||||
it('should create typed object from entries', () => {
|
||||
const entries: Array<['name' | 'age', string | number]> = [
|
||||
['name', 'Alice'],
|
||||
['age', 30],
|
||||
]
|
||||
const obj = objectMapFromEntries(entries)
|
||||
expect(obj).toEqual({ name: 'Alice', age: 30 })
|
||||
})
|
||||
|
||||
it('should handle duplicate keys (last wins)', () => {
|
||||
const entries: Array<['key', string]> = [
|
||||
['key', 'first'],
|
||||
['key', 'second'],
|
||||
]
|
||||
const obj = objectMapFromEntries(entries)
|
||||
expect(obj).toEqual({ key: 'second' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterEntries', () => {
|
||||
it('should filter entries and optimize unchanged objects', () => {
|
||||
const scores = { alice: 85, bob: 92, charlie: 78 }
|
||||
const passing = filterEntries(scores, (name, score) => score >= 80)
|
||||
expect(passing).toEqual({ alice: 85, bob: 92 })
|
||||
|
||||
// Optimization: return same reference when no changes
|
||||
const unchanged = filterEntries(scores, () => true)
|
||||
expect(unchanged).toBe(scores)
|
||||
})
|
||||
|
||||
it('should pass key and value to predicate', () => {
|
||||
const obj = { prefix_a: 1, other_b: 2, prefix_c: 3 }
|
||||
const result = filterEntries(obj, (key, value) => key.startsWith('prefix_') && value > 1)
|
||||
expect(result).toEqual({ prefix_c: 3 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapObjectMapValues', () => {
|
||||
it('should map values with key and value access', () => {
|
||||
const obj = { a: 1, b: 2, c: 3 }
|
||||
const result = mapObjectMapValues(obj, (key, value) => `${key}:${value}`)
|
||||
expect(result).toEqual({ a: 'a:1', b: 'b:2', c: 'c:3' })
|
||||
})
|
||||
|
||||
it('should skip inherited properties', () => {
|
||||
const parent = { inherited: 'value' }
|
||||
const child = Object.create(parent)
|
||||
child.own = 'ownValue'
|
||||
|
||||
const result = mapObjectMapValues(child, (k, v) => (v as string).toUpperCase())
|
||||
expect(result).toEqual({ own: 'OWNVALUE' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('areObjectsShallowEqual', () => {
|
||||
it('should compare shallow equality correctly', () => {
|
||||
const a = { x: 1, y: 2 }
|
||||
const b = { x: 1, y: 2 }
|
||||
const c = { x: 1, y: 3 }
|
||||
const d = { x: 1, z: 2 }
|
||||
|
||||
expect(areObjectsShallowEqual(a, a)).toBe(true) // Same reference
|
||||
expect(areObjectsShallowEqual(a, b)).toBe(true) // Same values
|
||||
expect(areObjectsShallowEqual(a, c)).toBe(false) // Different values
|
||||
expect(areObjectsShallowEqual(a, d as any)).toBe(false) // Different keys
|
||||
})
|
||||
|
||||
it('should use Object.is for value comparison', () => {
|
||||
expect(areObjectsShallowEqual({ x: NaN }, { x: NaN })).toBe(true)
|
||||
expect(areObjectsShallowEqual({ x: -0 }, { x: +0 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupBy', () => {
|
||||
it('should group items by key selector function', () => {
|
||||
const people = [
|
||||
{ name: 'Alice', age: 25 },
|
||||
{ name: 'Bob', age: 30 },
|
||||
{ name: 'Charlie', age: 25 },
|
||||
]
|
||||
const byAge = groupBy(people, (person) => `age-${person.age}`)
|
||||
expect(byAge).toEqual({
|
||||
'age-25': [
|
||||
{ name: 'Alice', age: 25 },
|
||||
{ name: 'Charlie', age: 25 },
|
||||
],
|
||||
'age-30': [{ name: 'Bob', age: 30 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should accumulate duplicate keys', () => {
|
||||
const items = ['apple', 'apricot', 'banana']
|
||||
const result = groupBy(items, (item) => item.charAt(0))
|
||||
expect(result).toEqual({ a: ['apple', 'apricot'], b: ['banana'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('omit', () => {
|
||||
it('should create new object without specified keys', () => {
|
||||
const user = { id: '123', name: 'Alice', password: 'secret', email: 'alice@example.com' }
|
||||
const publicUser = omit(user, ['password'])
|
||||
expect(publicUser).toEqual({ id: '123', name: 'Alice', email: 'alice@example.com' })
|
||||
expect(publicUser).not.toBe(user)
|
||||
})
|
||||
|
||||
it('should handle multiple keys and non-existent keys', () => {
|
||||
const obj = { a: 1, b: 2, c: 3 }
|
||||
const result = omit(obj, ['b', 'nonexistent'])
|
||||
expect(result).toEqual({ a: 1, c: 3 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getChangedKeys', () => {
|
||||
it('should identify changed keys using Object.is', () => {
|
||||
const before = { name: 'Alice', age: 25, city: 'NYC' }
|
||||
const after = { name: 'Alice', age: 26, city: 'NYC' }
|
||||
expect(getChangedKeys(before, after)).toEqual(['age'])
|
||||
})
|
||||
|
||||
it('should use Object.is comparison (NaN and zero handling)', () => {
|
||||
const before = { nan: NaN, zero: -0, normal: 1 }
|
||||
const after = { nan: NaN, zero: +0, normal: 2 }
|
||||
expect(getChangedKeys(before, after)).toEqual(['zero', 'normal'])
|
||||
})
|
||||
|
||||
it('should only check keys from first object', () => {
|
||||
const before = { a: 1, b: 2 }
|
||||
const after = { a: 1, b: 2, c: 3 }
|
||||
expect(getChangedKeys(before, after)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEqualAllowingForFloatingPointErrors', () => {
|
||||
it('should handle floating point precision errors', () => {
|
||||
const a = { x: 0.1 + 0.2 } // 0.30000000000000004
|
||||
const b = { x: 0.3 }
|
||||
expect(isEqualAllowingForFloatingPointErrors(a, b)).toBe(true)
|
||||
})
|
||||
|
||||
it('should work with custom threshold and reject large differences', () => {
|
||||
const a = { x: 0.1 }
|
||||
const b = { x: 0.15 }
|
||||
expect(isEqualAllowingForFloatingPointErrors(a, b, 0.1)).toBe(true)
|
||||
expect(isEqualAllowingForFloatingPointErrors(a, b, 0.01)).toBe(false)
|
||||
expect(isEqualAllowingForFloatingPointErrors({ x: 0.1 }, { x: 0.2 })).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle deep objects and arrays with mixed types', () => {
|
||||
const a = { user: 'Alice', score: 0.1 + 0.2, values: [1.0000001, 'test'] }
|
||||
const b = { user: 'Alice', score: 0.3, values: [1.0000002, 'test'] }
|
||||
expect(isEqualAllowingForFloatingPointErrors(a, b)).toBe(true)
|
||||
|
||||
// Different structure should fail
|
||||
expect(isEqualAllowingForFloatingPointErrors({ x: 0.3 }, { y: 0.3 })).toBe(false)
|
||||
})
|
||||
})
|
||||
369
packages/utils/src/lib/object.ts
Normal file
369
packages/utils/src/lib/object.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import isEqualWith from 'lodash.isequalwith'
|
||||
|
||||
/**
|
||||
* Safely checks if an object has a specific property as its own property (not inherited).
|
||||
* Uses Object.prototype.hasOwnProperty.call to avoid issues with objects that have null prototype
|
||||
* or have overridden the hasOwnProperty method.
|
||||
*
|
||||
* @param obj - The object to check
|
||||
* @param key - The property key to check for
|
||||
* @returns True if the object has the property as its own property, false otherwise
|
||||
* @example
|
||||
* ```ts
|
||||
* const obj = { name: 'Alice', age: 30 }
|
||||
* hasOwnProperty(obj, 'name') // true
|
||||
* hasOwnProperty(obj, 'toString') // false (inherited)
|
||||
* hasOwnProperty(obj, 'unknown') // false
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function hasOwnProperty(obj: object, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely gets an object's own property value (not inherited). Returns undefined if the property
|
||||
* doesn't exist as an own property. Provides type-safe access with proper TypeScript inference.
|
||||
*
|
||||
* @param obj - The object to get the property from
|
||||
* @param key - The property key to retrieve
|
||||
* @returns The property value if it exists as an own property, undefined otherwise
|
||||
* @example
|
||||
* ```ts
|
||||
* const user = { name: 'Alice', age: 30 }
|
||||
* const name = getOwnProperty(user, 'name') // 'Alice'
|
||||
* const missing = getOwnProperty(user, 'unknown') // undefined
|
||||
* const inherited = getOwnProperty(user, 'toString') // undefined (inherited)
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function getOwnProperty<K extends string, V>(
|
||||
obj: Partial<Record<K, V>>,
|
||||
key: K
|
||||
): V | undefined
|
||||
/** @internal */
|
||||
export function getOwnProperty<O extends object>(obj: O, key: string): O[keyof O] | undefined
|
||||
/** @internal */
|
||||
export function getOwnProperty(obj: object, key: string): unknown
|
||||
/** @internal */
|
||||
export function getOwnProperty(obj: object, key: string): unknown {
|
||||
if (!hasOwnProperty(obj, key)) {
|
||||
return undefined
|
||||
}
|
||||
// @ts-expect-error we know the property exists
|
||||
return obj[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* An alias for `Object.keys` that treats the object as a map and so preserves the type of the keys.
|
||||
* Unlike standard Object.keys which returns string[], this maintains the specific string literal types.
|
||||
*
|
||||
* @param object - The object to get keys from
|
||||
* @returns Array of keys with preserved string literal types
|
||||
* @example
|
||||
* ```ts
|
||||
* const config = { theme: 'dark', lang: 'en' } as const
|
||||
* const keys = objectMapKeys(config)
|
||||
* // keys is Array<'theme' | 'lang'> instead of string[]
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function objectMapKeys<Key extends string>(object: {
|
||||
readonly [K in Key]: unknown
|
||||
}): Array<Key> {
|
||||
return Object.keys(object) as Key[]
|
||||
}
|
||||
|
||||
/**
|
||||
* An alias for `Object.values` that treats the object as a map and so preserves the type of the
|
||||
* values. Unlike standard Object.values which returns unknown[], this maintains the specific value types.
|
||||
*
|
||||
* @param object - The object to get values from
|
||||
* @returns Array of values with preserved types
|
||||
* @example
|
||||
* ```ts
|
||||
* const scores = { alice: 85, bob: 92, charlie: 78 }
|
||||
* const values = objectMapValues(scores)
|
||||
* // values is Array<number> instead of unknown[]
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function objectMapValues<Key extends string, Value>(object: {
|
||||
[K in Key]: Value
|
||||
}): Array<Value> {
|
||||
return Object.values(object) as Value[]
|
||||
}
|
||||
|
||||
/**
|
||||
* An alias for `Object.entries` that treats the object as a map and so preserves the type of the
|
||||
* keys and values. Unlike standard Object.entries which returns `Array<[string, unknown]>`, this maintains specific types.
|
||||
*
|
||||
* @param object - The object to get entries from
|
||||
* @returns Array of key-value pairs with preserved types
|
||||
* @example
|
||||
* ```ts
|
||||
* const user = { name: 'Alice', age: 30 }
|
||||
* const entries = objectMapEntries(user)
|
||||
* // entries is Array<['name' | 'age', string | number]>
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function objectMapEntries<Obj extends object>(
|
||||
object: Obj
|
||||
): Array<[keyof Obj, Obj[keyof Obj]]> {
|
||||
return Object.entries(object) as [keyof Obj, Obj[keyof Obj]][]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the entries of an object as an iterable iterator.
|
||||
* Useful when working with large collections, to avoid allocating an array.
|
||||
* Only yields own properties (not inherited ones).
|
||||
*
|
||||
* @param object - The object to iterate over
|
||||
* @returns Iterator yielding key-value pairs with preserved types
|
||||
* @example
|
||||
* ```ts
|
||||
* const largeMap = { a: 1, b: 2, c: 3 } // Imagine thousands of entries
|
||||
* for (const [key, value] of objectMapEntriesIterable(largeMap)) {
|
||||
* // Process entries one at a time without creating a large array
|
||||
* console.log(key, value)
|
||||
* }
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function* objectMapEntriesIterable<Key extends string, Value>(object: {
|
||||
[K in Key]: Value
|
||||
}): IterableIterator<[Key, Value]> {
|
||||
for (const key in object) {
|
||||
if (!Object.prototype.hasOwnProperty.call(object, key)) continue
|
||||
yield [key, object[key]]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An alias for `Object.fromEntries` that treats the object as a map and so preserves the type of the
|
||||
* keys and values. Creates an object from key-value pairs with proper TypeScript typing.
|
||||
*
|
||||
* @param entries - Array of key-value pairs to convert to an object
|
||||
* @returns Object with preserved key and value types
|
||||
* @example
|
||||
* ```ts
|
||||
* const pairs: Array<['name' | 'age', string | number]> = [['name', 'Alice'], ['age', 30]]
|
||||
* const obj = objectMapFromEntries(pairs)
|
||||
* // obj is { name: string | number, age: string | number }
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function objectMapFromEntries<Key extends string, Value>(
|
||||
entries: ReadonlyArray<readonly [Key, Value]>
|
||||
): { [K in Key]: Value } {
|
||||
return Object.fromEntries(entries) as { [K in Key]: Value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters an object using a predicate function, returning a new object with only the entries
|
||||
* that pass the predicate. Optimized to return the original object if no changes are needed.
|
||||
*
|
||||
* @param object - The object to filter
|
||||
* @param predicate - Function that tests each key-value pair
|
||||
* @returns A new object with only the entries that pass the predicate, or the original object if unchanged
|
||||
* @example
|
||||
* ```ts
|
||||
* const scores = { alice: 85, bob: 92, charlie: 78 }
|
||||
* const passing = filterEntries(scores, (name, score) => score >= 80)
|
||||
* // { alice: 85, bob: 92 }
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function filterEntries<Key extends string, Value>(
|
||||
object: { [K in Key]: Value },
|
||||
predicate: (key: Key, value: Value) => boolean
|
||||
): { [K in Key]: Value } {
|
||||
const result: { [K in Key]?: Value } = {}
|
||||
let didChange = false
|
||||
for (const key in object) {
|
||||
if (!Object.prototype.hasOwnProperty.call(object, key)) continue
|
||||
const value = object[key]
|
||||
if (predicate(key, value)) {
|
||||
result[key] = value
|
||||
} else {
|
||||
didChange = true
|
||||
}
|
||||
}
|
||||
return didChange ? (result as { [K in Key]: Value }) : object
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the values of an object to new values using a mapper function, preserving keys.
|
||||
* The mapper function receives both the key and value for each entry.
|
||||
*
|
||||
* @param object - The object whose values to transform
|
||||
* @param mapper - Function that transforms each value (receives key and value)
|
||||
* @returns A new object with the same keys but transformed values
|
||||
* @example
|
||||
* ```ts
|
||||
* const prices = { apple: 1.50, banana: 0.75, orange: 2.00 }
|
||||
* const withTax = mapObjectMapValues(prices, (fruit, price) => price * 1.08)
|
||||
* // { apple: 1.62, banana: 0.81, orange: 2.16 }
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function mapObjectMapValues<Key extends string, ValueBefore, ValueAfter>(
|
||||
object: { readonly [K in Key]: ValueBefore },
|
||||
mapper: (key: Key, value: ValueBefore) => ValueAfter
|
||||
): { [K in Key]: ValueAfter } {
|
||||
const result = {} as { [K in Key]: ValueAfter }
|
||||
for (const key in object) {
|
||||
if (!Object.prototype.hasOwnProperty.call(object, key)) continue
|
||||
result[key] = mapper(key, object[key])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a shallow equality check between two objects. Compares all enumerable own properties
|
||||
* using Object.is for value comparison. Returns true if both objects have the same keys and values.
|
||||
*
|
||||
* @param obj1 - First object to compare
|
||||
* @param obj2 - Second object to compare
|
||||
* @returns True if objects are shallow equal, false otherwise
|
||||
* @example
|
||||
* ```ts
|
||||
* const a = { x: 1, y: 2 }
|
||||
* const b = { x: 1, y: 2 }
|
||||
* const c = { x: 1, y: 3 }
|
||||
* areObjectsShallowEqual(a, b) // true
|
||||
* areObjectsShallowEqual(a, c) // false
|
||||
* areObjectsShallowEqual(a, a) // true (same reference)
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function areObjectsShallowEqual<T extends object>(obj1: T, obj2: T): boolean {
|
||||
if (obj1 === obj2) return true
|
||||
const keys1 = Object.keys(obj1)
|
||||
if (keys1.length !== Object.keys(obj2).length) return false
|
||||
for (const key of keys1) {
|
||||
if (!hasOwnProperty(obj2, key)) return false
|
||||
if (!Object.is((obj1 as any)[key], (obj2 as any)[key])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups an array of values into a record by a key extracted from each value.
|
||||
* The key selector function is called for each element to determine the grouping key.
|
||||
*
|
||||
* @param array - The array to group
|
||||
* @param keySelector - Function that extracts the grouping key from each value
|
||||
* @returns A record where keys are the extracted keys and values are arrays of grouped items
|
||||
* @example
|
||||
* ```ts
|
||||
* const people = [
|
||||
* { name: 'Alice', age: 25 },
|
||||
* { name: 'Bob', age: 30 },
|
||||
* { name: 'Charlie', age: 25 }
|
||||
* ]
|
||||
* const byAge = groupBy(people, person => `age-${person.age}`)
|
||||
* // { 'age-25': [Alice, Charlie], 'age-30': [Bob] }
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function groupBy<K extends string, V>(
|
||||
array: ReadonlyArray<V>,
|
||||
keySelector: (value: V) => K
|
||||
): Record<K, V[]> {
|
||||
const result: Record<K, V[]> = {} as any
|
||||
for (const value of array) {
|
||||
const key = keySelector(value)
|
||||
if (!result[key]) result[key] = []
|
||||
result[key].push(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new object with specified keys omitted from the original object.
|
||||
* Uses shallow copying and then deletes the unwanted keys.
|
||||
*
|
||||
* @param obj - The source object
|
||||
* @param keys - Array of key names to omit from the result
|
||||
* @returns A new object without the specified keys
|
||||
* @example
|
||||
* ```ts
|
||||
* const user = { id: '123', name: 'Alice', password: 'secret', email: 'alice@example.com' }
|
||||
* const publicUser = omit(user, ['password'])
|
||||
* // { id: '123', name: 'Alice', email: 'alice@example.com' }
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function omit(
|
||||
obj: Record<string, unknown>,
|
||||
keys: ReadonlyArray<string>
|
||||
): Record<string, unknown> {
|
||||
const result = { ...obj }
|
||||
for (const key of keys) {
|
||||
delete result[key]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two objects and returns an array of keys where the values differ.
|
||||
* Uses Object.is for comparison, which handles NaN and -0/+0 correctly.
|
||||
* Only checks keys present in the first object.
|
||||
*
|
||||
* @param obj1 - The first object (keys to check come from this object)
|
||||
* @param obj2 - The second object to compare against
|
||||
* @returns Array of keys where values differ between the objects
|
||||
* @example
|
||||
* ```ts
|
||||
* const before = { name: 'Alice', age: 25, city: 'NYC' }
|
||||
* const after = { name: 'Alice', age: 26, city: 'NYC' }
|
||||
* const changed = getChangedKeys(before, after)
|
||||
* // ['age']
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function getChangedKeys<T extends object>(obj1: T, obj2: T): (keyof T)[] {
|
||||
const result: (keyof T)[] = []
|
||||
for (const key in obj1) {
|
||||
if (!Object.is(obj1[key], obj2[key])) {
|
||||
result.push(key)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep equality comparison that allows for floating-point precision errors.
|
||||
* Numbers are considered equal if they differ by less than the threshold.
|
||||
* Uses lodash.isequalwith internally for the deep comparison logic.
|
||||
*
|
||||
* @param obj1 - First object to compare
|
||||
* @param obj2 - Second object to compare
|
||||
* @param threshold - Maximum difference allowed between numbers (default: 0.000001)
|
||||
* @returns True if objects are deeply equal with floating-point tolerance
|
||||
* @example
|
||||
* ```ts
|
||||
* const a = { x: 0.1 + 0.2 } // 0.30000000000000004
|
||||
* const b = { x: 0.3 }
|
||||
* isEqualAllowingForFloatingPointErrors(a, b) // true
|
||||
*
|
||||
* const c = { coords: [1.0000001, 2.0000001] }
|
||||
* const d = { coords: [1.0000002, 2.0000002] }
|
||||
* isEqualAllowingForFloatingPointErrors(c, d) // true
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function isEqualAllowingForFloatingPointErrors(
|
||||
obj1: object,
|
||||
obj2: object,
|
||||
threshold = 0.000001
|
||||
): boolean {
|
||||
return isEqualWith(obj1, obj2, (value1, value2) => {
|
||||
if (typeof value1 === 'number' && typeof value2 === 'number') {
|
||||
return Math.abs(value1 - value2) < threshold
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
141
packages/utils/src/lib/perf.ts
Normal file
141
packages/utils/src/lib/perf.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Color scheme for performance indicators.
|
||||
* Provides consistent colors for performance measurement displays.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const PERFORMANCE_COLORS = {
|
||||
Good: '#40C057',
|
||||
Mid: '#FFC078',
|
||||
Poor: '#E03131',
|
||||
}
|
||||
|
||||
/**
|
||||
* Default color for performance measurement log prefixes.
|
||||
* Uses the 'Good' performance color for console output styling.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const PERFORMANCE_PREFIX_COLOR = PERFORMANCE_COLORS.Good
|
||||
|
||||
/**
|
||||
* Measures and logs the execution time of a callback function.
|
||||
* Executes the provided callback and logs the duration to the console with styled output.
|
||||
*
|
||||
* @param name - Descriptive name for the operation being measured
|
||||
* @param cb - Callback function to execute and measure
|
||||
* @returns The return value of the callback function
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = measureCbDuration('data processing', () => {
|
||||
* return processLargeDataSet(data)
|
||||
* })
|
||||
* // Console output: "Perf data processing took 42.5ms"
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function measureCbDuration(name: string, cb: () => any) {
|
||||
const start = performance.now()
|
||||
const result = cb()
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(
|
||||
`%cPerf%c ${name} took ${performance.now() - start}ms`,
|
||||
`color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`,
|
||||
'font-weight: normal'
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator that measures and logs the execution time of class methods.
|
||||
* Wraps the decorated method to automatically log its execution duration.
|
||||
*
|
||||
* @param _target - The class prototype (unused)
|
||||
* @param propertyKey - Name of the method being decorated
|
||||
* @param descriptor - Property descriptor of the method
|
||||
* @returns Modified property descriptor with timing measurement
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class DataProcessor {
|
||||
* @measureDuration
|
||||
* processData(data: unknown[]) {
|
||||
* return data.map(item => transform(item))
|
||||
* }
|
||||
* }
|
||||
* // When processData is called, logs: "Perf processData took: 15.2ms"
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function measureDuration(_target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value
|
||||
descriptor.value = function (...args: any[]) {
|
||||
const start = performance.now()
|
||||
const result = originalMethod.apply(this, args)
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(
|
||||
`%cPerf%c ${propertyKey} took: ${performance.now() - start}ms`,
|
||||
`color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`,
|
||||
'font-weight: normal'
|
||||
)
|
||||
return result
|
||||
}
|
||||
return descriptor
|
||||
}
|
||||
|
||||
const averages = new Map<any, { total: number; count: number }>()
|
||||
|
||||
/**
|
||||
* Decorator that measures method execution time and tracks running averages.
|
||||
* Wraps the decorated method to log both current execution time and running average.
|
||||
* Maintains a running total and count for each decorated method to calculate averages.
|
||||
*
|
||||
* @param _target - The class prototype (unused)
|
||||
* @param propertyKey - Name of the method being decorated
|
||||
* @param descriptor - Property descriptor of the method
|
||||
* @returns Modified property descriptor with timing measurement and averaging
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class RenderEngine {
|
||||
* @measureAverageDuration
|
||||
* renderFrame() {
|
||||
* // Rendering logic here
|
||||
* }
|
||||
* }
|
||||
* // After multiple calls, logs: "Perf renderFrame took 16.67ms | average 15.83ms"
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function measureAverageDuration(
|
||||
_target: any,
|
||||
propertyKey: string,
|
||||
descriptor: PropertyDescriptor
|
||||
) {
|
||||
const originalMethod = descriptor.value
|
||||
descriptor.value = function (...args: any[]) {
|
||||
const start = performance.now()
|
||||
const result = originalMethod.apply(this, args)
|
||||
const end = performance.now()
|
||||
const length = end - start
|
||||
if (length !== 0) {
|
||||
const value = averages.get(descriptor.value)!
|
||||
const total = value.total + length
|
||||
const count = value.count + 1
|
||||
averages.set(descriptor.value, { total, count })
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(
|
||||
`%cPerf%c ${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms`,
|
||||
`color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`,
|
||||
'font-weight: normal'
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
averages.set(descriptor.value, { total: 0, count: 0 })
|
||||
return descriptor
|
||||
}
|
||||
168
packages/utils/src/lib/reordering.test.ts
Normal file
168
packages/utils/src/lib/reordering.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
type IndexKey,
|
||||
getIndexAbove,
|
||||
getIndexBelow,
|
||||
getIndexBetween,
|
||||
getIndices,
|
||||
getIndicesAbove,
|
||||
getIndicesBelow,
|
||||
getIndicesBetween,
|
||||
sortByIndex,
|
||||
validateIndexKey,
|
||||
} from './reordering'
|
||||
|
||||
describe('reordering', () => {
|
||||
describe('validateIndexKey', () => {
|
||||
it('should accept valid index keys', () => {
|
||||
expect(() => validateIndexKey('a0')).not.toThrow()
|
||||
expect(() => validateIndexKey('a1')).not.toThrow()
|
||||
expect(() => validateIndexKey('a0V')).not.toThrow()
|
||||
})
|
||||
|
||||
it('should throw for invalid index keys', () => {
|
||||
expect(() => validateIndexKey('')).toThrow('invalid index: ')
|
||||
expect(() => validateIndexKey('invalid')).toThrow('invalid index: invalid')
|
||||
expect(() => validateIndexKey('123')).toThrow('invalid index: 123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getIndicesBetween', () => {
|
||||
it('should generate indices between two indices', () => {
|
||||
const indices = getIndicesBetween('a0' as IndexKey, 'a2' as IndexKey, 2)
|
||||
expect(indices).toHaveLength(2)
|
||||
expect(indices.every((index) => index > 'a0' && index < 'a2')).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle null/undefined parameters', () => {
|
||||
const indices1 = getIndicesBetween(null, 'a2' as IndexKey, 2)
|
||||
expect(indices1).toHaveLength(2)
|
||||
expect(indices1.every((index) => index < 'a2')).toBe(true)
|
||||
|
||||
const indices2 = getIndicesBetween('a0' as IndexKey, null, 2)
|
||||
expect(indices2).toHaveLength(2)
|
||||
expect(indices2.every((index) => index > 'a0')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getIndicesAbove', () => {
|
||||
it('should generate indices above a given index', () => {
|
||||
const indices = getIndicesAbove('a0' as IndexKey, 3)
|
||||
expect(indices).toHaveLength(3)
|
||||
expect(indices.every((index) => index > 'a0')).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle null/undefined parameters', () => {
|
||||
const indices = getIndicesAbove(null, 3)
|
||||
expect(indices).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getIndicesBelow', () => {
|
||||
it('should generate indices below a given index', () => {
|
||||
const indices = getIndicesBelow('a5' as IndexKey, 3)
|
||||
expect(indices).toHaveLength(3)
|
||||
expect(indices.every((index) => index < 'a5')).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle null/undefined parameters', () => {
|
||||
const indices = getIndicesBelow(null, 3)
|
||||
expect(indices).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getIndexBetween', () => {
|
||||
it('should generate single index between two indices', () => {
|
||||
const index = getIndexBetween('a0' as IndexKey, 'a2' as IndexKey)
|
||||
expect(index > 'a0' && index < 'a2').toBe(true)
|
||||
})
|
||||
|
||||
it('should handle null/undefined parameters', () => {
|
||||
const index1 = getIndexBetween(null, 'a2' as IndexKey)
|
||||
expect(index1 < 'a2').toBe(true)
|
||||
|
||||
const index2 = getIndexBetween('a0' as IndexKey, null)
|
||||
expect(index2 > 'a0').toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getIndexAbove', () => {
|
||||
it('should generate index above given index', () => {
|
||||
const index = getIndexAbove('a0' as IndexKey)
|
||||
expect(index > 'a0').toBe(true)
|
||||
})
|
||||
|
||||
it('should handle null/undefined/no parameters', () => {
|
||||
const index = getIndexAbove()
|
||||
expect(typeof index).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getIndexBelow', () => {
|
||||
it('should generate index below given index', () => {
|
||||
const index = getIndexBelow('a5' as IndexKey)
|
||||
expect(index < 'a5').toBe(true)
|
||||
})
|
||||
|
||||
it('should handle null/undefined/no parameters', () => {
|
||||
const index = getIndexBelow()
|
||||
expect(typeof index).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getIndices', () => {
|
||||
it('should generate indices starting from given index', () => {
|
||||
const indices = getIndices(3, 'a1' as IndexKey)
|
||||
expect(indices).toHaveLength(4) // start + n additional
|
||||
expect(indices[0]).toBe('a1')
|
||||
expect(indices.every((index) => index >= 'a1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should use default start index when not provided', () => {
|
||||
const indices = getIndices(3)
|
||||
expect(indices).toHaveLength(4)
|
||||
expect(indices[0]).toBe('a1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortByIndex', () => {
|
||||
it('should sort objects by index property in ascending order', () => {
|
||||
const objects = [
|
||||
{ id: 'c', index: 'a3' as IndexKey },
|
||||
{ id: 'a', index: 'a1' as IndexKey },
|
||||
{ id: 'b', index: 'a2' as IndexKey },
|
||||
]
|
||||
|
||||
const sorted = objects.sort(sortByIndex)
|
||||
expect(sorted.map((obj) => obj.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('should handle fractional indices correctly', () => {
|
||||
const objects = [
|
||||
{ id: 'c', index: 'a1V' as IndexKey }, // Between a1 and a2
|
||||
{ id: 'a', index: 'a1' as IndexKey },
|
||||
{ id: 'b', index: 'a2' as IndexKey },
|
||||
]
|
||||
|
||||
const sorted = objects.sort(sortByIndex)
|
||||
expect(sorted.map((obj) => obj.id)).toEqual(['a', 'c', 'b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration tests', () => {
|
||||
it('should maintain order when inserting indices between existing ones', () => {
|
||||
const start = getIndexAbove()
|
||||
const end = getIndexAbove(start)
|
||||
const between = getIndexBetween(start, end)
|
||||
|
||||
const objects = [
|
||||
{ id: 'first', index: start },
|
||||
{ id: 'last', index: end },
|
||||
{ id: 'middle', index: between },
|
||||
]
|
||||
|
||||
const sorted = objects.sort(sortByIndex)
|
||||
expect(sorted.map((obj) => obj.id)).toEqual(['first', 'middle', 'last'])
|
||||
})
|
||||
})
|
||||
})
|
||||
199
packages/utils/src/lib/reordering.ts
Normal file
199
packages/utils/src/lib/reordering.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
generateNJitteredKeysBetween,
|
||||
generateNKeysBetween,
|
||||
validateOrderKey,
|
||||
} from './fractionalIndexing'
|
||||
|
||||
const generateKeysFn =
|
||||
process.env.NODE_ENV === 'test' ? generateNKeysBetween : generateNJitteredKeysBetween
|
||||
|
||||
/**
|
||||
* A string made up of an integer part followed by a fraction part. The fraction point consists of
|
||||
* zero or more digits with no trailing zeros. Based on
|
||||
* {@link https://observablehq.com/@dgreensp/implementing-fractional-indexing}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type IndexKey = string & { __brand: 'indexKey' }
|
||||
|
||||
/**
|
||||
* The index key for the first index - 'a0'.
|
||||
* @public
|
||||
*/
|
||||
export const ZERO_INDEX_KEY = 'a0' as IndexKey
|
||||
|
||||
/**
|
||||
* Validates that a string is a valid IndexKey.
|
||||
* @param index - The string to validate.
|
||||
* @throws Error if the index is invalid.
|
||||
* @internal
|
||||
*/
|
||||
export function validateIndexKey(index: string): asserts index is IndexKey {
|
||||
try {
|
||||
validateOrderKey(index)
|
||||
} catch {
|
||||
throw new Error('invalid index: ' + index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a number of indices between two indices.
|
||||
* @param below - The index below.
|
||||
* @param above - The index above.
|
||||
* @param n - The number of indices to get.
|
||||
* @returns An array of n IndexKey values between below and above.
|
||||
* @example
|
||||
* ```ts
|
||||
* const indices = getIndicesBetween('a0' as IndexKey, 'a2' as IndexKey, 2)
|
||||
* console.log(indices) // ['a0V', 'a1']
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function getIndicesBetween(
|
||||
below: IndexKey | null | undefined,
|
||||
above: IndexKey | null | undefined,
|
||||
n: number
|
||||
) {
|
||||
return generateKeysFn(below ?? null, above ?? null, n) as IndexKey[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a number of indices above an index.
|
||||
* @param below - The index below.
|
||||
* @param n - The number of indices to get.
|
||||
* @returns An array of n IndexKey values above the given index.
|
||||
* @example
|
||||
* ```ts
|
||||
* const indices = getIndicesAbove('a0' as IndexKey, 3)
|
||||
* console.log(indices) // ['a1', 'a2', 'a3']
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function getIndicesAbove(below: IndexKey | null | undefined, n: number) {
|
||||
return generateKeysFn(below ?? null, null, n) as IndexKey[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a number of indices below an index.
|
||||
* @param above - The index above.
|
||||
* @param n - The number of indices to get.
|
||||
* @returns An array of n IndexKey values below the given index.
|
||||
* @example
|
||||
* ```ts
|
||||
* const indices = getIndicesBelow('a2' as IndexKey, 2)
|
||||
* console.log(indices) // ['a1', 'a0V']
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function getIndicesBelow(above: IndexKey | null | undefined, n: number) {
|
||||
return generateKeysFn(null, above ?? null, n) as IndexKey[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the index between two indices.
|
||||
* @param below - The index below.
|
||||
* @param above - The index above.
|
||||
* @returns A single IndexKey value between below and above.
|
||||
* @example
|
||||
* ```ts
|
||||
* const index = getIndexBetween('a0' as IndexKey, 'a2' as IndexKey)
|
||||
* console.log(index) // 'a1'
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function getIndexBetween(
|
||||
below: IndexKey | null | undefined,
|
||||
above: IndexKey | null | undefined
|
||||
) {
|
||||
return generateKeysFn(below ?? null, above ?? null, 1)[0] as IndexKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the index above a given index.
|
||||
* @param below - The index below.
|
||||
* @returns An IndexKey value above the given index.
|
||||
* @example
|
||||
* ```ts
|
||||
* const index = getIndexAbove('a0' as IndexKey)
|
||||
* console.log(index) // 'a1'
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function getIndexAbove(below: IndexKey | null | undefined = null) {
|
||||
return generateKeysFn(below, null, 1)[0] as IndexKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the index below a given index.
|
||||
* @param above - The index above.
|
||||
* @returns An IndexKey value below the given index.
|
||||
* @example
|
||||
* ```ts
|
||||
* const index = getIndexBelow('a2' as IndexKey)
|
||||
* console.log(index) // 'a1'
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function getIndexBelow(above: IndexKey | null | undefined = null) {
|
||||
return generateKeysFn(null, above, 1)[0] as IndexKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Get n number of indices, starting at an index.
|
||||
* @param n - The number of indices to get.
|
||||
* @param start - The index to start at.
|
||||
* @returns An array containing the start index plus n additional IndexKey values.
|
||||
* @example
|
||||
* ```ts
|
||||
* const indices = getIndices(3, 'a1' as IndexKey)
|
||||
* console.log(indices) // ['a1', 'a2', 'a3', 'a4']
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function getIndices(n: number, start = 'a1' as IndexKey) {
|
||||
return [start, ...generateKeysFn(start, null, n)] as IndexKey[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by index.
|
||||
* @param a - An object with an index property.
|
||||
* @param b - An object with an index property.
|
||||
* @returns A number indicating sort order (-1, 0, or 1).
|
||||
* @example
|
||||
* ```ts
|
||||
* const shapes = [
|
||||
* { id: 'b', index: 'a2' as IndexKey },
|
||||
* { id: 'a', index: 'a1' as IndexKey }
|
||||
* ]
|
||||
* const sorted = shapes.sort(sortByIndex)
|
||||
* console.log(sorted) // [{ id: 'a', index: 'a1' }, { id: 'b', index: 'a2' }]
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function sortByIndex<T extends { index: IndexKey }>(a: T, b: T) {
|
||||
if (a.index < b.index) {
|
||||
return -1
|
||||
} else if (a.index > b.index) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by index, or null.
|
||||
* @param a - An object with an index property.
|
||||
* @param b - An object with an index property.
|
||||
* @public
|
||||
*/
|
||||
export function sortByMaybeIndex<T extends { index?: IndexKey | null }>(a: T, b: T) {
|
||||
if (a.index && b.index) {
|
||||
return a.index < b.index ? -1 : 1
|
||||
}
|
||||
if (a.index && b.index == null) {
|
||||
return -1
|
||||
}
|
||||
if (a.index == null && b.index == null) {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
77
packages/utils/src/lib/retry.test.ts
Normal file
77
packages/utils/src/lib/retry.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { retry } from './retry'
|
||||
|
||||
describe('retry', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns result when function succeeds on first attempt', async () => {
|
||||
const successFn = vi.fn().mockResolvedValue('success')
|
||||
|
||||
const result = await retry(successFn)
|
||||
|
||||
expect(result).toBe('success')
|
||||
expect(successFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns result when function succeeds after failures', async () => {
|
||||
const successFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('fail 1'))
|
||||
.mockRejectedValueOnce(new Error('fail 2'))
|
||||
.mockResolvedValue('success')
|
||||
|
||||
const promise = retry(successFn)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
const result = await promise
|
||||
|
||||
expect(result).toBe('success')
|
||||
expect(successFn).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('respects custom waitDuration setting', async () => {
|
||||
const failFn = vi.fn().mockRejectedValueOnce(new Error('fail 1')).mockResolvedValue('success')
|
||||
|
||||
const promise = retry(failFn, { waitDuration: 2500 })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(failFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
const result = await promise
|
||||
|
||||
expect(result).toBe('success')
|
||||
expect(failFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries only errors that match the filter', async () => {
|
||||
class NetworkError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'NetworkError'
|
||||
}
|
||||
}
|
||||
|
||||
const failFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new NetworkError('network fail'))
|
||||
.mockRejectedValueOnce(new NetworkError('network fail 2'))
|
||||
.mockResolvedValue('success')
|
||||
|
||||
const promise = retry(failFn, {
|
||||
matchError: (error) => error instanceof NetworkError,
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
const result = await promise
|
||||
|
||||
expect(result).toBe('success')
|
||||
expect(failFn).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
77
packages/utils/src/lib/retry.ts
Normal file
77
packages/utils/src/lib/retry.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { sleep } from './control'
|
||||
|
||||
/**
|
||||
* Retries an async operation with configurable attempt count, wait duration, and error filtering.
|
||||
* Executes the provided async function repeatedly until it succeeds or the maximum number of attempts is reached.
|
||||
* Includes support for abort signals and custom error matching to determine which errors should trigger retries.
|
||||
*
|
||||
* @param fn - The async function to retry on failure
|
||||
* @param options - Configuration options for retry behavior:
|
||||
* - `attempts`: Maximum number of retry attempts (default: 3)
|
||||
* - `waitDuration`: Milliseconds to wait between retry attempts (default: 1000)
|
||||
* - `abortSignal`: Optional AbortSignal to cancel the retry operation
|
||||
* - `matchError`: Optional function to determine if an error should trigger a retry
|
||||
* @returns Promise that resolves with the function's return value on success
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Basic retry with default settings (3 attempts, 1 second wait)
|
||||
* const data = await retry(async () => {
|
||||
* const response = await fetch('/api/data')
|
||||
* if (!response.ok) throw new Error('Network error')
|
||||
* return response.json()
|
||||
* })
|
||||
*
|
||||
* // Custom retry configuration
|
||||
* const result = await retry(
|
||||
* async () => unreliableApiCall(),
|
||||
* {
|
||||
* attempts: 5,
|
||||
* waitDuration: 2000,
|
||||
* matchError: (error) => error instanceof NetworkError
|
||||
* }
|
||||
* )
|
||||
*
|
||||
* // With abort signal for cancellation
|
||||
* const controller = new AbortController()
|
||||
* setTimeout(() => controller.abort(), 10000) // Cancel after 10 seconds
|
||||
*
|
||||
* const data = await retry(
|
||||
* async () => fetchData(),
|
||||
* {
|
||||
* attempts: 10,
|
||||
* abortSignal: controller.signal
|
||||
* }
|
||||
* )
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export async function retry<T>(
|
||||
fn: (args: { attempt: number; remaining: number; total: number }) => Promise<T>,
|
||||
{
|
||||
attempts = 3,
|
||||
waitDuration = 1000,
|
||||
abortSignal,
|
||||
matchError,
|
||||
}: {
|
||||
attempts?: number
|
||||
waitDuration?: number
|
||||
abortSignal?: AbortSignal
|
||||
matchError?(error: unknown): boolean
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
let error: unknown = null
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
if (abortSignal?.aborted) throw new Error('aborted')
|
||||
try {
|
||||
return await fn({ attempt: i, remaining: attempts - i, total: attempts })
|
||||
} catch (e) {
|
||||
if (matchError && !matchError(e)) throw e
|
||||
error = e
|
||||
await sleep(waitDuration)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw error
|
||||
}
|
||||
36
packages/utils/src/lib/sort.test.ts
Normal file
36
packages/utils/src/lib/sort.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sortById } from './sort'
|
||||
|
||||
describe('sortById', () => {
|
||||
it('sorts objects with string ids in ascending order', () => {
|
||||
const items = [
|
||||
{ id: 'c', name: 'Charlie' },
|
||||
{ id: 'a', name: 'Alice' },
|
||||
{ id: 'b', name: 'Bob' },
|
||||
]
|
||||
|
||||
const sorted = items.sort(sortById)
|
||||
|
||||
expect(sorted).toEqual([
|
||||
{ id: 'a', name: 'Alice' },
|
||||
{ id: 'b', name: 'Bob' },
|
||||
{ id: 'c', name: 'Charlie' },
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts objects with numeric ids in ascending order', () => {
|
||||
const items = [
|
||||
{ id: 3, label: 'three' },
|
||||
{ id: 1, label: 'one' },
|
||||
{ id: 2, label: 'two' },
|
||||
]
|
||||
|
||||
const sorted = items.sort(sortById)
|
||||
|
||||
expect(sorted).toEqual([
|
||||
{ id: 1, label: 'one' },
|
||||
{ id: 2, label: 'two' },
|
||||
{ id: 3, label: 'three' },
|
||||
])
|
||||
})
|
||||
})
|
||||
25
packages/utils/src/lib/sort.ts
Normal file
25
packages/utils/src/lib/sort.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Compares two objects by their id property for use with Array.sort().
|
||||
* Sorts objects in ascending order based on their id values.
|
||||
*
|
||||
* @param a - First object to compare
|
||||
* @param b - Second object to compare
|
||||
* @returns 1 if a.id \> b.id, -1 if a.id \<= b.id
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const items = [
|
||||
* { id: 'c', name: 'Charlie' },
|
||||
* { id: 'a', name: 'Alice' },
|
||||
* { id: 'b', name: 'Bob' },
|
||||
* ]
|
||||
*
|
||||
* const sorted = items.sort(sortById)
|
||||
* // [{ id: 'a', name: 'Alice' }, { id: 'b', name: 'Bob' }, { id: 'c', name: 'Charlie' }]
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function sortById<T extends { id: any }>(a: T, b: T) {
|
||||
return a.id > b.id ? 1 : -1
|
||||
}
|
||||
121
packages/utils/src/lib/storage.test.ts
Normal file
121
packages/utils/src/lib/storage.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/* eslint-disable tldraw/no-direct-storage */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
clearLocalStorage,
|
||||
clearSessionStorage,
|
||||
deleteFromLocalStorage,
|
||||
deleteFromSessionStorage,
|
||||
getFromLocalStorage,
|
||||
getFromSessionStorage,
|
||||
setInLocalStorage,
|
||||
setInSessionStorage,
|
||||
} from './storage'
|
||||
|
||||
describe('storage', () => {
|
||||
// Stub the Window accessors rather than assigning to `global.localStorage`: jsdom defines
|
||||
// these as getters that assignment can't overwrite, and each read returns a fresh wrapper,
|
||||
// so a spy on one instance wouldn't be seen by the next read.
|
||||
function stubStorage(name: 'localStorage' | 'sessionStorage') {
|
||||
const mock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
vi.spyOn(window, name, 'get').mockReturnValue(mock as any)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubStorage('localStorage')
|
||||
stubStorage('sessionStorage')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('getFromLocalStorage', () => {
|
||||
it('should return null when localStorage.getItem throws an error', () => {
|
||||
;(localStorage.getItem as any).mockImplementation(() => {
|
||||
throw new Error('Storage not available')
|
||||
})
|
||||
|
||||
const result = getFromLocalStorage('test-key')
|
||||
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setInLocalStorage', () => {
|
||||
it('should not throw when localStorage.setItem throws an error', () => {
|
||||
;(localStorage.setItem as any).mockImplementation(() => {
|
||||
throw new Error('Quota exceeded')
|
||||
})
|
||||
|
||||
expect(() => setInLocalStorage('test-key', 'test-value')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteFromLocalStorage', () => {
|
||||
it('should not throw when localStorage.removeItem throws an error', () => {
|
||||
;(localStorage.removeItem as any).mockImplementation(() => {
|
||||
throw new Error('Storage not available')
|
||||
})
|
||||
|
||||
expect(() => deleteFromLocalStorage('test-key')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearLocalStorage', () => {
|
||||
it('should not throw when localStorage.clear throws an error', () => {
|
||||
;(localStorage.clear as any).mockImplementation(() => {
|
||||
throw new Error('Storage not available')
|
||||
})
|
||||
|
||||
expect(() => clearLocalStorage()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFromSessionStorage', () => {
|
||||
it('should return null when sessionStorage.getItem throws an error', () => {
|
||||
;(sessionStorage.getItem as any).mockImplementation(() => {
|
||||
throw new Error('Storage not available')
|
||||
})
|
||||
|
||||
const result = getFromSessionStorage('session-key')
|
||||
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setInSessionStorage', () => {
|
||||
it('should not throw when sessionStorage.setItem throws an error', () => {
|
||||
;(sessionStorage.setItem as any).mockImplementation(() => {
|
||||
throw new Error('Quota exceeded')
|
||||
})
|
||||
|
||||
expect(() => setInSessionStorage('session-key', 'session-value')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteFromSessionStorage', () => {
|
||||
it('should not throw when sessionStorage.removeItem throws an error', () => {
|
||||
;(sessionStorage.removeItem as any).mockImplementation(() => {
|
||||
throw new Error('Storage not available')
|
||||
})
|
||||
|
||||
expect(() => deleteFromSessionStorage('session-key')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearSessionStorage', () => {
|
||||
it('should not throw when sessionStorage.clear throws an error', () => {
|
||||
;(sessionStorage.clear as any).mockImplementation(() => {
|
||||
throw new Error('Storage not available')
|
||||
})
|
||||
|
||||
expect(() => clearSessionStorage()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
165
packages/utils/src/lib/storage.tsx
Normal file
165
packages/utils/src/lib/storage.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
/* eslint-disable tldraw/no-direct-storage */
|
||||
|
||||
/**
|
||||
* Get a value from local storage.
|
||||
*
|
||||
* @param key - The key to get.
|
||||
* @returns The stored value as a string, or null if not found or storage is unavailable.
|
||||
* @example
|
||||
* ```ts
|
||||
* const userTheme = getFromLocalStorage('user-theme')
|
||||
* if (userTheme) {
|
||||
* console.log('Stored theme:', userTheme)
|
||||
* }
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function getFromLocalStorage(key: string) {
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in local storage. Will not throw an error if localStorage is not available.
|
||||
*
|
||||
* @param key - The key to set.
|
||||
* @param value - The value to set.
|
||||
* @returns void
|
||||
* @example
|
||||
* ```ts
|
||||
* const preferences = { theme: 'dark', language: 'en' }
|
||||
* setInLocalStorage('user-preferences', JSON.stringify(preferences))
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function setInLocalStorage(key: string, value: string) {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a value from local storage. Will not throw an error if localStorage is not available.
|
||||
*
|
||||
* @param key - The key to remove.
|
||||
* @returns void
|
||||
* @example
|
||||
* ```ts
|
||||
* deleteFromLocalStorage('user-preferences')
|
||||
* // Value is now removed from localStorage
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function deleteFromLocalStorage(key: string) {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all values from local storage. Will not throw an error if localStorage is not available.
|
||||
*
|
||||
* @returns void
|
||||
* @example
|
||||
* ```ts
|
||||
* clearLocalStorage()
|
||||
* // All localStorage data is now cleared
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function clearLocalStorage() {
|
||||
try {
|
||||
localStorage.clear()
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from session storage.
|
||||
*
|
||||
* @param key - The key to get.
|
||||
* @returns The stored value as a string, or null if not found or storage is unavailable.
|
||||
* @example
|
||||
* ```ts
|
||||
* const currentTool = getFromSessionStorage('current-tool')
|
||||
* if (currentTool) {
|
||||
* console.log('Active tool:', currentTool)
|
||||
* }
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function getFromSessionStorage(key: string) {
|
||||
try {
|
||||
return sessionStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in session storage. Will not throw an error if sessionStorage is not available.
|
||||
*
|
||||
* @param key - The key to set.
|
||||
* @param value - The value to set.
|
||||
* @returns void
|
||||
* @example
|
||||
* ```ts
|
||||
* setInSessionStorage('current-tool', 'select')
|
||||
* setInSessionStorage('temp-data', JSON.stringify({ x: 100, y: 200 }))
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function setInSessionStorage(key: string, value: string) {
|
||||
try {
|
||||
sessionStorage.setItem(key, value)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a value from session storage. Will not throw an error if sessionStorage is not available.
|
||||
*
|
||||
* @param key - The key to remove.
|
||||
* @returns void
|
||||
* @example
|
||||
* ```ts
|
||||
* deleteFromSessionStorage('temp-data')
|
||||
* // Value is now removed from sessionStorage
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function deleteFromSessionStorage(key: string) {
|
||||
try {
|
||||
sessionStorage.removeItem(key)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all values from session storage. Will not throw an error if sessionStorage is not available.
|
||||
*
|
||||
* @returns void
|
||||
* @example
|
||||
* ```ts
|
||||
* clearSessionStorage()
|
||||
* // All sessionStorage data is now cleared
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function clearSessionStorage() {
|
||||
try {
|
||||
sessionStorage.clear()
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
80
packages/utils/src/lib/string.test.ts
Normal file
80
packages/utils/src/lib/string.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getFirstCharacter, iterateGraphemes } from './string'
|
||||
|
||||
describe('getFirstCharacter', () => {
|
||||
it('returns the first character of a plain string', () => {
|
||||
expect(getFirstCharacter('hello')).toBe('h')
|
||||
})
|
||||
|
||||
it('returns an empty string for an empty input', () => {
|
||||
expect(getFirstCharacter('')).toBe('')
|
||||
})
|
||||
|
||||
it('keeps a leading emoji whole instead of splitting a surrogate pair', () => {
|
||||
expect(getFirstCharacter('😀 hello')).toBe('😀')
|
||||
})
|
||||
|
||||
it('keeps a multi-code-point emoji cluster whole', () => {
|
||||
expect(getFirstCharacter('👨👩👧 family')).toBe('👨👩👧')
|
||||
})
|
||||
|
||||
it('handles a leading whitespace character', () => {
|
||||
expect(getFirstCharacter(' hi')).toBe(' ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('iterateGraphemes', () => {
|
||||
it('yields each grapheme cluster in order', () => {
|
||||
expect([...iterateGraphemes('a😀b')]).toEqual(['a', '😀', 'b'])
|
||||
})
|
||||
|
||||
it('keeps a multi-code-point cluster whole', () => {
|
||||
expect([...iterateGraphemes('👨👩👧!')]).toEqual(['👨👩👧', '!'])
|
||||
})
|
||||
|
||||
it('yields nothing for an empty string', () => {
|
||||
expect([...iterateGraphemes('')]).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// On browsers without `Intl.Segmenter` (e.g. Firefox before 125) the module must not throw and must
|
||||
// still iterate; it falls back to code-point iteration. The segmenter is probed lazily on first
|
||||
// use, so `Intl.Segmenter` has to be absent at call time — reset the module and remove the global
|
||||
// for the duration of each call.
|
||||
describe('grapheme iteration without Intl.Segmenter', () => {
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
async function withoutSegmenter<T>(fn: (mod: typeof import('./string')) => T): Promise<T> {
|
||||
vi.resetModules()
|
||||
// `Intl.Segmenter` is typed read-only; take a mutable view so we can remove and restore it.
|
||||
const mutableIntl = Intl as { Segmenter?: typeof Intl.Segmenter }
|
||||
const original = mutableIntl.Segmenter
|
||||
delete mutableIntl.Segmenter
|
||||
expect('Segmenter' in Intl).toBe(false)
|
||||
try {
|
||||
return fn(await import('./string'))
|
||||
} finally {
|
||||
mutableIntl.Segmenter = original
|
||||
}
|
||||
}
|
||||
|
||||
it('iterateGraphemes falls back to code-point iteration', async () => {
|
||||
await withoutSegmenter(({ iterateGraphemes }) => {
|
||||
expect([...iterateGraphemes('ab😀')]).toEqual(['a', 'b', '😀'])
|
||||
})
|
||||
})
|
||||
|
||||
it('getFirstCharacter keeps a surrogate pair whole', async () => {
|
||||
await withoutSegmenter(({ getFirstCharacter }) => {
|
||||
expect(getFirstCharacter('😀 hello')).toBe('😀')
|
||||
})
|
||||
})
|
||||
|
||||
it('getFirstCharacter splits a ZWJ cluster to its first code point rather than throwing', async () => {
|
||||
await withoutSegmenter(({ getFirstCharacter }) => {
|
||||
expect(getFirstCharacter('👨👩👧 family')).toBe('👨')
|
||||
})
|
||||
})
|
||||
})
|
||||
58
packages/utils/src/lib/string.ts
Normal file
58
packages/utils/src/lib/string.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
let graphemeSegmenter: Intl.Segmenter | undefined
|
||||
let checkedForSegmenter = false
|
||||
|
||||
/**
|
||||
* Resolve a shared grapheme `Intl.Segmenter`, or `undefined` when the runtime has no
|
||||
* `Intl.Segmenter`. `Intl.Segmenter` is unavailable on some older browsers (notably Firefox before
|
||||
* 125), so feature-detect it once rather than constructing it eagerly, which would throw at module
|
||||
* load and take down the whole app.
|
||||
*/
|
||||
function getGraphemeSegmenter(): Intl.Segmenter | undefined {
|
||||
if (!checkedForSegmenter) {
|
||||
checkedForSegmenter = true
|
||||
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
|
||||
graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
}
|
||||
}
|
||||
return graphemeSegmenter
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate over the grapheme clusters of a string — user-perceived characters such as emoji
|
||||
* sequences, flags, and accented letters — instead of UTF-16 code units. Uses `Intl.Segmenter`
|
||||
* where available and falls back to iterating by code point on browsers that lack it, where a
|
||||
* multi-code-point cluster is yielded as its component code points rather than as one segment.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function* iterateGraphemes(str: string): IterableIterator<string> {
|
||||
const segmenter = getGraphemeSegmenter()
|
||||
if (segmenter) {
|
||||
for (const { segment } of segmenter.segment(str)) yield segment
|
||||
} else {
|
||||
yield* str
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first character of a string, treating a multi-code-unit character such as an emoji as a
|
||||
* single character. Unlike `str[0]` or `str.charAt(0)`, which return one UTF-16 code unit and so
|
||||
* split an emoji into a broken half, this returns the first whole grapheme cluster. Returns an
|
||||
* empty string when the input is empty.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* getFirstCharacter('hello') // 'h'
|
||||
* getFirstCharacter('😀 hello') // '😀'
|
||||
* getFirstCharacter('') // ''
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function getFirstCharacter(str: string): string {
|
||||
if (!str) return ''
|
||||
for (const segment of iterateGraphemes(str)) {
|
||||
return segment
|
||||
}
|
||||
return ''
|
||||
}
|
||||
27
packages/utils/src/lib/stringEnum.ts
Normal file
27
packages/utils/src/lib/stringEnum.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Creates an enum-like object from string values where each key maps to itself.
|
||||
* Useful for creating string constant objects with type safety and autocompletion.
|
||||
* @param values - The string values to create the enum from.
|
||||
* @returns An object where each provided string is both the key and value.
|
||||
* @example
|
||||
* ```ts
|
||||
* const Colors = stringEnum('red', 'green', 'blue')
|
||||
* // Results in: { red: 'red', green: 'green', blue: 'blue' }
|
||||
*
|
||||
* // Type-safe usage
|
||||
* function setColor(color: keyof typeof Colors) {
|
||||
* console.log(`Setting color to ${Colors[color]}`)
|
||||
* }
|
||||
*
|
||||
* setColor('red') // ✓ Valid
|
||||
* setColor('yellow') // ✗ TypeScript error
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function stringEnum<T extends string>(...values: T[]): { [K in T]: K } {
|
||||
const obj = {} as { [K in T]: K }
|
||||
for (const value of values) {
|
||||
obj[value] = value
|
||||
}
|
||||
return obj
|
||||
}
|
||||
443
packages/utils/src/lib/throttle.test.ts
Normal file
443
packages/utils/src/lib/throttle.test.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FpsScheduler, fpsThrottle, throttleToNextFrame } from './throttle'
|
||||
|
||||
describe('FpsScheduler class', () => {
|
||||
let rafCallbacks: Array<FrameRequestCallback> = []
|
||||
let rafId = 0
|
||||
|
||||
beforeEach(() => {
|
||||
// Force RAF behavior in tests instead of immediate execution
|
||||
// @ts-expect-error - testing flag
|
||||
globalThis.__FORCE_RAF_IN_TESTS__ = true
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
|
||||
rafCallbacks = []
|
||||
rafId = 0
|
||||
|
||||
// Mock requestAnimationFrame to work with fake timers
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
const id = ++rafId
|
||||
rafCallbacks.push(callback)
|
||||
return id
|
||||
})
|
||||
|
||||
vi.stubGlobal('cancelAnimationFrame', (_id: number) => {
|
||||
// Simple cancel implementation
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const flushAnimationFrames = () => {
|
||||
// May need to flush multiple times as tick() can schedule nested RAF calls
|
||||
// But limit iterations to prevent infinite loops
|
||||
let iterations = 0
|
||||
const maxIterations = 10
|
||||
while (rafCallbacks.length > 0 && iterations < maxIterations) {
|
||||
const callbacks = [...rafCallbacks]
|
||||
rafCallbacks = []
|
||||
callbacks.forEach((cb) => cb(performance.now()))
|
||||
iterations++
|
||||
}
|
||||
}
|
||||
|
||||
describe('FPS throttling', () => {
|
||||
it('should throttle to the target FPS', () => {
|
||||
const throttle = new FpsScheduler(60) // ~16.67ms per frame, ~15ms with variance
|
||||
const fn = vi.fn()
|
||||
const throttled = throttle.fpsThrottle(fn)
|
||||
|
||||
throttled()
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
|
||||
// Flush the first frame
|
||||
flushAnimationFrames()
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Call again immediately (within same frame period)
|
||||
throttled()
|
||||
flushAnimationFrames()
|
||||
|
||||
// Should not execute again yet (not enough time passed)
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Wait for next frame period (16ms+)
|
||||
vi.advanceTimersByTime(16)
|
||||
throttled()
|
||||
flushAnimationFrames()
|
||||
|
||||
// Now it should execute
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should respect different FPS settings for different instances', () => {
|
||||
const fastThrottle = new FpsScheduler(120) // ~8.33ms per frame, ~7.5ms with variance
|
||||
const slowThrottle = new FpsScheduler(30) // ~33.33ms per frame, ~30ms with variance
|
||||
|
||||
const fastFn = vi.fn()
|
||||
const slowFn = vi.fn()
|
||||
|
||||
const throttledFast = fastThrottle.fpsThrottle(fastFn)
|
||||
const throttledSlow = slowThrottle.fpsThrottle(slowFn)
|
||||
|
||||
// Call both - they should both queue and wait for RAF
|
||||
throttledFast()
|
||||
throttledSlow()
|
||||
|
||||
// Flush RAF - both will execute on first frame
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(fastFn).toHaveBeenCalledTimes(1)
|
||||
expect(slowFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Call again immediately
|
||||
throttledFast()
|
||||
throttledSlow()
|
||||
|
||||
// Advance by 8ms - fast can execute again, slow cannot
|
||||
vi.advanceTimersByTime(8)
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(fastFn).toHaveBeenCalledTimes(2)
|
||||
expect(slowFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Advance by another 25ms (33ms total) - now slow should execute
|
||||
vi.advanceTimersByTime(25)
|
||||
throttledSlow()
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(slowFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('throttleToNextFrame', () => {
|
||||
it('should execute function on next frame', () => {
|
||||
const throttle = new FpsScheduler(120)
|
||||
const fn = vi.fn()
|
||||
|
||||
throttle.throttleToNextFrame(fn)
|
||||
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should deduplicate same function in queue', () => {
|
||||
const throttle = new FpsScheduler(120)
|
||||
const fn = vi.fn()
|
||||
|
||||
throttle.throttleToNextFrame(fn)
|
||||
throttle.throttleToNextFrame(fn)
|
||||
throttle.throttleToNextFrame(fn)
|
||||
|
||||
flushAnimationFrames()
|
||||
|
||||
// Should only execute once despite multiple calls
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should return cancel function that prevents execution', () => {
|
||||
const throttle = new FpsScheduler(120)
|
||||
const fn = vi.fn()
|
||||
|
||||
const cancel = throttle.throttleToNextFrame(fn)
|
||||
cancel()
|
||||
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should execute multiple different functions', () => {
|
||||
const throttle = new FpsScheduler(120)
|
||||
const fn1 = vi.fn()
|
||||
const fn2 = vi.fn()
|
||||
const fn3 = vi.fn()
|
||||
|
||||
throttle.throttleToNextFrame(fn1)
|
||||
throttle.throttleToNextFrame(fn2)
|
||||
throttle.throttleToNextFrame(fn3)
|
||||
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(fn1).toHaveBeenCalledTimes(1)
|
||||
expect(fn2).toHaveBeenCalledTimes(1)
|
||||
expect(fn3).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel functionality', () => {
|
||||
it('should cancel pending throttled function', () => {
|
||||
const throttle = new FpsScheduler(120)
|
||||
const fn = vi.fn()
|
||||
const throttled = throttle.fpsThrottle(fn)
|
||||
|
||||
throttled()
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
|
||||
throttled.cancel?.()
|
||||
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow function to be called again after cancel', () => {
|
||||
const throttle = new FpsScheduler(120)
|
||||
const fn = vi.fn()
|
||||
const throttled = throttle.fpsThrottle(fn)
|
||||
|
||||
throttled()
|
||||
throttled.cancel?.()
|
||||
|
||||
flushAnimationFrames()
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
|
||||
// Call again after cancel - need to advance time to allow re-throttling
|
||||
vi.advanceTimersByTime(10)
|
||||
throttled()
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('batching behavior', () => {
|
||||
it('should batch multiple calls within same frame window', () => {
|
||||
const throttle = new FpsScheduler(60)
|
||||
const fn = vi.fn()
|
||||
const throttled = throttle.fpsThrottle(fn)
|
||||
|
||||
// Multiple calls in quick succession
|
||||
throttled()
|
||||
throttled()
|
||||
throttled()
|
||||
throttled()
|
||||
|
||||
flushAnimationFrames()
|
||||
|
||||
// Should only execute once
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should maintain execution order', () => {
|
||||
const throttle = new FpsScheduler(120)
|
||||
const results: number[] = []
|
||||
|
||||
const fn1 = () => results.push(1)
|
||||
const fn2 = () => results.push(2)
|
||||
const fn3 = () => results.push(3)
|
||||
|
||||
throttle.throttleToNextFrame(fn1)
|
||||
throttle.throttleToNextFrame(fn2)
|
||||
throttle.throttleToNextFrame(fn3)
|
||||
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(results).toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('global fpsThrottle function', () => {
|
||||
it('should create a throttled function with cancel method', () => {
|
||||
const fn = vi.fn()
|
||||
const throttled = fpsThrottle(fn)
|
||||
|
||||
// Should return a function with cancel method
|
||||
expect(typeof throttled).toBe('function')
|
||||
expect(typeof throttled.cancel).toBe('function')
|
||||
|
||||
// Calling it should work (actual execution depends on test mode)
|
||||
throttled()
|
||||
|
||||
// Function should be callable without error
|
||||
expect(() => throttled()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should delegate to default FpsScheduler instance', () => {
|
||||
// This test just verifies the API works, not the RAF behavior
|
||||
// (RAF behavior is tested thoroughly in the FpsScheduler class tests)
|
||||
const fn1 = vi.fn()
|
||||
const fn2 = vi.fn()
|
||||
|
||||
const throttled1 = fpsThrottle(fn1)
|
||||
const throttled2 = fpsThrottle(fn2)
|
||||
|
||||
expect(throttled1).not.toBe(throttled2)
|
||||
expect(typeof throttled1.cancel).toBe('function')
|
||||
expect(typeof throttled2.cancel).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('global throttleToNextFrame function', () => {
|
||||
it('should return a cancel function', () => {
|
||||
const fn = vi.fn()
|
||||
|
||||
const cancel = throttleToNextFrame(fn)
|
||||
|
||||
// Should return a function
|
||||
expect(typeof cancel).toBe('function')
|
||||
|
||||
// Cancel should be callable
|
||||
expect(() => cancel()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should delegate to default FpsScheduler instance', () => {
|
||||
// This test just verifies the API works, not the RAF behavior
|
||||
// (RAF behavior is tested thoroughly in the FpsScheduler class tests)
|
||||
const fn1 = vi.fn()
|
||||
const fn2 = vi.fn()
|
||||
|
||||
const cancel1 = throttleToNextFrame(fn1)
|
||||
const cancel2 = throttleToNextFrame(fn2)
|
||||
|
||||
expect(typeof cancel1).toBe('function')
|
||||
expect(typeof cancel2).toBe('function')
|
||||
|
||||
// Both should be callable
|
||||
expect(() => cancel1()).not.toThrow()
|
||||
expect(() => cancel2()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('real-world scenarios', () => {
|
||||
let rafCallbacks: Array<FrameRequestCallback> = []
|
||||
let rafId = 0
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error - testing flag
|
||||
globalThis.__FORCE_RAF_IN_TESTS__ = true
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
|
||||
rafCallbacks = []
|
||||
rafId = 0
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
const id = ++rafId
|
||||
rafCallbacks.push(callback)
|
||||
return id
|
||||
})
|
||||
|
||||
vi.stubGlobal('cancelAnimationFrame', (_id: number) => {
|
||||
// Simple cancel implementation
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const flushAnimationFrames = () => {
|
||||
// May need to flush multiple times as tick() can schedule nested RAF calls
|
||||
// But limit iterations to prevent infinite loops
|
||||
let iterations = 0
|
||||
const maxIterations = 10
|
||||
while (rafCallbacks.length > 0 && iterations < maxIterations) {
|
||||
const callbacks = [...rafCallbacks]
|
||||
rafCallbacks = []
|
||||
callbacks.forEach((cb) => cb(performance.now()))
|
||||
iterations++
|
||||
}
|
||||
}
|
||||
|
||||
it('simulates UI throttle (120fps) and sync throttle (30fps) working independently', () => {
|
||||
// UI operations at 120fps (~7.5ms per frame with variance)
|
||||
const uiThrottle = new FpsScheduler(120)
|
||||
const updateUI = vi.fn()
|
||||
const throttledUI = uiThrottle.fpsThrottle(updateUI)
|
||||
|
||||
// Sync operations at 30fps (~30ms per frame with variance)
|
||||
const syncThrottle = new FpsScheduler(30)
|
||||
const syncData = vi.fn()
|
||||
const throttledSync = syncThrottle.fpsThrottle(syncData)
|
||||
|
||||
// Simulate rapid UI updates and sync requests
|
||||
throttledUI()
|
||||
throttledUI()
|
||||
throttledSync()
|
||||
throttledSync()
|
||||
|
||||
// First RAF flush - both execute on first frame
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(updateUI).toHaveBeenCalledTimes(1)
|
||||
expect(syncData).toHaveBeenCalledTimes(1)
|
||||
|
||||
// More rapid calls immediately
|
||||
throttledUI()
|
||||
throttledSync()
|
||||
flushAnimationFrames()
|
||||
|
||||
// Neither should execute yet (not enough time passed)
|
||||
expect(updateUI).toHaveBeenCalledTimes(1)
|
||||
expect(syncData).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Advance 8ms - UI can execute again, sync still waiting
|
||||
vi.advanceTimersByTime(8)
|
||||
throttledUI()
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(updateUI).toHaveBeenCalledTimes(2)
|
||||
expect(syncData).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Advance another 25ms (33ms total) - now sync should execute
|
||||
vi.advanceTimersByTime(25)
|
||||
throttledSync()
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(syncData).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('simulates switching between solo (1fps) and collaborative mode (30fps)', () => {
|
||||
const throttle = new FpsScheduler(30) // Start at collaborative mode (~30ms per frame)
|
||||
const syncFn = vi.fn()
|
||||
const throttled = throttle.fpsThrottle(syncFn)
|
||||
|
||||
// Call in collaborative mode
|
||||
throttled()
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(syncFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Call again after enough time
|
||||
vi.advanceTimersByTime(31)
|
||||
throttled()
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(syncFn).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Note: In real implementation, you'd recreate the throttle with new FPS
|
||||
// This test shows that each instance maintains its own FPS setting
|
||||
const soloThrottle = new FpsScheduler(1) // ~900ms per frame with variance
|
||||
const soloSyncFn = vi.fn()
|
||||
const soloThrottled = soloThrottle.fpsThrottle(soloSyncFn)
|
||||
|
||||
soloThrottled()
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(soloSyncFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Call again too soon
|
||||
soloThrottled()
|
||||
flushAnimationFrames()
|
||||
|
||||
// Should not execute yet
|
||||
expect(soloSyncFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Advance enough time for 1fps
|
||||
vi.advanceTimersByTime(1000)
|
||||
soloThrottled()
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(soloSyncFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
227
packages/utils/src/lib/throttle.ts
Normal file
227
packages/utils/src/lib/throttle.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
const isTest = () =>
|
||||
typeof process !== 'undefined' &&
|
||||
process.env.NODE_ENV === 'test' &&
|
||||
// @ts-expect-error
|
||||
!globalThis.__FORCE_RAF_IN_TESTS__
|
||||
|
||||
// Browsers aren't precise with frame timing - this factor prevents skipping frames unnecessarily
|
||||
// by aiming slightly below the theoretical frame duration (e.g., ~7.5ms instead of 8.33ms for 120fps)
|
||||
const timingVarianceFactor = 0.9
|
||||
const getTargetTimePerFrame = (targetFps: number) =>
|
||||
Math.floor(1000 / targetFps) * timingVarianceFactor
|
||||
|
||||
/**
|
||||
* A scheduler class that manages a queue of functions to be executed at a target frame rate.
|
||||
* Each instance maintains its own queue and state, allowing for separate throttling contexts
|
||||
* (e.g., UI operations vs network sync operations).
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class FpsScheduler {
|
||||
private targetFps: number
|
||||
private targetTimePerFrame: number
|
||||
private fpsQueue: Array<() => void> = []
|
||||
private frameRaf: undefined | number
|
||||
private flushRaf: undefined | number
|
||||
private lastFlushTime: number
|
||||
|
||||
constructor(targetFps: number = 120) {
|
||||
this.targetFps = targetFps
|
||||
this.targetTimePerFrame = getTargetTimePerFrame(targetFps)
|
||||
this.lastFlushTime = -this.targetTimePerFrame
|
||||
}
|
||||
|
||||
updateTargetFps(targetFps: number) {
|
||||
if (targetFps === this.targetFps) return
|
||||
this.targetFps = targetFps
|
||||
this.targetTimePerFrame = getTargetTimePerFrame(targetFps)
|
||||
this.lastFlushTime = -this.targetTimePerFrame
|
||||
}
|
||||
|
||||
private flush() {
|
||||
const queue = this.fpsQueue.splice(0, this.fpsQueue.length)
|
||||
for (const fn of queue) {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
private tick(isOnNextFrame = false) {
|
||||
if (this.frameRaf) return
|
||||
|
||||
const now = Date.now()
|
||||
const elapsed = now - this.lastFlushTime
|
||||
|
||||
if (elapsed < this.targetTimePerFrame) {
|
||||
// If we're too early to flush, we need to wait until the next frame to try and flush again.
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
this.frameRaf = requestAnimationFrame(() => {
|
||||
this.frameRaf = undefined
|
||||
this.tick(true)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isOnNextFrame) {
|
||||
// If we've already waited for the next frame to run the tick, then we can flush immediately
|
||||
if (this.flushRaf) return // ...though if there's a flush raf, that means we'll be flushing on this frame already, so we can do nothing here.
|
||||
this.lastFlushTime = now
|
||||
this.flush()
|
||||
} else {
|
||||
// If we haven't already waited for the next frame to run the tick, we need to wait until the next frame to flush.
|
||||
if (this.flushRaf) return // ...though if there's a flush raf, that means we'll be flushing on the next frame already, so we can do nothing here.
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
this.flushRaf = requestAnimationFrame(() => {
|
||||
this.flushRaf = undefined
|
||||
this.lastFlushTime = Date.now()
|
||||
this.flush()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a throttled version of a function that executes at most once per frame.
|
||||
* The default target frame rate is set by the FpsScheduler instance.
|
||||
* Subsequent calls within the same frame are ignored, ensuring smooth performance
|
||||
* for high-frequency events like mouse movements or scroll events.
|
||||
*
|
||||
* @param fn - The function to throttle, optionally with a cancel method
|
||||
* @returns A throttled function with an optional cancel method to remove pending calls
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
fpsThrottle(fn: { (): void; cancel?(): void }): {
|
||||
(): void
|
||||
cancel?(): void
|
||||
} {
|
||||
if (isTest()) {
|
||||
fn.cancel = () => {
|
||||
if (this.frameRaf) {
|
||||
cancelAnimationFrame(this.frameRaf)
|
||||
this.frameRaf = undefined
|
||||
}
|
||||
if (this.flushRaf) {
|
||||
cancelAnimationFrame(this.flushRaf)
|
||||
this.flushRaf = undefined
|
||||
}
|
||||
}
|
||||
return fn
|
||||
}
|
||||
|
||||
const throttledFn = () => {
|
||||
if (this.fpsQueue.includes(fn)) {
|
||||
return
|
||||
}
|
||||
this.fpsQueue.push(fn)
|
||||
this.tick()
|
||||
}
|
||||
throttledFn.cancel = () => {
|
||||
const index = this.fpsQueue.indexOf(fn)
|
||||
if (index > -1) {
|
||||
this.fpsQueue.splice(index, 1)
|
||||
}
|
||||
}
|
||||
return throttledFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a function to execute on the next animation frame.
|
||||
* If the same function is passed multiple times before the frame executes,
|
||||
* it will only be called once, effectively batching multiple calls.
|
||||
*
|
||||
* @param fn - The function to execute on the next frame
|
||||
* @returns A cancel function that can prevent execution if called before the next frame
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
throttleToNextFrame(fn: () => void): () => void {
|
||||
if (isTest()) {
|
||||
fn()
|
||||
return () => void null // noop
|
||||
}
|
||||
|
||||
if (!this.fpsQueue.includes(fn)) {
|
||||
this.fpsQueue.push(fn)
|
||||
this.tick()
|
||||
}
|
||||
|
||||
return () => {
|
||||
const index = this.fpsQueue.indexOf(fn)
|
||||
if (index > -1) {
|
||||
this.fpsQueue.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default instance for UI operations
|
||||
const defaultScheduler = new FpsScheduler(120)
|
||||
|
||||
/**
|
||||
* Creates a throttled version of a function that executes at most once per frame.
|
||||
* The default target frame rate is 120fps, but can be customized per function.
|
||||
* Subsequent calls within the same frame are ignored, ensuring smooth performance
|
||||
* for high-frequency events like mouse movements or scroll events.
|
||||
*
|
||||
* Uses the default throttle instance for UI operations. If you need a separate
|
||||
* throttling queue (e.g., for network operations), create your own Throttle instance.
|
||||
*
|
||||
* @param fn - The function to throttle, optionally with a cancel method
|
||||
* @returns A throttled function with an optional cancel method to remove pending calls
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Default 120fps throttling
|
||||
* const updateCanvas = fpsThrottle(() => {
|
||||
* // This will run at most once per frame (~8.33ms)
|
||||
* redrawCanvas()
|
||||
* })
|
||||
*
|
||||
* // Call as often as you want - automatically throttled to 120fps
|
||||
* document.addEventListener('mousemove', updateCanvas)
|
||||
*
|
||||
* // Cancel pending calls if needed
|
||||
* updateCanvas.cancel?.()
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function fpsThrottle(fn: { (): void; cancel?(): void }): {
|
||||
(): void
|
||||
cancel?(): void
|
||||
} {
|
||||
return defaultScheduler.fpsThrottle(fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a function to execute on the next animation frame, targeting 120fps.
|
||||
* If the same function is passed multiple times before the frame executes,
|
||||
* it will only be called once, effectively batching multiple calls.
|
||||
*
|
||||
* Uses the default throttle instance for UI operations.
|
||||
*
|
||||
* @param fn - The function to execute on the next frame
|
||||
* @returns A cancel function that can prevent execution if called before the next frame
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const updateUI = throttleToNextFrame(() => {
|
||||
* // Batches multiple calls into the next animation frame
|
||||
* updateStatusBar()
|
||||
* refreshToolbar()
|
||||
* })
|
||||
*
|
||||
* // Multiple calls within the same frame are batched
|
||||
* updateUI() // Will execute
|
||||
* updateUI() // Ignored (same function already queued)
|
||||
* updateUI() // Ignored (same function already queued)
|
||||
*
|
||||
* // Get cancel function to prevent execution
|
||||
* const cancel = updateUI()
|
||||
* cancel() // Prevents execution if called before next frame
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function throttleToNextFrame(fn: () => void): () => void {
|
||||
return defaultScheduler.throttleToNextFrame(fn)
|
||||
}
|
||||
230
packages/utils/src/lib/timers.test.ts
Normal file
230
packages/utils/src/lib/timers.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Timers } from './timers'
|
||||
|
||||
describe('Timers', () => {
|
||||
it('tracks timers by context and disposes them correctly', () => {
|
||||
const timers = new Timers()
|
||||
const mockClearTimeout = vi.fn()
|
||||
const mockClearInterval = vi.fn()
|
||||
const mockCancelAnimationFrame = vi.fn()
|
||||
|
||||
// Mock only the clear functions since those are what we need to verify
|
||||
vi.stubGlobal('setTimeout', vi.fn().mockReturnValueOnce(1).mockReturnValueOnce(2))
|
||||
vi.stubGlobal('setInterval', vi.fn().mockReturnValue(3))
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(4))
|
||||
vi.stubGlobal('clearTimeout', mockClearTimeout)
|
||||
vi.stubGlobal('clearInterval', mockClearInterval)
|
||||
vi.stubGlobal('cancelAnimationFrame', mockCancelAnimationFrame)
|
||||
|
||||
// Create timers in different contexts
|
||||
timers.setTimeout('context1', () => {}, 1000)
|
||||
timers.setTimeout('context1', () => {}, 2000)
|
||||
timers.setInterval('context1', () => {}, 500)
|
||||
timers.requestAnimationFrame('context2', () => {})
|
||||
|
||||
// Dispose one context
|
||||
timers.dispose('context1')
|
||||
|
||||
// Should clear timers for context1 but not context2
|
||||
expect(mockClearTimeout).toHaveBeenCalledWith(1)
|
||||
expect(mockClearTimeout).toHaveBeenCalledWith(2)
|
||||
expect(mockClearInterval).toHaveBeenCalledWith(3)
|
||||
expect(mockCancelAnimationFrame).not.toHaveBeenCalled()
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('disposes all contexts with disposeAll', () => {
|
||||
const timers = new Timers()
|
||||
const mockClearTimeout = vi.fn()
|
||||
const mockClearInterval = vi.fn()
|
||||
const mockCancelAnimationFrame = vi.fn()
|
||||
|
||||
vi.stubGlobal('setTimeout', vi.fn().mockReturnValueOnce(1).mockReturnValueOnce(2))
|
||||
vi.stubGlobal('setInterval', vi.fn().mockReturnValue(3))
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(4))
|
||||
vi.stubGlobal('clearTimeout', mockClearTimeout)
|
||||
vi.stubGlobal('clearInterval', mockClearInterval)
|
||||
vi.stubGlobal('cancelAnimationFrame', mockCancelAnimationFrame)
|
||||
|
||||
timers.setTimeout('context1', () => {}, 1000)
|
||||
timers.setTimeout('context2', () => {}, 2000)
|
||||
timers.setInterval('context1', () => {}, 500)
|
||||
// A context that only holds an animation frame must be disposed too
|
||||
timers.requestAnimationFrame('context3', () => {})
|
||||
|
||||
timers.disposeAll()
|
||||
|
||||
expect(mockClearTimeout).toHaveBeenCalledWith(1)
|
||||
expect(mockClearTimeout).toHaveBeenCalledWith(2)
|
||||
expect(mockClearInterval).toHaveBeenCalledWith(3)
|
||||
expect(mockCancelAnimationFrame).toHaveBeenCalledWith(4)
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('provides context-bound methods via forContext', () => {
|
||||
const timers = new Timers()
|
||||
const mockClearTimeout = vi.fn()
|
||||
|
||||
vi.stubGlobal('setTimeout', vi.fn().mockReturnValue(1))
|
||||
vi.stubGlobal('clearTimeout', mockClearTimeout)
|
||||
|
||||
const contextTimers = timers.forContext('test-context')
|
||||
contextTimers.setTimeout(() => {}, 1000)
|
||||
contextTimers.dispose()
|
||||
|
||||
expect(mockClearTimeout).toHaveBeenCalledWith(1)
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('pruning tracked ids', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('stops tracking a timeout once it has fired', () => {
|
||||
const timers = new Timers()
|
||||
const handler = vi.fn()
|
||||
|
||||
const fired = timers.setTimeout('ctx', handler, 100)
|
||||
const pending = timers.setTimeout('ctx', () => {}, 1000)
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
|
||||
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout')
|
||||
timers.dispose('ctx')
|
||||
|
||||
expect(clearTimeoutSpy.mock.calls).toEqual([[pending]])
|
||||
expect(clearTimeoutSpy).not.toHaveBeenCalledWith(fired)
|
||||
})
|
||||
|
||||
it('stops tracking an animation frame once it has run', () => {
|
||||
const timers = new Timers()
|
||||
const callback = vi.fn()
|
||||
|
||||
const ran = timers.requestAnimationFrame('ctx', callback)
|
||||
vi.advanceTimersToNextFrame()
|
||||
expect(callback).toHaveBeenCalledTimes(1)
|
||||
expect(callback.mock.calls[0][0]).toEqual(expect.any(Number))
|
||||
|
||||
const pending = timers.requestAnimationFrame('ctx', () => {})
|
||||
const cancelSpy = vi.spyOn(window, 'cancelAnimationFrame')
|
||||
timers.dispose('ctx')
|
||||
|
||||
expect(cancelSpy.mock.calls).toEqual([[pending]])
|
||||
expect(cancelSpy).not.toHaveBeenCalledWith(ran)
|
||||
})
|
||||
|
||||
// The sync fuzz suite stubs requestAnimationFrame to run its callback synchronously, so the
|
||||
// wrapper runs before the id it would prune has been assigned.
|
||||
it('survives a timer that fires before its id is returned', () => {
|
||||
const timers = new Timers()
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
cb(0)
|
||||
return 7
|
||||
})
|
||||
vi.stubGlobal('setTimeout', (handler: () => void) => {
|
||||
handler()
|
||||
return 8
|
||||
})
|
||||
const frameCallback = vi.fn()
|
||||
const timeoutHandler = vi.fn()
|
||||
|
||||
expect(timers.requestAnimationFrame('ctx', frameCallback)).toBe(7)
|
||||
expect(timers.setTimeout('ctx', timeoutHandler, 0)).toBe(8)
|
||||
expect(frameCallback).toHaveBeenCalledTimes(1)
|
||||
expect(timeoutHandler).toHaveBeenCalledTimes(1)
|
||||
|
||||
const cancelSpy = vi.fn()
|
||||
const clearSpy = vi.fn()
|
||||
vi.stubGlobal('cancelAnimationFrame', cancelSpy)
|
||||
vi.stubGlobal('clearTimeout', clearSpy)
|
||||
timers.dispose('ctx')
|
||||
expect(cancelSpy).not.toHaveBeenCalled()
|
||||
expect(clearSpy).not.toHaveBeenCalled()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('stops tracking timers cleared through the instance', () => {
|
||||
const timers = new Timers()
|
||||
const timeoutHandler = vi.fn()
|
||||
const intervalHandler = vi.fn()
|
||||
const frameCallback = vi.fn()
|
||||
|
||||
const timeout = timers.setTimeout('ctx', timeoutHandler, 100)
|
||||
const interval = timers.setInterval('ctx', intervalHandler, 100)
|
||||
const frame = timers.requestAnimationFrame('ctx', frameCallback)
|
||||
|
||||
timers.clearTimeout('ctx', timeout)
|
||||
timers.clearInterval('ctx', interval)
|
||||
timers.cancelAnimationFrame('ctx', frame)
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(timeoutHandler).not.toHaveBeenCalled()
|
||||
expect(intervalHandler).not.toHaveBeenCalled()
|
||||
expect(frameCallback).not.toHaveBeenCalled()
|
||||
|
||||
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout')
|
||||
const clearIntervalSpy = vi.spyOn(window, 'clearInterval')
|
||||
const cancelSpy = vi.spyOn(window, 'cancelAnimationFrame')
|
||||
timers.dispose('ctx')
|
||||
|
||||
expect(clearTimeoutSpy).not.toHaveBeenCalled()
|
||||
expect(clearIntervalSpy).not.toHaveBeenCalled()
|
||||
expect(cancelSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores undefined ids when clearing', () => {
|
||||
const timers = new Timers()
|
||||
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout')
|
||||
|
||||
timers.clearTimeout('ctx', undefined)
|
||||
timers.clearInterval('ctx', undefined)
|
||||
timers.cancelAnimationFrame('ctx', undefined)
|
||||
|
||||
expect(clearTimeoutSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps tracking an interval until it is cleared or disposed', () => {
|
||||
const timers = new Timers()
|
||||
const handler = vi.fn()
|
||||
|
||||
const interval = timers.setInterval('ctx', handler, 100)
|
||||
vi.advanceTimersByTime(350)
|
||||
expect(handler).toHaveBeenCalledTimes(3)
|
||||
|
||||
const clearIntervalSpy = vi.spyOn(window, 'clearInterval')
|
||||
timers.dispose('ctx')
|
||||
|
||||
expect(clearIntervalSpy.mock.calls).toEqual([[interval]])
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(handler).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('does not grow the tracked set as timeouts fire and are cleared', () => {
|
||||
const timers = new Timers()
|
||||
const context = timers.forContext('ctx')
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
// Mirrors a pointer down and up: a long-press timeout that is cleared before it fires,
|
||||
// and a click timeout that is left to fire on its own.
|
||||
const longPress = context.setTimeout(() => {}, 500)
|
||||
context.setTimeout(() => {}, 200)
|
||||
context.clearTimeout(longPress)
|
||||
vi.advanceTimersByTime(300)
|
||||
}
|
||||
|
||||
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout')
|
||||
context.dispose()
|
||||
|
||||
expect(clearTimeoutSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
290
packages/utils/src/lib/timers.ts
Normal file
290
packages/utils/src/lib/timers.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/* eslint-disable tldraw/no-restricted-properties */
|
||||
|
||||
/**
|
||||
* A utility class for managing timeouts, intervals, and animation frames with context-based organization and automatic cleanup.
|
||||
* Helps prevent memory leaks by organizing timers into named contexts that can be cleared together.
|
||||
* @example
|
||||
* ```ts
|
||||
* const timers = new Timers()
|
||||
*
|
||||
* // Set timers with context organization
|
||||
* timers.setTimeout('ui', () => console.log('Auto save'), 5000)
|
||||
* timers.setInterval('ui', () => console.log('Refresh'), 1000)
|
||||
* timers.requestAnimationFrame('ui', () => console.log('Render'))
|
||||
*
|
||||
* // Clear all timers for a context
|
||||
* timers.dispose('ui')
|
||||
*
|
||||
* // Or get context-bound functions
|
||||
* const uiTimers = timers.forContext('ui')
|
||||
* uiTimers.setTimeout(() => console.log('Contextual timeout'), 1000)
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export class Timers {
|
||||
// Ids are dropped as soon as a timer fires or is cleared through this class, so a
|
||||
// long-lived context (the editor creates two timeouts per pointer down) does not
|
||||
// accumulate stale ids that `dispose` would otherwise have to clear one by one.
|
||||
private timeouts = new Map<string, Set<number>>()
|
||||
private intervals = new Map<string, Set<number>>()
|
||||
private rafs = new Map<string, Set<number>>()
|
||||
|
||||
/**
|
||||
* Creates a new Timers instance with bound methods for safe callback usage.
|
||||
* @example
|
||||
* ```ts
|
||||
* const timers = new Timers()
|
||||
* // Methods are pre-bound, safe to use as callbacks
|
||||
* element.addEventListener('click', timers.dispose)
|
||||
* ```
|
||||
*/
|
||||
constructor() {
|
||||
this.setTimeout = this.setTimeout.bind(this)
|
||||
this.setInterval = this.setInterval.bind(this)
|
||||
this.requestAnimationFrame = this.requestAnimationFrame.bind(this)
|
||||
this.clearTimeout = this.clearTimeout.bind(this)
|
||||
this.clearInterval = this.clearInterval.bind(this)
|
||||
this.cancelAnimationFrame = this.cancelAnimationFrame.bind(this)
|
||||
this.dispose = this.dispose.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a timeout that will be tracked under the specified context.
|
||||
* @param contextId - The context identifier to group this timer under.
|
||||
* @param handler - The function to execute when the timeout expires.
|
||||
* @param timeout - The delay in milliseconds (default: 0).
|
||||
* @param args - Additional arguments to pass to the handler.
|
||||
* @returns The timer ID that can be used with clearTimeout.
|
||||
* @example
|
||||
* ```ts
|
||||
* const timers = new Timers()
|
||||
* const id = timers.setTimeout('autosave', () => save(), 5000)
|
||||
* // Timer will be automatically cleared when 'autosave' context is disposed
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
setTimeout(contextId: string, handler: TimerHandler, timeout?: number, ...args: any[]): number {
|
||||
const ids = getOrCreateSet(this.timeouts, contextId)
|
||||
// A test stub can run the handler synchronously, before `id` is assigned, so the wrapper
|
||||
// records that it fired instead of relying on the id being set when it runs.
|
||||
let fired = false
|
||||
let id = -1
|
||||
// A string handler is evaluated by the browser, so it cannot be wrapped and stays
|
||||
// tracked until it is cleared or the context is disposed.
|
||||
const wrapped =
|
||||
typeof handler === 'function'
|
||||
? (...handlerArgs: any[]) => {
|
||||
fired = true
|
||||
ids.delete(id)
|
||||
handler(...handlerArgs)
|
||||
}
|
||||
: handler
|
||||
id = window.setTimeout(wrapped, timeout, args)
|
||||
if (!fired) ids.add(id)
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an interval that will be tracked under the specified context.
|
||||
* @param contextId - The context identifier to group this timer under.
|
||||
* @param handler - The function to execute repeatedly.
|
||||
* @param timeout - The delay in milliseconds between executions (default: 0).
|
||||
* @param args - Additional arguments to pass to the handler.
|
||||
* @returns The interval ID that can be used with clearInterval.
|
||||
* @example
|
||||
* ```ts
|
||||
* const timers = new Timers()
|
||||
* const id = timers.setInterval('refresh', () => updateData(), 1000)
|
||||
* // Interval will be automatically cleared when 'refresh' context is disposed
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
setInterval(contextId: string, handler: TimerHandler, timeout?: number, ...args: any[]): number {
|
||||
const id = window.setInterval(handler, timeout, args)
|
||||
getOrCreateSet(this.intervals, contextId).add(id)
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests an animation frame that will be tracked under the specified context.
|
||||
* @param contextId - The context identifier to group this animation frame under.
|
||||
* @param callback - The function to execute on the next animation frame.
|
||||
* @returns The request ID that can be used with cancelAnimationFrame.
|
||||
* @example
|
||||
* ```ts
|
||||
* const timers = new Timers()
|
||||
* const id = timers.requestAnimationFrame('render', () => draw())
|
||||
* // Animation frame will be automatically cancelled when 'render' context is disposed
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
requestAnimationFrame(contextId: string, callback: FrameRequestCallback): number {
|
||||
const ids = getOrCreateSet(this.rafs, contextId)
|
||||
// Test stubs (the sync fuzz suite, for one) run the frame synchronously, before `id` is
|
||||
// assigned, so the callback records that it fired rather than touching `id` first.
|
||||
let fired = false
|
||||
let id = -1
|
||||
id = window.requestAnimationFrame((time) => {
|
||||
fired = true
|
||||
ids.delete(id)
|
||||
callback(time)
|
||||
})
|
||||
if (!fired) ids.add(id)
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a timeout created with {@link Timers.setTimeout} and stops tracking it.
|
||||
* Prefer this over the global `clearTimeout` for ids that came from this instance,
|
||||
* otherwise the id stays tracked until its context is disposed.
|
||||
* @param contextId - The context identifier the timeout was created under.
|
||||
* @param id - The timer ID returned by `setTimeout`. Ignored when undefined.
|
||||
* @example
|
||||
* ```ts
|
||||
* const timers = new Timers()
|
||||
* const id = timers.setTimeout('autosave', () => save(), 5000)
|
||||
* timers.clearTimeout('autosave', id)
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
clearTimeout(contextId: string, id: number | undefined): void {
|
||||
if (id === undefined) return
|
||||
clearTimeout(id)
|
||||
this.timeouts.get(contextId)?.delete(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears an interval created with {@link Timers.setInterval} and stops tracking it.
|
||||
* @param contextId - The context identifier the interval was created under.
|
||||
* @param id - The interval ID returned by `setInterval`. Ignored when undefined.
|
||||
* @example
|
||||
* ```ts
|
||||
* const timers = new Timers()
|
||||
* const id = timers.setInterval('refresh', () => updateData(), 1000)
|
||||
* timers.clearInterval('refresh', id)
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
clearInterval(contextId: string, id: number | undefined): void {
|
||||
if (id === undefined) return
|
||||
clearInterval(id)
|
||||
this.intervals.get(contextId)?.delete(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels an animation frame requested with {@link Timers.requestAnimationFrame} and stops tracking it.
|
||||
* @param contextId - The context identifier the animation frame was requested under.
|
||||
* @param id - The request ID returned by `requestAnimationFrame`. Ignored when undefined.
|
||||
* @example
|
||||
* ```ts
|
||||
* const timers = new Timers()
|
||||
* const id = timers.requestAnimationFrame('render', () => draw())
|
||||
* timers.cancelAnimationFrame('render', id)
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
cancelAnimationFrame(contextId: string, id: number | undefined): void {
|
||||
if (id === undefined) return
|
||||
cancelAnimationFrame(id)
|
||||
this.rafs.get(contextId)?.delete(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of all timers associated with the specified context.
|
||||
* Clears all timeouts, intervals, and animation frames for the given context ID.
|
||||
* @param contextId - The context identifier whose timers should be cleared.
|
||||
* @returns void
|
||||
* @example
|
||||
* ```ts
|
||||
* const timers = new Timers()
|
||||
* timers.setTimeout('ui', () => console.log('timeout'), 1000)
|
||||
* timers.setInterval('ui', () => console.log('interval'), 500)
|
||||
*
|
||||
* // Clear all 'ui' context timers
|
||||
* timers.dispose('ui')
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
dispose(contextId: string) {
|
||||
this.timeouts.get(contextId)?.forEach((id) => clearTimeout(id))
|
||||
this.intervals.get(contextId)?.forEach((id) => clearInterval(id))
|
||||
this.rafs.get(contextId)?.forEach((id) => cancelAnimationFrame(id))
|
||||
|
||||
this.timeouts.delete(contextId)
|
||||
this.intervals.delete(contextId)
|
||||
this.rafs.delete(contextId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of all timers across all contexts.
|
||||
* Clears every timeout, interval, and animation frame managed by this instance.
|
||||
* @returns void
|
||||
* @example
|
||||
* ```ts
|
||||
* const timers = new Timers()
|
||||
* timers.setTimeout('ui', () => console.log('ui'), 1000)
|
||||
* timers.setTimeout('background', () => console.log('bg'), 2000)
|
||||
*
|
||||
* // Clear everything
|
||||
* timers.disposeAll()
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
disposeAll() {
|
||||
const contextIds = new Set([
|
||||
...this.timeouts.keys(),
|
||||
...this.intervals.keys(),
|
||||
...this.rafs.keys(),
|
||||
])
|
||||
for (const contextId of contextIds) {
|
||||
this.dispose(contextId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object with timer methods bound to a specific context.
|
||||
* Convenient for getting context-specific timer functions without repeatedly passing the contextId.
|
||||
* @param contextId - The context identifier to bind the returned methods to.
|
||||
* @returns An object with setTimeout, setInterval, requestAnimationFrame, the matching clear methods, and dispose bound to the context.
|
||||
* @example
|
||||
* ```ts
|
||||
* const timers = new Timers()
|
||||
* const uiTimers = timers.forContext('ui')
|
||||
*
|
||||
* // These are equivalent to calling timers.setTimeout('ui', ...)
|
||||
* const id = uiTimers.setTimeout(() => console.log('timeout'), 1000)
|
||||
* uiTimers.setInterval(() => console.log('interval'), 500)
|
||||
* uiTimers.requestAnimationFrame(() => console.log('frame'))
|
||||
*
|
||||
* // Clear a single timer
|
||||
* uiTimers.clearTimeout(id)
|
||||
*
|
||||
* // Dispose only this context
|
||||
* uiTimers.dispose()
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
forContext(contextId: string) {
|
||||
return {
|
||||
setTimeout: (handler: TimerHandler, timeout?: number, ...args: any[]) =>
|
||||
this.setTimeout(contextId, handler, timeout, args),
|
||||
setInterval: (handler: TimerHandler, timeout?: number, ...args: any[]) =>
|
||||
this.setInterval(contextId, handler, timeout, args),
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) =>
|
||||
this.requestAnimationFrame(contextId, callback),
|
||||
clearTimeout: (id: number | undefined) => this.clearTimeout(contextId, id),
|
||||
clearInterval: (id: number | undefined) => this.clearInterval(contextId, id),
|
||||
cancelAnimationFrame: (id: number | undefined) => this.cancelAnimationFrame(contextId, id),
|
||||
dispose: () => this.dispose(contextId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateSet(map: Map<string, Set<number>>, contextId: string): Set<number> {
|
||||
let set = map.get(contextId)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
map.set(contextId, set)
|
||||
}
|
||||
return set
|
||||
}
|
||||
149
packages/utils/src/lib/types.ts
Normal file
149
packages/utils/src/lib/types.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Makes all properties in a type and all nested properties optional recursively.
|
||||
* This is useful for creating partial update objects where you only want to specify
|
||||
* some deeply nested properties while leaving others unchanged.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* interface User {
|
||||
* name: string
|
||||
* settings: {
|
||||
* theme: string
|
||||
* notifications: {
|
||||
* email: boolean
|
||||
* push: boolean
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* type PartialUser = RecursivePartial<User>
|
||||
* // Result: {
|
||||
* // name?: string
|
||||
* // settings?: {
|
||||
* // theme?: string
|
||||
* // notifications?: {
|
||||
* // email?: boolean
|
||||
* // push?: boolean
|
||||
* // }
|
||||
* // }
|
||||
* // }
|
||||
*
|
||||
* const update: PartialUser = {
|
||||
* settings: {
|
||||
* notifications: {
|
||||
* email: false
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type RecursivePartial<T> = {
|
||||
[P in keyof T]?: RecursivePartial<T[P]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands a type definition to show its full structure in IDE tooltips and error messages.
|
||||
* This utility type forces TypeScript to resolve and display the complete type structure
|
||||
* instead of showing complex conditional types or intersections as-is.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* type User = { name: string }
|
||||
* type WithId = { id: string }
|
||||
* type UserWithId = User & WithId
|
||||
*
|
||||
* // Without Expand, IDE shows: User & WithId
|
||||
* // With Expand, IDE shows: { name: string; id: string }
|
||||
* type ExpandedUserWithId = Expand<UserWithId>
|
||||
*
|
||||
* // Useful for complex intersections
|
||||
* type ComplexType = Expand<BaseType & Mixin1 & Mixin2>
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never
|
||||
|
||||
/**
|
||||
* A value that may be returned synchronously or as a `Promise` / `PromiseLike`.
|
||||
* Use with `await` or `Promise.resolve(...)` to normalize to a single `Promise`.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type Awaitable<T> = T | PromiseLike<T>
|
||||
|
||||
/**
|
||||
* Makes specified keys in a type required while keeping all other properties as-is.
|
||||
* This is useful when you need to ensure certain optional properties are provided
|
||||
* in specific contexts without affecting the entire type structure.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* interface Shape {
|
||||
* id: string
|
||||
* x?: number
|
||||
* y?: number
|
||||
* visible?: boolean
|
||||
* }
|
||||
*
|
||||
* // Make position properties required
|
||||
* type PositionedShape = Required<Shape, 'x' | 'y'>
|
||||
* // Result: {
|
||||
* // id: string
|
||||
* // x: number // now required
|
||||
* // y: number // now required
|
||||
* // visible?: boolean
|
||||
* // }
|
||||
*
|
||||
* const shape: PositionedShape = {
|
||||
* id: 'rect1',
|
||||
* x: 10, // must provide
|
||||
* y: 20, // must provide
|
||||
* // visible is still optional
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export type Required<T, K extends keyof T> = Expand<Omit<T, K> & { [P in K]-?: T[P] }>
|
||||
|
||||
/**
|
||||
* Automatically makes properties optional if their type includes `undefined`.
|
||||
* This transforms properties like `prop: string | undefined` to `prop?: string | undefined`,
|
||||
* making the API more ergonomic by not requiring explicit undefined assignments.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* interface RawConfig {
|
||||
* name: string
|
||||
* theme: string | undefined
|
||||
* debug: boolean | undefined
|
||||
* version: number
|
||||
* }
|
||||
*
|
||||
* type Config = MakeUndefinedOptional<RawConfig>
|
||||
* // Result: {
|
||||
* // name: string
|
||||
* // theme?: string | undefined // now optional
|
||||
* // debug?: boolean | undefined // now optional
|
||||
* // version: number
|
||||
* // }
|
||||
*
|
||||
* const config: Config = {
|
||||
* name: 'MyApp',
|
||||
* version: 1
|
||||
* // theme and debug can be omitted instead of explicitly set to undefined
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type MakeUndefinedOptional<T extends object> = Expand<
|
||||
{
|
||||
[P in { [K in keyof T]: undefined extends T[K] ? never : K }[keyof T]]: T[P]
|
||||
} & {
|
||||
[P in { [K in keyof T]: undefined extends T[K] ? K : never }[keyof T]]?: T[P]
|
||||
}
|
||||
>
|
||||
44
packages/utils/src/lib/url.test.ts
Normal file
44
packages/utils/src/lib/url.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { safeParseUrl } from './url'
|
||||
|
||||
describe('safeParseUrl', () => {
|
||||
it('returns URL object for valid absolute URLs', () => {
|
||||
const result = safeParseUrl('https://example.com/path')
|
||||
|
||||
expect(result).toBeInstanceOf(URL)
|
||||
expect(result?.href).toBe('https://example.com/path')
|
||||
})
|
||||
|
||||
it('resolves relative URLs with base URL', () => {
|
||||
const result = safeParseUrl('/path', 'https://example.com')
|
||||
|
||||
expect(result).toBeInstanceOf(URL)
|
||||
expect(result?.href).toBe('https://example.com/path')
|
||||
})
|
||||
|
||||
it('accepts URL object as base URL', () => {
|
||||
const baseUrl = new URL('https://example.com')
|
||||
const result = safeParseUrl('/path', baseUrl)
|
||||
|
||||
expect(result).toBeInstanceOf(URL)
|
||||
expect(result?.href).toBe('https://example.com/path')
|
||||
})
|
||||
|
||||
it('returns undefined for invalid URLs', () => {
|
||||
expect(safeParseUrl('')).toBeUndefined()
|
||||
expect(safeParseUrl('not-a-url')).toBeUndefined()
|
||||
expect(safeParseUrl('https://exam ple.com')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when relative URL has no base', () => {
|
||||
const result = safeParseUrl('/relative/path')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when base URL is invalid', () => {
|
||||
const result = safeParseUrl('/path', 'not-a-base-url')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
47
packages/utils/src/lib/url.ts
Normal file
47
packages/utils/src/lib/url.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Safely parses a URL string without throwing exceptions on invalid input.
|
||||
* Returns a URL object for valid URLs or undefined for invalid ones.
|
||||
*
|
||||
* @param url - The URL string to parse
|
||||
* @param baseUrl - Optional base URL to resolve relative URLs against
|
||||
* @returns A URL object if parsing succeeds, undefined if it fails
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Valid absolute URL
|
||||
* const url1 = safeParseUrl('https://example.com')
|
||||
* if (url1) {
|
||||
* console.log(`Valid URL: ${url1.href}`) // "Valid URL: https://example.com/"
|
||||
* }
|
||||
*
|
||||
* // Invalid URL
|
||||
* const url2 = safeParseUrl('not-a-url')
|
||||
* console.log(url2) // undefined
|
||||
*
|
||||
* // Relative URL with base
|
||||
* const url3 = safeParseUrl('/path', 'https://example.com')
|
||||
* if (url3) {
|
||||
* console.log(url3.href) // "https://example.com/path"
|
||||
* }
|
||||
*
|
||||
* // Error handling
|
||||
* function handleUserUrl(input: string) {
|
||||
* const url = safeParseUrl(input)
|
||||
* if (url) {
|
||||
* return url
|
||||
* } else {
|
||||
* console.log('Invalid URL provided')
|
||||
* return null
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function safeParseUrl(url: string, baseUrl?: string | URL) {
|
||||
try {
|
||||
return new URL(url, baseUrl)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
102
packages/utils/src/lib/value.test.ts
Normal file
102
packages/utils/src/lib/value.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isNativeStructuredClone,
|
||||
STRUCTURED_CLONE_OBJECT_PROTOTYPE,
|
||||
structuredClone,
|
||||
} from './value'
|
||||
|
||||
describe('value utilities', () => {
|
||||
describe('structuredClone', () => {
|
||||
it('should create deep copies of objects', () => {
|
||||
const original = { a: 1, b: { c: 2 } }
|
||||
const copy = structuredClone(original)
|
||||
|
||||
expect(copy).toEqual(original)
|
||||
expect(copy).not.toBe(original)
|
||||
expect(copy.b).not.toBe(original.b)
|
||||
|
||||
// Modifying copy should not affect original
|
||||
copy.b.c = 3
|
||||
expect(original.b.c).toBe(2)
|
||||
expect(copy.b.c).toBe(3)
|
||||
})
|
||||
|
||||
it('should handle primitive values', () => {
|
||||
expect(structuredClone(42)).toBe(42)
|
||||
expect(structuredClone('hello')).toBe('hello')
|
||||
expect(structuredClone(true)).toBe(true)
|
||||
expect(structuredClone(null)).toBe(null)
|
||||
expect(structuredClone(undefined)).toBe(undefined)
|
||||
})
|
||||
|
||||
it('should handle arrays', () => {
|
||||
const original = [1, [2, 3], { a: 4 }]
|
||||
const copy = structuredClone(original)
|
||||
|
||||
expect(copy).toEqual(original)
|
||||
expect(copy).not.toBe(original)
|
||||
expect(copy[1]).not.toBe(original[1])
|
||||
expect(copy[2]).not.toBe(original[2])
|
||||
|
||||
// Modifying copy should not affect original
|
||||
;(copy[1] as number[]).push(5)
|
||||
;(copy[2] as any).a = 99
|
||||
|
||||
expect(original[1]).toEqual([2, 3])
|
||||
expect((original[2] as any).a).toBe(4)
|
||||
})
|
||||
|
||||
it('should handle nested structures', () => {
|
||||
const original = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
value: 'deep',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const copy = structuredClone(original)
|
||||
|
||||
expect(copy).toEqual(original)
|
||||
expect(copy.level1).not.toBe(original.level1)
|
||||
expect(copy.level1.level2).not.toBe(original.level1.level2)
|
||||
expect(copy.level1.level2.level3).not.toBe(original.level1.level2.level3)
|
||||
|
||||
copy.level1.level2.level3.value = 'modified'
|
||||
expect(original.level1.level2.level3.value).toBe('deep')
|
||||
})
|
||||
|
||||
it('should handle dates', () => {
|
||||
const date = new Date('2023-01-01')
|
||||
const copy = structuredClone(date)
|
||||
|
||||
expect(copy).toEqual(date)
|
||||
expect(copy).not.toBe(date)
|
||||
expect(copy instanceof Date).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle circular references if native structuredClone is available', () => {
|
||||
if (isNativeStructuredClone) {
|
||||
const obj: any = { a: 1 }
|
||||
obj.self = obj
|
||||
|
||||
const copy = structuredClone(obj)
|
||||
|
||||
expect(copy.a).toBe(1)
|
||||
expect(copy.self).toBe(copy)
|
||||
expect(copy).not.toBe(obj)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('STRUCTURED_CLONE_OBJECT_PROTOTYPE', () => {
|
||||
it('should be the prototype of objects created by structuredClone', () => {
|
||||
const obj = {}
|
||||
const cloned = structuredClone(obj)
|
||||
|
||||
expect(Object.getPrototypeOf(cloned)).toBe(STRUCTURED_CLONE_OBJECT_PROTOTYPE)
|
||||
})
|
||||
})
|
||||
})
|
||||
137
packages/utils/src/lib/value.ts
Normal file
137
packages/utils/src/lib/value.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Get whether a value is not undefined.
|
||||
*
|
||||
* @param value - The value to check.
|
||||
* @returns True if the value is not undefined, with proper type narrowing.
|
||||
* @example
|
||||
* ```ts
|
||||
* const maybeString: string | undefined = getValue()
|
||||
*
|
||||
* if (isDefined(maybeString)) {
|
||||
* // TypeScript knows maybeString is string, not undefined
|
||||
* console.log(maybeString.toUpperCase())
|
||||
* }
|
||||
*
|
||||
* // Filter undefined values from arrays
|
||||
* const values = [1, undefined, 2, undefined, 3]
|
||||
* const definedValues = values.filter(isDefined) // [1, 2, 3]
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function isDefined<T>(value: T): value is typeof value extends undefined ? never : T {
|
||||
return value !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether a value is not null.
|
||||
*
|
||||
* @param value - The value to check.
|
||||
* @returns True if the value is not null, with proper type narrowing.
|
||||
* @example
|
||||
* ```ts
|
||||
* const maybeString: string | null = getValue()
|
||||
*
|
||||
* if (isNonNull(maybeString)) {
|
||||
* // TypeScript knows maybeString is string, not null
|
||||
* console.log(maybeString.length)
|
||||
* }
|
||||
*
|
||||
* // Filter null values from arrays
|
||||
* const values = ["a", null, "b", null, "c"]
|
||||
* const nonNullValues = values.filter(isNonNull) // ["a", "b", "c"]
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function isNonNull<T>(value: T): value is typeof value extends null ? never : T {
|
||||
return value !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether a value is not nullish (not null and not undefined).
|
||||
*
|
||||
* @param value - The value to check.
|
||||
* @returns True if the value is neither null nor undefined, with proper type narrowing.
|
||||
* @example
|
||||
* ```ts
|
||||
* const maybeString: string | null | undefined = getValue()
|
||||
*
|
||||
* if (isNonNullish(maybeString)) {
|
||||
* // TypeScript knows maybeString is string, not null or undefined
|
||||
* console.log(maybeString.charAt(0))
|
||||
* }
|
||||
*
|
||||
* // Filter nullish values from arrays
|
||||
* const values = ["hello", null, "world", undefined, "!"]
|
||||
* const cleanValues = values.filter(isNonNullish) // ["hello", "world", "!"]
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function isNonNullish<T>(
|
||||
value: T
|
||||
): value is typeof value extends undefined ? never : typeof value extends null ? never : T {
|
||||
return value !== null && value !== undefined
|
||||
}
|
||||
|
||||
function getStructuredClone(): [<T>(i: T) => T, boolean] {
|
||||
if (typeof globalThis !== 'undefined' && (globalThis as any).structuredClone) {
|
||||
return [globalThis.structuredClone as <T>(i: T) => T, true]
|
||||
}
|
||||
|
||||
if (typeof global !== 'undefined' && (global as any).structuredClone) {
|
||||
return [global.structuredClone as <T>(i: T) => T, true]
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && (window as any).structuredClone) {
|
||||
return [window.structuredClone as <T>(i: T) => T, true]
|
||||
}
|
||||
|
||||
return [<T>(i: T): T => (i ? JSON.parse(JSON.stringify(i)) : i), false]
|
||||
}
|
||||
|
||||
const _structuredClone = getStructuredClone()
|
||||
|
||||
/**
|
||||
* Create a deep copy of a value. Uses the structuredClone API if available, otherwise uses JSON.parse(JSON.stringify()).
|
||||
*
|
||||
* @param i - The value to clone.
|
||||
* @returns A deep copy of the input value.
|
||||
* @example
|
||||
* ```ts
|
||||
* const original = { a: 1, b: { c: 2 } }
|
||||
* const copy = structuredClone(original)
|
||||
*
|
||||
* copy.b.c = 3
|
||||
* console.log(original.b.c) // 2 (unchanged)
|
||||
* console.log(copy.b.c) // 3
|
||||
*
|
||||
* // Works with complex objects
|
||||
* const complexObject = {
|
||||
* date: new Date(),
|
||||
* array: [1, 2, 3],
|
||||
* nested: { deep: { value: "test" } }
|
||||
* }
|
||||
* const cloned = structuredClone(complexObject)
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export const structuredClone = _structuredClone[0]
|
||||
|
||||
/**
|
||||
* Whether the current environment has native structuredClone support.
|
||||
* @returns True if using native structuredClone, false if using JSON fallback.
|
||||
* @internal
|
||||
*/
|
||||
export const isNativeStructuredClone = _structuredClone[1]
|
||||
|
||||
/**
|
||||
* The prototype object used by structuredClone for cloned objects.
|
||||
* When we patch structuredClone in jsdom for testing (see https://github.com/jsdom/jsdom/issues/3363),
|
||||
* the Object that is used as a prototype for the cloned object is not the same as the Object in
|
||||
* the code under test (that comes from jsdom's fake global context). This constant is used in
|
||||
* our code to work around this case.
|
||||
*
|
||||
* This is also the case for Array prototype, but that problem can be worked around with an
|
||||
* Array.isArray() check.
|
||||
* @internal
|
||||
*/
|
||||
export const STRUCTURED_CLONE_OBJECT_PROTOTYPE = Object.getPrototypeOf(structuredClone({}))
|
||||
537
packages/utils/src/lib/version.test.ts
Normal file
537
packages/utils/src/lib/version.test.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clearRegisteredVersionsForTests, registerTldrawLibraryVersion } from './version'
|
||||
|
||||
describe('version utilities', () => {
|
||||
let mockConsoleLog: ReturnType<typeof vi.fn<(...args: any[]) => any>>
|
||||
|
||||
beforeEach(() => {
|
||||
mockConsoleLog = vi.fn()
|
||||
vi.stubGlobal('console', { log: mockConsoleLog })
|
||||
vi.useFakeTimers()
|
||||
clearRegisteredVersionsForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
clearRegisteredVersionsForTests()
|
||||
})
|
||||
|
||||
describe('clearRegisteredVersionsForTests', () => {
|
||||
it('should clear all registered versions', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/tldraw', '2.0.0', 'esm')
|
||||
|
||||
clearRegisteredVersionsForTests()
|
||||
|
||||
// After clearing, registering the same versions should not trigger warnings
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/tldraw', '2.0.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset warning state', () => {
|
||||
// Register conflicting versions to trigger warning
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '1.9.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalled()
|
||||
mockConsoleLog.mockClear()
|
||||
|
||||
clearRegisteredVersionsForTests()
|
||||
|
||||
// After clearing, same conflicting versions should trigger warning again
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '1.9.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear scheduled timeout', () => {
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
|
||||
clearRegisteredVersionsForTests()
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle multiple calls without error', () => {
|
||||
clearRegisteredVersionsForTests()
|
||||
clearRegisteredVersionsForTests()
|
||||
clearRegisteredVersionsForTests()
|
||||
|
||||
expect(() => clearRegisteredVersionsForTests()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle clearing when no timeout is scheduled', () => {
|
||||
const _clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
|
||||
clearRegisteredVersionsForTests()
|
||||
|
||||
// Should not throw when clearing empty state
|
||||
expect(() => clearRegisteredVersionsForTests()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerTldrawLibraryVersion', () => {
|
||||
it('should register a library version with valid parameters', () => {
|
||||
expect(() => registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')).not.toThrow()
|
||||
|
||||
// Should schedule a timeout check
|
||||
expect(vi.getTimerCount()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle missing name parameter', () => {
|
||||
expect(() => registerTldrawLibraryVersion(undefined, '2.0.0', 'esm')).not.toThrow()
|
||||
|
||||
// Should not schedule timeout when parameters are missing
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle missing version parameter', () => {
|
||||
expect(() => registerTldrawLibraryVersion('@tldraw/editor', undefined, 'esm')).not.toThrow()
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle missing modules parameter', () => {
|
||||
expect(() => registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', undefined)).not.toThrow()
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle all missing parameters', () => {
|
||||
expect(() => registerTldrawLibraryVersion()).not.toThrow()
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('should throw error in build environment when parameters missing', () => {
|
||||
const originalBuildFlag = (globalThis as any).TLDRAW_LIBRARY_IS_BUILD
|
||||
;(globalThis as any).TLDRAW_LIBRARY_IS_BUILD = true
|
||||
|
||||
expect(() => registerTldrawLibraryVersion(undefined, '2.0.0', 'esm')).toThrow(
|
||||
'Missing name/version/module system in built version of tldraw library'
|
||||
)
|
||||
;(globalThis as any).TLDRAW_LIBRARY_IS_BUILD = originalBuildFlag
|
||||
})
|
||||
|
||||
it('should schedule timeout check on first registration', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('should not schedule multiple timeouts for multiple registrations', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/tldraw', '2.0.0', 'esm')
|
||||
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle setTimeout failure gracefully', () => {
|
||||
vi.spyOn(global, 'setTimeout').mockImplementation(() => {
|
||||
throw new Error('setTimeout not available')
|
||||
})
|
||||
|
||||
// Should call check immediately when setTimeout fails
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '1.9.0', 'esm')
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('version conflict detection', () => {
|
||||
it('should detect version conflicts between different versions', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '1.9.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalled()
|
||||
const logMessage = mockConsoleLog.mock.calls[0][0]
|
||||
expect(logMessage).toContain('multiple versions of tldraw libraries')
|
||||
})
|
||||
|
||||
it('should not warn for identical versions', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/tldraw', '2.0.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should detect module type conflicts', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'cjs')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalled()
|
||||
const logMessage = mockConsoleLog.mock.calls[0][0]
|
||||
expect(logMessage).toContain('multiple instances')
|
||||
})
|
||||
|
||||
it('should warn only once per session', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '1.9.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Register more conflicting versions
|
||||
registerTldrawLibraryVersion('@tldraw/tldraw', '1.8.0', 'esm')
|
||||
|
||||
// Should not trigger another warning since didWarn is true
|
||||
expect(mockConsoleLog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle complex version scenarios', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/tldraw', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/store', '1.9.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/validate', '2.0.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalled()
|
||||
const logMessage = mockConsoleLog.mock.calls[0][0]
|
||||
expect(logMessage).toContain('multiple versions')
|
||||
expect(logMessage).toContain('v2.0.0')
|
||||
expect(logMessage).toContain('@tldraw/store')
|
||||
})
|
||||
|
||||
it('should handle pre-release versions', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0-alpha.1', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0-beta.1', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should sort versions correctly', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '1.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '1.5.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalled()
|
||||
const logMessage = mockConsoleLog.mock.calls[0][0]
|
||||
// Latest version should be 2.0.0
|
||||
expect(logMessage).toContain('v2.0.0')
|
||||
})
|
||||
|
||||
it('should handle invalid version formats gracefully', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', 'invalid-version', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', 'also-invalid', 'esm')
|
||||
|
||||
expect(() => vi.runAllTimers()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle empty version list', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
|
||||
// Clear versions but let timer run
|
||||
clearRegisteredVersionsForTests()
|
||||
|
||||
expect(() => vi.runAllTimers()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should format console output with colors', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '1.9.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalled()
|
||||
const logMessage = mockConsoleLog.mock.calls[0][0]
|
||||
// Check for ANSI color codes
|
||||
expect(logMessage).toContain('\x1B[')
|
||||
})
|
||||
|
||||
it('should show both version mismatches and module duplicates in output', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/tldraw', '1.9.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalled()
|
||||
const logMessage = mockConsoleLog.mock.calls[0][0]
|
||||
expect(logMessage).toContain('latest version')
|
||||
expect(logMessage).toContain('not on the latest version')
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration scenarios', () => {
|
||||
it('should handle typical multi-package setup', () => {
|
||||
const packages = [
|
||||
{ name: '@tldraw/editor', version: '2.0.0', modules: 'esm' },
|
||||
{ name: '@tldraw/tldraw', version: '2.0.0', modules: 'esm' },
|
||||
{ name: '@tldraw/store', version: '2.0.0', modules: 'esm' },
|
||||
{ name: '@tldraw/validate', version: '2.0.0', modules: 'esm' },
|
||||
]
|
||||
|
||||
packages.forEach((pkg) => {
|
||||
registerTldrawLibraryVersion(pkg.name, pkg.version, pkg.modules)
|
||||
})
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
// All same version - should not warn
|
||||
expect(mockConsoleLog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore duplicate registrations in Next.js dev mode', () => {
|
||||
// In Next.js dev mode, Fast Refresh re-executes module-level code which causes
|
||||
// the same library to register multiple times, producing a false positive warning.
|
||||
vi.stubGlobal('__NEXT_DATA__', {})
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'development'
|
||||
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).not.toHaveBeenCalled()
|
||||
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
})
|
||||
|
||||
it('should detect duplicate registrations outside Next.js dev mode', () => {
|
||||
// Outside Next.js dev, duplicate exact registrations should still be
|
||||
// tracked and trigger the multiple instances warning
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalled()
|
||||
const logMessage = mockConsoleLog.mock.calls[0][0]
|
||||
expect(logMessage).toContain('multiple instances')
|
||||
})
|
||||
|
||||
it('should handle bundler misconfiguration scenario', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'cjs')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalled()
|
||||
const logMessage = mockConsoleLog.mock.calls[0][0]
|
||||
expect(logMessage).toContain('bundler is misconfigured')
|
||||
expect(logMessage).toContain('ES Modules')
|
||||
expect(logMessage).toContain('CommonJS')
|
||||
})
|
||||
|
||||
it('should handle mixed version and module conflicts', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'cjs')
|
||||
registerTldrawLibraryVersion('@tldraw/tldraw', '1.9.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalled()
|
||||
const logMessage = mockConsoleLog.mock.calls[0][0]
|
||||
// Should show version conflict first (higher priority)
|
||||
expect(logMessage).toContain('multiple versions')
|
||||
})
|
||||
|
||||
it('should handle gradual package registration', () => {
|
||||
// Register packages over time
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).not.toHaveBeenCalled()
|
||||
|
||||
// Clear warning state and add conflicting version
|
||||
clearRegisteredVersionsForTests()
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/tldraw', '1.9.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
it('should handle extremely long package names', () => {
|
||||
const longName = '@tldraw/' + 'x'.repeat(1000)
|
||||
|
||||
expect(() => registerTldrawLibraryVersion(longName, '2.0.0', 'esm')).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle special characters in package names', () => {
|
||||
const specialName = '@tldraw/test-特殊字符-🎨'
|
||||
|
||||
expect(() => registerTldrawLibraryVersion(specialName, '2.0.0', 'esm')).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle unusual version strings', () => {
|
||||
const versions = [
|
||||
'2.0.0-alpha.1',
|
||||
'2.0.0-beta.1+build.123',
|
||||
'2.0.0-rc.1',
|
||||
'0.0.0',
|
||||
'999.999.999',
|
||||
]
|
||||
|
||||
versions.forEach((version) => {
|
||||
expect(() => registerTldrawLibraryVersion('@tldraw/test', version, 'esm')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle unusual module system values', () => {
|
||||
const moduleSystems = ['esm', 'cjs', 'umd', 'amd', 'custom']
|
||||
|
||||
moduleSystems.forEach((modules) => {
|
||||
expect(() => registerTldrawLibraryVersion('@tldraw/test', '2.0.0', modules)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle very large number of registrations', () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
registerTldrawLibraryVersion(`@tldraw/package-${i}`, '2.0.0', 'esm')
|
||||
}
|
||||
|
||||
expect(() => vi.runAllTimers()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle global object pollution', () => {
|
||||
// This test checks that the system is robust against different global contexts
|
||||
// We can't easily pollute the global object due to property descriptors,
|
||||
// but we can test that the system works with different global contexts
|
||||
expect(() => registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')).not.toThrow()
|
||||
|
||||
// Register a different library to verify state tracking works
|
||||
expect(() => registerTldrawLibraryVersion('@tldraw/tldraw', '2.0.0', 'esm')).not.toThrow()
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
// Should not warn for same versions
|
||||
expect(mockConsoleLog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeout behavior', () => {
|
||||
it('should use 100ms delay for setTimeout', () => {
|
||||
const setTimeoutSpy = vi.spyOn(global, 'setTimeout')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 100)
|
||||
})
|
||||
|
||||
it('should clear timeout after execution', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
// The timeout should be nulled after execution
|
||||
// This is internal behavior but we can test by checking that scheduling works again
|
||||
registerTldrawLibraryVersion('@tldraw/tldraw', '2.0.0', 'esm')
|
||||
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('should not schedule new timeout if one is already pending', () => {
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/tldraw', '2.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/store', '2.0.0', 'esm')
|
||||
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle timeout clearing correctly', () => {
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
|
||||
clearRegisteredVersionsForTests()
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('original test cases (updated)', () => {
|
||||
it('doesnt log anything if all versions are the same', () => {
|
||||
registerTldrawLibraryVersion('tldraw', '1.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '1.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/utils', '1.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/tlschema', '1.0.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it('logs if not all versions match', () => {
|
||||
registerTldrawLibraryVersion('tldraw', '1.0.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '1.1.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/utils', '1.1.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/utils', '1.2.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/tlschema', '1.2.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalledTimes(1)
|
||||
expect(mockConsoleLog.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
[
|
||||
"[1;41;97m[tldraw][m [31;1mYou have multiple versions of tldraw libraries installed. This can lead to bugs and unexpected behavior.[m
|
||||
|
||||
The latest version you have installed is [1;94mv1.2.0[m. The following libraries are on the latest version:
|
||||
• ✅ [1m@tldraw/tlschema[m
|
||||
|
||||
The following libraries are not on the latest version, or have multiple versions installed:
|
||||
• ❌ [1mtldraw[m ([31mv1.0.0[m)
|
||||
• ❌ [1m@tldraw/editor[m ([31mv1.1.0[m)
|
||||
• ❌ [1m@tldraw/utils[m ([31mv1.1.0[m, [32mv1.2.0[m)",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('logs if multiple versions of the same library are installed', () => {
|
||||
registerTldrawLibraryVersion('tldraw', '1.1.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '1.1.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/editor', '1.1.0', 'cjs')
|
||||
registerTldrawLibraryVersion('@tldraw/utils', '1.1.0', 'esm')
|
||||
registerTldrawLibraryVersion('@tldraw/utils', '1.1.0', 'cjs')
|
||||
registerTldrawLibraryVersion('@tldraw/tlschema', '1.1.0', 'esm')
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalledTimes(1)
|
||||
expect(mockConsoleLog.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
[
|
||||
"[1;41;97m[tldraw][m [31;1mYou have multiple instances of some tldraw libraries active. This can lead to bugs and unexpected behavior. [m
|
||||
|
||||
This usually means that your bundler is misconfigured, and is importing the same library multiple times - usually once as an ES Module, and once as a CommonJS module.
|
||||
|
||||
The following libraries have been imported multiple times:
|
||||
• ❌ [1m@tldraw/editor[m v1.1.0:
|
||||
1. ES Modules
|
||||
2. CommonJS
|
||||
• ❌ [1m@tldraw/utils[m v1.1.0:
|
||||
1. ES Modules
|
||||
2. CommonJS
|
||||
|
||||
You should configure your bundler to only import one version of each library.",
|
||||
]
|
||||
`)
|
||||
})
|
||||
})
|
||||
})
|
||||
247
packages/utils/src/lib/version.ts
Normal file
247
packages/utils/src/lib/version.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
interface TldrawLibraryVersion {
|
||||
name: string
|
||||
version: string
|
||||
modules: string
|
||||
}
|
||||
|
||||
interface TldrawLibraryVersionInfo {
|
||||
versions: TldrawLibraryVersion[]
|
||||
didWarn: boolean
|
||||
scheduledNotice: number | NodeJS.Timeout | null
|
||||
}
|
||||
|
||||
const TLDRAW_LIBRARY_VERSION_KEY = '__TLDRAW_LIBRARY_VERSIONS__' as const
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace, no-shadow-restricted-names
|
||||
declare namespace globalThis {
|
||||
export const __TLDRAW_LIBRARY_VERSIONS__: TldrawLibraryVersionInfo
|
||||
}
|
||||
|
||||
function getLibraryVersions(): TldrawLibraryVersionInfo {
|
||||
if (globalThis[TLDRAW_LIBRARY_VERSION_KEY]) {
|
||||
return globalThis[TLDRAW_LIBRARY_VERSION_KEY]
|
||||
}
|
||||
|
||||
const info: TldrawLibraryVersionInfo = {
|
||||
versions: [],
|
||||
didWarn: false,
|
||||
scheduledNotice: null,
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, TLDRAW_LIBRARY_VERSION_KEY, {
|
||||
value: info,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
})
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all registered library versions and resets warning state.
|
||||
* This function is intended for testing purposes only to reset the global version tracking state.
|
||||
* @returns void
|
||||
* @example
|
||||
* ```ts
|
||||
* // In a test setup
|
||||
* beforeEach(() => {
|
||||
* clearRegisteredVersionsForTests()
|
||||
* })
|
||||
*
|
||||
* // Now version tracking starts fresh for each test
|
||||
* registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function clearRegisteredVersionsForTests() {
|
||||
const info = getLibraryVersions()
|
||||
info.versions = []
|
||||
info.didWarn = false
|
||||
if (info.scheduledNotice) {
|
||||
clearTimeout(info.scheduledNotice)
|
||||
info.scheduledNotice = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a tldraw library version for conflict detection.
|
||||
* This function tracks different tldraw library versions to warn about potential conflicts
|
||||
* when multiple versions are loaded simultaneously.
|
||||
* @param name - The name of the tldraw library package (e.g., '\@tldraw/editor').
|
||||
* @param version - The semantic version string (e.g., '2.0.0').
|
||||
* @param modules - The module system being used ('esm' or 'cjs').
|
||||
* @returns void
|
||||
* @example
|
||||
* ```ts
|
||||
* // Register a library version during package initialization
|
||||
* registerTldrawLibraryVersion('@tldraw/editor', '2.0.0', 'esm')
|
||||
* registerTldrawLibraryVersion('@tldraw/tldraw', '2.0.0', 'esm')
|
||||
*
|
||||
* // If conflicting versions are detected, warnings will be logged:
|
||||
* registerTldrawLibraryVersion('@tldraw/editor', '1.9.0', 'cjs')
|
||||
* // Console warning about version mismatch will appear
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function registerTldrawLibraryVersion(name?: string, version?: string, modules?: string) {
|
||||
if (!name || !version || !modules) {
|
||||
if ((globalThis as any).TLDRAW_LIBRARY_IS_BUILD) {
|
||||
throw new Error('Missing name/version/module system in built version of tldraw library')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const info = getLibraryVersions()
|
||||
|
||||
// In Next.js dev mode, Fast Refresh re-executes module-level code which causes
|
||||
// the same library to register multiple times, producing a false positive warning.
|
||||
// We skip exact duplicates only in Next.js dev to avoid hiding genuine issues.
|
||||
if (isNextjsDev()) {
|
||||
const isDuplicate = info.versions.some(
|
||||
(v) => v.name === name && v.version === version && v.modules === modules
|
||||
)
|
||||
if (isDuplicate) return
|
||||
}
|
||||
|
||||
info.versions.push({ name, version, modules })
|
||||
|
||||
if (!info.scheduledNotice) {
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
info.scheduledNotice = setTimeout(() => {
|
||||
info.scheduledNotice = null
|
||||
checkLibraryVersions(info)
|
||||
}, 100)
|
||||
} catch {
|
||||
// some environments (e.g. cloudflare workers) don't support setTimeout immediately, only in a handler.
|
||||
// in this case, we'll just check immediately.
|
||||
checkLibraryVersions(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkLibraryVersions(info: TldrawLibraryVersionInfo) {
|
||||
if (!info.versions.length) return
|
||||
if (info.didWarn) return
|
||||
|
||||
const sorted = info.versions.sort((a, b) => compareVersions(a.version, b.version))
|
||||
const latestVersion = sorted[sorted.length - 1].version
|
||||
|
||||
const matchingVersions = new Set<string>()
|
||||
const nonMatchingVersions = new Map<string, Set<string>>()
|
||||
for (const lib of sorted) {
|
||||
if (nonMatchingVersions.has(lib.name)) {
|
||||
matchingVersions.delete(lib.name)
|
||||
entry(nonMatchingVersions, lib.name, new Set()).add(lib.version)
|
||||
continue
|
||||
}
|
||||
|
||||
if (lib.version === latestVersion) {
|
||||
matchingVersions.add(lib.name)
|
||||
} else {
|
||||
matchingVersions.delete(lib.name)
|
||||
entry(nonMatchingVersions, lib.name, new Set()).add(lib.version)
|
||||
}
|
||||
}
|
||||
|
||||
if (nonMatchingVersions.size > 0) {
|
||||
const message = [
|
||||
`${format('[tldraw]', ['bold', 'bgRed', 'textWhite'])} ${format('You have multiple versions of tldraw libraries installed. This can lead to bugs and unexpected behavior.', ['textRed', 'bold'])}`,
|
||||
'',
|
||||
`The latest version you have installed is ${format(`v${latestVersion}`, ['bold', 'textBlue'])}. The following libraries are on the latest version:`,
|
||||
...Array.from(matchingVersions, (name) => ` • ✅ ${format(name, ['bold'])}`),
|
||||
'',
|
||||
`The following libraries are not on the latest version, or have multiple versions installed:`,
|
||||
...Array.from(nonMatchingVersions, ([name, versions]) => {
|
||||
const sortedVersions = Array.from(versions)
|
||||
.sort(compareVersions)
|
||||
.map((v) => format(`v${v}`, v === latestVersion ? ['textGreen'] : ['textRed']))
|
||||
return ` • ❌ ${format(name, ['bold'])} (${sortedVersions.join(', ')})`
|
||||
}),
|
||||
]
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(message.join('\n'))
|
||||
info.didWarn = true
|
||||
return
|
||||
}
|
||||
|
||||
// at this point, we know that everything has the same version. there may still be duplicates though!
|
||||
const potentialDuplicates = new Map<string, { version: string; modules: string[] }>()
|
||||
for (const lib of sorted) {
|
||||
entry(potentialDuplicates, lib.name, { version: lib.version, modules: [] }).modules.push(
|
||||
lib.modules
|
||||
)
|
||||
}
|
||||
|
||||
const duplicates = new Map<string, { version: string; modules: string[] }>()
|
||||
for (const [name, lib] of potentialDuplicates) {
|
||||
if (lib.modules.length > 1) duplicates.set(name, lib)
|
||||
}
|
||||
|
||||
if (duplicates.size > 0) {
|
||||
const message = [
|
||||
`${format('[tldraw]', ['bold', 'bgRed', 'textWhite'])} ${format('You have multiple instances of some tldraw libraries active. This can lead to bugs and unexpected behavior. ', ['textRed', 'bold'])}`,
|
||||
'',
|
||||
'This usually means that your bundler is misconfigured, and is importing the same library multiple times - usually once as an ES Module, and once as a CommonJS module.',
|
||||
'',
|
||||
'The following libraries have been imported multiple times:',
|
||||
...Array.from(duplicates, ([name, lib]) => {
|
||||
const modules = lib.modules
|
||||
.map((m, i) => (m === 'esm' ? ` ${i + 1}. ES Modules` : ` ${i + 1}. CommonJS`))
|
||||
.join('\n')
|
||||
return ` • ❌ ${format(name, ['bold'])} v${lib.version}: \n${modules}`
|
||||
}),
|
||||
'',
|
||||
'You should configure your bundler to only import one version of each library.',
|
||||
]
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(message.join('\n'))
|
||||
info.didWarn = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function compareVersions(a: string, b: string) {
|
||||
const aMatch = a.match(/^(\d+)\.(\d+)\.(\d+)(?:-(\w+))?$/)
|
||||
const bMatch = b.match(/^(\d+)\.(\d+)\.(\d+)(?:-(\w+))?$/)
|
||||
|
||||
if (!aMatch || !bMatch) return a.localeCompare(b)
|
||||
if (aMatch[1] !== bMatch[1]) return Number(aMatch[1]) - Number(bMatch[1])
|
||||
if (aMatch[2] !== bMatch[2]) return Number(aMatch[2]) - Number(bMatch[2])
|
||||
if (aMatch[3] !== bMatch[3]) return Number(aMatch[3]) - Number(bMatch[3])
|
||||
if (aMatch[4] && bMatch[4]) return aMatch[4].localeCompare(bMatch[4])
|
||||
if (aMatch[4]) return 1
|
||||
if (bMatch[4]) return -1
|
||||
return 0
|
||||
}
|
||||
|
||||
const formats = {
|
||||
bold: '1',
|
||||
textBlue: '94',
|
||||
textRed: '31',
|
||||
textGreen: '32',
|
||||
bgRed: '41',
|
||||
textWhite: '97',
|
||||
} as const
|
||||
function format(value: string, formatters: (keyof typeof formats)[] = []) {
|
||||
return `\x1B[${formatters.map((f) => formats[f]).join(';')}m${value}\x1B[m`
|
||||
}
|
||||
|
||||
function isNextjsDev(): boolean {
|
||||
try {
|
||||
return process.env.NODE_ENV === 'development' && '__NEXT_DATA__' in globalThis
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function entry<K, V>(map: Map<K, V>, key: K, defaultValue: V): V {
|
||||
if (map.has(key)) {
|
||||
return map.get(key)!
|
||||
}
|
||||
map.set(key, defaultValue)
|
||||
return defaultValue
|
||||
}
|
||||
64
packages/utils/src/lib/warn.test.ts
Normal file
64
packages/utils/src/lib/warn.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { warnDeprecatedGetter, warnOnce } from './warn'
|
||||
|
||||
describe('warning utilities', () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
describe('warnOnce', () => {
|
||||
it('logs warning message with tldraw prefix', () => {
|
||||
warnOnce('Test warning')
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(1)
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[tldraw] Test warning')
|
||||
})
|
||||
|
||||
it('only logs the same message once', () => {
|
||||
warnOnce('Duplicate warning')
|
||||
warnOnce('Duplicate warning')
|
||||
warnOnce('Duplicate warning')
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('logs different messages separately', () => {
|
||||
warnOnce('First warning')
|
||||
warnOnce('Second warning')
|
||||
warnOnce('First warning')
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('warnDeprecatedGetter', () => {
|
||||
it('generates correct deprecation warning', () => {
|
||||
warnDeprecatedGetter('viewport')
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"[tldraw] Using 'viewport' is deprecated and will be removed in the near future. Please refactor to use 'getViewport' instead."
|
||||
)
|
||||
})
|
||||
|
||||
it('correctly capitalizes property name', () => {
|
||||
warnDeprecatedGetter('camera')
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"[tldraw] Using 'camera' is deprecated and will be removed in the near future. Please refactor to use 'getCamera' instead."
|
||||
)
|
||||
})
|
||||
|
||||
it('only shows the same deprecation warning once', () => {
|
||||
warnDeprecatedGetter('uniqueProp')
|
||||
warnDeprecatedGetter('uniqueProp')
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
59
packages/utils/src/lib/warn.ts
Normal file
59
packages/utils/src/lib/warn.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
const usedWarnings = new Set<string>()
|
||||
|
||||
/**
|
||||
* Issues a deprecation warning for deprecated getter properties, advising users to use
|
||||
* the equivalent getter method instead. The warning is shown only once per property name.
|
||||
*
|
||||
* @param name - The name of the deprecated property (e.g., 'viewport')
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Inside a class with deprecated property access
|
||||
* get viewport() {
|
||||
* warnDeprecatedGetter('viewport')
|
||||
* return this.getViewport()
|
||||
* }
|
||||
*
|
||||
* // Usage will show: "[tldraw] Using 'viewport' is deprecated and will be removed..."
|
||||
* // But only the first time it's accessed
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function warnDeprecatedGetter(name: string) {
|
||||
warnOnce(
|
||||
`Using '${name}' is deprecated and will be removed in the near future. Please refactor to use 'get${name[0].toLocaleUpperCase()}${name.slice(
|
||||
1
|
||||
)}' instead.`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues a warning message to the console, but only once per unique message.
|
||||
* Subsequent calls with the same message are ignored, preventing console spam.
|
||||
* All messages are prefixed with "[tldraw]".
|
||||
*
|
||||
* @param message - The warning message to display
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Warn about deprecated usage
|
||||
* function oldFunction() {
|
||||
* warnOnce('oldFunction is deprecated, use newFunction instead')
|
||||
* // Continue with implementation...
|
||||
* }
|
||||
*
|
||||
* // First call logs: "[tldraw] oldFunction is deprecated, use newFunction instead"
|
||||
* oldFunction() // Shows warning
|
||||
* oldFunction() // No warning (already shown)
|
||||
* oldFunction() // No warning (already shown)
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function warnOnce(message: string) {
|
||||
if (usedWarnings.has(message)) return
|
||||
|
||||
usedWarnings.add(message)
|
||||
console.warn(`[tldraw] ${message}`)
|
||||
}
|
||||
6
packages/utils/tsconfig.json
Normal file
6
packages/utils/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../internal/config/tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", ".tsbuild*"],
|
||||
"compilerOptions": { "outDir": "./.tsbuild", "rootDir": "src" }
|
||||
}
|
||||
10
packages/utils/vitest.config.ts
Normal file
10
packages/utils/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vitest" />
|
||||
import { mergeConfig } from 'vitest/config'
|
||||
import baseConfig from '../../internal/config/vitest/node-preset'
|
||||
|
||||
// Utils package uses the standard node preset with no overrides
|
||||
export default mergeConfig(baseConfig, {
|
||||
test: {
|
||||
setupFiles: ['../../internal/config/vitest/setup.ts', './setupVitest.js'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user