WorkspaceCanvas toolbar
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callCanvas toolbar
A floating toolbar for a workflow canvas: run, pick a tool, comment, fit and zoom. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add canvas-toolbarReact
npx designme add canvas-toolbar --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Canvas toolbar is the floating toolbar of a workflow canvas, fixed at the bottom centre of a canvas that fills the component. It has Run (which turns into a red Stop while a run is going), the pointer tools Select, Hand and Lasso, Comment, Fit to screen, and zoom out and in with the zoom level between them. Every icon button shows its shortcut in its tooltip, and the shortcuts work while the canvas has the focus.
- When people build or edit an agent workflow on a canvas and need to move around it, pick steps and run it.
- When a run should be started and stopped from the same place people navigate the canvas from.
- When the canvas needs the usual canvas shortcuts, so people who know other editors can work without the mouse.
- Not for a short flow that fits on screen and never needs panning or zooming: Automation canvas is enough then.
- Run starts a run and turns into Stop, on a red fill, until the run ends. In the demo the steps light up one after another along their wires and the run stops by itself; in React,
onRunandonStopcall you, and you setrunning, each node'sstateand thelitwires as your run goes. - Select, Hand and Lasso are one radio group. Select picks a step with a click (Shift adds to the pick), moves the picked steps by dragging, and draws a box round steps on empty canvas; Hand pans; Lasso picks the steps inside the shape you draw. The middle mouse button pans with any tool.
- Comment is a toggle: the next click on the canvas drops a numbered pin there and turns it off. The HTML fires
canvas-commentwith the spot; in ReactonCommentgets it, andcommentsholds the pins. - In HTML a pin is a button: a click on it opens its comment, which says the step it sits on or is near, with a Delete button. Delete, or the Delete or Backspace key on a focused pin, fades the pin out and fires
canvas-comment-deletewith its number. - Fit to screen fits every step in the room above the toolbar, at most 150%. On arrival, and when the canvas switches between wide and narrow, the view fits the steps at no more than 100%.
- Zoom out and Zoom in step through 25%, 33%, 50%, 67%, 75%, 90%, 100%, 125%, 150%, 200%, 300% and 400% round the middle of the view (
zoomStepschanges them). The level between them resets to 100%. Ctrl or ⌘ with the scroll wheel, or a trackpad pinch, zooms at the pointer. - Shortcuts: V Select, H Hand, L Lasso, C Comment, Shift+1 fit, Shift+0 100%, minus and plus zoom, the arrow keys pan the canvas (Shift for bigger steps), and Escape leaves Comment or clears the selection. They work while the focus is on the canvas or on the toolbar.
- The toolbar stays where it is, at the bottom centre. The canvas is the whole component, edge to edge, with its dots all the way out, and fills the height it is given, or is 560px tall (520px below 440px wide) when nothing gives it one.
- The canvas never traps the page: until someone has clicked or tapped in the component, or moved the keyboard focus into it, a finger on the canvas scrolls the page as usual. After that a finger works the tool, as the mouse does. The scroll wheel zooms the canvas only with Ctrl or ⌘, so without them it always scrolls the page.
- The toolbar stays on one line. Below 440px wide, Comment and Fit to screen move into a More menu; below 360px, the zoom buttons and the level move there too.
- Below 440px wide the HTML lays the steps out in a single column, so the whole workflow fits a phone at a size that reads, and the demo pans and zooms out there instead of in, so no step leaves the canvas.
- In HTML the component fires
canvas-run,canvas-stop(withfinished),canvas-tool,canvas-zoom,canvas-commentandcanvas-comment-deleteevents. In React,tool,commenting,view,selectedand the comments are each controlled or left to the component, and a ref canpanBy,zoomIn,zoomOut,zoomToandfit.
- The canvas is a focusable group with the role description "canvas" and a label, described by a line that lists its shortcuts. A click on it gives it the focus.
- The toolbar has
role="toolbar"and the label Canvas tools. Each icon button is labelled and shows its name and shortcut in a tooltip on hover and on keyboard focus. - Select, Hand and Lasso are a
radiogroupofradiobuttons witharia-checked, and a single tab stop: the arrow keys move through them and pick each one, wrapping at the ends, and Home and End jump to the ends. - Comment reports
aria-pressed. Zoom out and Zoom in arearia-disabledat the ends of the range, so they keep their place and the focus. The level's name says the current zoom and that it resets to 100%. - More is a menu button: Enter, Space or the down arrow open it on its first item, the arrows move and wrap, Escape closes it and gives the focus back. Comment is a
menuitemcheckboxthere. - In HTML each pin is a button labelled with its number, like "Comment 2", with
aria-haspopup="dialog"andaria-expanded. Enter or Space opens its comment, a dialog, with the focus on Delete, which is labelled "Delete comment 2". Escape closes it and gives the focus back to the pin; after a delete the focus goes to the canvas. - An
aria-live="polite"region says the tool picked, the zoom level, how many steps are selected, where a comment went, that a comment was deleted, and when a run starts, finishes or stops. - With reduced motion on, the view moves at once instead of gliding.
Usage
Import it and drive it from your own state.
Import
import { CanvasToolbar } from '@/components/designme/canvas-toolbar/CanvasToolbar';Example
The demo above as a React component, 55 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { CanvasToolbar, type CanvasToolbarEdge, type CanvasToolbarHandle, type CanvasToolbarNode } from './CanvasToolbar';
import { useScript, useStory } from '../lib/useScript';
// a support workflow: a row on a wide canvas, two by two on a narrow one
const NODES: CanvasToolbarNode[] = [
{ id: 'a', icon: 'bolt', color: 'var(--dm-py)', title: 'New ticket', detail: 'Trigger · inbox', x: 0, y: 48, narrow: { x: 0, y: 0 } },
{ id: 'b', icon: 'spark', color: 'var(--dm-accent-mark)', iconColor: 'var(--dm-accent-fg)', title: 'Classify', detail: 'Agent · intent', x: 208, y: 0, narrow: { x: 184, y: 0 } },
{ id: 'c', icon: 'search', color: 'var(--dm-pb)', title: 'Search docs', detail: 'Tool · help center', x: 416, y: 96, narrow: { x: 184, y: 104 } },
{ id: 'd', icon: 'edit', color: 'var(--dm-pm)', title: 'Draft reply', detail: 'Agent · for review', x: 624, y: 48, narrow: { x: 0, y: 104 } }
];
const EDGES: CanvasToolbarEdge[] = [['a', 'b'], ['b', 'c'], ['c', 'd']];
/* ---------- Canvas toolbar: take the hand and drag the workflow aside, zoom in twice, fit it back, then run it ---------- */
export default function CanvasToolbarDemo() {
const ref = useRef<HTMLDivElement>(null), canvas = useRef<CanvasToolbarHandle>(null);
const [run, setRun] = useState(0);
const [states, setStates] = useState<Record<string, CanvasToolbarNode['state']>>({});
const [lit, setLit] = useState<CanvasToolbarEdge[]>([]);
// Run lights each step in turn along its wire, then stops by itself; Stop ends it early
useScript(async ({ wait }) => {
// stopped: the step that was running goes back to waiting, the ones done keep their tick
if (run < 0) { setStates(s => Object.fromEntries(Object.entries(s).filter(([, v]) => v === 'done'))); return; }
if (!run) return;
setStates({}); setLit([]);
for (let i = 0; i < NODES.length; i++) {
const id = NODES[i].id;
setStates(s => ({ ...s, [id]: 'run' })); await wait(i ? 700 : 450); setStates(s => ({ ...s, [id]: 'done' }));
if (i < EDGES.length) { setLit(l => [...l, EDGES[i]]); await wait(250); }
}
await wait(500); setRun(0);
}, [run]);
// the preview's story. Your first click or key stops it, and the canvas is yours from wherever it got to
useStory(ref, async ({ wait, press }) => {
await wait(1500); press('.dm-ctb-tools [data-t="hand"]');
await wait(1000); canvas.current?.panBy(-140, 24);
await wait(1500); canvas.current?.zoomIn();
await wait(800); canvas.current?.zoomIn();
await wait(1000); canvas.current?.fit();
await wait(1000); press('.dm-ctb-tools [data-t="select"]');
await wait(500); press('.dm-ctb-run');
});
return (
<div ref={ref} style={{ width: '100%' }}>
<CanvasToolbar ref={canvas} nodes={NODES.map(n => ({ ...n, state: states[n.id] }))} edges={EDGES} lit={lit}
running={run > 0} onRun={() => setRun(r => Math.abs(r) + 1)} onStop={() => setRun(r => -Math.abs(r) - 1)} />
</div>
);
}
Props
Everything CanvasToolbar takes.
| Prop | Type | What it does |
|---|---|---|
nodesrequired | CanvasToolbarNode[] | |
edges | CanvasToolbarEdge[] | |
lit | CanvasToolbarEdge[] | The wires a run has reached; they light up in the accent |
running | boolean | Whether a run is going: Run turns into Stop, on a red fill |
onRun | () => void | Called by Run. The run is yours: set `running`, each node's state and `lit` as it goes, and `running` back to false |
onStop | () => void | Called by Stop |
runLabel | string | |
stopLabel | string | |
tool | CanvasTool | The pointer tool. Leave it out to let the toolbar and V, H and L pick it |
defaultTool | CanvasTool | |
onToolChange | (tool: CanvasTool) => void | |
commenting | boolean | Whether Comment is on: the next click on the canvas places a comment there |
defaultCommenting | boolean | |
onCommentingChange | (on: boolean) => void | |
onComment | (at: CanvasPoint) => void | Called where a comment was placed, in canvas pixels at 100%. The pins it shows are `comments` |
comments | CanvasPoint[] | The pins on the canvas. Leave it out to let the canvas keep the ones placed |
view | CanvasView | null | Where the canvas looks. null fits every step in the room above the toolbar (at most 100%), which it does until someone pans or zooms, and again when the canvas switches between wide and narrow. Leave it out to let the canvas keep it |
defaultView | CanvasView | null | |
onViewChange | (view: CanvasView | null) => void | |
selected | string[] | The selected steps, by id |
defaultSelected | string[] | |
onSelectedChange | (ids: string[]) => void | |
onNodesMove | (moves: Record<string, CanvasPoint>, narrow: boolean) => void | Called when steps are dropped after a drag with Select, with where each now sits and whether the canvas was narrow |
zoomSteps | number[] | The zoom levels the buttons and keys step through, from smallest to largest. Defaults to 25% to 400% |
label | string | The canvas's accessible name |
ref | Ref<CanvasToolbarHandle> | Moves the view from your code, as the buttons and keys do: `panBy`, `zoomIn`, `zoomOut`, `zoomTo`, `fit` |
children | ReactNode | More, drawn on the canvas under the steps, in canvas pixels at 100% |
className | string |
Code
The full source, as the CLI installs it.
CanvasToolbar.tsxReact · 382 lines
'use client';
import { useEffect, useId, useImperativeHandle, useRef, useState, type FocusEvent as ReactFocusEvent, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type Ref } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect, useReducedMotion } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './canvas-toolbar.css';
/** The pointer tool: Select picks and moves steps or draws a box round them, Hand pans, Lasso draws a shape round what it picks */
export type CanvasTool = 'select' | 'hand' | 'lasso';
/** Where the canvas looks: a point at (x, y) on the canvas shows at x * zoom + panX, y * zoom + panY */
export interface CanvasView {
panX: number;
panY: number;
/** 1 is 100% */
zoom: number;
}
/** A spot on the canvas, in canvas pixels at 100% */
export interface CanvasPoint {
x: number;
y: number;
}
/** One step on the canvas, drawn as a card */
export interface CanvasToolbarNode {
id: string;
icon: IconName;
/** The icon's colour, like 'var(--dm-pb)'. Its tile is a light tint of it */
color: string;
/** The icon's own colour when it differs from its tile's, like 'var(--dm-accent-fg)' for an accent tile */
iconColor?: string;
/** Like "Classify" */
title: string;
/** The quiet line under the title, like "Agent · intent" */
detail?: string;
/** Where its top left corner sits, in canvas pixels at 100% */
x: number;
y: number;
/** Where it sits when the canvas is narrower than 440px. Defaults to `x` and `y` */
narrow?: CanvasPoint;
/** 'run' rings it with a spinner, 'done' ticks it */
state?: 'run' | 'done';
}
/** A wire from one step to another, by id */
export type CanvasToolbarEdge = [from: string, to: string];
/** What a ref on CanvasToolbar can do */
export interface CanvasToolbarHandle {
/** Moves the view by this many screen pixels, gliding */
panBy: (dx: number, dy: number) => void;
zoomIn: () => void;
zoomOut: () => void;
/** Zooms to a level, 1 being 100%, round the middle of the room above the toolbar */
zoomTo: (zoom: number) => void;
/** Fits every step in the room above the toolbar, at most 150% */
fit: () => void;
}
export interface CanvasToolbarProps {
nodes: CanvasToolbarNode[];
edges?: CanvasToolbarEdge[];
/** The wires a run has reached; they light up in the accent */
lit?: CanvasToolbarEdge[];
/** Whether a run is going: Run turns into Stop, on a red fill */
running?: boolean;
/** Called by Run. The run is yours: set `running`, each node's state and `lit` as it goes, and `running` back to false */
onRun?: () => void;
/** Called by Stop */
onStop?: () => void;
runLabel?: string;
stopLabel?: string;
/** The pointer tool. Leave it out to let the toolbar and V, H and L pick it */
tool?: CanvasTool;
defaultTool?: CanvasTool;
onToolChange?: (tool: CanvasTool) => void;
/** Whether Comment is on: the next click on the canvas places a comment there */
commenting?: boolean;
defaultCommenting?: boolean;
onCommentingChange?: (on: boolean) => void;
/** Called where a comment was placed, in canvas pixels at 100%. The pins it shows are `comments` */
onComment?: (at: CanvasPoint) => void;
/** The pins on the canvas. Leave it out to let the canvas keep the ones placed */
comments?: CanvasPoint[];
/** Where the canvas looks. null fits every step in the room above the toolbar (at most 100%), which it does until someone
* pans or zooms, and again when the canvas switches between wide and narrow. Leave it out to let the canvas keep it */
view?: CanvasView | null;
defaultView?: CanvasView | null;
onViewChange?: (view: CanvasView | null) => void;
/** The selected steps, by id */
selected?: string[];
defaultSelected?: string[];
onSelectedChange?: (ids: string[]) => void;
/** Called when steps are dropped after a drag with Select, with where each now sits and whether the canvas was narrow */
onNodesMove?: (moves: Record<string, CanvasPoint>, narrow: boolean) => void;
/** The zoom levels the buttons and keys step through, from smallest to largest. Defaults to 25% to 400% */
zoomSteps?: number[];
/** The canvas's accessible name */
label?: string;
/** Moves the view from your code, as the buttons and keys do: `panBy`, `zoomIn`, `zoomOut`, `zoomTo`, `fit` */
ref?: Ref<CanvasToolbarHandle>;
/** More, drawn on the canvas under the steps, in canvas pixels at 100% */
children?: ReactNode;
className?: string;
}
const NARROW = 440;
const STEPS = [.25, .33, .5, .67, .75, .9, 1, 1.25, 1.5, 2, 3, 4];
const NAMES: Record<CanvasTool, string> = { select: 'Select', hand: 'Hand', lasso: 'Lasso' };
const TOOLS: { t: CanvasTool; icon: IconName; tip: string; key: string }[] = [
{ t: 'select', icon: 'cursor', tip: 'Select', key: 'V' }, { t: 'hand', icon: 'hand', tip: 'Pan', key: 'H' }, { t: 'lasso', icon: 'lasso', tip: 'Lasso select', key: 'L' }
];
const inside = ([x, y]: number[], poly: number[][]) => {
let k = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const [xi, yi] = poly[i], [xj, yj] = poly[j]; if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) k = !k; }
return k;
};
const same = (a: CanvasToolbarEdge, b: CanvasToolbarEdge) => a[0] === b[0] && a[1] === b[1];
/** A workflow canvas with a floating toolbar fixed at the bottom centre: Run and Stop, the pointer tools as one radio group,
* Comment, Fit to screen and zoom, each with its shortcut while the canvas has the focus. The canvas fills the height it is
* given (560px, or 520px below 440px wide, when nothing gives it one). Below 440px Comment and Fit move into More, and below
* 360px the zoom buttons follow. */
export function CanvasToolbar({
nodes, edges = [], lit = [], running = false, onRun, onStop, runLabel = 'Run', stopLabel = 'Stop',
tool: toolProp, defaultTool = 'select', onToolChange, commenting: cmProp, defaultCommenting = false, onCommentingChange, onComment, comments,
view: viewProp, defaultView = null, onViewChange, selected, defaultSelected = [], onSelectedChange, onNodesMove,
zoomSteps = STEPS, label = 'Workflow canvas', ref, children, className
}: CanvasToolbarProps) {
const [tool, setToolRaw] = useControllable(toolProp, defaultTool, onToolChange);
const [commenting, setCommenting] = useControllable(cmProp, defaultCommenting, onCommentingChange);
const [viewSet, setView] = useControllable<CanvasView | null>(viewProp, defaultView, onViewChange);
const [sel, setSel] = useControllable(selected, defaultSelected, onSelectedChange);
const [pins, setPins] = useState<CanvasPoint[]>([]);
const shownPins = comments ?? pins;
const [say, setSay] = useState('');
const reduce = useReducedMotion();
const uid = useId();
const root = useRef<HTMLDivElement>(null), cv = useRef<HTMLDivElement>(null), bar = useRef<HTMLDivElement>(null);
const els = useRef<Record<string, HTMLDivElement | null>>({});
const [size, setSize] = useState({ w: 0, h: 0, bar: 40 });
const [dims, setDims] = useState<Record<string, { w: number; h: number }>>({});
const narrow = size.w > 0 && size.w < NARROW;
// where people dragged the steps, kept apart for the wide and the narrow layout
const [moved, setMoved] = useState<{ wide: Record<string, CanvasPoint>; narrow: Record<string, CanvasPoint> }>({ wide: {}, narrow: {} });
const pos = (n: CanvasToolbarNode): CanvasPoint => (narrow ? moved.narrow[n.id] ?? n.narrow : moved.wide[n.id]) ?? { x: n.x, y: n.y };
const boxOf = (n: CanvasToolbarNode) => { const p = pos(n), d = dims[n.id] ?? { w: 160, h: 52 }; return { x: p.x, y: p.y, w: d.w, h: d.h }; };
useIsoLayoutEffect(() => {
const el = cv.current, b = bar.current; if (!el || !b) return;
const read = () => setSize(s => {
const n = { w: el.clientWidth, h: el.clientHeight, bar: b.offsetHeight };
return s.w === n.w && s.h === n.h && s.bar === n.bar ? s : n;
});
read();
const ro = new ResizeObserver(read); ro.observe(el); ro.observe(b);
return () => ro.disconnect();
}, []);
useIsoLayoutEffect(() => {
const next: Record<string, { w: number; h: number }> = {};
for (const n of nodes) { const e = els.current[n.id]; next[n.id] = { w: e?.offsetWidth ?? 160, h: e?.offsetHeight ?? 52 }; }
setDims(d => (nodes.every(n => d[n.id] && d[n.id].w === next[n.id].w && d[n.id].h === next[n.id].h) ? d : next));
});
/* ---------- the view ---------- */
const MIN = zoomSteps[0], MAX = zoomSteps[zoomSteps.length - 1];
const lim = (v: number) => Math.max(MIN, Math.min(MAX, v));
// the room the workflow has: the canvas above the toolbar
const roomH = size.h - size.bar - 20;
const fitView = (cap: number): CanvasView => {
if (!nodes.length || !size.w) return { panX: 0, panY: 0, zoom: 1 };
const P = 24, bs = nodes.map(boxOf);
const x0 = Math.min(...bs.map(b => b.x)), y0 = Math.min(...bs.map(b => b.y)), x1 = Math.max(...bs.map(b => b.x + b.w)), y1 = Math.max(...bs.map(b => b.y + b.h));
const zoom = lim(Math.min((size.w - 2 * P) / (x1 - x0), (roomH - 2 * P) / (y1 - y0), cap));
return { zoom, panX: (size.w - (x1 - x0) * zoom) / 2 - x0 * zoom, panY: (roomH - (y1 - y0) * zoom) / 2 - y0 * zoom };
};
const view = viewSet ?? fitView(1);
const { panX, panY, zoom } = view;
// a canvas that switches between wide and narrow fits the workflow again
const wasNarrow = useRef(narrow);
useEffect(() => { if (wasNarrow.current !== narrow) { wasNarrow.current = narrow; if (viewSet) setView(null); } }, [narrow]); // eslint-disable-line react-hooks/exhaustive-deps
const [anim, setAnim] = useState(false);
const go = (v: CanvasView, glide = true) => { setAnim(glide && !reduce); setView(v); };
const pct = Math.round(zoom * 100) + '%';
const zoomAt = (nz: number, cx: number, cy: number, glide = true) => {
nz = lim(nz); const wx = (cx - panX) / zoom, wy = (cy - panY) / zoom;
go({ zoom: nz, panX: cx - wx * nz, panY: cy - wy * nz }, glide);
};
const next = (d: number) => (d > 0 ? zoomSteps.find(x => x > zoom + 1e-3) : [...zoomSteps].reverse().find(x => x < zoom - 1e-3)) ?? zoom;
const zoomBy = (d: number) => { const nz = next(d); if (nz === zoom) return; zoomAt(nz, size.w / 2, roomH / 2); setSay('Zoom ' + Math.round(nz * 100) + '%'); };
const zoomReset = () => { zoomAt(1, size.w / 2, roomH / 2); setSay('Zoom 100%'); };
const fitNow = () => { const v = fitView(1.5); go(v); setSay('Fit to screen, ' + Math.round(v.zoom * 100) + '%'); };
const lo = zoom <= MIN + 1e-3, hi = zoom >= MAX - 1e-3;
useImperativeHandle(ref, () => ({
panBy: (dx, dy) => go({ zoom, panX: panX + dx, panY: panY + dy }),
zoomIn: () => zoomBy(1), zoomOut: () => zoomBy(-1),
zoomTo: z => { zoomAt(z, size.w / 2, roomH / 2); setSay('Zoom ' + Math.round(lim(z) * 100) + '%'); },
fit: fitNow
}));
/* ---------- tools ---------- */
const setTool = (t: CanvasTool, focus = false) => {
setToolRaw(t); if (commenting) setCommenting(false); setSay(NAMES[t] + ' tool');
if (focus) requestAnimationFrame(() => root.current?.querySelector<HTMLElement>(`[data-t="${t}"]`)?.focus({ preventScroll: true }));
};
const toggleComment = (on = !commenting) => { setCommenting(on); setSay(on ? 'Comment: click the canvas to place it' : 'Comment off'); };
const toolKeys = (e: ReactKeyboardEvent) => {
const i = TOOLS.findIndex(x => x.t === tool);
const d = ({ ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 } as Record<string, number>)[e.key];
const j = d ? (i + d + TOOLS.length) % TOOLS.length : e.key === 'Home' ? 0 : e.key === 'End' ? TOOLS.length - 1 : -1;
if (j < 0) return; e.preventDefault(); e.stopPropagation(); setTool(TOOLS[j].t, true);
};
/* ---------- the canvas: drag to pan, box or lasso select, move steps; Comment drops a pin ---------- */
const drag = useRef<{ kind: 'pan' | 'move' | 'box' | 'lasso'; p0: number[]; view: CanvasView; from: Record<string, CanvasPoint>; base: string[]; pts: number[][] } | null>(null);
const [panning, setPanning] = useState(false);
const [box, setBox] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const [lasso, setLasso] = useState<number[][] | null>(null);
const pt = (e: { clientX: number; clientY: number }) => { const b = cv.current!.getBoundingClientRect(); return [e.clientX - b.left, e.clientY - b.top]; };
const screen = (n: CanvasToolbarNode) => { const b = boxOf(n); return { l: b.x * zoom + panX, t: b.y * zoom + panY, r: (b.x + b.w) * zoom + panX, b: (b.y + b.h) * zoom + panY }; };
/* the canvas never traps the page: until the person has engaged with the component (a click in it, a tap in it, or the
keyboard's focus coming into it) a finger on the canvas belongs to the page, so it scrolls past as usual, and one that
lifts without scrolling is a tap, which engages it. Once engaged, a finger works the tool as the mouse does */
const engaged = useRef(false), lastKey = useRef(-1e9);
const [engagedOn, setEngagedOn] = useState(false);
const engageNow = () => { if (engaged.current) return; engaged.current = true; setEngagedOn(true); };
useEffect(() => { const k = (e: KeyboardEvent) => { if (e.isTrusted) lastKey.current = e.timeStamp; }; document.addEventListener('keydown', k, true); return () => document.removeEventListener('keydown', k, true); }, []);
const engage = (e: ReactPointerEvent) => { if (e.nativeEvent.isTrusted && (e.pointerType !== 'touch') === (e.type === 'pointerdown')) engageNow(); };
const engageFocus = (e: ReactFocusEvent) => { if (e.nativeEvent.isTrusted && e.nativeEvent.timeStamp - lastKey.current < 100) engageNow(); };
const down = (e: ReactPointerEvent<HTMLDivElement>) => {
if ((e.button !== 0 && e.button !== 1) || (e.pointerType === 'touch' && !engaged.current)) return;
const p = pt(e), node = (e.target as Element).closest<HTMLElement>('.dm-ctb-n');
cv.current?.focus({ preventScroll: true }); e.preventDefault();
if (commenting && e.button === 0) {
const at = { x: Math.round((p[0] - panX) / zoom), y: Math.round((p[1] - panY) / zoom) };
if (!comments) setPins(x => [...x, at]); onComment?.(at); setCommenting(false); setSay(`Comment ${shownPins.length + 1} placed`); return;
}
const kind = e.button === 1 || tool === 'hand' ? 'pan' : tool === 'select' ? (node ? 'move' : 'box') : 'lasso';
let now = sel;
if (kind === 'move') { const id = node!.dataset.id!; if (e.shiftKey) now = sel.includes(id) ? sel.filter(x => x !== id) : [...sel, id]; else if (!sel.includes(id)) now = [id]; if (now !== sel) setSel(now); }
else if (kind !== 'pan' && !e.shiftKey && sel.length) { now = []; setSel(now); }
drag.current = { kind, p0: p, view, from: Object.fromEntries(nodes.map(n => [n.id, pos(n)])), base: now, pts: [p] };
if (kind === 'pan') setPanning(true);
try { cv.current?.setPointerCapture(e.pointerId); } catch { /* the pointer has gone already */ }
};
const move = (e: ReactPointerEvent<HTMLDivElement>) => {
const d = drag.current; if (!d) return;
const p = pt(e), dx = p[0] - d.p0[0], dy = p[1] - d.p0[1];
if (d.kind === 'pan') go({ ...d.view, panX: d.view.panX + dx, panY: d.view.panY + dy }, false);
else if (d.kind === 'move') {
const layout = narrow ? 'narrow' : 'wide';
setMoved(m => ({ ...m, [layout]: { ...m[layout], ...Object.fromEntries(sel.map(id => [id, { x: d.from[id].x + dx / zoom, y: d.from[id].y + dy / zoom }])) } }));
} else if (d.kind === 'box') {
const b = { x: Math.min(p[0], d.p0[0]), y: Math.min(p[1], d.p0[1]), w: Math.abs(dx), h: Math.abs(dy) }; setBox(b);
setSel([...new Set([...d.base, ...nodes.filter(n => { const s = screen(n); return s.r > b.x && s.l < b.x + b.w && s.b > b.y && s.t < b.y + b.h; }).map(n => n.id)])]);
} else {
d.pts.push(p); setLasso([...d.pts]);
setSel([...new Set([...d.base, ...nodes.filter(n => { const s = screen(n); return inside([(s.l + s.r) / 2, (s.t + s.b) / 2], d.pts); }).map(n => n.id)])]);
}
};
const up = () => {
const d = drag.current; if (!d) return; drag.current = null;
setPanning(false); setBox(null); setLasso(null);
if (d.kind === 'move' && sel.length) onNodesMove?.(Object.fromEntries(sel.map(id => [id, pos(nodes.find(n => n.id === id)!)])), narrow);
if (d.kind !== 'pan') setSay(sel.length ? `${sel.length} of ${nodes.length} steps selected` : 'Nothing selected');
};
// Ctrl or ⌘ with the wheel, or a pinch, zooms at the pointer. It is added by hand so it can stop the page zooming
const wheel = useRef<(e: WheelEvent) => void>(() => {});
wheel.current = e => { if (!e.ctrlKey && !e.metaKey) return; e.preventDefault(); const p = pt(e); zoomAt(zoom * Math.exp(-e.deltaY / 300), p[0], p[1], false); };
useEffect(() => { const el = cv.current; if (!el) return; const f = (e: WheelEvent) => wheel.current(e); el.addEventListener('wheel', f, { passive: false }); return () => el.removeEventListener('wheel', f); }, []);
/* ---------- the keyboard ---------- */
const menu = usePopover({ align: 'end', side: 'up' });
const keys = (e: ReactKeyboardEvent<HTMLDivElement>) => {
const t = e.target as HTMLElement;
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || t.closest('.dm-ctb-menu') || /^(INPUT|TEXTAREA)$/.test(t.tagName)) return;
const k = e.key, low = k.length === 1 ? k.toLowerCase() : '';
if (e.shiftKey && e.code === 'Digit1') fitNow();
else if (e.shiftKey && e.code === 'Digit0') zoomReset();
else if (k === '+' || k === '=') zoomBy(1);
else if (k === '-' || k === '_') zoomBy(-1);
else if (!e.shiftKey && low && 'vhl'.includes(low)) setTool(({ v: 'select', h: 'hand', l: 'lasso' } as const)[low as 'v' | 'h' | 'l'], t.getAttribute('role') === 'radio');
else if (!e.shiftKey && low === 'c') toggleComment();
else if (t === cv.current && /^Arrow/.test(k)) {
const d = e.shiftKey ? 120 : 40;
go({ zoom, panX: panX + (k === 'ArrowLeft' ? d : k === 'ArrowRight' ? -d : 0), panY: panY + (k === 'ArrowUp' ? d : k === 'ArrowDown' ? -d : 0) });
} else if (k === 'Escape' && (commenting || sel.length)) { if (commenting) toggleComment(false); else { setSel([]); setSay('Nothing selected'); } }
else return;
e.preventDefault();
};
// Run says so, and a run that ends on its own says it finished
const wasRunning = useRef(running), stopped = useRef(false);
useEffect(() => {
if (wasRunning.current === running) return; wasRunning.current = running;
setSay(running ? 'Running the workflow' : stopped.current ? 'Run stopped' : 'Run finished'); stopped.current = false;
}, [running]);
const wire = ([a, b]: CanvasToolbarEdge) => {
const na = nodes.find(n => n.id === a), nb = nodes.find(n => n.id === b); if (!na || !nb) return '';
const A = boxOf(na), B = boxOf(nb);
// side to side when the next step is beside this one, top to bottom when it is under it
if (Math.abs(B.x - A.x) >= Math.abs(B.y - A.y)) { const ltr = B.x > A.x, x1 = ltr ? A.x + A.w : A.x, x2 = ltr ? B.x : B.x + B.w, y1 = A.y + A.h / 2, y2 = B.y + B.h / 2, m = (x1 + x2) / 2; return `M${x1} ${y1}C${m} ${y1} ${m} ${y2} ${x2} ${y2}`; }
const dn = B.y > A.y, y1 = dn ? A.y + A.h : A.y, y2 = dn ? B.y : B.y + B.h, x1 = A.x + A.w / 2, x2 = B.x + B.w / 2, m = (y1 + y2) / 2;
return `M${x1} ${y1}C${x1} ${m} ${x2} ${m} ${x2} ${y2}`;
};
const g = 16 * zoom * (zoom < .5 ? 2 : 1);
const item = (a: string, icon: IconName, text: string, kbd: string | null, act: () => void, opts: { ovf: 1 | 2; close?: boolean; checked?: boolean; disabled?: boolean; right?: string }) => (
<button type="button" className="dm-mi" role={opts.checked === undefined ? 'menuitem' : 'menuitemcheckbox'} aria-checked={opts.checked} aria-disabled={opts.disabled || undefined}
tabIndex={-1} data-a={a} data-ovf={opts.ovf} onClick={() => { if (opts.disabled) return; act(); if (opts.close) menu.closeSoon(); }}>
<Icon name={icon} /><span>{text}</span>{kbd ? <span className="dm-kbd">{kbd}</span> : <span className="dm-r dm-num">{opts.right}</span>}
</button>
);
return (
<div ref={root} data-dm="" data-tool={tool} className={cx('dm-ctb', commenting && 'dm-cm', className)} onKeyDown={keys}
onPointerDownCapture={engage} onPointerUpCapture={engage} onFocusCapture={engageFocus}>
<div ref={cv} className={cx('dm-ctb-cv', anim && 'dm-anim', panning && 'dm-pan', engagedOn && 'dm-engaged')} tabIndex={0} role="group" aria-roledescription="canvas" aria-label={label} aria-describedby={uid + '-k'}
style={{ backgroundSize: `${g}px ${g}px`, backgroundPosition: `${panX}px ${panY}px` }}
onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
<div className="dm-ctb-world" style={{ transform: `translate(${panX}px,${panY}px) scale(${zoom})` }}>
{children}
<svg className="dm-ctb-w" aria-hidden="true">
{edges.map(e => <path key={e.join('-')} className={lit.some(l => same(l, e)) ? 'dm-lit' : undefined} d={wire(e)} />)}
</svg>
<div className="dm-ctb-nodes">
{nodes.map((n, i) => {
const p = pos(n);
return (
<div key={n.id} ref={el => { els.current[n.id] = el; }} data-id={n.id} className={cx('dm-ctb-n dm-in', sel.includes(n.id) && 'dm-sel', n.state === 'run' && 'dm-run')}
style={{ left: p.x, top: p.y, animationDelay: i * 90 + 'ms' }}>
<span className="dm-ic" style={{ color: n.iconColor ?? n.color, background: `color-mix(in srgb,${n.color} 14%,transparent)` }}><Icon name={n.icon} size="xs" /></span>
<span className="dm-t"><b>{n.title}</b>{n.detail && <span>{n.detail}</span>}</span>
{n.state && <span className="dm-s" aria-hidden="true">{n.state === 'run' ? <span className="dm-spin" /> : <Icon name="check" size="xs" />}</span>}
</div>
);
})}
</div>
{shownPins.map((c, i) => <span key={i} className="dm-ctb-pin" aria-hidden="true" style={{ left: c.x, top: c.y }}>{i + 1}</span>)}
</div>
<svg className="dm-ctb-lz" aria-hidden="true"><path d={lasso ? 'M' + lasso.map(q => q.map(Math.round).join(' ')).join('L') + 'Z' : ''} /></svg>
{box && <div className="dm-ctb-mq" style={{ left: box.x, top: box.y, width: box.w, height: box.h }} />}
<span className="dm-ctb-sr" id={uid + '-k'}>V, H and L pick a tool, C adds a comment, Shift 1 fits the workflow to the screen, minus and plus zoom, and the arrow keys move the view.</span>
</div>
<div ref={bar} className="dm-ctb-bar" role="toolbar" aria-label="Canvas tools">
<button type="button" className={cx('dm-pill dm-primary dm-ctb-run', running && 'dm-stop')} onClick={() => { if (running) { stopped.current = true; onStop?.(); } else onRun?.(); }}>
<Icon name={running ? 'stop' : 'play'} size="xs" /><span>{running ? stopLabel : runLabel}</span>
</button>
<span className="dm-ctb-sep" />
<div className="dm-ctb-tools" role="radiogroup" aria-label="Tool" onKeyDown={toolKeys}>
{TOOLS.map(x => (
<Tip key={x.t} label={x.tip} kbd={x.key}>
<button type="button" className="dm-ctb-b" role="radio" aria-checked={tool === x.t} tabIndex={tool === x.t ? 0 : -1} data-t={x.t} aria-label={NAMES[x.t]} onClick={() => setTool(x.t)}><Icon name={x.icon} /></button>
</Tip>
))}
</div>
<span className="dm-ctb-sep" data-ovf="1" />
<Tip label="Comment" kbd="C"><button type="button" className="dm-ctb-b dm-ctb-cm" data-ovf="1" aria-pressed={commenting} aria-label="Comment" onClick={() => toggleComment()}><Icon name="comment" /></button></Tip>
<Tip label="Fit to screen" kbd="⇧1"><button type="button" className="dm-ctb-b dm-ctb-fit" data-ovf="1" aria-label="Fit to screen" onClick={fitNow}><Icon name="fit" /></button></Tip>
<span className="dm-ctb-sep" data-ovf="2" />
<Tip label="Zoom out" kbd="−"><button type="button" className="dm-ctb-b dm-ctb-zo" data-ovf="2" aria-label="Zoom out" aria-disabled={lo} onClick={() => zoomBy(-1)}><Icon name="minus" /></button></Tip>
<Tip label="Zoom to 100%" kbd="⇧0"><button type="button" className="dm-ctb-z dm-num" data-ovf="2" aria-label={`Zoom ${pct}, reset to 100%`} onClick={zoomReset}>{pct}</button></Tip>
<Tip label="Zoom in" kbd="+"><button type="button" className="dm-ctb-b dm-ctb-zi" data-ovf="2" aria-label="Zoom in" aria-disabled={hi} onClick={() => zoomBy(1)}><Icon name="plus" /></button></Tip>
<Tip label="More" off={menu.open}><button type="button" {...menu.trigger} className="dm-ctb-b dm-ctb-more" aria-label="More tools"><Icon name="more" /></button></Tip>
</div>
<div {...menu.pop} className={cx(menu.pop.className, 'dm-ctb-menu')} role="menu" aria-label="More tools">
{item('comment', 'comment', 'Comment', 'C', () => toggleComment(), { ovf: 1, close: true, checked: commenting })}
{item('fit', 'fit', 'Fit to screen', '⇧1', fitNow, { ovf: 1, close: true })}
{item('out', 'minus', 'Zoom out', '−', () => zoomBy(-1), { ovf: 2, disabled: lo })}
{item('in', 'plus', 'Zoom in', '+', () => zoomBy(1), { ovf: 2, disabled: hi })}
{item('reset', 'search', 'Zoom to 100%', null, zoomReset, { ovf: 2, close: true, right: pct })}
</div>
<p className="dm-ctb-live" aria-live="polite">{say}</p>
</div>
);
}
canvas-toolbar.cssReact · 94 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Canvas toolbar ---------- */
/* a workflow canvas with its floating toolbar fixed at the bottom centre: Run (Stop while a run is going), the pointer tools
as one radio group, Comment, Fit and zoom. A narrow canvas moves Comment and Fit, then the zoom buttons, into a More menu,
so the toolbar never wraps or runs past the canvas. The canvas is the whole component, edge to edge, in the stage's
colour; it fills the height it is given, and is 560px tall (520px below 440px wide) when nothing gives it one */
.dm-ctb{position:relative;width:100%;height:100%;container:ctb/inline-size;border-radius:var(--dm-r-in);
/* Stop's fill: a red that keeps white text at 4.5:1 and APCA Lc 75 in both themes, darker on hover */
--dm-ctb-stop:#bf3434;--dm-ctb-stop-2:#a52a2a;--dm-ctb-on-stop:#ffffff;
/* the wires: the field grey, which holds 3:1 against the canvas */
--dm-ctb-wire:var(--dm-line-strong)}
.dm-ctb-cv{position:relative;height:560px;min-height:100%;border-radius:inherit;overflow:hidden;outline:0;user-select:none;-webkit-user-select:none;cursor:default;
background-color:var(--dm-stage);background-image:radial-gradient(circle,color-mix(in srgb,var(--dm-fg) 14%,transparent) 1.1px,transparent 1.6px);background-size:16px 16px}
@container ctb (max-width:439px){.dm-ctb-cv{height:520px}}
/* touch is the page's until the canvas is engaged (a tap in it): then a finger works the tool as the mouse does */
.dm-ctb-cv.dm-engaged{touch-action:none}
/* in a stage the canvas takes all of it, so the stage's own room goes */
.dm-card.dm-wide .dm-stage-in:has(>.dm-ctb){max-width:none;align-self:stretch}
/* the canvas shows its focus only to the keyboard, in the quiet grey: a click gives it the focus without a ring */
.dm-ctb-cv:focus-visible:not(.dm-ptr){outline:2px solid color-mix(in srgb,var(--dm-field-line) 50%,transparent);outline-offset:-2px;border-radius:inherit}
.dm-ctb[data-tool="hand"] .dm-ctb-cv{cursor:grab}
.dm-ctb[data-tool="lasso"] .dm-ctb-cv,.dm-ctb.dm-cm .dm-ctb-cv{cursor:crosshair}
.dm-ctb .dm-ctb-cv.dm-pan{cursor:grabbing}
/* the buttons and the arrow keys glide the view; a drag or a pinch moves it as the pointer does */
.dm-ctb-cv.dm-anim{transition:background-position var(--dm-t) var(--dm-ease),background-size var(--dm-t) var(--dm-ease)}
.dm-ctb-cv.dm-anim .dm-ctb-world{transition:transform var(--dm-t) var(--dm-ease)}
.dm-ctb-world{position:absolute;left:0;top:0;transform-origin:0 0}
.dm-ctb-w{position:absolute;left:0;top:0;width:1px;height:1px;overflow:visible;pointer-events:none}
.dm-ctb-w path{fill:none;stroke:var(--dm-ctb-wire);stroke-width:1.5;transition:stroke .5s var(--dm-ease)}
.dm-ctb-w path.dm-lit{stroke:var(--dm-accent-mark)}
.dm-ctb-n{position:absolute;width:160px;display:flex;align-items:center;gap:var(--dm-s-2);padding:var(--dm-s-2) var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-surface);box-shadow:var(--dm-shadow);font-size:12.5px;line-height:1.35;transition:box-shadow .3s var(--dm-ease)}
.dm-ctb-n.dm-in{animation:dm-ctbin .56s var(--dm-ease) backwards}
@keyframes dm-ctbin{from{opacity:0;filter:blur(4px)}}
.dm-ctb-n .dm-ic{width:28px;height:28px;border-radius:var(--dm-r-sm);display:grid;place-items:center;flex:none}
.dm-ctb-n .dm-t{display:flex;flex-direction:column;min-width:0}
.dm-ctb-n .dm-t b{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-ctb-n .dm-t span{font-size:11.5px;color:var(--dm-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-ctb-n.dm-sel{box-shadow:0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent),var(--dm-shadow)}
.dm-ctb-n.dm-run{box-shadow:0 0 0 1.5px var(--dm-accent-mark),0 0 0 6px color-mix(in srgb,var(--dm-accent-mark,var(--dm-accent)) 12%,transparent),var(--dm-shadow)}
.dm-ctb-n .dm-s{position:absolute;right:-6px;top:-6px;width:18px;height:18px;border-radius:50%;display:grid;place-items:center;background:var(--dm-surface);box-shadow:0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);animation:dm-ctbs .3s var(--dm-ease)}
.dm-ctb-n .dm-s .dm-spin{width:10px;height:10px;color:var(--dm-accent-mark)}
.dm-ctb-n .dm-s .dm-i{color:var(--dm-ok)}
@keyframes dm-ctbs{from{opacity:0;transform:scale(.6)}}
/* a comment dropped on the canvas: a numbered pin in the accent, its point on the spot that was clicked */
.dm-ctb-pin{position:absolute;width:24px;height:24px;transform:translateY(-100%);display:grid;place-items:center;border-radius:var(--dm-r-pill) var(--dm-r-pill) var(--dm-r-pill) var(--dm-r-xs);background:var(--dm-accent);color:var(--dm-on-accent);box-shadow:0 0 0 2px var(--dm-surface),var(--dm-shadow);font-size:11.5px;font-weight:500;animation:dm-ctbpin .3s var(--dm-ease)}
@keyframes dm-ctbpin{from{opacity:0;transform:translateY(-100%) scale(.6)}}
.dm-ctb-pin{transform-origin:0 100%;transition:opacity .16s var(--dm-ease),scale .16s var(--dm-ease)}
.dm-ctb-pin[aria-expanded="true"]{box-shadow:0 0 0 2px var(--dm-surface),0 0 0 3px color-mix(in srgb,var(--dm-field-line) 50%,transparent),var(--dm-shadow)}
.dm-ctb-pin.dm-gone{opacity:0;scale:.6;pointer-events:none}
/* a comment opened from its pin: its number, where it is, and Delete */
.dm-ctb-cp{width:216px;min-width:0;padding:var(--dm-s-3)}
.dm-ctb-cp b{display:block;font-size:13px;font-weight:500}
.dm-ctb-cp p{margin:2px 0 var(--dm-s-3);font-size:12.5px;color:var(--dm-fg2)}
.dm-pill.dm-ctb-del,.dm-pill.dm-ctb-del .dm-i{color:var(--dm-bad)}
@media (hover:hover) and (pointer:fine){.dm-pill.dm-ctb-del:hover{background:var(--dm-bad-bg)}}
/* what a drag selects: a box for Select, a free shape for Lasso */
.dm-ctb-mq{position:absolute;left:0;top:0;box-shadow:inset 0 0 0 1px var(--dm-accent-mark);background:color-mix(in srgb,var(--dm-accent) 8%,transparent);pointer-events:none}
.dm-ctb-lz{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}
.dm-ctb-lz path{fill:color-mix(in srgb,var(--dm-accent-mark,var(--dm-accent)) 8%,transparent);stroke:var(--dm-accent-mark);stroke-width:1.5;stroke-dasharray:4 3;stroke-linejoin:round}
/* where the demo's pointer rests while it drags the canvas */
.dm-ctb-aim{position:absolute;width:20px;height:20px;pointer-events:none}
.dm-ctb-bar{position:absolute;left:50%;bottom:var(--dm-s-3);z-index:6;display:flex;align-items:center;padding:var(--dm-s-1);border-radius:var(--dm-r-pill);background:var(--dm-raised);box-shadow:var(--dm-shadow-pop);white-space:nowrap;
transform:translateX(-50%)}
.dm-ctb-bar :focus-visible{border-radius:var(--dm-r-pill)}
.dm-ctb-b{width:32px;height:32px;flex:none;display:grid;place-items:center;border-radius:var(--dm-r-pill);color:var(--dm-fg2);transition:background var(--dm-t-fast),color var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-ctb-b:hover{background:var(--dm-row);color:var(--dm-fg)}}
/* the tool in use sits on a soft accent tint, with no edge */
.dm-ctb-b:is([aria-checked="true"],[aria-pressed="true"]){background:color-mix(in srgb,var(--dm-accent) 12%,transparent);color:var(--dm-accent-fg);box-shadow:none}
@media (hover:hover) and (pointer:fine){.dm-ctb-b:is([aria-checked="true"],[aria-pressed="true"]):hover{background:color-mix(in srgb,var(--dm-accent) 12%,transparent);color:var(--dm-accent-fg);box-shadow:none}}
/* zoom out at 25%, or in at 400%, has nowhere to go: it greys out but keeps its place and the focus */
.dm-ctb-b[aria-disabled="true"],.dm-ctb-menu .dm-mi[aria-disabled="true"],.dm-ctb-menu .dm-mi[aria-disabled="true"] :is(.dm-i,.dm-kbd){color:var(--dm-dis);background:none;cursor:not-allowed}
@media (hover:hover) and (pointer:fine){.dm-ctb-b[aria-disabled="true"]:hover{color:var(--dm-dis);background:none;cursor:not-allowed}}
.dm-ctb-sep{width:1px;height:16px;margin:0 var(--dm-s-1);flex:none;background:var(--dm-line)}
.dm-ctb-tools{display:flex}
.dm-ctb-run{min-width:76px;padding:0 var(--dm-s-4) 0 var(--dm-s-3);transition:opacity var(--dm-t-fast),transform .15s}
.dm-ctb-run.dm-stop{background:var(--dm-ctb-stop);color:var(--dm-ctb-on-stop)}
@media (hover:hover) and (pointer:fine){.dm-ctb-run.dm-stop:hover{background:var(--dm-ctb-stop-2)}}
.dm-ctb-z{min-width:48px;height:32px;padding:0 var(--dm-s-1);flex:none;border-radius:var(--dm-r-pill);font-size:12.5px;color:var(--dm-fg2);transition:background var(--dm-t-fast),color var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-ctb-z:hover{background:var(--dm-row);color:var(--dm-fg)}}
.dm-ctb-more{display:none}
.dm-ctb-menu{width:216px}
.dm-ctb-menu .dm-mi .dm-kbd,.dm-ctb-menu .dm-mi .dm-r{margin-left:auto}
.dm-ctb-menu .dm-mi[aria-checked="true"]{font-weight:400}
.dm-ctb-menu .dm-mi[aria-checked="true"] .dm-i{color:var(--dm-fg)}
.dm-ctb-menu [data-ovf]{display:none}
/* the toolbar keeps to one row: below 440px Comment and Fit go into More, below 360px the zoom buttons follow */
@container ctb (max-width:439px){.dm-ctb-bar [data-ovf="1"]{display:none}.dm-ctb-more{display:grid}.dm-ctb-menu [data-ovf="1"]{display:flex}}
@container ctb (max-width:359px){.dm-ctb-bar [data-ovf="2"]{display:none}.dm-ctb-menu [data-ovf="2"]{display:flex}}
.dm-ctb-live,.dm-ctb-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .dm-ctb{--dm-ctb-stop:#c43535;--dm-ctb-stop-2:#d34646}}
:root[data-theme="dark"] .dm-ctb{--dm-ctb-stop:#c43535;--dm-ctb-stop-2:#d34646}
:root:not([data-style="soft"]) .dm-ctb-n.dm-run:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:0 0 0 1px var(--dm-accent-mark),0 0 0 4px color-mix(in srgb,var(--dm-accent-mark,var(--dm-accent)) 22%,transparent)}
:root:not([data-style="soft"]) .dm-ctb-n.dm-sel:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:0 0 0 1px color-mix(in srgb,var(--dm-pick-on) 50%,transparent)}
canvas-toolbar.htmlHTML · 39 lines
<div class="card wide" data-c="canvas-toolbar">
<div class="stage-in">
<div class="ctb" data-tool="select" data-reveal="flat">
<div class="ctb-cv" tabindex="0" role="group" aria-roledescription="canvas" aria-label="Workflow canvas" aria-describedby="">
<div class="ctb-world"><svg class="ctb-w" aria-hidden="true"></svg><div class="ctb-nodes"></div></div>
<svg class="ctb-lz" aria-hidden="true"><path d=""/></svg>
<div class="ctb-mq" hidden></div>
<span class="ctb-sr">V, H and L pick a tool, C adds a comment, Shift 1 fits the workflow to the screen, minus and plus zoom, and the arrow keys move the view.</span>
</div>
<div class="ctb-bar" role="toolbar" aria-label="Canvas tools">
<button class="pill primary ctb-run"><svg class="i xs"><use href="#i-play"/></svg><span>Run</span></button>
<span class="ctb-sep"></span>
<div class="ctb-tools" role="radiogroup" aria-label="Tool">
<button class="ctb-b" role="radio" aria-checked="true" data-t="select" aria-label="Select" data-tip="Select" data-tip-k="V"><svg class="i s"><use href="#i-cursor"/></svg></button>
<button class="ctb-b" role="radio" aria-checked="false" tabindex="-1" data-t="hand" aria-label="Hand" data-tip="Pan" data-tip-k="H"><svg class="i s"><use href="#i-hand"/></svg></button>
<button class="ctb-b" role="radio" aria-checked="false" tabindex="-1" data-t="lasso" aria-label="Lasso" data-tip="Lasso select" data-tip-k="L"><svg class="i s"><use href="#i-lasso"/></svg></button>
</div>
<span class="ctb-sep" data-ovf="1"></span>
<button class="ctb-b ctb-cm" data-ovf="1" aria-pressed="false" aria-label="Comment" data-tip="Comment" data-tip-k="C"><svg class="i s"><use href="#i-comment"/></svg></button>
<button class="ctb-b ctb-fit" data-ovf="1" aria-label="Fit to screen" data-tip="Fit to screen" data-tip-k="⇧1"><svg class="i s"><use href="#i-fit"/></svg></button>
<span class="ctb-sep" data-ovf="2"></span>
<button class="ctb-b ctb-zo" data-ovf="2" aria-label="Zoom out" data-tip="Zoom out" data-tip-k="−"><svg class="i s"><use href="#i-minus"/></svg></button>
<button class="ctb-z num" data-ovf="2" aria-label="Zoom 100%, reset to 100%" data-tip="Zoom to 100%" data-tip-k="⇧0">100%</button>
<button class="ctb-b ctb-zi" data-ovf="2" aria-label="Zoom in" data-tip="Zoom in" data-tip-k="+"><svg class="i s"><use href="#i-plus"/></svg></button>
<button class="ctb-b ctb-more" aria-label="More tools" aria-haspopup="menu" aria-expanded="false" data-pop=".ctb-menu" data-pop-pos="up" data-pop-align="end" data-tip="More"><svg class="i s"><use href="#i-more"/></svg></button>
</div>
<div class="pop ctb-menu" role="menu" aria-label="More tools">
<button class="mi" role="menuitemcheckbox" aria-checked="false" tabindex="-1" data-a="comment" data-ovf="1" data-close><svg class="i s"><use href="#i-comment"/></svg><span>Comment</span><span class="kbd">C</span></button>
<button class="mi" role="menuitem" tabindex="-1" data-a="fit" data-ovf="1" data-close><svg class="i s"><use href="#i-fit"/></svg><span>Fit to screen</span><span class="kbd">⇧1</span></button>
<button class="mi" role="menuitem" tabindex="-1" data-a="out" data-ovf="2"><svg class="i s"><use href="#i-minus"/></svg><span>Zoom out</span><span class="kbd">−</span></button>
<button class="mi" role="menuitem" tabindex="-1" data-a="in" data-ovf="2"><svg class="i s"><use href="#i-plus"/></svg><span>Zoom in</span><span class="kbd">+</span></button>
<button class="mi" role="menuitem" tabindex="-1" data-a="reset" data-ovf="2" data-close><svg class="i s"><use href="#i-search"/></svg><span>Zoom to 100%</span><span class="r num ctb-zl">100%</span></button>
</div>
<div class="pop ctb-cp" role="dialog" aria-label="Comment"><b class="ctb-ct"></b><p class="ctb-cw"></p><button class="pill line sm ctb-del"><svg class="i xs"><use href="#i-trash"/></svg>Delete</button></div>
<p class="ctb-live" aria-live="polite"></p>
</div>
</div>
</div>
canvas-toolbar.cssHTML · 93 lines
/* ---------- Canvas toolbar ---------- */
/* a workflow canvas with its floating toolbar fixed at the bottom centre: Run (Stop while a run is going), the pointer tools
as one radio group, Comment, Fit and zoom. A narrow canvas moves Comment and Fit, then the zoom buttons, into a More menu,
so the toolbar never wraps or runs past the canvas. The canvas is the whole component, edge to edge, in the stage's
colour; it fills the height it is given, and is 560px tall (520px below 440px wide) when nothing gives it one */
.ctb{position:relative;width:100%;height:100%;container:ctb/inline-size;border-radius:var(--r-in);
/* Stop's fill: a red that keeps white text at 4.5:1 and APCA Lc 75 in both themes, darker on hover */
--ctb-stop:#bf3434;--ctb-stop-2:#a52a2a;--ctb-on-stop:#ffffff;
/* the wires: the field grey, which holds 3:1 against the canvas */
--ctb-wire:var(--line-strong)}
.ctb-cv{position:relative;height:560px;min-height:100%;border-radius:inherit;overflow:hidden;outline:0;user-select:none;-webkit-user-select:none;cursor:default;
background-color:var(--stage);background-image:radial-gradient(circle,color-mix(in srgb,var(--fg) 14%,transparent) 1.1px,transparent 1.6px);background-size:16px 16px}
@container ctb (max-width:439px){.ctb-cv{height:520px}}
/* touch is the page's until the canvas is engaged (a tap in it): then a finger works the tool as the mouse does */
.ctb-cv.engaged{touch-action:none}
/* in a stage the canvas takes all of it, so the stage's own room goes */
.card.wide .stage-in:has(>.ctb){max-width:none;align-self:stretch}
/* the canvas shows its focus only to the keyboard, in the quiet grey: a click gives it the focus without a ring */
.ctb-cv:focus-visible:not(.ptr){outline:2px solid color-mix(in srgb,var(--field-line) 50%,transparent);outline-offset:-2px;border-radius:inherit}
.ctb[data-tool="hand"] .ctb-cv{cursor:grab}
.ctb[data-tool="lasso"] .ctb-cv,.ctb.cm .ctb-cv{cursor:crosshair}
.ctb .ctb-cv.pan{cursor:grabbing}
/* the buttons and the arrow keys glide the view; a drag or a pinch moves it as the pointer does */
.ctb-cv.anim{transition:background-position var(--t) var(--ease),background-size var(--t) var(--ease)}
.ctb-cv.anim .ctb-world{transition:transform var(--t) var(--ease)}
.ctb-world{position:absolute;left:0;top:0;transform-origin:0 0}
.ctb-w{position:absolute;left:0;top:0;width:1px;height:1px;overflow:visible;pointer-events:none}
.ctb-w path{fill:none;stroke:var(--ctb-wire);stroke-width:1.5;transition:stroke .5s var(--ease)}
.ctb-w path.lit{stroke:var(--accent-mark)}
.ctb-n{position:absolute;width:160px;display:flex;align-items:center;gap:var(--s-2);padding:var(--s-2) var(--s-3);border-radius:var(--r-in);background:var(--surface);box-shadow:var(--shadow);font-size:12.5px;line-height:1.35;transition:box-shadow .3s var(--ease)}
.ctb-n.in{animation:ctbin .56s var(--ease) backwards}
@keyframes ctbin{from{opacity:0;filter:blur(4px)}}
.ctb-n .ic{width:28px;height:28px;border-radius:var(--r-sm);display:grid;place-items:center;flex:none}
.ctb-n .t{display:flex;flex-direction:column;min-width:0}
.ctb-n .t b{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ctb-n .t span{font-size:11.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ctb-n.sel{box-shadow:0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent),var(--shadow)}
.ctb-n.run{box-shadow:0 0 0 1.5px var(--accent-mark),0 0 0 6px color-mix(in srgb,var(--accent-mark,var(--accent)) 12%,transparent),var(--shadow)}
.ctb-n .s{position:absolute;right:-6px;top:-6px;width:18px;height:18px;border-radius:50%;display:grid;place-items:center;background:var(--surface);box-shadow:0 0 0 1px color-mix(in srgb,var(--line) 50%,transparent);animation:ctbs .3s var(--ease)}
.ctb-n .s .spin{width:10px;height:10px;color:var(--accent-mark)}
.ctb-n .s .i{color:var(--ok)}
@keyframes ctbs{from{opacity:0;transform:scale(.6)}}
/* a comment dropped on the canvas: a numbered pin in the accent, its point on the spot that was clicked */
.ctb-pin{position:absolute;width:24px;height:24px;transform:translateY(-100%);display:grid;place-items:center;border-radius:var(--r-pill) var(--r-pill) var(--r-pill) var(--r-xs);background:var(--accent);color:var(--on-accent);box-shadow:0 0 0 2px var(--surface),var(--shadow);font-size:11.5px;font-weight:500;animation:ctbpin .3s var(--ease)}
@keyframes ctbpin{from{opacity:0;transform:translateY(-100%) scale(.6)}}
.ctb-pin{transform-origin:0 100%;transition:opacity .16s var(--ease),scale .16s var(--ease)}
.ctb-pin[aria-expanded="true"]{box-shadow:0 0 0 2px var(--surface),0 0 0 3px color-mix(in srgb,var(--field-line) 50%,transparent),var(--shadow)}
.ctb-pin.gone{opacity:0;scale:.6;pointer-events:none}
/* a comment opened from its pin: its number, where it is, and Delete */
.ctb-cp{width:216px;min-width:0;padding:var(--s-3)}
.ctb-cp b{display:block;font-size:13px;font-weight:500}
.ctb-cp p{margin:2px 0 var(--s-3);font-size:12.5px;color:var(--fg2)}
.pill.ctb-del,.pill.ctb-del .i{color:var(--bad)}
@media (hover:hover) and (pointer:fine){.pill.ctb-del:hover{background:var(--bad-bg)}}
/* what a drag selects: a box for Select, a free shape for Lasso */
.ctb-mq{position:absolute;left:0;top:0;box-shadow:inset 0 0 0 1px var(--accent-mark);background:color-mix(in srgb,var(--accent) 8%,transparent);pointer-events:none}
.ctb-lz{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}
.ctb-lz path{fill:color-mix(in srgb,var(--accent-mark,var(--accent)) 8%,transparent);stroke:var(--accent-mark);stroke-width:1.5;stroke-dasharray:4 3;stroke-linejoin:round}
/* where the demo's pointer rests while it drags the canvas */
.ctb-aim{position:absolute;width:20px;height:20px;pointer-events:none}
.ctb-bar{position:absolute;left:50%;bottom:var(--s-3);z-index:6;display:flex;align-items:center;padding:var(--s-1);border-radius:var(--r-pill);background:var(--raised);box-shadow:var(--shadow-pop);white-space:nowrap;
transform:translateX(-50%)}
.ctb-bar :focus-visible{border-radius:var(--r-pill)}
.ctb-b{width:32px;height:32px;flex:none;display:grid;place-items:center;border-radius:var(--r-pill);color:var(--fg2);transition:background var(--t-fast),color var(--t-fast)}
@media (hover:hover) and (pointer:fine){.ctb-b:hover{background:var(--row);color:var(--fg)}}
/* the tool in use sits on a soft accent tint, with no edge */
.ctb-b:is([aria-checked="true"],[aria-pressed="true"]){background:color-mix(in srgb,var(--accent) 12%,transparent);color:var(--accent-fg);box-shadow:none}
@media (hover:hover) and (pointer:fine){.ctb-b:is([aria-checked="true"],[aria-pressed="true"]):hover{background:color-mix(in srgb,var(--accent) 12%,transparent);color:var(--accent-fg);box-shadow:none}}
/* zoom out at 25%, or in at 400%, has nowhere to go: it greys out but keeps its place and the focus */
.ctb-b[aria-disabled="true"],.ctb-menu .mi[aria-disabled="true"],.ctb-menu .mi[aria-disabled="true"] :is(.i,.kbd){color:var(--dis);background:none;cursor:not-allowed}
@media (hover:hover) and (pointer:fine){.ctb-b[aria-disabled="true"]:hover{color:var(--dis);background:none;cursor:not-allowed}}
.ctb-sep{width:1px;height:16px;margin:0 var(--s-1);flex:none;background:var(--line)}
.ctb-tools{display:flex}
.ctb-run{min-width:76px;padding:0 var(--s-4) 0 var(--s-3);transition:opacity var(--t-fast),transform .15s}
.ctb-run.stop{background:var(--ctb-stop);color:var(--ctb-on-stop)}
@media (hover:hover) and (pointer:fine){.ctb-run.stop:hover{background:var(--ctb-stop-2)}}
.ctb-z{min-width:48px;height:32px;padding:0 var(--s-1);flex:none;border-radius:var(--r-pill);font-size:12.5px;color:var(--fg2);transition:background var(--t-fast),color var(--t-fast)}
@media (hover:hover) and (pointer:fine){.ctb-z:hover{background:var(--row);color:var(--fg)}}
.ctb-more{display:none}
.ctb-menu{width:216px}
.ctb-menu .mi .kbd,.ctb-menu .mi .r{margin-left:auto}
.ctb-menu .mi[aria-checked="true"]{font-weight:400}
.ctb-menu .mi[aria-checked="true"] .i{color:var(--fg)}
.ctb-menu [data-ovf]{display:none}
/* the toolbar keeps to one row: below 440px Comment and Fit go into More, below 360px the zoom buttons follow */
@container ctb (max-width:439px){.ctb-bar [data-ovf="1"]{display:none}.ctb-more{display:grid}.ctb-menu [data-ovf="1"]{display:flex}}
@container ctb (max-width:359px){.ctb-bar [data-ovf="2"]{display:none}.ctb-menu [data-ovf="2"]{display:flex}}
.ctb-live,.ctb-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .ctb{--ctb-stop:#c43535;--ctb-stop-2:#d34646}}
:root[data-theme="dark"] .ctb{--ctb-stop:#c43535;--ctb-stop-2:#d34646}
:root:not([data-style="soft"]) .ctb-n.run:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:0 0 0 1px var(--accent-mark),0 0 0 4px color-mix(in srgb,var(--accent-mark,var(--accent)) 22%,transparent)}
:root:not([data-style="soft"]) .ctb-n.sel:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:0 0 0 1px color-mix(in srgb,var(--pick-on) 50%,transparent)}
canvas-toolbar.jsHTML · 271 lines
DM.def('canvas-toolbar', (r, c) => {
r.innerHTML = r.innerHTML.replace(/\{(play|cursor|hand|lasso|comment|fit|minus|plus|more|zoom|trash)\}/g, (m, n) => I(n === 'zoom' ? 'search' : n, n === 'play' || n === 'trash' ? 'xs' : 's'));
const root = $('.ctb', r), cv = $('.ctb-cv', r), world = $('.ctb-world', r), wires = $('.ctb-w', r), nodes = $('.ctb-nodes', r), bar = $('.ctb-bar', r), live = $('.ctb-live', r);
const runB = $('.ctb-run', r), radios = $$('.ctb-tools [role="radio"]', r), cmB = $('.ctb-cm', r), fitB = $('.ctb-fit', r), zoB = $('.ctb-zo', r), ziB = $('.ctb-zi', r), zl = $('.ctb-z', r);
const menu = $('.ctb-menu', r), more = $('.ctb-more', r), mq = $('.ctb-mq', r), lz = $('.ctb-lz path', r), sr = $('.ctb-sr', r);
sr.id = 'ctb-k-' + Math.random().toString(36).slice(2, 7); cv.setAttribute('aria-describedby', sr.id);
const say = t => { live.textContent = t; };
// every change a person makes is also an event on the component, for your code to follow
const emit = (type, detail) => root.dispatchEvent(new CustomEvent(type, { bubbles:true, detail }));
// the workflow on the canvas, in pixels at 100%: a row on a wide canvas, a column on a narrow one, so a phone shows it
// whole at a size that reads
const N = [
{ id:'a', ic:'bolt', col:'var(--py)', t:'New ticket', d:'Trigger · inbox', w:[0, 48], n:[0, 0] },
{ id:'b', ic:'spark', col:'var(--accent-mark,var(--accent))', fg:'var(--accent-fg,var(--accent))', t:'Classify', d:'Agent · intent', w:[208, 0], n:[0, 80] },
{ id:'c', ic:'search', col:'var(--pb)', t:'Search docs', d:'Tool · help center', w:[416, 96], n:[0, 160] },
{ id:'d', ic:'edit', col:'var(--pm)', t:'Draft reply', d:'Agent · for review', w:[624, 48], n:[0, 240] }
];
const E = [['a', 'b'], ['b', 'c'], ['c', 'd']], ORDER = N.map(n => n.id);
nodes.innerHTML = N.map(n => `<div class="ctb-n in" data-id="${n.id}"><span class="ic" style="color:${n.fg || n.col};background:color-mix(in srgb,${n.col} 14%,transparent)">${I(n.ic, 'xs')}</span><span class="t"><b>${n.t}</b><span>${n.d}</span></span></div>`).join('');
$$('.ctb-n', nodes).forEach((x, i) => { x.style.animationDelay = i * 90 + 'ms'; });
const el = id => $(`.ctb-n[data-id="${id}"]`, nodes);
const box = n => { const e = el(n.id); return { x:n.x, y:n.y, w:e.offsetWidth, h:e.offsetHeight }; };
// below 440px the steps sit in a column; a step someone dragged keeps where they put it, in each layout
const NARROW = 440;
let small = false;
function layout(){
small = cv.clientWidth < NARROW;
N.forEach(n => { const p = (small ? n.um : n.uw) || (small ? n.n : n.w); n.x = p[0]; n.y = p[1]; const e = el(n.id); e.style.left = n.x + 'px'; e.style.top = n.y + 'px'; });
drawWires();
}
const wireEl = {};
function drawWires(){
E.forEach(([a, b]) => {
const A = box(N.find(n => n.id === a)), B = box(N.find(n => n.id === b)); let d;
// side to side when the next step is beside this one, top to bottom when it is under it
if (Math.abs(B.x - A.x) >= Math.abs(B.y - A.y)){ const ltr = B.x > A.x, x1 = ltr ? A.x + A.w : A.x, x2 = ltr ? B.x : B.x + B.w, y1 = A.y + A.h / 2, y2 = B.y + B.h / 2, m = (x1 + x2) / 2; d = `M${x1} ${y1}C${m} ${y1} ${m} ${y2} ${x2} ${y2}`; }
else { const dn = B.y > A.y, y1 = dn ? A.y + A.h : A.y, y2 = dn ? B.y : B.y + B.h, x1 = A.x + A.w / 2, x2 = B.x + B.w / 2, m = (y1 + y2) / 2; d = `M${x1} ${y1}C${x1} ${m} ${x2} ${m} ${x2} ${y2}`; }
let p = wireEl[a + b]; if (!p){ p = document.createElementNS('http://www.w3.org/2000/svg', 'path'); wires.appendChild(p); wireEl[a + b] = p; }
p.setAttribute('d', d);
});
}
/* ---------- the view: zoom z and pan (px, py), so a point x on the canvas shows at x * z + px ---------- */
const Z = [.25, .33, .5, .67, .75, .9, 1, 1.25, 1.5, 2, 3, 4], MIN = Z[0], MAX = Z[Z.length - 1];
let z = 1, px = 0, py = 0, moved = false;
const lim = v => Math.max(MIN, Math.min(MAX, v)), pct = () => Math.round(z * 100) + '%';
function apply(anim){
if (cpFor) closeCp();
cv.classList.toggle('anim', !!anim && !REDUCE);
world.style.transform = `translate(${px}px,${py}px) scale(${z})`;
// the dots move and scale with the view, and spread out again when it is zoomed far out
const g = 16 * z * (z < .5 ? 2 : 1); cv.style.backgroundSize = `${g}px ${g}px`; cv.style.backgroundPosition = `${px}px ${py}px`;
zl.textContent = pct(); zl.setAttribute('aria-label', `Zoom ${pct()}, reset to 100%`); $('.ctb-zl', menu).textContent = pct();
const lo = z <= MIN + 1e-3, hi = z >= MAX - 1e-3;
[zoB, $('[data-a="out"]', menu)].forEach(b => b.setAttribute('aria-disabled', lo));
[ziB, $('[data-a="in"]', menu)].forEach(b => b.setAttribute('aria-disabled', hi));
}
function zoomAt(nz, cx, cy, anim){ nz = lim(nz); const wx = (cx - px) / z, wy = (cy - py) / z; z = nz; px = cx - wx * z; py = cy - wy * z; moved = true; apply(anim); }
// the room the workflow has: the canvas above the toolbar
const room = () => ({ W:cv.clientWidth, H:cv.clientHeight - bar.offsetHeight - 20 });
const mid = () => { const { W, H } = room(); return [W / 2, H / 2]; };
const next = d => { const s = d > 0 ? Z.find(x => x > z + 1e-3) : [...Z].reverse().find(x => x < z - 1e-3); return s === undefined ? z : s; };
function zoomBy(d){ const nz = next(d); if (nz === z) return; zoomAt(nz, ...mid(), true); say('Zoom ' + pct()); emit('canvas-zoom', { zoom:z }); }
function zoomReset(){ zoomAt(1, ...mid(), true); say('Zoom 100%'); emit('canvas-zoom', { zoom:z }); }
// fit: the whole workflow in the room above the toolbar, centred, never more than 150% (on mount, never past 100%)
function fit(anim, cap = 1.5){
const { W, H } = room(), P = 24, bs = N.map(box);
const x0 = Math.min(...bs.map(b => b.x)), y0 = Math.min(...bs.map(b => b.y)), x1 = Math.max(...bs.map(b => b.x + b.w)), y1 = Math.max(...bs.map(b => b.y + b.h));
z = lim(Math.min((W - 2 * P) / (x1 - x0), (H - 2 * P) / (y1 - y0), cap));
px = (W - (x1 - x0) * z) / 2 - x0 * z; py = (H - (y1 - y0) * z) / 2 - y0 * z;
apply(anim);
}
function fitNow(){ fit(true); moved = true; say('Fit to screen, ' + pct()); emit('canvas-zoom', { zoom:z }); }
/* ---------- tools: Select, Hand and Lasso are one radio group; Comment is a toggle beside them ---------- */
const NAMES = { select:'Select', hand:'Hand', lasso:'Lasso' };
let tool = 'select', commenting = false;
function setTool(t, focus){
tool = t; root.dataset.tool = t;
radios.forEach(b => { const on = b.dataset.t === t; b.setAttribute('aria-checked', on); b.tabIndex = on ? 0 : -1; });
if (focus) $(`[data-t="${t}"]`, bar).focus({ preventScroll:true });
setComment(false, true); say(NAMES[t] + ' tool'); emit('canvas-tool', { tool:t });
}
function setComment(on, quiet){
if (commenting === on) return; commenting = on; root.classList.toggle('cm', on);
cmB.setAttribute('aria-pressed', on); $('[data-a="comment"]', menu).setAttribute('aria-checked', on);
if (!quiet) say(on ? 'Comment: click the canvas to place it' : 'Comment off');
}
radios.forEach(b => b.onclick = () => setTool(b.dataset.t));
// the arrow keys move through the tools and pick each one (focus follows the pick); Home and End jump to the ends
c.on($('.ctb-tools', r), 'keydown', e => {
const i = radios.indexOf(document.activeElement); if (i < 0) return;
const d = { ArrowRight:1, ArrowDown:1, ArrowLeft:-1, ArrowUp:-1 }[e.key], j = d ? (i + d + radios.length) % radios.length : e.key === 'Home' ? 0 : e.key === 'End' ? radios.length - 1 : -1;
if (j < 0) return; e.preventDefault(); e.stopPropagation(); setTool(radios[j].dataset.t, true);
});
cmB.onclick = () => setComment(!commenting);
fitB.onclick = fitNow;
zoB.onclick = () => zoomBy(-1); ziB.onclick = () => zoomBy(1); zl.onclick = zoomReset;
$$('.mi', menu).forEach(m => m.onclick = () => ({ comment:() => setComment(!commenting), fit:fitNow, out:() => zoomBy(-1), in:() => zoomBy(1), reset:zoomReset })[m.dataset.a]());
/* ---------- the canvas: Hand (or the middle button) pans, Select picks and moves steps or draws a box, Lasso draws a
shape round what it picks, and Comment drops a pin. Ctrl or ⌘ with the wheel, or a pinch, zooms at the pointer ---------- */
let sel = new Set(), drag = null, pins = 0;
function select(ids){ sel = new Set(ids); N.forEach(n => el(n.id).classList.toggle('sel', sel.has(n.id))); }
const pt = e => { const b = cv.getBoundingClientRect(); return [e.clientX - b.left, e.clientY - b.top]; };
const screen = n => { const b = box(n); return { l:b.x * z + px, t:b.y * z + py, r:(b.x + b.w) * z + px, b:(b.y + b.h) * z + py }; };
const inside = ([x, y], poly) => { let k = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++){ const [xi, yi] = poly[i], [xj, yj] = poly[j]; if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) k = !k; } return k; };
function pin([x, y]){
const n = ++pins, p = html(`<button class="ctb-pin" aria-label="Comment ${n}" aria-haspopup="dialog" aria-expanded="false">${n}</button>`);
p.style.left = x + 'px'; p.style.top = y + 'px'; p.dataset.n = n; p.dataset.x = x; p.dataset.y = y; world.appendChild(p);
p.onclick = e => { e.stopPropagation(); cpFor === p ? closeCp(true) : openCp(p, e.isTrusted); };
// Delete or Backspace on a pin deletes it straight away
p.onkeydown = e => { if (e.key === 'Delete' || e.key === 'Backspace'){ e.preventDefault(); e.stopPropagation(); unpin(p); } else if (e.key === 'Escape' && cpFor === p){ e.preventDefault(); e.stopPropagation(); closeCp(true); } };
setComment(false, true); say(`Comment ${n} placed`); emit('canvas-comment', { x:Math.round(x), y:Math.round(y) });
return p;
}
/* ---------- a pin opens its comment, with Delete: the pin fades out and the focus goes back to the canvas ---------- */
const cp = $('.ctb-cp', r), cpDel = $('.ctb-del', r); let cpFor = null; cp.inert = true;
// which step a pin sits on or nearest to, so the comment says where it is
const near = p => { const x = +p.dataset.x, y = +p.dataset.y; let best = null, bd = Infinity;
N.forEach(n => { const b = box(n), dx = Math.max(b.x - x, 0, x - b.x - b.w), dy = Math.max(b.y - y, 0, y - b.y - b.h), d = Math.hypot(dx, dy); if (d < bd){ bd = d; best = n; } });
return bd === 0 ? 'On ' + best.t : bd < 80 ? 'Near ' + best.t : 'On the canvas'; };
function openCp(p, focus){
if (cpFor) cpFor.setAttribute('aria-expanded', 'false');
cpFor = p; p.setAttribute('aria-expanded', 'true');
const n = p.dataset.n; $('.ctb-ct', cp).textContent = 'Comment ' + n; $('.ctb-cw', cp).textContent = near(p);
cp.setAttribute('aria-label', 'Comment ' + n); cpDel.setAttribute('aria-label', 'Delete comment ' + n);
// beside the pin, kept inside the room above the toolbar
const W = cv.clientWidth, H = cv.clientHeight - bar.offsetHeight - 20, pb = p.getBoundingClientRect(), cb = cv.getBoundingClientRect(), pw = cp.offsetWidth, ph = cp.offsetHeight, G = 8, M = 8;
let x = pb.right - cb.left + G, y = pb.top - cb.top;
if (x + pw > W - M) x = pb.left - cb.left - G - pw;
cp.style.left = Math.max(M, Math.min(W - pw - M, x)) + 'px'; cp.style.top = Math.max(M, Math.min(H - ph, y)) + 'px';
cp.classList.add('open'); cp.inert = false;
if (focus) cpDel.focus({ preventScroll:true });
}
function closeCp(back){
if (!cpFor) return; const p = cpFor, was = cp.contains(document.activeElement);
cp.classList.remove('open'); cp.inert = true; p.setAttribute('aria-expanded', 'false'); cpFor = null;
if (p.isConnected && (back || was)) p.focus({ preventScroll:true });
}
function unpin(p){
const n = p.dataset.n, was = cp.contains(document.activeElement) || document.activeElement === p;
if (cpFor === p){ cp.classList.remove('open'); cp.inert = true; cpFor = null; }
p.classList.add('gone'); setTimeout(() => p.remove(), REDUCE ? 0 : 180);
if (was) cv.focus({ preventScroll:true });
say(`Comment ${n} deleted`); emit('canvas-comment-delete', { n:+n });
}
cpDel.onclick = e => { e.stopPropagation(); if (cpFor) unpin(cpFor); };
c.on(cp, 'keydown', e => { if (e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); closeCp(true); } });
c.on(cp, 'pointerdown', e => e.stopPropagation());
c.on(document, 'pointerdown', e => { if (cpFor && !cp.contains(e.target) && e.target !== cpFor) closeCp(); });
// a pointer in the component hides the canvas's focus ring until the next key
c.on(root, 'pointerdown', () => cv.classList.add('ptr'), true); c.on(root, 'keydown', () => cv.classList.remove('ptr'), true);
/* the canvas never traps the page: until the person has engaged with the component (a click in it, a tap in it, or the
keyboard's focus coming into it) a finger on the canvas belongs to the page, so it scrolls past as usual, and one that
lifts without scrolling is a tap, which engages it. Once engaged (.engaged), a finger works the tool as the mouse does */
let engaged = false, lastKey = -1e9;
const engage = () => { if (engaged) return; engaged = true; cv.classList.add('engaged'); };
c.on(document, 'keydown', e => { if (e.isTrusted) lastKey = e.timeStamp; }, true);
c.on(root, 'pointerdown', e => { if (e.isTrusted && e.pointerType !== 'touch') engage(); }, true);
c.on(root, 'pointerup', e => { if (e.isTrusted && e.pointerType === 'touch') engage(); }, true);
c.on(root, 'focusin', e => { if (e.isTrusted && e.timeStamp - lastKey < 100) engage(); }, true);
c.on(cv, 'pointerdown', e => {
if ((e.button !== 0 && e.button !== 1) || (e.pointerType === 'touch' && !engaged)) return;
// a pin is a button: pressing it opens its comment, whatever the tool
if (e.button === 0 && e.target.closest('.ctb-pin')) return;
const p = pt(e), node = e.target.closest('.ctb-n');
// a click on the canvas gives it the focus, so the shortcuts work straight away
if (e.isTrusted) cv.focus({ preventScroll:true });
e.preventDefault();
if (commenting && e.button === 0) return pin([(p[0] - px) / z, (p[1] - py) / z]);
const kind = e.button === 1 || tool === 'hand' ? 'pan' : tool === 'select' ? (node ? 'move' : 'box') : 'lasso';
if (kind === 'move'){ const id = node.dataset.id; if (e.shiftKey){ const s = new Set(sel); s.has(id) ? s.delete(id) : s.add(id); select(s); } else if (!sel.has(id)) select([id]); }
else if (kind !== 'pan' && !e.shiftKey) select([]);
drag = { kind, p0:p, px, py, pts:[p], from:N.map(n => [n.x, n.y]), base:new Set(sel) };
if (kind === 'pan') cv.classList.add('pan');
try { cv.setPointerCapture(e.pointerId); } catch(err){}
});
c.on(cv, 'pointermove', e => {
if (!drag) return;
const p = pt(e), dx = p[0] - drag.p0[0], dy = p[1] - drag.p0[1];
if (drag.kind === 'pan'){ px = drag.px + dx; py = drag.py + dy; moved = true; apply(false); }
else if (drag.kind === 'move'){
N.forEach((n, i) => { if (!sel.has(n.id)) return; n.x = drag.from[i][0] + dx / z; n.y = drag.from[i][1] + dy / z; const e2 = el(n.id); e2.style.left = n.x + 'px'; e2.style.top = n.y + 'px'; if (small) n.um = [n.x, n.y]; else n.uw = [n.x, n.y]; });
drawWires();
} else if (drag.kind === 'box'){
const x = Math.min(p[0], drag.p0[0]), y = Math.min(p[1], drag.p0[1]), w = Math.abs(dx), h = Math.abs(dy);
mq.hidden = false; mq.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px`;
select([...drag.base, ...N.filter(n => { const s = screen(n); return s.r > x && s.l < x + w && s.b > y && s.t < y + h; }).map(n => n.id)]);
} else {
drag.pts.push(p); lz.setAttribute('d', 'M' + drag.pts.map(q => q.map(Math.round).join(' ')).join('L') + 'Z');
select([...drag.base, ...N.filter(n => { const s = screen(n); return inside([(s.l + s.r) / 2, (s.t + s.b) / 2], drag.pts); }).map(n => n.id)]);
}
});
const end = () => {
if (!drag) return; const k = drag.kind; drag = null;
cv.classList.remove('pan'); mq.hidden = true; lz.setAttribute('d', '');
if (k === 'box' || k === 'lasso' || k === 'move') say(sel.size ? `${sel.size} of ${N.length} steps selected` : 'Nothing selected');
};
c.on(cv, 'pointerup', end); c.on(cv, 'pointercancel', end);
c.on(cv, 'wheel', e => { if (!e.ctrlKey && !e.metaKey) return; e.preventDefault(); zoomAt(z * Math.exp(-e.deltaY / 300), ...pt(e), false); }, { passive:false });
/* ---------- the keyboard: V, H and L pick a tool, C comments, Shift+1 fits, Shift+0 is 100%, minus and plus zoom, the
arrow keys pan while the canvas has the focus, and Escape leaves Comment or clears the selection ---------- */
c.on(root, 'keydown', e => {
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || menu.contains(e.target) || cp.contains(e.target) || /^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;
const k = e.key, low = k.length === 1 ? k.toLowerCase() : '';
if (e.shiftKey && e.code === 'Digit1') fitNow();
else if (e.shiftKey && e.code === 'Digit0') zoomReset();
else if (k === '+' || k === '=') zoomBy(1);
else if (k === '-' || k === '_') zoomBy(-1);
else if (!e.shiftKey && 'vhl'.includes(low) && low) setTool({ v:'select', h:'hand', l:'lasso' }[low], radios.includes(e.target));
else if (!e.shiftKey && low === 'c') setComment(!commenting);
else if (e.target === cv && /^Arrow/.test(k)){ const d = e.shiftKey ? 120 : 40; px += k === 'ArrowLeft' ? d : k === 'ArrowRight' ? -d : 0; py += k === 'ArrowUp' ? d : k === 'ArrowDown' ? -d : 0; moved = true; apply(true); }
else if (k === 'Escape' && (commenting || sel.size)){ if (commenting) setComment(false); else { select([]); say('Nothing selected'); } }
else return;
e.preventDefault();
});
/* ---------- Run: the steps light up one after another along their wires, then it stops by itself; Stop ends it early ---------- */
let runId = 0, running = false;
function setRun(on){ running = on; runB.classList.toggle('stop', on); runB.innerHTML = I(on ? 'stop' : 'play', 'xs') + `<span>${on ? 'Stop' : 'Run'}</span>`; }
const mark = (id, s) => {
const e = el(id); e.classList.toggle('run', s === 'run'); let b = $('.s', e);
if (!s){ if (b) b.remove(); return; }
if (!b) b = e.appendChild(html('<span class="s" aria-hidden="true"></span>'));
b.innerHTML = s === 'run' ? '<span class="spin"></span>' : I('check', 'xs');
};
function run(){
const id = ++runId; setRun(true); ORDER.forEach(x => mark(x, null)); Object.values(wireEl).forEach(p => p.classList.remove('lit'));
say('Running the workflow'); emit('canvas-run', {});
c.run(async () => {
const w = async ms => { await c.wait(ms); if (id !== runId) throw 'stop'; };
for (let i = 0; i < ORDER.length; i++){
mark(ORDER[i], 'run'); await w(i ? 700 : 450); mark(ORDER[i], 'ok');
if (i < ORDER.length - 1){ wireEl[ORDER[i] + ORDER[i + 1]].classList.add('lit'); await w(250); }
}
await w(500); setRun(false); say(`Run finished: ${ORDER.length} of ${ORDER.length} steps passed`); emit('canvas-stop', { finished:true });
});
}
function stop(){ runId++; setRun(false); ORDER.forEach(x => { if (el(x).classList.contains('run')) mark(x, null); }); say('Run stopped'); emit('canvas-stop', { finished:false }); }
runB.onclick = () => running ? stop() : run();
// the view fits the workflow until someone moves it; a canvas that changes between wide and narrow fits it again
layout(); fit(false, 1);
let lw = cv.clientWidth;
const ro = new ResizeObserver(() => { const W = cv.clientWidth; if (W && W !== lw){ const was = small; lw = W; layout(); if (!moved || was !== small) fit(false, 1); } });
ro.observe(cv); ro.observe(bar); c.offs.push(() => ro.disconnect());
// the preview's story: take the hand and drag the workflow aside, zoom in twice, fit it back to the screen, then run it
c.story(async s => {
const shown = b => b.getClientRects().length > 0;
// a button a narrow toolbar keeps in More is pressed from there
const press = async (b, a) => {
if (shown(b)) return s.tap(b);
if (!menu.classList.contains('open')){ await s.tap(more); await s.beat(1); }
await s.tap($(`.mi[data-a="${a}"]`, menu));
};
await s.beat(3);
await s.tap(radios[1]); await s.beat(1);
const aim = html('<i class="ctb-aim" aria-hidden="true"></i>'); cv.appendChild(aim);
const at = (x, y) => { aim.style.left = Math.round(x) + 'px'; aim.style.top = Math.round(y) + 'px'; };
// on a narrow canvas the column moves a little down and zooms out instead, so no step ever leaves the canvas
const W = cv.clientWidth, H = cv.clientHeight, D = small ? 0 : Math.min(140, W * .22), V = small ? 32 : 24;
at(W * .6, H * .3); await s.point(aim, false);
const hand = $('.hand', r); if (hand) hand.classList.add('down'); await s.beat(.5);
at(W * .6 - D, H * .3 + V); px -= D; py += V; moved = true; apply(true);
await s.point(aim, false); if (hand) hand.classList.remove('down'); await s.beat(2);
const zb = small ? zoB : ziB, za = small ? 'out' : 'in';
await press(zb, za); await s.beat(1.5);
await press(zb, za); await s.beat(2);
await press(fitB, 'fit'); await s.beat(2);
await s.tap(radios[0]); await s.beat(1);
await s.tap(runB); s.away();
await s.until(() => !running); await s.beat(3);
});
});
Questions
What people ask about it.
- Does Run execute the workflow?
- No. In React it calls
onRun; your code runs the steps, setsrunningand each node'sstate, and adds the wires it has reached tolit. The HTML demo plays a pretend run and firescanvas-runandcanvas-stopfor you to hook into. - Can I draw my own canvas content?
- Yes.
nodesandedgesdraw the step cards and their wires, andchildrenis drawn on the canvas under them, in canvas pixels at 100%, so it pans and zooms with them. - How do I keep the view between visits?
- Control
viewand save whatonViewChangegives you: the zoom and the pan. Pass null to go back to fitting the steps. - Why do some buttons move into More?
- So the toolbar never wraps or runs past a narrow canvas. Run and the three pointer tools always stay on the bar; Comment and Fit go first, then the zoom buttons.
More in Workspace
Navigation, search, settings, models and automations around the agent.