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

@fieldnotes/core

v0.82.1

Published

Vanilla TypeScript infinite canvas engine

Readme

@fieldnotes/core

A lightweight, framework-agnostic infinite canvas SDK for the web — with first-class support for embedding interactive HTML elements.

Features

  • Infinite canvas — pan, zoom, pinch-to-zoom
  • Freehand drawing — pencil tool with stroke smoothing and pressure-sensitive width
  • Sticky notes — editable text notes with customizable colors
  • Arrows — curved bezier arrows with element binding
  • Shapes — rectangles, ellipses with fill and stroke
  • Text — standalone text elements with font size and alignment
  • Images — drag & drop or programmatic placement (canvas-rendered for proper layer ordering)
  • HTML embedding — add any DOM element as a fully interactive canvas citizen
  • Canvas-routed HTML painters — register a painter to draw html elements on the canvas (screen, minimap, and export) instead of the DOM, with typed element activation events
  • Layers — named layers with visibility, locking, and absolute ordering
  • Select & multi-select — click, drag box, move, resize (layer-aware)
  • Undo / redo — full history stack with configurable depth
  • State serialization — export/import JSON snapshots with automatic migration
  • Export — PNG export with scale, padding, background, and element filter options
  • Performance instrumentationgetRenderStats() and logPerformance() for frame timing
  • Touch & tablet — Pointer Events API, pinch-to-zoom, two-finger pan, stylus pressure
  • Zero dependencies — vanilla TypeScript, no framework required
  • Tree-shakeable — ESM + CJS output

Install

npm install @fieldnotes/core

Domain packages

Core is domain-agnostic. VTT-specific features (grid, fog of war, measurement, templates, tokens) live in @fieldnotes/vtt:

npm install @fieldnotes/vtt

Quick Start

import {
  Viewport,
  HandTool,
  SelectTool,
  PencilTool,
  EraserTool,
  ArrowTool,
  NoteTool,
} from '@fieldnotes/core';

// Mount on any container element
const viewport = new Viewport(document.getElementById('canvas'), {
  background: { pattern: 'dots', spacing: 24 },
});

// Register tools
viewport.toolManager.register(new HandTool());
viewport.toolManager.register(new SelectTool());
viewport.toolManager.register(new PencilTool({ color: '#1a1a1a', width: 2 }));
viewport.toolManager.register(new EraserTool());
viewport.toolManager.register(new ArrowTool({ color: '#1a1a1a', width: 2 }));
viewport.toolManager.register(new NoteTool());

// Activate a tool
viewport.setTool('select');

// Clean up when done
viewport.destroy();

Your container element needs a defined size (width/height). The canvas fills its container.

Embedding HTML Elements

The main differentiator — embed any DOM node as a fully interactive canvas element:

const card = document.createElement('div');
card.innerHTML = '<h3>My Card</h3><button>Click me</button>';

// Buttons, inputs, links — everything works
card.querySelector('button').addEventListener('click', () => {
  console.log('Clicked inside the canvas!');
});

const elementId = viewport.addHtmlElement(card, { x: 100, y: 200 }, { w: 250, h: 150 });

HTML elements pan, zoom, resize, and interleave by layer/zIndex with canvas-rendered content. They use a two-mode interaction model:

  • Default — the element can be selected, dragged, and resized like any other element
  • Double-click — enters interact mode, making buttons, inputs, and links work
  • Escape or click outside — exits interact mode

You can also exit interact mode programmatically:

viewport.stopInteracting();

Adding Images

// Programmatic
viewport.addImage('https://example.com/photo.jpg', { x: 0, y: 0 });
viewport.addImage('/assets/map.png', { x: 0, y: 0 }, { w: 800, h: 600 });

// Drag & drop is handled automatically — drop images onto the canvas

Important: Use URLs, not base64 data URLs. Images are stored inline in the serialized state. A single base64-encoded photo can be 2-5MB, which will blow past the localStorage ~5MB quota and make JSON exports impractical. Upload images to your server or CDN and use the URL. For offline/local-first apps, store blobs in IndexedDB and reference them by URL.

