Skip to content
D
Documentation

Build a stencil editor

how-to
4 min readUpdated

Use a stencil editor when readers need to browse shape masters, drop them onto a diagram, edit their shape data, and arrange a selection. This example renders a searchable palette on the left, three flowchart shapes in the canvas, and a selection-following property sheet on the right.

The framework binding mounts the diagram; the live instance connects the palette and panel to the same model and command history. Try the Visio-style editor demo to see the authoring surface running.

1. Install the packages

Run this in your browser application's project:

bash
npm install @grafloria/element @grafloria/engine @grafloria/renderer

For a framework application, also install its binding:

bash
# Angular
npm install @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser rxjs
# Qwik
npm install @grafloria/qwik @builder.io/qwik
# React
npm install @grafloria/react react react-dom
# Vue
npm install @grafloria/vue vue

2. Connect the editor to a mounted instance

Save this shared browser module as stencil-editor.ts. Each framework below calls it after mount, passing its live DiagramInstance.

registerStencils registers the shipped Flowchart, BPMN, UML and ERD masters in that instance's engine and returns the number registered. bindStencilPalette builds the searchable rail and binds drops to the canvas. bindShapeDataPanel builds the property sheet and follows selection changes.

Use NodeFactory to seed the document with an approval decision and two process shapes. These are setup writes, not user edits. Toolbar actions use AlignCommand and DistributeCommand, so each arrangement joins the same undo stack as palette drops and property edits.

ts
import { AlignCommand, DistributeCommand, NodeFactory, registerStencils } from '@grafloria/engine';
import { bindShapeDataPanel, bindStencilPalette } from '@grafloria/element';
import type { DiagramInstance } from '@grafloria/renderer';

export function connectEditor(
  api: DiagramInstance,
  rail: HTMLElement,
  panel: HTMLElement,
  toolbar: HTMLElement,
) {
  const engine = api.getEngine();
  const model = api.getModel();
  registerStencils(engine.templateRegistry);

  const factory = new NodeFactory(engine.templateRegistry, model);
  const decision = factory.createFromTemplate(
    'flowchart-decision', { label: 'Approve?' }, { x: 60, y: 80 },
  );
  factory.createFromTemplate(
    'flowchart-process', { label: 'Pay' }, { x: 230, y: 150 },
  );
  factory.createFromTemplate(
    'flowchart-process', { label: 'Archive' }, { x: 460, y: 100 },
  );

  const palette = bindStencilPalette(api, {
    palette: rail, canvas: api.container,
  }, {
    data: (master) => ({ label: master.meta.name }),
  });
  const properties = bindShapeDataPanel(api, panel);

  const selectedIds = () => model.getSelectedNodes()
    .filter((node) => !node.state.locked)
    .map((node) => node.id);

  const align = document.createElement('button');
  align.type = 'button';
  align.textContent = 'Align top';
  align.onclick = async () => {
    const ids = selectedIds();
    if (ids.length < 2) return;
    await engine.commandManager.execute(new AlignCommand(ids, 'top'));
    api.renderNow();
  };

  const distribute = document.createElement('button');
  distribute.type = 'button';
  distribute.textContent = 'Distribute horizontally';
  distribute.onclick = async () => {
    const ids = selectedIds();
    if (ids.length < 3) return;
    await engine.commandManager.execute(new DistributeCommand(ids, 'horizontal'));
    api.renderNow();
  };

  const selectAll = document.createElement('button');
  selectAll.type = 'button';
  selectAll.textContent = 'Select all shapes';
  selectAll.onclick = () => {
    for (const node of model.getNodes()) model.addToSelection(node);
    api.renderNow();
  };
  toolbar.append(selectAll, align, distribute);

  model.selectNode(decision);
  properties.refresh();
  api.renderNow();

  return {
    destroy() {
      palette.destroy();
      properties.destroy();
      selectAll.remove();
      align.remove();
      distribute.remove();
    },
  };
}

The initial selection opens the decision's properties, including its schema fields. The panel reads dataSchema.properties from the master identified by the node's templateId metadata. Enum fields render as selects, booleans as checkboxes, and numbers or strings as inputs. Press Enter or move focus out of a text field to commit an edit, rather than creating an undo entry for every keystroke.

3. Mount it in your application

Choose your framework's tab. Keep the palette, panel and toolbar hosts childless: the shared module owns their contents. All tabs use uncontrolled initial specs because subsequent edits belong to the live model.

The JavaScript tab uses render. The framework tabs use GrafloriaDiagramComponent for Angular and GrafloriaFlow for Qwik, GrafloriaFlow for React, or GrafloriaFlow for Vue. The empty initial arrays are followed by the three real masters created during initialization.

ts
// main.ts — load this module from your application's HTML entry point.
import { render } from '@grafloria/element';
import { connectEditor } from './stencil-editor';

const shell = document.createElement('section');
shell.style.cssText = 'height:600px;display:flex;flex-direction:column';
const toolbar = document.createElement('div');
const row = document.createElement('div');
row.style.cssText = 'display:flex;flex:1;min-height:0';
const rail = document.createElement('div');
rail.style.cssText = 'width:220px;flex:none;overflow:auto';
const canvas = document.createElement('div');
canvas.style.cssText = 'flex:1;min-width:0;position:relative';
const panel = document.createElement('div');
panel.style.cssText = 'width:240px;flex:none;overflow:auto';
row.append(rail, canvas, panel);
shell.append(toolbar, row);
document.body.append(shell);

