UNPKG

@tldraw/utils

Version:

tldraw infinite canvas SDK (private utilities).

467 lines (442 loc) • 13.9 kB
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 * @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): Promise<HTMLVideoElement> { return new Promise((resolve, reject) => { const video = 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) { const canvas = 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 * @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 ): 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. document.body.appendChild(img) dimensions = { w: img.clientWidth, h: img.clientHeight, } document.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 * @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): Promise<{ w: number; h: number }> { return MediaHelpers.usingObjectURL(blob, async (url) => { const video = await MediaHelpers.loadVideo(url) return { w: video.videoWidth, h: video.videoHeight } }) } /** * Get the size of an image blob * * @param blob - A Blob containing the image * @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): Promise<{ w: number; h: number }> { const { w, h } = await MediaHelpers.usingObjectURL(blob, MediaHelpers.getImageAndDimensions) 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) { // Calculate pixels per meter: // - 1 inch = 0.0254 meters // - 72 DPI is 72 dots per inch // - pixels per meter = 72 / 0.0254 const pixelsPerMeter = 72 / 0.0254 const pixelRatio = Math.max(physData.ppux / pixelsPerMeter, 1) return { w: Math.round(w / pixelRatio), h: Math.round(h / pixelRatio), } } } } } } catch (err) { console.error(err) return { w, h } } return { w, h } } /** * 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) } } }