Skip to content
Star on GitHub
WorkspaceCanvas toolbar
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Canvas 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.

$npx designme add canvas-toolbar
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.

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add canvas-toolbar

React

$npx designme add canvas-toolbar --react

The 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.

02

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.
03

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>
  );
}
04

Props

Everything CanvasToolbar takes.

PropTypeWhat it does
nodesrequiredCanvasToolbarNode[]
edgesCanvasToolbarEdge[]
litCanvasToolbarEdge[]The wires a run has reached; they light up in the accent
runningbooleanWhether a run is going: Run turns into Stop, on a red fill
onRun() => voidCalled by Run. The run is yours: set `running`, each node's state and `lit` as it goes, and `running` back to false
onStop() => voidCalled by Stop
runLabelstring
stopLabelstring
toolCanvasToolThe pointer tool. Leave it out to let the toolbar and V, H and L pick it
defaultToolCanvasTool
onToolChange(tool: CanvasTool) => void
commentingbooleanWhether Comment is on: the next click on the canvas places a comment there
defaultCommentingboolean
onCommentingChange(on: boolean) => void
onComment(at: CanvasPoint) => voidCalled where a comment was placed, in canvas pixels at 100%. The pins it shows are `comments`
commentsCanvasPoint[]The pins on the canvas. Leave it out to let the canvas keep the ones placed
viewCanvasView | nullWhere 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
defaultViewCanvasView | null
onViewChange(view: CanvasView | null) => void
selectedstring[]The selected steps, by id
defaultSelectedstring[]
onSelectedChange(ids: string[]) => void
onNodesMove(moves: Record<string, CanvasPoint>, narrow: boolean) => voidCalled when steps are dropped after a drag with Select, with where each now sits and whether the canvas was narrow
zoomStepsnumber[]The zoom levels the buttons and keys step through, from smallest to largest. Defaults to 25% to 400%
labelstringThe canvas's accessible name
refRef<CanvasToolbarHandle>Moves the view from your code, as the buttons and keys do: `panBy`, `zoomIn`, `zoomOut`, `zoomTo`, `fit`
childrenReactNodeMore, drawn on the canvas under the steps, in canvas pixels at 100%
classNamestring
05

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);
  });
});
06

Questions

What people ask about it.

Does Run execute the workflow?
No. In React it calls onRun; your code runs the steps, sets running and each node's state, and adds the wires it has reached to lit. The HTML demo plays a pretend run and fires canvas-run and canvas-stop for you to hook into.
Can I draw my own canvas content?
Yes. nodes and edges draw the step cards and their wires, and children is 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 view and save what onViewChange gives 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.
07

More in Workspace

Navigation, search, settings, models and automations around the agent.

Automation canvasA trigger, a few agent steps, and a run you can watch flow through.
Command paletteJump anywhere or hand a task to the agent from one box.
Memory panelWhat the agent remembers about your team, editable and removable.
Model compareRun one prompt on three models side by side and keep the best.
Agent settingsHow much the agent may do alone, with a save bar for changes.
Tools and connectionsThe tools the agent can reach, and how much it may do with each.
Knowledge sourcesThe files, sites and folders the agent knows, and how far each is indexed.
Scheduled runsTasks the agent runs on a schedule, with their last result and a switch.
All 11 in Workspace →