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}
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} />}
{users && <Users userId={userId} users={users} />}
</div>

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as React from 'react'
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 { BoundsBg } from '+components/bounds/bounds-bg'
import { Handles } from '+components/handles'

View file

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

View file

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

View file

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

View file

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

View file

@ -48,8 +48,9 @@ import type { BaseTool } from './tool/BaseTool'
const uuid = Utils.uniqueId()
export class TLDrawState extends StateManager<Data> {
private _onChange?: (tlstate: TLDrawState, data: Data, reason: string) => void
private _onMount?: (tlstate: TLDrawState) => void
private _onChange?: (tlstate: TLDrawState, data: Data, reason: string) => void
private _onUserChange?: (tlstate: TLDrawState, user: TLDrawUser) => void
inputs?: Inputs
@ -81,6 +82,9 @@ export class TLDrawState extends StateManager<Data> {
height: 480,
}
// The most recent pointer location
pointerPoint: number[] = [0, 0]
private pasteInfo = {
center: [0, 0],
offset: [0, 0],
@ -88,11 +92,12 @@ export class TLDrawState extends StateManager<Data> {
constructor(
id?: string,
onMount?: (tlstate: TLDrawState) => 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) => {
console.log('Migrating to a new version.')
console.warn('Migrating to a new version.')
return {
...next,
document: { ...next.document, ...prev.document },
@ -101,6 +106,7 @@ export class TLDrawState extends StateManager<Data> {
this._onChange = onChange
this._onMount = onMount
this._onUserChange = onUserChange
this.session = undefined
}
@ -278,31 +284,33 @@ export class TLDrawState extends StateManager<Data> {
const currentPageId = data.appState.currentPageId
const currentPage = data.document.pages[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 } }
// Remove any exited users
Object.values(prev.room.users).forEach((user) => {
if (room.users[user.id] === undefined) {
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]),
if (prev.room) {
Object.values(prev.room.users)
.filter(Boolean)
.forEach((user) => {
if (room.users[user.id] === undefined) {
delete room.users[user.id]
}
})
}
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
const newSelectedStyle = TLDR.getSelectedStyle(data, currentPageId)
@ -721,6 +729,28 @@ export class TLDrawState extends StateManager<Data> {
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.
* @param document The document to load
@ -1474,47 +1504,29 @@ export class TLDrawState extends StateManager<Data> {
private setSelectedIds = (ids: string[], push = false): this => {
const nextIds = push ? [...this.pageState.selectedIds, ...ids] : [...ids]
if (this.currentUser) {
return this.patchState(
{
appState: {
activeTool: 'select',
},
document: {
pageStates: {
[this.currentPageId]: {
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`
)
if (this.state.room) {
const { users, userId } = this.state.room
this._onUserChange?.(this, {
...users[userId],
selectedIds: nextIds,
})
}
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)
// onPan is called by onPointerMove when spaceKey is pressed,
// so we shouldn't call this again.
if (!info.spaceKey) {
// onPan is called by onPointerMove when spaceKey is pressed,
// so we shouldn't call this again.
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
this.currentTool.onPointerMove?.(info, e)
this.pointerPoint = this.getPagePoint(info.point)
// Move this to an emitted event
if (this.state.room) {
const { users, userId } = this.state.room
if (Object.values(users).length === 1) return
this.updateUsers(
[
{
...users[userId],
point: this.getPagePoint(info.point),
},
],
true
)
this._onUserChange?.(this, {
...users[userId],
point: this.getPagePoint(info.point),
})
}
}