npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@niallconaghan/ngx-surface

v0.1.0

Published

An infinite, pannable, zoomable canvas for building spatial UIs in Angular.

Readme

ngx-surface

Infinite, pannable, zoomable canvas for Angular spatial UIs.

npm install @niallconaghan/ngx-surface

Peer 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 ports
  • orthogonal — 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(): void

Coordinate system:

// Screen → Canvas
canvasX = (screenX - offsetX) / scale
canvasY = (screenY - offsetY) / scale

// Canvas → Screen
screenX = canvasX * scale + offsetX
screenY = canvasY * scale + offsetY

Configuration

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