Building a Tool Plugin

Create a standalone tool plugin that interacts with the canvas.

Not every tool creates shapes. The select tool and pan tool are standalone tools that manipulate existing shapes or the viewport. This guide shows how to build a tool-only plugin.

Example: Eraser Tool

We’ll build a simple eraser tool that deletes shapes on click.

1. Define the Tool Icon

function EraserIcon() {
  return (
    <svg width="20" height="20" viewBox="0 0 20 20">
      <path
        d="M4 16l4-4m0 0l8-8m-8 8L4 8m4 4l8 4"
        stroke="currentColor"
        strokeWidth="1.5"
        fill="none"
        strokeLinecap="round"
      />
    </svg>
  );
}

2. Implement the Pointer Handler

import type { CanvasPointerEvent, ToolContext } from "@edv4h/usketch-shared";

function onPointerDown(toolCtx: ToolContext, event: CanvasPointerEvent) {
  const { store, shapes } = toolCtx;

  // Find the topmost shape under the pointer (iterate in reverse insertion order)
  for (const [id, data] of Array.from(store.getShapes()).reverse()) {
    const def = shapes.get(data.type);
    if (def?.hitTest(data, event.worldPoint)) {
      // Delete via command for undo support
      toolCtx.commands.execute({
        execute() { store.deleteShape(id); },
        undo() { store.addShape(data); },
      });
      return;
    }
  }
}

3. Create the Plugin

import type { PluginContext, UsketchPlugin } from "@edv4h/usketch-shared";

export const eraserPlugin: UsketchPlugin = {
  id: "usketch-plugin-tool-eraser",
  name: "Eraser",

  setup(ctx: PluginContext) {
    ctx.tools.register("eraser", {
      icon: EraserIcon,
      cursor: "pointer",
      shortcut: "e",
      order: 5,
      onPointerDown,
    });
  },
};

Tool Lifecycle

User clicks tool in toolbar (or presses shortcut)
  → store.setActiveToolId("eraser")

User interacts with canvas
  → onPointerDown / onPointerMove / onPointerUp

User switches to another tool
  → store.setActiveToolId("select")

Note: onActivate and onDeactivate are defined in ToolDefinition for future use but are not currently called by the runtime. Use them to prepare for upcoming lifecycle support.

Adding Custom Layers

Tools can register layers for visual feedback. For example, a lasso-select tool might show the selection area:

setup(ctx: PluginContext) {
  let lassoPath: Point[] = [];

  ctx.layers.register({
    id: "lasso-overlay",
    order: 75,
    render: () => lassoPath.length > 0
      ? <LassoPath points={lassoPath} />
      : null,
  });

  ctx.tools.register("lasso-select", {
    icon: LassoIcon,
    onPointerDown: (toolCtx, event) => {
      lassoPath = [event.worldPoint];
    },
    onPointerMove: (toolCtx, event) => {
      lassoPath.push(event.worldPoint);
    },
    onPointerUp: (toolCtx) => {
      selectShapesInPath(toolCtx.store, lassoPath);
      lassoPath = [];
    },
  });
}

Reusable Session Helpers

For tools that move, rotate, resize, or rectangle-select shapes, the common state-machine logic lives in @edv4h/usketch-tool-helpers. The built-in select tool is implemented entirely on top of these helpers — picking them up in your own tool gives you the same drag/snap/undo behavior for free.

Each helper returns a session object you create on pointerdown, drive on pointermove, and finalize on pointerup:

import {
  startDragSession,
  startResizeSession,
  startRotateSession,
  startMarqueeSession,
  trackHover,
} from "@edv4h/usketch-tool-helpers";

let session: ReturnType<typeof startDragSession> | null = null;

ctx.tools.register("stamp-and-drag", {
  icon: StampIcon,
  cursor: "crosshair",
  onPointerDown(toolCtx, event) {
    // Place a fixed-size rect under the cursor, then let the user drag it
    // anywhere before releasing — useful for "stamp this object" tools.
    const id = createPresetRect(toolCtx, event.worldPoint);
    session = startDragSession({
      ctx: toolCtx,
      startPoint: event.worldPoint,
      shapeIds: [id],
      includeDescendants: false,
    });
  },
  onPointerMove(toolCtx, event) {
    session?.update(event);
  },
  onPointerUp(toolCtx) {
    const result = session?.commit();
    if (result) {
      // Schedule the command via microtask so any pointer-up cleanup
      // (snap teardown etc.) lands first.
      queueMicrotask(() => toolCtx.commands.execute(result.command));
    }
    session = null;
  },
});

If you want “press and drag to size” instead — like the built-in rectangle tool — drive the shape’s width/height directly inside onPointerMove (no drag session needed). startDragSession always translates by a delta; it doesn’t resize.

The helpers handle the parts that are easy to get subtly wrong — descendant collection on container drags, rotated-shape delta math on resize, anchor-flip detection, snap-aware undo commands — so your tool only writes the high-level decisions.

For static “click to place” tools, you don’t need a session at all; the eraser example above is the minimal form. Reach for sessions when the tool’s behavior depends on how the pointer moves between down and up.

Tips

  • Use onActivate / onDeactivate for setup and cleanup (e.g., changing cursor state, resetting internal state).
  • Keep tool state in closures within setup(), not on the plugin object. This avoids shared mutable state issues.
  • Emit events via ctx.events.emit() if other plugins need to react to your tool’s actions.
  • Set order thoughtfully — tools appear left-to-right in the toolbar sorted by order.

Coming from tldraw?

class MyTool extends StateNode becomes a ToolDefinition object registered via ctx.tools.register(). The conversion:

  • onEnter / onExit on the root state → onActivate / onDeactivate
  • Child StateNodes with transition("…") → a phase closure variable inside setup()
  • editor.inputs.currentPagePointevent.worldPoint
  • editor.createShape(...)ctx.commands.execute(createAddShapeCommand(store, shape))

Keep all internal state in setup() closures — never on the plugin object — so each plugin instance has its own state. See Migrating from tldraw for the full mapping including StateNode child hierarchies and track() reactivity.