Editful
DocsToolbar

Build a tool for the toolbar.

Start your first plugin with one useful feature: a creation tool in the toolbar.

  • Toolbar actions
  • Commands
  • Compact editors

Use the browser DOM APIs you already know. No UI framework required.

Trigger a creation flow

The most straightforward way to make your first plugin useful is to add a tool to the toolbar that triggers a creation flow. The flow introduces a new kind of canvas object and gives people a direct way to place it.

This example defines a Card and calls kind.create(...). That small contribution creates a complete interaction: Editful adds the tool to the toolbar, handles click and drag gestures, selects the new card, and returns to Select. Your plugin stays focused on what a card contains and how it looks.

Once the basic flow works, you can build on the same card kind with richer fields, custom rendering, editor panels, and commands. Start with creation, then add those capabilities when your workflow needs them.

Add node-kinds to the plugin's capabilities.

Choose Card, then click for the default size or drag to set the card's size.

toolbar-create.ts
import {
  Primitive,
  TextFont,
  TextVerticalAlign,
  definePlugin,
  hexColor,
  type PluginContext,
} from '@editful/canvas-sdk';

export function registerCard(context: PluginContext): void {
  const card = context.kind('example:card');
  const title = card.field.string('title', { default: 'New card' });

  card.create({
    label: 'Card',
    icon: './assets/card.svg',
    shortcut: 'c',
    cursor: 'crosshair',
    gesture: 'drag',
    order: 40,
    defaultSize: { width: 200, height: 96 },
    styleSlot: 'shape',
  });

  card.pack((node, services, output) => {
    output.quad(
      node.x,
      node.y,
      node.halfW,
      node.halfH,
      node.rotation,
      node.cornerRadius,
      node.strokeWidth,
      Primitive.RoundRect,
      node.fill,
      node.stroke,
    );
    output.textBlock({
      x: node.x,
      y: node.y,
      halfW: node.halfW,
      halfH: node.halfH,
      text: node.get(title),
      style: {
        ...services.text.sharedTextStyle,
        size: 18,
        weight: 400,
        color: hexColor('#171815'),
        lineHeight: 1.25,
      },
      font: TextFont.Sans,
      wrapWidth: Math.max(1, node.halfW * 2 - 40),
      padding: 20,
      verticalAlign: TextVerticalAlign.Middle,
    });
  });
}

export default definePlugin({
  register(context) {
    registerCard(context);
  },
});
label
The short name shown in the toolbar.
icon
A built-in glyph or plugin-relative SVG such as ./assets/card.svg.
gesture
Use drag to support both click placement and drag-to-size.
order
A safe integer from −1000 to 1000. Lower values appear first.
defaultSize
The width and height used when the user clicks without dragging.
styleSlot
The host style preset applied to each new node.

Include ./assets/card.svg in the plugin's icon assets. The host returns to Select after creating the card.

Render on the WebGL canvas

Editful renders the canvas with WebGL. Canvas objects do not live in the DOM, so you cannot render HTML or mount a React component inside a card. HTML still works well for toolbar controls, sidebars, and other editor surfaces; the objects on the canvas need drawing instructions that the GPU can render. For more complex layouts, use Editful's built-in Canvas UI component system.

For a simple shape, card.pack(...) is the most direct way to provide those instructions. Editful calls this function when it prepares a changed card for WebGL. The node argument is a read-only view of that card, and output—often shortened to out—records what the renderer should draw.

output.quad(...) records the card's body as one rectangle in the display list. It does not draw immediately. The renderer collects these compact instructions and sends many cards to the GPU together.

node.x, node.y
The center of the card on the canvas.
node.halfW, node.halfH
Half of its width and height, which is the size format the renderer uses.
node.rotation
The card’s current rotation.
node.cornerRadius, node.strokeWidth
The geometry of its corners and border.
Primitive.RoundRect
The body shape the GPU should render. Other options are Primitive.Ellipse and Primitive.None, which keeps the node's bounds and hit area without drawing a body.
node.fill, node.stroke
The card’s current fill and border colors.

Every card needs one body quad. output.textBlock(...) then adds the title as a separate text layer, using node.get(title) to read the plugin field defined above. When someone moves, resizes, recolors, or renames a card, the same pack function runs with its latest values, so the canvas stays in sync without your plugin managing DOM elements or WebGL state.

Edit text directly on the canvas

A string field stores text, but it does not tell Editful that the text can be edited on the canvas. Declare the title with field.text(...), then connect it to the card with card.text(...). The host can now place the caret, handle keyboard input, and add each edit to undo history.

ts
const title = card.field.text('title');

card.text({
  field: title,
  padding: 20,
  verticalAlign: 'center',
  font: 'shared',
  width: 'user',
  height: 'user',
});

The pack function still controls how the title is drawn. The text contribution gives Editful the matching editing and layout behavior. Set opensTextEditor: true on the creation contribution to start typing as soon as a new card is placed.

Create a card and start typing, or double-click the title of the existing card to edit it.

toolbar-editable-text.ts
import {
  Primitive,
  TextFont,
  TextVerticalAlign,
  definePlugin,
  type PluginContext,
} from '@editful/canvas-sdk';

