move some utils into tldraw/utils (#1750)

This PR moves certain shared utilities (for images, etc.) to
@tldraw/utils.

### Change Type

- [x] `major` — Breaking change
This commit is contained in:
Steve Ruiz 2023-07-19 11:50:40 +01:00 committed by GitHub
parent f1f706f0b6
commit 6309cbe6a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 283 additions and 249 deletions

View file

@ -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;

View file

@ -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,

View file

@ -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;

View file

@ -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 {

View file

@ -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')) {

View file

@ -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])
}

View file

@ -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
})
}

View file

@ -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,
})
}

View file

@ -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<string> {
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<string> {
const records: TLRecord[] = []
@ -191,7 +168,9 @@ export async function serializeTldrawJson(store: TLStore): Promise<string> {
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

View file

@ -42,6 +42,13 @@ export type Expand<T> = T extends infer O ? {
[K in keyof O]: O[K];
} : never;
// @public
export class FileHelpers {
// @internal (undocumented)
static base64ToFile(dataURL: string): Promise<ArrayBuffer>;
static fileToBase64(file: Blob): Promise<string>;
}
// @internal
export function filterEntries<Key extends string, Value>(object: {
[K in Key]: Value;
@ -112,6 +119,18 @@ export function mapObjectMapValues<Key extends string, ValueBefore, ValueAfter>(
[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<T>(arr: readonly T[], fn: (item: T) => number): T | undefined;
@ -153,6 +172,34 @@ export function omitFromStackTrace<Args extends Array<unknown>, Return>(fn: (...
// @internal
export function partition<T>(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<string, {
dataOffset: number;
size: number;
start: number;
}>;
// (undocumented)
static setPhysChunk(view: DataView, dpr?: number, options?: BlobPropertyBag): Blob;
}
// @internal (undocumented)
export function promiseWithResolve<T>(): Promise<T> & {
resolve: (value: T) => void;

View file

@ -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'

View file

@ -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<string> {
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)
}
})
}
}

View file

@ -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
})
}
}

View file

@ -54,125 +54,119 @@ const crc: CRCCalculator<Uint8Array> = (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<string, { dataOffset: number; size: number; start: number }> = {}
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<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
}
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)
}
}