Learn / Custom nodes in Angular

Custom nodes in Angular

One <ng-template grafloriaNode="type"> inside the canvas and every node of that type renders through real Angular — bindings, pipes, components, event handlers. This page is the full mechanics: the context, the auto-opt-in, sizing, ports, and the mistakes that cost an afternoon.

The idiom, precisely

A custom node is an ng-template projected into the canvas, tagged with the node type it renders. There is no registry call, no component map, no custom: true flag to remember — the directive's own doc says it best:

From the source: "A node whose type matches a template is rendered by THAT template in the HTML layer — full Angular change detection, pipes, directives, and bindings, no string micro-templates and no component registry required. In controlled mode the canvas flags matching specs as custom automatically, so declaring the template is the whole integration."
board.component.ts
import { Component } from '@angular/core';
import { DiagramCanvasComponent, GrafloriaNodeDefDirective } from '@grafloria/angular';

@Component({
  selector: 'app-board',
  imports: [DiagramCanvasComponent, GrafloriaNodeDefDirective],
  template: `
    <grafloria-diagram-canvas [(nodes)]="nodes" [(edges)]="edges"
        style="display:block; height:100vh">

      <ng-template grafloriaNode="ticket" let-node let-data="data" let-engine="engine">
        <div class="ticket">
          <header>{{ data['title'] }}</header>
          <p class="assignee">{{ data['assignee'] }}</p>
          <button (click)="engine?.getDiagram()?.removeNode(node.id)">✕</button>
        </div>
      </ng-template>

    </grafloria-diagram-canvas>
  `,
  styles: [`.ticket { height: 100%; box-sizing: border-box; background: #fff;
    border: 1.5px solid #94A5F0; border-radius: 12px; padding: 10px 14px; }`],
})
export class BoardComponent {
  nodes = [
    { id: 't1', type: 'ticket', position: { x: 80, y: 90 }, size: { width: 230, height: 110 },
      data: { title: 'Fix login flow', assignee: 'Nour' } },
  ];
  edges = [];
}

Both pieces must be in imports: the canvas and GrafloriaNodeDefDirective. Forget the directive and the grafloriaNode attribute matches nothing — the template is silently ignored and the node falls back to its default rendering. That is the single most common "my template does nothing" report.

Live: ng-template custom nodes →

The template context

The directive publishes a typed context (GrafloriaNodeTemplateContext), and ships an ngTemplateContextGuard, so the language service type-checks what you write in the template:

grafloria-node-def.directive.ts (shape)
export interface GrafloriaNodeTemplateContext {
  $implicit: NodeModel | GroupModel;      // let-node          — the LIVE model object
  engine: DiagramEngine | undefined;      // let-engine="engine"
  data: Record<string, unknown>;          // let-data="data"   — your payload
}
  • let-node — the $implicit value: not a copy of your spec but the live NodeModel (or GroupModel for groups). Its id, position and size are current mid-drag, and its tracked setters (setMetadata, setSize) are how external UI edits a node in place.
  • let-data="data" — the free-form payload from the spec's data field. It is typed Record<string, unknown>, so templates read it with index syntax: {{ data['title'] }}, not data.title.
  • let-engine="engine" — the same engine activeEngine() returns; undefined only before the canvas has one. Guard with engine?. and any button in your node can dispatch real operations.

The wildcard template

<ng-template grafloriaNode> with no value registers under the empty string and becomes the fallback: any HTML-layer node whose type has no exact template renders through it. Exact matches always win. One wildcard plus a few specialised templates is the usual shape of a node palette:

<ng-template grafloriaNode="decision" let-data="data">
  <div class="diamond">{{ data['question'] }}</div>
</ng-template>

<ng-template grafloriaNode let-node let-data="data">  <!-- everything else -->
  <div class="generic-card">{{ data['label'] ?? node.id }}</div>
</ng-template>

How the auto-custom mechanic works

Grafloria nodes normally paint in the SVG layer; HTML nodes live in a separate HTML layer that shares the same camera. The spec flag custom: true is what routes a node to the HTML layer — and in Angular you almost never write it, because the canvas derives it while syncing your arrays in. The exact rule, from the component source:

