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

Insert a widget, then click it · 0:07
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.

  1. Insert. popcraft.widget.insert() calls render(initialState) and places the result in the middle of the viewport, selected.
  2. Click. When someone clicks an element with an onClick, PopCraft starts your plugin in the background (with popcraft.command === 'widget') if it isn't already running.
  3. 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.
  4. 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 state aren't saved and aren't shared with collaborators.
  • State must be JSON. No functions, Date objects, 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.

PropertyTypeDefault
direction'horizontal' | 'vertical''horizontal'Stacking direction
spacingnumber0Gap between children
paddingnumber or { top, right, bottom, left, horizontal, vertical }0Inner padding
width, heightnumber 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
fillcolornoneBackground
strokecolornoneBorder (drawn inside the box)
strokeWidthnumber1Border thickness
cornerRadiusnumber0
childrenelement[][]null, false and undefined entries are skipped, so cond && {…} works

Text

PropertyTypeDefault
charactersstring—The text. \n starts a new line. Emoji are supported.
fontSizenumber16
fontWeightnumber400100–900
fillcolor#1f1f24Text color
widthnumberfits the textFixed width; text is centered within it

Text is set in Inter.

Rectangle and Ellipse

PropertyType
width, heightnumberRequired
fillcolor
strokecolor
strokeWidthnumberDefault 1
cornerRadiusnumberRectangles 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.