Building a Shape Plugin
Create a complete shape plugin with rendering, hit testing, and a drawing tool.
This guide walks through the rectangle plugin (usketch-plugin-shape-rect) as a real-world example. By the end, you’ll know how to create a shape plugin that:
- Renders a shape on the SVG canvas
- Supports hit testing and selection
- Handles resize from all eight handles
- Includes a drawing tool with live preview
- Integrates with the undo/redo system
The Full Picture
A shape plugin typically registers two things:
- A ShapeDefinition in the
ShapeRegistry - A ToolDefinition in the
ToolRegistry(for the drawing tool)
Both are registered inside a single setup() function.
Step-by-Step
1. Define the Shape Functions
First, declare an extension interface for any intrinsic fields your shape needs, then implement the required ShapeDefinition methods:
import type { BoundingBox, Point, ResizeHandle, ShapeData } from "@edv4h/usketch-shared";
import { DEFAULT_STYLE } from "@edv4h/usketch-shared";
/** Intrinsic data for the `rectangle` shape. Export so other plugins can read it type-safely. */
export interface RectangleShapeData extends ShapeData {
cornerRadius?: number;
}
function render(shape: ShapeData) {
const data = shape as RectangleShapeData;
return (
<rect
x={data.x}
y={data.y}
width={data.width}
height={data.height}
rx={data.cornerRadius ?? 0}
fill={data.style.fill}
stroke={data.style.stroke}
strokeWidth={data.style.strokeWidth}
opacity={data.style.opacity}
/>
);
}
function getBounds(data: ShapeData): BoundingBox {
return { x: data.x, y: data.y, width: data.width, height: data.height };
}
function hitTest(data: ShapeData, point: Point): boolean {
return (
point.x >= data.x &&
point.x <= data.x + data.width &&
point.y >= data.y &&
point.y <= data.y + data.height
);
}
function createDefault(params: { id: string; x: number; y: number }): RectangleShapeData {
return {
id: params.id,
type: "rectangle",
x: params.x,
y: params.y,
width: 100,
height: 80,
style: { ...DEFAULT_STYLE },
cornerRadius: 0,
};
}
Pattern. The ShapeRegistry works with the base ShapeData type, so render / resize / hitTest receive ShapeData. Cast to your extension type inside (const data = shape as RectangleShapeData) to access plugin-specific fields with full type safety.
2. Implement Resize Logic
Handle all eight resize handles:
function resize(data: ShapeData, handle: ResizeHandle, delta: Point): ShapeData {
let { x, y, width, height } = data;
switch (handle) {
case "se":
width += delta.x;
height += delta.y;
break;
case "nw":
x += delta.x;
y += delta.y;
width -= delta.x;
height -= delta.y;
break;
// ... handle all 8 directions
}
return {
...data,
x, y,
width: Math.max(1, width),
height: Math.max(1, height),
};
}
Always clamp width and height to a minimum value to prevent zero-size shapes.
3. Create the Drawing Tool
The drawing tool uses a three-phase pattern:
import { createAddShapeCommand } from "@edv4h/usketch-store";
import { generateId, type CanvasPointerEvent, type ToolContext } from "@edv4h/usketch-shared";
// In the actual plugin, drawState lives inside the setup() closure — not at module scope.
let drawState: { startX: number; startY: number; shapeId: string } | null = null;
function onPointerDown(toolCtx: ToolContext, event: CanvasPointerEvent) {
const id = generateId();
drawState = { startX: event.worldPoint.x, startY: event.worldPoint.y, shapeId: id };
// Create a temporary shape for live preview
const shape = createDefault({ id, x: event.worldPoint.x, y: event.worldPoint.y });
shape.width = 0;
shape.height = 0;
shape.style = { ...toolCtx.store.getStyleSettings() };
toolCtx.store.addShape(shape);
}
function onPointerMove(toolCtx: ToolContext, event: CanvasPointerEvent) {
if (!drawState) return;
const x = Math.min(drawState.startX, event.worldPoint.x);
const y = Math.min(drawState.startY, event.worldPoint.y);
const width = Math.abs(event.worldPoint.x - drawState.startX);
const height = Math.abs(event.worldPoint.y - drawState.startY);
toolCtx.store.updateShape(drawState.shapeId, { x, y, width, height });
}
function onPointerUp(toolCtx: ToolContext) {
if (!drawState) return;
const shape = toolCtx.store.getShape(drawState.shapeId);
if (shape && shape.width > 2 && shape.height > 2) {
// Replace temporary shape with an undoable command
toolCtx.store.deleteShape(drawState.shapeId);
toolCtx.commands.execute(createAddShapeCommand(toolCtx.store, shape));
} else {
// Too small — discard
toolCtx.store.deleteShape(drawState.shapeId);
}
drawState = null;
toolCtx.store.setActiveToolId("select");
}
Key pattern: Create a temporary shape directly on the store for live preview, then replace it with an undoable command on pointer up.
4. Wire Everything in setup()
export const rectPlugin: UsketchPlugin = {
id: "usketch-plugin-shape-rect",
name: "Rectangle",
setup(ctx: PluginContext) {
ctx.shapes.register("rectangle", {
render,
getBounds,
hitTest,
resize,
createDefault,
});
ctx.tools.register("rectangle-draw", {
icon: RectIcon,
cursor: "crosshair",
shortcut: "r",
order: 10,
onPointerDown,
onPointerMove,
onPointerUp,
});
},
};
Adding application data
Plugin-intrinsic fields (like cornerRadius above) belong on the extension interface. For application- or domain-specific data that is not part of the shape’s geometry (external references, identifiers, feature flags), use the meta field instead:
interface WeboardMeta {
employeeId?: string;
companyId?: string;
}
const shape: ShapeData<WeboardMeta> = {
id: "note_1", type: "rect", x: 0, y: 0, width: 200, height: 150,
style: DEFAULT_STYLE,
meta: { employeeId: "emp_123", companyId: "co_456" },
};
shape.meta?.employeeId; // typed as string | undefined
Pass your concrete meta shape as TMeta to ShapeData<TMeta> at the call site — the core APIs remain ShapeData (the generic parameter is opt-in per consumer).
For edge cases where meta does not fit (a gradual migration, or an exceptional top-level field), ShapeData also accepts any property whose name starts with x-:
const shape: ShapeData = {
...,
"x-legacyFlag": true, // escape hatch — prefer meta
};
See Shape System → Extending shapes for the full contract.
Key Takeaways
- Shape + tool live in the same plugin — they’re a single concept.
- Use direct store mutations for live preview, commands for the final action.
- Always handle the case where the user barely drags (width/height < threshold).
- Switch back to the select tool after drawing — this is the expected UX.
- Declare an
extends ShapeDatainterface for your plugin’s intrinsic fields; put application data inmeta.
Coming from tldraw?
If you previously wrote class MyShapeUtil extends ShapeUtil<TMyShape>, the uSketch equivalent is the plain object passed to ctx.shapes.register():
component(shape)→render(data)getGeometry(shape)→getBounds(data)+hitTest(data, point)onResize(info)→resize(data, handle, delta)getDefaultProps()+ theTLBaseShapeenvelope →createDefault(params)returning yourextends ShapeDatatypeshape.props.foo→data.foo(flat —width/height/x/yare core fields)
For the full API table, Yjs data migration, and common pitfalls, see Migrating from tldraw.