@m1z23r/ngx-visual
v0.0.2
Published
A signals-first Angular infinite canvas for node-based editors - draggable nodes, ports, bezier edges, minimap, undo/redo, copy/paste and auto-layout
Maintainers
Readme
@m1z23r/ngx-visual
A signals-first Angular infinite canvas for building node-based editors - the kind of visual builder you know from n8n, Node-RED or Unreal Blueprints. Zero dependencies beyond Angular, no rxjs, standalone components only.
Features
- Infinite pan/zoom canvas with dot grid, zoom-to-cursor wheel and fit-view
- Custom node UIs via plain
ng-template- any HTML/CSS, real inputs and buttons inside nodes - Input/output ports with drag-to-connect, connection validation and duplicate rejection
- Bezier edges with arrows, labels, hover/selected states and endpoint reconnection
- Click, shift-click and box selection; multi-node drag; snap-to-grid
- Undo/redo history, copy/cut/paste with id remapping, delete via keyboard
- Quick-add flow: dropping a connection on empty canvas emits an event with world coordinates (build your own n8n-style node picker)
- Context menu events for nodes, edges and canvas with world coordinates
- Minimap with viewport rect and click/drag navigation; zoom/history controls
- Dependency-free layered auto-layout (
autoLayout+applyAutoLayout) - Read-only mode, theming via CSS custom properties, multiple canvases per page
Install
npm i @m1z23r/ngx-visualQuick start
import { Component, signal } from '@angular/core';
import {
IVisualEdge,
IVisualNode,
NgxPortComponent,
NgxVisualComponent,
NgxVisualControlsComponent,
NgxVisualMinimapComponent,
NodeTypeTemplateDirective,
} from '@m1z23r/ngx-visual';
@Component({
selector: 'app-flow',
imports: [
NgxVisualComponent,
NgxVisualMinimapComponent,
NgxVisualControlsComponent,
NgxPortComponent,
NodeTypeTemplateDirective,
],
template: `
<ngx-visual [(nodes)]="nodes" [(edges)]="edges" style="height: 100vh">
<ng-template ngxNodeType="task" let-node let-selected="selected">
<div class="card" [class.active]="selected">
{{ node.data.title }}
<ngx-port id="in" type="input" class="port-in" />
<ngx-port id="out" type="output" class="port-out" />
</div>
</ng-template>
<ngx-visual-minimap />
<ngx-visual-controls />
</ngx-visual>
`,
})
export class FlowComponent {
nodes = signal<IVisualNode[]>([
{ id: 'a', type: 'task', position: { x: 0, y: 0 }, data: { title: 'Start' } },
{ id: 'b', type: 'task', position: { x: 300, y: 40 }, data: { title: 'Finish' } },
]);
edges = signal<IVisualEdge[]>([
{ id: 'e1', source: 'a', sourcePort: 'out', target: 'b', targetPort: 'in' },
]);
}Position the ports yourself (they are normal elements inside your template):
.card {
position: relative;
padding: 12px 16px;
}
.port-in {
position: absolute;
left: -8px;
top: calc(50% - 8px);
}
.port-out {
position: absolute;
right: -8px;
top: calc(50% - 8px);
}<ngx-visual> API
Two-way models
| Model | Type | Description |
| ------------ | --------------- | ------------------------------------ |
| nodes | IVisualNode[] | The nodes, [(nodes)] bindable |
| edges | IVisualEdge[] | The edges, [(edges)] bindable |
| viewport | IViewport | { x, y, zoom }, bindable |
Inputs
| Input | Default | Description |
| --------------------- | ------- | ------------------------------------------------------- |
| minZoom / maxZoom | 0.1/2.5 | Zoom clamp |
| snapToGrid | false | Snap node drags to the grid |
| gridSize | 20 | Grid cell size in world px |
| panOnDrag | true | Left-drag pans; shift+drag box-selects (false swaps) |
| readonly | false | Blocks all mutations, keeps pan/zoom/selection |
| showArrows | true | Arrowheads on edges |
| showGrid | true | Dot grid background |
| nativeContextMenu | false | Skip preventDefault() on right-click |
| connectionValidator | - | (conn: IConnection) => boolean, veto new connections |
Outputs
| Output | Payload | When |
| ------------------- | ------------------------ | ------------------------------------------------- |
| connect | IVisualEdge | Edge created or reconnected |
| connectionDrop | IConnectionDropEvent | Connection released over empty canvas (quick-add) |
| nodeContextMenu | INodeContextMenuEvent | Right-click on node |
| edgeContextMenu | IEdgeContextMenuEvent | Right-click on edge |
| canvasContextMenu | ICanvasContextMenuEvent| Right-click on canvas |
| selectionChange | ISelection | Node/edge selection changed |
| deleted | ISelection | Nodes/edges deleted via canvas |
Public methods (grab the component with viewChild)
fitView(), zoomIn(), zoomOut(), setViewport(), screenToWorld(), worldToScreen(), undo(), redo(), canUndo, canRedo, copySelection(), cutSelection(), paste(), deleteSelection(), selectAll(), clearSelection(), applyAutoLayout(options?), pushHistory(), graphBounds().
Call pushHistory() before mutating nodes/edges from outside the canvas so your change lands on the undo stack.
Keyboard (canvas must be focused - it focuses itself on click)
Delete/Backspace, Ctrl+Z, Ctrl+Shift+Z / Ctrl+Y, Ctrl+C/X/V, Ctrl+A, Escape.
Ports
<ngx-port id="out" type="output" />
<ngx-port id="in" type="input" side="top" />id is the port id used in IVisualEdge.sourcePort/targetPort. type is input or output (only opposites connect). side controls the bezier exit direction and defaults to left for inputs and right for outputs. Mark elements inside a node with data-ngx-nodrag to make them non-draggable (form fields, buttons and links already are).
Auto-layout
import { autoLayout } from '@m1z23r/ngx-visual';
const laidOut = autoLayout(nodes, edges, { direction: 'LR', layerGap: 120, nodeGap: 40 });Or canvasRef.applyAutoLayout() which uses the measured node sizes, pushes history and fits the view.
Theming
Override the CSS custom properties anywhere above the canvas:
ngx-visual {
--ngx-visual-bg: #0f172a;
--ngx-visual-dot-color: #1e293b;
--ngx-visual-edge-color: #475569;
--ngx-visual-accent: #22d3ee;
--ngx-visual-port-color: #64748b;
--ngx-visual-node-bg: #1e293b;
--ngx-visual-node-border: #334155;
--ngx-visual-label-color: #cbd5e1;
}Notes
- Node
datamust be structured-cloneable (history and clipboard usestructuredClone). - Rendering is plain DOM + one SVG layer; comfortable into the hundreds of nodes.
- Everything is signals:
OnPush+ zoneless ready, no rxjs anywhere.
License
MIT
