@niallconaghan/ngx-surface
v0.1.0
Published
An infinite, pannable, zoomable canvas for building spatial UIs in Angular.
Maintainers
Readme
ngx-surface
Infinite, pannable, zoomable canvas for Angular spatial UIs.
npm install @niallconaghan/ngx-surfacePeer dependencies: Angular ^21.2.0.
Features
- Infinite canvas — pan by dragging empty space, zoom with mouse wheel (cursor-centered)
- Elements — render nodes on the canvas in two modes: data-driven (internal) or projected (directive)
- Drag — move elements; supports multi-drag when multiple elements are selected
- Resize — 8 directional handles (N/NE/E/SE/S/SW/W/NW), respects circle shape
- Selection — single click, Shift+click multi-select, marquee selection (opt-in), Delete/Backspace handling
- Connections — draw lines between elements via connection ports; supports direct and orthogonal routing with draggable waypoints
- Text editing — double-click to edit text inline (internal and directive modes)
- Grid — configurable grid with snap-to-grid on drag/resize end
- Touch — pointer-event driven; two-finger pinch-zoom and two-finger pan
- Keyboard & a11y — focusable nodes with accessible names, arrow-key nudge, Delete via
elementDeleteRequested/connectionDeleteRequested, Enter to edit, Escape to cancel - Directives —
surfElement,surfDraggable,surfResizable,surfSelectable,surfTextEditable— project your own components onto the canvas - Zero runtime deps — only Angular (peer)
Quick Start
import { Component } from '@angular/core';
import { SurfCanvasComponent, SURF_CONFIG, SurfConfig } from '@niallconaghan/ngx-surface';
@Component({
selector: 'app-my-canvas',
standalone: true,
imports: [SurfCanvasComponent],
providers: [
{
provide: SURF_CONFIG,
useValue: { gridSize: 40, snapToGrid: true } satisfies SurfConfig,
},
],
template: `<surf-canvas style="display:block;width:100%;height:500px" />`,
})
export class MyCanvasComponent {}The SURF_CONFIG injection token is optional — defaults are applied for all fields.
Usage
There are two ways to populate the canvas: internal mode (pass SurfElement[] as input data) and directive mode (apply directives to projected content).
Internal Mode (Data-Driven)
Pass elements as a SurfElement[] array via the elements input. The canvas renders them and emits events when they change.
import { Component, signal } from '@angular/core';
import {
SurfCanvasComponent,
SurfElement,
SurfElementMovedEvent,
SurfElementResizedEvent,
SurfSelectionChangedEvent,
SurfElementTextChangedEvent,
SurfConnection,
SurfTransform,
} from '@niallconaghan/ngx-surface';
@Component({
selector: 'app-internal-example',
standalone: true,
imports: [SurfCanvasComponent],
template: `
<surf-canvas
[elements]="elements()"
[connections]="connections()"
[selectedIds]="selectedIds()"
[config]="{ snapToGrid: true, marqueeSelection: true }"
[ariaLabel]="'My diagram'"
(elementMoved)="onElementMoved($event)"
(elementResized)="onElementResized($event)"
(selectionChanged)="onSelectionChanged($event)"
(canvasClicked)="onCanvasClicked($event)"
(transformChanged)="onTransformChanged($event)"
(connectionCreated)="onConnectionCreated($event)"
(connectionUpdated)="onConnectionUpdated($event)"
(textChanged)="onTextChanged($event)"
(multiElementMoved)="onMultiElementMoved($event)"
(elementDeleteRequested)="onElementsDeleted($event)"
(connectionDeleteRequested)="onConnectionsDeleted($event)"
/>
`,
})
export class InternalExample {
protected readonly elements = signal<SurfElement[]>([
{ id: 1, x: 80, y: 80, width: 160, height: 80, text: 'Start' },
{ id: 2, x: 360, y: 40, width: 160, height: 80, text: 'Process' },
]);
protected readonly connections = signal<SurfConnection[]>([
{ id: 1, sourceId: 1, targetId: 2, sourceSide: 'right', targetSide: 'left' },
]);
protected readonly selectedIds = signal<number[]>([]);
private nextId = 3;
// Handle immutable updates — always return new objects
onElementMoved(event: SurfElementMovedEvent): void {
this.elements.update((els) =>
els.map((el) => (el.id === event.id ? { ...el, x: event.x, y: event.y } : el)),
);
}
onElementResized(event: SurfElementResizedEvent): void {
this.elements.update((els) =>
els.map((el) => (el.id === event.element.id ? { ...event.element } : el)),
);
}
onSelectionChanged(event: SurfSelectionChangedEvent): void {
this.selectedIds.set(event.selectedIds);
}
onCanvasClicked(coords: { x: number; y: number }): void {
this.elements.update((els) => [
...els,
{ id: this.nextId++, x: coords.x - 80, y: coords.y - 40, width: 160, height: 80 },
]);
}
onTextChanged(event: SurfElementTextChangedEvent): void {
this.elements.update((els) =>
els.map((el) => (el.id === event.elementId ? { ...el, text: event.text } : el)),
);
}
onTransformChanged(transform: SurfTransform): void {
console.log('Zoom:', transform.scale);
}
onConnectionCreated(connection: SurfConnection): void {
this.connections.update((conns) => [...conns, connection]);
}
onConnectionUpdated(updated: SurfConnection): void {
this.connections.update((conns) =>
conns.map((c) => (c.id === updated.id ? updated : c)),
);
}
onMultiElementMoved(
events: Array<{ id: SurfElementId; x: number; y: number; previousX: number; previousY: number }>,
): void {
for (const evt of events) {
this.elements.update((els) =>
els.map((el) => (el.id === evt.id ? { ...el, x: evt.x, y: evt.y } : el)),
);
}
}
onElementsDeleted(ids: SurfElementId[]): void {
const set = new Set(ids);
this.elements.update((els) => els.filter((el) => !set.has(el.id)));
}
onConnectionsDeleted(ids: SurfElementId[]): void {
const set = new Set(ids);
this.connections.update((conns) => conns.filter((c) => !set.has(c.id)));
}
}Text in Internal Mode
Elements can have text and textStyle properties. Double-click an element to edit inline:
{
id: 1,
x: 40, y: 40, width: 200, height: 80,
text: 'Hello World',
textStyle: { fontSize: '16px', color: '#333', alignH: 'center', alignV: 'center' },
}Element Styling in Internal Mode
Use elementStyle for visual customisation:
{
id: 2,
x: 40, y: 160, width: 160, height: 80,
text: 'Styled',
elementStyle: {
backgroundColor: '#e3f2fd',
strokeColor: '#1976d2',
strokeWidth: '2px',
borderRadius: '12px',
},
}Connections
Connections link two elements via named sides (top, right, bottom, left). Hover an element to see connection ports (if showConnectionPorts is enabled), then drag from a port to create a connection.
{
id: 1,
sourceId: 1,
targetId: 2,
sourceSide: 'right',
targetSide: 'left',
routingMode: 'orthogonal', // 'direct' | 'orthogonal'
waypoints: [{ id: 1, x: 160, y: 200 }],
}Routing modes:
direct— straight line between source and target portsorthogonal— axis-aligned path with a shared corner segment; the middle segment can be dragged to adjust the corner position
Waypoints are intermediate routing points. Double-click a waypoint to remove it. Click an already-selected connection to insert a waypoint at the click position.
Directive Mode (Projected Content)
Apply directives to your own projected components for full control over rendering.
<surf-canvas
[selectedIds]="selectedIds()"
[connections]="connections()"
[config]="{ snapToGrid: true }"
(selectionChanged)="onSelectionChanged($event)"
(connectionCreated)="onConnectionCreated($event)"
(connectionUpdated)="onConnectionUpdated($event)"
(multiElementMoved)="onMultiElementMoved($event)"
>
@for (el of elements(); track el.id) {
<div
surfElement
[id]="el.id"
[x]="el.x"
[y]="el.y"
[width]="el.width"
[height]="el.height"
[shape]="el.shape ?? 'square'"
[draggable]="el.draggable ?? true"
[resizable]="el.resizable ?? true"
[locked]="el.locked ?? false"
[elementStyle]="el.elementStyle"
surfDraggable
(dragEnd)="onDragEnd(el.id, $event)"
surfSelectable
surfResizable
(resizeEnd)="onResizeEnd(el.id, $event)"
surfTextEditable
[text]="el.text"
[textStyle]="el.textStyle"
(textChanged)="onTextChanged(el.id, $event)"
>
{{ el.text }}
</div>
}
</surf-canvas>Directive Reference
| Directive | Selector | Requires | Description |
|---|---|---|---|
| surfElement | [surfElement] | — | Registers the host as a canvas element. Applies position: absolute and transform: translate(x, y) for canvas-space positioning. Handles live drag/resize positions via context signals. |
| surfDraggable | [surfDraggable] | surfElement | Enables mouse dragging. Emits dragEnd with final canvas-space { x, y }. Multi-drag: when 2+ elements are selected, dragging one moves all selected. |
| surfResizable | [surfResizable] | surfElement | Renders 8 resize handles in a screen-space overlay (selection frame). Emits resizeEnd with { x, y, width, height }. Edge handles are hidden for circle-shaped elements. |
| surfSelectable | [surfSelectable] | surfElement | Toggles .surf-selected CSS class on click. Shift+click for multi-select. Renders a selection frame when surfResizable is not present. |
| surfTextEditable | [surfTextEditable] | surfElement | Double-click to enter text editing mode (sets contenteditable). Emits textChanged on blur with { elementId, previousText, text }. |
Using Directives on Custom Components
Any component can be projected onto the canvas:
<surf-canvas>
<app-clock
surfElement
[id]="99"
[x]="clockPos().x"
[y]="clockPos().y"
[width]="clockPos().width"
[height]="clockPos().height"
surfDraggable
(dragEnd)="onClockDragEnd($event)"
surfSelectable
surfResizable
(resizeEnd)="onClockResizeEnd($event)"
></app-clock>
</surf-canvas>Handling Drag/Resize in Directive Mode
Single element drag:
onDragEnd(elementId: SurfElementId, pos: { x: number; y: number }): void {
this.elements.update((els) =>
els.map((el) => (el.id === elementId ? { ...el, x: pos.x, y: pos.y } : el)),
);
}Multi-element drag: The canvas emits multiElementMoved for all moved elements. Use this instead of individual dragEnd events:
onMultiElementMoved(
events: Array<{ id: SurfElementId; x: number; y: number; previousX: number; previousY: number }>,
): void {
for (const evt of events) {
this.elements.update((els) =>
els.map((el) => (el.id === evt.id ? { ...el, x: evt.x, y: evt.y } : el)),
);
}
}Resize:
onResizeEnd(
elementId: SurfElementId,
dims: { x: number; y: number; width: number; height: number },
): void {
this.elements.update((els) =>
els.map((el) =>
el.id === elementId
? { ...el, x: dims.x, y: dims.y, width: dims.width, height: dims.height }
: el,
),
);
}Canvas API
SurfCanvasComponent exposes these methods via template reference:
<surf-canvas #canvas [elements]="elements()" />// Convert screen-space coordinates (viewport-relative) to canvas-space
canvas.screenToCanvas(screenX: number, screenY: number): { x: number; y: number }
// Convert canvas-space coordinates to screen-space
canvas.canvasToScreen(canvasX: number, canvasY: number): { x: number; y: number }
// Zoom to a specific scale (optionally centered on a canvas-space point)
canvas.zoomTo(scale: number, centerX?: number, centerY?: number): void
// Pan so a canvas-space coordinate is centered in the viewport
canvas.panTo(canvasX: number, canvasY: number): void
// Fit given elements within the viewport with optional padding (default 40px)
canvas.fitToContent(elements: SurfElement[], padding?: number): void
// Reset transform to default (offsetX=0, offsetY=0, scale=1)
canvas.resetView(): voidCoordinate system:
// Screen → Canvas
canvasX = (screenX - offsetX) / scale
canvasY = (screenY - offsetY) / scale
// Canvas → Screen
screenX = canvasX * scale + offsetX
screenY = canvasY * scale + offsetYConfiguration
Provide defaults globally via the SURF_CONFIG injection token, or override per-instance via the config input.
import { SURF_CONFIG, SurfConfig } from '@niallconaghan/ngx-surface';
// Global default
providers: [{ provide: SURF_CONFIG, useValue: { gridSize: 20, snapToGrid: true } }]
// Per-instance override (merged with defaults)
<surf-canvas [config]="{ showGrid: false, minScale: 0.2, maxScale: 5 }" />| Property | Type | Default | Description |
|---|---|---|---|
| gridSize | number | 40 | Grid spacing in canvas-space pixels |
| minScale | number | 0.05 | Minimum zoom level |
| maxScale | number | 10 | Maximum zoom level |
| snapToGrid | boolean | false | Snap element position/size to grid on drag/resize end |
| showGrid | boolean | true | Render the background grid |
| resizable | boolean | true | Allow elements to be resized (per-element override available) |
| minElementSize | number | 20 | Minimum element width/height when resizing |
| draggable | boolean | true | Allow elements to be dragged (per-element override available) |
| showConnectionPorts | boolean | true | Show connection ports on hover |
| marqueeSelection | boolean | false | Left-drag on empty viewport draws a marquee instead of panning (pan via middle-mouse) |
| defaultConnectionRouting | 'direct' \| 'orthogonal' | 'direct' | Default routing mode for new connections |
| selectedBorderColor | string | — | Sets --surf-selection-color |
| handleColor | string | — | Sets --surf-handle-border |
| portColor | string | — | Sets --surf-port-border |
| waypointColor | string | — | Sets --surf-waypoint-border |
| waypointActiveColor | string | — | Sets --surf-waypoint-selected-color |
CSS Custom Properties
All theming is via CSS custom properties on :host. Override them in your component or globally.
surf-canvas {
--surf-bg: #1e1e2e;
--surf-grid-color: rgba(255, 255, 255, 0.06);
--surf-selection-color: #89b4fa;
}| Variable | Default | Description |
|---|---|---|
| --surf-bg | #f0f0f0 | Canvas background colour |
| --surf-grid-color | rgba(0,0,0,0.08) | Grid dot/line colour |
| --surf-node-bg | #ffffff | Element background colour |
| --surf-node-border | #cccccc | Element border colour |
| --surf-node-radius | 6px | Element border radius |
| --surf-node-selected-border | #4a90e2 | Selected element border colour |
| --surf-selection-color | #4a90e2 | Selection frame and resize handle colour |
| --surf-handle-bg | #ffffff | Resize handle fill colour |
| --surf-handle-border | #4a90e2 | Resize handle border colour |
| --surf-handle-size | 8px | Resize handle size |
| --surf-connection-color | #999 | Connection line colour |
| --surf-connection-width | 2px | Connection line width |
| --surf-connection-selected-color | #4a90e2 | Selected connection line colour |
| --surf-port-size | 10px | Connection port size |
| --surf-port-color | #fff | Connection port fill colour |
| --surf-port-border | #4a90e2 | Connection port border colour |
| --surf-waypoint-size | 10px | Waypoint handle size |
| --surf-waypoint-color | #fff | Waypoint fill colour |
| --surf-waypoint-border | #4a90e2 | Waypoint border colour |
| --surf-waypoint-selected-color | #ff6b35 | Waypoint hover/fill colour |
| --surf-selection-rect-bg | rgba(74,144,226,0.08) | Marquee selection fill |
| --surf-selection-rect-border | #4a90e2 | Marquee selection border |
| --surf-focus-color | #4a90e2 | Keyboard focus ring colour |
| --surf-text-color | inherit | Element text colour |
| --surf-text-size | 13px | Element text font size |
Adding new variables is a minor version change. Removing or renaming is major (breaking).
Type Reference
SurfElement
interface SurfElement {
id: SurfElementId; // string | number
x: number; // Canvas-space X position
y: number; // Canvas-space Y position
width: number; // Canvas-space width
height: number; // Canvas-space height
shape?: 'square' | 'circle';
data?: unknown; // Consumer escape hatch — library never inspects
draggable?: boolean;
resizable?: boolean;
locked?: boolean;
text?: string;
textStyle?: SurfElementTextStyle;
elementStyle?: SurfElementStyle;
}SurfElementTextStyle
interface SurfElementTextStyle {
fontSize?: string; // e.g. '16px'
color?: string; // e.g. '#333'
alignH?: 'left' | 'center' | 'right';
alignV?: 'top' | 'center' | 'bottom';
}SurfElementStyle
interface SurfElementStyle {
backgroundColor?: string;
strokeColor?: string;
strokeWidth?: string; // e.g. '2px'
borderRadius?: string; // e.g. '12px'
}SurfConfig
interface SurfConfig {
gridSize?: number;
minScale?: number;
maxScale?: number;
snapToGrid?: boolean;
showGrid?: boolean;
resizable?: boolean;
minElementSize?: number;
draggable?: boolean;
showConnectionPorts?: boolean;
marqueeSelection?: boolean;
defaultConnectionRouting?: 'direct' | 'orthogonal';
selectedBorderColor?: string;
handleColor?: string;
portColor?: string;
waypointColor?: string;
waypointActiveColor?: string;
}SurfTransform
interface SurfTransform {
offsetX: number;
offsetY: number;
scale: number;
}SurfConnection
interface SurfConnection {
id: SurfElementId;
sourceId: SurfElementId;
targetId: SurfElementId;
sourceSide: 'top' | 'right' | 'bottom' | 'left';
targetSide: 'top' | 'right' | 'bottom' | 'left';
waypoints?: SurfWaypoint[];
routingMode?: 'direct' | 'orthogonal';
direction?: 'source-to-target' | 'target-to-source' | 'bidirectional';
}SurfWaypoint
interface SurfWaypoint {
id: number;
x: number; // Canvas-space
y: number; // Canvas-space
}Event Interfaces
interface SurfElementMovedEvent {
id: SurfElementId;
x: number;
y: number;
previousX: number;
previousY: number;
}
interface SurfElementResizedEvent {
element: SurfElement;
previousX: number;
previousY: number;
previousWidth: number;
previousHeight: number;
}
interface SurfSelectionChangedEvent {
selectedIds: SurfElementId[];
selectedConnectionIds?: SurfElementId[];
}
interface SurfElementTextChangedEvent {
elementId: SurfElementId;
previousText: string;
text: string;
}Utility Types
type SurfElementId = string | number;
type SurfElementShape = 'square' | 'circle';
type ConnectionSide = 'top' | 'right' | 'bottom' | 'left';
type ConnectionRoutingMode = 'direct' | 'orthogonal';
type ConnectionDirection = 'source-to-target' | 'target-to-source' | 'bidirectional';
type HandleDirection = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';Public API
All exports from ngx-surface:
// Components
export { SurfCanvasComponent };
// Directives
export { SurfElementDirective }; // [surfElement]
export { SurfDraggableDirective }; // [surfDraggable]
export { SurfResizableDirective }; // [surfResizable]
export { SurfSelectableDirective }; // [surfSelectable]
export { SurfTextEditableDirective }; // [surfTextEditable]
// Config token
export { SURF_CONFIG };
// Types
export type {
SurfElement,
SurfElementShape,
SurfElementId,
SurfElementStyle,
SurfElementTextStyle,
SurfElementTextChangedEvent,
SurfTransform,
SurfConfig,
SurfElementMovedEvent,
SurfElementResizedEvent,
SurfSelectionChangedEvent,
SurfConnection,
SurfWaypoint,
ConnectionSide,
ConnectionRoutingMode,
ConnectionDirection,
HandleDirection,
};Development
# Serve demo
npm start
# Run unit tests (Vitest)
npm test
# Run E2E tests (Playwright)
npm run test:e2e
# Build library
npm run build ngx-surface