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 { useMultiplayerAssets } from '~hooks/useMultiplayerAssets'
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: {
shape: T
meta: M
user?: TLUser<T>
user?: TLUser
bounds: TLBounds
isHovered: boolean
isSelected: boolean

View file

@ -2,6 +2,10 @@ import * as React from 'react'
import { mockDocument, renderWithContext } from '~test'
import { Canvas } from './Canvas'
function TestCustomCursor() {
return <div>Custom cursor</div>
}
describe('page', () => {
test('mounts component without crashing', () => {
expect(() =>
@ -26,4 +30,29 @@ describe('page', () => {
)
).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 { Brush } from '~components/Brush'
import { Cursor, CursorComponent } from '~components/Cursor'
import { EraseLine } from '~components/EraseLine'
import { Grid } from '~components/Grid'
import { Overlay } from '~components/Overlay'
@ -36,7 +37,7 @@ export interface CanvasProps<T extends TLShape, M extends Record<string, unknown
snapLines?: TLSnapLine[]
eraseLine?: number[][]
grid?: number
users?: TLUsers<T>
users?: TLUsers
userId?: string
hideBounds: boolean
hideHandles: boolean
@ -49,9 +50,13 @@ export interface CanvasProps<T extends TLShape, M extends Record<string, unknown
showDashedBrush: boolean
externalContainerRef?: React.RefObject<HTMLElement>
performanceMode?: TLPerformanceMode
components?: {
Cursor?: CursorComponent
}
meta?: M
id?: string
onBoundsChange: (bounds: TLBounds) => void
hideCursors?: boolean
}
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,
users,
userId,
components = {},
meta,
performanceMode,
externalContainerRef,
showDashedBrush,
hideHandles,
hideBounds,
@ -77,6 +82,7 @@ function _Canvas<T extends TLShape, M extends Record<string, unknown>>({
hideRotateHandle,
hideGrid,
onBoundsChange,
hideCursors,
}: CanvasProps<T, M>) {
const rCanvas = React.useRef<HTMLDivElement>(null)
@ -128,7 +134,9 @@ function _Canvas<T extends TLShape, M extends Record<string, unknown>>({
{pageState.brush && (
<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>
<Overlay camera={pageState.camera}>
{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 { Renderer } from './Renderer'
function TestCustomCursor() {
return <div>Custom cursor</div>
}
describe('renderer', () => {
test('mounts component without crashing', () => {
expect(() =>
@ -19,6 +23,19 @@ describe('renderer', () => {
)
).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', () => {

View file

@ -1,6 +1,6 @@
import * as React from 'react'
import { Canvas } from '~/components/Canvas'
import type { TLShapeUtilsMap } from '~TLShapeUtil'
import { CursorComponent } from '~components/Cursor'
import { TLContext, TLContextType, useTLTheme } from '~hooks'
import { Inputs } from '~inputs'
import type {
@ -17,7 +17,7 @@ import type {
TLUsers,
} 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>> & {
/**
@ -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.
*/
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.
*/
@ -53,7 +67,7 @@ export type RendererProps<T extends TLShape, M = any> = Partial<TLCallbacks<T>>
/**
* (optional) The current users to render.
*/
users?: TLUsers<T>
users?: TLUsers
/**
* (optional) The current snap lines to render.
*/
@ -148,6 +162,7 @@ function _Renderer<T extends TLShape, M extends Record<string, unknown>>({
grid,
containerRef,
performanceMode,
components,
hideHandles = false,
hideIndicators = false,
hideCloneHandles = false,
@ -157,6 +172,7 @@ function _Renderer<T extends TLShape, M extends Record<string, unknown>>({
hideBounds = false,
hideGrid = true,
showDashedBrush = false,
hideCursors,
...rest
}: RendererProps<T, M>) {
useTLTheme(theme, '#' + id)
@ -216,7 +232,9 @@ function _Renderer<T extends TLShape, M extends Record<string, unknown>>({
showDashedBrush={showDashedBrush}
onBoundsChange={onBoundsChange}
performanceMode={performanceMode}
components={components}
meta={meta}
hideCursors={hideCursors}
/>
</TLContext.Provider>
)

View file

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

View file

@ -1,12 +1,14 @@
import * as React from 'react'
import type { TLShape, TLUser } from '~types'
import { CursorComponent } from '~components/Cursor'
import type { TLUser } from '~types'
interface UserProps {
user: TLUser<TLShape>
user: TLUser
Cursor: CursorComponent
}
export function User({ user }: UserProps) {
const rCursor = React.useRef<SVGSVGElement>(null)
export function User({ user, Cursor }: UserProps) {
const rCursor = React.useRef<HTMLDivElement>(null)
React.useLayoutEffect(() => {
if (rCursor.current) {
@ -15,26 +17,11 @@ export function User({ user }: UserProps) {
}, [user.point])
return (
<svg
<div
ref={rCursor}
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)">
<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={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>
<Cursor id={user.id} color={user.color} />
</div>
)
}

View file

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

View file

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

View file

@ -44,7 +44,7 @@ export interface TLPageState {
bindingId?: string | null
}
export interface TLUser<T extends TLShape> {
export interface TLUser {
id: string
color: string
point: number[]
@ -52,7 +52,7 @@ export interface TLUser<T extends TLShape> {
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[][]

View file

@ -1,4 +1,4 @@
import { Renderer } from '@tldraw/core'
import { CursorComponent, Renderer } from '@tldraw/core'
import * as React from 'react'
import { ErrorBoundary as _Errorboundary } from 'react-error-boundary'
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.
*/
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
@ -123,6 +138,7 @@ export function Tldraw({
readOnly = false,
disableAssets = false,
darkMode = isSystemDarkMode,
components,
onMount,
onChange,
onChangePresence,
@ -143,6 +159,7 @@ export function Tldraw({
onSessionStart,
onSessionEnd,
onExport,
hideCursors,
}: TldrawProps) {
const [sId, setSId] = React.useState(id)
@ -336,6 +353,8 @@ export function Tldraw({
showTools={showTools}
showUI={showUI}
readOnly={readOnly}
components={components}
hideCursors={hideCursors}
/>
</AlertDialogContext.Provider>
</TldrawContext.Provider>
@ -353,6 +372,10 @@ interface InnerTldrawProps {
showStyles: boolean
showUI: boolean
showTools: boolean
components?: {
Cursor?: CursorComponent
}
hideCursors?: boolean
}
const InnerTldraw = React.memo(function InnerTldraw({
@ -366,6 +389,8 @@ const InnerTldraw = React.memo(function InnerTldraw({
showTools,
readOnly,
showUI,
components,
hideCursors,
}: InnerTldrawProps) {
const app = useTldrawApp()
const [dialogContainer, setDialogContainer] = React.useState<any>(null)
@ -489,6 +514,8 @@ const InnerTldraw = React.memo(function InnerTldraw({
userId={room?.userId}
theme={theme}
meta={meta}
components={components}
hideCursors={hideCursors}
hideBounds={hideBounds}
hideHandles={hideHandles}
hideResizeHandles={isHideResizeHandlesShape}

View file

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