Adding Shapes

// Default: a centered 100×100 rectangle
const id = viewport.addShape();

// Override shape, size, position, and colors
viewport.addShape({
  shape: 'ellipse',
  size: { w: 200, h: 120 },
  position: { x: 0, y: 0 },
  strokeColor: '#1d4ed8',
  fillColor: '#dbeafe',
  strokeWidth: 2,
});

addShape(opts?): string creates a shape in a single undo step, selects the new shape, and returns its id. With no options it places a 100×100 rectangle centered in the current viewport — a keyboard-friendly path to shape creation.

Image Export

Export the canvas as a PNG image:

const blob = await viewport.exportImage({
  scale: 2, // pixel density (default 2)
  padding: 20, // world-space padding around content (default 0)
  background: '#fff', // fill color (default '#ffffff')
  filter: (el) => el.type !== 'html', // optional per-element filter
  imageTimeoutMs: 10_000, // maximum wait per image
  maxDimension: 16_384, // maximum output width or height
  maxPixels: 67_108_864, // maximum output pixel count
  onAssetError: ({ elementId, src, reason }) => {
    console.warn(`Could not export ${elementId} (${reason}): ${src}`);
  },
});

Remote images are requested with anonymous CORS using their original URLs; failures are omitted from the result and reported through onAssetError when supplied.

Application-owned HTML embeds require an explicit rasterization hook. Return a ready canvas-compatible image source; Field Notes applies the element's size, rotation, paint order, and layer opacity in both PNG and SVG exports:

const options = {
  htmlTimeoutMs: 10_000,
  renderHtml: async (element) => {
    const node = document.querySelector(`[data-element-id="${element.id}"]`);
    return node ? rasterizeToCanvas(node) : null; // application or library implementation
  },
  onHtmlError: ({ elementId, reason }) => {
    console.warn(`Could not export HTML element ${elementId}: ${reason}`);
  },
};

const png = await viewport.exportImage(options);
const svg = await viewport.exportSVG(options);

Without renderHtml, embeds remain omitted and can be observed through onHtmlError.

Performance Monitoring

// Get a snapshot of render stats
const stats = viewport.getRenderStats();
// { fps, avgFrameMs, p95FrameMs, lastGridMs, frameCount }

// Log stats to console every 2 seconds (returns stop function)
const stop = viewport.logPerformance(2000);
// [FieldNotes] fps=60 frame=1.2ms p95=2.1ms grid=0.1ms
stop(); // stop logging

Camera Control

const { camera } = viewport;

camera.pan(100, 50); // pan by offset
camera.moveTo(0, 0); // jump to position
camera.setZoom(2); // set zoom level
camera.zoomAt(1.5, { x: 400, y: 300 }); // zoom toward screen point

const world = camera.screenToWorld({ x: e.clientX, y: e.clientY });
const screen = camera.worldToScreen({ x: 0, y: 0 });

camera.onChange(() => {
  /* camera moved */
});

Element Store

Direct access to canvas elements:

const { store } = viewport;

const all = store.getAll(); // sorted by zIndex
const el = store.getById('some-id');
const strokes = store.getElementsByType('stroke');

store.update('some-id', { locked: true });
store.remove('some-id');

store.on('add', (el) => console.log('added', el));
store.on('remove', (el) => console.log('removed', el));
store.on('update', ({ previous, current }) => {
  /* ... */
});

Undo / Redo

viewport.undo();
viewport.redo();

viewport.history.canUndo; // boolean
viewport.history.canRedo; // boolean
viewport.history.onChange(() => {
  /* update UI */
});

Layers

Organize elements into named layers with visibility, lock, and ordering controls. All elements on a higher layer render above all elements on a lower layer, regardless of individual z-index.

const { layerManager } = viewport;

