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

@heyramzi/canvas

v0.1.0

Published

A Svelte 5 canvas on xyflow with a hand-drawing pen, a plugin API for custom cards and ops, and headless ops for servers and CLIs.

Readme

@heyramzi/canvas

A canvas for Svelte 5 and SvelteKit, built on @xyflow/svelte. Cards and arrows from xyflow, a hand-drawing pen on top, and a plugin API for your own card types.

Strokes, text and shapes are ordinary xyflow nodes. So undo, copy and save work with no extra code. The same ops run on a server or in a CLI, with no browser.

MIT. It runs on any domain and costs nothing per install.

What's in it

  • Pen layer. Freehand strokes (perfect-freehand), a highlighter, an eraser, plain text, and rectangle, ellipse and diamond shapes. Colours are token names, so a board drawn in light mode reads fine in dark.
  • Tool bar. A floating bar with the tools, undo and redo. Pick from 6 colours and 3 brush sizes. Keys: V, H, P, M, E, T, R, O, D.
  • Plugin API. Register your own card types, ops and layout passes. One registry builds xyflow's nodeTypes for you.
  • Ops. 8 core ops (addNode, removeNode, patchNode, moveNode, setParent, addEdge, removeEdge, patchEdge) as plain functions over nodes and edges. Zod checks every one.
  • Undo history. createCanvasDispatcher wraps each edit in a transaction. A whole pen stroke is one undo step.
  • Headless. Everything that doesn't need a DOM, for servers and CLIs.

Install

npm install @heyramzi/canvas @xyflow/svelte svelte

Peers: svelte ^5.57.2 and @xyflow/svelte ^1.7.0.

The package ships Svelte source, so your app needs @sveltejs/vite-plugin-svelte. A SvelteKit app has it already.

Use it in SvelteKit

Drop this in src/routes/+page.svelte.

<script lang="ts">
  import { Background, SvelteFlow, type Edge, type Node } from "@xyflow/svelte";
  import "@xyflow/svelte/dist/style.css";
  import { createCanvasDispatcher, createRegistry } from "@heyramzi/canvas";
  import { penUiPlugin } from "@heyramzi/canvas/pen/ui.js";
  import type { PenColor } from "@heyramzi/canvas/pen/index.js";
  import {
    brushSize,
    DEFAULT_BRUSH_LEVEL,
    isDrawingTool,
    type CanvasTool,
  } from "@heyramzi/canvas/ui/tools.js";
  import CanvasToolbar from "@heyramzi/canvas/ui/CanvasToolbar.svelte";
  import PenLayer from "@heyramzi/canvas/ui/PenLayer.svelte";

  const registry = createRegistry([penUiPlugin]);

  let nodes = $state.raw<Node[]>([]);
  let edges = $state.raw<Edge[]>([]);
  let tool = $state<CanvasTool>("select");
  let color = $state<PenColor>("ink");
  let level = $state(DEFAULT_BRUSH_LEVEL);

  const history = createCanvasDispatcher({
    getState: () => ({ nodes, edges }),
    setState: (next) => {
      nodes = next.nodes;
      edges = next.edges;
    },
    // Save here. It fires once per commit, undo and redo.
    onMutated: () => {},
    opDefinitions: [...registry.ops.values()],
  });

  const labels = {
    toolbar: "Canvas tools",
    style: "Style",
    tools: {
      select: "Select", hand: "Hand", pen: "Pen", highlighter: "Highlighter",
      eraser: "Eraser", text: "Text", rectangle: "Rectangle", ellipse: "Ellipse",
      diamond: "Diamond", shapes: "Shapes",
    },
    colors: { ink: "Ink", red: "Red", blue: "Blue", green: "Green", amber: "Amber", violet: "Violet" },
    sizes: ["Small", "Medium", "Large"],
    undo: "Undo",
    redo: "Redo",
  } as const;
</script>

