Tool with child states (#3074)

Adds an example of a tool with child states. I'm going over the
annotations at the moment, just wanted to validate the idea in the
meantime.
Closes tld-2114
- [x] `documentation` — Changes to the documentation only[^2]

### Release Notes

- Add an example of a tool with child states

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Taha 2024-04-03 12:25:07 +01:00 committed by GitHub
parent 03e4c8575c
commit 4f2cf3dee0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 270 additions and 0 deletions

View file

@ -0,0 +1,12 @@
---
title: Tool with child states
component: ./ToolWithChildStatesExample.tsx
category: shapes/tools
priority: 2
---
You can implement more complex behaviour in a custom tool by using child states
---
Tools are nodes in tldraw's state machine. They are responsible for handling user input. You can create custom tools by extending the StateNode class and overriding its methods. In this example we expand on the sticker tool from the custom tool example to show how to create a tool that can handle more complex interactions by using child states.

View file

@ -0,0 +1,258 @@
import {
StateNode,
TLEventHandlers,
TLShapePartial,
TLTextShape,
Tldraw,
createShapeId,
} from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
const OFFSET = -12
// [1]
class StickerTool extends StateNode {
static override id = 'sticker'
static override initial = 'idle'
static override children = () => [Idle, Pointing, Dragging]
}
// [2]
class Idle extends StateNode {
static override id = 'idle'
//[a]
override onEnter = () => {
this.editor.setCursor({ type: 'cross' })
}
//[b]
override onPointerDown: TLEventHandlers['onPointerDown'] = (info) => {
const { editor } = this
switch (info.target) {
case 'canvas': {
const hitShape = editor.getShapeAtPoint(editor.inputs.currentPagePoint)
if (hitShape) {
this.onPointerDown({
...info,
shape: hitShape,
target: 'shape',
})
return
}
this.parent.transition('pointing', { shape: null })
break
}
case 'shape': {
if (editor.inputs.shiftKey) {
editor.updateShape({
id: info.shape.id,
type: 'text',
props: { text: '👻 boo!' },
})
} else {
this.parent.transition('pointing', { shape: info.shape })
}
break
}
}
}
//[c]
override onDoubleClick: TLEventHandlers['onDoubleClick'] = (info) => {
const { editor } = this
if (info.phase !== 'up') return
switch (info.target) {
case 'canvas': {
const hitShape = editor.getShapeAtPoint(editor.inputs.currentPagePoint)
if (hitShape) {
this.onDoubleClick({
...info,
shape: hitShape,
target: 'shape',
})
return
}
const { currentPagePoint } = editor.inputs
editor.createShape({
type: 'text',
x: currentPagePoint.x + OFFSET,
y: currentPagePoint.y + OFFSET,
props: { text: '❤️' },
})
break
}
case 'shape': {
editor.deleteShapes([info.shape.id])
break
}
}
}
}
// [3]
class Pointing extends StateNode {
static override id = 'pointing'
private shape: TLTextShape | null = null
override onEnter = (info: { shape: TLTextShape | null }) => {
this.shape = info.shape
}
override onPointerUp: TLEventHandlers['onPointerUp'] = () => {
this.parent.transition('idle')
}
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
if (this.editor.inputs.isDragging) {
this.parent.transition('dragging', { shape: this.shape })
}
}
}
// [4]
class Dragging extends StateNode {
static override id = 'dragging'
// [a]
private shape: TLShapePartial | null = null
private emojiArray = ['❤️', '🔥', '👍', '👎', '😭', '🤣']
// [b]
override onEnter = (info: { shape: TLShapePartial }) => {
const { currentPagePoint } = this.editor.inputs
const newShape = {
id: createShapeId(),
type: 'text',
x: currentPagePoint.x + OFFSET,
y: currentPagePoint.y + OFFSET,
props: { text: '❤️' },
}
if (info.shape) {
this.shape = info.shape
} else {
this.editor.createShape(newShape)
this.shape = { ...newShape }
}
}
//[c]
override onPointerUp: TLEventHandlers['onPointerUp'] = () => {
this.parent.transition('idle')
}
//[d]
override onPointerMove: TLEventHandlers['onPointerUp'] = () => {
const { shape } = this
const { originPagePoint, currentPagePoint } = this.editor.inputs
const distance = originPagePoint.dist(currentPagePoint)
if (shape) {
this.editor.updateShape({
id: shape.id,
type: 'text',
props: {
text: this.emojiArray[Math.floor(distance / 20) % this.emojiArray.length],
},
})
}
}
}
// [5]
const customTools = [StickerTool]
export default function ToolWithChildStatesExample() {
return (
<div className="tldraw__editor">
<Tldraw
// Pass in the array of custom tool classes
tools={customTools}
// Set the initial state to the sticker tool
initialState="sticker"
// hide the ui
hideUi
// Put some helpful text on the canvas
onMount={(editor) => {
editor.createShape({
type: 'text',
x: 50,
y: 50,
props: {
text: '-Double click the canvas to add a sticker\n-Double click a sticker to delete it\n-Click and drag on a sticker to change it\n-Click and drag on the canvas to create a sticker\n-Shift click a sticker for a surprise!',
size: 's',
align: 'start',
},
})
}}
/>
</div>
)
}
/*
Introduction:
Tools are nodes in tldraw's state machine. They are responsible for handling user input.
You can create custom tools by extending the `StateNode` class and overriding its
methods. In this example we expand on the sticker tool from the custom tool example to
show how to create a tool that can handle more complex interactions by using child states.
[1]
This is our custom tool. It has three child states: `Idle`, `Pointing`, and `Dragging`.
We need to define the `id` and `initial` properties, the id is a unique string that
identifies the tool to the editor, and the initial property is the initial state of the
tool. We also need to define a `children` method that returns an array of the tool's
child states.
[2]
This is our Idle state. It is the initial state of the tool. It's job is to figure out
what the user is trying to do and transition to the appropriate state. When transitioning
between states we can use the second argument to pass data to the new state. It has three
methods:
[a] `onEnter`
When entering any state, the `onEnter` method is called. In this case, we set the cursor to
a crosshair.
[b] `onPointerDown`
This method is called when the user presses the mouse button. The target parameter is always
the canvas, so we can use an editor method to check if we're over a shape, and call the
method again with the shape as the target. If we are over a shape, we transition to the
`pointing` state with the shape in the info object. If we're over a shape and holding the
shift key, we update the shape's text. If we're over the canvas, we transition to the
`pointing` state with a null shape in the info object.
[c] `onDoubleClick`
This method is called when the user double clicks the mouse button. We're using some similar
logic here to check if we're over a shape, and if we are, we delete it. If we're over the canvas,
we create a new shape.
[3]
This is our `Pointing` state. It's a transitionary state, we use it to store the shape we're pointing
at, and transition to the dragging state if the user starts dragging. It has three methods:
[a] `onEnter`
When entering this state, we store the shape we're pointing at by getting it from the info object.
[b] `onPointerUp`
This method is called when the user releases the mouse button. We transition to the `idle` state.
[c] `onPointerMove`
This method is called when the user moves the mouse. If the user starts dragging, we transition to
the `dragging` state and pass the shape we're pointing at.
[4]
This is our `Dragging` state. It's responsible for creating and updating the shape that the user is
dragging.
[a] `onEnter`
When entering this state, we create a new shape if we're not dragging an existing one. If we are,
we store the shape we're dragging.
[b] `onPointerUp`
This method is called when the user releases the mouse button. We transition to the `idle` state.
[c] `onPointerMove`
This method is called when the user moves the mouse. We use the distance between the origin and
current mouse position to cycle through an array of emojis and update the shape's text.
[5]
We pass our custom tool to the `Tldraw` component as an array. We also set the initial state to our
custom tool. For the purposes of this demo, we're also hiding the UI and adding some helpful text to
the canvas.
*/