/* eslint-disable react-hooks/rules-of-hooks */ import { Vec2d, toDomPrecision } from '@tldraw/primitives' import { TLImageShape, TLShapePartial } from '@tldraw/tlschema' import { deepCopy } from '@tldraw/utils' import { useEffect, useState } from 'react' import { useValue } from 'signia-react' import { DefaultSpinner } from '../../../components/DefaultSpinner' import { HTMLContainer } from '../../../components/HTMLContainer' import { useIsCropping } from '../../../hooks/useIsCropping' import { usePrefersReducedMotion } from '../../../utils/dom' import { BaseBoxShapeUtil } from '../BaseBoxShapeUtil' import { TLOnDoubleClickHandler } from '../ShapeUtil' import { HyperlinkButton } from '../shared/HyperlinkButton' const loadImage = async (url: string): Promise => { return new Promise((resolve, reject) => { const image = new Image() image.onload = () => resolve(image) image.onerror = () => reject(new Error('Failed to load image')) image.crossOrigin = 'anonymous' image.src = url }) } const getStateFrame = async (url: string) => { const image = await loadImage(url) const canvas = document.createElement('canvas') canvas.width = image.width canvas.height = image.height const ctx = canvas.getContext('2d') if (!ctx) return ctx.drawImage(image, 0, 0) return canvas.toDataURL() } async function getDataURIFromURL(url: string): Promise { const response = await fetch(url) const blob = await response.blob() return new Promise((resolve, reject) => { const reader = new FileReader() reader.onloadend = () => resolve(reader.result as string) reader.onerror = reject reader.readAsDataURL(blob) }) } /** @public */ export class ImageShapeUtil extends BaseBoxShapeUtil { static override type = 'image' override isAspectRatioLocked = () => true override canCrop = () => true override defaultProps(): TLImageShape['props'] { return { w: 100, h: 100, assetId: null, playing: true, url: '', crop: null, } } render(shape: TLImageShape) { const containerStyle = getContainerStyle(shape) const isCropping = useIsCropping(shape.id) const prefersReducedMotion = usePrefersReducedMotion() const [staticFrameSrc, setStaticFrameSrc] = useState('') const { w, h } = shape.props const asset = shape.props.assetId ? this.editor.getAssetById(shape.props.assetId) : undefined if (asset?.type === 'bookmark') { throw Error("Bookmark assets can't be rendered as images") } const isSelected = useValue( 'onlySelectedShape', () => shape.id === this.editor.onlySelectedShape?.id, [this.editor] ) const showCropPreview = isSelected && isCropping && this.editor.isInAny('select.crop', 'select.cropping', 'select.pointing_crop_handle') // We only want to reduce motion for mimeTypes that have motion const reduceMotion = prefersReducedMotion && (asset?.props.mimeType?.includes('video') || asset?.props.mimeType?.includes('gif')) useEffect(() => { if (asset?.props.src && 'mimeType' in asset.props && asset?.props.mimeType === 'image/gif') { let cancelled = false const run = async () => { const newStaticFrame = await getStateFrame(asset.props.src!) if (cancelled) return if (newStaticFrame) { setStaticFrameSrc(newStaticFrame) } } run() return () => { cancelled = true } } }, [prefersReducedMotion, asset?.props]) return ( <> {asset?.props.src && showCropPreview && (
)}
{asset?.props.src ? (
) : ( )} {asset?.props.isAnimated && !shape.props.playing && (
GIF
)}
{'url' in shape.props && shape.props.url && ( )} ) } indicator(shape: TLImageShape) { const isCropping = useIsCropping(shape.id) if (isCropping) { return null } return } async toSvg(shape: TLImageShape) { const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') const asset = shape.props.assetId ? this.editor.getAssetById(shape.props.assetId) : null let src = asset?.props.src || '' if (src && src.startsWith('http')) { // If it's a remote image, we need to fetch it and convert it to a data URI src = (await getDataURIFromURL(src)) || '' } const image = document.createElementNS('http://www.w3.org/2000/svg', 'image') image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', src) const containerStyle = getContainerStyle(shape) const crop = shape.props.crop if (containerStyle && crop) { const { transform, width, height } = containerStyle const points = [ new Vec2d(crop.topLeft.x * width, crop.topLeft.y * height), new Vec2d(crop.bottomRight.x * width, crop.topLeft.y * height), new Vec2d(crop.bottomRight.x * width, crop.bottomRight.y * height), new Vec2d(crop.topLeft.x * width, crop.bottomRight.y * height), ] const innerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g') innerElement.style.clipPath = `polygon(${points.map((p) => `${p.x}px ${p.y}px`).join(',')})` image.setAttribute('width', width.toString()) image.setAttribute('height', height.toString()) image.style.transform = transform innerElement.appendChild(image) g.appendChild(innerElement) } else { image.setAttribute('width', shape.props.w.toString()) image.setAttribute('height', shape.props.h.toString()) g.appendChild(image) } return g } onDoubleClick = (shape: TLImageShape) => { const asset = shape.props.assetId ? this.editor.getAssetById(shape.props.assetId) : undefined if (!asset) return const canPlay = asset.props.src && 'mimeType' in asset.props && asset.props.mimeType === 'image/gif' if (!canPlay) return this.editor.updateShapes([ { type: 'image', id: shape.id, props: { playing: !shape.props.playing, }, }, ]) } onDoubleClickEdge: TLOnDoubleClickHandler = (shape) => { const props = shape.props if (!props) return if (this.editor.croppingId !== shape.id) { return } const crop = deepCopy(props.crop) || { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, } // The true asset dimensions const w = (1 / (crop.bottomRight.x - crop.topLeft.x)) * shape.props.w const h = (1 / (crop.bottomRight.y - crop.topLeft.y)) * shape.props.h const pointDelta = new Vec2d(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]) } } /** * 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 getContainerStyle(shape: TLImageShape) { const crop = shape.props.crop const topLeft = crop?.topLeft if (!topLeft) return const w = (1 / (crop.bottomRight.x - crop.topLeft.x)) * shape.props.w const h = (1 / (crop.bottomRight.y - crop.topLeft.y)) * shape.props.h const offsetX = -topLeft.x * w const offsetY = -topLeft.y * h return { transform: `translate(${offsetX}px, ${offsetY}px)`, width: w, height: h, } }