Last updated

Canvas widgets

Ship a small, live React surface that a user can drop onto an Undra canvas, where the canvas owns the node and you own only its data.

A canvas widget is a renderer keyed by a widgetKind. You register the renderer in your extension’s activate(ctx); Undra’s canvas provides a built-in Insert Widget affordance that creates the node, seeds it from your defaults, and persists it. Your component never touches the canvas document. It reads data, draws, and calls setData(next) to save. This page assumes you have read the extensions overview and the ctx API.

The model: the canvas owns the node, you own the data

This is the one thing to internalize before writing any code.

  • You register a renderer, not a node. A renderer is { widgetKind, title, component, ... }. It is just a function the canvas calls to paint the inside of a widget node.
  • The canvas creates the node. A user right-clicks the canvas, opens the Widgets submenu, and picks your widget by its title. The canvas inserts a widget node, stamps it with your widgetKind, seeds its data from your defaultData, and sizes it from your defaultSize. You write no creation code.
  • You never mutate the canvas document. Your component receives the node’s persisted data and a setData function. Calling setData(next) is the only way you change anything, and it writes next back onto that one node through the canvas’s own update path (with undo/redo and persistence handled for you).

You do not get the canvas doc, the node id, or a save API

A widget component is sandboxed to one node’s data on purpose. There is no handle to the canvas document, no node id, and no ctx.workspace call inside the render path. If you reach for those, you are fighting the model. Everything you need to persist goes through setData.

The definition shape

ctx.registerCanvasWidgets([...]) takes an array of contributions. Each one matches FirstPartyCanvasWidgetContribution:

typescript
{
  widgetKind: string                         // required. stable unique id for this renderer
  title: string                              // required. shown in the canvas "Widgets" menu
  icon?: unknown                             // optional. a component; see the note below
  defaultData?: Record<string, unknown>      // optional. seeds a new node's data
  defaultSize?: { width: number; height: number } // optional. new node size in px
  component: (props: CanvasWidgetProps) => unknown // required. your renderer
}

Field by field:

  • widgetKind is the stable key. The canvas stamps it on every node your widget creates and looks the renderer up by it at paint time. Namespace it so two extensions never collide, for example community.example.counter. Registration is last-wins per widgetKind: if two extensions register the same kind, the later one takes over.
  • title is the human label in the canvas right-click Widgets submenu. That submenu only appears once at least one widget is registered.
  • icon is optional and typed as unknown (a component). Provide one for forward-compatibility, but note the current canvas Widgets submenu renders a generic grid glyph for every entry, so do not rely on your icon showing there yet.
  • defaultData is the object copied into a freshly inserted node’s data. If omitted, new nodes start with {}. The canvas shallow-copies it per node, so each inserted widget gets its own object.
  • defaultSize is the new node’s size in pixels. If omitted, the canvas falls back to 220 wide by 140 tall.
  • component is the renderer. It returns a React element built with ctx.runtime.

The props your component receives

Your component is called with exactly CanvasWidgetProps, nothing more:

typescript
type CanvasWidgetProps = {
  data: Record<string, unknown>            // this node's persisted data (never undefined; {} if empty)
  setData: (next: Record<string, unknown>) => void // persist new data onto this node
  width: number                            // current node width in px
  height: number                           // current node height in px
}

Key facts, all verified against the runtime:

  • data is never undefined. A node with no stored data is handed {}. You still want to read it defensively (cast or default each field) because its shape is whatever you have written over the node’s life.
  • setData(next) replaces the node’s data with next. It is not a merge. To change one field, spread the old data: setData({ ...data, count: n }). Each call persists immediately and registers an undo step, so do not call it on every animation frame.
  • width and height track the node’s live size as the user resizes it. Use them to lay out responsively. They are read-only; you cannot resize the node from inside the widget.

Why this is a Record<string, unknown>

The canvas stores widget data generically so any extension can put any JSON-serializable shape in a node. Keep data to plain JSON values (strings, numbers, booleans, arrays, plain objects). Functions, class instances, and DOM nodes will not survive a save and reload.