<div style="height: 100vh">
  <SvelteFlow bind:nodes bind:edges nodeTypes={registry.nodeTypes()} fitView>
    <Background />
    {#if isDrawingTool(tool)}
      <PenLayer
        {tool}
        {color}
        size={brushSize(tool, level)}
        {nodes}
        commit={(ops) => history.dispatch(ops)}
        startGesture={() => {
          history.begin();
          return history.commit;
        }}
      />
    {/if}
    <CanvasToolbar
      bind:tool
      bind:color
      bind:level
      {labels}
      canUndo={history.canUndo}
      canRedo={history.canRedo}
      onUndo={history.undo}
      onRedo={history.redo}
    />
  </SvelteFlow>
</div>

Pick the pen tool, drag, and you've drawn a stroke. Press undo and it's gone. The whole gesture is one step.

PenLayer and CanvasToolbar both read xyflow's store, so they go inside <SvelteFlow>. The tool bar takes a labels object because the package has no i18n. Pass your own strings.

Components import by path:

  • @heyramzi/canvas/ui/CanvasToolbar.svelte
  • @heyramzi/canvas/ui/PenLayer.svelte
  • @heyramzi/canvas/ui/canvas-grid.svelte, a dot grid that fades as you zoom out

The tool bar's styles read your theme tokens (--card, --border, --foreground, --ring) and fall back to plain colours when you have none.

The plugin API

A plugin is a name plus node kinds, ops and layout passes. Write it once for the server, then add the Svelte component for the browser. That's how the pen does it: penPlugin has no DOM, and penUiPlugin adds the renderers.

// invoice-plugin.ts, safe on a server
import { z } from "zod";
import { definePlugin, patchData, type OpState } from "@heyramzi/canvas";

const setTotal = z.object({
  kind: z.literal("setTotal"),
  nodeId: z.string().min(1),
  total: z.number(),
});

function applySetTotal(state: OpState, op: z.infer<typeof setTotal>, opIndex: number) {
  patchData(state, op.nodeId, opIndex, () => ({ total: op.total }));
}

export const invoicePlugin = definePlugin({
  id: "invoice",
  nodes: [
    {
      type: "invoice",
      label: "Invoice",
      size: { width: 240 },
      palette: { group: "Billing", order: 1 },
      createData: () => ({ total: 0 }),
    },
  ],
  ops: [
    { kind: "setTotal", schema: setTotal, apply: applySetTotal },
  ],
});
// invoice-ui.ts, browser only
import { definePlugin } from "@heyramzi/canvas";
import InvoiceCard from "./InvoiceCard.svelte";
import { invoicePlugin } from "./invoice-plugin";

export const invoiceUiPlugin = definePlugin({
  ...invoicePlugin,
  nodes: (invoicePlugin.nodes ?? []).map((kind) => ({ ...kind, component: InvoiceCard })),
});

Then createRegistry([penUiPlugin, invoiceUiPlugin]) gives you:

  • nodeTypes(), the map xyflow wants.
  • ops, to pass as opDefinitions to the dispatcher or applyOps.
  • parseOp(input), which checks one op against its kind's schema.
  • pickLayout(data), the first layout pass whose matches says yes.
  • contentSizedTypes() and layoutOwnedTypes(), so a layout knows which heights the DOM owns.

A node type or op kind can have only one owner. If a second plugin claims one, createRegistry throws and names both.

A node kind can also set validParents (which containers it may sit in) and sizing. A layout pass is { id, matches, run }, and run returns the new nodes and edges.

The pen

A stroke is a node with type: "stroke". Its data holds the simplified points, a colour token, a size and a tool. Text is "text" and a shape is "shape". They're real nodes, so the eraser, selection, copy and your save code treat them like cards.

The pen is PenOverlay (or PenLayer inside xyflow), and it needs 2 callbacks from you:

  • commit(ops) receives addNode and removeNode ops. Send them to history.dispatch.
  • startGesture() opens an undo transaction and returns the function that closes it. A stroke is a single history step.

The eraser removes whole strokes, never part of one. A swipe across a big shape doesn't wipe it.

The pen has 6 colours: ink, red, blue, green, amber and violet. pen.css maps them to CSS variables with a light and a dark value. Dark mode applies under a .dark class on an ancestor or on .svelte-flow.

Headless ops

Import from @heyramzi/canvas on a server, in a worker or in a CLI. The root export touches no DOM.

import { applyOps, createRegistry, type RawCanvasData } from "@heyramzi/canvas";
import { penPlugin } from "@heyramzi/canvas/pen/index.js";
import { compilePenMark, penMarkSchema } from "@heyramzi/canvas/headless/index.js";

const registry = createRegistry([penPlugin]);

let board: RawCanvasData = { nodes: [], edges: [] };

// Ops from a request body or a CLI flag. An unknown kind or a bad shape throws.
const ops = [
  { kind: "addShape", shape: "rectangle", box: { x: 0, y: 0, width: 200, height: 120 }, label: "Intake" },
  { kind: "addText", at: { x: 20, y: 160 }, text: "Where leads land" },
].map((op) => registry.parseOp(op));

board = applyOps(board, ops, [...registry.ops.values()]);

applyOps is pure. It returns a new board and never edits the one you pass. A bad op throws a CanvasOpError with the index of the op that failed, so you know which one in a list of 57.

For a whole drawing in one call, write pen marks as JSON. There are 3 kinds: stroke, text and shape. They compile to the same nodes the pen tool draws.

const marks = penMarkSchema.array().parse(JSON.parse(input));
const frames = new Map(); // frame id to { x, y }, if a mark uses `in`
const nodes = marks.map((mark, i) => compilePenMark(mark, i, frames));
board = { ...board, nodes: [...board.nodes, ...nodes] };

A mark with no id gets pen-<n> from its place in the list. Run the same file twice and you get the same ids.

The root export also has the pure helpers the editor uses: edge anchors, alignment, snap guides, fit-to-viewport, table layout, SVG figure sanitising and the sketch (rough.js) geometry. Import what you need.

Develop

Tests run in jsdom, so no browser.

pnpm install
pnpm test          # vitest in jsdom
pnpm check         # svelte-check
pnpm package       # builds dist with svelte-package

License

MIT, 2026 Ramzi (heyramzi). See LICENSE.