Tldraw/packages/editor/src/lib/utils/assets.ts

586 wiersze
14 KiB
TypeScript

import { Box2d, Vec2d, VecLike } from '@tldraw/primitives'
import {
AssetRecordType,
TLAsset,
TLAssetId,
TLBookmarkAsset,
TLImageShape,
TLShapePartial,
TLVideoShape,
Vec2dModel,
createShapeId,
} from '@tldraw/tlschema'
import { compact, getHashForString } from '@tldraw/utils'
import uniq from 'lodash.uniq'
import { MAX_ASSET_HEIGHT, MAX_ASSET_WIDTH } from '../constants'
import { Editor } from '../editor/Editor'
import { isAnimated } from './is-gif-animated'
import { findChunk, isPng, parsePhys } from './png'
/** @public */
export const ACCEPTED_IMG_TYPE = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml']
/** @public */
export const ACCEPTED_VID_TYPE = ['video/mp4', 'video/quicktime']
/** @public */
export const ACCEPTED_ASSET_TYPE = ACCEPTED_IMG_TYPE.concat(ACCEPTED_VID_TYPE).join(', ')
/** @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
})
}
/**
* @param dataURL - The file as a string.
* @internal
*
* from https://stackoverflow.com/a/53817185
*/
export async function base64ToFile(dataURL: string) {
return fetch(dataURL).then(function (result) {
return result.arrayBuffer()
})
}
/**
* 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 (isPng(view, 0)) {
const physChunk = findChunk(view, 'pHYs')
if (physChunk) {
const physData = 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
})
}
/**
* Get the size of an image from its source.
*
* @param dataURLForImage - The image file as a string.
* @param width - The desired width.
* @param height - The desired height.
* @public
*/
export async function getResizedImageDataUrl(
dataURLForImage: string,
width: number,
height: number
): Promise<string> {
return await new Promise((resolve) => {
const img = new Image()
img.onload = () => {
// Initialize the canvas and it's size
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return
// Set width and height
canvas.width = width * 2
canvas.height = height * 2
// Draw image and export to a data-uri
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
const newDataURL = canvas.toDataURL()
// Do something with the result, like overwrite original
resolve(newDataURL)
}
img.crossOrigin = 'anonymous'
img.src = dataURLForImage
})
}
/**
* Get an asset from a file.
*
* @param file - The file.
* @returns An image or video asset partial.
* @public
*/
export async function getMediaAssetFromFile(file: File): Promise<TLAsset> {
return await new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error)
reader.onload = async () => {
let dataUrl = reader.result as string
const isImageType = isImage(file.type)
const sizeFn = isImageType ? getImageSizeFromSrc : getVideoSizeFromSrc
// Hack to make .mov videos work via dataURL.
if (file.type === 'video/quicktime' && dataUrl.includes('video/quicktime')) {
dataUrl = dataUrl.replace('video/quicktime', 'video/mp4')
}
const originalSize = await sizeFn(dataUrl)
const size = containBoxSize(originalSize, { w: MAX_ASSET_WIDTH, h: MAX_ASSET_HEIGHT })
if (size !== originalSize && (file.type === 'image/jpeg' || file.type === 'image/png')) {
// If we created a new size and the type is an image, rescale the image
dataUrl = await getResizedImageDataUrl(dataUrl, size.w, size.h)
}
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(dataUrl))
const metadata = await getFileMetaData(file)
const asset: TLAsset = {
id: assetId,
type: isImageType ? 'image' : 'video',
typeName: 'asset',
props: {
name: file.name,
src: dataUrl,
w: size.w,
h: size.h,
mimeType: file.type,
isAnimated: metadata.isAnimated,
},
}
resolve(asset)
}
reader.readAsDataURL(file)
})
}
/**
* Get some metadata about the file
*
* @param file - The file.
* @public
*/
export async function getFileMetaData(file: File): Promise<{ isAnimated: boolean }> {
if (file.type === 'image/gif') {
return await new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error)
reader.onload = () => {
resolve({
isAnimated: reader.result ? isAnimated(reader.result as ArrayBuffer) : false,
})
}
reader.readAsArrayBuffer(file)
})
}
return {
isAnimated: isImage(file.type) ? false : true,
}
}
type BoxWidthHeight = {
w: number
h: number
}
/**
* Contains the size within the given box size
*
* @param originalSize - The size of the asset
* @param containBoxSize - The container size
* @returns Adjusted size
* @public
*/
export function containBoxSize(
originalSize: BoxWidthHeight,
containBoxSize: BoxWidthHeight
): BoxWidthHeight {
const overByXScale = originalSize.w / containBoxSize.w
const overByYScale = originalSize.h / containBoxSize.h
if (overByXScale <= 1 && overByYScale <= 1) {
return originalSize
} else if (overByXScale > overByYScale) {
return {
w: originalSize.w / overByXScale,
h: originalSize.h / overByXScale,
}
} else {
return {
w: originalSize.w / overByYScale,
h: originalSize.h / overByYScale,
}
}
}
/** @public */
export async function createShapesFromFiles(
editor: Editor,
files: File[],
position: VecLike,
_ignoreParent = false
) {
const pagePoint = new Vec2d(position.x, position.y)
const newAssetsForFiles = new Map<File, TLAsset>()
const shapePartials = await Promise.all(
files.map(async (file, i) => {
// Use mime type instead of file ext, this is because
// window.navigator.clipboard does not preserve file names
// of copied files.
if (!file.type) throw new Error('No mime type')
// We can only accept certain extensions (either images or a videos)
if (!ACCEPTED_IMG_TYPE.concat(ACCEPTED_VID_TYPE).includes(file.type)) {
console.warn(`${file.name} not loaded - Extension not allowed.`)
return null
}
try {
const asset = await editor.onCreateAssetFromFile(file)
if (asset.type === 'bookmark') return
if (!asset) throw Error('Could not create an asset')
newAssetsForFiles.set(file, asset)
const shapePartial: TLShapePartial<TLImageShape | TLVideoShape> = {
id: createShapeId(),
type: asset.type,
x: pagePoint.x + i,
y: pagePoint.y,
props: {
w: asset.props!.w,
h: asset.props!.h,
},
}
return shapePartial
} catch (error) {
console.error(error)
return null
}
})
)
// Filter any nullish values and sort the resulting models by x, so that the
// left-most model is created first (and placed lowest in the z-order).
const results = compact(shapePartials).sort((a, b) => a.x! - b.x!)
if (results.length === 0) return
// Adjust the placement of the models.
for (let i = 0; i < results.length; i++) {
const model = results[i]
if (i === 0) {
// The first shape is placed so that its center is at the dropping point
model.x! -= model.props!.w! / 2
model.y! -= model.props!.h! / 2
} else {
// Later models are placed to the right of the first shape
const prevModel = results[i - 1]
model.x = prevModel.x! + prevModel.props!.w!
model.y = prevModel.y!
}
}
const shapeUpdates = await Promise.all(
files.map(async (file, i) => {
const shape = results[i]
if (!shape) return
const asset = newAssetsForFiles.get(file)
if (!asset) return
// Does the asset collection already have a model with this id
let existing: TLAsset | undefined = editor.getAssetById(asset.id)
if (existing) {
newAssetsForFiles.delete(file)
if (shape.props) {
shape.props.assetId = existing.id
}
return shape
}
existing = editor.getAssetBySrc(asset.props!.src!)
if (existing) {
if (shape.props) {
shape.props.assetId = existing.id
}
return shape
}
// Create a new model for the new source file
if (shape.props) {
shape.props.assetId = asset.id
}
return shape
})
)
const filteredUpdates = compact(shapeUpdates)
editor.createAssets(compact([...newAssetsForFiles.values()]))
editor.createShapes(filteredUpdates)
editor.setSelectedIds(filteredUpdates.map((s) => s.id))
const { selectedIds, viewportPageBounds } = editor
const pageBounds = Box2d.Common(compact(selectedIds.map((id) => editor.getPageBoundsById(id))))
if (pageBounds && !viewportPageBounds.contains(pageBounds)) {
editor.zoomToSelection()
}
}
/** @public */
export function createEmbedShapeAtPoint(
editor: Editor,
url: string,
point: Vec2dModel,
props: {
width?: number
height?: number
doesResize?: boolean
}
) {
editor.createShapes(
[
{
id: createShapeId(),
type: 'embed',
x: point.x - (props.width || 450) / 2,
y: point.y - (props.height || 450) / 2,
props: {
w: props.width,
h: props.height,
doesResize: props.doesResize,
url,
},
},
],
true
)
}
/**
* Create a bookmark shape at a given point.
*
* @param editor - The editor to create the bookmark shape in.
* @param url - The bookmark's url.
* @param point - The point to insert the bookmark shape.
* @public
*/
export async function createBookmarkShapeAtPoint(editor: Editor, url: string, point: Vec2dModel) {
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
const existing = editor.getAssetById(assetId) as TLBookmarkAsset
if (existing) {
editor.createShapes([
{
id: createShapeId(),
type: 'bookmark',
x: point.x - 150,
y: point.y - 160,
opacity: 1,
props: {
assetId: existing.id,
url: existing.props.src!,
},
},
])
return
}
editor.batch(async () => {
const shapeId = createShapeId()
editor.createShapes(
[
{
id: shapeId,
type: 'bookmark',
x: point.x,
y: point.y,
opacity: 1,
props: {
url: url,
},
},
],
true
)
const meta = await editor.onCreateBookmarkFromUrl(url)
if (meta) {
editor.createAssets([
{
id: assetId,
typeName: 'asset',
type: 'bookmark',
props: {
src: url,
description: meta.description,
image: meta.image,
title: meta.title,
},
},
])
editor.updateShapes([
{
id: shapeId,
type: 'bookmark',
opacity: 1,
props: {
assetId: assetId,
},
},
])
}
})
}
/** @public */
export async function createAssetShapeAtPoint(
editor: Editor,
svgString: string,
point: Vec2dModel
) {
const svg = new DOMParser().parseFromString(svgString, 'image/svg+xml').querySelector('svg')
if (!svg) {
throw new Error('No <svg/> element present')
}
let width = parseFloat(svg.getAttribute('width') || '0')
let height = parseFloat(svg.getAttribute('height') || '0')
if (!(width && height)) {
document.body.appendChild(svg)
const box = svg.getBoundingClientRect()
document.body.removeChild(svg)
width = box.width
height = box.height
}
const asset = await editor.onCreateAssetFromFile(
new File([svgString], 'asset.svg', { type: 'image/svg+xml' })
)
if (asset.type !== 'bookmark') {
asset.props.w = width
asset.props.h = height
}
editor.batch(() => {
editor.createAssets([asset])
editor.createShapes(
[
{
id: createShapeId(),
type: 'image',
x: point.x - width / 2,
y: point.y - height / 2,
opacity: 1,
props: {
assetId: asset.id,
w: width,
h: height,
},
},
],
true
)
})
}
/** @public */
export const isValidHttpURL = (url: string) => {
try {
const u = new URL(url)
return u.protocol === 'http:' || u.protocol === 'https:'
} catch (e) {
return false
}
}
/** @public */
export const getValidHttpURLList = (url: string) => {
const urls = url.split(/[\n\s]/)
for (const url of urls) {
try {
const u = new URL(url)
if (!(u.protocol === 'http:' || u.protocol === 'https:')) {
return
}
} catch (e) {
return
}
}
return uniq(urls)
}
/** @public */
export const isSvgText = (text: string) => {
return /^<svg/.test(text)
}
/** @public */
export function dataUrlToFile(url: string, filename: string, mimeType: string) {
return fetch(url)
.then(function (res) {
return res.arrayBuffer()
})
.then(function (buf) {
return new File([buf], filename, { type: mimeType })
})
}