"Angular-native custom nodes: a spec whose type has an exact <ng-template grafloriaNode="type"> def renders in the HTML layer without the author touching custom — declaring the template IS the opt-in. Explicit custom (either value) always wins; live models pass through."

Three consequences worth spelling out:

  • The match is exact — the wildcard template does not auto-flag anything. A node with no type, or a type with no exact template, stays in the SVG layer unless you set custom: true yourself (at which point the wildcard can render it).
  • custom: false in a spec disables the template for that node, even when the type matches — explicit wins, in both directions.
  • The rewrite happens on the spec on its way into the reconciler; live NodeModel instances you pass through [(nodes)] are never rewritten.

Plain arrays or signals — both are the model

nodes and edges are model() signals on the component, so [(nodes)] accepts either a plain array property or a signal([...]) on your side; Angular wires the write-back in both cases. Updating a node from outside can therefore go two ways:

// 1 — data-down: hand the canvas a NEW array (the shared reconciler diffs it;
//     only fields that actually changed are written, nothing else repaints)
this.nodes = this.nodes.map((n) => n.id === 't1'
  ? { ...n, data: { ...n.data, title: 'Renamed' } } : n);

// 2 — straight at the live model: tracked setters repaint on the spot
const node = this.canvas().activeEngine()?.getDiagram()?.getNode('t1');
node?.setMetadata('title', 'Renamed');
node?.setSize(320, node.size.height);

Live: editing a live node from outside the canvas →

Sizing and styling the template root

The canvas absolutely positions a wrapper div per HTML node at the node's world position, sized to the node's size from the spec — your template is stamped inside that wrapper. Two rules follow:

  • Give every custom-node spec a real size. The wrapper's width and height come from it; the template does not size the node, the node sizes the template.
  • Make your template root fill the wrapper: height: 100% and box-sizing: border-box (so borders and padding don't overflow the node's hit area). Every shipped demo template starts this way.

Styling is ordinary Angular styling — the template belongs to your component, so your component's styles and your global stylesheet apply exactly as they would anywhere else. Nothing to import, no style bridge.

Ports on custom nodes

Ports are part of the node spec, and they survive the trip to the HTML layer: for each port on an HTML-layer node the canvas renders a small connection handle inside the wrapper — type: 'output' ports become drag sources, type: 'input' ports become drop targets, each positioned on its declared side (shape-aware, in percentages, so they stay put at any zoom):

nodes = [
  { id: 'step', type: 'ticket', position: { x: 80, y: 90 }, size: { width: 230, height: 110 },
    data: { title: 'Review' },
    ports: [
      { id: 'in',  side: 'left',  type: 'input' },
      { id: 'out', side: 'right', type: 'output' },
    ] },
];

Connections dragged from these handles run through the same validation pipeline as everywhere else — typed ports, validators, the lot; see ports & validation. To keep ports visible instead of hover-only, the port demos flip the engine's interaction config once the view exists:

ngAfterViewInit() {
  this.canvas().activeEngine()?.setInteractionConfig({ portVisibility: 'always' });
}

Live: typed ports →

Reaching out from inside a template

Because let-engine and let-node are in scope, a template can carry its own controls — the delete button in the first example is the pattern: read from data, act through engine, address the node by node.id. Anything the engine can do (selection, commands, layout) is one expression away.

There are no node/edge click outputs on the canvas component — do not hunt for a (nodeClick). For clicks on the node body, put the handler in your template (it is your DOM). For diagram-level interaction — connections being drawn, selection changing — subscribe to the engine's eventBus; the pattern is in State, signals & tooling.

Common mistakes

  • GrafloriaNodeDefDirective missing from imports. The attribute matches nothing, the template is ignored, no error is thrown.
  • data.title instead of data['title']. The payload is Record<string, unknown>; the strictly-typed context makes dot access a compile error.
  • No size on the spec / no height: 100% on the root. The node exists but the card renders collapsed or overflows its hit area.
  • custom: false left in a spec. Explicit always beats the template match — the node stays in the SVG layer and the template never runs.
  • Engine work in the constructor. activeEngine() is available from ngAfterViewInit onward; before that it is undefined and template buttons should guard with engine?..
  • No height on the canvas element. The canvas fills its container; an unstyled custom element collapses to zero and your nodes render into nothing.

Where next