import { AssetRecordType, Editor, FileHelpers, MediaHelpers, TLAsset, TLAssetId, TLBookmarkShape, TLEmbedShape, TLShapeId, TLShapePartial, TLTextShape, TLTextShapeProps, Vec, VecLike, assert, compact, createShapeId, getHashForBuffer, getHashForString, } from '@tldraw/editor' import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from './shapes/shared/default-shape-constants' import { TLUiToastsContextType } from './ui/context/toasts' import { useTranslation } from './ui/hooks/useTranslation/useTranslation' import { containBoxSize, downsizeImage, isGifAnimated } from './utils/assets/assets' import { getEmbedInfo } from './utils/embeds/embeds' import { cleanupText, isRightToLeftLanguage, truncateStringWithEllipsis } from './utils/text/text' /** @public */ export type TLExternalContentProps = { // The maximum dimension (width or height) of an image. Images larger than this will be rescaled to fit. Defaults to infinity. maxImageDimension: number // The maximum size (in bytes) of an asset. Assets larger than this will be rejected. Defaults to 10mb (10 * 1024 * 1024). maxAssetSize: number // The mime types of images that are allowed to be handled. Defaults to ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml']. acceptedImageMimeTypes: readonly string[] // The mime types of videos that are allowed to be handled. Defaults to ['video/mp4', 'video/webm', 'video/quicktime']. acceptedVideoMimeTypes: readonly string[] } export function registerDefaultExternalContentHandlers( editor: Editor, { maxImageDimension, maxAssetSize, acceptedImageMimeTypes, acceptedVideoMimeTypes, }: TLExternalContentProps, { toasts, msg }: { toasts: TLUiToastsContextType; msg: ReturnType } ) { // files -> asset editor.registerExternalAssetHandler('file', async ({ file: _file }) => { const name = _file.name let file: Blob = _file const isImageType = acceptedImageMimeTypes.includes(file.type) const isVideoType = acceptedVideoMimeTypes.includes(file.type) assert(isImageType || isVideoType, `File type not allowed: ${file.type}`) assert( file.size <= maxAssetSize, `File size too big: ${(file.size / 1024).toFixed()}kb > ${(maxAssetSize / 1024).toFixed()}kb` ) if (file.type === 'video/quicktime') { // hack to make .mov videos work file = new Blob([file], { type: 'video/mp4' }) } let size = isImageType ? await MediaHelpers.getImageSize(file) : await MediaHelpers.getVideoSize(file) const isAnimated = file.type === 'image/gif' ? await isGifAnimated(file) : isVideoType const hash = await getHashForBuffer(await file.arrayBuffer()) if (isFinite(maxImageDimension)) { const resizedSize = containBoxSize(size, { w: maxImageDimension, h: maxImageDimension }) if (size !== resizedSize && (file.type === 'image/jpeg' || file.type === 'image/png')) { size = resizedSize } } // Always rescale the image if (file.type === 'image/jpeg' || file.type === 'image/png') { file = await downsizeImage(file, size.w, size.h, { type: file.type, quality: 0.92, }) } const assetId: TLAssetId = AssetRecordType.createId(hash) const asset = AssetRecordType.create({ id: assetId, type: isImageType ? 'image' : 'video', typeName: 'asset', props: { name, src: await FileHelpers.blobToDataUrl(file), w: size.w, h: size.h, mimeType: file.type, isAnimated, }, }) return asset }) // urls -> bookmark asset editor.registerExternalAssetHandler('url', async ({ url }) => { let meta: { image: string; title: string; description: string } try { const resp = await fetch(url, { method: 'GET', mode: 'no-cors' }) const html = await resp.text() const doc = new DOMParser().parseFromString(html, 'text/html') meta = { image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '', title: doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? truncateStringWithEllipsis(url, 32), description: doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '', } } catch (error) { console.error(error) toasts.addToast({ title: msg('assets.url.failed'), severity: 'error', }) meta = { image: '', title: truncateStringWithEllipsis(url, 32), description: '' } } // Create the bookmark asset from the meta return { id: AssetRecordType.createId(getHashForString(url)), typeName: 'asset', type: 'bookmark', props: { src: url, description: meta.description, image: meta.image, title: meta.title, }, meta: {}, } }) // svg text editor.registerExternalContentHandler('svg-text', async ({ point, text }) => { const position = point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageCenter()) const svg = new DOMParser().parseFromString(text, 'image/svg+xml').querySelector('svg') if (!svg) { throw new Error('No 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.getAssetForExternalContent({ type: 'file', file: new File([text], 'asset.svg', { type: 'image/svg+xml' }), }) if (!asset) throw Error('Could not create an asset') createShapesForAssets(editor, [asset], position) }) // embeds editor.registerExternalContentHandler('embed', ({ point, url, embed }) => { const position = point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageCenter()) const { width, height } = embed const id = createShapeId() const shapePartial: TLShapePartial = { id, type: 'embed', x: position.x - (width || 450) / 2, y: position.y - (height || 450) / 2, props: { w: width, h: height, url, }, } editor.createShapes([shapePartial]).select(id) }) // files editor.registerExternalContentHandler('files', async ({ point, files }) => { const position = point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageCenter()) const pagePoint = new Vec(position.x, position.y) const assets: TLAsset[] = [] await Promise.all( files.map(async (file, i) => { if (file.size > maxAssetSize) { console.warn( `File size too big: ${(file.size / 1024).toFixed()}kb > ${( maxAssetSize / 1024 ).toFixed()}kb` ) return null } // 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 (!acceptedImageMimeTypes.concat(acceptedVideoMimeTypes).includes(file.type)) { console.warn(`${file.name} not loaded - Extension not allowed.`) return null } try { const asset = await editor.getAssetForExternalContent({ type: 'file', file }) if (!asset) { throw Error('Could not create an asset') } assets[i] = asset } catch (error) { toasts.addToast({ title: msg('assets.files.upload-failed'), severity: 'error', }) console.error(error) return null } }) ) createShapesForAssets(editor, compact(assets), pagePoint) }) // text editor.registerExternalContentHandler('text', async ({ point, text }) => { const p = point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageCenter()) const defaultProps = editor.getShapeUtil('text').getDefaultProps() const textToPaste = cleanupText(text) // Measure the text with default values let w: number let h: number let autoSize: boolean let align = 'middle' as TLTextShapeProps['align'] const isMultiLine = textToPaste.split('\n').length > 1 // check whether the text contains the most common characters in RTL languages const isRtl = isRightToLeftLanguage(textToPaste) if (isMultiLine) { align = isMultiLine ? (isRtl ? 'end' : 'start') : 'middle' } const rawSize = editor.textMeasure.measureText(textToPaste, { ...TEXT_PROPS, fontFamily: FONT_FAMILIES[defaultProps.font], fontSize: FONT_SIZES[defaultProps.size], maxWidth: null, }) const minWidth = Math.min( isMultiLine ? editor.getViewportPageBounds().width * 0.9 : 920, Math.max(200, editor.getViewportPageBounds().width * 0.9) ) if (rawSize.w > minWidth) { const shrunkSize = editor.textMeasure.measureText(textToPaste, { ...TEXT_PROPS, fontFamily: FONT_FAMILIES[defaultProps.font], fontSize: FONT_SIZES[defaultProps.size], maxWidth: minWidth, }) w = shrunkSize.w h = shrunkSize.h autoSize = false align = isRtl ? 'end' : 'start' } else { // autosize is fine w = rawSize.w h = rawSize.h autoSize = true } if (p.y - h / 2 < editor.getViewportPageBounds().minY + 40) { p.y = editor.getViewportPageBounds().minY + 40 + h / 2 } editor.createShapes([ { id: createShapeId(), type: 'text', x: p.x - w / 2, y: p.y - h / 2, props: { text: textToPaste, // if the text has more than one line, align it to the left align, autoSize, w, }, }, ]) }) // url editor.registerExternalContentHandler('url', async ({ point, url }) => { // try to paste as an embed first const embedInfo = getEmbedInfo(url) if (embedInfo) { return editor.putExternalContent({ type: 'embed', url: embedInfo.url, point, embed: embedInfo.definition, }) } const position = point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageCenter()) const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url)) const shape = createEmptyBookmarkShape(editor, url, position) // Use an existing asset if we have one, or else else create a new one let asset = editor.getAsset(assetId) as TLAsset let shouldAlsoCreateAsset = false if (!asset) { shouldAlsoCreateAsset = true try { const bookmarkAsset = await editor.getAssetForExternalContent({ type: 'url', url }) if (!bookmarkAsset) throw Error('Could not create an asset') asset = bookmarkAsset } catch (e) { toasts.addToast({ title: msg('assets.url.failed'), severity: 'error', }) return } } editor.batch(() => { if (shouldAlsoCreateAsset) { editor.createAssets([asset]) } editor.updateShapes([ { id: shape.id, type: shape.type, props: { assetId: asset.id, }, }, ]) }) }) } export async function createShapesForAssets( editor: Editor, assets: TLAsset[], position: VecLike ): Promise { if (!assets.length) return [] const currentPoint = Vec.From(position) const partials: TLShapePartial[] = [] for (const asset of assets) { switch (asset.type) { case 'bookmark': { partials.push({ id: createShapeId(), type: 'bookmark', x: currentPoint.x - 150, y: currentPoint.y - 160, opacity: 1, props: { assetId: asset.id, url: asset.props.src, }, }) currentPoint.x += 300 break } case 'image': { partials.push({ id: createShapeId(), type: 'image', x: currentPoint.x - asset.props.w / 2, y: currentPoint.y - asset.props.h / 2, opacity: 1, props: { assetId: asset.id, w: asset.props.w, h: asset.props.h, }, }) currentPoint.x += asset.props.w break } case 'video': { partials.push({ id: createShapeId(), type: 'video', x: currentPoint.x - asset.props.w / 2, y: currentPoint.y - asset.props.h / 2, opacity: 1, props: { assetId: asset.id, w: asset.props.w, h: asset.props.h, }, }) currentPoint.x += asset.props.w } } } editor.batch(() => { // Create any assets const assetsToCreate = assets.filter((asset) => !editor.getAsset(asset.id)) if (assetsToCreate.length) { editor.createAssets(assetsToCreate) } // Create the shapes editor.createShapes(partials).select(...partials.map((p) => p.id)) // Re-position shapes so that the center of the group is at the provided point centerSelectionAroundPoint(editor, position) }) return partials.map((p) => p.id) } function centerSelectionAroundPoint(editor: Editor, position: VecLike) { // Re-position shapes so that the center of the group is at the provided point const viewportPageBounds = editor.getViewportPageBounds() let selectionPageBounds = editor.getSelectionPageBounds() if (selectionPageBounds) { const offset = selectionPageBounds!.center.sub(position) editor.updateShapes( editor.getSelectedShapes().map((shape) => { const localRotation = editor.getShapeParentTransform(shape).decompose().rotation const localDelta = Vec.Rot(offset, -localRotation) return { id: shape.id, type: shape.type, x: shape.x! - localDelta.x, y: shape.y! - localDelta.y, } }) ) } // Zoom out to fit the shapes, if necessary selectionPageBounds = editor.getSelectionPageBounds() if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) { editor.zoomToSelection() } } export function createEmptyBookmarkShape( editor: Editor, url: string, position: VecLike ): TLBookmarkShape { const partial: TLShapePartial = { id: createShapeId(), type: 'bookmark', x: position.x - 150, y: position.y - 160, opacity: 1, props: { assetId: null, url, }, } editor.batch(() => { editor.createShapes([partial]).select(partial.id) centerSelectionAroundPoint(editor, position) }) return editor.getShape(partial.id) as TLBookmarkShape }