Documentation

Pro handbook

Toolbar

Toolbar is the state of a tool bar, not a UI. It knows which tools exist, which one is active, and which key selects which. What the buttons look like is up to you: the library draws no buttons.

const toolbar = new Toolbar(editor, defaultToolbarEntries());
toolbar.select('draw');      // activate a tool by name
toolbar.active;              // 'draw' — or null
const off = toolbar.on((active) => { /* redraw your buttons */ });
toolbar.destroy();           // unsubscribe from the editor

Entries#

A ToolbarEntry has four fields:

Field Type Meaning
name string The mode's name; must equal the name of the mode that create returns.
label string Text for your button.
shortcut string (optional) One character that selects the tool, compared case-insensitively.
create () => Mode Returns a new mode instance on every call.

defaultToolbarEntries() returns the 21 built-in tools listed on Modes (vectorstream Pro's mitProWerkzeugen adds two), with labels in the language current when you call it (see Language). The constructor throws an Error if two entries share a name or a key, or if a shortcut is longer than one character.

Behavior#

  • select(name) builds a fresh mode with create() and sets it on the editor — also when that tool is already active, which is the way to get rid of a stuck gesture. An unknown name throws.
  • Shortcut keys work through the editor's keyunhandled event: a key reaches the toolbar unless the active mode consumes it (returns true from onKey), and selects a tool only without Ctrl, Alt or Cmd (Shift does not matter). The toolbar then does nothing if the active mode reports an unfinished gesture through the optional Mode.laeuftEtwas() — points collected toward a ring, a drag in progress, but also the first click of split or union, or an open input in text — so a shortcut letter is ignored instead of discarding whatever is in progress; Escape still aborts, through the mode itself. A mode without laeuftEtwas() behaves as before: the shortcut switches right away. Keys typed into input fields never reach the editor.
  • active follows the editor, not only the toolbar: if your code calls editor.setMode(…) with a mode whose name is in the list, that entry becomes active; with any other mode, active is null, so no button claims to be pressed.
  • on(listener) reports every change of active (from a click, a key or setMode). It returns a function that unsubscribes.
  • destroy() unsubscribes from the editor; afterwards select does nothing.

Drawing buttons#

One button per entry, the active one marked with aria-pressed:

import type { Toolbar } from '@vectorstream/editor';

/** One button per toolbar entry; the active one is marked with `aria-pressed`. */
export function renderToolbar(toolbar: Toolbar, parent: HTMLElement): () => void {
  const buttons = new Map<string, HTMLButtonElement>();
  for (const entry of toolbar.entries) {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = entry.label;
    if (entry.shortcut !== undefined) button.title = `${entry.label} (${entry.shortcut.toUpperCase()})`;
    button.addEventListener('click', () => toolbar.select(entry.name));
    parent.append(button);
    buttons.set(entry.name, button);
  }

  const highlight = (active: string | null): void => {
    for (const [name, button] of buttons) button.setAttribute('aria-pressed', String(name === active));
  };
  highlight(toolbar.active);
  // Also fires when the mode changes by shortcut key or by `editor.setMode`.
  const off = toolbar.on(highlight);

  return () => {
    off();
    for (const button of buttons.values()) button.remove();
  };
}

Its test clicks a button, presses shortcut keys (including Ctrl+R, which must not switch tools), and switches modes through editor.setMode, checking the pressed button each time.

Toolbar UI#

@vectorstream/toolbar-ui is the ready-made, further built-out version of the example above — an optional, separately installed package, built on top of Toolbar rather than inside it; the separation above (state here, UI nowhere in this package) stays exactly as it is.

import { defaultToolbarEntries, Toolbar } from '@vectorstream/editor';
import { GUIDE_LINES_ICON, renderToggleButton, renderToolbarButtons } from '@vectorstream/toolbar-ui';

const toolbar = new Toolbar(editor, defaultToolbarEntries());
const off = renderToolbarButtons(toolbar, parent, {
  groups: [['draw', 'draw-line', 'draw-point'], ['edit', 'transform']],
});

// A toggle for state outside Toolbar entirely — snapping, guide lines, tracing, one snap-target
// kind — the same button/label/aria-pressed bookkeeping `renderToolbarButtons` has, generalized
// to any boolean instead of a `ToolbarEntry`.
const guides = renderToggleButton({
  icon: GUIDE_LINES_ICON,
  onLabel: 'Guide lines: on',
  offLabel: 'Guide lines: off',
  active: () => editor.hilfslinienAktiv,
  onToggle: () => { editor.hilfslinienAktiv = !editor.hilfslinienAktiv; },
});
parent.append(guides.element);
  • renderToolbarButtons(toolbar, parent, options?) — one button per entry, exactly like renderToolbar above, plus optional groups (a separator between each, none before the first or after the last; an entry no group names is appended at the end instead of disappearing) and an optional icon resolver (default: toolIcon, below). Returns the same kind of cleanup function.
  • renderToggleButton(options) — one on/off button for a boolean outside Toolbar (active/onToggle); returns the element and a refresh() for a state change from elsewhere.
  • TOOL_ICONS (Record<ModeName, string>) and toolIcon(name) — one hand-drawn SVG path per tool defaultToolbarEntries() lists, nothing more: a stroke-only path in a 20×20 field, so it inherits currentColor from the button's own CSS instead of carrying a fixed color. toolIcon falls back to the pointer's icon (with a console warning) for a name it does not cover — ModeName is a plain string, so this cannot be checked at compile time.
  • MAGNET_ICON, GUIDE_LINES_ICON, TRACE_ICON, CORNER_ICON, MIDPOINT_ICON, QUARTER_ICON, EDGE_ICON — icons for the snapping toggles. These are states, not tools, so none of them is a ModeName and none lives in TOOL_ICONS; pass one to renderToggleButton or to the plain renderIcon(markup, size?) helper directly.
  • Structure only. Every element this package creates carries one of three class names (toolbar-ui-button, toolbar-ui-separator, toolbar-ui-toggle) and nothing else — no color, no spacing, no layout. Styling and positioning stay the embedding page's own CSS, the same as active/inactive already rides on aria-pressed rather than a class this package would own.
import type { Editor } from '@vectorstream/editor';
import { defaultToolbarEntries, Toolbar } from '@vectorstream/editor';
import { GUIDE_LINES_ICON, renderToggleButton, renderToolbarButtons } from '@vectorstream/toolbar-ui';

/**
 * Three built-in tools, grouped, plus the guide-lines toggle — the same pieces
 * `app/src/werkzeuge.ts` hand-builds for this repository's own application, here from the
 * package instead. Returns the `Toolbar` and a function that undoes everything this call did.
 */
export function createToolbarUi(editor: Editor, parent: HTMLElement): { toolbar: Toolbar; destroy: () => void } {
  const entries = defaultToolbarEntries().filter((e) => ['draw', 'draw-line', 'edit', 'transform'].includes(e.name));
  const toolbar = new Toolbar(editor, entries);
  const offButtons = renderToolbarButtons(toolbar, parent, {
    groups: [['draw', 'draw-line'], ['edit', 'transform']],
  });

  const guideLines = renderToggleButton({
    icon: GUIDE_LINES_ICON,
    onLabel: 'Guide lines: on',
    offLabel: 'Guide lines: off',
    active: () => editor.hilfslinienAktiv,
    onToggle: () => {
      editor.hilfslinienAktiv = !editor.hilfslinienAktiv;
    },
  });
  parent.append(guideLines.element);

  return {
    toolbar,
    destroy: () => {
      offButtons();
      guideLines.element.remove();
      toolbar.destroy();
    },
  };
}

Its test builds the four grouped buttons plus the toggle, clicks one of each, and checks that destroy() removes every element and leaves the Toolbar unusable afterward (matching Toolbar.destroy()'s own contract).

Not included: the small panel icons (layer visibility, lock, reorder) app/src/ikonen.ts also draws — those belong to a layer tree, not a toolbar, and have no Toolbar/ModeName connection for this package to build on.

Your own tool list#

The default list is only a default. Build your own from the same mode classes — with your labels, keys and options, and only the tools you want:

import {
  DrawCircleMode,
  DrawMode,
  EditMode,
  RegularizeMode,
  SimplifyMode,
  StampMode,
  TransformMode,
  type ToolbarEntry,
} from '@vectorstream/editor';

/** A house outline of unit size around the origin, for the stamp. */
const HOUSE = [
  { x: -1, y: -1 },
  { x: 1, y: -1 },
  { x: 1, y: 0.4 },
  { x: 0, y: 1 },
  { x: -1, y: 0.4 },
];

/** Your own tool list: your labels, your keys, your options. `create` must return a fresh mode. */
export function myToolbarEntries(): ToolbarEntry[] {
  return [
    { name: 'draw', label: 'Polygon', shortcut: 'p', create: () => new DrawMode() },
    { name: 'draw-circle', label: 'Circle', shortcut: 'c', create: () => new DrawCircleMode({ sides: 64 }) },
    { name: 'edit', label: 'Vertices', shortcut: 'a', create: () => new EditMode() },
    {
      name: 'transform',
      label: 'Move, rotate, scale',
      shortcut: 'v',
      create: () => {
        const mode = new TransformMode();
        mode.nachbarnFolgen = true; // neighbors follow: shared boundaries move along
        return mode;
      },
    },
    { name: 'simplify', label: 'Simplify', create: () => new SimplifyMode({ tolerance: 0.00005 }) },
    {
      name: 'regularize',
      label: 'Straighten',
      shortcut: 'g',
      create: () => new RegularizeMode({ winkelGrad: 10, zackenMeter: 0.25, knickMeter: 1 }),
    },
    {
      name: 'stamp-house',
      label: 'House',
      shortcut: '1',
      // A click without dragging scales the unit template by `defaultSize` (world units — here
      // degrees); dragging sets size and rotation instead.
      create: () => new StampMode({ template: HOUSE, defaultSize: 0.0002, name: 'stamp-house' }),
    },
  ];
}

A tool can have no shortcut; it is then reachable only through your button.

API: Toolbar, ToolbarEntry, defaultToolbarEntries, renderToolbarButtons, renderToggleButton, toolIcon.

See it on a map.