Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx
iris 68e289fff5 swarm-ui: rewire a notification flow by drag or keyboard
The flow editor can now change a flow's shape on the canvas, not only
through the inspector's fields:

- drag a box onto another to move it and its branch there; a drop the
  save would refuse shows the target dashed red and gives the reason;
- a palette of node kinds adds a node below the picked box on click, or
  below any box it is dragged onto; it offers what the controller lists;
- arrow keys walk the tree, M picks a box up and Enter drops it under
  the focused one, Alt+Up/Down reorder a node among its siblings, Delete
  removes a branch; focus follows the edit;
- each box with siblings says when it runs, and the inspector can move
  it earlier or later, since the router runs children in edge order;
- request_reviewer is a second leaf kind beside todo, with a reviewer
  field;
- leaving with unsaved edits asks first, also for in-app links and the
  back button (a router hook runs navigation past a page's guard).
2026-10-24 15:08:48 +02:00

400 lines
13 KiB
TypeScript

// <FlowCanvas> — 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<SVGSVGElement>(null);
const dragRef = useRef<Drag | null>(null);
const [, redraw] = useState(0);
// The box picked up with the keyboard, and the one the cursor is on.
const [carrying, setCarrying] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(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<SVGGElement>(`[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<string, "up" | "down" | "left" | "right"> = {
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 (
<div class="notify-canvas-wrap">
<div class="notify-palette" role="group" aria-label="add a node">
<span class="notify-hint">add</span>
{palette.map((p) => (
<button
key={p.kind}
type="button"
class={`notify-chip notify-chip-${p.kind}`}
title={`${p.description ? `${p.description}. ` : ""}Click to add below the picked box, or drag onto a box.`}
onPointerDown={(e) => startDrag(e, { kind: "add", node: p.kind })}
onClick={() => {
if (selected && canAddBelow) edits.onAdd(p.kind, selected);
else edits.onRefused("pick a match or the source to add below");
}}
>
{p.kind}
</button>
))}
</div>
<div class="notify-canvas">
<svg
ref={svgRef}
width={layout.width}
height={layout.height}
viewBox={`0 0 ${layout.width} ${layout.height}`}
role="group"
aria-label={`${flow.name}, ${flow.nodes.length} nodes`}
aria-describedby="notify-canvas-help"
>
{layout.edges.map((e) => {
const mid = (e.x1 + e.x2) / 2;
return (
<path
key={`${e.from}>${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 (
<g
key={p.id}
data-node={p.id}
class={`notify-box notify-box-${node.kind}${p.id === selected ? " notify-box-selected" : ""}${held || carrying === p.id ? " notify-box-carried" : ""}${holdClass(p.id)}`}
transform={`translate(${p.x} ${p.y})`}
tabIndex={p.id === tabStop ? 0 : -1}
role="button"
aria-pressed={p.id === selected}
aria-label={`${node.kind} ${p.id}: ${text}${order}`}
onPointerDown={(e) => {
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)}
>
<title>{text}</title>
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
<text class="notify-box-kind" x="10" y="17">
{node.kind}
</text>
{pos.count > 1 ? (
<text
class="notify-box-kind notify-box-order"
x={NODE_WIDTH - 10}
y="17"
text-anchor="end"
>
{`runs ${ordinal(pos.index + 1)}`}
</text>
) : null}
<text class="notify-box-text" x="10" y="35">
{ellipsize(text, TEXT_CHARS)}
</text>
</g>
);
})}
</svg>
</div>
<p class="notify-hint" id="notify-canvas-help">
{HELP}
</p>
<p class="notify-sr" role="status">
{said}
</p>
{drag?.moved ? (
<div
class="notify-ghost"
style={{ left: `${drag.x + 12}px`, top: `${drag.y + 12}px` }}
>
{dragSource?.kind === "move" ? dragSource.id : dragSource?.node}
</div>
) : null}
</div>
);
}