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

View file

@ -20,7 +20,7 @@ interface PageProps<T extends TLShape, M extends Record<string, unknown>> {
/**
* 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,
pageState,
hideBounds,
@ -82,4 +82,4 @@ export function Page<T extends TLShape, M extends Record<string, unknown>>({
{!hideHandles && shapeWithHandles && <Handles shape={shapeWithHandles} />}
</>
)
}
})

View file

@ -8,10 +8,17 @@ interface IndicatorProps<T extends TLShape, M = any> {
meta: M extends any ? M : undefined
isSelected?: boolean
isHovered?: boolean
color?: string
}
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 utils = shapeUtils[shape.type]
const bounds = utils.getBounds(shape)
@ -20,10 +27,12 @@ export const ShapeIndicator = React.memo(
return (
<div
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%">
<g className="tl-centered-g">
<g className="tl-centered-g" stroke={color}>
<utils.Indicator
shape={shape}
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;
}
.tl-selected {
.tl-indicator {
fill: transparent;
stroke: var(--tl-selectStroke);
stroke-width: calc(1.5px * var(--tl-scale));
pointer-events: none;
}
.tl-hovered {
fill: transparent;
.tl-user-indicator-bounds {
border-style: solid;
border-width: calc(1px * var(--tl-scale));
}
.tl-selected {
stroke: var(--tl-selectStroke);
}
.tl-hovered {
stroke: var(--tl-selectStroke);
stroke-width: calc(1.5px * var(--tl-scale));
pointer-events: none;
}
.tl-bounds {

View file

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

View file

@ -2,15 +2,7 @@
import * as React from 'react'
import { TLDraw, TLDrawState, TLDrawDocument, TLDrawUser, Data } from '@tldraw/tldraw'
import { createClient, Presence } from '@liveblocks/client'
import {
LiveblocksProvider,
RoomProvider,
useErrorListener,
useObject,
useSelf,
useOthers,
useMyPresence,
} from '@liveblocks/react'
import { LiveblocksProvider, RoomProvider, useErrorListener, useObject } from '@liveblocks/react'
import { Utils } from '@tldraw/core'
interface TLDrawUserPresence extends Presence {
@ -21,9 +13,10 @@ const publicAPIKey = 'pk_live_1LJGGaqBSNLjLT-4Jalkl-U9'
const client = createClient({
publicApiKey: publicAPIKey,
throttle: 80,
})
const ROOM_ID = 'mp-test-1'
const ROOM_ID = 'mp-test-2'
export function Multiplayer() {
return (
@ -84,7 +77,7 @@ function TLDrawWrapper() {
}
// 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)
if (!room) return
const { userId, users } = state.room
@ -110,8 +103,7 @@ function TLDrawWrapper() {
tlstate.updateUsers(
others
.toArray()
.filter((other) => other.presence)
.map((other) => other.presence!.user)
.map((other) => other.presence?.user)
.filter(Boolean)
)
})
@ -131,6 +123,19 @@ function TLDrawWrapper() {
// Only merge the change if it caused by someone else
if (docObject.uuid !== docId) {
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 { SVGContainer, ShapeUtil } from '@tldraw/core'
import { defaultStyle, getPerfectDashProps } from '~shape/shape-styles'
import {
GroupShape,
TLDrawShapeType,
TLDrawToolType,
ColorStyle,
DashStyle,
TLDrawMeta,
} from '~types'
import { defaultStyle } from '~shape/shape-styles'
import { GroupShape, TLDrawShapeType, TLDrawToolType, ColorStyle, TLDrawMeta } from '~types'
import { getBoundsRectangle } from '../shared'
import css from '~styles'

View file

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

View file

@ -43,6 +43,7 @@ import { TLDR } from './tldr'
import { defaultStyle } from '~shape'
import * as Sessions from './session'
import * as Commands from './command'
import { sample, USER_COLORS } from './utils'
const defaultDocument: TLDrawDocument = {
id: 'doc',
@ -103,8 +104,10 @@ const defaultState: Data = {
users: {
[uuid]: {
id: uuid,
color: 'dodgerBlue',
color: sample(USER_COLORS),
point: [100, 100],
selectedIds: [],
activeShapes: [],
},
},
},
@ -336,6 +339,8 @@ export class TLDrawState extends StateManager<Data> {
})
}
const currentPageId = data.appState.currentPageId
// Remove any exited users
if (data.room !== prev.room) {
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
@ -1471,7 +1485,9 @@ export class TLDrawState extends StateManager<Data> {
* @param push Whether to add the ids to the current selection instead.
*/
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(
{
@ -1482,7 +1498,15 @@ export class TLDrawState extends StateManager<Data> {
document: {
pageStates: {
[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
}
get currentUser() {
return this.state.room.users[this.state.room.userId]
}
get centerPoint() {
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))`,
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
}
export interface TLDrawUser extends TLUser {
id: string
export interface TLDrawUser extends TLUser<TLDrawShape> {
activeShapes: TLDrawShape[]
}
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 * as gtag from '-utils/gtag'
import * as React from 'react'
@ -5,7 +6,7 @@ import { createClient, Presence } from '@liveblocks/client'
import { LiveblocksProvider, RoomProvider, useObject, useErrorListener } from '@liveblocks/react'
import { Utils } from '@tldraw/core'
interface TLDrawPresence extends Presence {
interface TLDrawUserPresence extends Presence {
user: TLDrawUser
}
@ -79,7 +80,7 @@ function Editor({ id }: { id: string }) {
}
// 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)
if (!room) return
const { userId, users } = state.room
@ -98,15 +99,18 @@ function Editor({ id }: { id: string }) {
// Update the user's presence with the user from state
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
room.subscribe<TLDrawPresence>('others', (others) => {
room.subscribe<TLDrawUserPresence>('others', (others) => {
tlstate.updateUsers(
others
.toArray()
.filter((other) => other.presence)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.map((other) => other.presence!.user)
.filter(Boolean)
)
@ -127,6 +131,19 @@ function Editor({ id }: { id: string }) {
// Only merge the change if it caused by someone else
if (docObject.uuid !== docId) {
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),
}
})
)
}
}