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:
parent
03e4c8575c
commit
4f2cf3dee0
2 changed files with 270 additions and 0 deletions
12
apps/examples/src/examples/tool-with-child-states/README.md
Normal file
12
apps/examples/src/examples/tool-with-child-states/README.md
Normal 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.
|
|
@ -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.
|
||||||
|
*/
|
Loading…
Reference in a new issue