add presence to yjs example (#1582)

This PR adds presence to the yjs example.

![Kapture 2023-06-13 at 19 47
16](https://github.com/tldraw/tldraw/assets/23072548/759e0bf9-a934-47c7-979f-512c16b03e48)


### Change Type

- [x] `documentation` — Changes to the documentation only[^2]

### Release Notes

- [editor] Add presence to yjs example.
This commit is contained in:
Steve Ruiz 2023-06-13 21:00:53 +01:00 committed by GitHub
parent ce1cf82029
commit 014576ba87
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 229 additions and 182 deletions

View file

@ -3,12 +3,19 @@ import '@tldraw/tldraw/editor.css'
import '@tldraw/tldraw/ui.css'
import { useYjsStore } from './useYjsStore'
const HOST_URL =
process.env.NODE_ENV === 'development' ? 'ws://localhost:1234' : 'wss://demos.yjs.dev'
export default function YjsExample() {
const storeWithStatus = useYjsStore()
const store = useYjsStore({
roomId: 'example',
hostUrl: HOST_URL,
version: 3,
})
return (
<div className="tldraw__editor">
<Tldraw autoFocus store={storeWithStatus} />
<Tldraw autoFocus store={store} />
</div>
)
}

View file

@ -1,39 +1,238 @@
import { TLStoreWithStatus, createTLStore, defaultShapes } from '@tldraw/tldraw'
import { useEffect, useState } from 'react'
import {
initializeStoreFromYjsDoc,
roomProvider,
syncStoreChangesToYjsDoc,
syncStorePresenceToYjsAwareness,
syncStoreSessionToYjsAwareness,
syncYjsAwarenessChangesToStore,
syncYjsDocChangesToStore,
} from './y'
DocumentRecordType,
PageRecordType,
TLDocument,
TLInstancePresence,
TLPageId,
TLRecord,
TLStoreWithStatus,
TLUserPreferences,
USER_COLORS,
createPresenceStateDerivation,
createTLStore,
defaultShapes,
} from '@tldraw/tldraw'
import { debounce } from '@tldraw/utils'
import { useEffect, useState } from 'react'
import { atom, react } from 'signia'
import { WebsocketProvider } from 'y-websocket'
import * as Y from 'yjs'
export function useYjsStore() {
const doc = new Y.Doc({ gc: true })
export function useYjsStore({
roomId = 'example',
version = 1,
hostUrl = process.env.NODE_ENV === 'development' ? 'ws://localhost:1234' : 'wss://demos.yjs.dev',
}: Partial<{ hostUrl: string; roomId: string; version: number }>) {
const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({ status: 'loading' })
useEffect(() => {
const store = createTLStore({ shapes: defaultShapes })
initializeStoreFromYjsDoc(store)
syncYjsDocChangesToStore(store)
syncStoreChangesToYjsDoc(store)
syncYjsAwarenessChangesToStore(store)
syncStorePresenceToYjsAwareness(store)
syncStoreSessionToYjsAwareness(store)
const yRecords = doc.getMap<TLRecord>(`tl_${roomId}_${version}`)
roomProvider.on('status', (connected: boolean) => {
const room = new WebsocketProvider(hostUrl, roomId, doc, { connect: true })
const userId = room.awareness.clientID.toString()
const unsubs: (() => void)[] = []
const store = createTLStore({ shapes: defaultShapes })
room.on('status', (connected: boolean) => {
if (connected) {
/* ----------------- Initialization ----------------- */
if (yRecords.size === 0) {
doc.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',
}),
])
store.allRecords().forEach((record) => {
yRecords.set(record.id, record)
})
})
} else {
store.put([...yRecords.values()], 'initialize')
}
/* -------------------- Document -------------------- */
// Sync store changes to the yjs doc
unsubs.push(
store.listen(
({ changes }) => {
doc.transact(() => {
Object.values(changes.added).forEach((record) => {
yRecords.set(record.id, record)
})
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' }
)
)
// Sync the yjs doc changes to the store
const handleChange = ([event]: Y.YEvent<any>[]) => {
const toDelete: TLRecord['id'][] = []
const toPut: TLRecord[] = []
event.changes.keys.forEach((change, id) => {
switch (change.action) {
case 'add':
case 'update': {
toPut.push(yRecords.get(id)!)
break
}
case 'delete': {
toDelete.push(id as TLRecord['id'])
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 = {
id: userId,
name: 'User Name',
locale: 'en',
color: USER_COLORS[Math.floor(Math.random() * USER_COLORS.length)],
isDarkMode: false,
isSnapMode: false,
animationSpeed: 1,
}
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({
store,
status: 'synced-remote',
connectionStatus: 'offline',
})
}
})
roomProvider.connect()
}, [])
return () => {
unsubs.forEach((fn) => fn())
unsubs.length = 0
}
}, [hostUrl, roomId, version])
return storeWithStatus
}

View file

@ -1,159 +0,0 @@
import {
DocumentRecordType,
PageRecordType,
TLDocument,
TLPageId,
TLRecord,
TLStore,
TLStoreEventInfo,
} from '@tldraw/tldraw'
import { WebsocketProvider } from 'y-websocket'
import * as Y from 'yjs'
const ROOM_ID = 'tldraw-20'
const HOST_URL =
process.env.NODE_ENV === 'development' ? 'ws://localhost:1234' : 'wss://demos.yjs.dev'
export const doc = new Y.Doc({ gc: true })
export const yRecords = doc.getMap<TLRecord>(`tl_${ROOM_ID}`)
export const roomProvider = new WebsocketProvider(HOST_URL, ROOM_ID, doc, { connect: false })
export const roomAwareness = roomProvider.awareness
roomAwareness.setLocalState({})
/* -------------------- Document -------------------- */
export function initializeStoreFromYjsDoc(store: TLStore) {
const existingYjsRecords = [...yRecords.values()]
if (existingYjsRecords.length === 0) {
doc.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',
}),
])
store.allRecords().forEach((record) => {
yRecords.set(record.id, record)
})
})
} else {
store.put(existingYjsRecords, 'initialize')
}
}
export function syncStoreChangesToYjsDoc(store: TLStore) {
return store.listen(
({ changes }) => {
doc.transact(() => {
Object.values(changes.added).forEach((record) => {
yRecords.set(record.id, record)
})
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' }
)
}
export function syncYjsDocChangesToStore(store: TLStore) {
yRecords.observeDeep(([event]) => {
store.mergeRemoteChanges(() => {
event.changes.keys.forEach((change, id) => {
switch (change.action) {
case 'add':
case 'update': {
store.put([yRecords.get(id)!])
break
}
case 'delete': {
store.remove([id as TLRecord['id']])
break
}
}
})
})
})
}
/* -------------------- Awareness ------------------- */
function syncStoreChangesToYjsAwareness({ changes }: TLStoreEventInfo) {
roomAwareness.doc.transact(() => {
Object.values(changes.added).forEach((record) => {
const idWithUserId = record.id.split(':')[0] + ':' + roomAwareness.clientID
roomAwareness.setLocalStateField(record.typeName, {
...record,
id: idWithUserId,
})
})
Object.values(changes.updated).forEach(([_, record]) => {
const idWithUserId = record.id.split(':')[0] + ':' + roomAwareness.clientID
roomAwareness.setLocalStateField(record.typeName, {
...record,
id: idWithUserId,
})
})
Object.values(changes.removed).forEach((record) => {
roomAwareness.setLocalStateField(record.typeName, null)
})
})
}
export function syncStoreSessionToYjsAwareness(store: TLStore) {
return store.listen(syncStoreChangesToYjsAwareness, { source: 'user', scope: 'session' })
}
export function syncStorePresenceToYjsAwareness(store: TLStore) {
return store.listen(syncStoreChangesToYjsAwareness, { source: 'user', scope: 'presence' })
}
export function syncYjsAwarenessChangesToStore(store: TLStore) {
roomAwareness.on(
'update',
({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }) => {
const states = roomAwareness.getStates()
store.mergeRemoteChanges(() => {
added.forEach((id) => {
const state = states.get(id) as Record<TLRecord['id'], TLRecord>
const records = Object.values(state)
store.put(records)
})
updated.forEach((id) => {
const state = states.get(id) as Record<TLRecord['id'], TLRecord>
const records = Object.values(state)
store.put(records)
})
if (removed.length) {
const allRecords = store.allRecords()
removed.forEach((id) => {
const recordsToRemove = allRecords
.filter((record) => record.id.split(':')[1] === id.toString())
.map((record) => record.id)
store.remove(recordsToRemove)
})
}
})
}
)
}