[]previous and next page

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.

StampTool.tsts
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]
}
ts
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.isDragging before 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: onCancel is how the editor tells you to put things back.
  • Let Escape go home. From idle, Escape should return to the select tool.

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.

ts
import { StateNode } from 'canvasflow'

class PlacingPoints extends StateNode {
  static override id = 'placing'
  override captureKeys = true
}

Ship it in a plugin#

ts
editor.use({ name: 'stamps', tools: [StampTool] })