diff --git a/apps/examples/src/examples/tool-with-child-states/README.md b/apps/examples/src/examples/tool-with-child-states/README.md new file mode 100644 index 000000000..0025ca810 --- /dev/null +++ b/apps/examples/src/examples/tool-with-child-states/README.md @@ -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. diff --git a/apps/examples/src/examples/tool-with-child-states/ToolWithChildStatesExample.tsx b/apps/examples/src/examples/tool-with-child-states/ToolWithChildStatesExample.tsx new file mode 100644 index 000000000..ab57f7e38 --- /dev/null +++ b/apps/examples/src/examples/tool-with-child-states/ToolWithChildStatesExample.tsx @@ -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 ( +
+ { + 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', + }, + }) + }} + /> +
+ ) +} + +/* +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. +*/