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 withcreate()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
keyunhandledevent: a key reaches the toolbar unless the active mode consumes it (returnstruefromonKey), 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 optionalMode.laeuftEtwas()— points collected toward a ring, a drag in progress, but also the first click ofsplitorunion, or an open input intext— so a shortcut letter is ignored instead of discarding whatever is in progress;Escapestill aborts, through the mode itself. A mode withoutlaeuftEtwas()behaves as before: the shortcut switches right away. Keys typed into input fields never reach the editor. activefollows the editor, not only the toolbar: if your code callseditor.setMode(…)with a mode whose name is in the list, that entry becomes active; with any other mode,activeisnull, so no button claims to be pressed.on(listener)reports every change ofactive(from a click, a key orsetMode). It returns a function that unsubscribes.destroy()unsubscribes from the editor; afterwardsselectdoes 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 likerenderToolbarabove, plus optionalgroups(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 optionaliconresolver (default:toolIcon, below). Returns the same kind of cleanup function.renderToggleButton(options)— one on/off button for a boolean outsideToolbar(active/onToggle); returns the element and arefresh()for a state change from elsewhere.TOOL_ICONS(Record<ModeName, string>) andtoolIcon(name)— one hand-drawn SVG path per tooldefaultToolbarEntries()lists, nothing more: a stroke-only path in a 20×20 field, so it inheritscurrentColorfrom the button's own CSS instead of carrying a fixed color.toolIconfalls back to the pointer's icon (with a console warning) for a name it does not cover —ModeNameis a plainstring, 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 aModeNameand none lives inTOOL_ICONS; pass one torenderToggleButtonor to the plainrenderIcon(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 asactive/inactivealready rides onaria-pressedrather 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.