// Create layers
const background = layerManager.activeLayer; // "Layer 1" exists by default
layerManager.renameLayer(background.id, 'Map');
const tokens = layerManager.createLayer('Tokens');
const notes = layerManager.createLayer('Notes');

// Set active layer — new elements are created on the active layer
layerManager.setActiveLayer(tokens.id);

// Visibility and locking
layerManager.setLayerVisible(background.id, false); // hide
layerManager.setLayerLocked(background.id, true); // prevent selection/editing

// Move elements between layers
layerManager.moveElementToLayer(elementId, notes.id);

// Reorder layers
layerManager.reorderLayer(tokens.id, 5); // higher order = renders on top

// Query
layerManager.getLayers(); // sorted by order
layerManager.isLayerVisible(id);
layerManager.isLayerLocked(id);

// Listen for changes
layerManager.on('change', () => {
  /* update UI */
});

Locked layers prevent selection, erasing, and arrow binding on their elements. Hidden layers are invisible and non-interactive. The active layer cannot be hidden or locked — if you try, it automatically switches to the next available layer.

State Serialization

// Save
const json = viewport.exportJSON();
localStorage.setItem('canvas', json);

// Load
viewport.loadJSON(localStorage.getItem('canvas'));

Note: Serialized state includes all layers and element layerId assignments. States saved before layers were introduced are automatically migrated — elements are placed on a default "Layer 1".

Two equivalent pairs: exportJSON() / loadJSON() work with strings and are the canonical choice for persistence. exportState() / loadState() work with in-memory CanvasState objects, skipping the JSON round-trip — this is what AutoSave uses. The module-level exportState / parseState functions are no longer exported; use the Viewport methods.

Tool Switching

viewport.setTool('pencil');
viewport.setTool('hand');

viewport.toolManager.onChange((toolName) => {
  console.log('switched to', toolName);
});

Keyboard shortcuts

Defaults (remappable): Delete/Backspace delete · Escape deselect · mod+Z undo · mod+Y/mod+Shift+Z redo · mod+A select all · mod+C/V/D copy/paste/duplicate · [/] z-order (with mod = to back/front) · Shift+1 zoom-to-fit · mod+= zoom in · mod+- zoom out · mod+0 reset zoom to 100% · arrows nudge · tool keys V select, H hand, P pencil, E eraser, A arrow, N note, T text, S shape.

mod = Ctrl or Cmd. Shortcuts fire only while the canvas has focus (click it once); pass shortcuts: { scope: 'window' } for page-wide handling.

const viewport = new Viewport(el, {
  shortcuts: {
    bindings: {
      duplicate: 'mod+shift+d', // remap
      'tool:pencil': ['p', 'b'], // multiple bindings
      copy: null, // disable
      'tool:my-custom-tool': 'f', // any registered tool works
    },
  },
});

viewport.shortcuts.rebind('undo', 'mod+u');
viewport.shortcuts.disable('select-all');
viewport.shortcuts.reset(); // back to defaults
viewport.shortcuts.getBindings(); // current table — render a settings UI

Changing Tool Options at Runtime

All drawing tools support setOptions() for changing color, width, and other settings without re-creating the tool:

// Get a tool by name (type-safe with generics)
const pencil = viewport.toolManager.getTool<PencilTool>('pencil');
const arrow = viewport.toolManager.getTool<ArrowTool>('arrow');
const note = viewport.toolManager.getTool<NoteTool>('note');

// Change colors
pencil?.setOptions({ color: '#ff0000' });
arrow?.setOptions({ color: '#ff0000' });
note?.setOptions({ backgroundColor: '#e8f5e9' });

// Change stroke width
pencil?.setOptions({ width: 5 });
arrow?.setOptions({ width: 3 });

Stroke Smoothing

The pencil tool automatically smooths freehand strokes using Ramer-Douglas-Peucker point simplification and Catmull-Rom curve fitting. You can control the smoothing tolerance:

