Widgets
Widgets are interactive objects on the canvas — counters, polls, checklists, status badges. Your plugin describes what a widget looks like for a given state; clicking it runs your code to produce the next state.
- A widget is saved in the file with its current state and appearance, so everyone in the file sees it and sees updates live, even people who don't have your plugin.
- Clicking a widget's buttons needs the plugin installed, and edit access to the file.
- Widget clicks are regular edits: they can be undone and appear in version history.
- Users can move, resize, rotate, copy and delete widgets like any other layer.
A complete widget
manifest.json
{
"id": "yourname.vote-counter",
"name": "Vote Counter",
"version": "1.0.0",
"description": "A counter everyone in the file can click.",
"main": "main.js",
"permissions": [],
"commands": [{ "id": "insert", "label": "Insert counter" }]
}Widgets need no permissions: a plugin can always create and update its own widgets.
main.js
/// <reference types="@popcraft/plugins" />
// 1. Register at the top level, for every command.
popcraft.widget.register({
initialState: { count: 0, label: 'Votes' },
render: (state) => ({
type: 'AutoLayout',
direction: 'horizontal',
spacing: 12,
padding: { horizontal: 14, vertical: 10 },
verticalAlign: 'center',
fill: '#ffffff',
stroke: '#e4e4e7',
cornerRadius: 14,
children: [
{
type: 'AutoLayout', width: 32, height: 32, cornerRadius: 16, fill: '#f4f4f5',
horizontalAlign: 'center', verticalAlign: 'center',
onClick: () => { state.count = Math.max(0, state.count - 1) },
children: [{ type: 'Text', characters: '−', fontSize: 18, fill: '#3f3f46' }],
},
{
type: 'AutoLayout', direction: 'vertical', horizontalAlign: 'center',
children: [
{ type: 'Text', characters: String(state.count), fontSize: 24, fontWeight: 700, fill: '#18181b' },
{ type: 'Text', characters: state.label, fontSize: 11, fill: '#71717a' },
],
},
{
type: 'AutoLayout', width: 32, height: 32, cornerRadius: 16, fill: '#0c8ce9',
horizontalAlign: 'center', verticalAlign: 'center',
onClick: () => { state.count += 1 },
children: [{ type: 'Text', characters: '+', fontSize: 18, fill: '#ffffff' }],
},
],
}),
})
// 2. Insert from your own command.
if (popcraft.command === 'insert') {
popcraft.widget.insert().then(() => popcraft.closePlugin())
}How it works
Description
A comic page with the Panel Checklist widget installed. Its Insert checklist command places the widget in the middle of the view: a checklist of pencils, inks, colours and letters. Clicking an item ticks it off; the widget redraws and saves its state in the file, for every collaborator.
- Insert.
popcraft.widget.insert()callsrender(initialState)and places the result in the middle of the viewport, selected. - Click. When someone clicks an element with an
onClick, PopCraft starts your plugin in the background (withpopcraft.command === 'widget') if it isn't already running. - Handle. PopCraft calls
render(state)with the widget's current state, finds the clicked element's handler and runs it. The handler either mutates the state it was given or returns a new state object. - Update. PopCraft renders the new state and saves both to the file, where every collaborator sees it.
Because of step 2, always call register at the top level of main.js, outside any command check. Only do
command-specific work (like inserting) inside if (popcraft.command === '…').
A background session closes itself after a minute without clicks.
Handlers
Inline functions are the simplest: they close over the state passed to render.
onClick: () => { state.done = !state.done }A handler also receives the state and a context object, and may return the next state (or a Promise of it):
onClick: async (state, { nodeId }) => ({ ...state, count: state.count + 1, lastClick: Date.now() })You can also name handlers and reference them by string:
popcraft.widget.register({
initialState: { items: [] },
handlers: {
clear: (state) => ({ ...state, items: [] }),
},
render: (state) => ({ type: 'Text', characters: 'Clear', onClick: 'clear' }),
})Handlers can use the rest of the plugin API (subject to its permissions) — for example read the selection or show a panel to edit a widget's settings. Rules for handlers:
- Render must be deterministic. The same state must produce the same tree, in the same order: PopCraft identifies an inline handler by its element's position in the tree.
- Keep everything in state. Variables outside
statearen't saved and aren't shared with collaborators. - State must be JSON. No functions,
Dateobjects,Maps or cycles; at most 64 KB. - If two people click at the same moment, the last update wins.
Elements
A tree has at most 400 elements and 16 levels. Colors are hex strings: #rgb, #rrggbb or #rrggbbaa. Sizes
are in canvas pixels. Unknown properties are ignored; unknown element types are an error.
Every element accepts onClick (a function or a named handler), which makes it clickable.
AutoLayout
A container that stacks its children — the building block for cards, rows and buttons.
| Property | Type | Default | |
|---|---|---|---|
direction | 'horizontal' | 'vertical' | 'horizontal' | Stacking direction |
spacing | number | 0 | Gap between children |
padding | number or { top, right, bottom, left, horizontal, vertical } | 0 | Inner padding |
width, height | number or 'hug' | 'hug' | Fixed size, or fit the children |
horizontalAlign | 'start' | 'center' | 'end' | 'start' | Child alignment on the horizontal axis |
verticalAlign | 'start' | 'center' | 'end' | 'start' | Child alignment on the vertical axis |
fill | color | none | Background |
stroke | color | none | Border (drawn inside the box) |
strokeWidth | number | 1 | Border thickness |
cornerRadius | number | 0 | |
children | element[] | [] | null, false and undefined entries are skipped, so cond && {…} works |
Text
| Property | Type | Default | |
|---|---|---|---|
characters | string | — | The text. \n starts a new line. Emoji are supported. |
fontSize | number | 16 | |
fontWeight | number | 400 | 100–900 |
fill | color | #1f1f24 | Text color |
width | number | fits the text | Fixed width; text is centered within it |
Text is set in Inter.
Rectangle and Ellipse
| Property | Type | |
|---|---|---|
width, height | number | Required |
fill | color | |
stroke | color | |
strokeWidth | number | Default 1 |
cornerRadius | number | Rectangles only |
Clicks on an Ellipse only count inside the ellipse.
Widget API
| Method | |
|---|---|
popcraft.widget.register({ initialState?, render, handlers? }) | Define the widget. initialState may be an object or a function returning one. |
popcraft.widget.insert({ state?, x?, y? }?) → Promise<string> | Insert a widget and resolve to its node id. x/y place its center in page coordinates (default: viewport center). |
popcraft.widget.getState(nodeId) → Promise<object> | Read a widget's state. |
popcraft.widget.setState(nodeId, state) → Promise<void> | Replace a widget's state and re-render it — e.g. from a settings panel. |
getState and setState only work on widgets created by your plugin. To find them, use findNodes({ type: 'WIDGET' })
(requires read) and check each node's pluginId against your manifest id.
Sizing
A widget is sized to its content. When its content grows or shrinks, the widget follows. If a user resizes a widget, it keeps their scale: the content is scaled to the new size, and the design panel offers Reset size.
Updating a widget plugin
Existing widgets keep their saved appearance until someone clicks them; the click re-renders with your new
render. Keep new versions compatible with state saved by older ones:
render: (state) => {
const label = state.label ?? 'Votes' // added in 1.1.0
// …
}Collaborators without the plugin
People who don't have your plugin see the widget and its live updates. Clicking it tells them to install the plugin from the marketplace; people with view-only access are told they can't use widgets in that file.