// — a flow drawn as boxes and lines: the source on the left, its // branches to the right, with a palette of node kinds above. // // Rewiring is by pointer or by keyboard, and both end in the same edits the // inspector makes: // - drag a box onto another to move it (and its branch) under that one, or // drag a palette chip onto a box to add a new node under it; // - arrow keys walk the tree, M picks the focused box up and Enter drops it // under the box now focused, Alt+Up/Down changes when a branch runs among // its siblings, Delete removes the box and its branch. // A drop the save would refuse is turned away with the reason, so a bad flow // is never drawn. import { useEffect, useRef, useState } from "preact/hooks"; import { addChild, blankNode, isLeafKind, reparent, siblingPosition, type AddableKind, type Edit, type Limits, } from "./FlowDraft.js"; import { layoutFlow, neighbour, nodeAt, NODE_HEIGHT, NODE_WIDTH, } from "./FlowLayout.js"; import { describeNode, ellipsize, ordinal, type Flow } from "./FlowTypes.js"; const TEXT_CHARS = 30; // How far the pointer must travel before a press counts as a drag. const DRAG_PX = 4; export interface PaletteKind { kind: AddableKind; description: string; } type Source = { kind: "move"; id: string } | { kind: "add"; node: AddableKind }; interface Drag { source: Source; startX: number; startY: number; x: number; y: number; moved: boolean; // The box under the pointer, if any. over: string | undefined; } export interface CanvasEdits { onSelect: (id: string) => void; onAdd: (kind: AddableKind, parentId: string) => void; onMove: (id: string, parentId: string) => void; onReorder: (id: string, delta: -1 | 1) => void; onRemove: (id: string) => void; // A drop or key press the draft turned away, and why. onRefused: (reason: string) => void; } const HELP = "Drag a box onto another to move it there, or drag a kind from above onto a box to add it. " + "Keys: arrows walk the tree, M picks a box up and Enter drops it under the focused box, " + "Alt+Up and Alt+Down change when a branch runs, Delete removes a box and its branch."; export function FlowCanvas({ flow, selected, limits, palette, ...edits }: { flow: Flow; selected: string | null; limits: Limits; palette: PaletteKind[]; } & CanvasEdits) { const layout = layoutFlow(flow); const byId = new Map(flow.nodes.map((n) => [n.id, n])); const svgRef = useRef(null); const dragRef = useRef(null); const [, redraw] = useState(0); // The box picked up with the keyboard, and the one the cursor is on. const [carrying, setCarrying] = useState(null); const [cursor, setCursor] = useState(null); const [said, setSaid] = useState(""); // When a key press last asked for focus to follow the selection, so a stale // request cannot pull focus out of the inspector later. const refocusAt = useRef(0); const wantRefocus = () => { refocusAt.current = Date.now(); }; // What an edit at `over` would do, without doing it. const dryRun = (source: Source, over: string): Edit => source.kind === "move" ? reparent(flow, source.id, over, limits) : addChild(flow, over, blankNode(flow, source.node), limits); // The pointer handlers outlive a render, so they read the latest of these. const latest = useRef({ layout, flow, dryRun, edits }); latest.current = { layout, flow, dryRun, edits }; const focusNode = (id: string) => svgRef.current?.querySelector(`[data-node="${id}"]`)?.focus(); // After an edit that moved or removed the focused box, put focus on the // selected one so the keyboard carries on from where it was. useEffect(() => { if (selected && Date.now() - refocusAt.current < 300) { refocusAt.current = 0; focusNode(selected); } }); const dragging = dragRef.current !== null; useEffect(() => { if (!dragging) return; const overAt = (e: PointerEvent): string | undefined => { const rect = svgRef.current?.getBoundingClientRect(); if (!rect) return undefined; return nodeAt( latest.current.layout, e.clientX - rect.left, e.clientY - rect.top, ); }; const onMove = (e: PointerEvent) => { const d = dragRef.current; if (!d) return; const over = overAt(e); dragRef.current = { ...d, x: e.clientX, y: e.clientY, moved: d.moved || Math.hypot(e.clientX - d.startX, e.clientY - d.startY) > DRAG_PX, over: d.source.kind === "move" && over === d.source.id ? undefined : over, }; redraw((n) => n + 1); }; const end = (drop: boolean) => { const d = dragRef.current; dragRef.current = null; redraw((n) => n + 1); if (!d) return; const { edits: e, dryRun: dry } = latest.current; if (!d.moved) { // A press without travel: a click on a box picks it. A palette chip // is a button, and its own click adds. if (drop && d.source.kind === "move") e.onSelect(d.source.id); return; } if (!drop || !d.over) return; const result = dry(d.source, d.over); if (!result.ok) { e.onRefused(result.reason); return; } if (d.source.kind === "move") e.onMove(d.source.id, d.over); else e.onAdd(d.source.node, d.over); }; const up = () => end(true); const cancel = () => end(false); const key = (e: KeyboardEvent) => { if (e.key === "Escape") cancel(); }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", up); window.addEventListener("pointercancel", cancel); window.addEventListener("keydown", key); return () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", cancel); window.removeEventListener("keydown", key); }; }, [dragging]); const startDrag = (e: PointerEvent, source: Source) => { if (e.button !== 0) return; dragRef.current = { source, startX: e.clientX, startY: e.clientY, x: e.clientX, y: e.clientY, moved: false, over: undefined, }; redraw((n) => n + 1); }; const drag = dragRef.current; const dragSource = drag?.source; // How a box reads while something is held over it. const holdClass = (id: string): string => { if (drag?.moved && dragSource && drag.over === id) { return dryRun(dragSource, id).ok ? " notify-box-target" : " notify-box-refused"; } if (carrying && cursor === id && carrying !== id) { return dryRun({ kind: "move", id: carrying }, id).ok ? " notify-box-target" : " notify-box-refused"; } return ""; }; const drop = (id: string) => { if (!carrying) return; const moving = carrying; const result = reparent(flow, moving, id, limits); setCarrying(null); if (!result.ok) { edits.onRefused(result.reason); setSaid(`Not moved: ${result.reason}.`); return; } wantRefocus(); edits.onMove(moving, id); setSaid(`Moved ${moving} under ${id}.`); }; const onKey = (e: KeyboardEvent, id: string) => { const dirs: Record = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", }; const dir = dirs[e.key]; if (dir) { if (e.altKey && (dir === "up" || dir === "down")) { e.preventDefault(); wantRefocus(); edits.onReorder(id, dir === "up" ? -1 : 1); return; } const next = neighbour(layout, flow, id, dir); if (!next) return; e.preventDefault(); setCursor(next); if (!carrying) edits.onSelect(next); focusNode(next); return; } if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (carrying) drop(id); else edits.onSelect(id); } else if (e.key === "m" || e.key === "M") { const node = byId.get(id); if (!node || node.kind === "source") return; e.preventDefault(); setCarrying(id); setCursor(id); setSaid( `Picked up ${id}. Arrow to the box to move it under, Enter to drop, Escape to cancel.`, ); } else if (e.key === "Escape" && carrying) { e.preventDefault(); setCarrying(null); setSaid(`Put ${carrying} back.`); } else if (e.key === "Delete" || e.key === "Backspace") { const node = byId.get(id); if (!node || node.kind === "source") return; e.preventDefault(); wantRefocus(); edits.onRemove(id); } }; const tabStop = selected ?? flow.nodes.find((n) => n.kind === "source")?.id; const selectedNode = selected ? byId.get(selected) : undefined; const canAddBelow = !!selectedNode && !isLeafKind(selectedNode.kind); return (
add {palette.map((p) => ( ))}
{layout.edges.map((e) => { const mid = (e.x1 + e.x2) / 2; return ( ${e.to}`} class="notify-edge" d={`M${e.x1} ${e.y1} C${mid} ${e.y1} ${mid} ${e.y2} ${e.x2} ${e.y2}`} /> ); })} {layout.nodes.map((p) => { const node = byId.get(p.id); if (!node) return null; const text = describeNode(node); const pos = siblingPosition(flow, p.id); const order = pos.count > 1 ? `, runs ${ordinal(pos.index + 1)} of ${pos.count}` : ""; const held = drag?.moved && dragSource?.kind === "move" && dragSource.id === p.id; return ( { if (node.kind === "source") { // The source cannot move; a press just picks it. if (e.button === 0) edits.onSelect(p.id); return; } e.preventDefault(); (e.currentTarget as SVGGElement).focus(); startDrag(e, { kind: "move", id: p.id }); }} onFocus={() => setCursor(p.id)} onKeyDown={(e) => onKey(e, p.id)} > {text} {node.kind} {pos.count > 1 ? ( {`runs ${ordinal(pos.index + 1)}`} ) : null} {ellipsize(text, TEXT_CHARS)} ); })}

{HELP}

{said}

{drag?.moved ? (
{dragSource?.kind === "move" ? dragSource.id : dragSource?.node}
) : null}
); }