new PencilTool({
  smoothing: 1.5, // default — higher = smoother, lower = more detail
});

// Or at runtime
pencil?.setOptions({ smoothing: 3 });

Pressure-Sensitive Width

When using a stylus (Apple Pencil, Surface Pen), stroke width varies based on pressure automatically. The width option sets the maximum width at full pressure. Mouse input uses a default pressure of 0.5 for consistent-width strokes.

Stroke points include pressure data in the StrokePoint type:

interface StrokePoint {
  x: number;
  y: number;
  pressure: number; // 0-1
}

Custom Tools

Implement the Tool interface to create your own tools:

import type { Tool, ToolContext, PointerState } from '@fieldnotes/core';

const myTool: Tool = {
  name: 'my-tool',

  onPointerDown(state: PointerState, ctx: ToolContext) {
    const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
    // state.pressure is available for stylus input (0-1)
  },

  onPointerMove(state: PointerState, ctx: ToolContext) {
    // called during drag
  },

  onPointerUp(state: PointerState, ctx: ToolContext) {
    // finalize action
    ctx.store.add(myElement);
    ctx.requestRender();
  },

  // Optional
  onActivate(ctx) {
    ctx.setCursor?.('crosshair');
  },
  onDeactivate(ctx) {
    ctx.setCursor?.('default');
  },
  renderOverlay(canvasCtx) {
    /* draw preview on canvas */
  },
};

viewport.toolManager.register(myTool);
viewport.setTool('my-tool');

Configuration

Viewport Options

new Viewport(container, {
  camera: {
    minZoom: 0.1, // default: 0.1
    maxZoom: 10, // default: 10
  },
  background: {
    pattern: 'dots', // 'dots' | 'grid' | 'none' (default: 'dots')
    spacing: 24, // grid spacing in px (default: 24)
    color: '#d0d0d0', // dot/line color (default: '#d0d0d0')
  },
  // Called for every drop; replaces the built-in image-drop handling
  onDrop: (event, worldPosition) => {
    /* handle drop */
  },
  // Called when an image element fails to load; failed images render a gray
  // placeholder. Falls back to console.warn when unset.
  onImageError: ({ src, elementIds }) => {
    /* handle broken image */
  },
});

ViewportOptions reference

  • camera?: CameraOptionsminZoom / maxZoom (defaults 0.1 / 10).
  • background?: BackgroundOptionspattern, spacing, color.
  • fontSizePresets?: FontSizePreset[] — custom font-size steps for the note toolbar.
  • toolbar?: boolean — show/hide the note formatting toolbar (default true).
  • placeholder?: string — placeholder text shown in empty notes.
  • shortcuts?: ShortcutOptions — seed the keyboard shortcut table with custom bindings.
  • onHtmlElementMount? — called after loadState for HTML elements that need content injected.
  • onDrop? — called for every drop event; replaces the built-in image-drop handling.
  • onImageError? — called when an image element fails to load.
  • panBufferMargin?: number (default 256) — CSS-pixel margin cached beyond the viewport so small pans re-composite instead of re-rasterizing the layers and grid. Larger = more pan reuse, more memory per layer. Set 0 to disable (exact-viewport caches) on memory-tight hosts.

Tool Options

new PencilTool({ color: '#ff0000', width: 3, smoothing: 1.5 });
new EraserTool({ radius: 30 }); // radius is screen pixels (converted to world units per zoom); mode: 'partial' (default) splits strokes at the erased span; mode: 'stroke' deletes the whole stroke
new ArrowTool({ color: '#333', width: 2 });

PencilTool also accepts opacity (0–1), blendMode ('source-over' | 'multiply'), and name — so a highlighter tool is just a named pencil variant with multiply blending:

// Register a highlighter alongside the standard pencil
viewport.toolManager.register(
  new PencilTool({
    name: 'highlighter',
    color: '#facc15',
    width: 12,
    opacity: 0.4,
    blendMode: 'multiply',
  }),
);
viewport.setTool('highlighter');

