Custom tools
Write a tool as a small state machine, handle pointer and keyboard input, and make gestures undo and cancel cleanly.
A tool is a tree of states. Each state handles the events it cares about and moves to another state when something happens. It sounds formal, but it's what keeps gestures honest: a state knows exactly what it started, so it can finish it or take it back.
A stamp tool#
Click to stamp a star. Drag before releasing to size it. Escape cancels.
import { StateNode, type KeyboardEventInfo, type PointerEventInfo } from 'canvasflow'
class Idle extends StateNode {
static override id = 'idle'
override onEnter() {
this.editor.setCursor('cross')
}
override onPointerDown(info: PointerEventInfo) {
if (info.button === 0) this.parent!.transition('stamping')
}
override onKeyDown(info: KeyboardEventInfo) {
if (info.key === 'Escape') this.editor.setTool('select')
}
}
class Stamping extends StateNode {
static override id = 'stamping'
private shapeId = ''
override onEnter() {
const { x, y } = this.editor.inputs.originPage
this.editor.history.begin('stamp') // the whole gesture is one undo step
this.shapeId = this.editor.createShape({ type: 'geo', x: x - 40, y: y - 40, props: { geo: 'star', w: 80, h: 80, color: 'yellow', fill: 'solid' } })
}
override onPointerMove() {
if (!this.editor.inputs.isDragging) return
const o = this.editor.inputs.originPage
const p = this.editor.inputs.currentPage
const size = Math.max(24, Math.hypot(p.x - o.x, p.y - o.y) * 2)
this.editor.updateShape({ id: this.shapeId, x: o.x - size / 2, y: o.y - size / 2, props: { w: size, h: size } })
}
override onPointerUp() {
this.editor.history.end()
this.parent!.transition('idle')
}
override onCancel() {
this.editor.history.bail() // takes the star back, as if nothing happened
this.parent!.transition('idle')
}
override onKeyDown(info: KeyboardEventInfo) {
if (info.key === 'Escape') this.onCancel()
}
}
export class StampTool extends StateNode {
static override id = 'stamp'
static override initial = 'idle'
static override children = () => [Idle, Stamping]
}
editor.registerTool(StampTool)
editor.setTool('stamp')
Events a state can handle#
| Handler | When |
|---|---|
onEnter(info, from), onExit(info, to) |
The state becomes active or stops being active |
onPointerDown, onPointerMove, onPointerUp |
Pointer input, from mouse, touch or pen |
onDoubleClick, onLongPress, onRightClick |
Higher-level gestures |
onKeyDown, onKeyUp, onKeyRepeat |
Keys (shortcuts that match an action run first) |
onWheel, onPinchStart, onPinch, onPinchEnd |
Scrolling and pinching |
onCancel |
The gesture must stop: window blur, a second finger, Escape |
onTick |
Once per frame while a pointer is down (for edge scrolling, animations) |
Events flow from the root down to the active state, so a parent can handle something for all of its children.
Reading input#
editor.inputs is the live input state:
| Field | What it is |
|---|---|
originPage, currentPage, previousPage |
Where the gesture started, is, and was a moment ago (page space) |
originScreen, currentScreen |
The same, in screen space |
isPointing, isDragging |
A pointer is down; it moved past the drag threshold (larger for touch) |
shiftKey, altKey, accelKey |
Modifiers (accelKey is ⌘ on Apple, Ctrl elsewhere) |
pointerType |
mouse, pen or touch |
Making gestures feel right#
- One gesture, one undo step.
history.begin()when it starts,end()when it completes,bail()when it's cancelled. - Respect the drag threshold. Check
inputs.isDraggingbefore treating a press as a drag, so a slightly shaky click stays a click. - Always handle cancel. Window blur, an incoming call, a second finger:
onCancelis how the editor tells you to put things back. - Let Escape go home. From idle, Escape should return to the select tool.
Modal states and keys#
Global shortcuts run before tools see a key. If a state needs keys like Enter or ⌫ for itself (the line tool does, while you place points), set captureKeys = true on it and it gets them first.
import { StateNode } from 'canvasflow'
class PlacingPoints extends StateNode {
static override id = 'placing'
override captureKeys = true
}
Ship it in a plugin#
editor.use({ name: 'stamps', tools: [StampTool] })