Skip to content

Commit

Permalink
custom sync presence (tldraw#5071)
Browse files Browse the repository at this point in the history
Allow supplying a custom presence derivation to `useSync` and
`useSyncDemo`.

### Change type
- [x] `api`

### Release notes

- It's now possible to customise what presence data is synced between
clients, or disable presence syncing entirely.
  • Loading branch information
SomeHats authored Dec 16, 2024
1 parent 45d1224 commit db18f4c
Show file tree
Hide file tree
Showing 24 changed files with 439 additions and 149 deletions.
12 changes: 12 additions & 0 deletions apps/examples/src/examples/sync-custom-presence/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
title: …with custom presence
component: ./SyncCustomPresence.tsx
category: collaboration
priority: 3
keywords: [basic, intro, simple, quick, start, multiplayer, sync, collaboration, presence]
multiplayer: true
---

---

This example shows how to customize the presence data synced between different tldraw instances.
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useSyncDemo } from '@tldraw/sync'
import { useEffect } from 'react'
import { Tldraw, getDefaultUserPresence, useAtom } from 'tldraw'
import 'tldraw/tldraw.css'

export default function SyncCustomUserExample({ roomId }: { roomId: string }) {
// [1]
const timer = useAtom('timer', Date.now())
useEffect(() => {
const tick = () => {
timer.set(Date.now())
frame = requestAnimationFrame(tick)
}
let frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [timer])

// [2]
const store = useSyncDemo({
roomId,
getUserPresence(store, user) {
// [2.1]
const defaults = getDefaultUserPresence(store, user)
if (!defaults) return null

return {
...defaults,

// [2.2]
camera: undefined,

// [2.3]
cursor: {
...defaults.cursor,
x: (defaults.cursor.x ?? 0) + 20 * Math.sin(timer.get() / 200),
y: (defaults.cursor.y ?? 0) + 20 * Math.cos(timer.get() / 200),
},
}
},
})

// [3]
return (
<div className="tldraw__editor">
<Tldraw store={store} deepLinks />
</div>
)
}

/**
* # Sync Custom User
*
* This example demonstrates how to use the sync demo server with custom presence state. The
* presence state is synchronized to all other clients and used for multiplayer features like
* cursors and viewport following. You can use custom presence state to change the data that's
* synced to other clients, or remove parts you don't need for your app.
*
* 1. We create a timer that updates every frame. You don't need to do this in your app, it's just
* to power an animation. We store it in an `atom` so that changes to it will cause the presence
* info to update.
*
* 2. We create a multiplayer store using the userSyncDemo hook, and pass in a custom
* `getUserPresence` function to change the presence state that gets sent.
*
* 2.1. We get the default presence state using the `getDefaultUserPresence` function. If you wanted
* to send a very minimal set of presence data, you could avoid this part.
*
* 2.2. We remove the camera from the presence state. This means that the camera position won't be
* sent to other clients. Attempting to follow this users viewport will not work.
*
* 2.3. We update the cursor position and rotation based on the current time. This will make the
* cursor spin around in a circle.
*
* 3. We create a Tldraw component and pass in the multiplayer store. This will render the editor.
*/
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default function UserPresenceExample() {
if (MOVING_CURSOR_SPEED > 0 || CURSOR_CHAT_MESSAGE) {
function loop() {
let cursor = peerPresence.cursor
if (!cursor) return
let chatMessage = peerPresence.chatMessage

const now = Date.now()
Expand All @@ -48,7 +49,7 @@ export default function UserPresenceExample() {
const t = (now % k) / k

cursor = {
...peerPresence.cursor,
...cursor,
x: 150 + Math.cos(t * Math.PI * 2) * MOVING_CURSOR_RADIUS,
y: 150 + Math.sin(t * Math.PI * 2) * MOVING_CURSOR_RADIUS,
}
Expand Down
3 changes: 3 additions & 0 deletions packages/editor/api-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -3767,6 +3767,9 @@ export { useQuickReactor }
// @internal (undocumented)
export const USER_COLORS: readonly ["#FF802B", "#EC5E41", "#F2555A", "#F04F88", "#E34BA9", "#BD54C6", "#9D5BD2", "#7B66DC", "#02B1CC", "#11B3A3", "#39B178", "#55B467"];

// @internal
export function useReactiveEvent<Args extends Array<unknown>, Result>(handler: (...args: Args) => Result): (...args: Args) => Result;

export { useReactor }

// @internal
Expand Down
2 changes: 1 addition & 1 deletion packages/editor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ export { getCursor } from './lib/hooks/useCursor'
export { EditorContext, useEditor, useMaybeEditor } from './lib/hooks/useEditor'
export { useEditorComponents } from './lib/hooks/useEditorComponents'
export type { TLEditorComponents } from './lib/hooks/useEditorComponents'
export { useEvent } from './lib/hooks/useEvent'
export { useEvent, useReactiveEvent } from './lib/hooks/useEvent'
export { useGlobalMenuIsOpen } from './lib/hooks/useGlobalMenuIsOpen'
export { useShallowArrayIdentity, useShallowObjectIdentity } from './lib/hooks/useIdentity'
export { useIsCropping } from './lib/hooks/useIsCropping'
Expand Down
4 changes: 3 additions & 1 deletion packages/editor/src/lib/components/LiveCollaborators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ const Collaborator = track(function Collaborator({
const { userId, chatMessage, brush, scribbles, selectedShapeIds, userName, cursor, color } =
latestPresence

if (!cursor) return null

// Add a little padding to the top-left of the viewport
// so that the cursor doesn't get cut off
const isCursorInViewport = !(
Expand Down Expand Up @@ -171,7 +173,7 @@ function useCollaboratorState(editor: Editor, latestPresence: TLInstancePresence
if (latestPresence) {
// We can do this on every render, it's free and cheaper than an effect
// remember, there can be lots and lots of cursors moving around all the time
rLastActivityTimestamp.current = latestPresence.lastActivityTimestamp
rLastActivityTimestamp.current = latestPresence.lastActivityTimestamp ?? Infinity
}

return state
Expand Down
17 changes: 12 additions & 5 deletions packages/editor/src/lib/editor/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {
hasOwnProperty,
last,
lerp,
maxBy,
sortById,
sortByIndex,
structuredClone,
Expand Down Expand Up @@ -2264,6 +2265,8 @@ export class Editor extends EventEmitter<TLEventMap> {
const leaderPresence = this.getCollaborators().find((c) => c.userId === followingUserId)
if (!leaderPresence) return null

if (!leaderPresence.camera || !leaderPresence.screenBounds) return null

// Fit their viewport inside of our screen bounds
// 1. calculate their viewport in page space
const { w: lw, h: lh } = leaderPresence.screenBounds
Expand Down Expand Up @@ -3161,6 +3164,9 @@ export class Editor extends EventEmitter<TLEventMap> {

if (!presence) return this

const cursor = presence.cursor
if (!cursor) return this

this.run(() => {
// If we're following someone, stop following them
if (this.getInstanceState().followingUserId !== null) {
Expand All @@ -3178,7 +3184,7 @@ export class Editor extends EventEmitter<TLEventMap> {
opts.animation = undefined
}

this.centerOnPoint(presence.cursor, opts)
this.centerOnPoint(cursor, opts)

// Highlight the user's cursor
const { highlightedUserIds } = this.getInstanceState()
Expand Down Expand Up @@ -3389,10 +3395,11 @@ export class Editor extends EventEmitter<TLEventMap> {
if (!allPresenceRecords.length) return EMPTY_ARRAY
const userIds = [...new Set(allPresenceRecords.map((c) => c.userId))].sort()
return userIds.map((id) => {
const latestPresence = allPresenceRecords
.filter((c) => c.userId === id)
.sort((a, b) => b.lastActivityTimestamp - a.lastActivityTimestamp)[0]
return latestPresence
const latestPresence = maxBy(
allPresenceRecords.filter((c) => c.userId === id),
(p) => p.lastActivityTimestamp ?? 0
)
return latestPresence!
})
}

Expand Down
29 changes: 29 additions & 0 deletions packages/editor/src/lib/hooks/useEvent.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useAtom } from '@tldraw/state-react'
import { assert } from '@tldraw/utils'
import { useCallback, useDebugValue, useLayoutEffect, useRef } from 'react'

Expand Down Expand Up @@ -42,3 +43,31 @@ export function useEvent<Args extends Array<unknown>, Result>(
return fn(...args)
}, [])
}

/**
* like {@link useEvent}, but for use in reactive contexts - when the handler function changes, it
* will invalidate any reactive contexts that call it.
* @internal
*/
export function useReactiveEvent<Args extends Array<unknown>, Result>(
handler: (...args: Args) => Result
): (...args: Args) => Result {
const handlerAtom = useAtom<(...args: Args) => Result>('useReactiveEvent', () => handler)

// In a real implementation, this would run before layout effects
useLayoutEffect(() => {
handlerAtom.set(handler)
})

useDebugValue(handler)

return useCallback(
(...args: Args) => {
// In a real implementation, this would throw if called during render
const fn = handlerAtom.get()
assert(fn, 'fn does not exist')
return fn(...args)
},
[handlerAtom]
)
}
3 changes: 2 additions & 1 deletion packages/store/api-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { Atom } from '@tldraw/state';
import { Computed } from '@tldraw/state';
import { Expand } from '@tldraw/utils';
import { Result } from '@tldraw/utils';

// @public
Expand Down Expand Up @@ -250,7 +251,7 @@ export class RecordType<R extends UnknownRecord, RequiredProperties extends keyo
readonly validator?: StoreValidator<R>;
});
clone(record: R): R;
create(properties: Pick<R, RequiredProperties> & Omit<Partial<R>, RequiredProperties>): R;
create(properties: Expand<Pick<R, RequiredProperties> & Omit<Partial<R>, RequiredProperties>>): R;
// @deprecated
createCustomId(id: string): IdOf<R>;
// (undocumented)
Expand Down
6 changes: 4 additions & 2 deletions packages/store/src/lib/RecordType.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { objectMapEntries, structuredClone, uniqueId } from '@tldraw/utils'
import { Expand, objectMapEntries, structuredClone, uniqueId } from '@tldraw/utils'
import { IdOf, UnknownRecord } from './BaseRecord'
import { StoreValidator } from './Store'

Expand Down Expand Up @@ -70,7 +70,9 @@ export class RecordType<
* @param properties - The properties of the record.
* @returns The new record.
*/
create(properties: Pick<R, RequiredProperties> & Omit<Partial<R>, RequiredProperties>): R {
create(
properties: Expand<Pick<R, RequiredProperties> & Omit<Partial<R>, RequiredProperties>>
): R {
const result = { ...this.createDefaultProperties(), id: this.createId() } as any

for (const [k, v] of Object.entries(properties)) {
Expand Down
22 changes: 4 additions & 18 deletions packages/sync-core/src/test/TLSyncRoom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,30 +671,16 @@ describe('isReadonly', () => {
"put",
{
"brush": null,
"camera": {
"x": 0,
"y": 0,
"z": 1,
},
"camera": null,
"chatMessage": "",
"color": "#FF0000",
"currentPageId": "page:page_2",
"cursor": {
"rotation": 0,
"type": "default",
"x": 0,
"y": 0,
},
"cursor": null,
"followingUserId": null,
"id": "instance_presence:id_0",
"lastActivityTimestamp": 0,
"lastActivityTimestamp": null,
"meta": {},
"screenBounds": {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"screenBounds": null,
"scribbles": [],
"selectedShapeIds": [],
"typeName": "instance_presence",
Expand Down
16 changes: 7 additions & 9 deletions packages/sync/api-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
import { Editor } from 'tldraw';
import { Signal } from 'tldraw';
import { TLAssetStore } from 'tldraw';
import { TLPresenceStateInfo } from 'tldraw';
import { TLPresenceUserInfo } from 'tldraw';
import { TLStore } from 'tldraw';
import { TLStoreSchemaOptions } from 'tldraw';
import { TLStoreWithStatus } from 'tldraw';

Expand All @@ -17,13 +20,6 @@ export type RemoteTLStoreWithStatus = Exclude<TLStoreWithStatus, {
status: 'synced-local';
}>;

// @public
export interface TLSyncUserInfo {
color?: null | string;
id: string;
name?: null | string;
}

// @public
export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus;

Expand All @@ -32,15 +28,17 @@ export function useSyncDemo(options: UseSyncDemoOptions & TLStoreSchemaOptions):

// @public (undocumented)
export interface UseSyncDemoOptions {
getUserPresence?(store: TLStore, user: TLPresenceUserInfo): null | TLPresenceStateInfo;
// @internal (undocumented)
host?: string;
roomId: string;
userInfo?: Signal<TLSyncUserInfo> | TLSyncUserInfo;
userInfo?: Signal<TLPresenceUserInfo> | TLPresenceUserInfo;
}

// @public
export interface UseSyncOptions {
assets: TLAssetStore;
getUserPresence?(store: TLStore, user: TLPresenceUserInfo): null | TLPresenceStateInfo;
// @internal (undocumented)
onMount?(editor: Editor): void;
// @internal
Expand All @@ -50,7 +48,7 @@ export interface UseSyncOptions {
[key: string]: any;
}): void;
uri: (() => Promise<string> | string) | string;
userInfo?: Signal<TLSyncUserInfo> | TLSyncUserInfo;
userInfo?: Signal<TLPresenceUserInfo> | TLPresenceUserInfo;
}


Expand Down
7 changes: 1 addition & 6 deletions packages/sync/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@ import { registerTldrawLibraryVersion } from '@tldraw/utils'
// eslint-disable-next-line local/no-export-star
export * from '@tldraw/sync-core'

export {
useSync,
type RemoteTLStoreWithStatus,
type TLSyncUserInfo,
type UseSyncOptions,
} from './useSync'
export { useSync, type RemoteTLStoreWithStatus, type UseSyncOptions } from './useSync'
export { useSyncDemo, type UseSyncDemoOptions } from './useSyncDemo'

registerTldrawLibraryVersion(
Expand Down
Loading

0 comments on commit db18f4c

Please sign in to comment.