diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 7b39f1f26..e8a8a504c 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -1067,15 +1067,6 @@ export const PI: number; // @public (undocumented) export const PI2: number; -// @public (undocumented) -export const png: { - isPng: typeof isPng; - readChunks: typeof readChunks; - parsePhys: typeof parsePhys; - findChunk: typeof findChunk; - setPhysChunk: typeof setPhysChunk; -}; - // @public export function pointInBounds(A: VecLike, b: Box2d): boolean; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index e9e62507e..ed9614a28 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -289,7 +289,6 @@ export { getPointerInfo } from './lib/utils/getPointerInfo' export { getSvgPathFromPoints } from './lib/utils/getSvgPathFromPoints' export { hardResetEditor } from './lib/utils/hardResetEditor' export { normalizeWheel } from './lib/utils/normalizeWheel' -export { png } from './lib/utils/png' export { refreshPage } from './lib/utils/refreshPage' export { getIndexAbove, diff --git a/packages/tldraw/api-report.md b/packages/tldraw/api-report.md index d03b6f2b2..3377d7100 100644 --- a/packages/tldraw/api-report.md +++ b/packages/tldraw/api-report.md @@ -191,18 +191,6 @@ export function getFileMetaData(file: File): Promise<{ isAnimated: boolean; }>; -// @public -export function getImageSizeFromSrc(dataURL: string): Promise<{ - w: number; - h: number; -}>; - -// @public -export function getVideoSizeFromSrc(src: string): Promise<{ - w: number; - h: number; -}>; - // @public (undocumented) function Group({ children, size, }: { children: any; diff --git a/packages/tldraw/src/index.ts b/packages/tldraw/src/index.ts index e84dd08fb..bb9ec47fe 100644 --- a/packages/tldraw/src/index.ts +++ b/packages/tldraw/src/index.ts @@ -110,13 +110,7 @@ export { } from './lib/ui/hooks/useTranslation/useTranslation' export { type TLUiIconType } from './lib/ui/icon-types' export { useDefaultHelpers, type TLUiOverrides } from './lib/ui/overrides' -export { - ACCEPTED_IMG_TYPE, - getFileMetaData, - getImageSizeFromSrc, - getVideoSizeFromSrc, - isImage, -} from './lib/utils/assets' +export { ACCEPTED_IMG_TYPE, getFileMetaData, isImage } from './lib/utils/assets' export { buildFromV1Document, type LegacyTldrawDocument } from './lib/utils/buildFromV1Document' export { getEmbedInfo } from './lib/utils/embeds' export { diff --git a/packages/tldraw/src/lib/useRegisterExternalContentHandlers.ts b/packages/tldraw/src/lib/useRegisterExternalContentHandlers.ts index 8a628981f..8c1e39fa9 100644 --- a/packages/tldraw/src/lib/useRegisterExternalContentHandlers.ts +++ b/packages/tldraw/src/lib/useRegisterExternalContentHandlers.ts @@ -1,6 +1,7 @@ import { AssetRecordType, Editor, + MediaHelpers, TLAsset, TLAssetId, TLEmbedShape, @@ -21,9 +22,7 @@ import { ACCEPTED_VID_TYPE, containBoxSize, getFileMetaData, - getImageSizeFromSrc, getResizedImageDataUrl, - getVideoSizeFromSrc, isImage, } from './utils/assets' import { getEmbedInfo } from './utils/embeds' @@ -46,7 +45,9 @@ export function useRegisterExternalContentHandlers() { let dataUrl = reader.result as string const isImageType = isImage(file.type) - const sizeFn = isImageType ? getImageSizeFromSrc : getVideoSizeFromSrc + const sizeFn = isImageType + ? MediaHelpers.getImageSizeFromSrc + : MediaHelpers.getVideoSizeFromSrc // Hack to make .mov videos work via dataURL. if (file.type === 'video/quicktime' && dataUrl.includes('video/quicktime')) { diff --git a/packages/tldraw/src/lib/utils/assetUrls.ts b/packages/tldraw/src/lib/utils/assetUrls.ts index cc2c8df8a..d73f75b2a 100644 --- a/packages/tldraw/src/lib/utils/assetUrls.ts +++ b/packages/tldraw/src/lib/utils/assetUrls.ts @@ -34,7 +34,7 @@ export function useDefaultEditorAssetsWithOverrides( if (!overrides) return defaultEditorAssetUrls return { - fonts: Object.assign({ ...defaultEditorAssetUrls.fonts }, { ...overrides?.fonts }), + fonts: { ...defaultEditorAssetUrls.fonts, ...overrides?.fonts }, } }, [overrides]) } diff --git a/packages/tldraw/src/lib/utils/assets.ts b/packages/tldraw/src/lib/utils/assets.ts index 9c566fd53..c15158949 100644 --- a/packages/tldraw/src/lib/utils/assets.ts +++ b/packages/tldraw/src/lib/utils/assets.ts @@ -1,4 +1,3 @@ -import { png } from '@tldraw/editor' import { isAnimated } from './is-gif-animated' type BoxWidthHeight = { @@ -94,18 +93,6 @@ export async function getResizedImageDataUrl( }) } -/** - * @param dataURL - The file as a string. - * @internal - * - * from https://stackoverflow.com/a/53817185 - */ -async function base64ToFile(dataURL: string) { - return fetch(dataURL).then(function (result) { - return result.arrayBuffer() - }) -} - /** @public */ export const ACCEPTED_IMG_TYPE = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'] /** @public */ @@ -113,62 +100,3 @@ export const ACCEPTED_VID_TYPE = ['video/mp4', 'video/quicktime'] /** @public */ export const isImage = (ext: string) => ACCEPTED_IMG_TYPE.includes(ext) - -/** - * Get the size of a video from its source. - * - * @param src - The source of the video. - * @public - */ -export async function getVideoSizeFromSrc(src: string): Promise<{ w: number; h: number }> { - return await new Promise((resolve, reject) => { - const video = document.createElement('video') - video.onloadeddata = () => resolve({ w: video.videoWidth, h: video.videoHeight }) - video.onerror = (e) => { - console.error(e) - reject(new Error('Could not get video size')) - } - video.crossOrigin = 'anonymous' - video.src = src - }) -} - -/** - * Get the size of an image from its source. - * - * @param dataURL - The file as a string. - * @public - */ -export async function getImageSizeFromSrc(dataURL: string): Promise<{ w: number; h: number }> { - return await new Promise((resolve, reject) => { - const img = new Image() - img.onload = async () => { - try { - const blob = await base64ToFile(dataURL) - const view = new DataView(blob) - if (png.isPng(view, 0)) { - const physChunk = png.findChunk(view, 'pHYs') - if (physChunk) { - const physData = png.parsePhys(view, physChunk.dataOffset) - if (physData.unit === 0 && physData.ppux === physData.ppuy) { - const pixelRatio = Math.round(physData.ppux / 2834.5) - resolve({ w: img.width / pixelRatio, h: img.height / pixelRatio }) - return - } - } - } - - resolve({ w: img.width, h: img.height }) - } catch (err) { - console.error(err) - resolve({ w: img.width, h: img.height }) - } - } - img.onerror = (err) => { - console.error(err) - reject(new Error('Could not get image size')) - } - img.crossOrigin = 'anonymous' - img.src = dataURL - }) -} diff --git a/packages/tldraw/src/lib/utils/export.ts b/packages/tldraw/src/lib/utils/export.ts index 632b29635..0984ae988 100644 --- a/packages/tldraw/src/lib/utils/export.ts +++ b/packages/tldraw/src/lib/utils/export.ts @@ -1,4 +1,4 @@ -import { debugFlags, png } from '@tldraw/editor' +import { PngHelpers, debugFlags } from '@tldraw/editor' import { getBrowserCanvasMaxSize } from '../shapes/shared/getBrowserCanvasMaxSize' /** @public */ @@ -110,7 +110,7 @@ export async function getSvgAsImage( if (!blob) return null const view = new DataView(await blob.arrayBuffer()) - return png.setPhysChunk(view, effectiveScale, { + return PngHelpers.setPhysChunk(view, effectiveScale, { type: 'image/' + type, }) } diff --git a/packages/tldraw/src/lib/utils/file.ts b/packages/tldraw/src/lib/utils/file.ts index 025cd1041..c5f80ba90 100644 --- a/packages/tldraw/src/lib/utils/file.ts +++ b/packages/tldraw/src/lib/utils/file.ts @@ -1,5 +1,6 @@ import { Editor, + FileHelpers, MigrationFailureReason, MigrationResult, RecordId, @@ -151,30 +152,6 @@ export function parseTldrawJsonFile({ } } -/** - * Convert a file to base64. - * - * @example - * - * ```ts - * const A = fileToBase64('./test.png') - * ``` - * - * @param value - The file as a blob. - * @public - */ -function fileToBase64(file: Blob): Promise { - return new Promise((resolve, reject) => { - if (file) { - const reader = new FileReader() - reader.readAsDataURL(file) - reader.onload = () => resolve(reader.result as string) - reader.onerror = (error) => reject(error) - reader.onabort = (error) => reject(error) - } - }) -} - /** @public */ export async function serializeTldrawJson(store: TLStore): Promise { const records: TLRecord[] = [] @@ -191,7 +168,9 @@ export async function serializeTldrawJson(store: TLStore): Promise { let assetSrcToSave try { // try to save the asset as a base64 string - assetSrcToSave = await fileToBase64(await (await fetch(record.props.src)).blob()) + assetSrcToSave = await FileHelpers.fileToBase64( + await (await fetch(record.props.src)).blob() + ) } catch { // if that fails, just save the original src assetSrcToSave = record.props.src diff --git a/packages/utils/api-report.md b/packages/utils/api-report.md index 562898268..734a7570c 100644 --- a/packages/utils/api-report.md +++ b/packages/utils/api-report.md @@ -42,6 +42,13 @@ export type Expand = T extends infer O ? { [K in keyof O]: O[K]; } : never; +// @public +export class FileHelpers { + // @internal (undocumented) + static base64ToFile(dataURL: string): Promise; + static fileToBase64(file: Blob): Promise; +} + // @internal export function filterEntries(object: { [K in Key]: Value; @@ -112,6 +119,18 @@ export function mapObjectMapValues( [K in Key]: ValueAfter; }; +// @public +export class MediaHelpers { + static getImageSizeFromSrc(dataURL: string): Promise<{ + w: number; + h: number; + }>; + static getVideoSizeFromSrc(src: string): Promise<{ + w: number; + h: number; + }>; +} + // @internal (undocumented) export function minBy(arr: readonly T[], fn: (item: T) => number): T | undefined; @@ -153,6 +172,34 @@ export function omitFromStackTrace, Return>(fn: (... // @internal export function partition(arr: T[], predicate: (item: T) => boolean): [T[], T[]]; +// @public (undocumented) +export class PngHelpers { + // (undocumented) + static findChunk(view: DataView, type: string): { + dataOffset: number; + size: number; + start: number; + }; + // (undocumented) + static getChunkType(view: DataView, offset: number): string; + // (undocumented) + static isPng(view: DataView, offset: number): boolean; + // (undocumented) + static parsePhys(view: DataView, offset: number): { + ppux: number; + ppuy: number; + unit: number; + }; + // (undocumented) + static readChunks(view: DataView, offset?: number): Record; + // (undocumented) + static setPhysChunk(view: DataView, dpr?: number, options?: BlobPropertyBag): Blob; +} + // @internal (undocumented) export function promiseWithResolve(): Promise & { resolve: (value: T) => void; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 7012923cf..5d5954b95 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -10,10 +10,12 @@ export { } from './lib/control' export { debounce } from './lib/debounce' export { annotateError, getErrorAnnotations } from './lib/error' +export { FileHelpers } from './lib/file' export { noop, omitFromStackTrace, throttle } from './lib/function' export { getHashForObject, getHashForString, lns } from './lib/hash' export { getFirstFromIterable } from './lib/iterable' export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from './lib/json-value' +export { MediaHelpers } from './lib/media' export { lerp, modulate, rng } from './lib/number' export { deepCopy, @@ -26,6 +28,7 @@ export { objectMapKeys, objectMapValues, } from './lib/object' +export { PngHelpers } from './lib/png' export { rafThrottle, throttledRaf } from './lib/raf' export { sortById } from './lib/sort' export type { Expand, RecursivePartial, Required } from './lib/types' diff --git a/packages/utils/src/lib/file.ts b/packages/utils/src/lib/file.ts new file mode 100644 index 000000000..58c05785f --- /dev/null +++ b/packages/utils/src/lib/file.ts @@ -0,0 +1,42 @@ +/** + * Helpers for files + * + * @public + */ +export class FileHelpers { + /** + * @param dataURL - The file as a string. + * @internal + * + * from https://stackoverflow.com/a/53817185 + */ + static async base64ToFile(dataURL: string) { + return fetch(dataURL).then(function (result) { + return result.arrayBuffer() + }) + } + + /** + * Convert a file to base64. + * + * @example + * + * ```ts + * const A = fileToBase64('./test.png') + * ``` + * + * @param value - The file as a blob. + * @public + */ + static async fileToBase64(file: Blob): Promise { + return await new Promise((resolve, reject) => { + if (file) { + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = () => resolve(reader.result as string) + reader.onerror = (error) => reject(error) + reader.onabort = (error) => reject(error) + } + }) + } +} diff --git a/packages/utils/src/lib/media.ts b/packages/utils/src/lib/media.ts new file mode 100644 index 000000000..6315255b7 --- /dev/null +++ b/packages/utils/src/lib/media.ts @@ -0,0 +1,68 @@ +import { FileHelpers } from './file' +import { PngHelpers } from './png' + +/** + * Helpers for media + * + * @public + */ +export class MediaHelpers { + /** + * Get the size of a video from its source. + * + * @param src - The source of the video. + * @public + */ + static async getVideoSizeFromSrc(src: string): Promise<{ w: number; h: number }> { + return await new Promise((resolve, reject) => { + const video = document.createElement('video') + video.onloadeddata = () => resolve({ w: video.videoWidth, h: video.videoHeight }) + video.onerror = (e) => { + console.error(e) + reject(new Error('Could not get video size')) + } + video.crossOrigin = 'anonymous' + video.src = src + }) + } + + /** + * Get the size of an image from its source. + * + * @param dataURL - The file as a string. + * @public + */ + static async getImageSizeFromSrc(dataURL: string): Promise<{ w: number; h: number }> { + return await new Promise((resolve, reject) => { + const img = new Image() + img.onload = async () => { + try { + const blob = await FileHelpers.base64ToFile(dataURL) + const view = new DataView(blob) + if (PngHelpers.isPng(view, 0)) { + const physChunk = PngHelpers.findChunk(view, 'pHYs') + if (physChunk) { + const physData = PngHelpers.parsePhys(view, physChunk.dataOffset) + if (physData.unit === 0 && physData.ppux === physData.ppuy) { + const pixelRatio = Math.round(physData.ppux / 2834.5) + resolve({ w: img.width / pixelRatio, h: img.height / pixelRatio }) + return + } + } + } + + resolve({ w: img.width, h: img.height }) + } catch (err) { + console.error(err) + resolve({ w: img.width, h: img.height }) + } + } + img.onerror = (err) => { + console.error(err) + reject(new Error('Could not get image size')) + } + img.crossOrigin = 'anonymous' + img.src = dataURL + }) + } +} diff --git a/packages/editor/src/lib/utils/png.ts b/packages/utils/src/lib/png.ts similarity index 58% rename from packages/editor/src/lib/utils/png.ts rename to packages/utils/src/lib/png.ts index 3746e85e4..b1bbd5703 100644 --- a/packages/editor/src/lib/utils/png.ts +++ b/packages/utils/src/lib/png.ts @@ -54,125 +54,119 @@ const crc: CRCCalculator = (current, previous) => { return crc ^ -1 } -function 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 -} - -function 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('') -} - const LEN_SIZE = 4 const CRC_SIZE = 4 -function readChunks(view: DataView, offset = 0) { - const chunks: Record = {} - if (!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 = 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 -} - -function parsePhys(view: DataView, offset: number) { - return { - ppux: view.getUint32(offset), - ppuy: view.getUint32(offset + 4), - unit: view.getUint8(offset + 4), - } -} - -function findChunk(view: DataView, type: string) { - const chunks = readChunks(view) - return chunks[type] -} - -function setPhysChunk(view: DataView, dpr = 1, options?: BlobPropertyBag) { - let offset = 46 - let size = 0 - const res1 = findChunk(view, 'pHYs') - if (res1) { - offset = res1.start - size = res1.size - } - - const res2 = 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_96 = 2835.5 - - pHYsDataView.setInt32(8, DPI_96 * dpr) - pHYsDataView.setInt32(12, DPI_96 * 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, pHYsData, endBuf], options) -} - /** @public */ -export const png = { - isPng, - readChunks, - parsePhys, - findChunk, - setPhysChunk, +export class PngHelpers { + 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 + } + + 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('') + } + + static readChunks(view: DataView, offset = 0) { + const chunks: Record = {} + 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 + } + + static parsePhys(view: DataView, offset: number) { + return { + ppux: view.getUint32(offset), + ppuy: view.getUint32(offset + 4), + unit: view.getUint8(offset + 4), + } + } + + static findChunk(view: DataView, type: string) { + const chunks = PngHelpers.readChunks(view) + return chunks[type] + } + + 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_96 = 2835.5 + + pHYsDataView.setInt32(8, DPI_96 * dpr) + pHYsDataView.setInt32(12, DPI_96 * 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, pHYsData, endBuf], options) + } }