// oxlint-disable typescript/no-empty-object-type import { BaseBoxShapeUtil, Editor, Ellipse2d, FileHelpers, Geometry2d, HTMLContainer, Image, MediaHelpers, Rectangle2d, SvgExportContext, TLAsset, TLAssetId, TLImageAsset, TLImageShape, TLImageShapeProps, TLResizeInfo, TLShapePartial, Vec, VecModel, WeakCache, createShapeId, fetch, getGlobalDocument, imageShapeMigrations, imageShapeProps, lerp, modulate, resizeBox, structuredClone, useEditor, useUniqueSafeId, useValue, } from '@tldraw/editor' import classNames from 'classnames' import { memo, useEffect, useState } from 'react' import { BrokenAssetIcon } from '../shared/BrokenAssetIcon' import { getUncroppedSize } from '../shared/crop' import type { ShapeOptionsWithDisplayValues } from '../shared/getDisplayValues' import { HyperlinkButton } from '../shared/HyperlinkButton' import { useImageOrVideoAsset } from '../shared/useImageOrVideoAsset' import { usePrefersReducedMotion } from '../shared/usePrefersReducedMotion' import { TRANSPARENT_IMAGE_MIMETYPES, getAlphaData, preloadAlphaData } from './ImageAlphaCache' import { ImageEllipse2d, ImageRectangle2d } from './ImageAlphaGeometry' async function getDataURIFromURL(url: string): Promise { const response = await fetch(url) const blob = await response.blob() return FileHelpers.blobToDataUrl(blob) } const imageSvgExportCache = new WeakCache>() /** @public */ export interface ImageShapeUtilDisplayValues {} /** @public */ export interface ImageShapeOptions extends ShapeOptionsWithDisplayValues< TLImageShape, ImageShapeUtilDisplayValues > {} /** @public */ export class ImageShapeUtil extends BaseBoxShapeUtil { static override type = 'image' as const static override props = imageShapeProps static override migrations = imageShapeMigrations static override handledAssetTypes = ['image'] as const override options: ImageShapeOptions = { getDefaultDisplayValues(): ImageShapeUtilDisplayValues { return {} }, getCustomDisplayValues(): Partial { return {} }, } override isAspectRatioLocked(shape: TLImageShape) { return true } override canCrop(shape: TLImageShape) { return true } override isExportBoundsContainer(): boolean { return true } override getDefaultProps(): TLImageShape['props'] { return { w: 100, h: 100, assetId: null, playing: true, url: '', crop: null, flipX: false, flipY: false, altText: '', } } override createShapeForAsset(asset: TLAsset, position: VecModel): TLShapePartial | null { const imageAsset = asset as TLImageAsset return { id: createShapeId(), type: 'image', x: position.x, y: position.y, opacity: 1, props: { assetId: imageAsset.id, w: imageAsset.props.w, h: imageAsset.props.h, }, } } override getGeometry(shape: TLImageShape): Geometry2d { const asset = shape.props.assetId ? this.editor.getAsset(shape.props.assetId) : null const mimeType = asset && 'mimeType' in asset.props ? asset.props.mimeType : null const supportsTransparency = mimeType != null && TRANSPARENT_IMAGE_MIMETYPES.includes(mimeType) const assetSrc = asset && 'src' in asset.props ? asset.props.src : null if (shape.props.crop?.isCircle) { if (supportsTransparency && assetSrc) { const src = assetSrc return new ImageEllipse2d({ width: shape.props.w, height: shape.props.h, isFilled: true, alphaDataGetter: () => getAlphaData(src), crop: shape.props.crop, flipX: shape.props.flipX, flipY: shape.props.flipY, }) } return new Ellipse2d({ width: shape.props.w, height: shape.props.h, isFilled: true, }) } if (supportsTransparency && assetSrc) { const src = assetSrc return new ImageRectangle2d({ width: shape.props.w, height: shape.props.h, isFilled: true, alphaDataGetter: () => getAlphaData(src), crop: shape.props.crop, flipX: shape.props.flipX, flipY: shape.props.flipY, }) } return new Rectangle2d({ width: shape.props.w, height: shape.props.h, isFilled: true, }) } override getAriaDescriptor(shape: TLImageShape) { return shape.props.altText } override onResize(shape: TLImageShape, info: TLResizeInfo) { let resized: TLImageShape = resizeBox(shape, info) const { flipX, flipY } = info.initialShape.props const { scaleX, scaleY, mode } = info resized = { ...resized, props: { ...resized.props, flipX: scaleX < 0 !== flipX, flipY: scaleY < 0 !== flipY, }, } if (!shape.props.crop) return resized const flipCropHorizontally = // We used the flip horizontally feature (mode === 'scale_shape' && scaleX === -1) || // We resized the shape past it's bounds, so it flipped (mode === 'resize_bounds' && flipX !== resized.props.flipX) const flipCropVertically = // We used the flip vertically feature (mode === 'scale_shape' && scaleY === -1) || // We resized the shape past it's bounds, so it flipped (mode === 'resize_bounds' && flipY !== resized.props.flipY) const { topLeft, bottomRight } = shape.props.crop resized.props.crop = { topLeft: { x: flipCropHorizontally ? 1 - bottomRight.x : topLeft.x, y: flipCropVertically ? 1 - bottomRight.y : topLeft.y, }, bottomRight: { x: flipCropHorizontally ? 1 - topLeft.x : bottomRight.x, y: flipCropVertically ? 1 - topLeft.y : bottomRight.y, }, isCircle: shape.props.crop.isCircle, } return resized } component(shape: TLImageShape) { return } override getIndicatorPath(shape: TLImageShape): Path2D | undefined { if (this.editor.getCroppingShapeId() === shape.id) return undefined const path = new Path2D() if (shape.props.crop?.isCircle) { const cx = shape.props.w / 2 const cy = shape.props.h / 2 path.ellipse(cx, cy, cx, cy, 0, 0, Math.PI * 2) } else { path.rect(0, 0, shape.props.w, shape.props.h) } return path } override async toSvg(shape: TLImageShape, ctx: SvgExportContext) { const props = shape.props if (!props.assetId) return null const asset = this.editor.getAsset(props.assetId) if (!asset) return null const { w } = getUncroppedSize(shape.props, props.crop) const src = await imageSvgExportCache.get(asset, async () => { let src = await ctx.resolveAssetUrl(asset.id, w) if (!src) return null if ( src.startsWith('blob:') || src.startsWith('http') || src.startsWith('/') || src.startsWith('./') ) { // If it's a remote image, we need to fetch it and convert it to a data URI src = (await getDataURIFromURL(src)) || '' } // If it's animated then we need to get the first frame if (getIsAnimated(this.editor, asset.id)) { const { promise } = getFirstFrameOfAnimatedImage(src) src = await promise } return src }) if (!src) return null return } override onDoubleClickEdge(shape: TLImageShape) { const props = shape.props if (!props) return if (this.editor.getCroppingShapeId() !== shape.id) { return } const crop = structuredClone(props.crop) || { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, } // The true asset dimensions const { w, h } = getUncroppedSize(shape.props, crop) const pointDelta = new Vec(crop.topLeft.x * w, crop.topLeft.y * h).rot(shape.rotation) const partial: TLShapePartial = { id: shape.id, type: shape.type, x: shape.x - pointDelta.x, y: shape.y - pointDelta.y, props: { crop: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, }, w, h, }, } this.editor.updateShapes([partial]) } override getInterpolatedProps( startShape: TLImageShape, endShape: TLImageShape, t: number ): TLImageShapeProps { function interpolateCrop( startShape: TLImageShape, endShape: TLImageShape ): TLImageShapeProps['crop'] { if (startShape.props.crop === null && endShape.props.crop === null) return null const startTL = startShape.props.crop?.topLeft || { x: 0, y: 0 } const startBR = startShape.props.crop?.bottomRight || { x: 1, y: 1 } const endTL = endShape.props.crop?.topLeft || { x: 0, y: 0 } const endBR = endShape.props.crop?.bottomRight || { x: 1, y: 1 } return { topLeft: { x: lerp(startTL.x, endTL.x, t), y: lerp(startTL.y, endTL.y, t) }, bottomRight: { x: lerp(startBR.x, endBR.x, t), y: lerp(startBR.y, endBR.y, t) }, } } return { ...(t > 0.5 ? endShape.props : startShape.props), w: lerp(startShape.props.w, endShape.props.w, t), h: lerp(startShape.props.h, endShape.props.h, t), crop: interpolateCrop(startShape, endShape), } } } const ImageShape = memo(function ImageShape({ shape }: { shape: TLImageShape }) { const editor = useEditor() const { w } = getUncroppedSize(shape.props, shape.props.crop) const { asset, url } = useImageOrVideoAsset({ shapeId: shape.id, assetId: shape.props.assetId, width: w, }) const prefersReducedMotion = usePrefersReducedMotion() const [staticFrameSrc, setStaticFrameSrc] = useState('') const [loadedUrl, setLoadedUrl] = useState(null) const isAnimated = asset && getIsAnimated(editor, asset.id) useEffect(() => { if (url && isAnimated) { const { promise, cancel } = getFirstFrameOfAnimatedImage(url) promise.then((dataUrl) => { setStaticFrameSrc(dataUrl) setLoadedUrl(url) }) return () => { cancel() } } return undefined }, [editor, isAnimated, prefersReducedMotion, url]) const mimeType = asset && 'mimeType' in asset.props ? asset.props.mimeType : null const supportsTransparency = mimeType != null && TRANSPARENT_IMAGE_MIMETYPES.includes(mimeType) const assetSrc = asset && 'src' in asset.props ? asset.props.src : null useEffect(() => { if (url && supportsTransparency) { // Cache under asset.props.src so getGeometry (which only has the asset // record) can look up the data even when the resolved URL differs. preloadAlphaData(url, assetSrc ?? undefined) } }, [url, supportsTransparency, assetSrc]) const showCropPreview = useValue( 'show crop preview', () => shape.id === editor.getOnlySelectedShapeId() && editor.getCroppingShapeId() === shape.id && editor.isIn('select.crop'), [editor, shape.id] ) // We only want to reduce motion for mimeTypes that have motion const reduceMotion = prefersReducedMotion && (asset?.props.mimeType?.includes('video') || isAnimated) const containerStyle = getCroppedContainerStyle(shape) const nextSrc = url === loadedUrl ? null : url const loadedSrc = reduceMotion ? staticFrameSrc : loadedUrl // This logic path is for when it's broken/missing asset. if (!url && !asset?.props.src) { return (
{asset ? null : }
{'url' in shape.props && shape.props.url && }
) } // We don't set crossOrigin for non-animated images because for Cloudflare we don't currently // have that set up. const crossOrigin = isAnimated ? 'anonymous' : undefined return ( <> {showCropPreview && loadedSrc && (
)}
{/* We have two images: the currently loaded image, and the next image that we're waiting to load. we keep the loaded image mounted while we're waiting for the next one by storing the loaded URL in state. We use `key` props with the src of the image so that when the next image is ready, the previous one will be unmounted and the next will be shown with the browser having to remount a fresh image and decoded it again from the cache. */} {loadedSrc && ( {shape.props.altText} )} {nextSrc && ( {shape.props.altText} setLoadedUrl(nextSrc)} /> )}
{shape.props.url && }
) }) function getIsAnimated(editor: Editor, assetId: TLAssetId) { const asset = assetId ? editor.getAsset(assetId) : undefined if (!asset) return false return ( ('mimeType' in asset.props && MediaHelpers.isAnimatedImageType(asset?.props.mimeType)) || ('isAnimated' in asset.props && asset.props.isAnimated) ) } /** * When an image is cropped we need to translate the image to show the portion withing the cropped * area. We do this by translating the image by the negative of the top left corner of the crop * area. * * @param shape - Shape The image shape for which to get the container style * @returns - Styles to apply to the image container */ function getCroppedContainerStyle(shape: TLImageShape) { const crop = shape.props.crop const topLeft = crop?.topLeft if (!topLeft) { return { width: shape.props.w, height: shape.props.h, } } const { w, h } = getUncroppedSize(shape.props, crop) const offsetX = -topLeft.x * w const offsetY = -topLeft.y * h return { transform: `translate(${offsetX}px, ${offsetY}px)`, width: w, height: h, } } function getFlipStyle(shape: TLImageShape, size?: { width: number; height: number }) { const { flipX, flipY, crop } = shape.props if (!flipX && !flipY) return undefined let cropOffsetX let cropOffsetY if (crop) { // We have to do all this extra math because of the whole transform origin around 0,0 // instead of center in SVG-land, ugh. const { w, h } = getUncroppedSize(shape.props, crop) // Find the resulting w/h of the crop in normalized (0-1) coordinates const cropWidth = crop.bottomRight.x - crop.topLeft.x const cropHeight = crop.bottomRight.y - crop.topLeft.y // Map from the normalized crop coordinate space to shape pixel space cropOffsetX = modulate(crop.topLeft.x, [0, 1 - cropWidth], [0, w - shape.props.w]) cropOffsetY = modulate(crop.topLeft.y, [0, 1 - cropHeight], [0, h - shape.props.h]) } const scale = `scale(${flipX ? -1 : 1}, ${flipY ? -1 : 1})` const translate = size ? `translate(${(flipX ? size.width : 0) - (cropOffsetX ? cropOffsetX : 0)}px, ${(flipY ? size.height : 0) - (cropOffsetY ? cropOffsetY : 0)}px)` : '' return { transform: `${translate} ${scale}`, // in SVG, flipping around the center doesn't work so we use explicit width/height transformOrigin: size ? '0 0' : 'center center', } } function SvgImage({ shape, src }: { shape: TLImageShape; src: string }) { const cropClipId = useUniqueSafeId() const containerStyle = getCroppedContainerStyle(shape) const crop = shape.props.crop if (containerStyle.transform && crop) { const { transform: cropTransform, width, height } = containerStyle const croppedWidth = (crop.bottomRight.x - crop.topLeft.x) * width const croppedHeight = (crop.bottomRight.y - crop.topLeft.y) * height const points = [ new Vec(0, 0), new Vec(croppedWidth, 0), new Vec(croppedWidth, croppedHeight), new Vec(0, croppedHeight), ] const flip = getFlipStyle(shape, { width, height }) return ( <> {crop.isCircle ? ( ) : ( `${p.x},${p.y}`).join(' ')} /> )} ) } else { return ( ) } } function getFirstFrameOfAnimatedImage(url: string) { let cancelled = false const promise = new Promise((resolve) => { const image = Image() image.onload = () => { if (cancelled) return const canvas = getGlobalDocument().createElement('canvas') canvas.width = image.width canvas.height = image.height const ctx = canvas.getContext('2d') if (!ctx) return ctx.drawImage(image, 0, 0) resolve(canvas.toDataURL()) } image.crossOrigin = 'anonymous' image.src = url }) return { promise, cancel: () => (cancelled = true) } }