[]previous and next page

Custom shapes

Add a new kind of shape, from a quick object definition to a full ShapeUtil with handles, resizing rules and editable text.

A shape type is defined by three things: its default props, its geometry (the true outline, used for every interaction) and how it renders (a tree of render nodes). That's all CanvasFlow needs to draw it, select it, snap to it, bind arrows to it, export it and sync it.

The quick way: defineShape#

badge.tsts
import { defineShape, ellipseVertices, Geometry, labelNode, LINE_HEIGHT } from 'canvasflow'

export const Badge = defineShape({
  type: 'badge',
  props: () => ({ w: 120, h: 120, label: 'NEW', color: 'violet' }),
  geometry: (shape) => new Geometry(ellipseVertices(shape.props.w, shape.props.h), { isClosed: true, isFilled: true }),
  render: (shape, { editor }) => {
    const { w, h, label, color } = shape.props
    const text = { font: 'sans', fontSize: 22, lineHeight: LINE_HEIGHT }
    return {
      type: 'g',
      children: [
        { type: 'ellipse', cx: w / 2, cy: h / 2, rx: w / 2, ry: h / 2, fill: color, stroke: 'none' },
        labelNode(editor.measureText(label, text, w), { x: 0, y: 0, w, h }, { ...text, align: 'middle', color: 'white' }),
      ],
    }
  },
  textProp: 'label', // double-click to edit the label
  aspectLocked: true,
})

Register it, and it works like any built-in shape:

ts
import { createEditor } from 'canvasflow'

const editor = createEditor({ shapes: [Badge] })
editor.createShape({ type: 'badge', x: 100, y: 100 })

defineShape also accepts name, onResize, canResize, canRotate and canBind.

Geometry is the important part#

The geometry is what makes your shape feel solid. Give it the real outline:

  • rectVertices(w, h) and ellipseVertices(w, h) cover most boxes and rounds.
  • isClosed: true for shapes with an inside. isFilled: true when clicking the inside should select it (set it from your fill prop if your shape can be hollow).
  • For lines, pass the points with isClosed: false and a strokeRadius of half the stroke width.

If render and geometry disagree, people will feel it: clicks that miss, outlines that float. Keep them in sync, and the engine does the rest.

Render nodes#

Render nodes are plain objects, not DOM. The same tree becomes SVG elements on the web, react-native-svg components on mobile and an SVG string on a server.

Node Fields
g children, transform, opacity, clip
path d plus stroke and fill attributes
rect x, y, w, h, r
ellipse cx, cy, rx, ry
text laid-out text, from labelNode
html arbitrary content on the web, from reactNode (see React)

Colors in nodes can be theme tokens ('blue', 'background', 'text', 'accent'), so your shape follows dark mode like the built-ins.

The helpers the built-in shapes use are exported too: strokeAttrs, fillPaint, polygonNodes (with sloppiness and rounded edges), labelNode and arrowheadNode.

The full way: a ShapeUtil class#

When a shape needs handles, custom resizing or text editing, extend ShapeUtil:

ProgressShape.tsts
import { Geometry, rectVertices, ShapeUtil, type ShapeHandle, type ShapeRecord, type HandleDragInfo } from 'canvasflow'

type Progress = ShapeRecord<{ w: number; h: number; value: number; color: string }, 'progress'>

export class ProgressShape extends ShapeUtil<Progress> {
  static override type = 'progress'

  getDefaultProps() {
    return { w: 240, h: 28, value: 0.4, color: 'green' }
  }

  getGeometry(shape: Progress) {
    return new Geometry(rectVertices(shape.props.w, shape.props.h), { isClosed: true, isFilled: true })
  }

  render(shape: Progress) {
    const { w, h, value, color } = shape.props
    return {
      type: 'g' as const,
      children: [
        { type: 'rect' as const, x: 0, y: 0, w, h, r: h / 2, fill: 'background', stroke: color, strokeWidth: 2 },
        { type: 'rect' as const, x: 0, y: 0, w: Math.max(h, w * value), h, r: h / 2, fill: color, stroke: 'none' },
      ],
    }
  }

  // A handle on the bar's end: drag it to change the value.
  override getHandles(shape: Progress): ShapeHandle[] {
    return [{ id: 'value', type: 'vertex', x: shape.props.w * shape.props.value, y: shape.props.h / 2 }]
  }

  override onHandleDrag(shape: Progress, info: HandleDragInfo) {
    const value = Math.min(1, Math.max(0, info.point.x / shape.props.w))
    return { props: { ...shape.props, value } }
  }
}

What you can override#

Method Default Use it to
getName(shape) the type Name it in the layers panel and for screen readers
canResize, canRotate, canBind, canSnap true Turn behaviors off
canEditText, getText, setText, getTextEditInfo no text Make it editable on double-click
isAspectRatioLocked false Keep proportions while resizing
onResize(shape, info) scales w/h Custom resize rules (minimum sizes, reflowing text)
getHandles, onHandleDrag, onHandleDoubleClick none Points people can drag
onBeforeCreate(shape) none Normalize props when a shape is created
onDoubleClick(shape) none Toggle something on double-click
isContainer false Own other shapes, like a frame
hideSelectionBounds, hideRotateHandle false Simplify the selection for point-based shapes

Add it to the toolbar#

A creation tool for a box-like shape is one call:

ts
import { createBoxTool } from 'canvasflow'

const ProgressTool = createBoxTool({ id: 'progress', shapeType: 'progress', defaultSize: [240, 28] })
editor.registerTool(ProgressTool)

Then add { id: 'progress', icon: 'shapes', label: 'Progress' } to the toolbar's tools (see Customizing the UI).

Share it without code#

If your shape is just artwork with style bindings (an icon, a network symbol, a logo), you don't need code at all. Make it a spec shape: it's data, it's safe to accept from anyone, and it travels inside the document, so collaborators see it even without the plugin.