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:
parent
8b390dddb1
commit
d1151a7af5
6 changed files with 340 additions and 1 deletions
|
@ -2,7 +2,7 @@ import { Tldraw } from '@tldraw/tldraw'
|
||||||
import '@tldraw/tldraw/tldraw.css'
|
import '@tldraw/tldraw/tldraw.css'
|
||||||
import { CardShapeTool } from './CardShape/CardShapeTool'
|
import { CardShapeTool } from './CardShape/CardShapeTool'
|
||||||
import { CardShapeUtil } from './CardShape/CardShapeUtil'
|
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!
|
// There's a guide at the bottom of this file!
|
||||||
|
|
||||||
|
@ -21,6 +21,8 @@ export default function CustomConfigExample() {
|
||||||
tools={customTools}
|
tools={customTools}
|
||||||
// Pass in any overrides to the user interface
|
// Pass in any overrides to the user interface
|
||||||
overrides={uiOverrides}
|
overrides={uiOverrides}
|
||||||
|
// Pass in the new Keybaord Shortcuts component
|
||||||
|
components={components}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
*/
|
12
apps/examples/src/examples/editable-shape/README.md
Normal file
12
apps/examples/src/examples/editable-shape/README.md
Normal 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.
|
|
@ -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.
|
||||||
|
|
||||||
|
*/
|
|
@ -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.
|
||||||
|
|
||||||
|
*/
|
60
apps/examples/src/examples/editable-shape/ui-overrides.tsx
Normal file
60
apps/examples/src/examples/editable-shape/ui-overrides.tsx
Normal 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.
|
||||||
|
|
||||||
|
|
||||||
|
*/
|
Loading…
Add table
Add a link
Reference in a new issue