ShapeTool supports a 'line' shape kind that draws a straight segment between two points. Hold Shift while drawing to snap to 45° increments. Lines are hit-tested by proximity to the segment, and ShapeElement.flip records which diagonal of the bounding box the line runs along. When a line is selected, it shows two endpoint drag-handles instead of bounding-box resize handles — drag one endpoint to reshape the line while the other stays anchored.

Arrow Labels

Arrows support an optional label string, rendered as a pill at the curve midpoint. Pass it at creation or double-click an arrow on the canvas to add or edit the label inline.

createArrow({ from: { x: 0, y: 0 }, to: { x: 200, y: 0 }, label: 'depends on' });
new NoteTool({ backgroundColor: '#fff9c4', size: { w: 200, h: 150 } });
new ImageTool({ size: { w: 400, h: 300 } });

Element Types

All elements share a base shape:

interface BaseElement {
  id: string;
  type: string;
  position: { x: number; y: number };
  zIndex: number;
  locked: boolean;
  layerId: string;
}

| Type | Key Fields | | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | stroke | points: StrokePoint[], color, width, opacity | | note | size, text, backgroundColor, textColor | | arrow | from, to, bend, color, width, fromBinding, toBinding | | image | size, src | | shape | size, shape (rectangle | ellipse | line), strokeColor, fillColor, flip (boolean — which bbox diagonal a line runs along) | | text | size, text, fontSize, color, textAlign | | html | size |

Styling the Selection

A normalized ElementStyle interface lets you read and apply visual properties across all element types through a single, consistent shape. The SelectTool emits a selection-change event; Viewport exposes four methods that together cover reactive UIs.

ElementStyle interface

interface ElementStyle {
  color?: string; // stroke color / text color
  fillColor?: string; // fill / background color
  strokeWidth?: number; // line width in world-space units
  opacity?: number; // 0–1
  fontSize?: number; // px
}

Mapping across element types

| ElementStyle field | stroke | arrow | shape | note | text | | -------------------- | --------- | ------- | ------------- | ----------------- | ---------- | | color | color | color | strokeColor | textColor | color | | fillColor | — | — | fillColor | backgroundColor | — | | strokeWidth | width | width | strokeWidth | — | — | | opacity | opacity | — | — | — | — | | fontSize | — | — | — | (via toolbar) | fontSize |

Conversion helpers

import { styleToPatch, getElementStyle } from '@fieldnotes/core';

// ElementStyle → element-specific patch object
const patch = styleToPatch(element, { color: '#e00', strokeWidth: 3 });
store.update(element.id, patch);

// element → normalized ElementStyle
const style = getElementStyle(element);

Viewport methods

  • viewport.getSelectedIds() — returns the current selection as a referentially-stable array (the same array reference is reused across calls when the selection has not changed — safe for useSyncExternalStore equality checks).
  • viewport.onSelectionChange(listener) — subscribes to selection changes; returns an unsubscribe function. The listener receives the new stable id array.
  • viewport.getSelectionStyle() — returns an ElementStyle containing only the properties that are identical across every selected element. Properties that differ are omitted.
  • viewport.applyStyleToSelection(style) — applies the given ElementStyle to all selected elements in a single undo step.

SelectTool.onSelectionChange

const selectTool = viewport.toolManager.getTool<SelectTool>('select');
selectTool?.onSelectionChange((ids) => {
  console.log('selected:', ids);
});

Example

// Apply a red stroke to everything currently selected — one undo step
viewport.applyStyleToSelection({ color: '#ff0000' });

// Read back the shared style for a UI color picker
const style = viewport.getSelectionStyle();
// style.color is defined only if all selected elements share the same color

// React to selection changes
const unsub = viewport.onSelectionChange((ids) => {
  setSelectedIds(ids); // ids is referentially stable — safe for deps arrays
});
// call unsub() to unsubscribe

Aligning the Selection

