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).
127 lines
4 KiB
TypeScript
127 lines
4 KiB
TypeScript
// Where the flow editor's canvas puts each node: the tree runs left to
|
|
// right, one column per level, a leaf per row, and a parent sits at the
|
|
// middle of its children. Computed from the flow on every edit, so nothing
|
|
// about the layout is stored.
|
|
import type { Flow } from "./FlowTypes.js";
|
|
|
|
export const NODE_WIDTH = 190;
|
|
export const NODE_HEIGHT = 46;
|
|
const GAP_X = 56;
|
|
const GAP_Y = 14;
|
|
const PAD = 12;
|
|
|
|
export interface PlacedNode {
|
|
id: string;
|
|
depth: number;
|
|
// Top-left corner.
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
export interface PlacedEdge {
|
|
from: string;
|
|
to: string;
|
|
// Right-middle of the parent, left-middle of the child.
|
|
x1: number;
|
|
y1: number;
|
|
x2: number;
|
|
y2: number;
|
|
}
|
|
|
|
export interface Layout {
|
|
nodes: PlacedNode[];
|
|
edges: PlacedEdge[];
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
// The node whose box holds the point (`x`, `y`) in canvas coordinates, if any.
|
|
export function nodeAt(
|
|
layout: Layout,
|
|
x: number,
|
|
y: number,
|
|
): string | undefined {
|
|
return layout.nodes.find(
|
|
(n) =>
|
|
x >= n.x && x <= n.x + NODE_WIDTH && y >= n.y && y <= n.y + NODE_HEIGHT,
|
|
)?.id;
|
|
}
|
|
|
|
// The node next to `id` in direction `dir` on the canvas: left is its parent,
|
|
// right its first child, up and down the nearest box above or below it in the
|
|
// same column. Undefined at an edge, so a key press there does nothing.
|
|
export function neighbour(
|
|
layout: Layout,
|
|
flow: Flow,
|
|
id: string,
|
|
dir: "up" | "down" | "left" | "right",
|
|
): string | undefined {
|
|
const me = layout.nodes.find((n) => n.id === id);
|
|
if (!me) return undefined;
|
|
if (dir === "left") return flow.edges.find((e) => e.to === id)?.from;
|
|
if (dir === "right") return flow.edges.find((e) => e.from === id)?.to;
|
|
const column = layout.nodes
|
|
.filter((n) => n.depth === me.depth && n.id !== id)
|
|
.filter((n) => (dir === "up" ? n.y < me.y : n.y > me.y))
|
|
.sort((a, b) => (dir === "up" ? b.y - a.y : a.y - b.y));
|
|
return column[0]?.id;
|
|
}
|
|
|
|
export function layoutFlow(flow: Flow): Layout {
|
|
const children = new Map<string, string[]>();
|
|
for (const e of flow.edges) {
|
|
children.set(e.from, [...(children.get(e.from) ?? []), e.to]);
|
|
}
|
|
const known = new Set(flow.nodes.map((n) => n.id));
|
|
const placed = new Map<string, PlacedNode>();
|
|
let row = 0;
|
|
|
|
// Depth first, so a leaf takes the next row and a parent the middle of its
|
|
// first and last child. `placed` also stops a cycle from looping.
|
|
const place = (id: string, depth: number): number => {
|
|
const kids = (children.get(id) ?? []).filter(
|
|
(k) => known.has(k) && !placed.has(k),
|
|
);
|
|
const at = (r: number): number => PAD + r * (NODE_HEIGHT + GAP_Y);
|
|
const x = PAD + depth * (NODE_WIDTH + GAP_X);
|
|
// Reserve the spot first so a node cannot be reached twice.
|
|
placed.set(id, { id, depth, x, y: 0 });
|
|
// A node with two parents (a draft the save would refuse) is placed under
|
|
// the first that reaches it.
|
|
const ys: number[] = [];
|
|
for (const k of kids) if (!placed.has(k)) ys.push(place(k, depth + 1));
|
|
const y = ys.length === 0 ? at(row++) : (ys[0] + ys[ys.length - 1]) / 2;
|
|
placed.set(id, { id, depth, x, y });
|
|
return y;
|
|
};
|
|
|
|
const root = flow.nodes.find((n) => n.kind === "source");
|
|
if (root) place(root.id, 0);
|
|
// A draft being rewired can hold a node nothing reaches yet: show it below
|
|
// rather than lose it, until it is attached.
|
|
for (const n of flow.nodes) {
|
|
if (!placed.has(n.id)) place(n.id, 0);
|
|
}
|
|
|
|
const nodes = flow.nodes.map((n) => placed.get(n.id) as PlacedNode);
|
|
const edges = flow.edges
|
|
.filter((e) => placed.has(e.from) && placed.has(e.to))
|
|
.map((e) => {
|
|
const a = placed.get(e.from) as PlacedNode;
|
|
const b = placed.get(e.to) as PlacedNode;
|
|
return {
|
|
from: e.from,
|
|
to: e.to,
|
|
x1: a.x + NODE_WIDTH,
|
|
y1: a.y + NODE_HEIGHT / 2,
|
|
x2: b.x,
|
|
y2: b.y + NODE_HEIGHT / 2,
|
|
};
|
|
});
|
|
return {
|
|
nodes,
|
|
edges,
|
|
width: Math.max(0, ...nodes.map((n) => n.x + NODE_WIDTH)) + PAD,
|
|
height: Math.max(0, ...nodes.map((n) => n.y + NODE_HEIGHT)) + PAD,
|
|
};
|
|
}
|