Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
310 lines
9.8 KiB
TypeScript
310 lines
9.8 KiB
TypeScript
import { useCallback, useMemo } from 'react'
|
|
import {
|
|
AssetRecordType,
|
|
Editor,
|
|
MediaHelpers,
|
|
TLAsset,
|
|
TLAssetStore,
|
|
TLPresenceStateInfo,
|
|
TLStore,
|
|
TLStoreSchemaOptions,
|
|
TLUser,
|
|
TLUserStore,
|
|
clamp,
|
|
defaultBindingUtils,
|
|
defaultShapeUtils,
|
|
getHashForString,
|
|
uniqueId,
|
|
useShallowObjectIdentity,
|
|
} from 'tldraw'
|
|
import { RemoteTLStoreWithStatus, useSync } from './useSync'
|
|
|
|
/** @public */
|
|
export interface UseSyncDemoOptions {
|
|
/**
|
|
* The room ID to sync with. Make sure the room ID is unique. The namespace is shared by
|
|
* everyone using the demo server. Consider prefixing it with your company or project name.
|
|
*/
|
|
roomId: string
|
|
|
|
/**
|
|
* User store for identity, presence and attribution.
|
|
* If not provided, a default implementation based on localStorage will be used.
|
|
*/
|
|
users?: TLUserStore
|
|
|
|
/** @internal */
|
|
host?: string
|
|
|
|
/**
|
|
* {@inheritdoc UseSyncOptions.getUserPresence}
|
|
* @public
|
|
*/
|
|
getUserPresence?(store: TLStore, user: TLUser): TLPresenceStateInfo | null
|
|
}
|
|
|
|
/**
|
|
* Safely accesses environment variables across different bundling environments.
|
|
*
|
|
* Depending on the environment this package is used in, process.env may not be available. This function
|
|
* wraps `process.env` accesses in a try/catch to prevent runtime errors in environments where process
|
|
* is not defined.
|
|
*
|
|
* 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.
|
|
*
|
|
* @param cb - Callback function that accesses an environment variable
|
|
* @returns The environment variable value if available, otherwise undefined
|
|
* @internal
|
|
*/
|
|
function getEnv(cb: () => string | undefined): string | undefined {
|
|
try {
|
|
return cb()
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
const DEMO_WORKER = getEnv(() => process.env.TLDRAW_BEMO_URL) ?? 'https://demo.tldraw.xyz'
|
|
const IMAGE_WORKER = getEnv(() => process.env.TLDRAW_IMAGE_URL) ?? 'https://images.tldraw.xyz'
|
|
|
|
/**
|
|
* Creates a tldraw store synced with a multiplayer room hosted on tldraw's demo server `https://demo.tldraw.xyz`.
|
|
*
|
|
* The store can be passed directly into the `<Tldraw />` component to enable multiplayer features.
|
|
* It will handle loading states, and enable multiplayer UX like user cursors and following.
|
|
*
|
|
* All data on the demo server is
|
|
*
|
|
* - Deleted after a day or so.
|
|
* - Publicly accessible to anyone who knows the room ID. Use your company name as a prefix to help avoid collisions, or generate UUIDs for maximum privacy.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* function MyApp() {
|
|
* const store = useSyncDemo({roomId: 'my-app-test-room'})
|
|
* return <Tldraw store={store} />
|
|
* }
|
|
* ```
|
|
*
|
|
* @param options - Options for the multiplayer demo sync store. See {@link UseSyncDemoOptions} and {@link @tldraw/editor#TLStoreSchemaOptions}.
|
|
*
|
|
* @public
|
|
*/
|
|
export function useSyncDemo(
|
|
options: UseSyncDemoOptions & TLStoreSchemaOptions
|
|
): RemoteTLStoreWithStatus {
|
|
const { roomId, host = DEMO_WORKER, ..._syncOpts } = options
|
|
const assets = useMemo(() => createDemoAssetStore(host), [host])
|
|
|
|
const syncOpts = useShallowObjectIdentity(_syncOpts)
|
|
const syncOptsWithDefaults = useMemo(() => {
|
|
if ('schema' in syncOpts && syncOpts.schema) return syncOpts
|
|
|
|
return {
|
|
...syncOpts,
|
|
shapeUtils:
|
|
'shapeUtils' in syncOpts
|
|
? [...defaultShapeUtils, ...(syncOpts.shapeUtils ?? [])]
|
|
: defaultShapeUtils,
|
|
bindingUtils:
|
|
'bindingUtils' in syncOpts
|
|
? [...defaultBindingUtils, ...(syncOpts.bindingUtils ?? [])]
|
|
: defaultBindingUtils,
|
|
}
|
|
}, [syncOpts])
|
|
|
|
return useSync({
|
|
uri: `${host}/connect/${encodeURIComponent(roomId)}`,
|
|
roomId,
|
|
assets,
|
|
onMount: useCallback(
|
|
(editor: Editor) => {
|
|
editor.registerExternalAssetHandler('url', ({ url }) =>
|
|
createAssetFromUrlUsingDemoServer(host, url)
|
|
)
|
|
},
|
|
[host]
|
|
),
|
|
...syncOptsWithDefaults,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Determines whether file uploads should be disabled for a given host.
|
|
*
|
|
* Uploads are disabled for production tldraw domains to prevent abuse of the demo server
|
|
* infrastructure. This includes tldraw.com and tldraw.xyz domains and their subdomains.
|
|
*
|
|
* @param host - The host URL to check for upload restrictions
|
|
* @returns True if uploads should be disabled, false otherwise
|
|
* @internal
|
|
*/
|
|
function shouldDisallowUploads(host: string) {
|
|
const disallowedHosts = ['tldraw.com', 'tldraw.xyz']
|
|
return disallowedHosts.some(
|
|
(disallowedHost) => host === disallowedHost || host.endsWith(`.${disallowedHost}`)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Creates an asset store implementation optimized for the tldraw demo server.
|
|
*
|
|
* This asset store handles file uploads to the demo server and provides intelligent
|
|
* asset resolution with automatic image optimization based on network conditions,
|
|
* screen density, and display size. It includes safeguards to prevent uploads to
|
|
* production domains and optimizes images through the tldraw image processing service.
|
|
*
|
|
* @param host - The demo server host URL for file uploads and asset resolution
|
|
* @returns A TLAssetStore implementation with upload and resolve capabilities
|
|
* @example
|
|
* ```ts
|
|
* const assetStore = createDemoAssetStore('https://demo.tldraw.xyz')
|
|
*
|
|
* // Upload a file
|
|
* const result = await assetStore.upload(asset, file)
|
|
* console.log('Uploaded to:', result.src)
|
|
*
|
|
* // Resolve optimized asset URL
|
|
* const optimizedUrl = assetStore.resolve(imageAsset, {
|
|
* steppedScreenScale: 1.5,
|
|
* dpr: 2,
|
|
* networkEffectiveType: '4g'
|
|
* })
|
|
* ```
|
|
* @internal
|
|
*/
|
|
function createDemoAssetStore(host: string): TLAssetStore {
|
|
return {
|
|
upload: async (_asset, file) => {
|
|
if (shouldDisallowUploads(host)) {
|
|
alert('Uploading images is disabled in this demo.')
|
|
throw new Error('Uploading images is disabled in this demo.')
|
|
}
|
|
const objectName = `${uniqueId()}-${file.name}`.replace(/\W/g, '-')
|
|
const url = `${host}/uploads/${objectName}`
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
body: file,
|
|
})
|
|
// a rejected upload (invalid name, duplicate, server error) resolves the fetch; without
|
|
// this the asset would point at a URL that was never stored
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to upload asset (${response.status})`)
|
|
}
|
|
|
|
return { src: url }
|
|
},
|
|
|
|
resolve(asset, context) {
|
|
const { src } = asset.props
|
|
if (!src) return null
|
|
|
|
// We don't deal with videos at the moment.
|
|
if (asset.type === 'video') return 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 (!src.startsWith('http:') && !src.startsWith('https:')) return src
|
|
|
|
if (context.shouldResolveToOriginal) return src
|
|
|
|
// Don't try to transform animated images.
|
|
if (MediaHelpers.isAnimatedImageType(asset.props.mimeType) || asset.props.isAnimated)
|
|
return src
|
|
|
|
// Don't try to transform vector images.
|
|
if (MediaHelpers.isVectorImageType(asset.props.mimeType)) return src
|
|
|
|
const url = new URL(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 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 { fileSize = 0 } = asset.props
|
|
const isWorthResizing = 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 pixelRatio = asset.props.pixelRatio ?? 1
|
|
const trueWidth = asset.props.w * pixelRatio
|
|
const width = Math.ceil(
|
|
Math.min(
|
|
trueWidth *
|
|
clamp(context.steppedScreenScale, 1 / 32, 1) *
|
|
networkCompensation *
|
|
context.dpr,
|
|
trueWidth
|
|
)
|
|
)
|
|
|
|
url.searchParams.set('w', width.toString())
|
|
}
|
|
|
|
return `${IMAGE_WORKER}/${url.host}/${url.toString().slice(url.origin.length + 1)}`
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a bookmark asset by fetching metadata from a URL using the demo server.
|
|
*
|
|
* This function uses the demo server's bookmark unfurling service to extract metadata
|
|
* like title, description, favicon, and preview image from a given URL. If the metadata
|
|
* fetch fails, it returns a blank bookmark asset with just the URL.
|
|
*
|
|
* @param host - The demo server host URL to use for bookmark unfurling
|
|
* @param url - The URL to create a bookmark asset from
|
|
* @returns A promise that resolves to a TLAsset of type 'bookmark' with extracted metadata
|
|
* @example
|
|
* ```ts
|
|
* const asset = await createAssetFromUrlUsingDemoServer(
|
|
* 'https://demo.tldraw.xyz',
|
|
* 'https://example.com'
|
|
* )
|
|
*
|
|
* console.log(asset.props.title) // "Example Domain"
|
|
* console.log(asset.props.description) // "This domain is for use in illustrative examples..."
|
|
* ```
|
|
* @internal
|
|
*/
|
|
async function createAssetFromUrlUsingDemoServer(host: string, url: string): Promise<TLAsset> {
|
|
let meta: { description?: string; image?: string; favicon?: string; title?: string } | null = null
|
|
try {
|
|
// First, try to get the meta data from our endpoint
|
|
const fetchUrl = new URL(`${host}/bookmarks/unfurl`)
|
|
fetchUrl.searchParams.set('url', url)
|
|
meta = await (await fetch(fetchUrl, { method: 'POST' })).json()
|
|
} catch (error) {
|
|
// Otherwise, fall back to a blank bookmark
|
|
console.error(error)
|
|
}
|
|
|
|
return {
|
|
id: AssetRecordType.createId(getHashForString(url)),
|
|
typeName: 'asset',
|
|
type: 'bookmark',
|
|
props: {
|
|
src: url,
|
|
description: meta?.description ?? '',
|
|
image: meta?.image ?? '',
|
|
favicon: meta?.favicon ?? '',
|
|
title: meta?.title ?? '',
|
|
},
|
|
meta: {},
|
|
}
|
|
}
|