[1/4] Blob storage in TLStore (#4068)

Reworks the store to include information about how blob assets
(images/videos) are stored/retrieved. This replaces the old
internal-only `assetOptions` prop, and supplements the existing
`registerExternalAssetHandler` API.

Previously, `registerExternalAssetHandler` had two responsibilities:
1. Extracting asset metadata
2. Uploading the asset and returning its URL

Existing `registerExternalAssetHandler` implementation will still work,
but now uploading is the responsibility of a new `editor.uploadAsset`
method which calls the new store-based upload method. Our default asset
handlers extract metadata, then call that new API. I think this is a
pretty big improvement over what we had before: overriding uploads was a
pretty common ask, but doing so meant having to copy paste our metadata
extraction which felt pretty fragile. Just in this codebase, we had a
bunch of very slightly different metadata extraction code-paths that had
been copy-pasted around then diverged over time. Now, you can change how
uploads work without having to mess with metadata extraction and
vice-versa.

As part of this we also:
1. merge the old separate asset indexeddb store with the main one.
because this warrants some pretty big migration stuff, i refactored our
indexed-db helpers to work around an instance instead of being free
functions
2. move our existing asset stuff over to the new approach
3. add a new hook in `sync-react` to create a demo store with the new
assets

### Change type

- [x] `api`

### Release notes

Introduce a new `assets` option for the store, describing how to save
and retrieve asset blobs like images & videos from e.g. a user-content
CDN. These are accessible through `editor.uploadAsset` and
`editor.resolveAssetUrl`. This supplements the existing
`registerExternalAssetHandler` API: `registerExternalAssetHandler` is
for customising metadata extraction, and should call
`editor.uploadAsset` to save assets. Existing
`registerExternalAssetHandler` calls will still work, but if you're only
using them to configure uploads and don't want to customise metadata
extraction, consider switching to the new `assets` store prop.
This commit is contained in:
alex 2024-07-10 14:00:18 +01:00 committed by GitHub
parent 92fa5304f5
commit 965bc10997
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1091 additions and 1195 deletions

View file

@ -1 +1,2 @@
export { useDemoRemoteSyncClient, type UseDemoSyncClientConfig } from './useDemoSyncClient'
export { useRemoteSyncClient, type RemoteTLStoreWithStatus } from './useRemoteSyncClient'

View file

@ -0,0 +1,118 @@
import { useMemo } from 'react'
import { MediaHelpers, Signal, TLAssetStore, TLUserPreferences, uniqueId } from 'tldraw'
import { RemoteTLStoreWithStatus, useRemoteSyncClient } from './useRemoteSyncClient'
/** @public */
export interface UseDemoSyncClientConfig {
roomId: string
userPreferences?: Signal<TLUserPreferences>
/** @internal */
host?: string
}
/**
* Depending on the environment this package is used in, process.env may not be available. Wrap
* `process.env` accesses in this to make sure they don't fail.
*
* The reason that this is just a try/catch and not a dynamic check e.g. `process &&
* process.env[key]` is that many bundlers implement `process.env.WHATEVER` using compile-time
* string replacement, rather than actually creating a runtime implementation of a `process` object.
*/
function getEnv(cb: () => string | undefined): string | undefined {
try {
return cb()
} catch {
return undefined
}
}
const DEMO_WORKER = getEnv(() => process.env.DEMO_WORKER) ?? 'https://demo.tldraw.xyz'
const IMAGE_WORKER = getEnv(() => process.env.IMAGE_WORKER) ?? 'https://images.tldraw.xyz'
export function useDemoRemoteSyncClient({
roomId,
userPreferences,
host = DEMO_WORKER,
}: UseDemoSyncClientConfig): RemoteTLStoreWithStatus {
const assets = useMemo(() => createDemoAssetStore(host), [host])
return useRemoteSyncClient({
uri: `${host}/connect/${roomId}`,
roomId,
userPreferences,
assets,
})
}
function createDemoAssetStore(host: string): TLAssetStore {
return {
upload: async (asset, file) => {
const id = uniqueId()
const objectName = `${id}-${file.name}`.replaceAll(/[^a-zA-Z0-9.]/g, '-')
const url = `${host}/uploads/${objectName}`
await fetch(url, {
method: 'POST',
body: file,
})
return url
},
resolve(asset, context) {
if (!asset.props.src) return null
// We don't deal with videos at the moment.
if (asset.type === 'video') return asset.props.src
// Assert it's an image to make TS happy.
if (asset.type !== 'image') return null
// Don't try to transform data: URLs, yikes.
if (!asset.props.src.startsWith('http:') && !asset.props.src.startsWith('https:'))
return asset.props.src
if (context.shouldResolveToOriginal) return asset.props.src
// Don't try to transform animated images.
if (MediaHelpers.isAnimatedImageType(asset?.props.mimeType) || asset.props.isAnimated)
return asset.props.src
// Don't try to transform vector images.
if (MediaHelpers.isVectorImageType(asset?.props.mimeType)) return asset.props.src
const url = new URL(asset.props.src)
// we only transform images that are hosted on domains we control
const isTldrawImage =
url.origin === host || /\.tldraw\.(?:com|xyz|dev|workers\.dev)$/.test(url.host)
if (!isTldrawImage) return asset.props.src
// Assets that are under a certain file size aren't worth transforming (and incurring cost).
// We still send them through the image worker to get them optimized though.
const isWorthResizing =
asset.props.fileSize !== -1 && asset.props.fileSize >= 1024 * 1024 * 1.5
if (isWorthResizing) {
// N.B. navigator.connection is only available in certain browsers (mainly Blink-based browsers)
// 4g is as high the 'effectiveType' goes and we can pick a lower effective image quality for slower connections.
const networkCompensation =
!context.networkEffectiveType || context.networkEffectiveType === '4g' ? 1 : 0.5
const width = Math.ceil(
Math.min(
asset.props.w * context.steppedScreenScale * networkCompensation * context.dpr,
asset.props.w
)
)
url.searchParams.set('w', width.toString())
}
const newUrl = `${IMAGE_WORKER}/${url.host}/${url.toString().slice(url.origin.length + 1)}`
return newUrl
},
}
}

View file

@ -11,9 +11,9 @@ import { useEffect, useState } from 'react'
import {
Signal,
TAB_ID,
TLAssetStore,
TLRecord,
TLStore,
TLStoreSnapshot,
TLStoreWithStatus,
TLUserPreferences,
computed,
@ -38,9 +38,9 @@ export function useRemoteSyncClient(opts: UseSyncClientConfig): RemoteTLStoreWit
readyClient?: TLSyncClient<TLRecord, TLStore>
error?: Error
} | null>(null)
const { uri, roomId = 'default', userPreferences: prefs } = opts
const { uri, roomId = 'default', userPreferences: prefs, assets } = opts
const store = useTLStore({ schema })
const store = useTLStore({ schema, assets })
const error: NonNullable<typeof state>['error'] = state?.error ?? undefined
const track = opts.trackAnalyticsEvent
@ -138,7 +138,7 @@ export interface UseSyncClientConfig {
uri: string
roomId?: string
userPreferences?: Signal<TLUserPreferences>
snapshotForNewRoomRef?: { current: null | TLStoreSnapshot }
/* @internal */
trackAnalyticsEvent?(name: string, data: { [key: string]: any }): void
assets?: Partial<TLAssetStore>
}