Add support for custom cursor components (#994)

* Add support for custom cursor components

* Add tests for the custom cursor props

* Make the main tldraw app take the components prop

* feat: add the ability to hide cursors

* Update cursor props

* Update imports

Co-authored-by: Judicael <46365844+judicaelandria@users.noreply.github.com>
Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
James Vaughan 2022-09-24 12:24:11 -07:00 committed by GitHub
parent b7c968e2aa
commit d721ae6a2f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 157 additions and 40 deletions

View file

@ -1,4 +1,4 @@
import { Tldraw, useFileSystem } from '@tldraw/tldraw' import { Tldraw, TldrawProps, useFileSystem } from '@tldraw/tldraw'
import * as React from 'react' import * as React from 'react'
import { useMultiplayerAssets } from '~hooks/useMultiplayerAssets' import { useMultiplayerAssets } from '~hooks/useMultiplayerAssets'
import { useMultiplayerState } from '~hooks/useMultiplayerState' import { useMultiplayerState } from '~hooks/useMultiplayerState'

View file

@ -19,7 +19,7 @@ export abstract class TLShapeUtil<T extends TLShape, E extends Element = any, M
abstract Indicator: (props: { abstract Indicator: (props: {
shape: T shape: T
meta: M meta: M
user?: TLUser<T> user?: TLUser
bounds: TLBounds bounds: TLBounds
isHovered: boolean isHovered: boolean
isSelected: boolean isSelected: boolean

View file

@ -2,6 +2,10 @@ import * as React from 'react'
import { mockDocument, renderWithContext } from '~test' import { mockDocument, renderWithContext } from '~test'
import { Canvas } from './Canvas' import { Canvas } from './Canvas'
function TestCustomCursor() {
return <div>Custom cursor</div>
}
describe('page', () => { describe('page', () => {
test('mounts component without crashing', () => { test('mounts component without crashing', () => {
expect(() => expect(() =>
@ -26,4 +30,29 @@ describe('page', () => {
) )
).not.toThrowError() ).not.toThrowError()
}) })
test('mounts component with custom cursors without crashing', () => {
expect(() =>
renderWithContext(
<Canvas
page={mockDocument.page}
pageState={mockDocument.pageState}
hideBounds={false}
hideGrid={false}
hideIndicators={false}
hideHandles={false}
hideBindingHandles={false}
hideResizeHandles={false}
hideCloneHandles={false}
hideRotateHandle={false}
showDashedBrush={false}
onBoundsChange={() => {
// noop
}}
assets={{}}
components={{ Cursor: TestCustomCursor }}
/>
)
).not.toThrowError()
})
}) })

View file

@ -1,5 +1,6 @@
import * as React from 'react' import * as React from 'react'
import { Brush } from '~components/Brush' import { Brush } from '~components/Brush'
import { Cursor, CursorComponent } from '~components/Cursor'
import { EraseLine } from '~components/EraseLine' import { EraseLine } from '~components/EraseLine'
import { Grid } from '~components/Grid' import { Grid } from '~components/Grid'
import { Overlay } from '~components/Overlay' import { Overlay } from '~components/Overlay'
@ -36,7 +37,7 @@ export interface CanvasProps<T extends TLShape, M extends Record<string, unknown
snapLines?: TLSnapLine[] snapLines?: TLSnapLine[]
eraseLine?: number[][] eraseLine?: number[][]
grid?: number grid?: number
users?: TLUsers<T> users?: TLUsers
userId?: string userId?: string
hideBounds: boolean hideBounds: boolean
hideHandles: boolean hideHandles: boolean
@ -49,9 +50,13 @@ export interface CanvasProps<T extends TLShape, M extends Record<string, unknown
showDashedBrush: boolean showDashedBrush: boolean
externalContainerRef?: React.RefObject<HTMLElement> externalContainerRef?: React.RefObject<HTMLElement>
performanceMode?: TLPerformanceMode performanceMode?: TLPerformanceMode
components?: {
Cursor?: CursorComponent
}
meta?: M meta?: M
id?: string id?: string
onBoundsChange: (bounds: TLBounds) => void onBoundsChange: (bounds: TLBounds) => void
hideCursors?: boolean
} }
function _Canvas<T extends TLShape, M extends Record<string, unknown>>({ function _Canvas<T extends TLShape, M extends Record<string, unknown>>({
@ -64,9 +69,9 @@ function _Canvas<T extends TLShape, M extends Record<string, unknown>>({
grid, grid,
users, users,
userId, userId,
components = {},
meta, meta,
performanceMode, performanceMode,
externalContainerRef,
showDashedBrush, showDashedBrush,
hideHandles, hideHandles,
hideBounds, hideBounds,
@ -77,6 +82,7 @@ function _Canvas<T extends TLShape, M extends Record<string, unknown>>({
hideRotateHandle, hideRotateHandle,
hideGrid, hideGrid,
onBoundsChange, onBoundsChange,
hideCursors,
}: CanvasProps<T, M>) { }: CanvasProps<T, M>) {
const rCanvas = React.useRef<HTMLDivElement>(null) const rCanvas = React.useRef<HTMLDivElement>(null)
@ -128,7 +134,9 @@ function _Canvas<T extends TLShape, M extends Record<string, unknown>>({
{pageState.brush && ( {pageState.brush && (
<Brush brush={pageState.brush} dashed={showDashedBrush} zoom={pageState.camera.zoom} /> <Brush brush={pageState.brush} dashed={showDashedBrush} zoom={pageState.camera.zoom} />
)} )}
{users && <Users userId={userId} users={users} />} {users && !hideCursors && (
<Users userId={userId} users={users} Cursor={components?.Cursor ?? Cursor} />
)}
</div> </div>
<Overlay camera={pageState.camera}> <Overlay camera={pageState.camera}>
{eraseLine && <EraseLine points={eraseLine} zoom={pageState.camera.zoom} />} {eraseLine && <EraseLine points={eraseLine} zoom={pageState.camera.zoom} />}

View file

@ -0,0 +1,27 @@
import * as React from 'react'
interface CursorProps {
id: string
color: string
}
export type CursorComponent = (props: CursorProps) => any
export const Cursor: CursorComponent = React.memo(({ color }) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 35 35" fill="none" fillRule="evenodd">
<g fill="rgba(0,0,0,.2)" transform="translate(1,1)">
<path d="m12 24.4219v-16.015l11.591 11.619h-6.781l-.411.124z" />
<path d="m21.0845 25.0962-3.605 1.535-4.682-11.089 3.686-1.553z" />
</g>
<g fill="white">
<path d="m12 24.4219v-16.015l11.591 11.619h-6.781l-.411.124z" />
<path d="m21.0845 25.0962-3.605 1.535-4.682-11.089 3.686-1.553z" />
</g>
<g fill={color}>
<path d="m19.751 24.4155-1.844.774-3.1-7.374 1.841-.775z" />
<path d="m13 10.814v11.188l2.969-2.866.428-.139h4.768z" />
</g>
</svg>
)
})

View file

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

View file

@ -7,6 +7,10 @@ import type { TLBounds, TLPage, TLPageState } from '~types'
import Utils from '~utils' import Utils from '~utils'
import { Renderer } from './Renderer' import { Renderer } from './Renderer'
function TestCustomCursor() {
return <div>Custom cursor</div>
}
describe('renderer', () => { describe('renderer', () => {
test('mounts component without crashing', () => { test('mounts component without crashing', () => {
expect(() => expect(() =>
@ -19,6 +23,19 @@ describe('renderer', () => {
) )
).not.toThrowError() ).not.toThrowError()
}) })
test('mounts component with custom cursors without crashing', () => {
expect(() =>
render(
<Renderer
shapeUtils={mockUtils as any}
page={mockDocument.page}
pageState={mockDocument.pageState}
components={{ Cursor: TestCustomCursor }}
/>
)
).not.toThrowError()
})
}) })
// describe('When passing observables', () => { // describe('When passing observables', () => {

View file

@ -1,6 +1,6 @@
import * as React from 'react' import * as React from 'react'
import { Canvas } from '~/components/Canvas' import { Canvas } from '~/components/Canvas'
import type { TLShapeUtilsMap } from '~TLShapeUtil' import { CursorComponent } from '~components/Cursor'
import { TLContext, TLContextType, useTLTheme } from '~hooks' import { TLContext, TLContextType, useTLTheme } from '~hooks'
import { Inputs } from '~inputs' import { Inputs } from '~inputs'
import type { import type {
@ -17,7 +17,7 @@ import type {
TLUsers, TLUsers,
} from '~types' } from '~types'
const EMPTY_OBJECT = {} as TLAssets const EMPTY_OBJECT = Object.freeze({}) as TLAssets
export type RendererProps<T extends TLShape, M = any> = Partial<TLCallbacks<T>> & { export type RendererProps<T extends TLShape, M = any> = Partial<TLCallbacks<T>> & {
/** /**
@ -46,6 +46,20 @@ export type RendererProps<T extends TLShape, M = any> = Partial<TLCallbacks<T>>
* (optional) A ref for the renderer's container element, used for scoping event handlers. * (optional) A ref for the renderer's container element, used for scoping event handlers.
*/ */
containerRef?: React.RefObject<HTMLElement> containerRef?: React.RefObject<HTMLElement>
/**
* (optional) Custom components to override parts of the default UI.
*/
components?: {
/**
* The component to render for multiplayer cursors.
*/
Cursor?: CursorComponent
}
/**
* (optional) To hide cursors
*/
hideCursors?: boolean
/** /**
* (optional) An object of custom options that should be passed to rendered shapes. * (optional) An object of custom options that should be passed to rendered shapes.
*/ */
@ -53,7 +67,7 @@ export type RendererProps<T extends TLShape, M = any> = Partial<TLCallbacks<T>>
/** /**
* (optional) The current users to render. * (optional) The current users to render.
*/ */
users?: TLUsers<T> users?: TLUsers
/** /**
* (optional) The current snap lines to render. * (optional) The current snap lines to render.
*/ */
@ -148,6 +162,7 @@ function _Renderer<T extends TLShape, M extends Record<string, unknown>>({
grid, grid,
containerRef, containerRef,
performanceMode, performanceMode,
components,
hideHandles = false, hideHandles = false,
hideIndicators = false, hideIndicators = false,
hideCloneHandles = false, hideCloneHandles = false,
@ -157,6 +172,7 @@ function _Renderer<T extends TLShape, M extends Record<string, unknown>>({
hideBounds = false, hideBounds = false,
hideGrid = true, hideGrid = true,
showDashedBrush = false, showDashedBrush = false,
hideCursors,
...rest ...rest
}: RendererProps<T, M>) { }: RendererProps<T, M>) {
useTLTheme(theme, '#' + id) useTLTheme(theme, '#' + id)
@ -216,7 +232,9 @@ function _Renderer<T extends TLShape, M extends Record<string, unknown>>({
showDashedBrush={showDashedBrush} showDashedBrush={showDashedBrush}
onBoundsChange={onBoundsChange} onBoundsChange={onBoundsChange}
performanceMode={performanceMode} performanceMode={performanceMode}
components={components}
meta={meta} meta={meta}
hideCursors={hideCursors}
/> />
</TLContext.Provider> </TLContext.Provider>
) )

View file

@ -8,7 +8,7 @@ export interface IndicatorProps<T extends TLShape, M = unknown> {
isSelected?: boolean isSelected?: boolean
isHovered?: boolean isHovered?: boolean
isEditing?: boolean isEditing?: boolean
user?: TLUser<T> user?: TLUser
} }
function _ShapeIndicator<T extends TLShape, M>({ function _ShapeIndicator<T extends TLShape, M>({

View file

@ -1,12 +1,14 @@
import * as React from 'react' import * as React from 'react'
import type { TLShape, TLUser } from '~types' import { CursorComponent } from '~components/Cursor'
import type { TLUser } from '~types'
interface UserProps { interface UserProps {
user: TLUser<TLShape> user: TLUser
Cursor: CursorComponent
} }
export function User({ user }: UserProps) { export function User({ user, Cursor }: UserProps) {
const rCursor = React.useRef<SVGSVGElement>(null) const rCursor = React.useRef<HTMLDivElement>(null)
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (rCursor.current) { if (rCursor.current) {
@ -15,26 +17,11 @@ export function User({ user }: UserProps) {
}, [user.point]) }, [user.point])
return ( return (
<svg <div
ref={rCursor} ref={rCursor}
className={`tl-absolute tl-user tl-counter-scaled ${user.session ? '' : 'tl-animated'}`} className={`tl-absolute tl-user tl-counter-scaled ${user.session ? '' : 'tl-animated'}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 35 35"
fill="none"
fillRule="evenodd"
> >
<g fill="rgba(0,0,0,.2)" transform="translate(1,1)"> <Cursor id={user.id} color={user.color} />
<path d="m12 24.4219v-16.015l11.591 11.619h-6.781l-.411.124z" /> </div>
<path d="m21.0845 25.0962-3.605 1.535-4.682-11.089 3.686-1.553z" />
</g>
<g fill="white">
<path d="m12 24.4219v-16.015l11.591 11.619h-6.781l-.411.124z" />
<path d="m21.0845 25.0962-3.605 1.535-4.682-11.089 3.686-1.553z" />
</g>
<g fill={user.color}>
<path d="m19.751 24.4155-1.844.774-3.1-7.374 1.841-.775z" />
<path d="m13 10.814v11.188l2.969-2.866.428-.139h4.768z" />
</g>
</svg>
) )
} }

View file

@ -1,19 +1,21 @@
import * as React from 'react' import * as React from 'react'
import { CursorComponent } from '~components/Cursor'
import { User } from '~components/User/User' import { User } from '~components/User/User'
import type { TLShape, TLUsers } from '~types' import type { TLUsers } from '~types'
export interface UserProps { export interface UserProps {
userId?: string userId?: string
users: TLUsers<TLShape> users: TLUsers
Cursor: CursorComponent
} }
export function Users({ userId, users }: UserProps) { export function Users({ userId, users, Cursor }: UserProps) {
return ( return (
<> <>
{Object.values(users) {Object.values(users)
.filter((user) => user && user.id !== userId) .filter((user) => user && user.id !== userId)
.map((user) => ( .map((user) => (
<User key={user.id} user={user} /> <User key={user.id} user={user} Cursor={Cursor} />
))} ))}
</> </>
) )

View file

@ -7,7 +7,7 @@ import Utils from '~utils'
interface UserIndicatorProps<T extends TLShape> { interface UserIndicatorProps<T extends TLShape> {
page: TLPage<any, any> page: TLPage<any, any>
userId: string userId: string
users: TLUsers<T> users: TLUsers
meta: any meta: any
} }

View file

@ -1,3 +1,4 @@
export * from './Renderer' export * from './Renderer'
export * from './SVGContainer' export * from './SVGContainer'
export * from './HTMLContainer' export * from './HTMLContainer'
export * from './Cursor'

View file

@ -44,7 +44,7 @@ export interface TLPageState {
bindingId?: string | null bindingId?: string | null
} }
export interface TLUser<T extends TLShape> { export interface TLUser {
id: string id: string
color: string color: string
point: number[] point: number[]
@ -52,7 +52,7 @@ export interface TLUser<T extends TLShape> {
session?: boolean session?: boolean
} }
export type TLUsers<T extends TLShape, U extends TLUser<T> = TLUser<T>> = Record<string, U> export type TLUsers = Record<string, TLUser>
export type TLSnapLine = number[][] export type TLSnapLine = number[][]

View file

@ -1,4 +1,4 @@
import { Renderer } from '@tldraw/core' import { CursorComponent, Renderer } from '@tldraw/core'
import * as React from 'react' import * as React from 'react'
import { ErrorBoundary as _Errorboundary } from 'react-error-boundary' import { ErrorBoundary as _Errorboundary } from 'react-error-boundary'
import { IntlProvider } from 'react-intl' import { IntlProvider } from 'react-intl'
@ -102,6 +102,21 @@ export interface TldrawProps extends TDCallbacks {
* bucket based solution will cause massive base64 string to be written to the liveblocks room. * bucket based solution will cause massive base64 string to be written to the liveblocks room.
*/ */
disableAssets?: boolean disableAssets?: boolean
/**
* (optional) Custom components to override parts of the default UI.
*/
components?: {
/**
* The component to render for multiplayer cursors.
*/
Cursor?: CursorComponent
}
/**
* (optional) To hide cursors
*/
hideCursors?: boolean
} }
const isSystemDarkMode = window.matchMedia const isSystemDarkMode = window.matchMedia
@ -123,6 +138,7 @@ export function Tldraw({
readOnly = false, readOnly = false,
disableAssets = false, disableAssets = false,
darkMode = isSystemDarkMode, darkMode = isSystemDarkMode,
components,
onMount, onMount,
onChange, onChange,
onChangePresence, onChangePresence,
@ -143,6 +159,7 @@ export function Tldraw({
onSessionStart, onSessionStart,
onSessionEnd, onSessionEnd,
onExport, onExport,
hideCursors,
}: TldrawProps) { }: TldrawProps) {
const [sId, setSId] = React.useState(id) const [sId, setSId] = React.useState(id)
@ -336,6 +353,8 @@ export function Tldraw({
showTools={showTools} showTools={showTools}
showUI={showUI} showUI={showUI}
readOnly={readOnly} readOnly={readOnly}
components={components}
hideCursors={hideCursors}
/> />
</AlertDialogContext.Provider> </AlertDialogContext.Provider>
</TldrawContext.Provider> </TldrawContext.Provider>
@ -353,6 +372,10 @@ interface InnerTldrawProps {
showStyles: boolean showStyles: boolean
showUI: boolean showUI: boolean
showTools: boolean showTools: boolean
components?: {
Cursor?: CursorComponent
}
hideCursors?: boolean
} }
const InnerTldraw = React.memo(function InnerTldraw({ const InnerTldraw = React.memo(function InnerTldraw({
@ -366,6 +389,8 @@ const InnerTldraw = React.memo(function InnerTldraw({
showTools, showTools,
readOnly, readOnly,
showUI, showUI,
components,
hideCursors,
}: InnerTldrawProps) { }: InnerTldrawProps) {
const app = useTldrawApp() const app = useTldrawApp()
const [dialogContainer, setDialogContainer] = React.useState<any>(null) const [dialogContainer, setDialogContainer] = React.useState<any>(null)
@ -489,6 +514,8 @@ const InnerTldraw = React.memo(function InnerTldraw({
userId={room?.userId} userId={room?.userId}
theme={theme} theme={theme}
meta={meta} meta={meta}
components={components}
hideCursors={hideCursors}
hideBounds={hideBounds} hideBounds={hideBounds}
hideHandles={hideHandles} hideHandles={hideHandles}
hideResizeHandles={isHideResizeHandlesShape} hideResizeHandles={isHideResizeHandlesShape}

View file

@ -176,7 +176,7 @@ export enum TDUserStatus {
} }
// A TDUser, for multiplayer rooms // A TDUser, for multiplayer rooms
export interface TDUser extends TLUser<TDShape> { export interface TDUser extends TLUser {
activeShapes: TDShape[] activeShapes: TDShape[]
status: TDUserStatus status: TDUserStatus
session?: boolean session?: boolean