Shape System
How shapes are defined, registered, and rendered.
ShapeData
Every shape on the canvas is represented by a ShapeData object:
interface ShapeData<TMeta = Record<string, unknown>> {
id: string;
type: string; // Must match a registered ShapeDefinition
x: number;
y: number;
width: number;
height: number;
style: ShapeStyle;
rotation?: number;
zIndex?: string; // Fractional index for z-order
createdAt?: number; // Unix millis — set by the store on addShape
updatedAt?: number; // Unix millis — updated by the store on mutation
parentId?: string; // For hierarchical grouping (frames, groups)
meta?: TMeta; // Application/domain data (preferred)
[key: `x-${string}`]: unknown; // Escape hatch: custom top-level fields
}
interface ShapeStyle {
fill: string;
stroke: string;
strokeWidth: number;
opacity: number;
}
The type field links the data to its ShapeDefinition.
Extending shapes
ShapeData distinguishes three places where data can live:
1. Core fields
The explicit fields listed above (id, type, x, y, width, height, style, rotation, zIndex, createdAt, updatedAt, parentId, meta) are managed by the uSketch core. Plugins and applications must not redefine them.
2. Plugin-specific fields — extend the interface
A plugin that needs intrinsic data (data that defines what the shape is — e.g. the text of a text shape, the points of a freehand stroke) should declare an extension interface:
import type { ShapeData } from "@edv4h/usketch-shared";
export interface TextShapeData extends ShapeData {
text: string;
fontSize: number;
fontFamily: string;
isEditing: boolean;
}
Use the extension type inside your plugin (e.g. in render and createDefault) and export it so other plugins can read it back type-safely.
3. Application/domain data — use meta (preferred)
For data that is not intrinsic to the shape (external references, identifiers, feature flags), put it under meta. Pass your concrete meta type as TMeta for full type safety:
interface WeboardMeta {
employeeId?: string;
documentKey?: 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", documentKey: "doc_456" },
};
shape.meta?.employeeId; // typed as string | undefined
Escape hatch: the x-* prefix
For the rare case where meta does not fit — e.g. a gradual migration that temporarily needs a top-level field — ShapeData accepts any property whose name starts with x-:
const shape: ShapeData = {
id: "2", type: "rect", x: 0, y: 0, width: 100, height: 100,
style: DEFAULT_STYLE,
"x-legacyFlag": true,
};
The x- namespace is guaranteed to never clash with core or plugin-extension fields (this mirrors the convention used by HTTP headers, JSON-LD, OpenAPI, and package.json). Prefer meta whenever possible — it is typed, x-* is not.
ShapeDefinition
A ShapeDefinition tells the system how to render and interact with a shape type:
interface ShapeDefinition {
render: (data: ShapeData) => ReactElement;
getBounds: (data: ShapeData) => BoundingBox;
hitTest: (data: ShapeData, point: Point) => boolean;
resize: (data: ShapeData, handle: ResizeHandle, delta: Point) => ShapeData;
createDefault: (params: { id: string; x: number; y: number }) => ShapeData;
renderTarget?: "svg" | "html";
minSize?: { width: number; height: number };
move?: (data: ShapeData, dx: number, dy: number) => Partial<ShapeData>;
applyBounds?: (data: ShapeData, newBounds: BoundingBox) => Partial<ShapeData>;
}
| Method | Purpose |
|---|---|
render | Returns JSX for the shape. SVG elements for "svg" target. |
getBounds | Returns the axis-aligned bounding box. |
hitTest | Returns true if a world-space point is inside the shape. |
resize | Returns updated shape data after a resize handle drag. |
createDefault | Creates a new shape with sensible defaults at a given position. |
move | Optional. Custom move logic (e.g., for shapes with absolute point arrays). |
applyBounds | Optional. Fit shape data to a new bounding box during multi-select resize. |
ShapeRegistry
Register shape definitions during setup():
setup(ctx: PluginContext) {
ctx.shapes.register("rectangle", {
render,
getBounds,
hitTest,
resize,
createDefault,
});
}
Retrieve definitions at runtime:
const def = ctx.shapes.get("rectangle");
const allShapes = ctx.shapes.getAll(); // ReadonlyMap<string, ShapeDefinition>
Resize Handles
The ResizeHandle type represents the eight handles around a selected shape:
type ResizeHandle = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
Your resize function receives the handle being dragged and a delta: Point representing the movement in world coordinates.
Example: Rectangle
See the full rectangle plugin in the Shape Plugin Guide.