bemo custom shape example (#4174)

Adds a custom shape bemo example. Instead of having to create a schema
ahead of time, here i've added bindingUtils/shapeUtils props to our sync
hooks to match the ones we have in the `<Tldraw />` component. Not 100%
about this though, I could easily be convinced to go with just the
schema prop.

### Change type

- [x] `other`
This commit is contained in:
alex 2024-07-16 12:24:01 +01:00 committed by GitHub
parent c8ebe57e24
commit 43811d54ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 279 additions and 60 deletions

View file

@ -92,6 +92,7 @@ function MultiplayerExampleWrapper({
<WifiIcon />
<div>Live Example</div>
<button
className="MultiplayerExampleWrapper-copy"
onClick={() => {
// copy current url with roomId=roomId to clipboard
navigator.clipboard.writeText(window.location.href.split('?')[0] + `?roomId=${roomId}`)
@ -99,7 +100,8 @@ function MultiplayerExampleWrapper({
}}
aria-label="join"
>
{confirm ? 'Copied!' : 'Copy Share URL'}
Copy link
{confirm && <div className="MultiplayerExampleWrapper-copied">Copied!</div>}
</button>
</div>
<div className="MultiplayerExampleWrapper-example">

View file

@ -0,0 +1,69 @@
import { MouseEvent } from 'react'
import {
BaseBoxShapeTool,
BaseBoxShapeUtil,
HTMLContainer,
T,
TLBaseShape,
stopEventPropagation,
} from 'tldraw'
type CounterShape = TLBaseShape<'counter', { w: number; h: number; count: number }>
export class CounterShapeUtil extends BaseBoxShapeUtil<CounterShape> {
static override type = 'counter' as const
static override props = {
w: T.positiveNumber,
h: T.positiveNumber,
count: T.number,
}
override getDefaultProps() {
return {
w: 200,
h: 200,
count: 0,
}
}
override component(shape: CounterShape) {
const onClick = (event: MouseEvent, change: number) => {
event.stopPropagation()
this.editor.updateShape({
id: shape.id,
type: 'counter',
props: { count: shape.props.count + change },
})
}
return (
<HTMLContainer
style={{
pointerEvents: 'all',
background: '#efefef',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
}}
>
<button onClick={(e) => onClick(e, -1)} onPointerDown={stopEventPropagation}>
-
</button>
<span>{shape.props.count}</span>
<button onClick={(e) => onClick(e, 1)} onPointerDown={stopEventPropagation}>
+
</button>
</HTMLContainer>
)
}
override indicator(shape: CounterShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}
export class CounterShapeTool extends BaseBoxShapeTool {
static override id = 'counter'
override shapeType = 'counter'
}

View file

@ -0,0 +1,23 @@
import { useMultiplayerDemo } from '@tldraw/sync'
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import { CounterShapeTool, CounterShapeUtil } from './CounterShape'
import { components, uiOverrides } from './ui'
const customShapes = [CounterShapeUtil]
const customTools = [CounterShapeTool]
export default function MultiplayerCustomShapeExample({ roomId }: { roomId: string }) {
const store = useMultiplayerDemo({ roomId, shapeUtils: customShapes })
return (
<div className="tldraw__editor">
<Tldraw
store={store}
shapeUtils={customShapes}
tools={customTools}
overrides={uiOverrides}
components={components}
/>
</div>
)
}

View file

@ -0,0 +1,12 @@
---
title: Multiplayer demo with custom shape
component: ./MultiplayerDemoExample.tsx
category: basic
priority: 3
keywords: [basic, intro, simple, quick, start, multiplayer, sync, collaboration, custom shape]
multiplayer: true
---
---
The `useMultiplayerDemo` hook can be used to quickly prototype multiplayer experiences in tldraw using a demo backend that we host. Data is wiped after one day.

View file

@ -0,0 +1,38 @@
import {
DefaultToolbar,
DefaultToolbarContent,
TLComponents,
TLUiOverrides,
TldrawUiMenuItem,
useIsToolSelected,
useTools,
} from 'tldraw'
export const uiOverrides: TLUiOverrides = {
tools(editor, tools) {
// Create a tool item in the ui's context.
tools.counter = {
id: 'counter',
icon: 'color',
label: 'counter',
kbd: 'c',
onSelect: () => {
editor.setCurrentTool('counter')
},
}
return tools
},
}
export const components: TLComponents = {
Toolbar: (props) => {
const tools = useTools()
const isCounterSelected = useIsToolSelected(tools['counter'])
return (
<DefaultToolbar {...props}>
<TldrawUiMenuItem {...tools['counter']} isSelected={isCounterSelected} />
<DefaultToolbarContent />
</DefaultToolbar>
)
},
}

View file

@ -469,14 +469,29 @@ a.example__sidebar__header-link {
color: var(--text);
font-size: 14px;
}
.MultiplayerExampleWrapper-picker button {
.MultiplayerExampleWrapper-copy {
background: var(--black-transparent-lighter);
border: 1px solid var(--gray-dark);
border-radius: 4px;
padding: 4px 12px;
height: 24px;
cursor: pointer;
position: relative;
text-align: center;
}
.MultiplayerExampleWrapper-picker button:hover {
.MultiplayerExampleWrapper-copy:has(.MultiplayerExampleWrapper-copied) {
color: transparent;
}
.MultiplayerExampleWrapper-copied {
color: var(--text);
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.MultiplayerExampleWrapper-copy:hover {
border: 1px solid var(--black-transparent-lighter);
}
.MultiplayerExampleWrapper-example {

View file

@ -490,6 +490,9 @@ export function counterClockwiseAngleDist(a0: number, a1: number): number;
// @public
export function createSessionStateSnapshotSignal(store: TLStore): Signal<null | TLSessionStateSnapshot>;
// @public
export function createTLSchemaFromUtils(opts: TLStoreSchemaOptions): StoreSchema<TLRecord, TLStoreProps>;
// @public
export function createTLStore({ initialData, defaultName, id, assets, onEditorMount, multiplayerStatus, ...rest }?: TLStoreOptions): TLStore;
@ -3273,15 +3276,18 @@ export interface TLStoreBaseOptions {
export type TLStoreEventInfo = HistoryEntry<TLRecord>;
// @public (undocumented)
export type TLStoreOptions = TLStoreBaseOptions & ({
bindingUtils?: readonly TLAnyBindingUtilConstructor[];
export type TLStoreOptions = TLStoreBaseOptions & {
id?: string;
} & TLStoreSchemaOptions;
// @public (undocumented)
export type TLStoreSchemaOptions = {
bindingUtils?: readonly TLAnyBindingUtilConstructor[];
migrations?: readonly MigrationSequence[];
shapeUtils?: readonly TLAnyShapeUtilConstructor[];
} | {
id?: string;
schema?: StoreSchema<TLRecord, TLStoreProps>;
});
};
// @public (undocumented)
export type TLStoreWithStatus = {
@ -3519,7 +3525,7 @@ export function useSelectionEvents(handle: TLSelectionHandle): {
export function useShallowArrayIdentity<T>(arr: readonly T[]): readonly T[];
// @internal (undocumented)
export function useShallowObjectIdentity<T extends object>(arr: T): T;
export function useShallowObjectIdentity<T extends object>(obj: T): T;
export { useStateTracking }
@ -3528,6 +3534,9 @@ export function useSvgExportContext(): {
isDarkMode: boolean;
} | null;
// @public (undocumented)
export function useTLSchemaFromUtils(opts: TLStoreSchemaOptions): StoreSchema<TLRecord, TLStoreProps>;
// @public (undocumented)
export function useTLStore(opts: TLStoreOptions & {
snapshot?: TLEditorSnapshot | TLStoreSnapshot;

View file

@ -125,10 +125,12 @@ export {
type TLUserPreferences,
} from './lib/config/TLUserPreferences'
export {
createTLSchemaFromUtils,
createTLStore,
type TLStoreBaseOptions,
type TLStoreEventInfo,
type TLStoreOptions,
type TLStoreSchemaOptions,
} from './lib/config/createTLStore'
export { createTLUser, type TLUser } from './lib/config/createTLUser'
export { type TLAnyBindingUtilConstructor } from './lib/config/defaultBindings'
@ -280,7 +282,7 @@ export { usePresence } from './lib/hooks/usePresence'
export { useRefState } from './lib/hooks/useRefState'
export { useSafeId } from './lib/hooks/useSafeId'
export { useSelectionEvents } from './lib/hooks/useSelectionEvents'
export { useTLStore } from './lib/hooks/useTLStore'
export { useTLSchemaFromUtils, useTLStore } from './lib/hooks/useTLStore'
export { useTransform } from './lib/hooks/useTransform'
export { defaultTldrawOptions, type TldrawOptions } from './lib/options'
export {

View file

@ -32,19 +32,18 @@ export interface TLStoreBaseOptions {
}
/** @public */
export type TLStoreOptions = TLStoreBaseOptions &
(
| {
id?: string
shapeUtils?: readonly TLAnyShapeUtilConstructor[]
migrations?: readonly MigrationSequence[]
bindingUtils?: readonly TLAnyBindingUtilConstructor[]
}
| {
id?: string
schema?: StoreSchema<TLRecord, TLStoreProps>
}
)
export type TLStoreSchemaOptions =
| {
schema?: StoreSchema<TLRecord, TLStoreProps>
}
| {
shapeUtils?: readonly TLAnyShapeUtilConstructor[]
migrations?: readonly MigrationSequence[]
bindingUtils?: readonly TLAnyBindingUtilConstructor[]
}
/** @public */
export type TLStoreOptions = TLStoreBaseOptions & { id?: string } & TLStoreSchemaOptions
/** @public */
export type TLStoreEventInfo = HistoryEntry<TLRecord>
@ -55,12 +54,39 @@ export const defaultAssetStore: TLAssetStore = {
resolve: (asset) => asset.props.src,
}
/**
* A helper for creating a TLStore schema from either an object with shapeUtils, bindingUtils, and
* migrations, or a schema.
*
* @param opts - Options for creating the schema.
*
* @public
*/
export function createTLSchemaFromUtils(
opts: TLStoreSchemaOptions
): StoreSchema<TLRecord, TLStoreProps> {
if ('schema' in opts && opts.schema) return opts.schema
return createTLSchema({
shapes:
'shapeUtils' in opts && opts.shapeUtils
? utilsToMap(checkShapesAndAddCore(opts.shapeUtils))
: undefined,
bindings:
'bindingUtils' in opts && opts.bindingUtils
? utilsToMap(checkBindings(opts.bindingUtils))
: undefined,
migrations: 'migrations' in opts ? opts.migrations : undefined,
})
}
/**
* A helper for creating a TLStore.
*
* @param opts - Options for creating the store.
*
* @public */
* @public
*/
export function createTLStore({
initialData,
defaultName = '',
@ -70,22 +96,7 @@ export function createTLStore({
multiplayerStatus,
...rest
}: TLStoreOptions = {}): TLStore {
const schema =
'schema' in rest && rest.schema
? // we have a schema
rest.schema
: // we need a schema
createTLSchema({
shapes:
'shapeUtils' in rest && rest.shapeUtils
? utilsToMap(checkShapesAndAddCore(rest.shapeUtils))
: undefined,
bindings:
'bindingUtils' in rest && rest.bindingUtils
? utilsToMap(checkBindings(rest.bindingUtils))
: undefined,
migrations: 'migrations' in rest ? rest.migrations : undefined,
})
const schema = createTLSchemaFromUtils(rest)
return new Store({
id,

View file

@ -16,6 +16,6 @@ export function useShallowArrayIdentity<T>(arr: readonly T[]): readonly T[] {
}
/** @internal */
export function useShallowObjectIdentity<T extends object>(arr: T): T {
return useIdentity(arr, areObjectsShallowEqual)
export function useShallowObjectIdentity<T extends object>(obj: T): T {
return useIdentity(obj, areObjectsShallowEqual)
}

View file

@ -2,7 +2,12 @@ import { TLStoreSnapshot } from '@tldraw/tlschema'
import { areObjectsShallowEqual } from '@tldraw/utils'
import { useState } from 'react'
import { TLEditorSnapshot, loadSnapshot } from '../config/TLEditorSnapshot'
import { TLStoreOptions, createTLStore } from '../config/createTLStore'
import {
TLStoreOptions,
TLStoreSchemaOptions,
createTLSchemaFromUtils,
createTLStore,
} from '../config/createTLStore'
/** @public */
type UseTLStoreOptions = TLStoreOptions & {
@ -31,3 +36,16 @@ export function useTLStore(
return current.store
}
/** @public */
export function useTLSchemaFromUtils(opts: TLStoreSchemaOptions) {
const [current, setCurrent] = useState(() => ({ opts, schema: createTLSchemaFromUtils(opts) }))
if (!areObjectsShallowEqual(current.opts, opts)) {
const next = createTLSchemaFromUtils(opts)
setCurrent({ opts, schema: next })
return next
}
return current.schema
}

View file

@ -7,7 +7,7 @@
import { Editor } from 'tldraw';
import { Signal } from 'tldraw';
import { TLAssetStore } from 'tldraw';
import { TLSchema } from 'tldraw';
import { TLStoreSchemaOptions } from 'tldraw';
import { TLStoreWithStatus } from 'tldraw';
import { TLUserPreferences } from 'tldraw';
@ -19,7 +19,7 @@ export type RemoteTLStoreWithStatus = Exclude<TLStoreWithStatus, {
}>;
// @public (undocumented)
export function useMultiplayerDemo(options: UseMultiplayerDemoOptions): RemoteTLStoreWithStatus;
export function useMultiplayerDemo(options: UseMultiplayerDemoOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus;
// @public (undocumented)
export interface UseMultiplayerDemoOptions {
@ -28,13 +28,11 @@ export interface UseMultiplayerDemoOptions {
// (undocumented)
roomId: string;
// (undocumented)
schema?: TLSchema;
// (undocumented)
userPreferences?: Signal<TLUserPreferences>;
}
// @public (undocumented)
export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLStoreWithStatus;
export function useMultiplayerSync(opts: UseMultiplayerSyncOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus;
// @public (undocumented)
export interface UseMultiplayerSyncOptions {
@ -45,8 +43,6 @@ export interface UseMultiplayerSyncOptions {
// (undocumented)
roomId?: string;
// (undocumented)
schema?: TLSchema;
// (undocumented)
trackAnalyticsEvent?(name: string, data: {
[key: string]: any;
}): void;

View file

@ -13,18 +13,18 @@ import {
TAB_ID,
TLAssetStore,
TLRecord,
TLSchema,
TLStore,
TLStoreSchemaOptions,
TLStoreWithStatus,
TLUserPreferences,
computed,
createPresenceStateDerivation,
createTLSchema,
createTLStore,
defaultUserPreferences,
getUserPreferences,
uniqueId,
useRefState,
useTLSchemaFromUtils,
useValue,
} from 'tldraw'
@ -37,7 +37,9 @@ export type RemoteTLStoreWithStatus = Exclude<
>
/** @public */
export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLStoreWithStatus {
export function useMultiplayerSync(
opts: UseMultiplayerSyncOptions & TLStoreSchemaOptions
): RemoteTLStoreWithStatus {
const [state, setState] = useRefState<{
readyClient?: TLSyncClient<TLRecord, TLStore>
error?: Error
@ -49,9 +51,11 @@ export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLSto
assets,
onEditorMount,
trackAnalyticsEvent: track,
schema,
...schemaOpts
} = opts
const schema = useTLSchemaFromUtils(schemaOpts)
useEffect(() => {
const storeId = uniqueId()
@ -89,7 +93,7 @@ export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLSto
const store = createTLStore({
id: storeId,
schema: schema ?? createTLSchema(),
schema,
assets,
onEditorMount,
multiplayerStatus: computed('multiplayer status', () =>
@ -160,5 +164,4 @@ export interface UseMultiplayerSyncOptions {
trackAnalyticsEvent?(name: string, data: { [key: string]: any }): void
assets?: Partial<TLAssetStore>
onEditorMount?: (editor: Editor) => void
schema?: TLSchema
}

View file

@ -6,10 +6,13 @@ import {
Signal,
TLAsset,
TLAssetStore,
TLSchema,
TLStoreSchemaOptions,
TLUserPreferences,
defaultBindingUtils,
defaultShapeUtils,
getHashForString,
uniqueId,
useShallowObjectIdentity,
} from 'tldraw'
import { RemoteTLStoreWithStatus, useMultiplayerSync } from './useMultiplayerSync'
@ -19,7 +22,6 @@ export interface UseMultiplayerDemoOptions {
userPreferences?: Signal<TLUserPreferences>
/** @internal */
host?: string
schema?: TLSchema
}
/**
@ -42,16 +44,34 @@ const DEMO_WORKER = getEnv(() => process.env.TLDRAW_BEMO_URL) ?? 'https://demo.t
const IMAGE_WORKER = getEnv(() => process.env.TLDRAW_IMAGE_URL) ?? 'https://images.tldraw.xyz'
/** @public */
export function useMultiplayerDemo(options: UseMultiplayerDemoOptions): RemoteTLStoreWithStatus {
const { roomId, userPreferences, host = DEMO_WORKER, schema } = options
export function useMultiplayerDemo(
options: UseMultiplayerDemoOptions & TLStoreSchemaOptions
): RemoteTLStoreWithStatus {
const { roomId, userPreferences, host = DEMO_WORKER, ..._schemaOpts } = options
const assets = useMemo(() => createDemoAssetStore(host), [host])
const schemaOpts = useShallowObjectIdentity(_schemaOpts)
const schemaOptsWithDefaults = useMemo((): TLStoreSchemaOptions => {
if ('schema' in schemaOpts && schemaOpts.schema) return schemaOpts
return {
...schemaOpts,
shapeUtils:
'shapeUtils' in schemaOpts
? [...defaultShapeUtils, ...(schemaOpts.shapeUtils ?? [])]
: defaultShapeUtils,
bindingUtils:
'bindingUtils' in schemaOpts
? [...defaultBindingUtils, ...(schemaOpts.bindingUtils ?? [])]
: defaultBindingUtils,
}
}, [schemaOpts])
return useMultiplayerSync({
uri: `${host}/connect/${encodeURIComponent(roomId)}`,
roomId,
userPreferences,
assets,
schema,
onEditorMount: useCallback(
(editor: Editor) => {
editor.registerExternalAssetHandler('url', async ({ url }) => {
@ -60,6 +80,7 @@ export function useMultiplayerDemo(options: UseMultiplayerDemoOptions): RemoteTL
},
[host]
),
...schemaOptsWithDefaults,
})
}