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).
400 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|