diff --git a/lerna.json b/lerna.json index 45455ce09..5ea4fee74 100644 --- a/lerna.json +++ b/lerna.json @@ -1,7 +1,5 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "packages": [ - "packages/*" - ], + "packages": ["packages/*"], "version": "2.0.0" } diff --git a/packages/editor/editor.css b/packages/editor/editor.css index e10e7f18d..78730becc 100644 --- a/packages/editor/editor.css +++ b/packages/editor/editor.css @@ -546,6 +546,7 @@ input, height: 100%; } +.tl-video-container, .tl-image-container, .tl-embed-container { width: 100%; diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 6bebbc141..4e4e2c691 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -7768,6 +7768,7 @@ export class Editor extends EventEmitter { } } + // Ok, we've got our migrated shapes and assets, now we can continue! const idMap = new Map(shapes.map((shape) => [shape.id, createShapeId()])) // By default, the paste parent will be the current page. @@ -7905,54 +7906,57 @@ export class Editor extends EventEmitter { return this } - // Migrate the new shapes - - let assetsToCreate: TLAsset[] = [] + // These are all the assets we need to create + const assetsToCreate: TLAsset[] = [] + // These assets have base64 data that may need to be hosted const assetsToUpdate: (TLImageAsset | TLVideoAsset)[] = [] - assetsToCreate = assets - .filter((asset) => !this.store.has(asset.id)) - .map((asset) => { - if (asset.type === 'image' || asset.type === 'video') { - if (asset.props.src && asset.props.src?.startsWith('data:image')) { - assetsToUpdate.push(structuredClone(asset)) - asset.props.src = null - } else { - assetsToUpdate.push(structuredClone(asset)) - } - } + for (const asset of assets) { + if (this.store.has(asset.id)) { + // We already have this asset + continue + } - return asset - }) + if ( + (asset.type === 'image' || asset.type === 'video') && + asset.props.src?.startsWith('data:image') + ) { + // it's src is a base64 image or video; we need to create a new asset without the src, + // then create a new asset from the original src. So we save a copy of the original asset, + // then delete the src from the original asset. + assetsToUpdate.push(structuredClone(asset as TLImageAsset | TLVideoAsset)) + asset.props.src = null + } + // Add the asset to the list of assets to create + assetsToCreate.push(asset) + } + + // Start loading the new assets, order does not matter Promise.allSettled( - assetsToUpdate.map(async (asset) => { + (assetsToUpdate as (TLImageAsset | TLVideoAsset)[]).map(async (asset) => { + // Turn the data url into a file const file = await dataUrlToFile( asset.props.src!, asset.props.name, asset.props.mimeType ?? 'image/png' ) + // Get a new asset for the file const newAsset = await this.getAssetForExternalContent({ type: 'file', file }) if (!newAsset) { - return null + // If we don't have a new asset, delete the old asset. + // The shapes that reference this asset should break. + this.deleteAssets([asset.id]) + return } - return [asset, newAsset] as const + // Save the new asset under the old asset's id + this.updateAssets([{ ...newAsset, id: asset.id }]) }) - ).then((assets) => { - this.updateAssets( - compact( - assets.map((result) => - result.status === 'fulfilled' && result.value - ? { ...result.value[1], id: result.value[0].id } - : undefined - ) - ) - ) - }) + ) this.batch(() => { // Create any assets that need to be created diff --git a/packages/namespaced-tldraw/tldraw.css b/packages/namespaced-tldraw/tldraw.css index 107ed6606..ef9a2899b 100644 --- a/packages/namespaced-tldraw/tldraw.css +++ b/packages/namespaced-tldraw/tldraw.css @@ -550,6 +550,7 @@ input, height: 100%; } +.tl-video-container, .tl-image-container, .tl-embed-container { width: 100%; diff --git a/packages/tldraw/api-report.md b/packages/tldraw/api-report.md index d217a8b17..3137a139c 100644 --- a/packages/tldraw/api-report.md +++ b/packages/tldraw/api-report.md @@ -896,8 +896,6 @@ export class ImageShapeUtil extends BaseBoxShapeUtil { } | null>; }; // (undocumented) - shouldGetDataURI(src: string): "" | boolean; - // (undocumented) toSvg(shape: TLImageShape): Promise; // (undocumented) static type: "image"; diff --git a/packages/tldraw/api/api.json b/packages/tldraw/api/api.json index 254b9b788..848018a88 100644 --- a/packages/tldraw/api/api.json +++ b/packages/tldraw/api/api.json @@ -10424,54 +10424,6 @@ "isProtected": false, "isAbstract": false }, - { - "kind": "Method", - "canonicalReference": "tldraw!ImageShapeUtil#shouldGetDataURI:member(1)", - "docComment": "", - "excerptTokens": [ - { - "kind": "Content", - "text": "shouldGetDataURI(src: " - }, - { - "kind": "Content", - "text": "string" - }, - { - "kind": "Content", - "text": "): " - }, - { - "kind": "Content", - "text": "\"\" | boolean" - }, - { - "kind": "Content", - "text": ";" - } - ], - "isStatic": false, - "returnTypeTokenRange": { - "startIndex": 3, - "endIndex": 4 - }, - "releaseTag": "Public", - "isProtected": false, - "overloadIndex": 1, - "parameters": [ - { - "parameterName": "src", - "parameterTypeTokenRange": { - "startIndex": 1, - "endIndex": 2 - }, - "isOptional": false - } - ], - "isOptional": false, - "isAbstract": false, - "name": "shouldGetDataURI" - }, { "kind": "Method", "canonicalReference": "tldraw!ImageShapeUtil#toSvg:member(1)", diff --git a/packages/tldraw/src/lib/shapes/image/ImageShapeUtil.tsx b/packages/tldraw/src/lib/shapes/image/ImageShapeUtil.tsx index 7f2656952..784a4db90 100644 --- a/packages/tldraw/src/lib/shapes/image/ImageShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/image/ImageShapeUtil.tsx @@ -10,37 +10,12 @@ import { imageShapeMigrations, imageShapeProps, toDomPrecision, - useIsCropping, - useValue, } from '@tldraw/editor' import { useEffect, useState } from 'react' +import { BrokenAssetIcon } from '../shared/BrokenAssetIcon' import { HyperlinkButton } from '../shared/HyperlinkButton' import { usePrefersReducedMotion } from '../shared/usePrefersReducedMotion' -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() @@ -73,23 +48,47 @@ export class ImageShapeUtil extends BaseBoxShapeUtil { } component(shape: TLImageShape) { - const containerStyle = getContainerStyle(shape) - const isCropping = useIsCropping(shape.id) + const isCropping = this.editor.getCroppingShapeId() === shape.id const prefersReducedMotion = usePrefersReducedMotion() const [staticFrameSrc, setStaticFrameSrc] = useState('') const asset = shape.props.assetId ? this.editor.getAsset(shape.props.assetId) : undefined + const isSelected = shape.id === this.editor.getOnlySelectedShape()?.id + + useEffect(() => { + if (asset?.props.src && 'mimeType' in asset.props && asset?.props.mimeType === 'image/gif') { + let cancelled = false + const url = asset.props.src + if (!url) return + + const image = new Image() + image.onload = () => { + if (cancelled) return + + 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) + setStaticFrameSrc(canvas.toDataURL()) + } + image.crossOrigin = 'anonymous' + image.src = url + + return () => { + cancelled = true + } + } + }, [prefersReducedMotion, asset?.props]) + if (asset?.type === 'bookmark') { throw Error("Bookmark assets can't be rendered as images") } - const isSelected = useValue( - 'onlySelectedShape', - () => shape.id === this.editor.getOnlySelectedShape()?.id, - [this.editor] - ) - const showCropPreview = isSelected && isCropping && @@ -100,27 +99,35 @@ export class ImageShapeUtil extends BaseBoxShapeUtil { 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() + const containerStyle = getCroppedContainerStyle(shape) - return () => { - cancelled = true - } - } - }, [prefersReducedMotion, asset?.props]) + if (!asset?.props.src) { + return ( + +
+ {asset ? null : } +
+ ) + {'url' in shape.props && shape.props.url && ( + + )} +
+ ) + } return ( <> - {asset?.props.src && showCropPreview && ( + {showCropPreview && (
{ style={{ overflow: 'hidden', width: shape.props.w, height: shape.props.h }} >
- {asset?.props.src ? ( -
- ) : null} - {asset?.props.isAnimated && !shape.props.playing && ( +
+ {asset.props.isAnimated && !shape.props.playing && (
GIF
)}
- {'url' in shape.props && shape.props.url && ( + ) + {shape.props.url && ( )} @@ -163,30 +169,26 @@ export class ImageShapeUtil extends BaseBoxShapeUtil { } indicator(shape: TLImageShape) { - const isCropping = useIsCropping(shape.id) - if (isCropping) { - return null - } + const isCropping = this.editor.getCroppingShapeId() === shape.id + if (isCropping) return null return } - shouldGetDataURI(src: string) { - return src && (src.startsWith('http') || src.startsWith('/') || src.startsWith('./')) - } - override async toSvg(shape: TLImageShape) { const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') const asset = shape.props.assetId ? this.editor.getAsset(shape.props.assetId) : null + if (!asset) return g + let src = asset?.props.src || '' - if (this.shouldGetDataURI(src)) { + if (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)) || '' } 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 containerStyle = getCroppedContainerStyle(shape) const crop = shape.props.crop if (containerStyle.transform && crop) { const { transform, width, height } = containerStyle @@ -294,7 +296,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil { * @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) { +function getCroppedContainerStyle(shape: TLImageShape) { const crop = shape.props.crop const topLeft = crop?.topLeft if (!topLeft) { diff --git a/packages/tldraw/src/lib/shapes/shared/BrokenAssetIcon.tsx b/packages/tldraw/src/lib/shapes/shared/BrokenAssetIcon.tsx new file mode 100644 index 000000000..8b6197bd4 --- /dev/null +++ b/packages/tldraw/src/lib/shapes/shared/BrokenAssetIcon.tsx @@ -0,0 +1,18 @@ +export function BrokenAssetIcon() { + return ( + + + + + + ) +} diff --git a/packages/tldraw/src/lib/shapes/video/VideoShapeUtil.tsx b/packages/tldraw/src/lib/shapes/video/VideoShapeUtil.tsx index 8c0a5b43f..8c0ffb493 100644 --- a/packages/tldraw/src/lib/shapes/video/VideoShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/video/VideoShapeUtil.tsx @@ -1,14 +1,15 @@ +/* eslint-disable react-hooks/rules-of-hooks */ import { BaseBoxShapeUtil, HTMLContainer, TLVideoShape, toDomPrecision, - track, useIsEditing, videoShapeMigrations, videoShapeProps, } from '@tldraw/editor' -import React from 'react' +import { ReactEventHandler, useCallback, useEffect, useRef, useState } from 'react' +import { BrokenAssetIcon } from '../shared/BrokenAssetIcon' import { HyperlinkButton } from '../shared/HyperlinkButton' import { usePrefersReducedMotion } from '../shared/usePrefersReducedMotion' @@ -33,7 +34,156 @@ export class VideoShapeUtil extends BaseBoxShapeUtil { } component(shape: TLVideoShape) { - return + const { editor } = this + const showControls = editor.getShapeGeometry(shape).bounds.w * editor.getZoomLevel() >= 110 + const asset = shape.props.assetId ? editor.getAsset(shape.props.assetId) : null + const { time, playing } = shape.props + const isEditing = useIsEditing(shape.id) + const prefersReducedMotion = usePrefersReducedMotion() + + const rVideo = useRef(null!) + + const handlePlay = useCallback>( + (e) => { + const video = e.currentTarget + + editor.updateShapes([ + { + type: 'video', + id: shape.id, + props: { + playing: true, + time: video.currentTime, + }, + }, + ]) + }, + [shape.id, editor] + ) + + const handlePause = useCallback>( + (e) => { + const video = e.currentTarget + + editor.updateShapes([ + { + type: 'video', + id: shape.id, + props: { + playing: false, + time: video.currentTime, + }, + }, + ]) + }, + [shape.id, editor] + ) + + const handleSetCurrentTime = useCallback>( + (e) => { + const video = e.currentTarget + + if (isEditing) { + editor.updateShapes([ + { + type: 'video', + id: shape.id, + props: { + time: video.currentTime, + }, + }, + ]) + } + }, + [isEditing, shape.id, editor] + ) + + const [isLoaded, setIsLoaded] = useState(false) + + const handleLoadedData = useCallback>( + (e) => { + const video = e.currentTarget + if (time !== video.currentTime) { + video.currentTime = time + } + + if (!playing) { + video.pause() + } + + setIsLoaded(true) + }, + [playing, time] + ) + + // If the current time changes and we're not editing the video, update the video time + useEffect(() => { + const video = rVideo.current + + if (!video) return + + if (isLoaded && !isEditing && time !== video.currentTime) { + video.currentTime = time + } + + if (isEditing) { + if (document.activeElement !== video) { + video.focus() + } + } + }, [isEditing, isLoaded, time]) + + useEffect(() => { + if (prefersReducedMotion) { + const video = rVideo.current + video.pause() + video.currentTime = 0 + } + }, [rVideo, prefersReducedMotion]) + + return ( + <> + + {asset?.props.src ? ( + + ) : ( + + )} + + {'url' in shape.props && shape.props.url && ( + + )} + + ) } indicator(shape: TLVideoShape) { @@ -64,152 +214,3 @@ function serializeVideo(id: string): string { return canvas.toDataURL('image/png') } else throw new Error('Video with not found when attempting serialization.') } - -const TLVideoUtilComponent = track(function TLVideoUtilComponent(props: { - shape: TLVideoShape - videoUtil: VideoShapeUtil -}) { - const { shape, videoUtil } = props - const showControls = - videoUtil.editor.getShapeGeometry(shape).bounds.w * videoUtil.editor.getZoomLevel() >= 110 - const asset = shape.props.assetId ? videoUtil.editor.getAsset(shape.props.assetId) : null - const { time, playing } = shape.props - const isEditing = useIsEditing(shape.id) - const prefersReducedMotion = usePrefersReducedMotion() - - const rVideo = React.useRef(null!) - - const handlePlay = React.useCallback>( - (e) => { - const video = e.currentTarget - - videoUtil.editor.updateShapes([ - { - type: 'video', - id: shape.id, - props: { - playing: true, - time: video.currentTime, - }, - }, - ]) - }, - [shape.id, videoUtil.editor] - ) - - const handlePause = React.useCallback>( - (e) => { - const video = e.currentTarget - - videoUtil.editor.updateShapes([ - { - type: 'video', - id: shape.id, - props: { - playing: false, - time: video.currentTime, - }, - }, - ]) - }, - [shape.id, videoUtil.editor] - ) - - const handleSetCurrentTime = React.useCallback>( - (e) => { - const video = e.currentTarget - - if (isEditing) { - videoUtil.editor.updateShapes([ - { - type: 'video', - id: shape.id, - props: { - time: video.currentTime, - }, - }, - ]) - } - }, - [isEditing, shape.id, videoUtil.editor] - ) - - const [isLoaded, setIsLoaded] = React.useState(false) - - const handleLoadedData = React.useCallback>( - (e) => { - const video = e.currentTarget - if (time !== video.currentTime) { - video.currentTime = time - } - - if (!playing) { - video.pause() - } - - setIsLoaded(true) - }, - [playing, time] - ) - - // If the current time changes and we're not editing the video, update the video time - React.useEffect(() => { - const video = rVideo.current - - if (!video) return - - if (isLoaded && !isEditing && time !== video.currentTime) { - video.currentTime = time - } - - if (isEditing) { - if (document.activeElement !== video) { - video.focus() - } - } - }, [isEditing, isLoaded, time]) - - React.useEffect(() => { - if (prefersReducedMotion) { - const video = rVideo.current - video.pause() - video.currentTime = 0 - } - }, [rVideo, prefersReducedMotion]) - - return ( - <> - -
- {asset?.props.src ? ( - - ) : null} -
-
- {'url' in shape.props && shape.props.url && ( - - )} - - ) -})