const api = render({ nodes: [], edges: [] }, canvas);
const editor = connectEditor(api, rail, panel, toolbar);

// Call this when your application's router removes this view.
export function unmountEditor() {
  editor.destroy();
  api.dispose();
  shell.remove();
}
JavaScript: a searchable Flowchart rail, three shapes, arrangement buttons, and the selected decision's property sheet.

Angular renders the same rail and property sheet beside the selected decision and the Pay shape; the Archive shape extends behind the panel at this viewport width.

Angular: the selected Approve? decision with Name, Size & Position, and Format controls on the right.

Qwik renders the editor with its three toolbar actions above the palette and canvas.

React renders the same initial selection and arrangement controls.

Vue renders the Flowchart palette with search and the selected decision's property sheet.

Angular's component owns instance disposal. React, Vue and Qwik likewise own the mounted flow's lifecycle; their unmount hooks above remove only the editor's additional bindings. Qwik keeps the function-bearing editor handle in noSerialize() state and creates it in the browser's initialization callback.

Arrange masters with Angular's canvas component

If your Angular editor already uses DiagramCanvasComponent, bind its engine input rather than mounting a second diagram. This standalone alternative renders three shipped masters and applies the arrangement commands to that canvas's model. Click Align top to line up their top edges, then Distribute horizontally to equalize the gaps.

ts
import { Component, OnDestroy } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import { AlignCommand, DiagramEngine, DistributeCommand, NodeFactory, registerStencils } from '@grafloria/engine';

@Component({
  selector: 'app-canvas-editor',
  standalone: true,
  imports: [DiagramCanvasComponent],
  template: `
    <button type="button" (click)="align()">Align top</button>
    <button type="button" (click)="distribute()">Distribute horizontally</button>
    <grafloria-diagram-canvas [engine]="engine"
      style="display:block;height:500px" />
  `,
})
export class CanvasEditorComponent implements OnDestroy {
  readonly engine = new DiagramEngine();
  private readonly model = this.engine.createDiagram('Stencil arrangement');

  constructor() {
    registerStencils(this.engine.templateRegistry);
    const factory = new NodeFactory(this.engine.templateRegistry, this.model);
    factory.createFromTemplate('flowchart-decision', { label: 'Approve?' }, { x: 60, y: 80 });
    factory.createFromTemplate('flowchart-process', { label: 'Pay' }, { x: 230, y: 150 });
    factory.createFromTemplate('flowchart-process', { label: 'Archive' }, { x: 460, y: 100 });
    for (const node of this.model.getNodes()) this.model.addToSelection(node);
  }

  async align() {
    const ids = this.model.getSelectedNodes().map((node) => node.id);
    await this.engine.commandManager.execute(new AlignCommand(ids, 'top'));
  }

  async distribute() {
    const ids = this.model.getSelectedNodes().map((node) => node.id);
    await this.engine.commandManager.execute(new DistributeCommand(ids, 'horizontal'));
  }

  ngOnDestroy() { this.engine.dispose(); }
}
Angular canvas: Approve?, Pay, and Archive are selected, with Align top and Distribute horizontally buttons above them.

4. Drop, edit and arrange shapes

  • Type gateway in the palette search box. Search matches master names, ids and tags, and opens matching sections. Clearing the query restores the section collapse choices.
  • Drag a master onto the canvas. It lands centered on the drop point, converted through the instance's viewport into world coordinates. The placement is one undoable command batch.
  • Select a shape to inspect its properties. Select an edge to see its label, line, arrows and route controls; select multiple shapes to see their shared Format section. Deselect everything to return to the empty message.
  • Click Select all shapes, then Align top. The shapes share the smallest top coordinate in the selection.
  • Click Distribute horizontally. The leftmost and rightmost shapes stay put, and the interior shapes move to equalize the gaps—not the distances between centers.

Alignment needs at least two movable nodes; distribution needs three. Both ignore locked nodes. The sample filters them before checking the count. Distribution can produce negative gaps when the outer span cannot contain all selected widths, so spread the extreme shapes farther apart if you want space between them.

Options that matter

Configure the palette with StencilPaletteOptions and the panel with ShapeDataPanelOptions.

OptionTypeDefaultWhat it does
Palette stencilsStencil[]Every built-in stencilChooses the sections to show; register the same masters in the engine.
Palette searchbooleantrueShows the search input.
Palette collapsedstring[]All stencil ids except the firstChooses initially closed sections.
Palette data(master: NodeTemplate) => Record<string, unknown>Not suppliedSupplies data merged into template defaults on placement.
Panel titlestring'Shape data'Sets the heading.
Panel emptyTextstring'Select a shape to edit its data.'Sets the no-selection message.

Stencil describes a categorized set of masters; NodeTemplate carries each master's structure, defaults and schema. Register masters before placement: the palette's place(masterId, world) returns null when the registry has no matching master, otherwise it resolves to the placed root node id. setSearch(query) filters programmatically. The panel's refresh() re-reads selection and rebuilds its fields.

Was this page helpful?