snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
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
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
This commit is contained in:
604
packages/sync/src/useSync.ts
Normal file
604
packages/sync/src/useSync.ts
Normal file
@@ -0,0 +1,604 @@
|
||||
import { atom, transact } from '@tldraw/state'
|
||||
import {
|
||||
ClientWebSocketAdapter,
|
||||
TLCustomMessageHandler,
|
||||
TLObjectStoreAccess,
|
||||
TLPersistentClientSocket,
|
||||
TLPresenceMode,
|
||||
TLRemoteSyncError,
|
||||
TLSocketClientSentEvent,
|
||||
TLSocketServerSentEvent,
|
||||
TLSyncClient,
|
||||
TLSyncErrorCloseEventReason,
|
||||
} from '@tldraw/sync-core'
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
Editor,
|
||||
TAB_ID,
|
||||
TLAssetStore,
|
||||
TLPresenceStateInfo,
|
||||
TLRecord,
|
||||
TLStore,
|
||||
TLStoreSchemaOptions,
|
||||
TLStoreWithStatus,
|
||||
TLThemes,
|
||||
TLUser,
|
||||
TLUserStore,
|
||||
UserRecordType,
|
||||
computed,
|
||||
createCachedUserResolve,
|
||||
createPresenceStateDerivation,
|
||||
registerColorsFromThemes,
|
||||
registerFontsFromThemes,
|
||||
resolveThemes,
|
||||
createTLStore,
|
||||
createUserId,
|
||||
defaultUserPreferences,
|
||||
defaultUserStore,
|
||||
getDefaultUserPresence,
|
||||
getUserPreferences,
|
||||
uniqueId,
|
||||
useEvent,
|
||||
useReactiveEvent,
|
||||
useRefState,
|
||||
useTLSchemaFromUtils,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
|
||||
const MULTIPLAYER_EVENT_NAME = 'multiplayer.client'
|
||||
|
||||
const defaultCustomMessageHandler: TLCustomMessageHandler = () => {}
|
||||
|
||||
// Keyed by `reason`, which is an arbitrary string off the server's close event, so a Map rather
|
||||
// than an object literal: a reason of 'constructor' or '__proto__' would find a prototype member.
|
||||
const SYNC_ERROR_EVENT_NAMES = new Map<string, string>([
|
||||
[TLSyncErrorCloseEventReason.NOT_FOUND, 'room-not-found'],
|
||||
[TLSyncErrorCloseEventReason.FORBIDDEN, 'forbidden'],
|
||||
[TLSyncErrorCloseEventReason.NOT_AUTHENTICATED, 'not-authenticated'],
|
||||
[TLSyncErrorCloseEventReason.RATE_LIMITED, 'rate-limited'],
|
||||
])
|
||||
|
||||
type SyncSocket = TLPersistentClientSocket<
|
||||
TLSocketClientSentEvent<TLRecord>,
|
||||
TLSocketServerSentEvent<TLRecord>
|
||||
>
|
||||
|
||||
// The socket reports 'error' but the collaboration status only knows 'offline'.
|
||||
function toCollaborationStatus(status: SyncSocket['connectionStatus']) {
|
||||
return status === 'error' ? 'offline' : status
|
||||
}
|
||||
|
||||
/**
|
||||
* A store wrapper specifically for remote collaboration that excludes local-only states.
|
||||
* This type represents a tldraw store that is synchronized with a remote multiplayer server.
|
||||
*
|
||||
* Unlike the base TLStoreWithStatus, this excludes 'synced-local' and 'not-synced' states
|
||||
* since remote stores are always either loading, connected to a server, or in an error state.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyCollaborativeApp() {
|
||||
* const store: RemoteTLStoreWithStatus = useSync({
|
||||
* uri: 'wss://myserver.com/sync/room-123',
|
||||
* assets: myAssetStore
|
||||
* })
|
||||
*
|
||||
* if (store.status === 'loading') {
|
||||
* return <div>Connecting to multiplayer session...</div>
|
||||
* }
|
||||
*
|
||||
* if (store.status === 'error') {
|
||||
* return <div>Connection failed: {store.error.message}</div>
|
||||
* }
|
||||
*
|
||||
* // store.status === 'synced-remote'
|
||||
* return <Tldraw store={store.store} />
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type RemoteTLStoreWithStatus =
|
||||
| Exclude<
|
||||
TLStoreWithStatus,
|
||||
{ status: 'synced-local' } | { status: 'not-synced' } | { status: 'synced-remote' }
|
||||
>
|
||||
| (Extract<TLStoreWithStatus, { status: 'synced-remote' }> & {
|
||||
/**
|
||||
* Write access for object-store lane record types (e.g. comments), as granted by the
|
||||
* server for this session. Independent of the canvas read-only state, so a session can
|
||||
* be allowed to comment without being allowed to edit. Defaults to `'write'`.
|
||||
*/
|
||||
readonly objectAccess: TLObjectStoreAccess
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates a reactive store synchronized with a multiplayer server for real-time collaboration.
|
||||
*
|
||||
* This hook manages the complete lifecycle of a collaborative tldraw session, including
|
||||
* WebSocket connection establishment, state synchronization, user presence, and error handling.
|
||||
* The returned store can be passed directly to the Tldraw component to enable multiplayer features.
|
||||
*
|
||||
* The store progresses through multiple states:
|
||||
* - `loading`: Establishing connection and synchronizing initial state
|
||||
* - `synced-remote`: Successfully connected and actively synchronizing changes
|
||||
* - `error`: Connection failed or synchronization error occurred
|
||||
*
|
||||
* For optimal performance with media assets, provide an `assets` store that implements
|
||||
* external blob storage. Without this, large images and videos will be stored inline
|
||||
* as base64, causing performance issues during serialization.
|
||||
*
|
||||
* @param opts - Configuration options for multiplayer synchronization
|
||||
* - `uri` - WebSocket server URI (string or async function returning URI)
|
||||
* - `assets` - Asset store for blob storage (required for production use)
|
||||
* - `users` - User store for identity, presence and attribution
|
||||
* - `getUserPresence` - Optional function to customize presence data
|
||||
* - `onCustomMessageReceived` - Handler for custom socket messages
|
||||
* - `roomId` - Room identifier for analytics (internal use)
|
||||
* - `onMount` - Callback when editor mounts (internal use)
|
||||
* - `trackAnalyticsEvent` - Analytics event tracker (internal use)
|
||||
*
|
||||
* @returns A reactive store wrapper with connection status and synchronized store
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic multiplayer setup
|
||||
* function CollaborativeApp() {
|
||||
* const store = useSync({
|
||||
* uri: 'wss://myserver.com/sync/room-123',
|
||||
* assets: myAssetStore,
|
||||
* users: {
|
||||
* currentUser: computed('current-user', () => ({
|
||||
* id: createUserId('user-1'),
|
||||
* name: 'Alice',
|
||||
* color: '#ff0000',
|
||||
* meta: {},
|
||||
* })),
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* if (store.status === 'loading') {
|
||||
* return <div>Connecting to collaboration session...</div>
|
||||
* }
|
||||
*
|
||||
* if (store.status === 'error') {
|
||||
* return <div>Failed to connect: {store.error.message}</div>
|
||||
* }
|
||||
*
|
||||
* return <Tldraw store={store.store} />
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Dynamic authentication with user store
|
||||
* function AuthenticatedApp() {
|
||||
* const store = useSync({
|
||||
* uri: async () => {
|
||||
* const token = await getAuthToken()
|
||||
* return `wss://myserver.com/sync/room-123?token=${token}`
|
||||
* },
|
||||
* assets: authenticatedAssetStore,
|
||||
* users: myUserStore,
|
||||
* getUserPresence: (store, user) => {
|
||||
* return {
|
||||
* userId: user.id,
|
||||
* userName: user.name,
|
||||
* cursor: getCurrentCursor(store)
|
||||
* }
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* return <Tldraw store={store.store} />
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus {
|
||||
const [state, setState] = useRefState<{
|
||||
readyClient?: TLSyncClient<TLRecord, TLStore>
|
||||
error?: Error
|
||||
objectAccess?: TLObjectStoreAccess
|
||||
} | null>(null)
|
||||
const {
|
||||
uri,
|
||||
roomId = 'default',
|
||||
assets,
|
||||
users: _users,
|
||||
onMount,
|
||||
connect,
|
||||
trackAnalyticsEvent: track,
|
||||
getUserPresence: _getUserPresence,
|
||||
onCustomMessageReceived: _onCustomMessageReceived,
|
||||
themes,
|
||||
...schemaOpts
|
||||
} = opts
|
||||
|
||||
// This line will throw a type error if we add any new options to the useSync hook but we don't destructure them
|
||||
// This is required because otherwise the useTLSchemaFromUtils might return a new schema on every render if the newly-added option
|
||||
// is allowed to be unstable
|
||||
const __never__: never = 0 as any as keyof Omit<typeof schemaOpts, keyof TLStoreSchemaOptions>
|
||||
|
||||
const resolvedThemes = resolveThemes(themes)
|
||||
registerColorsFromThemes(resolvedThemes)
|
||||
registerFontsFromThemes(resolvedThemes)
|
||||
const schema = useTLSchemaFromUtils(schemaOpts)
|
||||
|
||||
const getUserPresence = useReactiveEvent(
|
||||
(_getUserPresence ?? getDefaultUserPresence) as typeof getDefaultUserPresence
|
||||
)
|
||||
const onCustomMessageReceived = useEvent(_onCustomMessageReceived ?? defaultCustomMessageHandler)
|
||||
|
||||
useEffect(() => {
|
||||
const storeId = uniqueId()
|
||||
|
||||
const currentUserSignal = _users?.currentUser ?? defaultUserStore.currentUser
|
||||
const users: Required<TLUserStore> = {
|
||||
currentUser: currentUserSignal,
|
||||
resolve:
|
||||
_users?.resolve ??
|
||||
createCachedUserResolve((userId) => {
|
||||
const id = createUserId(userId)
|
||||
const current = currentUserSignal.get()
|
||||
if (current && current.id === id) return current
|
||||
// Only the default user store falls back to presence records for other users.
|
||||
if (_users) return null
|
||||
const match = store.query
|
||||
.records('instance_presence')
|
||||
.get()
|
||||
.find((p) => p.userId === id)
|
||||
return match
|
||||
? UserRecordType.create({ id, name: match.userName, color: match.color })
|
||||
: null
|
||||
}),
|
||||
}
|
||||
|
||||
// This always returns a non-null user for presence display, falling back
|
||||
// to anonymous user preferences. The store receives the raw `users` object
|
||||
// (where currentUser may return null), so attribution via
|
||||
// getAttributionUserId() correctly returns null for anonymous sessions.
|
||||
const currentUser = computed<TLUser>('currentUser', () => {
|
||||
const user = users.currentUser.get()
|
||||
if (user) return user
|
||||
const prefs = getUserPreferences()
|
||||
return UserRecordType.create({
|
||||
id: createUserId(prefs.id),
|
||||
name: prefs.name ?? '',
|
||||
color: prefs.color ?? defaultUserPreferences.color,
|
||||
})
|
||||
})
|
||||
|
||||
let socket: SyncSocket
|
||||
if (connect) {
|
||||
if (uri) {
|
||||
throw new Error('uri and connect cannot be used together')
|
||||
}
|
||||
|
||||
socket = connect({ sessionId: TAB_ID, storeId }) as SyncSocket
|
||||
} else if (uri) {
|
||||
socket = new ClientWebSocketAdapter(async () => {
|
||||
// set sessionId as a query param on the uri
|
||||
const withParams = new URL(typeof uri === 'string' ? uri : await uri())
|
||||
for (const param of ['sessionId', 'storeId']) {
|
||||
if (withParams.searchParams.has(param)) {
|
||||
throw new Error(
|
||||
`useSync. "${param}" is a reserved query param name. Please use a different name`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
withParams.searchParams.set('sessionId', TAB_ID)
|
||||
withParams.searchParams.set('storeId', storeId)
|
||||
return withParams.toString()
|
||||
})
|
||||
} else {
|
||||
throw new Error('uri or connect must be provided')
|
||||
}
|
||||
|
||||
let didCancel = false
|
||||
|
||||
const collaborationStatusSignal = atom(
|
||||
'collaboration status',
|
||||
toCollaborationStatus(socket.connectionStatus)
|
||||
)
|
||||
const unsubscribeFromConnectionStatus = socket.onStatusChange(() => {
|
||||
collaborationStatusSignal.set(toCollaborationStatus(socket.connectionStatus))
|
||||
})
|
||||
|
||||
const syncMode = atom('sync mode', 'readwrite' as 'readonly' | 'readwrite')
|
||||
|
||||
const store = createTLStore({
|
||||
id: storeId,
|
||||
schema,
|
||||
assets,
|
||||
users,
|
||||
onMount,
|
||||
collaboration: {
|
||||
status: collaborationStatusSignal,
|
||||
mode: syncMode,
|
||||
},
|
||||
})
|
||||
|
||||
const presence = createPresenceStateDerivation(currentUser, {
|
||||
getUserPresence,
|
||||
})(store)
|
||||
|
||||
// Every connected session — each tab, window, or device — pushes its
|
||||
// presence on connect, so the store holds one instance_presence record per
|
||||
// *other* session in the room, including the user's own other tabs. (The
|
||||
// server never echoes a session its own record.) So an empty set means
|
||||
// we're genuinely the only session and can throttle to solo; any other
|
||||
// session — another user, or just another tab of our own — keeps us at the
|
||||
// full sync rate so edits propagate without the solo-mode lag.
|
||||
const otherSessions = store.query.ids('instance_presence')
|
||||
|
||||
const presenceMode = computed<TLPresenceMode>('presenceMode', () => {
|
||||
return otherSessions.get().size === 0 ? 'solo' : 'full'
|
||||
})
|
||||
|
||||
const client = new TLSyncClient<TLRecord, TLStore>({
|
||||
store,
|
||||
socket,
|
||||
didCancel: () => didCancel,
|
||||
onLoad(client) {
|
||||
track?.(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })
|
||||
// Keep objectAccess (onAfterConnect always runs first for this client) but drop the
|
||||
// rest: an `error` left behind by a previous client (e.g. NOT_FOUND on the old uri)
|
||||
// would otherwise keep the hook reporting status 'error' for the new, healthy room.
|
||||
setState((prev) => ({ readyClient: client, objectAccess: prev?.objectAccess }))
|
||||
},
|
||||
onSyncError(reason) {
|
||||
console.error('sync error', reason)
|
||||
|
||||
track?.(MULTIPLAYER_EVENT_NAME, {
|
||||
name: SYNC_ERROR_EVENT_NAMES.get(reason) ?? 'sync-error:' + reason,
|
||||
roomId,
|
||||
})
|
||||
setState({ error: new TLRemoteSyncError(reason) })
|
||||
socket.close()
|
||||
},
|
||||
onAfterConnect(_, { isReadonly, objectAccess }) {
|
||||
// Object-lane (comment) write access is decided per session by the server and can
|
||||
// change on reconnect (e.g. when the file's share tier changes), so surface it on
|
||||
// the returned status for consumers that gate comment UI. This fires before the
|
||||
// first onLoad, hence the merge in both directions.
|
||||
setState((prev) => ({ ...prev, objectAccess }))
|
||||
transact(() => {
|
||||
syncMode.set(isReadonly ? 'readonly' : 'readwrite')
|
||||
// if the server crashes and loses all data it can return an empty document
|
||||
// when it comes back up. This is a safety check to make sure that if something like
|
||||
// that happens, it won't render the app broken and require a restart. The user will
|
||||
// most likely lose all their changes though since they'll have been working with pages
|
||||
// that won't exist. There's certainly something we can do to make this better.
|
||||
// but the likelihood of this happening is very low and maybe not worth caring about beyond this.
|
||||
store.ensureStoreIsUsable()
|
||||
})
|
||||
},
|
||||
onCustomMessageReceived,
|
||||
presence,
|
||||
presenceMode,
|
||||
})
|
||||
|
||||
return () => {
|
||||
didCancel = true
|
||||
unsubscribeFromConnectionStatus()
|
||||
client.close()
|
||||
socket.close()
|
||||
}
|
||||
}, [
|
||||
assets,
|
||||
onMount,
|
||||
connect,
|
||||
_users,
|
||||
roomId,
|
||||
schema,
|
||||
setState,
|
||||
track,
|
||||
uri,
|
||||
getUserPresence,
|
||||
onCustomMessageReceived,
|
||||
])
|
||||
|
||||
return useValue<RemoteTLStoreWithStatus>(
|
||||
'remote synced store',
|
||||
() => {
|
||||
if (!state) return { status: 'loading' }
|
||||
if (state.error) return { status: 'error', error: state.error }
|
||||
if (!state.readyClient) return { status: 'loading' }
|
||||
return {
|
||||
status: 'synced-remote',
|
||||
connectionStatus: toCollaborationStatus(state.readyClient.socket.connectionStatus),
|
||||
store: state.readyClient.store,
|
||||
objectAccess: state.objectAccess ?? 'write',
|
||||
}
|
||||
},
|
||||
[state]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for the {@link useSync} hook to establish multiplayer collaboration.
|
||||
*
|
||||
* This interface defines the required and optional settings for connecting to a multiplayer
|
||||
* server, managing user presence, handling assets, and customizing the collaboration experience.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const syncOptions: UseSyncOptions = {
|
||||
* uri: 'wss://myserver.com/sync/room-123',
|
||||
* assets: myAssetStore,
|
||||
* users: {
|
||||
* currentUser: myCurrentUserSignal,
|
||||
* },
|
||||
* getUserPresence: (store, user) => ({
|
||||
* userId: user.id,
|
||||
* userName: user.name,
|
||||
* cursor: getCursorPosition()
|
||||
* })
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface UseSyncOptionsBase {
|
||||
/**
|
||||
* Named theme definitions. When provided, custom color names are automatically
|
||||
* registered before the store is constructed so persisted data with those
|
||||
* colors passes validation on load.
|
||||
*/
|
||||
themes?: Partial<TLThemes>
|
||||
|
||||
/**
|
||||
* Asset store implementation for handling file uploads and storage.
|
||||
*
|
||||
* Required for production applications to handle images, videos, and other
|
||||
* media efficiently. Without an asset store, files are stored inline as
|
||||
* base64, which causes performance issues with large files.
|
||||
*
|
||||
* The asset store must implement upload (for new files) and resolve
|
||||
* (for displaying existing files) methods. For prototyping, you can use
|
||||
* {@link @tldraw/editor#inlineBase64AssetStore} but this is not recommended for production.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const myAssetStore: TLAssetStore = {
|
||||
* upload: async (asset, file) => {
|
||||
* const url = await uploadToCloudStorage(file)
|
||||
* return { src: url }
|
||||
* },
|
||||
* resolve: (asset, context) => {
|
||||
* return getOptimizedUrl(asset.src, context)
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
assets: TLAssetStore
|
||||
|
||||
/**
|
||||
* User store for identity, presence and attribution.
|
||||
*
|
||||
* Both methods return reactive {@link @tldraw/state#Signal | Signals}.
|
||||
* `currentUser` provides the current user's identity (used for
|
||||
* both presence broadcasting and shape attribution) and optionally
|
||||
* `resolve(userId)` looks up other users by ID. If not provided,
|
||||
* a default implementation backed by localStorage user preferences is
|
||||
* used, with `resolve` falling back to presence records in the store.
|
||||
*/
|
||||
users?: TLUserStore
|
||||
|
||||
/**
|
||||
* Handler for receiving custom messages sent through the multiplayer connection.
|
||||
*
|
||||
* Use this to implement custom communication channels between clients beyond
|
||||
* the standard shape and presence synchronization. Messages are sent using
|
||||
* the TLSyncClient's sendMessage method.
|
||||
*
|
||||
* @param data - The custom message data received from another client
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* onCustomMessageReceived: (data) => {
|
||||
* if (data.type === 'chat') {
|
||||
* displayChatMessage(data.message, data.userId)
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
onCustomMessageReceived?(data: any): void
|
||||
|
||||
/** @internal */
|
||||
onMount?(editor: Editor): void
|
||||
/** @internal used for analytics only, we should refactor this away */
|
||||
roomId?: string
|
||||
/** @internal */
|
||||
trackAnalyticsEvent?(name: string, data: { [key: string]: any }): void
|
||||
|
||||
/**
|
||||
* A reactive function that returns a {@link @tldraw/tlschema#TLInstancePresence} object.
|
||||
*
|
||||
* This function is called reactively whenever the store state changes and
|
||||
* determines what presence information to broadcast to other clients. The
|
||||
* result is synchronized across all connected clients for displaying cursors,
|
||||
* selections, and other collaborative indicators.
|
||||
*
|
||||
* If not provided, uses the default implementation which includes standard
|
||||
* cursor position and selection state. Custom implementations allow you to
|
||||
* add additional presence data like current tool, view state, or custom status.
|
||||
*
|
||||
* See {@link @tldraw/tlschema#getDefaultUserPresence} for
|
||||
* the default implementation of this function.
|
||||
*
|
||||
* @param store - The current TLStore
|
||||
* @param user - The current user information
|
||||
* @returns Presence state to broadcast to other clients, or null to hide presence
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* getUserPresence: (store, user) => {
|
||||
* return {
|
||||
* userId: user.id,
|
||||
* userName: user.name,
|
||||
* cursor: { x: 100, y: 200 },
|
||||
* currentTool: 'select',
|
||||
* isActive: true
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
getUserPresence?(store: TLStore, user: TLUser): TLPresenceStateInfo | null
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface UseSyncOptionsWithUri extends UseSyncOptionsBase {
|
||||
/**
|
||||
* The WebSocket URI of the multiplayer server for real-time synchronization.
|
||||
*
|
||||
* Must include the protocol (wss:// for secure, ws:// for local development).
|
||||
* HTTP/HTTPS URLs will be automatically upgraded to WebSocket connections.
|
||||
*
|
||||
* Can be a static string or a function that returns a URI (useful for dynamic
|
||||
* authentication tokens or room routing). The function is called on each
|
||||
* connection attempt, allowing for token refresh and dynamic routing.
|
||||
*
|
||||
* Reserved query parameters `sessionId` and `storeId` are automatically added
|
||||
* by the sync system and should not be included in your URI.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Static URI
|
||||
* uri: 'wss://myserver.com/sync/room-123'
|
||||
*
|
||||
* // Dynamic URI with authentication
|
||||
* uri: async () => {
|
||||
* const token = await getAuthToken()
|
||||
* return `wss://myserver.com/sync/room-123?token=${token}`
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
uri: string | (() => string | Promise<string>)
|
||||
connect?: never
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface UseSyncOptionsWithConnectFn extends UseSyncOptionsBase {
|
||||
/**
|
||||
* Create a connection to the server. Mostly you should use {@link UseSyncOptionsWithUri.uri}
|
||||
* instead, but this is useful if you want to use a custom transport to connect to the server,
|
||||
* instead of our default websocket-based transport.
|
||||
*/
|
||||
connect: UseSyncConnectFn
|
||||
uri?: never
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type UseSyncConnectFn = (query: {
|
||||
sessionId: string
|
||||
storeId: string
|
||||
}) => TLPersistentClientSocket
|
||||
|
||||
/**
|
||||
* Options for the {@link useSync} hook.
|
||||
* @public
|
||||
*/
|
||||
export type UseSyncOptions = UseSyncOptionsWithUri | UseSyncOptionsWithConnectFn
|
||||
Reference in New Issue
Block a user