From 93827e45dd74ed97a7d6161d7239a0ddbbd28f70 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Tue, 12 Oct 2021 15:59:04 +0100 Subject: [PATCH] Adds indicators for selected shapes from other users --- .../core/src/components/canvas/canvas.tsx | 6 +- packages/core/src/components/page/page.tsx | 4 +- .../shape-indicator/shape-indicator.tsx | 15 ++++- .../src/components/users-indicators/index.ts | 1 + .../users-indicators/users-indicators.tsx | 56 +++++++++++++++++++ packages/core/src/hooks/useStyle.tsx | 17 ++++-- packages/core/src/types.ts | 8 ++- packages/dev/src/multiplayer/multiplayer.tsx | 31 +++++----- .../tldraw/src/shape/shapes/group/group.tsx | 11 +--- .../session/sessions/brush/brush.session.ts | 15 ++--- packages/tldraw/src/state/tlstate.ts | 36 ++++++++++-- packages/tldraw/src/state/utils.ts | 17 ++++++ packages/tldraw/src/types.ts | 4 +- .../www/components/multiplayer-editor.tsx | 27 +++++++-- 14 files changed, 192 insertions(+), 56 deletions(-) create mode 100644 packages/core/src/components/users-indicators/index.ts create mode 100644 packages/core/src/components/users-indicators/users-indicators.tsx diff --git a/packages/core/src/components/canvas/canvas.tsx b/packages/core/src/components/canvas/canvas.tsx index e15fd5427..421babf0c 100644 --- a/packages/core/src/components/canvas/canvas.tsx +++ b/packages/core/src/components/canvas/canvas.tsx @@ -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> { page: TLPage pageState: TLPageState - users?: TLUsers + users?: TLUsers userId?: string hideBounds?: boolean hideHandles?: boolean @@ -87,6 +88,7 @@ export function Canvas>({ hideHandles={hideHandles} meta={meta} /> + {users && userId && } {pageState.brush && } {users && } diff --git a/packages/core/src/components/page/page.tsx b/packages/core/src/components/page/page.tsx index f7df61ce6..720fbcce7 100644 --- a/packages/core/src/components/page/page.tsx +++ b/packages/core/src/components/page/page.tsx @@ -20,7 +20,7 @@ interface PageProps> { /** * The Page component renders the current page. */ -export function Page>({ +export const Page = React.memo(function Page>({ page, pageState, hideBounds, @@ -82,4 +82,4 @@ export function Page>({ {!hideHandles && shapeWithHandles && } ) -} +}) diff --git a/packages/core/src/components/shape-indicator/shape-indicator.tsx b/packages/core/src/components/shape-indicator/shape-indicator.tsx index 683483117..7b729d216 100644 --- a/packages/core/src/components/shape-indicator/shape-indicator.tsx +++ b/packages/core/src/components/shape-indicator/shape-indicator.tsx @@ -8,10 +8,17 @@ interface IndicatorProps { meta: M extends any ? M : undefined isSelected?: boolean isHovered?: boolean + color?: string } export const ShapeIndicator = React.memo( - ({ isHovered, isSelected, shape, meta }: IndicatorProps) => { + ({ + isHovered, + isSelected, + shape, + color, + meta, + }: IndicatorProps) => { const { shapeUtils } = useTLContext() const utils = shapeUtils[shape.type] const bounds = utils.getBounds(shape) @@ -20,10 +27,12 @@ export const ShapeIndicator = React.memo( return (
- + { + userId: string + users: TLUsers + meta: any +} + +export function UsersIndicators({ userId, users, meta }: UserIndicatorProps) { + 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 ( + +
+ {shapes.map((shape) => ( + + ))} + + ) + })} + + ) +} diff --git a/packages/core/src/hooks/useStyle.tsx b/packages/core/src/hooks/useStyle.tsx index db444393f..29bfd0da6 100644 --- a/packages/core/src/hooks/useStyle.tsx +++ b/packages/core/src/hooks/useStyle.tsx @@ -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 { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c60bbd24b..9defbefd8 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -16,8 +16,6 @@ export interface TLPage { bindings: Record } -export type TLUsers = Record - export interface TLPageState { id: string selectedIds: string[] @@ -34,12 +32,16 @@ export interface TLPageState { currentParentId?: string | null } -export interface TLUser { +export interface TLUser { id: string color: string point: number[] + selectedIds: string[] + activeShapes: T[] } +export type TLUsers = TLUser> = Record + export interface TLHandle { id: string index: number diff --git a/packages/dev/src/multiplayer/multiplayer.tsx b/packages/dev/src/multiplayer/multiplayer.tsx index 7fb5868fd..9f73b9afb 100644 --- a/packages/dev/src/multiplayer/multiplayer.tsx +++ b/packages/dev/src/multiplayer/multiplayer.tsx @@ -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), + } + }) + ) } } diff --git a/packages/tldraw/src/shape/shapes/group/group.tsx b/packages/tldraw/src/shape/shapes/group/group.tsx index a468ed8f1..3e28a2425 100644 --- a/packages/tldraw/src/shape/shapes/group/group.tsx +++ b/packages/tldraw/src/shape/shapes/group/group.tsx @@ -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' diff --git a/packages/tldraw/src/state/session/sessions/brush/brush.session.ts b/packages/tldraw/src/state/session/sessions/brush/brush.session.ts index c336bd104..659f3ed8c 100644 --- a/packages/tldraw/src/state/session/sessions/brush/brush.session.ts +++ b/packages/tldraw/src/state/session/sessions/brush/brush.session.ts @@ -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, }, }, }, diff --git a/packages/tldraw/src/state/tlstate.ts b/packages/tldraw/src/state/tlstate.ts index 1b382a495..3f7ddd5c8 100644 --- a/packages/tldraw/src/state/tlstate.ts +++ b/packages/tldraw/src/state/tlstate.ts @@ -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 { }) } + 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 { }) } - 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 { * @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 { 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 { // TODO } + get currentUser() { + return this.state.room.users[this.state.room.userId] + } + get centerPoint() { return Vec.round(Utils.getBoundsCenter(this.bounds)) } diff --git a/packages/tldraw/src/state/utils.ts b/packages/tldraw/src/state/utils.ts index c7b18d05b..ab8200d2a 100644 --- a/packages/tldraw/src/state/utils.ts +++ b/packages/tldraw/src/state/utils.ts @@ -52,3 +52,20 @@ export const EASING_STRINGS: Record = { 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)] diff --git a/packages/tldraw/src/types.ts b/packages/tldraw/src/types.ts index c47d73f5d..2aa947752 100644 --- a/packages/tldraw/src/types.ts +++ b/packages/tldraw/src/types.ts @@ -40,8 +40,8 @@ export interface TLDrawMeta { isDarkMode: boolean } -export interface TLDrawUser extends TLUser { - id: string +export interface TLDrawUser extends TLUser { + activeShapes: TLDrawShape[] } export type TLDrawShapeProps = TLShapeProps< diff --git a/packages/www/components/multiplayer-editor.tsx b/packages/www/components/multiplayer-editor.tsx index 461db131c..ef908f5e9 100644 --- a/packages/www/components/multiplayer-editor.tsx +++ b/packages/www/components/multiplayer-editor.tsx @@ -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('others', (others) => { + room.subscribe('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), + } + }) + ) } }