[fix] yjs presence (#1603)

This PR:
- updates the yjs example to include user presence
- tweaks the `createPresenceStateDerivation` API
- fix a "double update" bug caused by re-syncing local changes
- fix connection bugs

### Change Type

- [x] `minor` — New feature
This commit is contained in:
Steve Ruiz 2023-06-16 16:59:13 +01:00 committed by GitHub
parent bdd8913af3
commit 3f52c24fec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 228 additions and 184 deletions

View file

@ -1,6 +1,7 @@
import { Tldraw } from '@tldraw/tldraw' import { Tldraw, useEditor } from '@tldraw/tldraw'
import '@tldraw/tldraw/editor.css' import '@tldraw/tldraw/editor.css'
import '@tldraw/tldraw/ui.css' import '@tldraw/tldraw/ui.css'
import { track } from 'signia-react'
import { useYjsStore } from './useYjsStore' import { useYjsStore } from './useYjsStore'
const HOST_URL = const HOST_URL =
@ -8,14 +9,41 @@ const HOST_URL =
export default function YjsExample() { export default function YjsExample() {
const store = useYjsStore({ const store = useYjsStore({
roomId: 'example', roomId: 'example6',
hostUrl: HOST_URL, hostUrl: HOST_URL,
version: 3,
}) })
return ( return (
<div className="tldraw__editor"> <div className="tldraw__editor">
<Tldraw autoFocus store={store} /> <Tldraw autoFocus store={store} shareZone={<NameEditor />} />
</div> </div>
) )
} }
const NameEditor = track(() => {
const editor = useEditor()
const { color, name } = editor.user
return (
<div style={{ pointerEvents: 'all', display: 'flex' }}>
<input
type="color"
value={color}
onChange={(e) => {
editor.user.updateUserPreferences({
color: e.currentTarget.value,
})
}}
/>
<input
value={name}
onChange={(e) => {
editor.user.updateUserPreferences({
name: e.currentTarget.value,
})
}}
/>
</div>
)
})

View file

@ -1,95 +1,133 @@
import { import {
DocumentRecordType, DocumentRecordType,
InstancePresenceRecordType,
PageRecordType, PageRecordType,
TLDocument, TLDocument,
TLInstancePresence, TLInstancePresence,
TLPageId, TLPageId,
TLRecord, TLRecord,
TLStoreWithStatus, TLStoreWithStatus,
TLUserPreferences,
createPresenceStateDerivation, createPresenceStateDerivation,
createTLStore, createTLStore,
defaultShapes, defaultShapes,
getFreshUserPreferences, getUserPreferences,
} from '@tldraw/tldraw' } from '@tldraw/tldraw'
import { debounce } from '@tldraw/utils' import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react' import { computed, react, transact } from 'signia'
import { atom, react } from 'signia'
import { WebsocketProvider } from 'y-websocket' import { WebsocketProvider } from 'y-websocket'
import * as Y from 'yjs' import * as Y from 'yjs'
const doc = new Y.Doc({ gc: true })
export function useYjsStore({ export function useYjsStore({
roomId = 'example', roomId = 'example',
version = 1,
hostUrl = process.env.NODE_ENV === 'development' ? 'ws://localhost:1234' : 'wss://demos.yjs.dev', hostUrl = process.env.NODE_ENV === 'development' ? 'ws://localhost:1234' : 'wss://demos.yjs.dev',
}: Partial<{ hostUrl: string; roomId: string; version: number }>) { }: Partial<{ hostUrl: string; roomId: string; version: number }>) {
const [store] = useState(() => createTLStore({ shapes: defaultShapes }))
const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({ status: 'loading' }) const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({ status: 'loading' })
const { doc, room, yRecords } = useMemo(() => {
const doc = new Y.Doc({ gc: true })
return {
doc,
room: new WebsocketProvider(hostUrl, roomId, doc, { connect: true }),
yRecords: doc.getMap<TLRecord>(`tl_${roomId}`),
}
}, [hostUrl, roomId])
useEffect(() => { useEffect(() => {
const yRecords = doc.getMap<TLRecord>(`tl_${roomId}_${version}`)
const room = new WebsocketProvider(hostUrl, roomId, doc, { connect: true })
const userId = room.awareness.clientID.toString()
const unsubs: (() => void)[] = [] const unsubs: (() => void)[] = []
const store = createTLStore({ shapes: defaultShapes })
room.on('status', (connected: boolean) => { // We'll use this flag to prevent repeating subscriptions if our connection drops and reconnects.
if (connected) { let didConnect = false
/* ----------------- Initialization ----------------- */
if (yRecords.size === 0) { room.on('status', ({ status }: { status: 'connecting' | 'disconnected' | 'connected' }) => {
doc.transact(() => { // If we're disconnected, set the store status to 'synced-remote' and the connection status to 'offline'
store.clear() if (status === 'connecting' || status === 'disconnected') {
store.put([ setStoreWithStatus({
DocumentRecordType.create({ store,
id: 'document:document' as TLDocument['id'], status: 'synced-remote',
}), connectionStatus: 'offline',
PageRecordType.create({ })
id: 'page:page' as TLPageId, return
name: 'Page 1', }
index: 'a1',
}),
])
store.allRecords().forEach((record) => {
yRecords.set(record.id, record)
})
})
} else {
store.put([...yRecords.values()], 'initialize')
}
/* -------------------- Document -------------------- */ if (status !== 'connected') return
// Sync store changes to the yjs doc if (didConnect) {
unsubs.push( setStoreWithStatus({
store.listen( store,
({ changes }) => { status: 'synced-remote',
doc.transact(() => { connectionStatus: 'online',
Object.values(changes.added).forEach((record) => { })
yRecords.set(record.id, record) return
}) }
Object.values(changes.updated).forEach(([_, record]) => { // Ok, we're connecting for the first time. Let's get started!
yRecords.set(record.id, record) didConnect = true
})
Object.values(changes.removed).forEach((record) => { // Initialize the store with the yjs doc records—or, if the yjs doc
yRecords.delete(record.id) // is empty, initialize the yjs doc with the default store records.
}) if (yRecords.size === 0) {
// Create the initial store records
transact(() => {
store.clear()
store.put([
DocumentRecordType.create({
id: 'document:document' as TLDocument['id'],
}),
PageRecordType.create({
id: 'page:page' as TLPageId,
name: 'Page 1',
index: 'a1',
}),
])
})
// Sync the store records to the yjs doc
doc.transact(() => {
for (const record of store.allRecords()) {
yRecords.set(record.id, record)
}
})
} else {
// Replace the store records with the yjs doc records
transact(() => {
store.clear()
store.put([...yRecords.values()])
})
}
/* -------------------- Document -------------------- */
// Sync store changes to the yjs doc
unsubs.push(
store.listen(
function syncStoreChangesToYjsDoc({ changes }) {
doc.transact(() => {
Object.values(changes.added).forEach((record) => {
yRecords.set(record.id, record)
}) })
},
{ source: 'user', scope: 'document' } Object.values(changes.updated).forEach(([_, record]) => {
) yRecords.set(record.id, record)
})
Object.values(changes.removed).forEach((record) => {
yRecords.delete(record.id)
})
})
},
{ source: 'user', scope: 'document' } // only sync user's document changes
) )
)
// Sync the yjs doc changes to the store // Sync the yjs doc changes to the store
const handleChange = ([event]: Y.YEvent<any>[]) => { const handleChange = (events: Y.YEvent<any>[], transaction: Y.Transaction) => {
const toDelete: TLRecord['id'][] = [] if (transaction.local) return
const toPut: TLRecord[] = []
const toRemove: TLRecord['id'][] = []
const toPut: TLRecord[] = []
events.forEach((event) => {
event.changes.keys.forEach((change, id) => { event.changes.keys.forEach((change, id) => {
switch (change.action) { switch (change.action) {
case 'add': case 'add':
@ -98,136 +136,96 @@ export function useYjsStore({
break break
} }
case 'delete': { case 'delete': {
toDelete.push(id as TLRecord['id']) toRemove.push(id as TLRecord['id'])
break break
} }
} }
}) })
store.mergeRemoteChanges(() => {
store.remove(toDelete)
store.put(toPut)
})
}
yRecords.observeDeep(handleChange)
unsubs.push(() => yRecords.unobserveDeep(handleChange))
/* -------------------- Awareness ------------------- */
// Get the persisted user preferences or use the defaults
let userPreferences: TLUserPreferences = {
...getFreshUserPreferences(),
id: userId,
}
const persistedUserPreferences = localStorage.getItem(`tldraw-presence-${version}`)
if (persistedUserPreferences !== null) {
try {
userPreferences = JSON.parse(persistedUserPreferences) as TLUserPreferences
} catch (e: any) {
// Something went wrong, persist the defaults instead
localStorage.setItem(`tldraw-presence-${version}`, JSON.stringify(userPreferences))
}
}
const debouncedPersist = debounce((presence: TLInstancePresence) => {
const preferences: TLUserPreferences = {
...userPreferences,
name: presence.userName,
color: presence.color,
}
localStorage.setItem(`tldraw-presence-${version}`, JSON.stringify(preferences))
}, 1000)
// Create the instance presence derivation
const userPreferencesSignal = atom<TLUserPreferences>('user preferences', userPreferences)
const presenceDerivation = createPresenceStateDerivation(userPreferencesSignal)(store)
room.awareness.setLocalStateField('presence', presenceDerivation.value)
// Sync the instance presence changes to yjs awareness
unsubs.push(
react('when presence changes', () => {
const presence = presenceDerivation.value
if (presence && presence.userId === userId) {
room.awareness.setLocalStateField('presence', presence)
debouncedPersist(presence)
}
})
)
// Sync yjs awareness changes to the store
const handleUpdate = ({
added,
updated,
removed,
}: {
added: number[]
updated: number[]
removed: number[]
}) => {
const states = room.awareness.getStates()
store.mergeRemoteChanges(() => {
added.forEach((id) => {
const state = states.get(id) as { presence: TLInstancePresence }
if (state.presence) {
if (state.presence.userId !== userId) {
store.put([state.presence])
}
}
})
updated.forEach((id) => {
const state = states.get(id) as { presence: TLInstancePresence }
if (state.presence) {
if (state.presence.userId !== userId) {
store.put([state.presence])
}
}
})
if (removed.length) {
const allRecords = store.allRecords()
removed.forEach((id) => {
const stringId = id.toString()
const recordsToRemove = allRecords
.filter((record) => 'userId' in record && record.userId === stringId)
.map((record) => record.id)
store.remove(recordsToRemove)
})
}
})
}
room.awareness.on('update', handleUpdate)
unsubs.push(() => room.awareness.off('update', handleUpdate))
// And we're done!
setStoreWithStatus({
store,
status: 'synced-remote',
connectionStatus: 'online',
}) })
} else {
setStoreWithStatus({ // put / remove the records in the store
store, store.mergeRemoteChanges(() => {
status: 'synced-remote', if (toRemove.length) store.remove(toRemove)
connectionStatus: 'offline', if (toPut.length) store.put(toPut)
}) })
} }
yRecords.observeDeep(handleChange)
unsubs.push(() => yRecords.unobserveDeep(handleChange))
/* -------------------- Awareness ------------------- */
// Create the instance presence derivation
const yClientId = room.awareness.clientID.toString()
const presenceId = InstancePresenceRecordType.createId(yClientId)
const userPreferencesComputed = computed('ok', () => getUserPreferences())
const presenceDerivation = createPresenceStateDerivation(
userPreferencesComputed,
presenceId
)(store)
// Set our initial presence from the derivation's current value
room.awareness.setLocalStateField('presence', presenceDerivation.value)
// When the derivation change, sync presence to to yjs awareness
unsubs.push(
react('when presence changes', () => {
const presence = presenceDerivation.value
requestAnimationFrame(() => {
room.awareness.setLocalStateField('presence', presence)
})
})
)
// Sync yjs awareness changes to the store
const handleUpdate = (update: { added: number[]; updated: number[]; removed: number[] }) => {
const states = room.awareness.getStates() as Map<number, { presence: TLInstancePresence }>
const toRemove: TLInstancePresence['id'][] = []
const toPut: TLInstancePresence[] = []
// Connect records to put / remove
for (const clientId of update.added) {
const state = states.get(clientId)
if (state?.presence && state.presence.id !== presenceId) {
toPut.push(state.presence)
}
}
for (const clientId of update.updated) {
const state = states.get(clientId)
if (state?.presence && state.presence.id !== presenceId) {
toPut.push(state.presence)
}
}
for (const clientId of update.removed) {
toRemove.push(InstancePresenceRecordType.createId(clientId.toString()))
}
// put / remove the records in the store
store.mergeRemoteChanges(() => {
if (toRemove.length) store.remove(toRemove)
if (toPut.length) store.put(toPut)
})
}
room.awareness.on('update', handleUpdate)
unsubs.push(() => room.awareness.off('update', handleUpdate))
// And we're done!
setStoreWithStatus({
store,
status: 'synced-remote',
connectionStatus: 'online',
})
}) })
return () => { return () => {
unsubs.forEach((fn) => fn()) unsubs.forEach((fn) => fn())
unsubs.length = 0 unsubs.length = 0
} }
}, [hostUrl, roomId, version]) }, [room, doc, store, yRecords])
return storeWithStatus return storeWithStatus
} }

View file

@ -243,6 +243,13 @@ export function createSessionStateSnapshotSignal(store: TLStore): Signal<null |
// @public // @public
export function createTLStore({ initialData, defaultName, ...rest }: TLStoreOptions): TLStore; export function createTLStore({ initialData, defaultName, ...rest }: TLStoreOptions): TLStore;
// @public (undocumented)
export function createTLUser(opts?: {
derivePresenceState?: ((store: TLStore) => Signal<null | TLInstancePresence>) | undefined;
userPreferences?: Signal<TLUserPreferences, unknown> | undefined;
setUserPreferences?: ((userPreferences: TLUserPreferences) => void) | undefined;
}): TLUser;
// @public (undocumented) // @public (undocumented)
export function dataTransferItemAsString(item: DataTransferItem): Promise<string>; export function dataTransferItemAsString(item: DataTransferItem): Promise<string>;

View file

@ -45,6 +45,7 @@ export {
type TLStoreEventInfo, type TLStoreEventInfo,
type TLStoreOptions, type TLStoreOptions,
} from './lib/config/createTLStore' } from './lib/config/createTLStore'
export { createTLUser } from './lib/config/createTLUser'
export { coreShapes, defaultShapes } from './lib/config/defaultShapes' export { coreShapes, defaultShapes } from './lib/config/defaultShapes'
export { defaultTools } from './lib/config/defaultTools' export { defaultTools } from './lib/config/defaultTools'
export { defineShape, type TLShapeInfo } from './lib/config/defineShape' export { defineShape, type TLShapeInfo } from './lib/config/defineShape'

