kopia lustrzana https://github.com/Tldraw/Tldraw
Merge 2eb7dfbfe6
into c6ba621c11
commit
4a6f5c3786
|
@ -3,15 +3,16 @@ import {
|
|||
AssetRecordType,
|
||||
DEFAULT_ACCEPTED_IMG_TYPE,
|
||||
MediaHelpers,
|
||||
TLAsset,
|
||||
TLAssetId,
|
||||
TLImageAsset,
|
||||
TLVideoAsset,
|
||||
getHashForString,
|
||||
uniqueId,
|
||||
} from 'tldraw'
|
||||
|
||||
export function useMultiplayerAssets(assetUploaderUrl: string) {
|
||||
export function useMultiplayerVideoAsset(assetUploaderUrl: string) {
|
||||
return useCallback(
|
||||
async (file: File): Promise<TLAsset> => {
|
||||
async (file: File): Promise<TLVideoAsset> => {
|
||||
const id = uniqueId()
|
||||
|
||||
const UPLOAD_URL = `${assetUploaderUrl}/uploads`
|
||||
|
@ -24,30 +25,14 @@ export function useMultiplayerAssets(assetUploaderUrl: string) {
|
|||
})
|
||||
|
||||
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
|
||||
|
||||
const isImageType = DEFAULT_ACCEPTED_IMG_TYPE.includes(file.type)
|
||||
if (isImageType) throw Error('File is not a video')
|
||||
const isAnimated = true
|
||||
const size = await MediaHelpers.getVideoSize(file)
|
||||
|
||||
let size: {
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
let isAnimated: boolean
|
||||
|
||||
if (isImageType) {
|
||||
size = await MediaHelpers.getImageSize(file)
|
||||
if (file.type === 'image/gif') {
|
||||
isAnimated = true // await getIsGifAnimated(file) todo export me from editor
|
||||
} else {
|
||||
isAnimated = false
|
||||
}
|
||||
} else {
|
||||
isAnimated = true
|
||||
size = await MediaHelpers.getVideoSize(file)
|
||||
}
|
||||
|
||||
const asset: TLAsset = AssetRecordType.create({
|
||||
const asset = AssetRecordType.create({
|
||||
id: assetId,
|
||||
type: isImageType ? 'image' : 'video',
|
||||
type: 'video',
|
||||
typeName: 'asset',
|
||||
props: {
|
||||
name: file.name,
|
||||
|
@ -58,7 +43,68 @@ export function useMultiplayerAssets(assetUploaderUrl: string) {
|
|||
isAnimated,
|
||||
},
|
||||
meta: {},
|
||||
})
|
||||
} satisfies TLVideoAsset) as TLVideoAsset
|
||||
|
||||
return asset
|
||||
},
|
||||
[assetUploaderUrl]
|
||||
)
|
||||
}
|
||||
|
||||
export function useMultiplayerImageAsset(assetUploaderUrl: string) {
|
||||
return useCallback(
|
||||
async (files: File[]): Promise<TLImageAsset> => {
|
||||
const id = uniqueId()
|
||||
|
||||
const UPLOAD_URL = `${assetUploaderUrl}/uploads`
|
||||
|
||||
const urls: string[] = []
|
||||
|
||||
let size: { w: number; h: number } | undefined
|
||||
let isAnimated = false
|
||||
let assetId: TLAssetId | undefined
|
||||
let name: string | undefined
|
||||
let mimeType: string | undefined
|
||||
|
||||
// let's assume the scale will be 1, .5, .25, with the first being the largest
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
if (!DEFAULT_ACCEPTED_IMG_TYPE.includes(file.type)) throw Error('File is not an image')
|
||||
|
||||
const objectName = `${id}-${file.name}-${i}`.replaceAll(/[^a-zA-Z0-9.]/g, '-')
|
||||
const url = `${UPLOAD_URL}/${objectName}`
|
||||
|
||||
if (i === 0) {
|
||||
name = file.name
|
||||
assetId = AssetRecordType.createId(getHashForString(url))
|
||||
if (file.type === 'image/gif') isAnimated = true
|
||||
size = await MediaHelpers.getImageSize(file)
|
||||
}
|
||||
|
||||
urls.push(url)
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
body: file,
|
||||
})
|
||||
}
|
||||
|
||||
if (!assetId) throw Error('No asset id created')
|
||||
|
||||
const asset = AssetRecordType.create({
|
||||
id: assetId,
|
||||
type: 'image',
|
||||
typeName: 'asset',
|
||||
props: {
|
||||
name: name!,
|
||||
w: size!.w,
|
||||
h: size!.h,
|
||||
mimeType: mimeType!,
|
||||
isAnimated,
|
||||
sources: urls.map((url, i) => ({ scale: 1 / 2 ** i, src: url })),
|
||||
},
|
||||
meta: {},
|
||||
} satisfies TLImageAsset) as TLImageAsset
|
||||
|
||||
return asset
|
||||
},
|
||||
|
|
|
@ -1,20 +1,26 @@
|
|||
import { TLAsset } from 'tldraw'
|
||||
import { TLAsset, TLImageAsset, TLVideoAsset, structuredClone } from 'tldraw'
|
||||
|
||||
export async function cloneAssetForShare(
|
||||
asset: TLAsset,
|
||||
uploadFileToAsset: (file: File) => Promise<TLAsset>
|
||||
uploadFiles: {
|
||||
image: (files: File[]) => Promise<TLImageAsset>
|
||||
video: (file: File) => Promise<TLVideoAsset>
|
||||
}
|
||||
): Promise<TLAsset> {
|
||||
if (asset.type === 'bookmark') return asset
|
||||
if (asset.props.src) {
|
||||
const dataUrlMatch = asset.props.src.match(/data:(.*?)(;base64)?,/)
|
||||
if (!dataUrlMatch) return asset
|
||||
if (asset.type === 'bookmark') {
|
||||
return asset
|
||||
}
|
||||
|
||||
const response = await fetch(asset.props.src)
|
||||
if (asset.type === 'video') {
|
||||
const { src } = asset.props
|
||||
if (!src) return asset
|
||||
const dataUrlMatch = src.match(/data:(.*?)(;base64)?,/)
|
||||
if (!dataUrlMatch) return asset
|
||||
const response = await fetch(src)
|
||||
const file = new File([await response.blob()], asset.props.name, {
|
||||
type: dataUrlMatch[1] ?? asset.props.mimeType,
|
||||
})
|
||||
|
||||
const uploadedAsset = await uploadFileToAsset(file)
|
||||
const uploadedAsset = await uploadFiles.video(file)
|
||||
|
||||
return {
|
||||
...asset,
|
||||
|
@ -24,5 +30,33 @@ export async function cloneAssetForShare(
|
|||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (asset.type === 'image') {
|
||||
const { sources } = asset.props
|
||||
const nextSources = structuredClone(sources)
|
||||
const files: File[] = []
|
||||
for (const source of nextSources) {
|
||||
const { src } = source
|
||||
if (!src) continue
|
||||
const dataUrlMatch = src.match(/data:(.*?)(;base64)?,/)
|
||||
if (!dataUrlMatch) continue
|
||||
const response = await fetch(src)
|
||||
const file = new File([await response.blob()], asset.props.name, {
|
||||
type: dataUrlMatch[1] ?? asset.props.mimeType,
|
||||
})
|
||||
files.push(file)
|
||||
}
|
||||
|
||||
const uploadedAsset = await uploadFiles.image(files)
|
||||
|
||||
return {
|
||||
...asset,
|
||||
props: {
|
||||
...asset.props,
|
||||
...uploadedAsset.props,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return asset
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
Editor,
|
||||
TLAsset,
|
||||
TLAssetId,
|
||||
TLImageAsset,
|
||||
TLRecord,
|
||||
TLShape,
|
||||
TLShapeId,
|
||||
|
@ -20,9 +21,10 @@ import {
|
|||
TLUiOverrides,
|
||||
TLUiToastsContextType,
|
||||
TLUiTranslationKey,
|
||||
TLVideoAsset,
|
||||
isShape,
|
||||
} from 'tldraw'
|
||||
import { useMultiplayerAssets } from '../hooks/useMultiplayerAssets'
|
||||
import { useMultiplayerImageAsset, useMultiplayerVideoAsset } from '../hooks/useMultiplayerAssets'
|
||||
import { getViewportUrlQuery } from '../hooks/useUrlState'
|
||||
import { cloneAssetForShare } from './cloneAssetForShare'
|
||||
import { ASSET_UPLOADER_URL } from './config'
|
||||
|
@ -45,11 +47,14 @@ async function getSnapshotLink(
|
|||
handleUiEvent: TLUiEventHandler,
|
||||
addToast: TLUiToastsContextType['addToast'],
|
||||
msg: (id: TLUiTranslationKey) => string,
|
||||
uploadFileToAsset: (file: File) => Promise<TLAsset>,
|
||||
parentSlug: string | undefined
|
||||
parentSlug: string | undefined,
|
||||
uploadFiles: {
|
||||
image: (files: File[]) => Promise<TLImageAsset>
|
||||
video: (file: File) => Promise<TLVideoAsset>
|
||||
}
|
||||
) {
|
||||
handleUiEvent('share-snapshot' as UI_OVERRIDE_TODO_EVENT, { source } as UI_OVERRIDE_TODO_EVENT)
|
||||
const data = await getRoomData(editor, addToast, msg, uploadFileToAsset)
|
||||
const data = await getRoomData(editor, addToast, msg, uploadFiles)
|
||||
if (!data) return ''
|
||||
|
||||
const res = await fetch(CREATE_SNAPSHOT_ENDPOINT, {
|
||||
|
@ -98,7 +103,8 @@ export function useSharing(): TLUiOverrides {
|
|||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const roomId = params.roomId
|
||||
const uploadFileToAsset = useMultiplayerAssets(ASSET_UPLOADER_URL)
|
||||
const uploadVideoFileToAsset = useMultiplayerVideoAsset(ASSET_UPLOADER_URL)
|
||||
const uploadImageFilesToAsset = useMultiplayerImageAsset(ASSET_UPLOADER_URL)
|
||||
const handleUiEvent = useHandleUiEvents()
|
||||
const runningInIFrame = isInIframe()
|
||||
|
||||
|
@ -125,7 +131,10 @@ export function useSharing(): TLUiOverrides {
|
|||
onSelect: async (source) => {
|
||||
try {
|
||||
handleUiEvent('share-project', { source })
|
||||
const data = await getRoomData(editor, addToast, msg, uploadFileToAsset)
|
||||
const data = await getRoomData(editor, addToast, msg, {
|
||||
video: uploadVideoFileToAsset,
|
||||
image: uploadImageFilesToAsset,
|
||||
})
|
||||
if (!data) return
|
||||
|
||||
const res = await getNewRoomResponse({
|
||||
|
@ -167,15 +176,10 @@ export function useSharing(): TLUiOverrides {
|
|||
label: 'share-menu.create-snapshot-link',
|
||||
readonlyOk: true,
|
||||
onSelect: async (source) => {
|
||||
const result = getSnapshotLink(
|
||||
source,
|
||||
editor,
|
||||
handleUiEvent,
|
||||
addToast,
|
||||
msg,
|
||||
uploadFileToAsset,
|
||||
roomId
|
||||
)
|
||||
const result = getSnapshotLink(source, editor, handleUiEvent, addToast, msg, roomId, {
|
||||
video: uploadVideoFileToAsset,
|
||||
image: uploadImageFilesToAsset,
|
||||
})
|
||||
if (navigator?.clipboard?.write) {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
|
@ -197,7 +201,14 @@ export function useSharing(): TLUiOverrides {
|
|||
return actions
|
||||
},
|
||||
}),
|
||||
[handleUiEvent, navigate, uploadFileToAsset, roomId, runningInIFrame]
|
||||
[
|
||||
handleUiEvent,
|
||||
navigate,
|
||||
roomId,
|
||||
runningInIFrame,
|
||||
uploadImageFilesToAsset,
|
||||
uploadVideoFileToAsset,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -205,7 +216,10 @@ async function getRoomData(
|
|||
editor: Editor,
|
||||
addToast: TLUiToastsContextType['addToast'],
|
||||
msg: (id: TLUiTranslationKey) => string,
|
||||
uploadFileToAsset: (file: File) => Promise<TLAsset>
|
||||
uploadFiles: {
|
||||
image: (files: File[]) => Promise<TLImageAsset>
|
||||
video: (file: File) => Promise<TLVideoAsset>
|
||||
}
|
||||
) {
|
||||
const rawData = editor.store.serialize()
|
||||
|
||||
|
@ -241,7 +255,7 @@ async function getRoomData(
|
|||
// processed it
|
||||
if (!asset) continue
|
||||
|
||||
data[asset.id] = await cloneAssetForShare(asset, uploadFileToAsset)
|
||||
data[asset.id] = await cloneAssetForShare(asset, uploadFiles)
|
||||
// remove the asset after processing so we don't clone it multiple times
|
||||
assets.delete(asset.id)
|
||||
}
|
||||
|
|
|
@ -49,7 +49,12 @@ export function ImageAnnotationEditor({
|
|||
w: image.width,
|
||||
h: image.height,
|
||||
mimeType: image.type,
|
||||
src: image.src,
|
||||
sources: [
|
||||
{
|
||||
scale: 1,
|
||||
src: image.src,
|
||||
},
|
||||
],
|
||||
name: 'image',
|
||||
isAnimated: false,
|
||||
},
|
||||
|
|
|
@ -18,7 +18,12 @@ export default function LocalImagesExample() {
|
|||
typeName: 'asset',
|
||||
props: {
|
||||
name: 'tldraw.png',
|
||||
src: '/tldraw.png', // You could also use a base64 encoded string here
|
||||
sources: [
|
||||
{
|
||||
scale: 1,
|
||||
src: '/tldraw.png', // You could also use a base64 encoded string here
|
||||
},
|
||||
],
|
||||
w: imageWidth,
|
||||
h: imageHeight,
|
||||
mimeType: 'image/png',
|
||||
|
|
|
@ -46,7 +46,12 @@ export function PdfEditor({ pdf }: { pdf: Pdf }) {
|
|||
w: page.bounds.w,
|
||||
h: page.bounds.h,
|
||||
mimeType: 'image/png',
|
||||
src: page.src,
|
||||
sources: [
|
||||
{
|
||||
scale: 1,
|
||||
src: page.src,
|
||||
},
|
||||
],
|
||||
name: 'page',
|
||||
isAnimated: false,
|
||||
},
|
||||
|
|
|
@ -17,6 +17,7 @@ import { EMPTY_ARRAY } from '@tldraw/state';
|
|||
import { EventEmitter } from 'eventemitter3';
|
||||
import { Expand } from '@tldraw/utils';
|
||||
import { HistoryEntry } from '@tldraw/store';
|
||||
import { IdOf } from '@tldraw/store';
|
||||
import { IndexKey } from '@tldraw/utils';
|
||||
import { JsonObject } from '@tldraw/utils';
|
||||
import { JSX as JSX_2 } from 'react/jsx-runtime';
|
||||
|
@ -781,7 +782,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
getAncestorPageId(shape?: TLShape | TLShapeId): TLPageId | undefined;
|
||||
getArrowInfo(shape: TLArrowShape | TLShapeId): TLArrowInfo | undefined;
|
||||
getArrowsBoundTo(shapeId: TLShapeId): TLArrowShape[];
|
||||
getAsset(asset: TLAsset | TLAssetId): TLAsset | undefined;
|
||||
getAsset<T extends TLAsset>(asset: IdOf<T> | T): T | undefined;
|
||||
getAssetForExternalContent(info: TLExternalAssetContent): Promise<TLAsset | undefined>;
|
||||
getAssets(): (TLBookmarkAsset | TLImageAsset | TLVideoAsset)[];
|
||||
getBaseZoom(): number;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { EMPTY_ARRAY, atom, computed, transact } from '@tldraw/state'
|
||||
import {
|
||||
ComputedCache,
|
||||
IdOf,
|
||||
RecordType,
|
||||
StoreSnapshot,
|
||||
UnknownRecord,
|
||||
|
@ -3780,8 +3781,8 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
*
|
||||
* @public
|
||||
*/
|
||||
getAsset(asset: TLAssetId | TLAsset): TLAsset | undefined {
|
||||
return this.store.get(typeof asset === 'string' ? asset : asset.id) as TLAsset | undefined
|
||||
getAsset<T extends TLAsset>(asset: IdOf<T> | T): T | undefined {
|
||||
return this.store.get(typeof asset === 'string' ? asset : asset.id) as T | undefined
|
||||
}
|
||||
|
||||
/* --------------------- Shapes --------------------- */
|
||||
|
@ -7741,15 +7742,28 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
continue
|
||||
}
|
||||
|
||||
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
|
||||
if (asset.type === 'image' || asset.type === 'video') {
|
||||
const src =
|
||||
asset.type === 'video'
|
||||
? asset.props.src
|
||||
: asset.type === 'image'
|
||||
? // sort smallest to largest
|
||||
asset.props.sources.sort((a, b) => a.scale - b.scale)[0]?.src
|
||||
: null
|
||||
|
||||
if (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))
|
||||
|
||||
// clear the asset for now, we'll update it in a moment
|
||||
if (asset.type === 'video') {
|
||||
asset.props.src = null
|
||||
} else {
|
||||
asset.props.sources = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the asset to the list of assets to create
|
||||
|
@ -7759,12 +7773,18 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
// Start loading the new assets, order does not matter
|
||||
Promise.allSettled(
|
||||
(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 the source with the smallest zoom
|
||||
const src =
|
||||
asset.type === 'video'
|
||||
? asset.props.src
|
||||
: asset.props.sources.sort((a, b) => a.scale - b.scale)[0]?.src
|
||||
|
||||
if (!src) {
|
||||
this.deleteAssets([asset.id])
|
||||
return
|
||||
}
|
||||
|
||||
const file = await dataUrlToFile(src, asset.props.name, asset.props.mimeType ?? 'image/png')
|
||||
|
||||
// Get a new asset for the file
|
||||
const newAsset = await this.getAssetForExternalContent({ type: 'file', file })
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
TLAssetId,
|
||||
TLBookmarkShape,
|
||||
TLEmbedShape,
|
||||
TLImageAsset,
|
||||
TLShapeId,
|
||||
TLShapePartial,
|
||||
TLTextShape,
|
||||
|
@ -81,19 +82,63 @@ export function registerDefaultExternalContentHandlers(
|
|||
}
|
||||
}
|
||||
|
||||
// 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({
|
||||
if (isImageType) {
|
||||
const sources: TLImageAsset['props']['sources'] = []
|
||||
|
||||
// Always rescale the image
|
||||
if (file.type === 'image/jpeg' || file.type === 'image/png') {
|
||||
sources.push(
|
||||
{
|
||||
scale: 1,
|
||||
src: await FileHelpers.blobToDataUrl(
|
||||
await downsizeImage(file, size.w, size.h, {
|
||||
type: file.type,
|
||||
quality: 0.92,
|
||||
})
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: 1 / 2,
|
||||
src: await FileHelpers.blobToDataUrl(
|
||||
await downsizeImage(file, size.w / 2, size.h / 2, {
|
||||
type: file.type,
|
||||
quality: 0.92,
|
||||
})
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: 1 / 4,
|
||||
src: await FileHelpers.blobToDataUrl(
|
||||
await downsizeImage(file, size.w / 4, size.h / 4, {
|
||||
type: file.type,
|
||||
quality: 0.92,
|
||||
})
|
||||
),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return AssetRecordType.create({
|
||||
id: assetId,
|
||||
type: 'image',
|
||||
typeName: 'asset',
|
||||
props: {
|
||||
name,
|
||||
sources,
|
||||
w: size.w,
|
||||
h: size.h,
|
||||
mimeType: file.type,
|
||||
isAnimated,
|
||||
},
|
||||
meta: {},
|
||||
} satisfies TLImageAsset)
|
||||
}
|
||||
|
||||
return AssetRecordType.create({
|
||||
id: assetId,
|
||||
type: isImageType ? 'image' : 'video',
|
||||
type: 'video',
|
||||
typeName: 'asset',
|
||||
props: {
|
||||
name,
|
||||
|
@ -103,9 +148,8 @@ export function registerDefaultExternalContentHandlers(
|
|||
mimeType: file.type,
|
||||
isAnimated,
|
||||
},
|
||||
meta: {},
|
||||
})
|
||||
|
||||
return asset
|
||||
})
|
||||
|
||||
// urls -> bookmark asset
|
||||
|
|
|
@ -3,6 +3,7 @@ import {
|
|||
BaseBoxShapeUtil,
|
||||
FileHelpers,
|
||||
HTMLContainer,
|
||||
TLImageAsset,
|
||||
TLImageShape,
|
||||
TLOnDoubleClickHandler,
|
||||
TLShapePartial,
|
||||
|
@ -11,6 +12,7 @@ import {
|
|||
imageShapeProps,
|
||||
structuredClone,
|
||||
toDomPrecision,
|
||||
useValue,
|
||||
} from '@tldraw/editor'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BrokenAssetIcon } from '../shared/BrokenAssetIcon'
|
||||
|
@ -44,18 +46,31 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
|
|||
}
|
||||
|
||||
component(shape: TLImageShape) {
|
||||
const containerStyle = getCroppedContainerStyle(shape)
|
||||
|
||||
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.getOnlySelectedShapeId()
|
||||
|
||||
const zoomLevel = useValue('zoom level', () => this.editor.getZoomLevel(), [this.editor])
|
||||
const asset = shape.props.assetId
|
||||
? this.editor.getAsset<TLImageAsset>(shape.props.assetId)
|
||||
: undefined
|
||||
|
||||
if (asset?.type !== 'image') {
|
||||
throw Error('Asset is not a video')
|
||||
}
|
||||
|
||||
const src = getImageSrcForZoom(asset, zoomLevel, shape.props.w / asset.props.w)
|
||||
|
||||
useEffect(() => {
|
||||
if (asset?.props.src && 'mimeType' in asset.props && asset?.props.mimeType === 'image/gif') {
|
||||
if (!asset) return
|
||||
|
||||
if (src && 'mimeType' in asset.props && asset.props.mimeType === 'image/gif') {
|
||||
let cancelled = false
|
||||
const url = asset.props.src
|
||||
const url = src
|
||||
if (!url) return
|
||||
|
||||
const image = new Image()
|
||||
|
@ -79,11 +94,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
|
|||
cancelled = true
|
||||
}
|
||||
}
|
||||
}, [prefersReducedMotion, asset?.props])
|
||||
|
||||
if (asset?.type === 'bookmark') {
|
||||
throw Error("Bookmark assets can't be rendered as images")
|
||||
}
|
||||
}, [prefersReducedMotion, src, asset])
|
||||
|
||||
const showCropPreview =
|
||||
isSelected &&
|
||||
|
@ -95,9 +106,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
|
|||
prefersReducedMotion &&
|
||||
(asset?.props.mimeType?.includes('video') || asset?.props.mimeType?.includes('gif'))
|
||||
|
||||
const containerStyle = getCroppedContainerStyle(shape)
|
||||
|
||||
if (!asset?.props.src) {
|
||||
if (!asset || !src) {
|
||||
return (
|
||||
<HTMLContainer
|
||||
id={shape.id}
|
||||
|
@ -130,7 +139,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
|
|||
style={{
|
||||
opacity: 0.1,
|
||||
backgroundImage: `url(${
|
||||
!shape.props.playing || reduceMotion ? staticFrameSrc : asset.props.src
|
||||
!shape.props.playing || reduceMotion ? staticFrameSrc : src
|
||||
})`,
|
||||
}}
|
||||
draggable={false}
|
||||
|
@ -146,7 +155,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
|
|||
className="tl-image"
|
||||
style={{
|
||||
backgroundImage: `url(${
|
||||
!shape.props.playing || reduceMotion ? staticFrameSrc : asset.props.src
|
||||
!shape.props.playing || reduceMotion ? staticFrameSrc : src
|
||||
})`,
|
||||
}}
|
||||
draggable={false}
|
||||
|
@ -171,11 +180,15 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
|
|||
}
|
||||
|
||||
override async toSvg(shape: TLImageShape) {
|
||||
const asset = shape.props.assetId ? this.editor.getAsset(shape.props.assetId) : null
|
||||
|
||||
const zoomLevel = useValue('zoom level', () => this.editor.getZoomLevel(), [this.editor])
|
||||
const asset = shape.props.assetId
|
||||
? this.editor.getAsset<TLImageAsset>(shape.props.assetId)
|
||||
: undefined
|
||||
if (!asset) return null
|
||||
|
||||
let src = asset?.props.src || ''
|
||||
let src = getImageSrcForZoom(asset, zoomLevel, shape.props.w / asset.props.w)
|
||||
if (!src) return null
|
||||
|
||||
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)) || ''
|
||||
|
@ -214,13 +227,16 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
|
|||
}
|
||||
|
||||
override onDoubleClick = (shape: TLImageShape) => {
|
||||
const asset = shape.props.assetId ? this.editor.getAsset(shape.props.assetId) : undefined
|
||||
|
||||
const zoomLevel = this.editor.getZoomLevel()
|
||||
const asset = shape.props.assetId
|
||||
? this.editor.getAsset<TLImageAsset>(shape.props.assetId)
|
||||
: undefined
|
||||
if (!asset) return
|
||||
|
||||
const canPlay =
|
||||
asset.props.src && 'mimeType' in asset.props && asset.props.mimeType === 'image/gif'
|
||||
const src = getImageSrcForZoom(asset, zoomLevel, shape.props.w / asset.props.w)
|
||||
if (!src) return
|
||||
|
||||
const canPlay = src && 'mimeType' in asset.props && asset.props.mimeType === 'image/gif'
|
||||
if (!canPlay) return
|
||||
|
||||
this.editor.updateShapes([
|
||||
|
@ -301,3 +317,23 @@ function getCroppedContainerStyle(shape: TLImageShape) {
|
|||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the image source for a given zoom level.
|
||||
*
|
||||
* @param asset - The image asset
|
||||
* @param zoomLevel - The zoom level
|
||||
* @param scale - How the image has been scaled (ie a 100x100 image resized to 200x200 would have a scale of 2)
|
||||
* @internal
|
||||
*/
|
||||
function getImageSrcForZoom(asset: TLImageAsset, zoomLevel: number, scale: number) {
|
||||
let src = asset.props.sources[0]?.src ?? null
|
||||
for (let i = 1; i < asset.props.sources.length; i++) {
|
||||
const source = asset.props.sources[i]
|
||||
if (source.scale / scale > zoomLevel) {
|
||||
src = source.src
|
||||
continue
|
||||
}
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import {
|
||||
BaseBoxShapeUtil,
|
||||
HTMLContainer,
|
||||
TLVideoAsset,
|
||||
TLVideoShape,
|
||||
toDomPrecision,
|
||||
useIsEditing,
|
||||
|
@ -36,7 +37,12 @@ export class VideoShapeUtil extends BaseBoxShapeUtil<TLVideoShape> {
|
|||
component(shape: TLVideoShape) {
|
||||
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 asset = shape.props.assetId ? editor.getAsset<TLVideoAsset>(shape.props.assetId) : null
|
||||
|
||||
if (asset?.type !== 'video') {
|
||||
throw Error('Asset is not a video')
|
||||
}
|
||||
|
||||
const { time, playing } = shape.props
|
||||
const isEditing = useIsEditing(shape.id)
|
||||
const prefersReducedMotion = usePrefersReducedMotion()
|
||||
|
|
|
@ -305,7 +305,12 @@ export async function pasteExcalidrawContent(editor: Editor, clipboard: any, poi
|
|||
name: element.id ?? 'Untitled',
|
||||
isAnimated: false,
|
||||
mimeType: file.mimeType,
|
||||
src: file.dataURL,
|
||||
sources: [
|
||||
{
|
||||
scale: 1,
|
||||
src: file.dataURL,
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {},
|
||||
})
|
||||
|
|
|
@ -70,7 +70,12 @@ export function buildFromV1Document(editor: Editor, document: LegacyTldrawDocume
|
|||
name: v1Asset.fileName ?? 'Untitled',
|
||||
isAnimated: false,
|
||||
mimeType: null,
|
||||
src: v1Asset.src,
|
||||
sources: [
|
||||
{
|
||||
scale: 1,
|
||||
src: v1Asset.src,
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {},
|
||||
}
|
||||
|
@ -624,9 +629,27 @@ function coerceDimension(d: unknown): number {
|
|||
*/
|
||||
async function tryMigrateAsset(editor: Editor, placeholderAsset: TLAsset) {
|
||||
try {
|
||||
if (placeholderAsset.type === 'bookmark' || !placeholderAsset.props.src) return
|
||||
if (placeholderAsset.type === 'bookmark') return
|
||||
|
||||
const response = await fetch(placeholderAsset.props.src)
|
||||
let src: string | null = null
|
||||
|
||||
switch (placeholderAsset.type) {
|
||||
case 'video': {
|
||||
src = placeholderAsset.props.src
|
||||
break
|
||||
}
|
||||
case 'image': {
|
||||
const biggestImageSource = placeholderAsset.props.sources.sort(
|
||||
(a, b) => b.scale - a.scale
|
||||
)[0]
|
||||
src = biggestImageSource.src
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!src) return
|
||||
|
||||
const response = await fetch(src)
|
||||
if (!response.ok) return
|
||||
|
||||
const file = new File([await response.blob()], placeholderAsset.props.name, {
|
||||
|
|
|
@ -179,34 +179,55 @@ export async function serializeTldrawJson(store: TLStore): Promise<string> {
|
|||
const records: TLRecord[] = []
|
||||
for (const record of store.allRecords()) {
|
||||
switch (record.typeName) {
|
||||
case 'asset':
|
||||
if (
|
||||
record.type !== 'bookmark' &&
|
||||
record.props.src &&
|
||||
!record.props.src.startsWith('data:')
|
||||
) {
|
||||
let assetSrcToSave
|
||||
try {
|
||||
// try to save the asset as a base64 string
|
||||
assetSrcToSave = await FileHelpers.blobToDataUrl(
|
||||
await (await fetch(record.props.src)).blob()
|
||||
)
|
||||
} catch {
|
||||
// if that fails, just save the original src
|
||||
assetSrcToSave = record.props.src
|
||||
case 'asset': {
|
||||
if (record.type === 'video') {
|
||||
if (record.props.src && !record.props.src.startsWith('data:')) {
|
||||
let assetSrcToSave: string | null = record.props.src
|
||||
try {
|
||||
assetSrcToSave = await FileHelpers.blobToDataUrl(
|
||||
await (await fetch(assetSrcToSave)).blob()
|
||||
)
|
||||
} catch {
|
||||
// noop, keep the original src
|
||||
}
|
||||
records.push({
|
||||
...record,
|
||||
props: {
|
||||
...record.props,
|
||||
src: assetSrcToSave,
|
||||
},
|
||||
})
|
||||
}
|
||||
} else if (record.type === 'image') {
|
||||
const biggestImageSource = record.props.sources.sort((a, b) => b.scale - a.scale)[0]
|
||||
if (biggestImageSource.src && !biggestImageSource.src.startsWith('data:')) {
|
||||
let assetSrcToSave: string | null = biggestImageSource.src
|
||||
try {
|
||||
assetSrcToSave = await FileHelpers.blobToDataUrl(
|
||||
await (await fetch(assetSrcToSave)).blob()
|
||||
)
|
||||
} catch {
|
||||
// noop, keep the original src
|
||||
}
|
||||
records.push({
|
||||
...record,
|
||||
props: {
|
||||
...record.props,
|
||||
sources: [
|
||||
{
|
||||
scale: 1,
|
||||
src: assetSrcToSave,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
records.push({
|
||||
...record,
|
||||
props: {
|
||||
...record.props,
|
||||
src: assetSrcToSave,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
records.push(record)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
default:
|
||||
records.push(record)
|
||||
break
|
||||
|
|
|
@ -540,7 +540,7 @@ describe('snapshots', () => {
|
|||
name: '',
|
||||
isAnimated: false,
|
||||
mimeType: 'png',
|
||||
src: '',
|
||||
sources: [],
|
||||
},
|
||||
meta: {},
|
||||
},
|
||||
|
|
|
@ -1099,7 +1099,10 @@ export type TLImageAsset = TLBaseAsset<'image', {
|
|||
isAnimated: boolean;
|
||||
mimeType: null | string;
|
||||
name: string;
|
||||
src: null | string;
|
||||
sources: {
|
||||
scale: number;
|
||||
src: string;
|
||||
}[];
|
||||
w: number;
|
||||
}>;
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ export type TLImageAsset = TLBaseAsset<
|
|||
name: string
|
||||
isAnimated: boolean
|
||||
mimeType: string | null
|
||||
src: string | null
|
||||
sources: { scale: number; src: string }[]
|
||||
}
|
||||
>
|
||||
|
||||
|
@ -28,7 +28,7 @@ export const imageAssetValidator: T.Validator<TLImageAsset> = createAssetValidat
|
|||
name: T.string,
|
||||
isAnimated: T.boolean,
|
||||
mimeType: T.string.nullable(),
|
||||
src: T.srcUrl.nullable(),
|
||||
sources: T.arrayOf(T.object({ scale: T.number, src: T.string })),
|
||||
})
|
||||
)
|
||||
|
||||
|
@ -36,6 +36,7 @@ const Versions = createMigrationIds('com.tldraw.asset.image', {
|
|||
AddIsAnimated: 1,
|
||||
RenameWidthHeight: 2,
|
||||
MakeUrlsValid: 3,
|
||||
AddSources: 4,
|
||||
} as const)
|
||||
|
||||
export { Versions as imageAssetVersions }
|
||||
|
@ -81,5 +82,25 @@ export const imageAssetMigrations = createRecordMigrationSequence({
|
|||
// noop
|
||||
},
|
||||
},
|
||||
{
|
||||
id: Versions.AddSources,
|
||||
up: (asset: any) => {
|
||||
if (asset.props.src === null) {
|
||||
asset.props.sources = []
|
||||
} else {
|
||||
asset.props.sources = [{ scale: 1, src: asset.props.src }]
|
||||
}
|
||||
delete asset.props.src
|
||||
},
|
||||
down: (asset: any) => {
|
||||
// get the largest source
|
||||
const biggestImageSource = (asset as TLImageAsset).props.sources.sort(
|
||||
(a, b) => b.scale - a.scale
|
||||
)[0]
|
||||
const src = biggestImageSource?.src ?? null
|
||||
asset.props.src = src
|
||||
delete asset.props.sources
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -1573,6 +1573,22 @@ describe('Add text align to text shapes', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Add image sources to image shape', () => {
|
||||
const { up, down } = getTestMigration(imageAssetVersions.AddSources)
|
||||
|
||||
test('up works as expected', () => {
|
||||
expect(up({ props: { src: 'abc' } })).toEqual({
|
||||
props: { sources: [{ scale: 1, src: 'abc' }] },
|
||||
})
|
||||
})
|
||||
|
||||
test('down works as expected', () => {
|
||||
expect(down({ props: { sources: [{ scale: 1, src: 'abc' }] } })).toEqual({
|
||||
props: { src: 'abc' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Extract bindings from arrows', () => {
|
||||
const { up } = getTestMigration(arrowShapeVersions.ExtractBindings)
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue