Custom shape examples (#2994)

This PR:
- adds a simple custom shape example
- adds an interactive shape example
- updates editable shape example

closes TLD-2118

- [x] `documentation` — Changes to the documentation only[^2]

### Release Notes

- adds a simple custom shape example
- adds an interactive shape example
- updates editable shape example

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Taha 2024-03-02 20:18:31 +00:00 committed by GitHub
parent f819a57a05
commit 8658e20ab5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 462 additions and 306 deletions

View file

@ -24,5 +24,5 @@
".next/types/**/*.ts",
"watcher.ts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", ".next"]
}

View file

@ -1,8 +1,8 @@
---
title: Custom shapes / tools
title: Custom shape and tool
component: ./CustomConfigExample.tsx
category: shapes/tools
priority: 1
priority: 3
---
Create custom shapes / tools

View file

@ -0,0 +1,145 @@
import {
Geometry2d,
HTMLContainer,
Rectangle2d,
ShapeProps,
ShapeUtil,
T,
TLBaseShape,
TLOnResizeHandler,
Tldraw,
resizeBox,
} from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
// [1]
type ICustomShape = TLBaseShape<
'my-custom-shape',
{
w: number
h: number
text: string
}
>
// [2]
export class MyShapeUtil extends ShapeUtil<ICustomShape> {
// [a]
static override type = 'my-custom-shape' as const
static override props: ShapeProps<ICustomShape> = {
w: T.number,
h: T.number,
text: T.string,
}
// [b]
getDefaultProps(): ICustomShape['props'] {
return {
w: 200,
h: 200,
text: "I'm a shape!",
}
}
// [c]
override canBind = () => true
override canEdit = () => false
override canResize = () => true
override isAspectRatioLocked = () => false
// [d]
getGeometry(shape: ICustomShape): Geometry2d {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}
// [e]
override onResize: TLOnResizeHandler<any> = (shape, info) => {
return resizeBox(shape, info)
}
// [f]
component(shape: ICustomShape) {
return <HTMLContainer style={{ backgroundColor: '#efefef' }}>{shape.props.text}</HTMLContainer>
}
// [g]
indicator(shape: ICustomShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}
// [3]
const customShape = [MyShapeUtil]
export default function CustomShapeExample() {
return (
<div className="tldraw__editor">
<Tldraw
shapeUtils={customShape}
onMount={(editor) => {
editor.createShape({ type: 'my-custom-shape', x: 100, y: 100 })
}}
/>
</div>
)
}
/*
Introduction:
You can create custom shapes in tldraw by creating a shape util and passing it to the Tldraw component.
In this example, we'll create a custom shape that is a simple rectangle with some text inside of it.
[1]
Define the shape type. This is a type that extend the `TLBaseShape` generic and defines the shape's
props. We need to pass in a unique string literal for the shape's type and an object that defines the
shape's props.
[2]
This is our shape util. In tldraw shape utils are classes that define how a shape behaves and renders.
We can extend the ShapeUtil class and provide the shape type as a generic. If we extended the
BaseBoxShapeUtil class instead, we wouldn't have define methods such as `getGeometry` and `onResize`.
[a]
This is where we define out shape's props and type for the editor. It's important to use the same
string for the type as we did in [1]. We need to define the shape's props using tldraw's validator
library. The validator will help make sure the store always has shape data we can trust.
[b]
This is a method that returns the default props for our shape.
[c]
Some handy methods for controlling different shape behaviour. You don't have to define these, and
they're only shown here so you know they exist. Check out the editable shape example to learn more
about creating an editable shape.
[d]
The getGeometry method is what the editor uses for hit-testing, binding etc. We're using the
Rectangle2d class from tldraw's geometry library to create a rectangle shape. If we extended the
BaseBoxShapeUtil class, we wouldn't have to define this method.
[e]
We're using the resizeBox utility method to handle resizing our shape. If we extended the
BaseBoxShapeUtil class, we wouldn't have to define this method.
[f]
The component method defines how our shape renders. We're returning an HTMLContainer here, which
is a handy component that tldraw exports. It's essentially a div with some special css. There's a
lot of flexibility here, and you can use any React hooks you want and return any valid JSX.
[g]
The indicator is the blue outline around a selected shape. We're just returning a rectangle with the
same width and height as the shape here. You can return any valid JSX here.
[3]
This is where we render the Tldraw component with our custom shape. We're passing in our custom shape
util as an array to the shapeUtils prop. We're also using the onMount callback to create a shape on
the canvas. If you want to learn how to add a tool for your shape, check out the custom config example.
If you want to learn how to programmatically control the canvas, check out the Editor API examples.
*/

View file

@ -0,0 +1,13 @@
---
title: Custom shape
component: ./CustomShapeExample.tsx
category: shapes/tools
priority: 1
---
A simple custom shape.
---
You can create custom shapes in tldraw by creating a shape util and passing it to the Tldraw component.
In this example, we'll create a custom shape that is a simple rectangle with some text inside of it.

View file

@ -1,26 +1,19 @@
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import { MyshapeTool } from './my-shape/my-shape-tool'
import { MyshapeUtil } from './my-shape/my-shape-util'
import { components, uiOverrides } from './ui-overrides'
import { EditableShapeUtil } from './EditableShapeUtil'
// [1]
const customShapeUtils = [MyshapeUtil]
const customTools = [MyshapeTool]
const customShapeUtils = [EditableShapeUtil]
//[2]
export default function EditableShapeExample() {
return (
<div className="tldraw__editor">
<Tldraw
// Pass in the array of custom shape classes
shapeUtils={customShapeUtils}
// Pass in the array of custom tools
tools={customTools}
// Pass in any overrides to the user interface
overrides={uiOverrides}
// pass in the new Keyboard Shortcuts component
components={components}
// Create a shape when the editor mounts
onMount={(editor) => {
editor.createShape({ type: 'my-editable-shape', x: 100, y: 100 })
}}
/>
</div>
)
@ -31,24 +24,12 @@ Introduction:
In Tldraw shapes can exist in an editing state. When shapes are in the editing state
they are focused and can't be dragged, resized or rotated. Shapes enter this state
when they are double-clicked, this means that users can drag and resize shapes without
accidentally entering the editing state. In our default shapes we mostly use this for
editing text, but it's also used in our video shape. In this example we'll create a
shape that you could use for a game of Go, but instead of black and white stones, we'll
use cats and dogs.
when they are double-clicked. In our default shapes we mostly use this for editing text.
In this example we'll create a shape that renders an emoji and allows the user to change
the emoji when the shape is in the editing state.
Most of the relevant code for this is in the my-shape-util.tsx file. We also define a
very simple tool in my-shape-tool.tsx, and make our new tool appear on the toolbar in
ui-overrides.ts.
Most of the relevant code for this is in the EditableShapeUtil.tsx file. If you want a more
in-depth explanation of the shape util, check out the custom shape example.
[1]
We have to define our array of custom shapes and tools outside of the component. So it
doesn't get redefined every time the component re-renders. We'll pass that in to the
editors props.
[2]
We pass in our custom shape classes to the Tldraw component as props. We also pass in
any uiOverrides we want to use, this is to make sure that our custom tool appears on
the toolbar.
*/

View file

@ -0,0 +1,126 @@
import {
BaseBoxShapeUtil,
HTMLContainer,
ShapeProps,
T,
TLBaseShape,
TLOnEditEndHandler,
stopEventPropagation,
} from 'tldraw'
// There's a guide at the bottom of this file!
const ANIMAL_EMOJIS = ['🐶', '🐱', '🐨', '🐮', '🐴']
type IMyEditableShape = TLBaseShape<
'my-editable-shape',
{
w: number
h: number
animal: number
}
>
export class EditableShapeUtil extends BaseBoxShapeUtil<IMyEditableShape> {
static override type = 'my-editable-shape' as const
static override props: ShapeProps<IMyEditableShape> = {
w: T.number,
h: T.number,
animal: T.number,
}
// [1] !!!
override canEdit = () => true
getDefaultProps(): IMyEditableShape['props'] {
return {
w: 200,
h: 200,
animal: 0,
}
}
// [2]
component(shape: IMyEditableShape) {
// [a]
const isEditing = this.editor.getEditingShapeId() === shape.id
return (
<HTMLContainer
id={shape.id}
// [b]
onPointerDown={isEditing ? stopEventPropagation : undefined}
style={{
pointerEvents: isEditing ? 'all' : 'none',
backgroundColor: '#efefef',
fontSize: 24,
padding: 16,
}}
>
{ANIMAL_EMOJIS[shape.props.animal]}
{/* [c] */}
{isEditing ? (
<button
onClick={() => {
this.editor.updateShape({
id: shape.id,
type: shape.type,
props: {
...shape.props,
animal: (shape.props.animal + 1) % ANIMAL_EMOJIS.length,
},
})
}}
>
Next
</button>
) : (
// [d] when not editing...
<p style={{ fontSize: 12 }}>Double Click to Edit</p>
)}
</HTMLContainer>
)
}
indicator(shape: IMyEditableShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
// [3]
override onEditEnd: TLOnEditEndHandler<IMyEditableShape> = (shape) => {
this.editor.animateShape(
{ ...shape, rotation: shape.rotation + Math.PI * 2 },
{ duration: 250 }
)
}
}
/*
This is our shape util, which defines how our shape renders and behaves. For
more information on the shape util, check out the custom shape example.
[1]
We override the canEdit method to allow the shape to enter the editing state.
[2]
We want to conditionally render the component based on whether it is being
edited or not.
[a] We can check whether our shape is being edited by comparing the
editing shape id to the shape's id.
[b] We want to allow pointer events when the shape is being edited,
and stop event propagation on pointer down. Check out the interactive
shape example for more information on this.
[c] We render a button to change the animal emoji when the shape is being
edited.
[e] We also render a message when the shape is not being edited.
[3]
The onEditEnd method is called when the shape exits the editing state. In this
case we rotate the shape 360 degrees.
*/

View file

@ -2,11 +2,18 @@
title: Editable shape
component: ./EditableShapeExample.tsx
category: shapes/tools
priority: 1
priority: 2
---
A custom shape that you can edit by double-clicking it.
---
Learn how you can have a shape that enters an editing state, and have a side effect run when editing has finished.
In Tldraw, the Editor can have one editing shape at a time. When in its editing state, the editor will ignore events until the user exits the editing state by pressing Escape or clicking on the canvas.
Only shapes with a `canEdit` flag that returns true may become editable. A user may begin editing a shape by double clicking on the editable shape, or selecting the editable shape and pressing enter.
Many of our shapes use editing to allow for interactions inside of the shape. For example, a text shape behaves like a text graphic until the user begins editing it—and only then can the user use their keyboard to edit the text. Note that a shape can be interactive regardless of whether it's the editor's editing shape—the "editing" mechanic is just a way of managing a common pattern in canvas appliations.
In this example we'll create a shape that renders an emoji and allows the user to change the emoji when the shape is in the editing state.
Most of the relevant code for this is in the EditableShapeUtil.tsx file. If you want a more in-depth explanation of the shape util, check out the custom shape example.

View file

@ -1,15 +0,0 @@
import { BaseBoxShapeTool } from 'tldraw'
export class MyshapeTool extends BaseBoxShapeTool {
static override id = 'Myshape'
static override initial = 'idle'
override shapeType = 'Myshape'
}
/*
This file contains our custom tool. The tool is a StateNode with the `id` "Myshape".
We get a lot of functionality for free by extending the BaseBoxShapeTool. but we can
handle events in our own way by overriding methods like onDoubleClick. For an example
of a tool with more custom functionality, check out the screenshot-tool example.
*/

View file

@ -1,196 +0,0 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useState } from 'react'
import {
HTMLContainer,
Rectangle2d,
ShapeProps,
ShapeUtil,
T,
TLBaseShape,
TLOnEditEndHandler,
TLOnResizeHandler,
resizeBox,
structuredClone,
useIsEditing,
} from 'tldraw'
// There's a guide at the bottom of this file!
// [1]
type IMyshape = TLBaseShape<
'Myshape',
{
w: number
h: number
}
>
export class MyshapeUtil extends ShapeUtil<IMyshape> {
// [2]
static override type = 'Myshape' as const
static override props: ShapeProps<IMyshape> = {
w: T.number,
h: T.number,
}
// [3]
override isAspectRatioLocked = (_shape: IMyshape) => true
override canResize = (_shape: IMyshape) => true
override canBind = (_shape: IMyshape) => true
// [4]
override canEdit = () => true
// [5]
getDefaultProps(): IMyshape['props'] {
return {
w: 170,
h: 165,
}
}
// [6]
getGeometry(shape: IMyshape) {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}
// [7]
component(shape: IMyshape) {
// [a]
const isEditing = useIsEditing(shape.id)
const [animal, setAnimal] = useState<boolean>(true)
// [b]
return (
<HTMLContainer id={shape.id}>
<div
style={{
border: '1px solid rgba(0, 0, 0, 0.1)',
boxShadow: '0px 1px 2px rgba(0, 0, 0, 0.12), 0px 1px 3px rgba(0, 0, 0, 0.04)',
display: 'flex',
borderRadius: '50%',
height: '100%',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'all',
backgroundColor: animal ? 'hsl(180, 34%, 86%)' : 'hsl(10, 34%, 86%)',
position: 'relative',
}}
>
<button
style={{
display: isEditing ? 'block' : 'none',
border: 'none',
position: 'absolute',
top: 0,
left: 50,
cursor: 'pointer',
padding: '8px 8px',
borderRadius: '4px',
backgroundColor: 'hsl(120, 54%, 46%)',
color: '#fff',
textDecoration: 'none',
boxShadow: '0px 1px 2px rgba(0, 0, 0, 0.12), 0px 1px 3px rgba(0, 0, 0, 0.04)',
}}
disabled={!isEditing}
onPointerDown={(e) => e.stopPropagation()}
onClick={() => setAnimal((prev) => !prev)}
>
Change
</button>
<p style={{ fontSize: shape.props.h / 1.5, margin: 0 }}>{animal ? '🐶' : '🐱'}</p>
</div>
</HTMLContainer>
)
}
// [8]
indicator(shape: IMyshape) {
const isEditing = useIsEditing(shape.id)
return <rect stroke={isEditing ? 'red' : 'blue'} width={shape.props.w} height={shape.props.h} />
}
// [9]
override onResize: TLOnResizeHandler<IMyshape> = (shape, info) => {
return resizeBox(shape, info)
}
// [10]
override onEditEnd: TLOnEditEndHandler<IMyshape> = (shape) => {
const frame1 = structuredClone(shape)
const frame2 = structuredClone(shape)
frame1.x = shape.x + 1.2
frame2.x = shape.x - 1.2
this.editor.animateShape(frame1, { duration: 50 })
setTimeout(() => {
this.editor.animateShape(frame2, { duration: 50 })
}, 100)
setTimeout(() => {
this.editor.animateShape(shape, { duration: 100 })
}, 200)
}
}
/*
This is a utility class for the Myshape shape. This is where you define the shape's behavior,
how it renders (its component and indicator), and how it handles different events.
[1]
This is where we define the shape's type for Typescript. We can extend the TLBaseShape type,
providing a unique string to identify the shape and the shape's props. We only need height
and width for this shape.
[2]
We define the shape's type and props for the editor. We can use tldraw's validator library to
define the shape's properties. In this case, we define the width and height properties as numbers.
[3]
Some methods we can override to define specific beahviour for the shape. For this shape, we don't
want the aspect ratio to change, we want it to resize, and sure it can bind, why not. Who doesn't
love arrows?
[4]
This is the important one. We set canEdit to true. This means that the shape can enter the editing
state.
[5]
This will be the default props for the shape when you create it via clicking.
[6]
We define the getGeometry method. This method returns the geometry of the shape. In this case,
a Rectangle2d object.
[7]
We define the component method. This controls what the shape looks like and it returns JSX.
[a] We can use the useIsEditing hook to check if the shape is in the editing state. If it is,
we want our shape to render differently.
[b] The HTML container is a really handy wrapper for custom shapes, it essentially creates a
div with some helpful css for you. We can use the isEditing variable to conditionally
render the shape. We also use the useState hook to toggle between a cat and a dog.
[8]
The indicator method is the blue box that appears around the shape when it's selected. We can
make it appear red if the shape is in the editing state by using the useIsEditing hook.
[9]
The onResize method is where we handle the resizing of the shape. We use the resizeBox helper
to handle the resizing for us.
[10]
The onEditEnd method is called when the shape exits the editing state. In the tldraw codebase we
mostly use this for trimming text fields in shapes. In this case, we use it to animate the shape
when it exits the editing state.
*/

View file

@ -1,60 +0,0 @@
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
TLComponents,
TLUiOverrides,
TldrawUiMenuItem,
toolbarItem,
useTools,
} from 'tldraw'
// There's a guide at the bottom of this file!
export const uiOverrides: TLUiOverrides = {
tools(editor, tools) {
// Create a tool item in the ui's context.
tools.Myshape = {
id: 'Myshape',
icon: 'color',
label: 'Myshape',
kbd: 'c',
onSelect: () => {
editor.setCurrentTool('Myshape')
},
}
return tools
},
toolbar(_app, toolbar, { tools }) {
// Add the tool item from the context to the toolbar.
toolbar.splice(4, 0, toolbarItem(tools.Myshape))
return toolbar
},
}
export const components: TLComponents = {
KeyboardShortcutsDialog: (props) => {
const tools = useTools()
return (
<DefaultKeyboardShortcutsDialog {...props}>
<DefaultKeyboardShortcutsDialogContent />
{/* Ideally, we'd interleave this into the tools group */}
<TldrawUiMenuItem {...tools['Myshape']} />
</DefaultKeyboardShortcutsDialog>
)
},
}
/*
This file contains overrides for the Tldraw UI. These overrides are used to add your custom tools
to the toolbar and the keyboard shortcuts menu.
We do this by providing a custom toolbar override to the Tldraw component. This override is a
function that takes the current editor, the default toolbar items, and the default tools.
It returns the new toolbar items. We use the toolbarItem helper to create a new toolbar item
for our custom tool. We then splice it into the toolbar items array at the 4th index. This puts
it after the eraser tool. We'll pass our overrides object into the Tldraw component's `overrides`
prop.
*/

View file

@ -0,0 +1,33 @@
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import { myInteractiveShape } from './my-interactive-shape-util'
// There's a guide at the bottom of this file!
// [1]
const customShapeUtils = [myInteractiveShape]
//[2]
export default function InteractiveShapeExample() {
return (
<div className="tldraw__editor">
<Tldraw
shapeUtils={customShapeUtils}
onMount={(editor) => {
editor.createShape({ type: 'my-interactive-shape', x: 100, y: 100 })
}}
/>
</div>
)
}
/*
By default the editor handles pointer events, but sometimes you want to handle
interactions on your shape in your own ways, for example via a button. You can do this
by using the css property `pointer events: all` and stopping event propagation. In
this example we want our todo shape to have a checkbox so the user can mark them as
done.
Check out my-interactive-shape-util.tsx to see how we create the shape.
*/

View file

@ -0,0 +1,14 @@
---
title: Interactive shape
component: ./InteractiveShapeExample.tsx
category: shapes/tools
priority: 1
---
A custom shape that has its own onClick interactions.
---
By default the editor handles pointer events, but sometimes you want to handle interactions on your shape in your own ways, for example via a button. You can do this by using the css property `pointer events: all` and stopping event propagation. In this example we want our todo shape to have a checkbox so the user can mark them as done.
Check out my-interactive-shape-util.tsx to see how we create the shape.

View file

@ -0,0 +1,108 @@
import { BaseBoxShapeUtil, HTMLContainer, ShapeProps, T, TLBaseShape } from 'tldraw'
// There's a guide at the bottom of this file!
type IMyInteractiveShape = TLBaseShape<
'my-interactive-shape',
{
w: number
h: number
checked: boolean
text: string
}
>
export class myInteractiveShape extends BaseBoxShapeUtil<IMyInteractiveShape> {
static override type = 'my-interactive-shape' as const
static override props: ShapeProps<IMyInteractiveShape> = {
w: T.number,
h: T.number,
checked: T.boolean,
text: T.string,
}
getDefaultProps(): IMyInteractiveShape['props'] {
return {
w: 230,
h: 230,
checked: false,
text: '',
}
}
// [1]
component(shape: IMyInteractiveShape) {
return (
<HTMLContainer
style={{
padding: 16,
height: shape.props.h,
width: shape.props.w,
// [a] This is where we allow pointer events on our shape
pointerEvents: 'all',
backgroundColor: '#efefef',
overflow: 'hidden',
}}
>
<input
type="checkbox"
checked={shape.props.checked}
onChange={() =>
this.editor.updateShape<IMyInteractiveShape>({
id: shape.id,
type: 'my-interactive-shape',
props: { checked: !shape.props.checked },
})
}
// [b] This is where we stop event propagation
onPointerDown={(e) => e.stopPropagation()}
/>
<input
type="text"
placeholder="Enter a todo..."
readOnly={shape.props.checked}
value={shape.props.text}
onChange={(e) =>
this.editor.updateShape<IMyInteractiveShape>({
id: shape.id,
type: 'my-interactive-shape',
props: { text: e.currentTarget.value },
})
}
// [c]
onPointerDown={(e) => {
if (!shape.props.checked) {
e.stopPropagation()
}
}}
/>
</HTMLContainer>
)
}
// [5]
indicator(shape: IMyInteractiveShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}
/*
This is a custom shape, for a more in-depth look at how to create a custom shape,
see our custom shape example.
[1]
This is where we describe how our shape will render
[a] We need to set pointer-events to all so that we can interact with our shape. This CSS property is
set to "none" off by default. We need to manually opt-in to accepting pointer events by setting it to
'all' or 'auto'.
[b] We need to stop event propagation so that the editor doesn't select the shape
when we click on the checkbox. The 'canvas container' forwards events that it receives
on to the editor, so stopping propagation here prevents the event from reaching the canvas.
[c] If the shape is not checked, we stop event propagation so that the editor doesn't
select the shape when we click on the input. If the shape is checked then we allow that event to
propagate to the canvas and then get sent to the editor, triggering clicks or drags as usual.
*/