diff --git a/apps/examples/src/yjs/YjsExample.tsx b/apps/examples/src/yjs/YjsExample.tsx
index f5950dab2..e194d4367 100644
--- a/apps/examples/src/yjs/YjsExample.tsx
+++ b/apps/examples/src/yjs/YjsExample.tsx
@@ -1,6 +1,7 @@
-import { Tldraw } from '@tldraw/tldraw'
+import { Tldraw, useEditor } from '@tldraw/tldraw'
import '@tldraw/tldraw/editor.css'
import '@tldraw/tldraw/ui.css'
+import { track } from 'signia-react'
import { useYjsStore } from './useYjsStore'
const HOST_URL =
@@ -8,14 +9,41 @@ const HOST_URL =
export default function YjsExample() {
const store = useYjsStore({
- roomId: 'example',
+ roomId: 'example6',
hostUrl: HOST_URL,
- version: 3,
})
return (
-
+ } />
)
}
+
+const NameEditor = track(() => {
+ const editor = useEditor()
+
+ const { color, name } = editor.user
+
+ return (
+
+ {
+ editor.user.updateUserPreferences({
+ color: e.currentTarget.value,
+ })
+ }}
+ />
+ {
+ editor.user.updateUserPreferences({
+ name: e.currentTarget.value,
+ })
+ }}
+ />
+
+ )
+})
diff --git a/apps/examples/src/yjs/useYjsStore.ts b/apps/examples/src/yjs/useYjsStore.ts
index 9d557c2b8..58bbbff73 100644
--- a/apps/examples/src/yjs/useYjsStore.ts
+++ b/apps/examples/src/yjs/useYjsStore.ts
@@ -1,95 +1,133 @@
import {
DocumentRecordType,
+ InstancePresenceRecordType,
PageRecordType,
TLDocument,
TLInstancePresence,
TLPageId,
TLRecord,
TLStoreWithStatus,
- TLUserPreferences,
createPresenceStateDerivation,
createTLStore,
defaultShapes,
- getFreshUserPreferences,
+ getUserPreferences,
} from '@tldraw/tldraw'
-import { debounce } from '@tldraw/utils'
-import { useEffect, useState } from 'react'
-import { atom, react } from 'signia'
+import { useEffect, useMemo, useState } from 'react'
+import { computed, react, transact } from 'signia'
import { WebsocketProvider } from 'y-websocket'
import * as Y from 'yjs'
-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 [store] = useState(() => createTLStore({ shapes: defaultShapes }))
const [storeWithStatus, setStoreWithStatus] = useState({ 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(`tl_${roomId}`),
+ }
+ }, [hostUrl, roomId])
+
useEffect(() => {
- const yRecords = doc.getMap(`tl_${roomId}_${version}`)
-
- 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 ----------------- */
+ // We'll use this flag to prevent repeating subscriptions if our connection drops and reconnects.
+ let didConnect = false
- 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')
- }
+ room.on('status', ({ status }: { status: 'connecting' | 'disconnected' | 'connected' }) => {
+ // If we're disconnected, set the store status to 'synced-remote' and the connection status to 'offline'
+ if (status === 'connecting' || status === 'disconnected') {
+ setStoreWithStatus({
+ store,
+ status: 'synced-remote',
+ connectionStatus: 'offline',
+ })
+ return
+ }
- /* -------------------- Document -------------------- */
+ if (status !== 'connected') return
- // 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)
- })
+ if (didConnect) {
+ setStoreWithStatus({
+ store,
+ status: 'synced-remote',
+ connectionStatus: 'online',
+ })
+ return
+ }
- Object.values(changes.updated).forEach(([_, record]) => {
- yRecords.set(record.id, record)
- })
+ // Ok, we're connecting for the first time. Let's get started!
+ didConnect = true
- Object.values(changes.removed).forEach((record) => {
- yRecords.delete(record.id)
- })
+ // Initialize the store with the yjs doc records—or, if the yjs doc
+ // 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
- const handleChange = ([event]: Y.YEvent[]) => {
- const toDelete: TLRecord['id'][] = []
- const toPut: TLRecord[] = []
+ // Sync the yjs doc changes to the store
+ const handleChange = (events: Y.YEvent[], transaction: Y.Transaction) => {
+ if (transaction.local) return
+ const toRemove: TLRecord['id'][] = []
+ const toPut: TLRecord[] = []
+
+ events.forEach((event) => {
event.changes.keys.forEach((change, id) => {
switch (change.action) {
case 'add':
@@ -98,136 +136,96 @@ export function useYjsStore({
break
}
case 'delete': {
- toDelete.push(id as TLRecord['id'])
+ toRemove.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 = {
- ...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('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',
+
+ // put / remove the records in the store
+ store.mergeRemoteChanges(() => {
+ if (toRemove.length) store.remove(toRemove)
+ 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
+
+ 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 () => {
unsubs.forEach((fn) => fn())
unsubs.length = 0
}
- }, [hostUrl, roomId, version])
+ }, [room, doc, store, yRecords])
return storeWithStatus
}
diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md
index e7e68fbb4..42a0adae1 100644
--- a/packages/editor/api-report.md
+++ b/packages/editor/api-report.md
@@ -243,6 +243,13 @@ export function createSessionStateSnapshotSignal(store: TLStore): Signal Signal) | undefined;
+ userPreferences?: Signal | undefined;
+ setUserPreferences?: ((userPreferences: TLUserPreferences) => void) | undefined;
+}): TLUser;
+
// @public (undocumented)
export function dataTransferItemAsString(item: DataTransferItem): Promise;
diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
index 7000deee4..1b9bc2e47 100644
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -45,6 +45,7 @@ export {
type TLStoreEventInfo,
type TLStoreOptions,
} from './lib/config/createTLStore'
+export { createTLUser } from './lib/config/createTLUser'
export { coreShapes, defaultShapes } from './lib/config/defaultShapes'
export { defaultTools } from './lib/config/defaultTools'
export { defineShape, type TLShapeInfo } from './lib/config/defineShape'
diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts
index a67c56456..2986875f2 100644
--- a/packages/editor/src/lib/editor/Editor.ts
+++ b/packages/editor/src/lib/editor/Editor.ts
@@ -1674,7 +1674,10 @@ export class Editor extends EventEmitter {
* @public
*/
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
}
/**
diff --git a/packages/editor/src/lib/editor/managers/UserPreferencesManager.ts b/packages/editor/src/lib/editor/managers/UserPreferencesManager.ts
index aac6d1924..b332429c5 100644
--- a/packages/editor/src/lib/editor/managers/UserPreferencesManager.ts
+++ b/packages/editor/src/lib/editor/managers/UserPreferencesManager.ts
@@ -11,6 +11,10 @@ export class UserPreferencesManager {
})
}
+ get userPreferences() {
+ return this.user.userPreferences
+ }
+
get isDarkMode() {
return this.user.userPreferences.value.isDarkMode
}
diff --git a/packages/tlschema/api-report.md b/packages/tlschema/api-report.md
index 290e3cebe..1ab8ea6ad 100644
--- a/packages/tlschema/api-report.md
+++ b/packages/tlschema/api-report.md
@@ -125,7 +125,7 @@ export const createPresenceStateDerivation: ($user: Signal<{
id: string;
color: string;
name: string;
-}>) => (store: TLStore) => Signal;
+}>, instanceId?: TLInstancePresence['id']) => (store: TLStore) => Signal;
// @public (undocumented)
export function createShapeId(id?: string): TLShapeId;
diff --git a/packages/tlschema/src/createPresenceStateDerivation.ts b/packages/tlschema/src/createPresenceStateDerivation.ts
index e5a18f383..102f0f31e 100644
--- a/packages/tlschema/src/createPresenceStateDerivation.ts
+++ b/packages/tlschema/src/createPresenceStateDerivation.ts
@@ -8,7 +8,10 @@ import { InstancePresenceRecordType, TLInstancePresence } from './records/TLPres
/** @public */
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 => {
return computed('instancePresence', () => {
const instance = store.get(TLINSTANCE_ID)
@@ -21,7 +24,7 @@ export const createPresenceStateDerivation =
}
return InstancePresenceRecordType.create({
- id: InstancePresenceRecordType.createId(store.id),
+ id: instanceId ?? InstancePresenceRecordType.createId(store.id),
selectedIds: pageState.selectedIds,
brush: instance.brush,
scribble: instance.scribble,