Steve Ruiz 2024-05-08 15:48:05 +00:00 zatwierdzone przez GitHub
commit 4a6f5c3786
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
18 zmienionych plików z 443 dodań i 138 usunięć

Wyświetl plik

@ -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
},

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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,
},

Wyświetl plik

@ -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',

Wyświetl plik

@ -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,
},

Wyświetl plik

@ -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;

Wyświetl plik

@ -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 })

Wyświetl plik

@ -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

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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()

Wyświetl plik

@ -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: {},
})

Wyświetl plik

@ -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, {

Wyświetl plik

@ -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

Wyświetl plik

@ -540,7 +540,7 @@ describe('snapshots', () => {
name: '',
isAnimated: false,
mimeType: 'png',
src: '',
sources: [],
},
meta: {},
},

Wyświetl plik

@ -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;
}>;

Wyświetl plik

@ -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
},
},
],
})

Wyświetl plik

@ -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)