Capability

Declare the capability in your manifest or the host will refuse to activate the extension:

javascript
export const manifest = {
  id: 'community.example.counter',
  version: '0.1.0',
  displayName: 'Counter Widget',
  publisher: 'example',
  capabilities: ['canvasWidgets.registry'],
}

canvasWidgets.registry is the only capability a pure widget needs. If the same extension also registers an item type or commands, add those capabilities (itemTypes.registry, commands.registry) alongside it.

Complete example: a counter widget

A full, runnable single-file extension. It reads a count from data, renders plus and minus buttons, and persists every change through setData. It uses only ctx.runtime React and inline theme styles, so it matches light and dark themes.

extension.js
export const manifest = {
  id: 'community.example.counter',
  version: '0.1.0',
  displayName: 'Counter Widget',
  publisher: 'example',
  capabilities: ['canvasWidgets.registry'],
}

export function activate(ctx) {
  const { createElement: h } = ctx.runtime

  function Counter(props) {
    // props is CanvasWidgetProps: { data, setData, width, height }
    const count = typeof props.data.count === 'number' ? props.data.count : 0
    const set = function (n) {
      // setData REPLACES data, so spread to keep any other fields.
      props.setData(Object.assign({}, props.data, { count: n }))
    }

    const wrap = {
      width: '100%',
      height: '100%',
      boxSizing: 'border-box',
      display: 'flex',
      flexDirection: 'column',
      alignItems: 'center',
      justifyContent: 'center',
      gap: '10px',
      padding: '12px',
      borderRadius: '12px',
      background: 'var(--u-bgPanel, #16191c)',
      color: 'var(--u-text0, #f2f4f5)',
      border: '1px solid var(--u-border0, rgba(255,255,255,0.12))',
      font: 'inherit',
    }
    const value = { fontSize: '34px', fontWeight: 700, lineHeight: 1 }
    const row = { display: 'flex', gap: '8px' }
    const btn = {
      width: '38px',
      height: '34px',
      borderRadius: '9px',
      cursor: 'pointer',
      fontSize: '18px',
      lineHeight: 1,
      border: '1px solid var(--u-border0, rgba(255,255,255,0.12))',
      background: 'var(--u-bg0, #111315)',
      color: 'var(--u-text0, #f2f4f5)',
    }
    const accentBtn = Object.assign({}, btn, {
      background: 'var(--u-accent, #5b8def)',
      borderColor: 'var(--u-accent, #5b8def)',
      color: '#fff',
    })

    return h('div', { style: wrap },
      h('div', { style: { fontSize: '11px', color: 'var(--u-text2, #8c969f)', textTransform: 'uppercase', letterSpacing: '0.06em' } }, 'Counter'),
      h('div', { style: value }, String(count)),
      h('div', { style: row },
        h('button', { style: btn, onClick: function () { set(count - 1) } }, '−'),
        h('button', { style: btn, onClick: function () { set(0) } }, '0'),
        h('button', { style: accentBtn, onClick: function () { set(count + 1) } }, '+'),
      ),
    )
  }

  ctx.registerCanvasWidgets([
    {
      widgetKind: 'community.example.counter',
      title: 'Counter',
      defaultData: { count: 0 },
      defaultSize: { width: 200, height: 150 },
      component: Counter,
    },
  ])

  return function deactivate() {}
}

To try it: save this at <workspace>/.undra/extensions/counter/extension.js, restart Undra, open a canvas, right-click it, and pick Widgets → Counter. The node appears, the buttons work, and the count survives a reload because every change goes through setData.

Reading the node size: a chart placeholder

width and height let a widget lay itself out to fit the node. This placeholder draws a bar proportional to the live node size, then persists a label the user can edit. It shows the responsive pattern without bundling a chart library (for a real chart, bundle one as described under Scaling up).

