Adds indicators for selected shapes from other users

This commit is contained in:
Steve Ruiz 2021-10-12 15:59:04 +01:00
parent 7eae5c87e0
commit 93827e45dd
14 changed files with 192 additions and 56 deletions

View file

@ -8,7 +8,7 @@ import {
useCameraCss, useCameraCss,
useKeyEvents, useKeyEvents,
} from '+hooks' } from '+hooks'
import type { TLBinding, TLPage, TLPageState, TLShape, TLUser, TLUsers } from '+types' import type { TLBinding, TLPage, TLPageState, TLShape, TLUsers } from '+types'
import { ErrorFallback } from '+components/error-fallback' import { ErrorFallback } from '+components/error-fallback'
import { ErrorBoundary } from '+components/error-boundary' import { ErrorBoundary } from '+components/error-boundary'
import { Brush } from '+components/brush' import { Brush } from '+components/brush'
@ -16,6 +16,7 @@ import { Page } from '+components/page'
import { Users } from '+components/users' import { Users } from '+components/users'
import { useResizeObserver } from '+hooks/useResizeObserver' import { useResizeObserver } from '+hooks/useResizeObserver'
import { inputs } from '+inputs' import { inputs } from '+inputs'
import { UsersIndicators } from '+components/users-indicators'
function resetError() { function resetError() {
void null void null
@ -24,7 +25,7 @@ function resetError() {
interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> { interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> {
page: TLPage<T, TLBinding> page: TLPage<T, TLBinding>
pageState: TLPageState pageState: TLPageState
users?: TLUsers users?: TLUsers<T>
userId?: string userId?: string
hideBounds?: boolean hideBounds?: boolean
hideHandles?: boolean hideHandles?: boolean
@ -87,6 +88,7 @@ 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} />}
{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

@ -20,7 +20,7 @@ interface PageProps<T extends TLShape, M extends Record<string, unknown>> {
/** /**
* The Page component renders the current page. * The Page component renders the current page.
*/ */
export function Page<T extends TLShape, M extends Record<string, unknown>>({ export const Page = React.memo(function Page<T extends TLShape, M extends Record<string, unknown>>({
page, page,
pageState, pageState,
hideBounds, hideBounds,
@ -82,4 +82,4 @@ export function Page<T extends TLShape, M extends Record<string, unknown>>({
{!hideHandles && shapeWithHandles && <Handles shape={shapeWithHandles} />} {!hideHandles && shapeWithHandles && <Handles shape={shapeWithHandles} />}
</> </>
) )
} })

View file

@ -8,10 +8,17 @@ interface IndicatorProps<T extends TLShape, M = any> {
meta: M extends any ? M : undefined meta: M extends any ? M : undefined
isSelected?: boolean isSelected?: boolean
isHovered?: boolean isHovered?: boolean
color?: string
} }
export const ShapeIndicator = React.memo( export const ShapeIndicator = React.memo(
<T extends TLShape, M = any>({ isHovered, isSelected, shape, meta }: IndicatorProps<T, M>) => { <T extends TLShape, M = any>({
isHovered,
isSelected,
shape,
color,
meta,
}: IndicatorProps<T, M>) => {
const { shapeUtils } = useTLContext() const { shapeUtils } = useTLContext()
const utils = shapeUtils[shape.type] const utils = shapeUtils[shape.type]
const bounds = utils.getBounds(shape) const bounds = utils.getBounds(shape)
@ -20,10 +27,12 @@ export const ShapeIndicator = React.memo(
return ( return (
<div <div
ref={rPositioned} ref={rPositioned}
className={'tl-indicator tl-absolute ' + (isSelected ? 'tl-selected' : 'tl-hovered')} className={
'tl-indicator tl-absolute ' + (color ? '' : isSelected ? 'tl-selected' : 'tl-hovered')
}
> >
<svg width="100%" height="100%"> <svg width="100%" height="100%">
<g className="tl-centered-g"> <g className="tl-centered-g" stroke={color}>
<utils.Indicator <utils.Indicator
shape={shape} shape={shape}
meta={meta} meta={meta}

View file

@ -0,0 +1 @@
export * from './users-indicators'

View file

@ -0,0 +1,56 @@
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 Utils from '+utils'
import { useTLContext } from '+hooks'
interface UserIndicatorProps<T extends TLShape> {
userId: string
users: TLUsers<T>
meta: any
}
export function UsersIndicators<T extends TLShape>({ userId, users, meta }: UserIndicatorProps<T>) {
const { shapeUtils } = useTLContext()
return (
<>
{Object.values(users)
.filter(Boolean)
.filter((user) => user.id !== userId && user.selectedIds.length > 0)
.map((user) => {
const shapes = user.activeShapes //.map((id) => page.shapes[id])
const bounds = Utils.getCommonBounds(
shapes.map((shape) => shapeUtils[shape.type].getBounds(shape))
)
return (
<React.Fragment key={user.id + '_shapes'}>
<div
className="tl-absolute tl-user-indicator-bounds"
style={{
backgroundColor: user.color + '0d',
borderColor: user.color + '78',
transform: `translate(${bounds.minX}px, ${bounds.minY}px)`,
width: bounds.width,
height: bounds.height,
pointerEvents: 'none',
}}
/>
{shapes.map((shape) => (
<ShapeIndicator
key={`${user.id}_${shape.id}_indicator`}
shape={shape}
color={user.color}
meta={meta}
isHovered
/>
))}
</React.Fragment>
)
})}
</>
)
}

View file

@ -242,18 +242,23 @@ const tlcss = css`
pointer-events: none; pointer-events: none;
} }
.tl-selected { .tl-indicator {
fill: transparent; fill: transparent;
stroke: var(--tl-selectStroke);
stroke-width: calc(1.5px * var(--tl-scale)); stroke-width: calc(1.5px * var(--tl-scale));
pointer-events: none; pointer-events: none;
} }
.tl-hovered { .tl-user-indicator-bounds {
fill: transparent; border-style: solid;
border-width: calc(1px * var(--tl-scale));
}
.tl-selected {
stroke: var(--tl-selectStroke);
}
.tl-hovered {
stroke: var(--tl-selectStroke); stroke: var(--tl-selectStroke);
stroke-width: calc(1.5px * var(--tl-scale));
pointer-events: none;
} }
.tl-bounds { .tl-bounds {

View file

@ -16,8 +16,6 @@ export interface TLPage<T extends TLShape, B extends TLBinding> {
bindings: Record<string, B> bindings: Record<string, B>
} }
export type TLUsers<U extends TLUser = TLUser> = Record<string, U>
export interface TLPageState { export interface TLPageState {
id: string id: string
selectedIds: string[] selectedIds: string[]
@ -34,12 +32,16 @@ export interface TLPageState {
currentParentId?: string | null currentParentId?: string | null
} }
export interface TLUser { export interface TLUser<T extends TLShape> {
id: string id: string
color: string color: string
point: number[] point: number[]
selectedIds: string[]
activeShapes: T[]
} }
export type TLUsers<T extends TLShape, U extends TLUser<T> = TLUser<T>> = Record<string, U>
export interface TLHandle { export interface TLHandle {
id: string id: string
index: number index: number

View file

@ -2,15 +2,7 @@
import * as React from 'react' import * as React from 'react'
import { TLDraw, TLDrawState, TLDrawDocument, TLDrawUser, Data } from '@tldraw/tldraw' import { TLDraw, TLDrawState, TLDrawDocument, TLDrawUser, Data } from '@tldraw/tldraw'
import { createClient, Presence } from '@liveblocks/client' import { createClient, Presence } from '@liveblocks/client'
import { import { LiveblocksProvider, RoomProvider, useErrorListener, useObject } from '@liveblocks/react'
LiveblocksProvider,
RoomProvider,
useErrorListener,
useObject,
useSelf,
useOthers,
useMyPresence,
} from '@liveblocks/react'
import { Utils } from '@tldraw/core' import { Utils } from '@tldraw/core'
interface TLDrawUserPresence extends Presence { interface TLDrawUserPresence extends Presence {
@ -21,9 +13,10 @@ const publicAPIKey = 'pk_live_1LJGGaqBSNLjLT-4Jalkl-U9'
const client = createClient({ const client = createClient({
publicApiKey: publicAPIKey, publicApiKey: publicAPIKey,
throttle: 80,
}) })
const ROOM_ID = 'mp-test-1' const ROOM_ID = 'mp-test-2'
export function Multiplayer() { export function Multiplayer() {
return ( return (
@ -84,7 +77,7 @@ function TLDrawWrapper() {
} }
// When the client updates its presence, update the room // When the client updates its presence, update the room
if (reason === 'patch:room:self:update' && state.room) { 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
@ -110,8 +103,7 @@ function TLDrawWrapper() {
tlstate.updateUsers( tlstate.updateUsers(
others others
.toArray() .toArray()
.filter((other) => other.presence) .map((other) => other.presence?.user)
.map((other) => other.presence!.user)
.filter(Boolean) .filter(Boolean)
) )
}) })
@ -131,6 +123,19 @@ function TLDrawWrapper() {
// Only merge the change if it caused by someone else // Only merge the change if it caused by someone else
if (docObject.uuid !== docId) { if (docObject.uuid !== docId) {
tlstate.mergeDocument(docObject.document) tlstate.mergeDocument(docObject.document)
} 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)
return {
...user,
activeShapes: activeShapes,
selectedIds: activeShapes.map((shape) => shape.id),
}
})
)
} }
} }

View file

@ -1,14 +1,7 @@
import * as React from 'react' import * as React from 'react'
import { SVGContainer, ShapeUtil } from '@tldraw/core' import { SVGContainer, ShapeUtil } from '@tldraw/core'
import { defaultStyle, getPerfectDashProps } from '~shape/shape-styles' import { defaultStyle } from '~shape/shape-styles'
import { import { GroupShape, TLDrawShapeType, TLDrawToolType, ColorStyle, TLDrawMeta } from '~types'
GroupShape,
TLDrawShapeType,
TLDrawToolType,
ColorStyle,
DashStyle,
TLDrawMeta,
} from '~types'
import { getBoundsRectangle } from '../shared' import { getBoundsRectangle } from '../shared'
import css from '~styles' import css from '~styles'

View file

@ -52,19 +52,20 @@ export class BrushSession implements Session {
} }
}) })
// if ( const currentSelectedIds = data.document.pageStates[data.appState.currentPageId].selectedIds
// selectedIds.size === pageState.selectedIds.length &&
// pageState.selectedIds.every((id) => selectedIds.has(id)) const didChange =
// ) { selectedIds.size !== currentSelectedIds.length ||
// return {} currentSelectedIds.some((id) => !selectedIds.has(id))
// }
const afterSelectedIds = didChange ? Array.from(selectedIds.values()) : currentSelectedIds
return { return {
document: { document: {
pageStates: { pageStates: {
[currentPageId]: { [currentPageId]: {
brush, brush,
selectedIds: Array.from(selectedIds.values()), selectedIds: afterSelectedIds,
}, },
}, },
}, },

View file

@ -43,6 +43,7 @@ import { TLDR } from './tldr'
import { defaultStyle } from '~shape' import { defaultStyle } from '~shape'
import * as Sessions from './session' import * as Sessions from './session'
import * as Commands from './command' import * as Commands from './command'
import { sample, USER_COLORS } from './utils'
const defaultDocument: TLDrawDocument = { const defaultDocument: TLDrawDocument = {
id: 'doc', id: 'doc',
@ -103,8 +104,10 @@ const defaultState: Data = {
users: { users: {
[uuid]: { [uuid]: {
id: uuid, id: uuid,
color: 'dodgerBlue', color: sample(USER_COLORS),
point: [100, 100], point: [100, 100],
selectedIds: [],
activeShapes: [],
}, },
}, },
}, },
@ -336,6 +339,8 @@ export class TLDrawState extends StateManager<Data> {
}) })
} }
const currentPageId = data.appState.currentPageId
// Remove any exited users // Remove any exited users
if (data.room !== prev.room) { if (data.room !== prev.room) {
Object.values(prev.room.users).forEach((user) => { Object.values(prev.room.users).forEach((user) => {
@ -345,7 +350,16 @@ export class TLDrawState extends StateManager<Data> {
}) })
} }
const currentPageId = data.appState.currentPageId const currentPage = data.document.pages[currentPageId]
const currentPageState = data.document.pageStates[currentPageId]
// Update the room presence selected ids
data.room.users[data.room.userId].selectedIds = currentPageState.selectedIds
// Update the room presence active shapes
data.room.users[data.room.userId].activeShapes = currentPageState.selectedIds.map(
(id) => currentPage.shapes[id]
)
// Apply selected style change, if any // Apply selected style change, if any
@ -1471,7 +1485,9 @@ export class TLDrawState extends StateManager<Data> {
* @param push Whether to add the ids to the current selection instead. * @param push Whether to add the ids to the current selection instead.
*/ */
private setSelectedIds = (ids: string[], push = false): this => { private setSelectedIds = (ids: string[], push = false): this => {
// Also clear any pasted center const nextIds = push ? [...this.pageState.selectedIds, ...ids] : [...ids]
console.log('selecting ids', ids, nextIds)
return this.patchState( return this.patchState(
{ {
@ -1482,7 +1498,15 @@ export class TLDrawState extends StateManager<Data> {
document: { document: {
pageStates: { pageStates: {
[this.currentPageId]: { [this.currentPageId]: {
selectedIds: push ? [...this.pageState.selectedIds, ...ids] : [...ids], selectedIds: nextIds,
},
},
},
room: {
users: {
[this.currentUser.id]: {
...this.currentUser,
selectedIds: nextIds,
}, },
}, },
}, },
@ -3095,6 +3119,10 @@ export class TLDrawState extends StateManager<Data> {
// TODO // TODO
} }
get currentUser() {
return this.state.room.users[this.state.room.userId]
}
get centerPoint() { get centerPoint() {
return Vec.round(Utils.getBoundsCenter(this.bounds)) return Vec.round(Utils.getBoundsCenter(this.bounds))
} }

View file

@ -52,3 +52,20 @@ export const EASING_STRINGS: Record<Easing, string> = {
easeOutExpo: `(t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t))`, easeOutExpo: `(t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t))`,
easeInOutExpo: `(t) => t <= 0 ? 0 : t >= 1 ? 1 : t < 0.5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2`, easeInOutExpo: `(t) => t <= 0 ? 0 : t >= 1 ? 1 : t < 0.5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2`,
} }
export const USER_COLORS = [
'#EC5E41',
'#F2555A',
'#F04F88',
'#E34BA9',
'#BD54C6',
'#9D5BD2',
'#7B66DC',
'#02B1CC',
'#11B3A3',
'#39B178',
'#55B467',
'#FF802B',
]
export const sample = (arr: any[]) => arr[Math.floor(Math.random() * arr.length)]

View file

@ -40,8 +40,8 @@ export interface TLDrawMeta {
isDarkMode: boolean isDarkMode: boolean
} }
export interface TLDrawUser extends TLUser { export interface TLDrawUser extends TLUser<TLDrawShape> {
id: string activeShapes: TLDrawShape[]
} }
export type TLDrawShapeProps<T extends TLDrawShape, E extends Element> = TLShapeProps< export type TLDrawShapeProps<T extends TLDrawShape, E extends Element> = TLShapeProps<

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { TLDraw, TLDrawState, Data, TLDrawDocument, TLDrawUser } from '@tldraw/tldraw' import { TLDraw, TLDrawState, Data, TLDrawDocument, TLDrawUser } from '@tldraw/tldraw'
import * as gtag from '-utils/gtag' import * as gtag from '-utils/gtag'
import * as React from 'react' import * as React from 'react'
@ -5,7 +6,7 @@ import { createClient, Presence } from '@liveblocks/client'
import { LiveblocksProvider, RoomProvider, useObject, useErrorListener } from '@liveblocks/react' import { LiveblocksProvider, RoomProvider, useObject, useErrorListener } from '@liveblocks/react'
import { Utils } from '@tldraw/core' import { Utils } from '@tldraw/core'
interface TLDrawPresence extends Presence { interface TLDrawUserPresence extends Presence {
user: TLDrawUser user: TLDrawUser
} }
@ -79,7 +80,7 @@ function Editor({ id }: { id: string }) {
} }
// When the client updates its presence, update the room // When the client updates its presence, update the room
if (reason === 'patch:room:self:update' && state.room) { if (state.room && (reason === 'patch:room:self:update' || reason === 'patch:selected')) {
const room = client.getRoom(id) const room = client.getRoom(id)
if (!room) return if (!room) return
const { userId, users } = state.room const { userId, users } = state.room
@ -98,15 +99,18 @@ function Editor({ id }: { id: string }) {
// 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],
shapes: Object.fromEntries(tlstate.selectedIds.map((id) => [id, tlstate.getShape(id)])),
})
// Subscribe to presence changes; when others change, update the state // Subscribe to presence changes; when others change, update the state
room.subscribe<TLDrawPresence>('others', (others) => { room.subscribe<TLDrawUserPresence>('others', (others) => {
tlstate.updateUsers( tlstate.updateUsers(
others others
.toArray() .toArray()
.filter((other) => other.presence) .filter((other) => other.presence)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.map((other) => other.presence!.user) .map((other) => other.presence!.user)
.filter(Boolean) .filter(Boolean)
) )
@ -127,6 +131,19 @@ function Editor({ id }: { id: string }) {
// Only merge the change if it caused by someone else // Only merge the change if it caused by someone else
if (docObject.uuid !== docId) { if (docObject.uuid !== docId) {
tlstate.mergeDocument(docObject.document) tlstate.mergeDocument(docObject.document)
} 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)
return {
...user,
activeShapes: activeShapes,
selectedIds: activeShapes.map((shape) => shape.id),
}
})
)
} }
} }