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:

  1. A ShapeDefinition in the ShapeRegistry
  2. 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 ShapeData interface for your plugin’s intrinsic fields; put application data in meta.

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() + the TLBaseShape envelope → createDefault(params) returning your extends ShapeData type
  • shape.props.foodata.foo (flat — width / height / x / y are core fields)

For the full API table, Yjs data migration, and common pitfalls, see Migrating from tldraw.