Two methods on Viewport let you snap or space selected elements in one undo step.

  • viewport.alignSelection(edge)edge: AlignEdge = 'left' | 'center-x' | 'right' | 'top' | 'middle' | 'bottom'; aligns every selected element to the corresponding edge or center of the selection's bounding box. Needs 2+ selected elements. Locked elements anchor the bounding box without moving.
  • viewport.distributeSelection(axis)axis: DistributeAxis = 'horizontal' | 'vertical'; evenly spaces selected elements' centers along the axis. Needs 3+ selected elements. Locked elements anchor the span without moving.
viewport.alignSelection('left'); // flush left edges
viewport.alignSelection('center-x'); // center on vertical axis
viewport.alignSelection('middle'); // center on horizontal axis
viewport.distributeSelection('horizontal'); // equal horizontal spacing

Smart Alignment Guides

Call viewport.setSmartGuides(true) to enable drag-time alignment snapping. While dragging a selection, its edges and centers snap to the edges and centers of nearby visible elements (within 6 screen pixels), and guide lines are drawn at each matched alignment. The result is still committed as a single undo step.

viewport.setSmartGuides(true); // enable
viewport.setSmartGuides(false); // disable (default)

Grouping

Group elements so they select, move, delete, z-order, and align as a single unit.

  • viewport.groupSelection() — groups the current selection under a new id.
  • viewport.ungroupSelection() — dissolves any groups in the current selection.

Each is one undo step. Selecting any member selects its whole group, so to edit a single member individually, ungroup first. Pasting or duplicating a group keeps the copies grouped under a fresh id.

viewport.groupSelection(); // Ctrl/Cmd+G
viewport.ungroupSelection(); // Ctrl/Cmd+Shift+G

The shortcuts are rebindable as group and ungroup.

Rotation

Select a single element and a rotate handle appears above the selection box. Drag it to rotate the element about its center; hold Shift to snap to 15° increments. Notes, text, images, HTML embeds, shapes, and strokes can all be rotated.

Hit-testing, marquee selection, and resize are all rotation-aware: resizing a rotated element keeps the opposite corner fixed in the element's local frame. Rotation is reflected in PNG export and round-trips through serialization (rotation? on elements, stored in radians).

Context menu & lock

Right-click (desktop) or touch long-press (tablet) opens a context menu over the canvas with Cut/Copy/Paste/Duplicate/Delete, z-order (to front / forward / backward / to back), and Lock/Unlock. The menu is core-provided (plain DOM) and selects the element under the pointer if it isn't already selected. Opt out with new Viewport(el, { contextMenu: false }).

Lock with viewport.toggleLockSelection() or Ctrl/Cmd+Shift+L; a lock badge appears on the selection. Locked elements stay selectable but can't be moved, resized, or rotated. Ctrl/Cmd+X cuts the selection. The shortcuts are rebindable as toggle-lock and cut.

You can drive any menu action programmatically with viewport.runAction(name) (e.g. 'cut', 'paste', 'toggle-lock'), and viewport.canPaste() reports whether the clipboard has content.

Built-in Interactions

| Input | Action | | -------------------- | ------------------- | | Scroll wheel | Zoom | | Middle-click drag | Pan | | Space + drag | Pan | | Two-finger pinch | Zoom | | Two-finger drag | Pan | | Delete / Backspace | Remove selected | | Ctrl+Z / Cmd+Z | Undo | | Ctrl+Shift+Z / Cmd+Y | Redo | | Double-click note | Edit text | | Double-click HTML | Enter interact mode | | Escape | Exit interact mode |

Browser Support

Works in all modern browsers supporting Pointer Events API and HTML5 Canvas.

Versioning

@fieldnotes/core and @fieldnotes/react are versioned independently. The react package's peerDependencies declare the compatible core range. Pre-1.0, minor versions may contain breaking changes. The core peer range is bounded at the next major rather than per-minor; if a core minor ever breaks the wrapper, a coordinated react release raises the lower bound.

License

MIT