Editable shape example (#2853)

This PR adds an example for an editable shape. I wanted to show the
onEditEnd method so I just made the shape do a little wiggle.

Closes #2592 

### Change Type

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

- Adds an editable shape example
This commit is contained in:
Taha 2024-02-19 16:00:37 +00:00 committed by GitHub
parent 8b390dddb1
commit d1151a7af5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 340 additions and 1 deletions

View file

@ -2,7 +2,7 @@ import { Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
import { CardShapeTool } from './CardShape/CardShapeTool'
import { CardShapeUtil } from './CardShape/CardShapeUtil'
import { uiOverrides } from './ui-overrides'
import { components, uiOverrides } from './ui-overrides'
// There's a guide at the bottom of this file!
@ -21,6 +21,8 @@ export default function CustomConfigExample() {
tools={customTools}
// Pass in any overrides to the user interface
overrides={uiOverrides}
// Pass in the new Keybaord Shortcuts component
components={components}
/>
</div>
)

View file

@ -0,0 +1,54 @@
import { Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
import { CatDogTool } from './my-shape/my-shape-tool'
import { CatDogUtil } from './my-shape/my-shape-util'
import { components, uiOverrides } from './ui-overrides'
// [1]
const customShapeUtils = [CatDogUtil]
const customTools = [CatDogTool]
//[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}
/>
</div>
)
}
/*
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.
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.
[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,12 @@
---
title: Editable Shape
component: ./EditableShapeExample.tsx
category: shapes/tools
priority: 1
---
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.

View file

@ -0,0 +1,15 @@
import { BaseBoxShapeTool } from '@tldraw/tldraw'
export class CatDogTool extends BaseBoxShapeTool {
static override id = 'catdog'
static override initial = 'idle'
override shapeType = 'catdog'
}
/*
This file contains our custom tool. The tool is a StateNode with the `id` "catdog".
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

@ -0,0 +1,196 @@
/* eslint-disable react-hooks/rules-of-hooks */
import {
HTMLContainer,
Rectangle2d,
ShapeProps,
ShapeUtil,
T,
TLBaseShape,
TLOnEditEndHandler,
TLOnResizeHandler,
resizeBox,
structuredClone,
useIsEditing,
} from '@tldraw/tldraw'
import { useState } from 'react'
// There's a guide at the bottom of this file!
// [1]
type ICatDog = TLBaseShape<
'catdog',
{
w: number
h: number
}
>
export class CatDogUtil extends ShapeUtil<ICatDog> {
// [2]
static override type = 'catdog' as const
static override props: ShapeProps<ICatDog> = {
w: T.number,
h: T.number,
}
// [3]
override isAspectRatioLocked = (_shape: ICatDog) => true
override canResize = (_shape: ICatDog) => true
override canBind = (_shape: ICatDog) => true
// [4]
override canEdit = () => true
// [5]
getDefaultProps(): ICatDog['props'] {
return {
w: 170,
h: 165,
}
}
// [6]
getGeometry(shape: ICatDog) {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}
// [7]
component(shape: ICatDog) {
// [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: ICatDog) {
const isEditing = useIsEditing(shape.id)
return <rect stroke={isEditing ? 'red' : 'blue'} width={shape.props.w} height={shape.props.h} />
}
// [9]
override onResize: TLOnResizeHandler<ICatDog> = (shape, info) => {
return resizeBox(shape, info)
}
// [10]
override onEditEnd: TLOnEditEndHandler<ICatDog> = (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 catdog 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

@ -0,0 +1,60 @@
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
TLComponents,
TLUiOverrides,
TldrawUiMenuItem,
toolbarItem,
useTools,
} from '@tldraw/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.catdog = {
id: 'catdog',
icon: 'color',
label: 'Catdog',
kbd: 'c',
onSelect: () => {
editor.setCurrentTool('catdog')
},
}
return tools
},
toolbar(_app, toolbar, { tools }) {
// Add the tool item from the context to the toolbar.
toolbar.splice(4, 0, toolbarItem(tools.catdog))
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['catdog']} />
</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.
*/