extension.js
export const manifest = {
  id: 'community.example.chartph',
  version: '0.1.0',
  displayName: 'Chart Placeholder',
  publisher: 'example',
  capabilities: ['canvasWidgets.registry'],
}

export function activate(ctx) {
  const { createElement: h } = ctx.runtime

  function ChartPlaceholder(props) {
    const label = typeof props.data.label === 'string' ? props.data.label : 'Untitled'
    const pct = typeof props.data.pct === 'number' ? props.data.pct : 60
    // Use the live node size to fill the available space.
    const barMax = Math.max(0, props.width - 24)
    const barWidth = Math.round((barMax * pct) / 100)

    return h('div', {
      style: {
        width: '100%', height: '100%', boxSizing: 'border-box', padding: '12px',
        display: 'flex', flexDirection: 'column', gap: '8px',
        background: 'var(--u-bgPanel, #16191c)', color: 'var(--u-text0, #f2f4f5)',
        border: '1px solid var(--u-border0, rgba(255,255,255,0.12))', borderRadius: '12px',
        font: 'inherit',
      },
    },
      h('input', {
        value: label,
        onChange: function (e) { props.setData(Object.assign({}, props.data, { label: e.target.value })) },
        style: {
          font: 'inherit', fontSize: '13px', fontWeight: 600, color: 'var(--u-text0, #f2f4f5)',
          background: 'transparent', border: 'none', outline: 'none', padding: 0,
        },
      }),
      h('div', { style: { flex: 1, display: 'flex', alignItems: 'flex-end' } },
        h('div', {
          style: {
            width: barWidth + 'px', height: '60%', borderRadius: '6px',
            background: 'var(--u-accent, #5b8def)',
            transition: 'width 120ms ease',
          },
        }),
      ),
      h('div', { style: { fontSize: '11px', color: 'var(--u-text2, #8c969f)' } }, pct + '%  (' + props.width + '×' + props.height + ')'),
    )
  }

  ctx.registerCanvasWidgets([
    {
      widgetKind: 'community.example.chartph',
      title: 'Chart Placeholder',
      defaultData: { label: 'Revenue', pct: 60 },
      defaultSize: { width: 280, height: 160 },
      component: ChartPlaceholder,
    },
  ])

  return function deactivate() {}
}

How the user inserts it

You do not build the insertion UI. Once your extension is active:

  1. The user right-clicks an open canvas.
  2. A Widgets submenu appears in the context menu (only present because a widget is registered).
  3. Each registered widget is listed by its title. Picking one inserts a node at the click point.
  4. The canvas seeds the new node’s data from your defaultData (or {}) and its size from your defaultSize (or 220 x 140), then selects it.

From then on the node lives in the canvas document. Your renderer is called whenever the node paints, with that node’s current data, width, and height.

Late registration is handled

Community extensions activate after the canvas has already painted. The canvas subscribes to the widget registry, so a widget you register after boot still shows up in the Widgets menu and existing nodes of that widgetKind re-render once the renderer lands. If a node references a widgetKind with no registered renderer (for example the extension is disabled), the canvas shows a small “missing widget” placeholder labeled with the kind instead of erroring.

Scaling up: real libraries and TypeScript

The single-file form is right for a counter or a small placeholder. For a real chart widget, write it as normal TypeScript with a charting library and bundle it into one extension.js with esbuild, exactly as the overview’s “Scaling up” section describes. Two rules carry over and matter especially for widgets:

  • Use Undra’s React, never your own. Take createElement and hooks from ctx.runtime. Two copies of React break hooks, and a widget that mounts inside the canvas is the easiest place to hit that bug.
  • Bundle every other library freely. Charts, parsers, date utilities: inline them. The loader only needs one import-free file.

See Packaging and distribution for the build and the signed community catalog.

Where to go next

  • The ctx API for ctx.runtime, ctx.workspace, and the rest of the activation surface.
  • Item types if you want a full first-class item with its own editor instead of a canvas node.
  • Packaging and distribution to bundle, sign, and publish your widget.