Adds indicators for selected shapes from other users
This commit is contained in:
parent
7eae5c87e0
commit
93827e45dd
14 changed files with 192 additions and 56 deletions
|
@ -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>
|
||||
|
|
|
@ -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} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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}
|
||||
|
|
1
packages/core/src/components/users-indicators/index.ts
Normal file
1
packages/core/src/components/users-indicators/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './users-indicators'
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue