improves multiplayer presence (#160)

This commit is contained in:
Steve Ruiz 2021-10-16 21:24:31 +01:00 committed by GitHub
parent 3e7d2c3ad9
commit abcdcd8dae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 137 additions and 94 deletions

View file

@ -80,7 +80,9 @@ export function Canvas<T extends TLShape, M extends Record<string, unknown>>({
hideHandles={hideHandles} hideHandles={hideHandles}
meta={meta} meta={meta}
/> />
{users && userId && <UsersIndicators userId={userId} users={users} meta={meta} />} {users && userId && (
<UsersIndicators userId={userId} users={users} page={page} meta={meta} />
)}
{pageState.brush && <Brush brush={pageState.brush} />} {pageState.brush && <Brush brush={pageState.brush} />}
{users && <Users userId={userId} users={users} />} {users && <Users userId={userId} users={users} />}
</div> </div>

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import * as React from 'react' import * as React from 'react'
import type { TLBinding, TLPage, TLPageState, TLShape, TLShapeUtil } from '+types' import type { TLBinding, TLPage, TLPageState, TLShape, TLShapeUtil } from '+types'
import { useSelection, useShapeTree, useHandles, useTLContext } from '+hooks' import { useSelection, useShapeTree, useTLContext } from '+hooks'
import { Bounds } from '+components/bounds' import { Bounds } from '+components/bounds'
import { BoundsBg } from '+components/bounds/bounds-bg' import { BoundsBg } from '+components/bounds/bounds-bg'
import { Handles } from '+components/handles' import { Handles } from '+components/handles'

View file

@ -1,17 +1,23 @@
import * as React from 'react' import * as React from 'react'
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { ShapeIndicator } from '+components/shape-indicator' import { ShapeIndicator } from '+components/shape-indicator'
import type { TLShape, TLUsers } from '+types' import type { TLPage, TLShape, TLUsers } from '+types'
import Utils from '+utils' import Utils from '+utils'
import { useTLContext } from '+hooks' import { useTLContext } from '+hooks'
interface UserIndicatorProps<T extends TLShape> { interface UserIndicatorProps<T extends TLShape> {
page: TLPage<any, any>
userId: string userId: string
users: TLUsers<T> users: TLUsers<T>
meta: any meta: any
} }
export function UsersIndicators<T extends TLShape>({ userId, users, meta }: UserIndicatorProps<T>) { export function UsersIndicators<T extends TLShape>({
userId,
users,
meta,
page,
}: UserIndicatorProps<T>) {
const { shapeUtils } = useTLContext() const { shapeUtils } = useTLContext()
return ( return (
@ -20,7 +26,9 @@ export function UsersIndicators<T extends TLShape>({ userId, users, meta }: User
.filter(Boolean) .filter(Boolean)
.filter((user) => user.id !== userId && user.selectedIds.length > 0) .filter((user) => user.id !== userId && user.selectedIds.length > 0)
.map((user) => { .map((user) => {
const shapes = user.activeShapes //.map((id) => page.shapes[id]) const shapes = user.selectedIds.map((id) => page.shapes[id]).filter(Boolean)
if (shapes.length === 0) return null
const bounds = Utils.getCommonBounds( const bounds = Utils.getCommonBounds(
shapes.map((shape) => shapeUtils[shape.type].getBounds(shape)) shapes.map((shape) => shapeUtils[shape.type].getBounds(shape))

View file

@ -11,7 +11,7 @@ export function Users({ userId, users }: UserProps) {
return ( return (
<> <>
{Object.values(users) {Object.values(users)
.filter((user) => user.id !== userId) .filter((user) => user && user.id !== userId)
.map((user) => ( .map((user) => (
<User key={user.id} user={user} /> <User key={user.id} user={user} />
))} ))}

View file

@ -66,23 +66,26 @@ function TLDrawWrapper() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
window.tlstate = tlstate window.tlstate = tlstate
tlstate.loadRoom(ROOM_ID)
setTlstate(tlstate) setTlstate(tlstate)
}, []) }, [])
const handleChange = React.useCallback( const handleChange = React.useCallback(
(_tlstate: TLDrawState, state: Data, reason: string) => { (_tlstate: TLDrawState, state: Data, reason: string) => {
// If the client updates its document, update the room's document // If the client updates its document, update the room's document
if (reason.startsWith('command')) { if (reason.startsWith('command') || reason.startsWith('undo') || reason.startsWith('redo')) {
doc?.update({ uuid: docId, document: state.document }) doc?.update({ uuid: docId, document: state.document })
} }
// When the client updates its presence, update the room // When the client updates its presence, update the room
if (state.room && (reason === 'patch:room:self:update' || reason === 'patch:selected')) { // if (state.room && (reason === 'patch:room:self:update' || reason === 'patch:selected')) {
const room = client.getRoom(ROOM_ID) // const room = client.getRoom(ROOM_ID)
if (!room) return // if (!room) return
const { userId, users } = state.room // const { userId, users } = state.room
room.updatePresence({ id: userId, user: users[userId] }) // room.updatePresence({ id: userId, user: users[userId] })
} // }
}, },
[docId, doc] [docId, doc]
) )
@ -93,9 +96,11 @@ function TLDrawWrapper() {
if (!room) return if (!room) return
if (!doc) return if (!doc) return
if (!tlstate) return if (!tlstate) return
if (!tlstate.state.room) return
// Update the user's presence with the user from state // Update the user's presence with the user from state
const { users, userId } = tlstate.state.room const { users, userId } = tlstate.state.room
room.updatePresence({ id: userId, user: users[userId] }) room.updatePresence({ id: userId, user: users[userId] })
// Subscribe to presence changes; when others change, update the state // Subscribe to presence changes; when others change, update the state
@ -118,6 +123,7 @@ function TLDrawWrapper() {
function handleDocumentUpdates() { function handleDocumentUpdates() {
if (!doc) return if (!doc) return
if (!tlstate) return if (!tlstate) return
if (!tlstate.state.room) return
const docObject = doc.toObject() const docObject = doc.toObject()
@ -127,13 +133,13 @@ function TLDrawWrapper() {
} else { } else {
tlstate.updateUsers( tlstate.updateUsers(
Object.values(tlstate.state.room.users).map((user) => { Object.values(tlstate.state.room.users).map((user) => {
const activeShapes = user.activeShapes // const activeShapes = user.activeShapes
.map((shape) => docObject.document.pages[tlstate.currentPageId].shapes[shape.id]) // .map((shape) => docObject.document.pages[tlstate.currentPageId].shapes[shape.id])
.filter(Boolean) // .filter(Boolean)
return { return {
...user, ...user,
activeShapes: activeShapes, // activeShapes: activeShapes,
selectedIds: activeShapes.map((shape) => shape.id), selectedIds: user.selectedIds, // activeShapes.map((shape) => shape.id),
} }
}) })
) )
@ -141,7 +147,8 @@ function TLDrawWrapper() {
} }
function handleExit() { function handleExit() {
room?.broadcastEvent({ name: 'exit', userId: tlstate?.state.room.userId }) if (!(tlstate && tlstate.state.room)) return
room?.broadcastEvent({ name: 'exit', userId: tlstate.state.room.userId })
} }
window.addEventListener('beforeunload', handleExit) window.addEventListener('beforeunload', handleExit)
@ -158,13 +165,26 @@ function TLDrawWrapper() {
} }
}, [doc, docId, tlstate]) }, [doc, docId, tlstate])
const handleUserChange = React.useCallback(
(tlstate: TLDrawState, user: TLDrawUser) => {
const room = client.getRoom(ROOM_ID)
room?.updatePresence({ id: tlstate.state.room?.userId, user })
},
[client]
)
if (error) return <div>Error: {error.message}</div> if (error) return <div>Error: {error.message}</div>
if (doc === null) return <div>Loading...</div> if (doc === null) return <div>Loading...</div>
return ( return (
<div className="tldraw"> <div className="tldraw">
<TLDraw onChange={handleChange} onMount={handleMount} showPages={false} /> <TLDraw
onMount={handleMount}
onChange={handleChange}
onUserChange={handleUserChange}
showPages={false}
/>
</div> </div>
) )
} }

View file

@ -2,7 +2,7 @@ import * as React from 'react'
import { IdProvider } from '@radix-ui/react-id' import { IdProvider } from '@radix-ui/react-id'
import { Renderer } from '@tldraw/core' import { Renderer } from '@tldraw/core'
import css from '~styles' import css from '~styles'
import { Data, TLDrawDocument, TLDrawStatus } from '~types' import { Data, TLDrawDocument, TLDrawStatus, TLDrawUser } from '~types'
import { TLDrawState } from '~state' import { TLDrawState } from '~state'
import { import {
TLDrawContext, TLDrawContext,
@ -83,6 +83,8 @@ export interface TLDrawProps {
* (optional) A callback to run when the component's state changes. * (optional) A callback to run when the component's state changes.
*/ */
onChange?: TLDrawState['_onChange'] onChange?: TLDrawState['_onChange']
onUserChange?: (state: TLDrawState, user: TLDrawUser) => void
} }
export function TLDraw({ export function TLDraw({
@ -94,16 +96,19 @@ export function TLDraw({
showPages = true, showPages = true,
onMount, onMount,
onChange, onChange,
onUserChange,
}: TLDrawProps) { }: TLDrawProps) {
const [sId, setSId] = React.useState(id) const [sId, setSId] = React.useState(id)
const [tlstate, setTlstate] = React.useState(() => new TLDrawState(id, onChange, onMount)) const [tlstate, setTlstate] = React.useState(
() => new TLDrawState(id, onMount, onChange, onUserChange)
)
const [context, setContext] = React.useState(() => ({ tlstate, useSelector: tlstate.useStore })) const [context, setContext] = React.useState(() => ({ tlstate, useSelector: tlstate.useStore }))
React.useEffect(() => { React.useEffect(() => {
if (id === sId) return if (id === sId) return
// If a new id is loaded, replace the entire state // If a new id is loaded, replace the entire state
const newState = new TLDrawState(id, onChange, onMount) const newState = new TLDrawState(id, onMount, onChange, onUserChange)
setTlstate(newState) setTlstate(newState)
setContext({ tlstate: newState, useSelector: newState.useStore }) setContext({ tlstate: newState, useSelector: newState.useStore })
setSId(id) setSId(id)

View file

@ -48,8 +48,9 @@ import type { BaseTool } from './tool/BaseTool'
const uuid = Utils.uniqueId() const uuid = Utils.uniqueId()
export class TLDrawState extends StateManager<Data> { export class TLDrawState extends StateManager<Data> {
private _onChange?: (tlstate: TLDrawState, data: Data, reason: string) => void
private _onMount?: (tlstate: TLDrawState) => void private _onMount?: (tlstate: TLDrawState) => void
private _onChange?: (tlstate: TLDrawState, data: Data, reason: string) => void
private _onUserChange?: (tlstate: TLDrawState, user: TLDrawUser) => void
inputs?: Inputs inputs?: Inputs
@ -81,6 +82,9 @@ export class TLDrawState extends StateManager<Data> {
height: 480, height: 480,
} }
// The most recent pointer location
pointerPoint: number[] = [0, 0]
private pasteInfo = { private pasteInfo = {
center: [0, 0], center: [0, 0],
offset: [0, 0], offset: [0, 0],
@ -88,11 +92,12 @@ export class TLDrawState extends StateManager<Data> {
constructor( constructor(
id?: string, id?: string,
onMount?: (tlstate: TLDrawState) => void,
onChange?: (tlstate: TLDrawState, data: Data, reason: string) => void, onChange?: (tlstate: TLDrawState, data: Data, reason: string) => void,
onMount?: (tlstate: TLDrawState) => void onUserChange?: (tlstate: TLDrawState, user: TLDrawUser) => void
) { ) {
super(TLDrawState.defaultState, id, TLDrawState.version, (prev, next) => { super(TLDrawState.defaultState, id, TLDrawState.version, (prev, next) => {
console.log('Migrating to a new version.') console.warn('Migrating to a new version.')
return { return {
...next, ...next,
document: { ...next.document, ...prev.document }, document: { ...next.document, ...prev.document },
@ -101,6 +106,7 @@ export class TLDrawState extends StateManager<Data> {
this._onChange = onChange this._onChange = onChange
this._onMount = onMount this._onMount = onMount
this._onUserChange = onUserChange
this.session = undefined this.session = undefined
} }
@ -278,31 +284,33 @@ export class TLDrawState extends StateManager<Data> {
const currentPageId = data.appState.currentPageId const currentPageId = data.appState.currentPageId
const currentPage = data.document.pages[currentPageId]
const currentPageState = data.document.pageStates[currentPageId] const currentPageState = data.document.pageStates[currentPageId]
if (data.room && prev.room && data.room !== prev.room) { if (data.room && data.room !== prev.room) {
const room = { ...data.room, users: { ...data.room.users } } const room = { ...data.room, users: { ...data.room.users } }
// Remove any exited users // Remove any exited users
Object.values(prev.room.users).forEach((user) => { if (prev.room) {
Object.values(prev.room.users)
.filter(Boolean)
.forEach((user) => {
if (room.users[user.id] === undefined) { if (room.users[user.id] === undefined) {
delete room.users[user.id] delete room.users[user.id]
} }
}) })
// Update the room presence selected ids
// Update the room presence active shapes
room.users[room.userId] = {
...room.users[room.userId],
selectedIds: currentPageState.selectedIds,
activeShapes: currentPageState.selectedIds.map((id) => currentPage.shapes[id]),
} }
data.room = room data.room = room
} }
if (data.room) {
data.room.users[data.room.userId] = {
...data.room.users[data.room.userId],
point: this.pointerPoint,
selectedIds: currentPageState.selectedIds,
}
}
// Apply selected style change, if any // Apply selected style change, if any
const newSelectedStyle = TLDR.getSelectedStyle(data, currentPageId) const newSelectedStyle = TLDR.getSelectedStyle(data, currentPageId)
@ -721,6 +729,28 @@ export class TLDrawState extends StateManager<Data> {
return this.replaceState(nextState, `${reason}:${document.id}`) return this.replaceState(nextState, `${reason}:${document.id}`)
} }
/**
* Load a fresh room into the state.
* @param roomId
*/
loadRoom = (roomId: string) => {
this.patchState({
room: {
id: roomId,
userId: uuid,
users: {
[uuid]: {
id: uuid,
color: sample(USER_COLORS),
point: [100, 100],
selectedIds: [],
activeShapes: [],
},
},
},
})
}
/** /**
* Load a new document. * Load a new document.
* @param document The document to load * @param document The document to load
@ -1474,47 +1504,29 @@ export class TLDrawState extends StateManager<Data> {
private setSelectedIds = (ids: string[], push = false): this => { private setSelectedIds = (ids: string[], push = false): this => {
const nextIds = push ? [...this.pageState.selectedIds, ...ids] : [...ids] const nextIds = push ? [...this.pageState.selectedIds, ...ids] : [...ids]
if (this.currentUser) { if (this.state.room) {
return this.patchState( const { users, userId } = this.state.room
{ this._onUserChange?.(this, {
appState: { ...users[userId],
activeTool: 'select',
},
document: {
pageStates: {
[this.currentPageId]: {
selectedIds: nextIds, selectedIds: nextIds,
}, })
},
},
room: {
users: {
[this.currentUser.id]: {
...this.currentUser,
selectedIds: nextIds,
},
},
},
},
`selected`
)
} else {
return this.patchState(
{
appState: {
activeTool: 'select',
},
document: {
pageStates: {
[this.currentPageId]: {
selectedIds: nextIds,
},
},
},
},
`selected`
)
} }
return this.patchState(
{
appState: {
activeTool: 'select',
},
document: {
pageStates: {
[this.currentPageId]: {
selectedIds: nextIds,
},
},
},
},
`selected`
)
} }
/** /**
@ -2161,9 +2173,9 @@ export class TLDrawState extends StateManager<Data> {
this.pan(delta) this.pan(delta)
if (!info.spaceKey) {
// onPan is called by onPointerMove when spaceKey is pressed, // onPan is called by onPointerMove when spaceKey is pressed,
// so we shouldn't call this again. // so we shouldn't call this again.
if (!info.spaceKey) {
this.onPointerMove(info, e as unknown as React.PointerEvent) this.onPointerMove(info, e as unknown as React.PointerEvent)
} }
} }
@ -2180,20 +2192,16 @@ export class TLDrawState extends StateManager<Data> {
// Several events (e.g. pan) can trigger the same "pointer move" behavior // Several events (e.g. pan) can trigger the same "pointer move" behavior
this.currentTool.onPointerMove?.(info, e) this.currentTool.onPointerMove?.(info, e)
this.pointerPoint = this.getPagePoint(info.point)
// Move this to an emitted event
if (this.state.room) { if (this.state.room) {
const { users, userId } = this.state.room const { users, userId } = this.state.room
if (Object.values(users).length === 1) return this._onUserChange?.(this, {
this.updateUsers(
[
{
...users[userId], ...users[userId],
point: this.getPagePoint(info.point), point: this.getPagePoint(info.point),
}, })
],
true
)
} }
} }