Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/notifications/FlowLayout.ts
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

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,
};
}