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 /> <WifiIcon />
<div>Live Example</div> <div>Live Example</div>
<button <button
className="MultiplayerExampleWrapper-copy"
onClick={() => { onClick={() => {
// copy current url with roomId=roomId to clipboard // copy current url with roomId=roomId to clipboard
navigator.clipboard.writeText(window.location.href.split('?')[0] + `?roomId=${roomId}`) navigator.clipboard.writeText(window.location.href.split('?')[0] + `?roomId=${roomId}`)
@ -99,7 +100,8 @@ function MultiplayerExampleWrapper({
}} }}
aria-label="join" aria-label="join"
> >
{confirm ? 'Copied!' : 'Copy Share URL'} Copy link
{confirm && <div className="MultiplayerExampleWrapper-copied">Copied!</div>}
</button> </button>
</div> </div>
<div className="MultiplayerExampleWrapper-example"> <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); color: var(--text);
font-size: 14px; font-size: 14px;
} }
.MultiplayerExampleWrapper-picker button { .MultiplayerExampleWrapper-copy {
background: var(--black-transparent-lighter); background: var(--black-transparent-lighter);
border: 1px solid var(--gray-dark); border: 1px solid var(--gray-dark);
border-radius: 4px; border-radius: 4px;
padding: 4px 12px; padding: 4px 12px;
height: 24px;
cursor: pointer; 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); border: 1px solid var(--black-transparent-lighter);
} }
.MultiplayerExampleWrapper-example { .MultiplayerExampleWrapper-example {

View file

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

View file

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

View file

@ -32,19 +32,18 @@ export interface TLStoreBaseOptions {
} }
/** @public */ /** @public */
export type TLStoreOptions = TLStoreBaseOptions & export type TLStoreSchemaOptions =
( | {
| { schema?: StoreSchema<TLRecord, TLStoreProps>
id?: string }
shapeUtils?: readonly TLAnyShapeUtilConstructor[] | {
migrations?: readonly MigrationSequence[] shapeUtils?: readonly TLAnyShapeUtilConstructor[]
bindingUtils?: readonly TLAnyBindingUtilConstructor[] migrations?: readonly MigrationSequence[]
} bindingUtils?: readonly TLAnyBindingUtilConstructor[]
| { }
id?: string
schema?: StoreSchema<TLRecord, TLStoreProps> /** @public */
} export type TLStoreOptions = TLStoreBaseOptions & { id?: string } & TLStoreSchemaOptions
)
/** @public */ /** @public */
export type TLStoreEventInfo = HistoryEntry<TLRecord> export type TLStoreEventInfo = HistoryEntry<TLRecord>
@ -55,12 +54,39 @@ export const defaultAssetStore: TLAssetStore = {
resolve: (asset) => asset.props.src, 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. * A helper for creating a TLStore.
* *
* @param opts - Options for creating the store. * @param opts - Options for creating the store.
* *
* @public */ * @public
*/
export function createTLStore({ export function createTLStore({
initialData, initialData,
defaultName = '', defaultName = '',
@ -70,22 +96,7 @@ export function createTLStore({
multiplayerStatus, multiplayerStatus,
...rest ...rest
}: TLStoreOptions = {}): TLStore { }: TLStoreOptions = {}): TLStore {
const schema = const schema = createTLSchemaFromUtils(rest)
'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,
})
return new Store({ return new Store({
id, id,

View file

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

View file

@ -2,7 +2,12 @@ import { TLStoreSnapshot } from '@tldraw/tlschema'
import { areObjectsShallowEqual } from '@tldraw/utils' import { areObjectsShallowEqual } from '@tldraw/utils'
import { useState } from 'react' import { useState } from 'react'
import { TLEditorSnapshot, loadSnapshot } from '../config/TLEditorSnapshot' import { TLEditorSnapshot, loadSnapshot } from '../config/TLEditorSnapshot'
import { TLStoreOptions, createTLStore } from '../config/createTLStore' import {
TLStoreOptions,
TLStoreSchemaOptions,
createTLSchemaFromUtils,
createTLStore,
} from '../config/createTLStore'
/** @public */ /** @public */
type UseTLStoreOptions = TLStoreOptions & { type UseTLStoreOptions = TLStoreOptions & {
@ -31,3 +36,16 @@ export function useTLStore(
return current.store 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 { Editor } from 'tldraw';
import { Signal } from 'tldraw'; import { Signal } from 'tldraw';
import { TLAssetStore } from 'tldraw'; import { TLAssetStore } from 'tldraw';
import { TLSchema } from 'tldraw'; import { TLStoreSchemaOptions } from 'tldraw';
import { TLStoreWithStatus } from 'tldraw'; import { TLStoreWithStatus } from 'tldraw';
import { TLUserPreferences } from 'tldraw'; import { TLUserPreferences } from 'tldraw';
@ -19,7 +19,7 @@ export type RemoteTLStoreWithStatus = Exclude<TLStoreWithStatus, {
}>; }>;
// @public (undocumented) // @public (undocumented)
export function useMultiplayerDemo(options: UseMultiplayerDemoOptions): RemoteTLStoreWithStatus; export function useMultiplayerDemo(options: UseMultiplayerDemoOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus;
// @public (undocumented) // @public (undocumented)
export interface UseMultiplayerDemoOptions { export interface UseMultiplayerDemoOptions {
@ -28,13 +28,11 @@ export interface UseMultiplayerDemoOptions {
// (undocumented) // (undocumented)
roomId: string; roomId: string;
// (undocumented) // (undocumented)
schema?: TLSchema;
// (undocumented)
userPreferences?: Signal<TLUserPreferences>; userPreferences?: Signal<TLUserPreferences>;
} }
// @public (undocumented) // @public (undocumented)
export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLStoreWithStatus; export function useMultiplayerSync(opts: UseMultiplayerSyncOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus;
// @public (undocumented) // @public (undocumented)
export interface UseMultiplayerSyncOptions { export interface UseMultiplayerSyncOptions {
@ -45,8 +43,6 @@ export interface UseMultiplayerSyncOptions {
// (undocumented) // (undocumented)
roomId?: string; roomId?: string;
// (undocumented) // (undocumented)
schema?: TLSchema;
// (undocumented)
trackAnalyticsEvent?(name: string, data: { trackAnalyticsEvent?(name: string, data: {
[key: string]: any; [key: string]: any;
}): void; }): void;

View file

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

View file

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