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:
onActivateandonDeactivateare defined inToolDefinitionfor 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/heightdirectly insideonPointerMove(no drag session needed).startDragSessionalways 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/onDeactivatefor 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
orderthoughtfully — tools appear left-to-right in the toolbar sorted byorder.
Coming from tldraw?
class MyTool extends StateNode becomes a ToolDefinition object registered via ctx.tools.register(). The conversion:
onEnter/onExiton the root state →onActivate/onDeactivate- Child
StateNodes withtransition("…")→ aphaseclosure variable insidesetup() editor.inputs.currentPagePoint→event.worldPointeditor.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.