View file

@ -1674,7 +1674,10 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
get currentPage(): TLPage { get currentPage(): TLPage {
return this.getPageById(this.currentPageId)! const page = this.getPageById(this.currentPageId)
if (!page)
throw Error(`No current page (id ${this.currentPageId}, ${this.pages.length} pages))`)
return page
} }
/** /**

View file

@ -11,6 +11,10 @@ export class UserPreferencesManager {
}) })
} }
get userPreferences() {
return this.user.userPreferences
}
get isDarkMode() { get isDarkMode() {
return this.user.userPreferences.value.isDarkMode return this.user.userPreferences.value.isDarkMode
} }

View file

@ -125,7 +125,7 @@ export const createPresenceStateDerivation: ($user: Signal<{
id: string; id: string;
color: string; color: string;
name: string; name: string;
}>) => (store: TLStore) => Signal<null | TLInstancePresence>; }>, instanceId?: TLInstancePresence['id']) => (store: TLStore) => Signal<null | TLInstancePresence>;
// @public (undocumented) // @public (undocumented)
export function createShapeId(id?: string): TLShapeId; export function createShapeId(id?: string): TLShapeId;

View file

@ -8,7 +8,10 @@ import { InstancePresenceRecordType, TLInstancePresence } from './records/TLPres
/** @public */ /** @public */
export const createPresenceStateDerivation = export const createPresenceStateDerivation =
($user: Signal<{ id: string; color: string; name: string }>) => (
$user: Signal<{ id: string; color: string; name: string }>,
instanceId?: TLInstancePresence['id']
) =>
(store: TLStore): Signal<TLInstancePresence | null> => { (store: TLStore): Signal<TLInstancePresence | null> => {
return computed('instancePresence', () => { return computed('instancePresence', () => {
const instance = store.get(TLINSTANCE_ID) const instance = store.get(TLINSTANCE_ID)
@ -21,7 +24,7 @@ export const createPresenceStateDerivation =
} }
return InstancePresenceRecordType.create({ return InstancePresenceRecordType.create({
id: InstancePresenceRecordType.createId(store.id), id: instanceId ?? InstancePresenceRecordType.createId(store.id),
selectedIds: pageState.selectedIds, selectedIds: pageState.selectedIds,
brush: instance.brush, brush: instance.brush,
scribble: instance.scribble, scribble: instance.scribble,