# 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](/docs/extensions/) and the [ctx API](/docs/extensions/ctx-api/). ## The model: the canvas owns the node, you own the data {#model} 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). :::warn 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 {#definition} `ctx.registerCanvasWidgets([...])` takes an array of contributions. Each one matches `FirstPartyCanvasWidgetContribution`: ```ts { 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 // 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 {#props} Your `component` is called with exactly `CanvasWidgetProps`, nothing more: ```ts type CanvasWidgetProps = { data: Record // this node's persisted data (never undefined; {} if empty) setData: (next: Record) => 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. :::note Why this is a `Record` 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 {#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 {#example} 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. ```javascript file="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 `/.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](#scaling)). ```javascript file="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 {#insert} 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`. :::tip 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 {#scaling} 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](/docs/extensions/#scaling) 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](/docs/extensions/packaging/) for the build and the signed community catalog. ## Where to go next - [The ctx API](/docs/extensions/ctx-api/) for `ctx.runtime`, `ctx.workspace`, and the rest of the activation surface. - [Item types](/docs/extensions/item-types/) if you want a full first-class item with its own editor instead of a canvas node. - [Packaging and distribution](/docs/extensions/packaging/) to bundle, sign, and publish your widget.