add presence to yjs example (#1582)
This PR adds presence to the yjs example.  ### Change Type - [x] `documentation` — Changes to the documentation only[^2] ### Release Notes - [editor] Add presence to yjs example.
This commit is contained in:
parent
ce1cf82029
commit
014576ba87
3 changed files with 229 additions and 182 deletions
|
@ -3,12 +3,19 @@ import '@tldraw/tldraw/editor.css'
|
||||||
import '@tldraw/tldraw/ui.css'
|
import '@tldraw/tldraw/ui.css'
|
||||||
import { useYjsStore } from './useYjsStore'
|
import { useYjsStore } from './useYjsStore'
|
||||||
|
|
||||||
|
const HOST_URL =
|
||||||
|
process.env.NODE_ENV === 'development' ? 'ws://localhost:1234' : 'wss://demos.yjs.dev'
|
||||||
|
|
||||||
export default function YjsExample() {
|
export default function YjsExample() {
|
||||||
const storeWithStatus = useYjsStore()
|
const store = useYjsStore({
|
||||||
|
roomId: 'example',
|
||||||
|
hostUrl: HOST_URL,
|
||||||
|
version: 3,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tldraw__editor">
|
<div className="tldraw__editor">
|
||||||
<Tldraw autoFocus store={storeWithStatus} />
|
<Tldraw autoFocus store={store} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,39 +1,238 @@
|
||||||
import { TLStoreWithStatus, createTLStore, defaultShapes } from '@tldraw/tldraw'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import {
|
import {
|
||||||
initializeStoreFromYjsDoc,
|
DocumentRecordType,
|
||||||
roomProvider,
|
PageRecordType,
|
||||||
syncStoreChangesToYjsDoc,
|
TLDocument,
|
||||||
syncStorePresenceToYjsAwareness,
|
TLInstancePresence,
|
||||||
syncStoreSessionToYjsAwareness,
|
TLPageId,
|
||||||
syncYjsAwarenessChangesToStore,
|
TLRecord,
|
||||||
syncYjsDocChangesToStore,
|
TLStoreWithStatus,
|
||||||
} from './y'
|
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' })
|
const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({ status: 'loading' })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const store = createTLStore({ shapes: defaultShapes })
|
const yRecords = doc.getMap<TLRecord>(`tl_${roomId}_${version}`)
|
||||||
initializeStoreFromYjsDoc(store)
|
|
||||||
syncYjsDocChangesToStore(store)
|
|
||||||
syncStoreChangesToYjsDoc(store)
|
|
||||||
syncYjsAwarenessChangesToStore(store)
|
|
||||||
syncStorePresenceToYjsAwareness(store)
|
|
||||||
syncStoreSessionToYjsAwareness(store)
|
|
||||||
|
|
||||||
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) {
|
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({
|
setStoreWithStatus({
|
||||||
store,
|
store,
|
||||||
status: 'synced-remote',
|
status: 'synced-remote',
|
||||||
connectionStatus: 'online',
|
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
|
return storeWithStatus
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
Loading…
Add table
Add a link
Reference in a new issue