[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:
parent
92fa5304f5
commit
965bc10997
41 changed files with 1091 additions and 1195 deletions
|
@ -1 +1,2 @@
|
|||
export { useDemoRemoteSyncClient, type UseDemoSyncClientConfig } from './useDemoSyncClient'
|
||||
export { useRemoteSyncClient, type RemoteTLStoreWithStatus } from './useRemoteSyncClient'
|
||||
|
|
118
packages/sync-react/src/useDemoSyncClient.ts
Normal file
118
packages/sync-react/src/useDemoSyncClient.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue