@tldraw/utils 4.1.0-next.b6dfe9bccde9 → 4.1.0-next.b73a0d46b63f
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.d.ts +1350 -80
- package/dist-cjs/index.js +5 -5
- package/dist-cjs/lib/ExecutionQueue.js +79 -0
- package/dist-cjs/lib/ExecutionQueue.js.map +2 -2
- package/dist-cjs/lib/PerformanceTracker.js +43 -0
- package/dist-cjs/lib/PerformanceTracker.js.map +2 -2
- package/dist-cjs/lib/array.js +3 -1
- package/dist-cjs/lib/array.js.map +2 -2
- package/dist-cjs/lib/bind.js.map +2 -2
- package/dist-cjs/lib/cache.js +27 -5
- package/dist-cjs/lib/cache.js.map +2 -2
- package/dist-cjs/lib/control.js +12 -0
- package/dist-cjs/lib/control.js.map +2 -2
- package/dist-cjs/lib/debounce.js.map +2 -2
- package/dist-cjs/lib/error.js.map +2 -2
- package/dist-cjs/lib/file.js +76 -11
- package/dist-cjs/lib/file.js.map +2 -2
- package/dist-cjs/lib/function.js.map +2 -2
- package/dist-cjs/lib/hash.js.map +2 -2
- package/dist-cjs/lib/id.js.map +2 -2
- package/dist-cjs/lib/iterable.js.map +2 -2
- package/dist-cjs/lib/json-value.js.map +1 -1
- package/dist-cjs/lib/media/apng.js.map +2 -2
- package/dist-cjs/lib/media/avif.js.map +2 -2
- package/dist-cjs/lib/media/gif.js.map +2 -2
- package/dist-cjs/lib/media/media.js +130 -4
- package/dist-cjs/lib/media/media.js.map +2 -2
- package/dist-cjs/lib/media/png.js +141 -0
- package/dist-cjs/lib/media/png.js.map +2 -2
- package/dist-cjs/lib/media/webp.js +1 -0
- package/dist-cjs/lib/media/webp.js.map +2 -2
- package/dist-cjs/lib/network.js.map +2 -2
- package/dist-cjs/lib/number.js.map +2 -2
- package/dist-cjs/lib/object.js +1 -1
- package/dist-cjs/lib/object.js.map +2 -2
- package/dist-cjs/lib/perf.js.map +2 -2
- package/dist-cjs/lib/reordering.js.map +2 -2
- package/dist-cjs/lib/retry.js.map +2 -2
- package/dist-cjs/lib/sort.js.map +2 -2
- package/dist-cjs/lib/storage.js.map +2 -2
- package/dist-cjs/lib/stringEnum.js.map +2 -2
- package/dist-cjs/lib/throttle.js.map +2 -2
- package/dist-cjs/lib/timers.js +103 -4
- package/dist-cjs/lib/timers.js.map +2 -2
- package/dist-cjs/lib/types.js.map +1 -1
- package/dist-cjs/lib/url.js.map +2 -2
- package/dist-cjs/lib/value.js.map +2 -2
- package/dist-cjs/lib/version.js.map +2 -2
- package/dist-cjs/lib/warn.js.map +2 -2
- package/dist-esm/index.d.mts +1350 -80
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ExecutionQueue.mjs +79 -0
- package/dist-esm/lib/ExecutionQueue.mjs.map +2 -2
- package/dist-esm/lib/PerformanceTracker.mjs +43 -0
- package/dist-esm/lib/PerformanceTracker.mjs.map +2 -2
- package/dist-esm/lib/array.mjs +3 -1
- package/dist-esm/lib/array.mjs.map +2 -2
- package/dist-esm/lib/bind.mjs.map +2 -2
- package/dist-esm/lib/cache.mjs +27 -5
- package/dist-esm/lib/cache.mjs.map +2 -2
- package/dist-esm/lib/control.mjs +12 -0
- package/dist-esm/lib/control.mjs.map +2 -2
- package/dist-esm/lib/debounce.mjs.map +2 -2
- package/dist-esm/lib/error.mjs.map +2 -2
- package/dist-esm/lib/file.mjs +76 -11
- package/dist-esm/lib/file.mjs.map +2 -2
- package/dist-esm/lib/function.mjs.map +2 -2
- package/dist-esm/lib/hash.mjs.map +2 -2
- package/dist-esm/lib/id.mjs.map +2 -2
- package/dist-esm/lib/iterable.mjs.map +2 -2
- package/dist-esm/lib/media/apng.mjs.map +2 -2
- package/dist-esm/lib/media/avif.mjs.map +2 -2
- package/dist-esm/lib/media/gif.mjs.map +2 -2
- package/dist-esm/lib/media/media.mjs +130 -4
- package/dist-esm/lib/media/media.mjs.map +2 -2
- package/dist-esm/lib/media/png.mjs +141 -0
- package/dist-esm/lib/media/png.mjs.map +2 -2
- package/dist-esm/lib/media/webp.mjs +1 -0
- package/dist-esm/lib/media/webp.mjs.map +2 -2
- package/dist-esm/lib/network.mjs.map +2 -2
- package/dist-esm/lib/number.mjs.map +2 -2
- package/dist-esm/lib/object.mjs.map +2 -2
- package/dist-esm/lib/perf.mjs.map +2 -2
- package/dist-esm/lib/reordering.mjs.map +2 -2
- package/dist-esm/lib/retry.mjs.map +2 -2
- package/dist-esm/lib/sort.mjs.map +2 -2
- package/dist-esm/lib/storage.mjs.map +2 -2
- package/dist-esm/lib/stringEnum.mjs.map +2 -2
- package/dist-esm/lib/throttle.mjs.map +2 -2
- package/dist-esm/lib/timers.mjs +103 -4
- package/dist-esm/lib/timers.mjs.map +2 -2
- package/dist-esm/lib/url.mjs.map +2 -2
- package/dist-esm/lib/value.mjs.map +2 -2
- package/dist-esm/lib/version.mjs.map +2 -2
- package/dist-esm/lib/warn.mjs.map +2 -2
- package/package.json +1 -1
- package/src/lib/ExecutionQueue.test.ts +162 -20
- package/src/lib/ExecutionQueue.ts +110 -1
- package/src/lib/PerformanceTracker.test.ts +124 -0
- package/src/lib/PerformanceTracker.ts +63 -1
- package/src/lib/array.test.ts +263 -1
- package/src/lib/array.ts +183 -14
- package/src/lib/bind.test.ts +47 -0
- package/src/lib/bind.ts +69 -4
- package/src/lib/cache.test.ts +73 -0
- package/src/lib/cache.ts +47 -6
- package/src/lib/control.test.ts +50 -0
- package/src/lib/control.ts +198 -9
- package/src/lib/debounce.ts +28 -3
- package/src/lib/error.test.ts +60 -0
- package/src/lib/error.ts +27 -1
- package/src/lib/file.test.ts +49 -0
- package/src/lib/file.ts +117 -12
- package/src/lib/function.ts +11 -0
- package/src/lib/hash.test.ts +99 -0
- package/src/lib/hash.ts +69 -2
- package/src/lib/id.test.ts +32 -0
- package/src/lib/id.ts +53 -5
- package/src/lib/iterable.test.ts +25 -0
- package/src/lib/iterable.ts +4 -5
- package/src/lib/json-value.ts +71 -4
- package/src/lib/media/apng.test.ts +67 -0
- package/src/lib/media/apng.ts +38 -21
- package/src/lib/media/avif.test.ts +26 -0
- package/src/lib/media/avif.ts +34 -0
- package/src/lib/media/gif.test.ts +52 -0
- package/src/lib/media/gif.ts +25 -2
- package/src/lib/media/media.test.ts +58 -0
- package/src/lib/media/media.ts +220 -11
- package/src/lib/media/png.ts +162 -1
- package/src/lib/media/webp.test.ts +81 -0
- package/src/lib/media/webp.ts +33 -1
- package/src/lib/network.test.ts +38 -0
- package/src/lib/network.ts +6 -0
- package/src/lib/number.test.ts +74 -0
- package/src/lib/number.ts +29 -5
- package/src/lib/object.test.ts +236 -0
- package/src/lib/object.ts +194 -14
- package/src/lib/perf.ts +75 -3
- package/src/lib/reordering.test.ts +168 -0
- package/src/lib/reordering.ts +62 -4
- package/src/lib/retry.test.ts +77 -0
- package/src/lib/retry.ts +47 -1
- package/src/lib/sort.test.ts +36 -0
- package/src/lib/sort.ts +22 -1
- package/src/lib/storage.test.ts +130 -0
- package/src/lib/storage.tsx +54 -8
- package/src/lib/stringEnum.ts +20 -1
- package/src/lib/throttle.ts +46 -8
- package/src/lib/timers.test.ts +75 -0
- package/src/lib/timers.ts +124 -5
- package/src/lib/types.ts +126 -4
- package/src/lib/url.test.ts +44 -0
- package/src/lib/url.ts +40 -1
- package/src/lib/value.test.ts +102 -0
- package/src/lib/value.ts +67 -3
- package/src/lib/version.test.ts +494 -56
- package/src/lib/version.ts +36 -1
- package/src/lib/warn.test.ts +64 -0
- package/src/lib/warn.ts +43 -2
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isGIF, isGifAnimated } from './gif'
|
|
3
|
+
|
|
4
|
+
describe('isGIF', () => {
|
|
5
|
+
it('should return true for valid GIF header', () => {
|
|
6
|
+
const buffer = new ArrayBuffer(10)
|
|
7
|
+
const view = new Uint8Array(buffer)
|
|
8
|
+
|
|
9
|
+
// Set GIF header "GIF"
|
|
10
|
+
view[0] = 71 // G
|
|
11
|
+
view[1] = 73 // I
|
|
12
|
+
view[2] = 70 // F
|
|
13
|
+
|
|
14
|
+
expect(isGIF(buffer)).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('should return false for non-GIF header', () => {
|
|
18
|
+
const buffer = new ArrayBuffer(10)
|
|
19
|
+
const view = new Uint8Array(buffer)
|
|
20
|
+
|
|
21
|
+
// Set PNG header instead
|
|
22
|
+
view[0] = 137 // PNG signature
|
|
23
|
+
view[1] = 80 // P
|
|
24
|
+
view[2] = 78 // N
|
|
25
|
+
|
|
26
|
+
expect(isGIF(buffer)).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('should return false for empty buffer', () => {
|
|
30
|
+
const buffer = new ArrayBuffer(0)
|
|
31
|
+
expect(isGIF(buffer)).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('isGifAnimated', () => {
|
|
36
|
+
it('should return false for non-GIF data', () => {
|
|
37
|
+
const buffer = new ArrayBuffer(20)
|
|
38
|
+
const view = new Uint8Array(buffer)
|
|
39
|
+
|
|
40
|
+
// Set non-GIF header
|
|
41
|
+
view[0] = 80 // P
|
|
42
|
+
view[1] = 78 // N
|
|
43
|
+
view[2] = 71 // G
|
|
44
|
+
|
|
45
|
+
expect(isGifAnimated(buffer)).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('should return false for empty buffer', () => {
|
|
49
|
+
const buffer = new ArrayBuffer(0)
|
|
50
|
+
expect(isGifAnimated(buffer)).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
})
|
package/src/lib/media/gif.ts
CHANGED
|
@@ -16,8 +16,18 @@ function getDataBlocksLength(buffer: Uint8Array, offset: number): number {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Checks if buffer contains GIF image
|
|
19
|
+
* Checks if buffer contains GIF image by examining the file header.
|
|
20
20
|
*
|
|
21
|
+
* @param buffer - The ArrayBuffer containing the image data to check
|
|
22
|
+
* @returns True if the buffer contains a GIF image, false otherwise
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* // Check a file from user input
|
|
26
|
+
* const file = event.target.files[0]
|
|
27
|
+
* const buffer = await file.arrayBuffer()
|
|
28
|
+
* const isGif = isGIF(buffer)
|
|
29
|
+
* console.log(isGif ? 'GIF image' : 'Not a GIF')
|
|
30
|
+
* ```
|
|
21
31
|
* @public
|
|
22
32
|
*/
|
|
23
33
|
export function isGIF(buffer: ArrayBuffer): boolean {
|
|
@@ -27,8 +37,21 @@ export function isGIF(buffer: ArrayBuffer): boolean {
|
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
/**
|
|
30
|
-
* Checks if buffer contains animated GIF image
|
|
40
|
+
* Checks if buffer contains animated GIF image by parsing the GIF structure and counting image descriptors.
|
|
41
|
+
* A GIF is considered animated if it contains more than one image descriptor block.
|
|
31
42
|
*
|
|
43
|
+
* @param buffer - The ArrayBuffer containing the GIF image data
|
|
44
|
+
* @returns True if the GIF is animated (contains multiple frames), false otherwise
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* // Check if a GIF file is animated
|
|
48
|
+
* const file = event.target.files[0]
|
|
49
|
+
* if (file.type === 'image/gif') {
|
|
50
|
+
* const buffer = await file.arrayBuffer()
|
|
51
|
+
* const animated = isGifAnimated(buffer)
|
|
52
|
+
* console.log(animated ? 'Animated GIF' : 'Static GIF')
|
|
53
|
+
* }
|
|
54
|
+
* ```
|
|
32
55
|
* @public
|
|
33
56
|
*/
|
|
34
57
|
export function isGifAnimated(buffer: ArrayBuffer): boolean {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { MediaHelpers } from './media'
|
|
3
|
+
|
|
4
|
+
describe('MediaHelpers', () => {
|
|
5
|
+
// Note: Most MediaHelpers methods are DOM-dependent and already tested through
|
|
6
|
+
// integration tests in packages that use them. The complex mocking required
|
|
7
|
+
// for these tests doesn't add value and makes tests brittle.
|
|
8
|
+
|
|
9
|
+
describe('type checking methods', () => {
|
|
10
|
+
it('should correctly identify animated image types', () => {
|
|
11
|
+
expect(MediaHelpers.isAnimatedImageType('image/gif')).toBe(true)
|
|
12
|
+
expect(MediaHelpers.isAnimatedImageType('image/apng')).toBe(true)
|
|
13
|
+
expect(MediaHelpers.isAnimatedImageType('image/avif')).toBe(true)
|
|
14
|
+
expect(MediaHelpers.isAnimatedImageType('image/webp')).toBe(false) // webp can be static
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('should correctly identify static image types', () => {
|
|
18
|
+
expect(MediaHelpers.isStaticImageType('image/jpeg')).toBe(true)
|
|
19
|
+
expect(MediaHelpers.isStaticImageType('image/png')).toBe(true)
|
|
20
|
+
expect(MediaHelpers.isStaticImageType('image/webp')).toBe(true)
|
|
21
|
+
expect(MediaHelpers.isStaticImageType('image/gif')).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should correctly identify vector image types', () => {
|
|
25
|
+
expect(MediaHelpers.isVectorImageType('image/svg+xml')).toBe(true)
|
|
26
|
+
expect(MediaHelpers.isVectorImageType('image/jpeg')).toBe(false)
|
|
27
|
+
expect(MediaHelpers.isVectorImageType('image/gif')).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should correctly identify all image types', () => {
|
|
31
|
+
expect(MediaHelpers.isImageType('image/jpeg')).toBe(true)
|
|
32
|
+
expect(MediaHelpers.isImageType('image/png')).toBe(true)
|
|
33
|
+
expect(MediaHelpers.isImageType('image/gif')).toBe(true)
|
|
34
|
+
expect(MediaHelpers.isImageType('image/svg+xml')).toBe(true)
|
|
35
|
+
expect(MediaHelpers.isImageType('video/mp4')).toBe(false)
|
|
36
|
+
expect(MediaHelpers.isImageType('text/plain')).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('should handle null and undefined gracefully', () => {
|
|
40
|
+
expect(MediaHelpers.isAnimatedImageType(null)).toBe(false)
|
|
41
|
+
expect(MediaHelpers.isStaticImageType(null)).toBe(false)
|
|
42
|
+
expect(MediaHelpers.isVectorImageType(null)).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Note: The following methods are not tested here as they require extensive DOM mocking
|
|
47
|
+
// that would test the mocking framework more than the actual business logic:
|
|
48
|
+
// - loadVideo: Complex DOM video element mocking
|
|
49
|
+
// - getVideoFrameAsDataUrl: Canvas and video interaction mocking
|
|
50
|
+
// - getImageAndDimensions: Image element and DOM manipulation mocking
|
|
51
|
+
// - getVideoSize: Depends on loadVideo
|
|
52
|
+
// - getImageSize: Complex PNG parsing logic with extensive mocking
|
|
53
|
+
// - isAnimated: Delegates to format-specific functions already tested elsewhere
|
|
54
|
+
// - usingObjectURL: Simple wrapper around URL APIs with cleanup
|
|
55
|
+
|
|
56
|
+
// These methods are better tested through integration tests in the consuming packages
|
|
57
|
+
// (like @tldraw/tldraw) where they're used in realistic scenarios.
|
|
58
|
+
})
|
package/src/lib/media/media.ts
CHANGED
|
@@ -6,38 +6,121 @@ import { isGifAnimated } from './gif'
|
|
|
6
6
|
import { PngHelpers } from './png'
|
|
7
7
|
import { isWebpAnimated } from './webp'
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Array of supported vector image MIME types.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES } from '@tldraw/utils'
|
|
15
|
+
*
|
|
16
|
+
* const isSvg = DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES.includes('image/svg+xml')
|
|
17
|
+
* console.log(isSvg) // true
|
|
18
|
+
* ```
|
|
19
|
+
* @public
|
|
20
|
+
*/
|
|
10
21
|
export const DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES = Object.freeze(['image/svg+xml' as const])
|
|
11
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Array of supported static (non-animated) image MIME types.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* import { DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES } from '@tldraw/utils'
|
|
28
|
+
*
|
|
29
|
+
* const isStatic = DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES.includes('image/jpeg')
|
|
30
|
+
* console.log(isStatic) // true
|
|
31
|
+
* ```
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
12
34
|
export const DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES = Object.freeze([
|
|
13
35
|
'image/jpeg' as const,
|
|
14
36
|
'image/png' as const,
|
|
15
37
|
'image/webp' as const,
|
|
16
38
|
])
|
|
17
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Array of supported animated image MIME types.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* import { DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES } from '@tldraw/utils'
|
|
45
|
+
*
|
|
46
|
+
* const isAnimated = DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES.includes('image/gif')
|
|
47
|
+
* console.log(isAnimated) // true
|
|
48
|
+
* ```
|
|
49
|
+
* @public
|
|
50
|
+
*/
|
|
18
51
|
export const DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES = Object.freeze([
|
|
19
52
|
'image/gif' as const,
|
|
20
53
|
'image/apng' as const,
|
|
21
54
|
'image/avif' as const,
|
|
22
55
|
])
|
|
23
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Array of all supported image MIME types, combining static, vector, and animated types.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* import { DEFAULT_SUPPORTED_IMAGE_TYPES } from '@tldraw/utils'
|
|
62
|
+
*
|
|
63
|
+
* const isSupported = DEFAULT_SUPPORTED_IMAGE_TYPES.includes('image/png')
|
|
64
|
+
* console.log(isSupported) // true
|
|
65
|
+
* ```
|
|
66
|
+
* @public
|
|
67
|
+
*/
|
|
24
68
|
export const DEFAULT_SUPPORTED_IMAGE_TYPES = Object.freeze([
|
|
25
69
|
...DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES,
|
|
26
70
|
...DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES,
|
|
27
71
|
...DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES,
|
|
28
72
|
])
|
|
29
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* Array of supported video MIME types.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```ts
|
|
78
|
+
* import { DEFAULT_SUPPORT_VIDEO_TYPES } from '@tldraw/utils'
|
|
79
|
+
*
|
|
80
|
+
* const isVideo = DEFAULT_SUPPORT_VIDEO_TYPES.includes('video/mp4')
|
|
81
|
+
* console.log(isVideo) // true
|
|
82
|
+
* ```
|
|
83
|
+
* @public
|
|
84
|
+
*/
|
|
30
85
|
export const DEFAULT_SUPPORT_VIDEO_TYPES = Object.freeze([
|
|
31
86
|
'video/mp4' as const,
|
|
32
87
|
'video/webm' as const,
|
|
33
88
|
'video/quicktime' as const,
|
|
34
89
|
])
|
|
35
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* Array of all supported media MIME types, combining images and videos.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```ts
|
|
95
|
+
* import { DEFAULT_SUPPORTED_MEDIA_TYPES } from '@tldraw/utils'
|
|
96
|
+
*
|
|
97
|
+
* const isMediaFile = DEFAULT_SUPPORTED_MEDIA_TYPES.includes('video/mp4')
|
|
98
|
+
* console.log(isMediaFile) // true
|
|
99
|
+
* ```
|
|
100
|
+
* @public
|
|
101
|
+
*/
|
|
36
102
|
export const DEFAULT_SUPPORTED_MEDIA_TYPES = Object.freeze([
|
|
37
103
|
...DEFAULT_SUPPORTED_IMAGE_TYPES,
|
|
38
104
|
...DEFAULT_SUPPORT_VIDEO_TYPES,
|
|
39
105
|
])
|
|
40
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Comma-separated string of all supported media MIME types, useful for HTML file input accept attributes.
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```ts
|
|
111
|
+
* import { DEFAULT_SUPPORTED_MEDIA_TYPE_LIST } from '@tldraw/utils'
|
|
112
|
+
*
|
|
113
|
+
* // Use in HTML file input for media uploads
|
|
114
|
+
* const input = document.createElement('input')
|
|
115
|
+
* input.type = 'file'
|
|
116
|
+
* input.accept = DEFAULT_SUPPORTED_MEDIA_TYPE_LIST
|
|
117
|
+
* input.addEventListener('change', (e) => {
|
|
118
|
+
* const files = (e.target as HTMLInputElement).files
|
|
119
|
+
* if (files) console.log(`Selected ${files.length} file(s)`)
|
|
120
|
+
* })
|
|
121
|
+
* ```
|
|
122
|
+
* @public
|
|
123
|
+
*/
|
|
41
124
|
export const DEFAULT_SUPPORTED_MEDIA_TYPE_LIST = DEFAULT_SUPPORTED_MEDIA_TYPES.join(',')
|
|
42
125
|
|
|
43
126
|
/**
|
|
@@ -47,7 +130,15 @@ export const DEFAULT_SUPPORTED_MEDIA_TYPE_LIST = DEFAULT_SUPPORTED_MEDIA_TYPES.j
|
|
|
47
130
|
*/
|
|
48
131
|
export class MediaHelpers {
|
|
49
132
|
/**
|
|
50
|
-
* Load a video from a
|
|
133
|
+
* Load a video element from a URL with cross-origin support.
|
|
134
|
+
*
|
|
135
|
+
* @param src - The URL of the video to load
|
|
136
|
+
* @returns Promise that resolves to the loaded HTMLVideoElement
|
|
137
|
+
* @example
|
|
138
|
+
* ```ts
|
|
139
|
+
* const video = await MediaHelpers.loadVideo('https://example.com/video.mp4')
|
|
140
|
+
* console.log(`Video dimensions: ${video.videoWidth}x${video.videoHeight}`)
|
|
141
|
+
* ```
|
|
51
142
|
* @public
|
|
52
143
|
*/
|
|
53
144
|
static loadVideo(src: string): Promise<HTMLVideoElement> {
|
|
@@ -63,6 +154,22 @@ export class MediaHelpers {
|
|
|
63
154
|
})
|
|
64
155
|
}
|
|
65
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Extract a frame from a video element as a data URL.
|
|
159
|
+
*
|
|
160
|
+
* @param video - The HTMLVideoElement to extract frame from
|
|
161
|
+
* @param time - The time in seconds to extract the frame from (default: 0)
|
|
162
|
+
* @returns Promise that resolves to a data URL of the video frame
|
|
163
|
+
* @example
|
|
164
|
+
* ```ts
|
|
165
|
+
* const video = await MediaHelpers.loadVideo('https://example.com/video.mp4')
|
|
166
|
+
* const frameDataUrl = await MediaHelpers.getVideoFrameAsDataUrl(video, 5.0)
|
|
167
|
+
* // Use frameDataUrl as image thumbnail
|
|
168
|
+
* const img = document.createElement('img')
|
|
169
|
+
* img.src = frameDataUrl
|
|
170
|
+
* ```
|
|
171
|
+
* @public
|
|
172
|
+
*/
|
|
66
173
|
static async getVideoFrameAsDataUrl(video: HTMLVideoElement, time = 0): Promise<string> {
|
|
67
174
|
const promise = promiseWithResolve<string>()
|
|
68
175
|
let didSetTime = false
|
|
@@ -118,7 +225,17 @@ export class MediaHelpers {
|
|
|
118
225
|
}
|
|
119
226
|
|
|
120
227
|
/**
|
|
121
|
-
* Load an image from a
|
|
228
|
+
* Load an image from a URL and get its dimensions along with the image element.
|
|
229
|
+
*
|
|
230
|
+
* @param src - The URL of the image to load
|
|
231
|
+
* @returns Promise that resolves to an object with width, height, and the image element
|
|
232
|
+
* @example
|
|
233
|
+
* ```ts
|
|
234
|
+
* const { w, h, image } = await MediaHelpers.getImageAndDimensions('https://example.com/image.png')
|
|
235
|
+
* console.log(`Image size: ${w}x${h}`)
|
|
236
|
+
* // Image is ready to use
|
|
237
|
+
* document.body.appendChild(image)
|
|
238
|
+
* ```
|
|
122
239
|
* @public
|
|
123
240
|
*/
|
|
124
241
|
static getImageAndDimensions(
|
|
@@ -162,7 +279,14 @@ export class MediaHelpers {
|
|
|
162
279
|
/**
|
|
163
280
|
* Get the size of a video blob
|
|
164
281
|
*
|
|
165
|
-
* @param blob - A
|
|
282
|
+
* @param blob - A Blob containing the video
|
|
283
|
+
* @returns Promise that resolves to an object with width and height properties
|
|
284
|
+
* @example
|
|
285
|
+
* ```ts
|
|
286
|
+
* const file = new File([...], 'video.mp4', { type: 'video/mp4' })
|
|
287
|
+
* const { w, h } = await MediaHelpers.getVideoSize(file)
|
|
288
|
+
* console.log(`Video dimensions: ${w}x${h}`)
|
|
289
|
+
* ```
|
|
166
290
|
* @public
|
|
167
291
|
*/
|
|
168
292
|
static async getVideoSize(blob: Blob): Promise<{ w: number; h: number }> {
|
|
@@ -175,7 +299,14 @@ export class MediaHelpers {
|
|
|
175
299
|
/**
|
|
176
300
|
* Get the size of an image blob
|
|
177
301
|
*
|
|
178
|
-
* @param blob - A Blob containing the image
|
|
302
|
+
* @param blob - A Blob containing the image
|
|
303
|
+
* @returns Promise that resolves to an object with width and height properties
|
|
304
|
+
* @example
|
|
305
|
+
* ```ts
|
|
306
|
+
* const file = new File([...], 'image.png', { type: 'image/png' })
|
|
307
|
+
* const { w, h } = await MediaHelpers.getImageSize(file)
|
|
308
|
+
* console.log(`Image dimensions: ${w}x${h}`)
|
|
309
|
+
* ```
|
|
179
310
|
* @public
|
|
180
311
|
*/
|
|
181
312
|
static async getImageSize(blob: Blob): Promise<{ w: number; h: number }> {
|
|
@@ -210,6 +341,19 @@ export class MediaHelpers {
|
|
|
210
341
|
return { w, h }
|
|
211
342
|
}
|
|
212
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Check if a media file blob contains animation data.
|
|
346
|
+
*
|
|
347
|
+
* @param file - The Blob to check for animation
|
|
348
|
+
* @returns Promise that resolves to true if the file is animated, false otherwise
|
|
349
|
+
* @example
|
|
350
|
+
* ```ts
|
|
351
|
+
* const file = new File([...], 'animation.gif', { type: 'image/gif' })
|
|
352
|
+
* const animated = await MediaHelpers.isAnimated(file)
|
|
353
|
+
* console.log(animated ? 'Animated' : 'Static')
|
|
354
|
+
* ```
|
|
355
|
+
* @public
|
|
356
|
+
*/
|
|
213
357
|
static async isAnimated(file: Blob): Promise<boolean> {
|
|
214
358
|
if (file.type === 'image/gif') {
|
|
215
359
|
return isGifAnimated(await file.arrayBuffer())
|
|
@@ -230,22 +374,87 @@ export class MediaHelpers {
|
|
|
230
374
|
return false
|
|
231
375
|
}
|
|
232
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Check if a MIME type represents an animated image format.
|
|
379
|
+
*
|
|
380
|
+
* @param mimeType - The MIME type to check
|
|
381
|
+
* @returns True if the MIME type is an animated image format, false otherwise
|
|
382
|
+
* @example
|
|
383
|
+
* ```ts
|
|
384
|
+
* const isAnimated = MediaHelpers.isAnimatedImageType('image/gif')
|
|
385
|
+
* console.log(isAnimated) // true
|
|
386
|
+
* ```
|
|
387
|
+
* @public
|
|
388
|
+
*/
|
|
233
389
|
static isAnimatedImageType(mimeType: string | null): boolean {
|
|
234
390
|
return DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES.includes((mimeType as any) || '')
|
|
235
391
|
}
|
|
236
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Check if a MIME type represents a static (non-animated) image format.
|
|
395
|
+
*
|
|
396
|
+
* @param mimeType - The MIME type to check
|
|
397
|
+
* @returns True if the MIME type is a static image format, false otherwise
|
|
398
|
+
* @example
|
|
399
|
+
* ```ts
|
|
400
|
+
* const isStatic = MediaHelpers.isStaticImageType('image/jpeg')
|
|
401
|
+
* console.log(isStatic) // true
|
|
402
|
+
* ```
|
|
403
|
+
* @public
|
|
404
|
+
*/
|
|
237
405
|
static isStaticImageType(mimeType: string | null): boolean {
|
|
238
406
|
return DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES.includes((mimeType as any) || '')
|
|
239
407
|
}
|
|
240
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Check if a MIME type represents a vector image format.
|
|
411
|
+
*
|
|
412
|
+
* @param mimeType - The MIME type to check
|
|
413
|
+
* @returns True if the MIME type is a vector image format, false otherwise
|
|
414
|
+
* @example
|
|
415
|
+
* ```ts
|
|
416
|
+
* const isVector = MediaHelpers.isVectorImageType('image/svg+xml')
|
|
417
|
+
* console.log(isVector) // true
|
|
418
|
+
* ```
|
|
419
|
+
* @public
|
|
420
|
+
*/
|
|
241
421
|
static isVectorImageType(mimeType: string | null): boolean {
|
|
242
422
|
return DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES.includes((mimeType as any) || '')
|
|
243
423
|
}
|
|
244
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Check if a MIME type represents any supported image format (static, animated, or vector).
|
|
427
|
+
*
|
|
428
|
+
* @param mimeType - The MIME type to check
|
|
429
|
+
* @returns True if the MIME type is a supported image format, false otherwise
|
|
430
|
+
* @example
|
|
431
|
+
* ```ts
|
|
432
|
+
* const isImage = MediaHelpers.isImageType('image/png')
|
|
433
|
+
* console.log(isImage) // true
|
|
434
|
+
* ```
|
|
435
|
+
* @public
|
|
436
|
+
*/
|
|
245
437
|
static isImageType(mimeType: string): boolean {
|
|
246
438
|
return DEFAULT_SUPPORTED_IMAGE_TYPES.includes((mimeType as any) || '')
|
|
247
439
|
}
|
|
248
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Utility function to create an object URL from a blob, execute a function with it, and automatically clean it up.
|
|
443
|
+
*
|
|
444
|
+
* @param blob - The Blob to create an object URL for
|
|
445
|
+
* @param fn - Function to execute with the object URL
|
|
446
|
+
* @returns Promise that resolves to the result of the function
|
|
447
|
+
* @example
|
|
448
|
+
* ```ts
|
|
449
|
+
* const result = await MediaHelpers.usingObjectURL(imageBlob, async (url) => {
|
|
450
|
+
* const { w, h } = await MediaHelpers.getImageAndDimensions(url)
|
|
451
|
+
* return { width: w, height: h }
|
|
452
|
+
* })
|
|
453
|
+
* // Object URL is automatically revoked after function completes
|
|
454
|
+
* console.log(`Image dimensions: ${result.width}x${result.height}`)
|
|
455
|
+
* ```
|
|
456
|
+
* @public
|
|
457
|
+
*/
|
|
249
458
|
static async usingObjectURL<T>(blob: Blob, fn: (url: string) => Promise<T>): Promise<T> {
|
|
250
459
|
const url = URL.createObjectURL(blob)
|
|
251
460
|
try {
|
package/src/lib/media/png.ts
CHANGED
|
@@ -62,8 +62,51 @@ const crc: CRCCalculator<Uint8Array> = (current, previous) => {
|
|
|
62
62
|
const LEN_SIZE = 4
|
|
63
63
|
const CRC_SIZE = 4
|
|
64
64
|
|
|
65
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* Utility class for reading and manipulating PNG image files.
|
|
67
|
+
* Provides methods for parsing PNG chunks, validating PNG format, and modifying PNG metadata.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* // Validate PNG file from blob
|
|
72
|
+
* const blob = new Blob([pngData], { type: 'image/png' })
|
|
73
|
+
* const view = new DataView(await blob.arrayBuffer())
|
|
74
|
+
* const isPng = PngHelpers.isPng(view, 0)
|
|
75
|
+
*
|
|
76
|
+
* // Parse PNG metadata for image processing
|
|
77
|
+
* const chunks = PngHelpers.readChunks(view)
|
|
78
|
+
* const physChunk = PngHelpers.findChunk(view, 'pHYs')
|
|
79
|
+
*
|
|
80
|
+
* // Create high-DPI PNG for export
|
|
81
|
+
* const highDpiBlob = PngHelpers.setPhysChunk(view, 2, { type: 'image/png' })
|
|
82
|
+
* ```
|
|
83
|
+
*
|
|
84
|
+
* @public
|
|
85
|
+
*/
|
|
66
86
|
export class PngHelpers {
|
|
87
|
+
/**
|
|
88
|
+
* Checks if binary data at the specified offset contains a valid PNG file signature.
|
|
89
|
+
* Validates the 8-byte PNG signature: 89 50 4E 47 0D 0A 1A 0A.
|
|
90
|
+
*
|
|
91
|
+
* @param view - DataView containing the binary data to check
|
|
92
|
+
* @param offset - Byte offset where the PNG signature should start
|
|
93
|
+
* @returns True if the data contains a valid PNG signature, false otherwise
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* // Validate PNG from file upload
|
|
98
|
+
* const file = event.target.files[0]
|
|
99
|
+
* const buffer = await file.arrayBuffer()
|
|
100
|
+
* const view = new DataView(buffer)
|
|
101
|
+
*
|
|
102
|
+
* if (PngHelpers.isPng(view, 0)) {
|
|
103
|
+
* console.log('Valid PNG file detected')
|
|
104
|
+
* // Process PNG file...
|
|
105
|
+
* } else {
|
|
106
|
+
* console.error('Not a valid PNG file')
|
|
107
|
+
* }
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
67
110
|
static isPng(view: DataView, offset: number) {
|
|
68
111
|
if (
|
|
69
112
|
view.getUint8(offset + 0) === 0x89 &&
|
|
@@ -80,6 +123,25 @@ export class PngHelpers {
|
|
|
80
123
|
return false
|
|
81
124
|
}
|
|
82
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Reads the 4-character chunk type identifier from a PNG chunk header.
|
|
128
|
+
*
|
|
129
|
+
* @param view - DataView containing the PNG data
|
|
130
|
+
* @param offset - Byte offset of the chunk type field (after length field)
|
|
131
|
+
* @returns 4-character string representing the chunk type (e.g., 'IHDR', 'IDAT', 'IEND')
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```ts
|
|
135
|
+
* // Read chunk type from PNG header (after 8-byte signature)
|
|
136
|
+
* const chunkType = PngHelpers.getChunkType(dataView, 8)
|
|
137
|
+
* console.log(chunkType) // 'IHDR' (Image Header)
|
|
138
|
+
*
|
|
139
|
+
* // Read chunk type at a specific position during parsing
|
|
140
|
+
* let offset = 8 // Skip PNG signature
|
|
141
|
+
* const chunkLength = dataView.getUint32(offset)
|
|
142
|
+
* const type = PngHelpers.getChunkType(dataView, offset + 4)
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
83
145
|
static getChunkType(view: DataView, offset: number) {
|
|
84
146
|
return [
|
|
85
147
|
String.fromCharCode(view.getUint8(offset)),
|
|
@@ -89,6 +151,32 @@ export class PngHelpers {
|
|
|
89
151
|
].join('')
|
|
90
152
|
}
|
|
91
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Parses all chunks in a PNG file and returns their metadata.
|
|
156
|
+
* Skips duplicate IDAT chunks but includes all other chunk types.
|
|
157
|
+
*
|
|
158
|
+
* @param view - DataView containing the complete PNG file data
|
|
159
|
+
* @param offset - Starting byte offset (defaults to 0)
|
|
160
|
+
* @returns Record mapping chunk types to their metadata (start position, data offset, and size)
|
|
161
|
+
* @throws Error if the data is not a valid PNG file
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```ts
|
|
165
|
+
* // Parse PNG structure for metadata extraction
|
|
166
|
+
* const view = new DataView(await blob.arrayBuffer())
|
|
167
|
+
* const chunks = PngHelpers.readChunks(view)
|
|
168
|
+
*
|
|
169
|
+
* // Check for specific chunks
|
|
170
|
+
* const ihdrChunk = chunks['IHDR']
|
|
171
|
+
* const physChunk = chunks['pHYs']
|
|
172
|
+
*
|
|
173
|
+
* if (physChunk) {
|
|
174
|
+
* console.log(`Found pixel density info at byte ${physChunk.start}`)
|
|
175
|
+
* } else {
|
|
176
|
+
* console.log('No pixel density information found')
|
|
177
|
+
* }
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
92
180
|
static readChunks(view: DataView, offset = 0) {
|
|
93
181
|
const chunks: Record<string, { dataOffset: number; size: number; start: number }> = {}
|
|
94
182
|
if (!PngHelpers.isPng(view, offset)) {
|
|
@@ -122,6 +210,29 @@ export class PngHelpers {
|
|
|
122
210
|
return chunks
|
|
123
211
|
}
|
|
124
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Parses the pHYs (physical pixel dimensions) chunk data.
|
|
215
|
+
* Reads pixels per unit for X and Y axes, and the unit specifier.
|
|
216
|
+
*
|
|
217
|
+
* @param view - DataView containing the PNG data
|
|
218
|
+
* @param offset - Byte offset of the pHYs chunk data
|
|
219
|
+
* @returns Object with ppux (pixels per unit X), ppuy (pixels per unit Y), and unit specifier
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```ts
|
|
223
|
+
* // Extract pixel density information for DPI calculation
|
|
224
|
+
* const physChunk = PngHelpers.findChunk(dataView, 'pHYs')
|
|
225
|
+
* if (physChunk) {
|
|
226
|
+
* const physData = PngHelpers.parsePhys(dataView, physChunk.dataOffset)
|
|
227
|
+
*
|
|
228
|
+
* if (physData.unit === 1) { // meters
|
|
229
|
+
* const dpiX = Math.round(physData.ppux * 0.0254)
|
|
230
|
+
* const dpiY = Math.round(physData.ppuy * 0.0254)
|
|
231
|
+
* console.log(`DPI: ${dpiX} x ${dpiY}`)
|
|
232
|
+
* }
|
|
233
|
+
* }
|
|
234
|
+
* ```
|
|
235
|
+
*/
|
|
125
236
|
static parsePhys(view: DataView, offset: number) {
|
|
126
237
|
return {
|
|
127
238
|
ppux: view.getUint32(offset),
|
|
@@ -130,11 +241,61 @@ export class PngHelpers {
|
|
|
130
241
|
}
|
|
131
242
|
}
|
|
132
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Finds a specific chunk type in the PNG file and returns its metadata.
|
|
246
|
+
*
|
|
247
|
+
* @param view - DataView containing the PNG file data
|
|
248
|
+
* @param type - 4-character chunk type to search for (e.g., 'pHYs', 'IDAT')
|
|
249
|
+
* @returns Chunk metadata object if found, undefined otherwise
|
|
250
|
+
*
|
|
251
|
+
* @example
|
|
252
|
+
* ```ts
|
|
253
|
+
* // Look for pixel density information in PNG
|
|
254
|
+
* const physChunk = PngHelpers.findChunk(dataView, 'pHYs')
|
|
255
|
+
* if (physChunk) {
|
|
256
|
+
* const physData = PngHelpers.parsePhys(dataView, physChunk.dataOffset)
|
|
257
|
+
* console.log(`Found pHYs chunk with ${physData.ppux} x ${physData.ppuy} pixels per unit`)
|
|
258
|
+
* }
|
|
259
|
+
*
|
|
260
|
+
* // Check for text metadata
|
|
261
|
+
* const textChunk = PngHelpers.findChunk(dataView, 'tEXt')
|
|
262
|
+
* if (textChunk) {
|
|
263
|
+
* console.log(`Found text metadata at byte ${textChunk.start}`)
|
|
264
|
+
* }
|
|
265
|
+
* ```
|
|
266
|
+
*/
|
|
133
267
|
static findChunk(view: DataView, type: string) {
|
|
134
268
|
const chunks = PngHelpers.readChunks(view)
|
|
135
269
|
return chunks[type]
|
|
136
270
|
}
|
|
137
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Adds or replaces a pHYs chunk in a PNG file to set pixel density for high-DPI displays.
|
|
274
|
+
* The method determines insertion point by prioritizing IDAT chunk position over existing pHYs,
|
|
275
|
+
* creates a properly formatted pHYs chunk with CRC validation, and returns a new Blob.
|
|
276
|
+
*
|
|
277
|
+
* @param view - DataView containing the original PNG file data
|
|
278
|
+
* @param dpr - Device pixel ratio multiplier (defaults to 1)
|
|
279
|
+
* @param options - Optional Blob constructor options for MIME type and other properties
|
|
280
|
+
* @returns New Blob containing the PNG with updated pixel density information
|
|
281
|
+
*
|
|
282
|
+
* @example
|
|
283
|
+
* ```ts
|
|
284
|
+
* // Export PNG with proper pixel density for high-DPI displays
|
|
285
|
+
* const canvas = document.createElement('canvas')
|
|
286
|
+
* const ctx = canvas.getContext('2d')
|
|
287
|
+
* // ... draw content to canvas ...
|
|
288
|
+
*
|
|
289
|
+
* canvas.toBlob(async (blob) => {
|
|
290
|
+
* if (blob) {
|
|
291
|
+
* const view = new DataView(await blob.arrayBuffer())
|
|
292
|
+
* // Create 2x DPI version for Retina displays
|
|
293
|
+
* const highDpiBlob = PngHelpers.setPhysChunk(view, 2, { type: 'image/png' })
|
|
294
|
+
* // Download or use the blob...
|
|
295
|
+
* }
|
|
296
|
+
* }, 'image/png')
|
|
297
|
+
* ```
|
|
298
|
+
*/
|
|
138
299
|
static setPhysChunk(view: DataView, dpr = 1, options?: BlobPropertyBag) {
|
|
139
300
|
let offset = 46
|
|
140
301
|
let size = 0
|