tldraw/apps/www/hooks/useMultiplayerState.ts
Milo Hill e62755ef10
Add support for persisting images/video in multiplayer (#475)
* Kee track of changed assets

* Add support for multiplayer image assets

* fix tests

* Add images

* Improve asset deletion

* Remove assets from document

* Test with v small image only

* Add test for deleteShapes

* Cleanup document assets

* Add multiplayer assets to www

* remove default storage root, which was preventing migration

* Update onAssetDelete flow, rename some files

* bump upload size to 5mb

* Fix freeze bug where image fails to load

* fix logic for removing assets

* Update useMultiplayerState.ts

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
2022-01-10 15:13:52 +00:00

232 lines
6.8 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react'
import type { TldrawApp, TDUser, TDShape, TDBinding, TDDocument, TDAsset } from '@tldraw/tldraw'
import { useRedo, useUndo, useRoom, useUpdateMyPresence } from '@liveblocks/react'
import { LiveMap, LiveObject } from '@liveblocks/client'
declare const window: Window & { app: TldrawApp }
export function useMultiplayerState(roomId: string) {
const [app, setApp] = React.useState<TldrawApp>()
const [error, setError] = React.useState<Error>()
const [loading, setLoading] = React.useState(true)
const room = useRoom()
const onUndo = useUndo()
const onRedo = useRedo()
const updateMyPresence = useUpdateMyPresence()
const rLiveShapes = React.useRef<LiveMap<string, TDShape>>()
const rLiveBindings = React.useRef<LiveMap<string, TDBinding>>()
const rLiveAssets = React.useRef<LiveMap<string, TDAsset>>()
// Callbacks --------------
// Put the state into the window, for debugging.
const onMount = React.useCallback(
(app: TldrawApp) => {
app.loadRoom(roomId)
app.pause() // Turn off the app's own undo / redo stack
window.app = app
setApp(app)
},
[roomId]
)
// Update the live shapes when the app's shapes change.
const onChangePage = React.useCallback(
(
app: TldrawApp,
shapes: Record<string, TDShape | undefined>,
bindings: Record<string, TDBinding | undefined>,
assets: Record<string, TDAsset | undefined>
) => {
room.batch(() => {
const lShapes = rLiveShapes.current
const lBindings = rLiveBindings.current
const lAssets = rLiveAssets.current
if (!(lShapes && lBindings && lAssets)) return
Object.entries(shapes).forEach(([id, shape]) => {
if (!shape) {
lShapes.delete(id)
} else {
lShapes.set(shape.id, shape)
}
})
Object.entries(bindings).forEach(([id, binding]) => {
if (!binding) {
lBindings.delete(id)
} else {
lBindings.set(binding.id, binding)
}
})
Object.entries(assets).forEach(([id, asset]) => {
if (!asset) {
lAssets.delete(id)
} else {
lAssets.set(asset.id, asset)
}
})
})
},
[room]
)
// Handle presence updates when the user's pointer / selection changes
const onChangePresence = React.useCallback(
(app: TldrawApp, user: TDUser) => {
updateMyPresence({ id: app.room?.userId, user })
},
[updateMyPresence]
)
// Document Changes --------
React.useEffect(() => {
const unsubs: (() => void)[] = []
if (!(app && room)) return
// Handle errors
unsubs.push(room.subscribe('error', (error) => setError(error)))
// Handle changes to other users' presence
unsubs.push(
room.subscribe('others', (others) => {
app.updateUsers(
others
.toArray()
.filter((other) => other.presence)
.map((other) => other.presence!.user)
.filter(Boolean)
)
})
)
// Handle events from the room
unsubs.push(
room.subscribe(
'event',
(e: { connectionId: number; event: { name: string; userId: string } }) => {
switch (e.event.name) {
case 'exit': {
app?.removeUser(e.event.userId)
break
}
}
}
)
)
// Send the exit event when the tab closes
function handleExit() {
if (!(room && app?.room)) return
room?.broadcastEvent({ name: 'exit', userId: app.room.userId })
}
window.addEventListener('beforeunload', handleExit)
unsubs.push(() => window.removeEventListener('beforeunload', handleExit))
let stillAlive = true
// Setup the document's storage and subscriptions
async function setupDocument() {
const storage = await room.getStorage<any>()
// Migrate previous versions
const version = storage.root.get('version')
// Initialize (get or create) maps for shapes/bindings/assets
let lShapes: LiveMap<string, TDShape> = storage.root.get('shapes')
if (!lShapes || !('_serialize' in lShapes)) {
storage.root.set('shapes', new LiveMap<string, TDShape>())
lShapes = storage.root.get('shapes')
}
rLiveShapes.current = lShapes
let lBindings: LiveMap<string, TDBinding> = storage.root.get('bindings')
if (!lBindings || !('_serialize' in lBindings)) {
storage.root.set('bindings', new LiveMap<string, TDBinding>())
lBindings = storage.root.get('bindings')
}
rLiveBindings.current = lBindings
let lAssets: LiveMap<string, TDAsset> = storage.root.get('assets')
if (!lAssets || !('_serialize' in lAssets)) {
storage.root.set('assets', new LiveMap<string, TDAsset>())
lAssets = storage.root.get('assets')
}
rLiveAssets.current = lAssets
if (!version) {
// The doc object will only be present if the document was created
// prior to the current multiplayer implementation. At this time, the
// document was a single LiveObject named 'doc'. If we find a doc,
// then we need to move the shapes and bindings over to the new structures
// and then mark the doc as migrated.
const doc = storage.root.get('doc') as LiveObject<{
uuid: string
document: TDDocument
migrated?: boolean
}>
// No doc? No problem. This was likely a newer document
if (doc) {
const {
document: {
pages: {
page: { shapes, bindings },
},
assets,
},
} = doc.toObject()
Object.values(shapes).forEach((shape) => lShapes.set(shape.id, shape))
Object.values(bindings).forEach((binding) => lBindings.set(binding.id, binding))
Object.values(assets).forEach((asset) => lAssets.set(asset.id, asset))
}
}
// Save the version number for future migrations
storage.root.set('version', 2.1)
// Subscribe to changes
const handleChanges = () => {
app?.replacePageContent(
Object.fromEntries(lShapes.entries()),
Object.fromEntries(lBindings.entries()),
Object.fromEntries(lAssets.entries())
)
}
if (stillAlive) {
unsubs.push(room.subscribe(lShapes, handleChanges))
// Update the document with initial content
handleChanges()
setLoading(false)
}
}
setupDocument()
return () => {
stillAlive = false
unsubs.forEach((unsub) => unsub())
}
}, [room, app])
return {
onUndo,
onRedo,
onMount,
onChangePage,
onChangePresence,
error,
loading,
}
}