export function registerEditableCard(context: PluginContext): void {
  const card = context.kind('example:card');
  const title = card.field.text('title');

  card.create({
    label: 'Card',
    icon: './assets/card.svg',
    shortcut: 'c',
    cursor: 'crosshair',
    gesture: 'drag',
    order: 40,
    defaultSize: { width: 200, height: 96 },
    styleSlot: 'shape',
    opensTextEditor: true,
  });

  card.text({
    field: title,
    padding: 20,
    verticalAlign: 'center',
    font: 'shared',
    width: 'user',
    height: 'user',
  });

  card.pack((node, services, output) => {
    output.quad(
      node.x,
      node.y,
      node.halfW,
      node.halfH,
      node.rotation,
      node.cornerRadius,
      node.strokeWidth,
      Primitive.RoundRect,
      node.fill,
      node.stroke,
    );
    output.textBlock({
      x: node.x,
      y: node.y,
      halfW: node.halfW,
      halfH: node.halfH,
      text: node.get(title),
      style: services.text.sharedTextStyle,
      font: TextFont.Sans,
      wrapWidth: Math.max(1, node.halfW * 2 - 40),
      padding: 20,
      verticalAlign: TextVerticalAlign.Middle,
    });
  });
}

export default definePlugin({ register: registerEditableCard });
field.text(...)
Declares the field as editable canvas text.
card.text(...)
Describes where the text sits and how its bounds behave while editing.
opensTextEditor
Moves directly into text editing after the creation gesture finishes.

Mount controls above the toolbar

Once people can create a card, the next useful step is to give them a quick way to adjust it. The toolbar editor surface is designed for a small set of frequent controls that should stay close to the canvas.

This example builds on the editable Card above. The Card toolbar tool remains available, and selecting a card reveals three color choices directly above it. Editful decides when the controls appear and keeps the tool row available underneath.

Use this surface for focused choices such as color, alignment, or size presets. If an edit needs several fields or a longer workflow, move it to a sidebar instead.

Keep node-kinds and add editor-ui to the plugin's capabilities. Choose Card to create another card, then select any card and change its color.

Editful editor placement: above the toolbarLEFT SIDEBARRIGHT SIDEBARTOOLBAR EDITOR
toolbar-editor.ts
import {
  definePlugin,
  hexColor,
  type PluginContext,
} from '@editful/canvas-sdk';
import { registerEditableCard } from './toolbar-editable-text';

export const cardColors = [
  {
    label: 'Coral',
    fill: hexColor('#ff6b4a'),
    stroke: hexColor('#a9321d'),
  },
  {
    label: 'Blue',
    fill: hexColor('#5b7cfa'),
    stroke: hexColor('#2e46a4'),
  },
  {
    label: 'Green',
    fill: hexColor('#3e9568'),
    stroke: hexColor('#205c3e'),
  },
] as const;

export function registerCardColorEditor(context: PluginContext): void {
  context.editor({
    id: 'example:card-color',
    label: 'Card color',
    surface: 'toolbar',
    order: 20,
    selection: { minimum: 1, kinds: ['example:card'] },
    mount(container, initialAction) {
      let action = initialAction;
      const listeners = new AbortController();
      const group = document.createElement('div');
      group.setAttribute('role', 'group');
      group.setAttribute('aria-label', 'Card color');
      const buttons = cardColors.map((color) => {
        const button = document.createElement('button');
        button.type = 'button';
        button.textContent = color.label;
        button.addEventListener('click', () => {
          const transaction = action.document.transaction('Change card color');
          for (const id of action.selection.nodeIds()) {
            transaction.update({
              id,
              fill: color.fill,
              stroke: color.stroke,
            });
          }
          transaction.commit();
        }, { signal: listeners.signal });
        group.append(button);
        return { button, fill: color.fill, stroke: color.stroke };
      });
      container.replaceChildren(group);

      const sync = () => {
        const [card] = action.document.inspect(action.selection.nodeIds());
        for (const { button, fill, stroke } of buttons) {
          button.setAttribute(
            'aria-pressed',
            String(card?.fill === fill && card.stroke === stroke),
          );
        }
      };
      sync();

      return {
        update(nextAction) {
          action = nextAction;
          sync();
        },
        dispose() {
          listeners.abort();
          container.replaceChildren();
        },
      };
    },
  });
}

export default definePlugin({
  register(context) {
    registerEditableCard(context);
    registerCardColorEditor(context);
  },
});

Notify after a command

Add toolbar to a command contribution, then call action.ui.notify(...) from its run function. The host renders the notification in the same tray used by first-party canvas actions.

Add commands to the plugin's capabilities. Select the card and choose Refresh to see the notification.

toolbar-notification.ts
import { definePlugin } from '@editful/canvas-sdk';
import { registerEditableCard } from './toolbar-editable-text';

export default definePlugin({
  register(context) {
    registerEditableCard(context);
    context.command({
      id: 'example:refresh-card',
      label: 'Refresh selected card',
      description: 'Fetch the latest card data.',
      shortcut: 'mod+shift+r',
      selection: { minimum: 1, maximum: 1, kinds: ['example:card'] },
      toolbar: {
        label: 'Refresh',
        icon: './assets/refresh.svg',
        order: 50,
      },
      run(action) {
        action.ui.notify({ title: 'Card refreshed', tone: 'success' });
        return Promise.resolve();
      },
    });
  },
});
label
Optional short label used by the toolbar. The command palette keeps the command label.
icon
A built-in glyph or relative SVG path such as ./assets/refresh.svg.
order
A safe integer from −1000 to 1000. Lower values appear first.
selection
Disable the command until the selected node count and kinds match.

Set assets: { icons: ['./assets/refresh.svg'] } in editful.plugin.ts. SVG assets are passive and bundled beside the plugin entry.