@tangle-network/sandbox-ui 0.134.12 → 0.136.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{WorkflowGraph-Dz6aA2Mp.js → WorkflowGraph-Cfm0Ub4F.js} +992 -62
- package/dist/globals.css +46 -0
- package/dist/{node-output-CHlf3tni.js → node-output-fuk0NV0V.js} +78 -8
- package/dist/styles.css +46 -0
- package/dist/workflows.d.ts +24 -3
- package/dist/workflows.js +2 -2
- package/package.json +6 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { A as triggerNodeIndex, B as fmtTokens, C as COMPACT_NODE_SIZE, D as buildWorkflowGraph, I as shortModel, L as clampPreview, M as worstSeverity, O as isStructuralKind, R as fmtCost, S as statusBorder, _ as emptySlotLabel, a as NodeMark, b as problemSurface, c as RESTING_BORDER, d as STATUS_LABEL, g as edgeColor, h as StatusPill, j as wfEdgeId, l as RESTING_SHADOW, m as StatusFooter, n as classifyOutput, o as ProblemMarker, s as ProblemMessages, t as NodeOutputBody, u as STATUS_COLOR, v as kindAccent, x as problemTitle, y as problemBorder, z as fmtDuration } from "./node-output-
|
|
1
|
+
import { A as triggerNodeIndex, B as fmtTokens, C as COMPACT_NODE_SIZE, D as buildWorkflowGraph, I as shortModel, L as clampPreview, M as worstSeverity, O as isStructuralKind, R as fmtCost, S as statusBorder, _ as emptySlotLabel, a as NodeMark, b as problemSurface, c as RESTING_BORDER, d as STATUS_LABEL, g as edgeColor, h as StatusPill, j as wfEdgeId, l as RESTING_SHADOW, m as StatusFooter, n as classifyOutput, o as ProblemMarker, s as ProblemMessages, t as NodeOutputBody, u as STATUS_COLOR, v as kindAccent, x as problemTitle, y as problemBorder, z as fmtDuration } from "./node-output-fuk0NV0V.js";
|
|
2
2
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
|
-
import { Maximize2, Minimize2, Plus, X } from "lucide-react";
|
|
5
|
-
import { Background, BaseEdge, Controls, EdgeLabelRenderer, Handle, MarkerType, Panel, Position, ReactFlow, getSmoothStepPath, getViewportForBounds, useEdgesState, useNodesState } from "@xyflow/react";
|
|
4
|
+
import { Expand, Maximize2, Minimize2, Plus, Shrink, X } from "lucide-react";
|
|
5
|
+
import { Background, BaseEdge, Controls, EdgeLabelRenderer, Handle, MarkerType, MiniMap, Panel, Position, ReactFlow, ViewportPortal, getSmoothStepPath, getViewportForBounds, useEdgesState, useNodesState, useStore } from "@xyflow/react";
|
|
6
6
|
import "@xyflow/react/dist/style.css";
|
|
7
7
|
//#region src/workflows/flow-graph.ts
|
|
8
8
|
/**
|
|
@@ -48,7 +48,8 @@ const FLOW_GRAPH_OPTION_KEYS = [
|
|
|
48
48
|
"compact",
|
|
49
49
|
"wrap",
|
|
50
50
|
"edges",
|
|
51
|
-
"reserveEdgeInsert"
|
|
51
|
+
"reserveEdgeInsert",
|
|
52
|
+
"itemCounts"
|
|
52
53
|
];
|
|
53
54
|
/**
|
|
54
55
|
* Accept either an options object or a bare `nodeState` map as the second arg, so
|
|
@@ -88,13 +89,17 @@ function buildFlowGraph(yaml, optionsOrNodeState) {
|
|
|
88
89
|
compact,
|
|
89
90
|
...options.wrap ? { wrap: true } : {},
|
|
90
91
|
...options.edges ? { edges: options.edges } : {},
|
|
91
|
-
...options.reserveEdgeInsert ? { reserveEdgeInsert: true } : {}
|
|
92
|
+
...options.reserveEdgeInsert ? { reserveEdgeInsert: true } : {},
|
|
93
|
+
...options.itemCounts ? { itemCounts: options.itemCounts } : {}
|
|
92
94
|
});
|
|
93
95
|
const isLR = direction === "LR";
|
|
94
96
|
const sourcePosition = isLR ? Position.Right : Position.Bottom;
|
|
95
97
|
const targetPosition = isLR ? Position.Left : Position.Top;
|
|
96
98
|
return {
|
|
97
99
|
error: graph.error,
|
|
100
|
+
compact,
|
|
101
|
+
direction,
|
|
102
|
+
...graph.spacing ? { spacing: graph.spacing } : {},
|
|
98
103
|
nodes: graph.nodes.map((n) => ({
|
|
99
104
|
id: n.id,
|
|
100
105
|
type: "wfNode",
|
|
@@ -287,6 +292,520 @@ function clearOfNodeBoxes(point, boxes, direction, half) {
|
|
|
287
292
|
return Math.abs(up) <= Math.abs(down) ? up : down;
|
|
288
293
|
}
|
|
289
294
|
//#endregion
|
|
295
|
+
//#region src/workflows/elk-layout.ts
|
|
296
|
+
/** One ELK instance per page. `null` once loading failed, so a host without
|
|
297
|
+
* the peer pays for one failed import, not one per render. */
|
|
298
|
+
let elkInstance;
|
|
299
|
+
function loadElk() {
|
|
300
|
+
elkInstance ??= import("elkjs/lib/elk.bundled.js").then((mod) => {
|
|
301
|
+
const Elk = mod.default;
|
|
302
|
+
return new Elk();
|
|
303
|
+
}).catch(() => null);
|
|
304
|
+
return elkInstance;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Whether the graph is worth the ELK pass: anything but a plain line. A line
|
|
308
|
+
* has nothing to order or route, and `model.ts` folds a long one into rows
|
|
309
|
+
* that read in order, which a layered layout would not improve.
|
|
310
|
+
*/
|
|
311
|
+
function wantsElkLayout(graph) {
|
|
312
|
+
if (graph.error || graph.nodes.length < 3) return false;
|
|
313
|
+
const outdegree = /* @__PURE__ */ new Map();
|
|
314
|
+
const indegree = /* @__PURE__ */ new Map();
|
|
315
|
+
for (const e of graph.edges) {
|
|
316
|
+
if (e.data?.whenLabel !== void 0 || e.data?.backEdge) return true;
|
|
317
|
+
outdegree.set(e.source, (outdegree.get(e.source) ?? 0) + 1);
|
|
318
|
+
indegree.set(e.target, (indegree.get(e.target) ?? 0) + 1);
|
|
319
|
+
}
|
|
320
|
+
for (const n of outdegree.values()) if (n > 1) return true;
|
|
321
|
+
for (const n of indegree.values()) if (n > 1) return true;
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* A join that runs when ANY of its inputs fails (`join: any_failed`) is a
|
|
326
|
+
* failure handler, not a step in the flow: the factory's `failed` node is fed
|
|
327
|
+
* by ten steps across every stage. Drawn as ten real edges it pulls the whole
|
|
328
|
+
* layout toward one corner and buries the flow under lines that only matter
|
|
329
|
+
* when something breaks. Its inputs are laid out and drawn as one caption on
|
|
330
|
+
* the node instead; the node itself still lays out and opens like any other.
|
|
331
|
+
*/
|
|
332
|
+
function isFailureSink(graph, nodeId) {
|
|
333
|
+
return graph.nodes.find((n) => n.id === nodeId)?.data.config?.join === "any_failed" && !graph.edges.some((e) => e.source === nodeId);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* ELK options for one flow direction. Every value here was chosen against the
|
|
337
|
+
* shapes the catalogue ships (a 22-step factory with two loops and a ten-way
|
|
338
|
+
* failure join, a four-lane review) rather than taken as defaults:
|
|
339
|
+
* - model order breaks ties, so a layer reads in the order the author wrote
|
|
340
|
+
* the steps unless reordering removes a crossing;
|
|
341
|
+
* - cycle breaking follows the same order, so the edge drawn as the loop's
|
|
342
|
+
* return is the one that points back up the definition, as the model's own
|
|
343
|
+
* back-edge classification marks it;
|
|
344
|
+
* - orthogonal routing keeps every edge on horizontal and vertical runs, and
|
|
345
|
+
* a guard chip sits on its edge's longest run (see the renderer). Chips are
|
|
346
|
+
* NOT given to ELK as label boxes: a label is laid out as a node of its own
|
|
347
|
+
* layer, which on the factory doubled the layer count and the width.
|
|
348
|
+
*/
|
|
349
|
+
function elkLayoutOptions(direction, spacing) {
|
|
350
|
+
return {
|
|
351
|
+
"elk.algorithm": "layered",
|
|
352
|
+
"elk.direction": direction === "LR" ? "RIGHT" : "DOWN",
|
|
353
|
+
"elk.edgeRouting": "ORTHOGONAL",
|
|
354
|
+
"elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
|
|
355
|
+
"elk.layered.cycleBreaking.strategy": "MODEL_ORDER",
|
|
356
|
+
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
|
|
357
|
+
"elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
|
|
358
|
+
"elk.layered.nodePlacement.bk.fixedAlignment": "BALANCED",
|
|
359
|
+
"elk.layered.feedbackEdges": "true",
|
|
360
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": String(Math.max(spacing.rank, 40)),
|
|
361
|
+
"elk.spacing.nodeNode": String(Math.max(spacing.cross, 20)),
|
|
362
|
+
"elk.spacing.edgeNode": "14",
|
|
363
|
+
"elk.spacing.edgeEdge": "10",
|
|
364
|
+
"elk.layered.spacing.edgeNodeBetweenLayers": "14",
|
|
365
|
+
"elk.layered.spacing.edgeEdgeBetweenLayers": "10",
|
|
366
|
+
"elk.padding": "[top=0,left=0,bottom=0,right=0]",
|
|
367
|
+
"elk.separateConnectedComponents": "false"
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Cut a long layered graph into rows stacked toward `aspect` (width / height
|
|
372
|
+
* of the canvas it is drawn in), so a fourteen-layer pipeline uses the
|
|
373
|
+
* panel's height instead of shrinking to fit one strip. ELK picks the cuts
|
|
374
|
+
* and routes the edges that cross them.
|
|
375
|
+
*/
|
|
376
|
+
function wrapOptions(aspect) {
|
|
377
|
+
return {
|
|
378
|
+
"elk.aspectRatio": String(aspect),
|
|
379
|
+
"elk.layered.wrapping.strategy": "MULTI_EDGE",
|
|
380
|
+
"elk.layered.wrapping.additionalEdgeSpacing": "16",
|
|
381
|
+
"elk.layered.wrapping.correctionFactor": "1.0"
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/** The graph in ELK's terms: every node at its authoritative size, and every
|
|
385
|
+
* edge except a failure sink's inputs. */
|
|
386
|
+
/**
|
|
387
|
+
* Where a node's edges attach, relative to its box: the handle the card
|
|
388
|
+
* draws. A compact node's box is much bigger than its tile (the name sits
|
|
389
|
+
* under it), so routing to the box would stop every edge short of the tile it
|
|
390
|
+
* points at. One port per side, at the handle, makes ELK route to exactly
|
|
391
|
+
* where the card draws its connection point.
|
|
392
|
+
*/
|
|
393
|
+
function handleOffset(width, height, direction, compact) {
|
|
394
|
+
if (direction === "LR") {
|
|
395
|
+
const y = compact ? 38 : height / 2;
|
|
396
|
+
return {
|
|
397
|
+
inX: 0,
|
|
398
|
+
inY: y,
|
|
399
|
+
outX: width,
|
|
400
|
+
outY: y
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
const x = compact ? 38 : width / 2;
|
|
404
|
+
return {
|
|
405
|
+
inX: x,
|
|
406
|
+
inY: 0,
|
|
407
|
+
outX: x,
|
|
408
|
+
outY: height
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/** Fewer items than this stay a column of lanes; more become a grid. */
|
|
412
|
+
const GRID_MIN_ITEMS = 5;
|
|
413
|
+
const GRID_GAP = 16;
|
|
414
|
+
function itemGrids(graph, direction) {
|
|
415
|
+
const grids = [];
|
|
416
|
+
for (const source of graph.nodes) {
|
|
417
|
+
if (source.data.kind !== "foreach") continue;
|
|
418
|
+
const prefix = `${source.id}-b`;
|
|
419
|
+
const members = graph.nodes.filter((n) => n.id.startsWith(prefix) && /^\d+$/.test(n.id.slice(prefix.length)));
|
|
420
|
+
if (members.length < GRID_MIN_ITEMS) continue;
|
|
421
|
+
const ids = new Set(members.map((n) => n.id));
|
|
422
|
+
const targets = [...new Set(graph.edges.filter((e) => ids.has(e.source) && !ids.has(e.target)).map((e) => e.target))];
|
|
423
|
+
const cellW = Math.max(...members.map((n) => n.width ?? 0));
|
|
424
|
+
const cellH = Math.max(...members.map((n) => n.height ?? 0));
|
|
425
|
+
const cols = direction === "LR" ? Math.ceil(Math.sqrt(members.length * 1.6)) : 2;
|
|
426
|
+
const rows = Math.ceil(members.length / cols);
|
|
427
|
+
grids.push({
|
|
428
|
+
source: source.id,
|
|
429
|
+
members: members.map((n) => n.id),
|
|
430
|
+
targets,
|
|
431
|
+
cols,
|
|
432
|
+
cellW,
|
|
433
|
+
cellH,
|
|
434
|
+
width: cols * cellW + (cols - 1) * GRID_GAP + 2 * FRAME_PAD,
|
|
435
|
+
height: rows * cellH + (rows - 1) * GRID_GAP + 2 * FRAME_PAD + FRAME_CAPTION
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return grids;
|
|
439
|
+
}
|
|
440
|
+
const gridNodeId = (source) => `grid:${source}`;
|
|
441
|
+
function toElkGraph(graph, direction, wrapAspect, compact = false, foldFailureInputs = true) {
|
|
442
|
+
const spacing = graph.spacing ?? {
|
|
443
|
+
rank: 72,
|
|
444
|
+
cross: 24
|
|
445
|
+
};
|
|
446
|
+
const sinks = foldedSinks(graph, foldFailureInputs);
|
|
447
|
+
const grids = itemGrids(graph, direction);
|
|
448
|
+
const inGrid = new Set(grids.flatMap((g) => g.members));
|
|
449
|
+
const isLR = direction === "LR";
|
|
450
|
+
const nodeChild = (n) => {
|
|
451
|
+
const width = n.width ?? 0;
|
|
452
|
+
const height = n.height ?? 0;
|
|
453
|
+
const at = handleOffset(width, height, direction, compact);
|
|
454
|
+
return {
|
|
455
|
+
id: n.id,
|
|
456
|
+
width,
|
|
457
|
+
height,
|
|
458
|
+
layoutOptions: {
|
|
459
|
+
"elk.portConstraints": "FIXED_POS",
|
|
460
|
+
...sinks.has(n.id) ? { "elk.layered.layering.layerConstraint": "LAST" } : {}
|
|
461
|
+
},
|
|
462
|
+
ports: [{
|
|
463
|
+
id: `${n.id}:in`,
|
|
464
|
+
x: at.inX,
|
|
465
|
+
y: at.inY,
|
|
466
|
+
width: 0,
|
|
467
|
+
height: 0
|
|
468
|
+
}, {
|
|
469
|
+
id: `${n.id}:out`,
|
|
470
|
+
x: at.outX,
|
|
471
|
+
y: at.outY,
|
|
472
|
+
width: 0,
|
|
473
|
+
height: 0
|
|
474
|
+
}]
|
|
475
|
+
};
|
|
476
|
+
};
|
|
477
|
+
const gridChild = (g) => ({
|
|
478
|
+
id: gridNodeId(g.source),
|
|
479
|
+
width: g.width,
|
|
480
|
+
height: g.height,
|
|
481
|
+
layoutOptions: { "elk.portConstraints": "FIXED_POS" },
|
|
482
|
+
ports: [{
|
|
483
|
+
id: `${gridNodeId(g.source)}:in`,
|
|
484
|
+
x: isLR ? 0 : g.width / 2,
|
|
485
|
+
y: isLR ? g.height / 2 : 0,
|
|
486
|
+
width: 0,
|
|
487
|
+
height: 0
|
|
488
|
+
}, {
|
|
489
|
+
id: `${gridNodeId(g.source)}:out`,
|
|
490
|
+
x: isLR ? g.width : g.width / 2,
|
|
491
|
+
y: isLR ? g.height / 2 : g.height,
|
|
492
|
+
width: 0,
|
|
493
|
+
height: 0
|
|
494
|
+
}]
|
|
495
|
+
});
|
|
496
|
+
return {
|
|
497
|
+
id: "root",
|
|
498
|
+
layoutOptions: {
|
|
499
|
+
...elkLayoutOptions(direction, spacing),
|
|
500
|
+
...wrapAspect !== void 0 ? wrapOptions(wrapAspect) : {}
|
|
501
|
+
},
|
|
502
|
+
children: graph.nodes.flatMap((n) => {
|
|
503
|
+
const grid = grids.find((g) => g.members[0] === n.id);
|
|
504
|
+
if (grid) return [gridChild(grid)];
|
|
505
|
+
if (inGrid.has(n.id)) return [];
|
|
506
|
+
return [nodeChild(n)];
|
|
507
|
+
}),
|
|
508
|
+
edges: [...graph.edges.filter((e) => !sinks.has(e.target) && !inGrid.has(e.source) && !inGrid.has(e.target)).map((e) => ({
|
|
509
|
+
id: e.id,
|
|
510
|
+
sources: [`${e.source}:out`],
|
|
511
|
+
targets: [`${e.target}:in`]
|
|
512
|
+
})), ...grids.flatMap((g) => [{
|
|
513
|
+
id: `${gridNodeId(g.source)}:enter`,
|
|
514
|
+
sources: [`${g.source}:out`],
|
|
515
|
+
targets: [`${gridNodeId(g.source)}:in`]
|
|
516
|
+
}, ...g.targets.filter((t) => !sinks.has(t)).map((t) => ({
|
|
517
|
+
id: `${gridNodeId(g.source)}:leave:${t}`,
|
|
518
|
+
sources: [`${gridNodeId(g.source)}:out`],
|
|
519
|
+
targets: [`${t}:in`]
|
|
520
|
+
}))])]
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
/** The middle of a polyline's longest straight run: the one place on a routed
|
|
524
|
+
* edge with room for a chip, and distinct per edge even when several leave
|
|
525
|
+
* the same node together. */
|
|
526
|
+
function longestRunMidpoint(points) {
|
|
527
|
+
let best = 0;
|
|
528
|
+
let at = {
|
|
529
|
+
x: points[0].x,
|
|
530
|
+
y: points[0].y
|
|
531
|
+
};
|
|
532
|
+
for (let i = 1; i < points.length; i += 1) {
|
|
533
|
+
const a = points[i - 1];
|
|
534
|
+
const b = points[i];
|
|
535
|
+
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
|
536
|
+
if (len > best) {
|
|
537
|
+
best = len;
|
|
538
|
+
at = {
|
|
539
|
+
x: (a.x + b.x) / 2,
|
|
540
|
+
y: (a.y + b.y) / 2
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return at;
|
|
545
|
+
}
|
|
546
|
+
/** Flatten an ELK edge's sections into one polyline. */
|
|
547
|
+
function edgePoints(edge) {
|
|
548
|
+
const points = [];
|
|
549
|
+
for (const section of edge.sections ?? []) points.push(section.startPoint, ...section.bendPoints ?? [], section.endPoint);
|
|
550
|
+
return points;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Apply an ELK result to the graph: new positions, a route on every edge, and
|
|
554
|
+
* frames around parallel fan-outs. Pure, so the mapping is checkable without
|
|
555
|
+
* running ELK. Returns null when the result does not cover the graph (a node
|
|
556
|
+
* ELK did not place), which the caller treats as "keep the first layout".
|
|
557
|
+
*/
|
|
558
|
+
/** The failure handlers whose inputs this layout folds. */
|
|
559
|
+
function foldedSinks(graph, fold) {
|
|
560
|
+
if (!fold) return /* @__PURE__ */ new Set();
|
|
561
|
+
return new Set(graph.nodes.filter((n) => isFailureSink(graph, n.id)).map((n) => n.id));
|
|
562
|
+
}
|
|
563
|
+
function applyElkLayout(graph, laid, direction, foldFailureInputs = true) {
|
|
564
|
+
const placed = /* @__PURE__ */ new Map();
|
|
565
|
+
for (const child of laid.children ?? []) {
|
|
566
|
+
if (child.x === void 0 || child.y === void 0) return null;
|
|
567
|
+
placed.set(child.id, {
|
|
568
|
+
x: child.x,
|
|
569
|
+
y: child.y
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
const grids = itemGrids(graph, direction);
|
|
573
|
+
const gridFrames = [];
|
|
574
|
+
for (const g of grids) {
|
|
575
|
+
const box = placed.get(gridNodeId(g.source));
|
|
576
|
+
if (!box) return null;
|
|
577
|
+
g.members.forEach((id, k) => {
|
|
578
|
+
const col = k % g.cols;
|
|
579
|
+
const row = Math.floor(k / g.cols);
|
|
580
|
+
placed.set(id, {
|
|
581
|
+
x: box.x + FRAME_PAD + col * (g.cellW + GRID_GAP),
|
|
582
|
+
y: box.y + FRAME_PAD + FRAME_CAPTION + row * (g.cellH + GRID_GAP)
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
const concurrency = graph.nodes.find((n) => n.id === g.source)?.data.config?.maxConcurrency;
|
|
586
|
+
const tileInset = graph.compact && graph.direction === "LR" ? (n) => ((n.width ?? 0) - 76) / 2 : () => 0;
|
|
587
|
+
const nodeById = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
588
|
+
const connectors = (laid.edges ?? []).filter((e) => e.id.startsWith(`${gridNodeId(g.source)}:`)).map((e) => {
|
|
589
|
+
const points = edgePoints(e).map((p) => ({
|
|
590
|
+
x: p.x,
|
|
591
|
+
y: p.y
|
|
592
|
+
}));
|
|
593
|
+
const from = nodeById.get(e.sources[0].replace(/:out$/, ""));
|
|
594
|
+
const to = nodeById.get(e.targets[0].replace(/:in$/, ""));
|
|
595
|
+
if (points.length >= 2 && from) points[0].x -= tileInset(from);
|
|
596
|
+
if (points.length >= 2 && to) points[points.length - 1].x += tileInset(to);
|
|
597
|
+
return { points };
|
|
598
|
+
}).filter((c) => c.points.length >= 2);
|
|
599
|
+
gridFrames.push({
|
|
600
|
+
id: `frame:${gridNodeId(g.source)}`,
|
|
601
|
+
x: box.x,
|
|
602
|
+
y: box.y,
|
|
603
|
+
width: g.width,
|
|
604
|
+
height: g.height,
|
|
605
|
+
lanes: g.members.length,
|
|
606
|
+
agents: g.members.filter((id) => graph.nodes.find((n) => n.id === id)?.data.kind === "agent.run").length,
|
|
607
|
+
forEach: typeof concurrency === "number" ? { concurrency } : {},
|
|
608
|
+
connectors
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
const inGrid = new Set(grids.flatMap((g) => g.members));
|
|
612
|
+
if (graph.nodes.some((n) => !placed.has(n.id))) return null;
|
|
613
|
+
const routed = /* @__PURE__ */ new Map();
|
|
614
|
+
for (const e of laid.edges ?? []) routed.set(e.id, e);
|
|
615
|
+
const sinks = foldedSinks(graph, foldFailureInputs);
|
|
616
|
+
const sinkInputs = /* @__PURE__ */ new Map();
|
|
617
|
+
for (const e of graph.edges) if (sinks.has(e.target)) sinkInputs.set(e.target, (sinkInputs.get(e.target) ?? 0) + 1);
|
|
618
|
+
const nodes = graph.nodes.map((n) => {
|
|
619
|
+
const inputs = sinkInputs.get(n.id);
|
|
620
|
+
return {
|
|
621
|
+
...n,
|
|
622
|
+
position: placed.get(n.id),
|
|
623
|
+
...inputs !== void 0 ? { data: {
|
|
624
|
+
...n.data,
|
|
625
|
+
subtitle: `If any of ${inputs} step${inputs === 1 ? "" : "s"} fails`
|
|
626
|
+
} } : {}
|
|
627
|
+
};
|
|
628
|
+
});
|
|
629
|
+
const edges = graph.edges.map((e) => {
|
|
630
|
+
if (inGrid.has(e.source) || inGrid.has(e.target)) return {
|
|
631
|
+
...e,
|
|
632
|
+
hidden: true
|
|
633
|
+
};
|
|
634
|
+
if (sinkInputs.has(e.target)) return {
|
|
635
|
+
...e,
|
|
636
|
+
hidden: true,
|
|
637
|
+
...e.data ? { data: {
|
|
638
|
+
...e.data,
|
|
639
|
+
foldedFailureInput: true
|
|
640
|
+
} } : {}
|
|
641
|
+
};
|
|
642
|
+
const r = routed.get(e.id);
|
|
643
|
+
const points = r ? edgePoints(r) : [];
|
|
644
|
+
if (points.length < 2) return e;
|
|
645
|
+
const from = placed.get(e.source);
|
|
646
|
+
const to = placed.get(e.target);
|
|
647
|
+
return {
|
|
648
|
+
...e,
|
|
649
|
+
type: WF_EDGE_TYPE,
|
|
650
|
+
data: {
|
|
651
|
+
...e.data ?? { kind: "spine" },
|
|
652
|
+
route: {
|
|
653
|
+
points: points.map((p) => ({
|
|
654
|
+
x: p.x,
|
|
655
|
+
y: p.y
|
|
656
|
+
})),
|
|
657
|
+
label: longestRunMidpoint(points),
|
|
658
|
+
from,
|
|
659
|
+
to
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
});
|
|
664
|
+
const laidOut = {
|
|
665
|
+
...graph,
|
|
666
|
+
nodes,
|
|
667
|
+
edges,
|
|
668
|
+
engine: "elk"
|
|
669
|
+
};
|
|
670
|
+
const groups = [...gridFrames, ...parallelFrames(laidOut)];
|
|
671
|
+
return groups.length > 0 ? {
|
|
672
|
+
...laidOut,
|
|
673
|
+
groups
|
|
674
|
+
} : laidOut;
|
|
675
|
+
}
|
|
676
|
+
/** Air between a frame and the cards inside it, and the band its caption sits
|
|
677
|
+
* in above them. */
|
|
678
|
+
const FRAME_PAD = 10;
|
|
679
|
+
const FRAME_CAPTION = 18;
|
|
680
|
+
/**
|
|
681
|
+
* Frames around PARALLEL fan-outs: two or more steps that start together
|
|
682
|
+
* because the same steps finished, over unguarded edges. A guarded fan-out is
|
|
683
|
+
* a choice, not parallel work, and gets no frame — its edge chips already say
|
|
684
|
+
* which way it goes. Each member is followed down its own lane (a single
|
|
685
|
+
* unguarded successor that only it feeds) so a review-then-verify lane is
|
|
686
|
+
* framed whole rather than just its first step.
|
|
687
|
+
*
|
|
688
|
+
* A frame that would enclose a card outside the fan-out is dropped: a frame
|
|
689
|
+
* claiming an unrelated step is worse than no frame.
|
|
690
|
+
*/
|
|
691
|
+
function parallelFrames(graph) {
|
|
692
|
+
const incomingSource = (key) => key.includes("|") ? null : key;
|
|
693
|
+
const forward = graph.edges.filter((e) => !e.data?.backEdge && !e.hidden);
|
|
694
|
+
const preds = /* @__PURE__ */ new Map();
|
|
695
|
+
const succs = /* @__PURE__ */ new Map();
|
|
696
|
+
for (const e of forward) {
|
|
697
|
+
preds.set(e.target, [...preds.get(e.target) ?? [], e]);
|
|
698
|
+
succs.set(e.source, [...succs.get(e.source) ?? [], e]);
|
|
699
|
+
}
|
|
700
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
701
|
+
const siblings = /* @__PURE__ */ new Map();
|
|
702
|
+
for (const n of graph.nodes) {
|
|
703
|
+
if (triggerNodeIndex(n.id) !== null) continue;
|
|
704
|
+
const incoming = preds.get(n.id) ?? [];
|
|
705
|
+
if (incoming.length === 0) continue;
|
|
706
|
+
if (incoming.some((e) => e.data?.whenLabel !== void 0)) continue;
|
|
707
|
+
if (n.data.config && "if" in n.data.config) continue;
|
|
708
|
+
const key = incoming.map((e) => e.source).sort().join("|");
|
|
709
|
+
siblings.set(key, [...siblings.get(key) ?? [], n.id]);
|
|
710
|
+
}
|
|
711
|
+
const frames = [];
|
|
712
|
+
for (const [key, members] of siblings) {
|
|
713
|
+
if (members.length < 2) continue;
|
|
714
|
+
const framed = /* @__PURE__ */ new Set();
|
|
715
|
+
for (const start of members) {
|
|
716
|
+
let at = start;
|
|
717
|
+
framed.add(at);
|
|
718
|
+
for (let hop = 0; hop < graph.nodes.length; hop += 1) {
|
|
719
|
+
const out = succs.get(at) ?? [];
|
|
720
|
+
if (out.length !== 1 || out[0].data?.whenLabel !== void 0) break;
|
|
721
|
+
const next = out[0].target;
|
|
722
|
+
if ((preds.get(next) ?? []).length !== 1 || framed.has(next)) break;
|
|
723
|
+
const config = byId.get(next)?.data.config;
|
|
724
|
+
if (config && "if" in config) break;
|
|
725
|
+
framed.add(next);
|
|
726
|
+
at = next;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const boxes = [...framed].map((id) => byId.get(id)).filter(Boolean);
|
|
730
|
+
const x0 = Math.min(...boxes.map((n) => n.position.x)) - FRAME_PAD;
|
|
731
|
+
const y0 = Math.min(...boxes.map((n) => n.position.y)) - FRAME_PAD - FRAME_CAPTION;
|
|
732
|
+
const x1 = Math.max(...boxes.map((n) => n.position.x + (n.width ?? 0))) + FRAME_PAD;
|
|
733
|
+
const y1 = Math.max(...boxes.map((n) => n.position.y + (n.height ?? 0))) + FRAME_PAD;
|
|
734
|
+
if (graph.nodes.some((n) => {
|
|
735
|
+
if (framed.has(n.id)) return false;
|
|
736
|
+
const nx1 = n.position.x + (n.width ?? 0);
|
|
737
|
+
const ny1 = n.position.y + (n.height ?? 0);
|
|
738
|
+
return n.position.x < x1 && nx1 > x0 && n.position.y < y1 && ny1 > y0;
|
|
739
|
+
})) continue;
|
|
740
|
+
const source = incomingSource(key);
|
|
741
|
+
const sourceNode = source ? byId.get(source) : void 0;
|
|
742
|
+
const concurrency = sourceNode?.data.config?.maxConcurrency;
|
|
743
|
+
frames.push({
|
|
744
|
+
...sourceNode?.data.kind === "foreach" ? { forEach: typeof concurrency === "number" ? { concurrency } : {} } : {},
|
|
745
|
+
id: `frame:${key}`,
|
|
746
|
+
x: x0,
|
|
747
|
+
y: y0,
|
|
748
|
+
width: x1 - x0,
|
|
749
|
+
height: y1 - y0,
|
|
750
|
+
lanes: members.length,
|
|
751
|
+
agents: boxes.filter((n) => n.data.kind === "agent.run").length
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
return frames;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Lay the graph out with ELK. Resolves to null when ELK is unavailable or the
|
|
758
|
+
* layout fails, and the caller keeps the synchronous layout.
|
|
759
|
+
*/
|
|
760
|
+
async function layoutWithElk(graph, direction, compact, canvasAspect = DEFAULT_CANVAS_ASPECT, options = {}) {
|
|
761
|
+
const fold = options.foldFailureInputs ?? true;
|
|
762
|
+
const elk = await loadElk();
|
|
763
|
+
if (!elk) return null;
|
|
764
|
+
try {
|
|
765
|
+
const plain = applyElkLayout(graph, await elk.layout(toElkGraph(graph, direction, void 0, compact, fold)), direction, fold);
|
|
766
|
+
if (!plain) return null;
|
|
767
|
+
const plainAspect = boundsAspect(plain);
|
|
768
|
+
if (direction !== "LR" || laidOutNodeCount(graph) < WRAP_MIN_NODES || plainAspect < canvasAspect * WRAP_WHEN_WIDER) return plain;
|
|
769
|
+
const wrapped = applyElkLayout(graph, await elk.layout(toElkGraph(graph, direction, canvasAspect, compact, fold)), direction, fold);
|
|
770
|
+
if (!wrapped) return plain;
|
|
771
|
+
return fitScale(wrapped, canvasAspect) > fitScale(plain, canvasAspect) ? wrapped : plain;
|
|
772
|
+
} catch {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
/** The aspect a graph is framed in when the host says nothing: the workflow
|
|
777
|
+
* page's canvas, about 1100 x 480. */
|
|
778
|
+
const DEFAULT_CANVAS_ASPECT = 2.2;
|
|
779
|
+
/** How much wider than its canvas a single band must be before wrapping is
|
|
780
|
+
* tried. */
|
|
781
|
+
const WRAP_WHEN_WIDER = 1.6;
|
|
782
|
+
/** The nodes ELK places: a grid of items counts once. */
|
|
783
|
+
function laidOutNodeCount(graph) {
|
|
784
|
+
const grids = itemGrids(graph, "LR");
|
|
785
|
+
return graph.nodes.length - grids.reduce((sum, g) => sum + g.members.length, 0) + grids.length;
|
|
786
|
+
}
|
|
787
|
+
/** Below this many nodes a graph reads better as one band: cut into rows, a
|
|
788
|
+
* six-step loop sends its edges across the row break for no gain in size. */
|
|
789
|
+
const WRAP_MIN_NODES = 12;
|
|
790
|
+
function bounds(graph) {
|
|
791
|
+
const xs = graph.nodes.flatMap((n) => [n.position.x, n.position.x + (n.width ?? 0)]);
|
|
792
|
+
const ys = graph.nodes.flatMap((n) => [n.position.y, n.position.y + (n.height ?? 0)]);
|
|
793
|
+
return {
|
|
794
|
+
width: Math.max(...xs) - Math.min(...xs),
|
|
795
|
+
height: Math.max(...ys) - Math.min(...ys)
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
function boundsAspect(graph) {
|
|
799
|
+
const { width, height } = bounds(graph);
|
|
800
|
+
return width / Math.max(height, 1);
|
|
801
|
+
}
|
|
802
|
+
/** The scale a graph fits a unit-height canvas of `aspect` at: bigger is a
|
|
803
|
+
* more readable graph. */
|
|
804
|
+
function fitScale(graph, aspect) {
|
|
805
|
+
const { width, height } = bounds(graph);
|
|
806
|
+
return Math.min(aspect / Math.max(width, 1), 1 / Math.max(height, 1));
|
|
807
|
+
}
|
|
808
|
+
//#endregion
|
|
290
809
|
//#region src/workflows/WorkflowGraph.tsx
|
|
291
810
|
/**
|
|
292
811
|
* n8n-style node-graph view of a workflow, rendered from its YAML definition.
|
|
@@ -341,6 +860,22 @@ function fitZoomFloor(compact) {
|
|
|
341
860
|
return compact ? .55 : .35;
|
|
342
861
|
}
|
|
343
862
|
/**
|
|
863
|
+
* The floor for a graph ELK laid out. ELK wraps a long graph into rows sized
|
|
864
|
+
* for the canvas, so its fit is the whole workflow at once — the shape Drew
|
|
865
|
+
* asked to see — and names stay legible through semantic zoom
|
|
866
|
+
* (useCompactTitleScale). Clamping it at the compact floor instead would crop
|
|
867
|
+
* a layout built to be seen whole.
|
|
868
|
+
*/
|
|
869
|
+
const ELK_FIT_FLOOR = .28;
|
|
870
|
+
/**
|
|
871
|
+
* The fit floor a laid-out graph opens at. Only a left-to-right ELK layout
|
|
872
|
+
* is built to be seen whole; a top-to-bottom one is a phone's column, read by
|
|
873
|
+
* panning down it from the top, so it keeps the readable compact floor.
|
|
874
|
+
*/
|
|
875
|
+
function elkFloor(graph, direction) {
|
|
876
|
+
return graph.engine === "elk" && direction === "LR" ? ELK_FIT_FLOOR : void 0;
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
344
879
|
* The rectangle the LAID-OUT nodes occupy, in flow coordinates.
|
|
345
880
|
*
|
|
346
881
|
* Read straight off the layouter's own position + size rather than through
|
|
@@ -378,6 +913,39 @@ function layoutBounds(nodes) {
|
|
|
378
913
|
height: maxY - minY
|
|
379
914
|
};
|
|
380
915
|
}
|
|
916
|
+
/** What the camera frames: the nodes, and the parallel frames drawn around
|
|
917
|
+
* them, whose captions sit above the first lane. */
|
|
918
|
+
function graphBounds(graph) {
|
|
919
|
+
const nodes = layoutBounds(graph.nodes);
|
|
920
|
+
if (!graph.groups || graph.groups.length === 0) return nodes;
|
|
921
|
+
const x0 = Math.min(nodes.x, ...graph.groups.map((g) => g.x));
|
|
922
|
+
const y0 = Math.min(nodes.y, ...graph.groups.map((g) => g.y));
|
|
923
|
+
const x1 = Math.max(nodes.x + nodes.width, ...graph.groups.map((g) => g.x + g.width));
|
|
924
|
+
const y1 = Math.max(nodes.y + nodes.height, ...graph.groups.map((g) => g.y + g.height));
|
|
925
|
+
return {
|
|
926
|
+
x: x0,
|
|
927
|
+
y: y0,
|
|
928
|
+
width: x1 - x0,
|
|
929
|
+
height: y1 - y0
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* The camera a graph opens at: {@link framingViewport} over the graph and its
|
|
934
|
+
* frames, at the floor its layout calls for. A top-to-bottom column wider
|
|
935
|
+
* than a phone is read down its spine, so the camera centres on the entry
|
|
936
|
+
* step rather than on the column's left edge, where only loop returns run.
|
|
937
|
+
*/
|
|
938
|
+
function openingViewport(graph, width, height, compact, direction) {
|
|
939
|
+
const bounds = graphBounds(graph);
|
|
940
|
+
const view = framingViewport(bounds, width, height, compact, elkFloor(graph, direction));
|
|
941
|
+
const entry = graph.nodes[0];
|
|
942
|
+
if (direction !== "TB" || !entry || bounds.width * view.zoom <= width) return view;
|
|
943
|
+
const entryCenter = entry.position.x + (entry.width ?? 0) / 2;
|
|
944
|
+
return {
|
|
945
|
+
...view,
|
|
946
|
+
x: width / 2 - entryCenter * view.zoom
|
|
947
|
+
};
|
|
948
|
+
}
|
|
381
949
|
/**
|
|
382
950
|
* The camera that frames `bounds` inside a `width`×`height` canvas.
|
|
383
951
|
*
|
|
@@ -397,8 +965,8 @@ function layoutBounds(nodes) {
|
|
|
397
965
|
* is already centered with slack on both sides, so `max` returns it untouched,
|
|
398
966
|
* and nothing here has to re-derive React Flow's own reading of `padding`.
|
|
399
967
|
*/
|
|
400
|
-
function framingViewport(bounds, width, height, compact) {
|
|
401
|
-
const fit = getViewportForBounds(bounds, width, height,
|
|
968
|
+
function framingViewport(bounds, width, height, compact, floor = fitZoomFloor(compact)) {
|
|
969
|
+
const fit = getViewportForBounds(bounds, width, height, floor, fitZoomCeiling(compact), FIT_VIEW.padding);
|
|
402
970
|
/** Slide `offset` just far enough that the bounds' leading edge is on canvas. */
|
|
403
971
|
const anchored = (offset, origin) => offset + Math.max(0, -(offset + origin * fit.zoom));
|
|
404
972
|
return {
|
|
@@ -657,6 +1225,7 @@ function WorkflowNode({ id, data, sourcePosition, targetPosition }) {
|
|
|
657
1225
|
className: handleClass,
|
|
658
1226
|
style: compact ? compactHandleStyle(sourcePos, isLR) : void 0
|
|
659
1227
|
})] });
|
|
1228
|
+
const titleScale = useCompactTitleScale(compact);
|
|
660
1229
|
if (compact) return /* @__PURE__ */ jsxs("div", {
|
|
661
1230
|
className: `relative flex h-full w-full ${isLR ? "flex-col items-center" : "flex-row items-center"}`,
|
|
662
1231
|
children: [
|
|
@@ -712,10 +1281,14 @@ function WorkflowNode({ id, data, sourcePosition, targetPosition }) {
|
|
|
712
1281
|
children: [
|
|
713
1282
|
/* @__PURE__ */ jsx("div", {
|
|
714
1283
|
className: "truncate font-semibold text-[12px] text-foreground leading-tight",
|
|
1284
|
+
style: titleScale > 1 ? {
|
|
1285
|
+
fontSize: 12 * titleScale,
|
|
1286
|
+
...isLR ? { marginInline: -16 } : {}
|
|
1287
|
+
} : void 0,
|
|
715
1288
|
title: d.title,
|
|
716
1289
|
children: d.title
|
|
717
1290
|
}),
|
|
718
|
-
subtitle && /* @__PURE__ */ jsx("div", {
|
|
1291
|
+
subtitle && titleScale <= SUBTITLE_MAX_SCALE && /* @__PURE__ */ jsx("div", {
|
|
719
1292
|
className: "truncate text-[11px] text-muted-foreground leading-tight",
|
|
720
1293
|
title: subtitle,
|
|
721
1294
|
children: subtitle
|
|
@@ -944,6 +1517,263 @@ function releasedOnCanvas(at, frame) {
|
|
|
944
1517
|
* then never revisited.
|
|
945
1518
|
*/
|
|
946
1519
|
const NodeBoxesContext = createContext([]);
|
|
1520
|
+
/** A node's current top-left, read from the store so an edge notices its end
|
|
1521
|
+
* moving (a drag, a tween). Null while the node is not in the store. */
|
|
1522
|
+
function useInternalNodePosition(id) {
|
|
1523
|
+
return useStore(useCallback((state) => {
|
|
1524
|
+
const node = state.nodeLookup.get(id);
|
|
1525
|
+
return node ? node.position : null;
|
|
1526
|
+
}, [id]), (a, b) => a === b || a !== null && b !== null && samePoint(a, b));
|
|
1527
|
+
}
|
|
1528
|
+
function samePoint(a, b) {
|
|
1529
|
+
return Math.abs(a.x - b.x) < .5 && Math.abs(a.y - b.y) < .5;
|
|
1530
|
+
}
|
|
1531
|
+
/** Corner radius of a routed edge's bends, capped by the shorter of the two
|
|
1532
|
+
* segments that meet there so a tight jog never overshoots. */
|
|
1533
|
+
const ROUTE_CORNER = 8;
|
|
1534
|
+
/** An orthogonal polyline as an SVG path with rounded bends. */
|
|
1535
|
+
function roundedPolyline(points) {
|
|
1536
|
+
if (points.length === 0) return "";
|
|
1537
|
+
let d = `M ${points[0].x} ${points[0].y}`;
|
|
1538
|
+
for (let i = 1; i < points.length - 1; i += 1) {
|
|
1539
|
+
const prev = points[i - 1];
|
|
1540
|
+
const at = points[i];
|
|
1541
|
+
const next = points[i + 1];
|
|
1542
|
+
const inLen = Math.hypot(at.x - prev.x, at.y - prev.y);
|
|
1543
|
+
const outLen = Math.hypot(next.x - at.x, next.y - at.y);
|
|
1544
|
+
const r = Math.min(ROUTE_CORNER, inLen / 2, outLen / 2);
|
|
1545
|
+
if (r < .5) {
|
|
1546
|
+
d += ` L ${at.x} ${at.y}`;
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1549
|
+
const before = {
|
|
1550
|
+
x: at.x - (at.x - prev.x) / inLen * r,
|
|
1551
|
+
y: at.y - (at.y - prev.y) / inLen * r
|
|
1552
|
+
};
|
|
1553
|
+
const after = {
|
|
1554
|
+
x: at.x + (next.x - at.x) / outLen * r,
|
|
1555
|
+
y: at.y + (next.y - at.y) / outLen * r
|
|
1556
|
+
};
|
|
1557
|
+
d += ` L ${before.x} ${before.y} Q ${at.x} ${at.y} ${after.x} ${after.y}`;
|
|
1558
|
+
}
|
|
1559
|
+
const last = points[points.length - 1];
|
|
1560
|
+
return `${d} L ${last.x} ${last.y}`;
|
|
1561
|
+
}
|
|
1562
|
+
/** The point halfway along a polyline, where an unlabelled route's insert
|
|
1563
|
+
* control or problem chip sits. */
|
|
1564
|
+
function polylineMidpoint(points) {
|
|
1565
|
+
const lengths = points.slice(1).map((p, i) => Math.hypot(p.x - points[i].x, p.y - points[i].y));
|
|
1566
|
+
let remaining = lengths.reduce((sum, len) => sum + len, 0) / 2;
|
|
1567
|
+
for (let i = 0; i < lengths.length; i += 1) {
|
|
1568
|
+
if (remaining <= lengths[i] && lengths[i] > 0) {
|
|
1569
|
+
const t = remaining / lengths[i];
|
|
1570
|
+
return {
|
|
1571
|
+
x: points[i].x + (points[i + 1].x - points[i].x) * t,
|
|
1572
|
+
y: points[i].y + (points[i + 1].y - points[i].y) * t
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
remaining -= lengths[i];
|
|
1576
|
+
}
|
|
1577
|
+
return points[points.length - 1] ?? {
|
|
1578
|
+
x: 0,
|
|
1579
|
+
y: 0
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* The graph `buildFlowGraph` laid out, re-laid by ELK when it branches,
|
|
1584
|
+
* guards or loops (see elk-layout.ts).
|
|
1585
|
+
*
|
|
1586
|
+
* While ELK works on a new structure the graph ON SCREEN stays, so a relayout
|
|
1587
|
+
* is one move from what the reader sees to the new layout — never a detour
|
|
1588
|
+
* through an older laid-out graph. Before anything has been shown the canvas
|
|
1589
|
+
* stays hidden (`ready` false) rather than flashing a layout it is about to
|
|
1590
|
+
* replace, but only until {@link REVEAL_DEADLINE_MS}: ELK that is slow, absent
|
|
1591
|
+
* or failing then leaves the synchronous layout on screen, so no failure can
|
|
1592
|
+
* leave the canvas blank. `ready` gates the first reveal only.
|
|
1593
|
+
*/
|
|
1594
|
+
function useElkLayout(base, direction, compact, canvasAspect, foldFailureInputs) {
|
|
1595
|
+
const wanted = wantsElkLayout(base);
|
|
1596
|
+
const [laid, setLaid] = useState(null);
|
|
1597
|
+
const [deadlinePassed, setDeadlinePassed] = useState(false);
|
|
1598
|
+
const shownRef = useRef(null);
|
|
1599
|
+
useEffect(() => {
|
|
1600
|
+
if (!wanted || canvasAspect === null) return;
|
|
1601
|
+
let live = true;
|
|
1602
|
+
layoutWithElk(base, direction, compact, canvasAspect, { foldFailureInputs }).then((graph) => {
|
|
1603
|
+
if (live) setLaid({
|
|
1604
|
+
base,
|
|
1605
|
+
graph: graph ?? base
|
|
1606
|
+
});
|
|
1607
|
+
});
|
|
1608
|
+
return () => {
|
|
1609
|
+
live = false;
|
|
1610
|
+
};
|
|
1611
|
+
}, [
|
|
1612
|
+
base,
|
|
1613
|
+
direction,
|
|
1614
|
+
wanted,
|
|
1615
|
+
canvasAspect,
|
|
1616
|
+
foldFailureInputs
|
|
1617
|
+
]);
|
|
1618
|
+
useEffect(() => {
|
|
1619
|
+
if (deadlinePassed) return;
|
|
1620
|
+
const timer = setTimeout(() => setDeadlinePassed(true), REVEAL_DEADLINE_MS);
|
|
1621
|
+
return () => clearTimeout(timer);
|
|
1622
|
+
}, [deadlinePassed]);
|
|
1623
|
+
let graph;
|
|
1624
|
+
let settled;
|
|
1625
|
+
if (!wanted) {
|
|
1626
|
+
graph = base;
|
|
1627
|
+
settled = true;
|
|
1628
|
+
} else if (laid?.base === base) {
|
|
1629
|
+
graph = laid.graph;
|
|
1630
|
+
settled = true;
|
|
1631
|
+
} else {
|
|
1632
|
+
graph = shownRef.current ?? base;
|
|
1633
|
+
settled = false;
|
|
1634
|
+
}
|
|
1635
|
+
const ready = settled || shownRef.current !== null || deadlinePassed;
|
|
1636
|
+
if (ready) shownRef.current = graph;
|
|
1637
|
+
return {
|
|
1638
|
+
graph,
|
|
1639
|
+
ready
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
/** How long a first ELK layout may keep the canvas hidden before the
|
|
1643
|
+
* synchronous layout is shown instead. */
|
|
1644
|
+
const REVEAL_DEADLINE_MS = 600;
|
|
1645
|
+
/**
|
|
1646
|
+
* The canvas's shape, in three buckets: portrait (a phone), square, and the
|
|
1647
|
+
* wide panel the workflow pages use. Bucketed so a resize re-lays the graph
|
|
1648
|
+
* out only when its shape class changes, never on every pixel. Null until
|
|
1649
|
+
* the canvas has been measured.
|
|
1650
|
+
*
|
|
1651
|
+
* Returns a CALLBACK ref: the canvas element can mount after the graph does
|
|
1652
|
+
* (a first render with no YAML shows a placeholder instead), and a mount-time
|
|
1653
|
+
* effect would never see it — leaving the canvas unmeasured and ELK unrun.
|
|
1654
|
+
*/
|
|
1655
|
+
function useCanvasAspect() {
|
|
1656
|
+
const [aspect, setAspect] = useState(null);
|
|
1657
|
+
const observerRef = useRef(null);
|
|
1658
|
+
return [aspect, useCallback((el) => {
|
|
1659
|
+
observerRef.current?.disconnect();
|
|
1660
|
+
observerRef.current = null;
|
|
1661
|
+
if (!el) return;
|
|
1662
|
+
const measure = () => {
|
|
1663
|
+
const { width, height } = el.getBoundingClientRect();
|
|
1664
|
+
if (width === 0 || height === 0) return;
|
|
1665
|
+
const raw = width / height;
|
|
1666
|
+
setAspect(raw < .9 ? .6 : raw < 1.6 ? 1.2 : 2.2);
|
|
1667
|
+
};
|
|
1668
|
+
measure();
|
|
1669
|
+
if (typeof ResizeObserver === "undefined") {
|
|
1670
|
+
setAspect((current) => current ?? 2.2);
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
observerRef.current = new ResizeObserver(measure);
|
|
1674
|
+
observerRef.current.observe(el);
|
|
1675
|
+
}, [])];
|
|
1676
|
+
}
|
|
1677
|
+
/** "4 agents in parallel" when each lane is one agent; "4 parallel lanes ·
|
|
1678
|
+
* 8 agents" when a lane is a chain (a reviewer and its verifier). */
|
|
1679
|
+
function parallelCaption(frame) {
|
|
1680
|
+
if (frame.forEach) {
|
|
1681
|
+
const at = frame.forEach.concurrency;
|
|
1682
|
+
return at !== void 0 && at < frame.lanes ? `${frame.lanes} items · ${at} at a time` : `${frame.lanes} items in parallel`;
|
|
1683
|
+
}
|
|
1684
|
+
if (frame.agents > frame.lanes) return `${frame.lanes} parallel lanes · ${frame.agents} agents`;
|
|
1685
|
+
return frame.agents === frame.lanes ? `${frame.lanes} agents in parallel` : `${frame.lanes} in parallel`;
|
|
1686
|
+
}
|
|
1687
|
+
/** Frames behind parallel fan-outs, drawn in flow coordinates under the
|
|
1688
|
+
* nodes. Pointer-transparent: a frame annotates, it is never a target. */
|
|
1689
|
+
function ParallelFrames({ frames }) {
|
|
1690
|
+
const connectors = frames.flatMap((f) => f.connectors ?? []);
|
|
1691
|
+
return /* @__PURE__ */ jsxs(ViewportPortal, { children: [connectors.length > 0 && /* @__PURE__ */ jsxs("svg", {
|
|
1692
|
+
"aria-hidden": true,
|
|
1693
|
+
className: "pointer-events-none absolute top-0 left-0 overflow-visible",
|
|
1694
|
+
style: { zIndex: -1 },
|
|
1695
|
+
width: 1,
|
|
1696
|
+
height: 1,
|
|
1697
|
+
children: [/* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsx("marker", {
|
|
1698
|
+
id: "wf-grid-arrow",
|
|
1699
|
+
viewBox: "0 0 10 10",
|
|
1700
|
+
refX: "9",
|
|
1701
|
+
refY: "5",
|
|
1702
|
+
markerWidth: "7",
|
|
1703
|
+
markerHeight: "7",
|
|
1704
|
+
orient: "auto-start-reverse",
|
|
1705
|
+
children: /* @__PURE__ */ jsx("path", {
|
|
1706
|
+
d: "M 0 0 L 10 5 L 0 10 z",
|
|
1707
|
+
fill: GRID_CONNECTOR_COLOR
|
|
1708
|
+
})
|
|
1709
|
+
}) }), connectors.map((c, k) => /* @__PURE__ */ jsx("path", {
|
|
1710
|
+
d: roundedPolyline(c.points),
|
|
1711
|
+
fill: "none",
|
|
1712
|
+
stroke: GRID_CONNECTOR_COLOR,
|
|
1713
|
+
strokeWidth: 1.75,
|
|
1714
|
+
markerEnd: "url(#wf-grid-arrow)"
|
|
1715
|
+
}, k))]
|
|
1716
|
+
}), frames.map((frame) => /* @__PURE__ */ jsx("div", {
|
|
1717
|
+
"data-testid": "wf-parallel-frame",
|
|
1718
|
+
className: "pointer-events-none absolute rounded-xl border border-primary/30 border-dashed bg-primary/[0.04]",
|
|
1719
|
+
style: {
|
|
1720
|
+
transform: `translate(${frame.x}px, ${frame.y}px)`,
|
|
1721
|
+
width: frame.width,
|
|
1722
|
+
height: frame.height,
|
|
1723
|
+
zIndex: -1
|
|
1724
|
+
},
|
|
1725
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
1726
|
+
className: "absolute top-1 left-2.5 font-medium text-[10px] text-primary/80 uppercase tracking-wide",
|
|
1727
|
+
children: parallelCaption(frame)
|
|
1728
|
+
})
|
|
1729
|
+
}, frame.id))] });
|
|
1730
|
+
}
|
|
1731
|
+
/** Below this zoom a compact node's name is too small to read, so it grows to
|
|
1732
|
+
* hold a readable size on screen; the tile, the layout and the edges keep
|
|
1733
|
+
* their scale. Capped so a name never outgrows the corridor beside it. */
|
|
1734
|
+
const TITLE_READABLE_ZOOM = .75;
|
|
1735
|
+
const TITLE_MAX_SCALE = 2.4;
|
|
1736
|
+
/** Past this the subtitle is dropped: two grown lines would crowd the next
|
|
1737
|
+
* row's tiles, and the title is the one a reader scans for. */
|
|
1738
|
+
const SUBTITLE_MAX_SCALE = 1.25;
|
|
1739
|
+
/**
|
|
1740
|
+
* Semantic zoom for a compact node's name. A whole 20-step factory framed in
|
|
1741
|
+
* one canvas sits near zoom 0.35, where 12px names render at 4px — the graph's
|
|
1742
|
+
* SHAPE reads but no step does. Quantised to tenths so a zoom gesture
|
|
1743
|
+
* re-renders a node a handful of times, not on every frame.
|
|
1744
|
+
*/
|
|
1745
|
+
function useCompactTitleScale(enabled) {
|
|
1746
|
+
return useStore(useCallback((state) => {
|
|
1747
|
+
if (!enabled) return 1;
|
|
1748
|
+
const zoom = state.transform[2];
|
|
1749
|
+
if (zoom >= TITLE_READABLE_ZOOM) return 1;
|
|
1750
|
+
return Math.min(TITLE_MAX_SCALE, Math.round(TITLE_READABLE_ZOOM / zoom * 10) / 10);
|
|
1751
|
+
}, [enabled]));
|
|
1752
|
+
}
|
|
1753
|
+
/** The highest leaf index per fan-out in a run state, as a stable string.
|
|
1754
|
+
* The model applies counts to `foreach` steps only; a `parallel` already
|
|
1755
|
+
* draws every branch it declares. Null when no step reported more than one
|
|
1756
|
+
* item. */
|
|
1757
|
+
function foreachItemCountsKey(nodeState) {
|
|
1758
|
+
if (!nodeState) return null;
|
|
1759
|
+
const counts = {};
|
|
1760
|
+
for (const id of Object.keys(nodeState)) {
|
|
1761
|
+
const match = /^a(\d+)-b(\d+)$/.exec(id);
|
|
1762
|
+
if (!match) continue;
|
|
1763
|
+
const action = Number(match[1]);
|
|
1764
|
+
counts[action] = Math.max(counts[action] ?? 0, Number(match[2]) + 1);
|
|
1765
|
+
}
|
|
1766
|
+
const wide = Object.entries(counts).filter(([, n]) => n > 1);
|
|
1767
|
+
return wide.length > 0 ? JSON.stringify(Object.fromEntries(wide)) : null;
|
|
1768
|
+
}
|
|
1769
|
+
/** The resting edge colour (edgeColor with no run state), for the lines a
|
|
1770
|
+
* grid frame draws itself. */
|
|
1771
|
+
const GRID_CONNECTOR_COLOR = "color-mix(in srgb, hsl(var(--muted-foreground)) 38%, transparent)";
|
|
1772
|
+
/** Past this many nodes the full canvas carries a minimap, so a reader zoomed
|
|
1773
|
+
* into one corner of a large graph can see where they are in it. Below it
|
|
1774
|
+
* the wrapped layout fits the canvas whole, and a minimap would only cover
|
|
1775
|
+
* part of the graph it summarises. */
|
|
1776
|
+
const MINIMAP_MIN_NODES = 40;
|
|
947
1777
|
/** Chip styling shared by the guard summary and the cycle badge, so an edge's
|
|
948
1778
|
* two possible annotations read as one pair rather than two designs. */
|
|
949
1779
|
const EDGE_CHIP_CLASS = "rounded-full border border-border bg-card/90 px-1.5 py-[1px] text-[10px] leading-tight backdrop-blur";
|
|
@@ -959,7 +1789,11 @@ const EDGE_CHIP_CLASS = "rounded-full border border-border bg-card/90 px-1.5 py-
|
|
|
959
1789
|
*/
|
|
960
1790
|
function WfEdgeRenderer({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, markerEnd, style }) {
|
|
961
1791
|
const insertStep = useContext(EdgeInsertContext);
|
|
962
|
-
const
|
|
1792
|
+
const sourceNode = useInternalNodePosition(source);
|
|
1793
|
+
const targetNode = useInternalNodePosition(target);
|
|
1794
|
+
const route = data?.route;
|
|
1795
|
+
const onRoute = route !== void 0 && sourceNode !== null && targetNode !== null && samePoint(sourceNode, route.from) && samePoint(targetNode, route.to);
|
|
1796
|
+
const [stepPath, stepLabelX, stepLabelY] = getSmoothStepPath({
|
|
963
1797
|
sourceX,
|
|
964
1798
|
sourceY,
|
|
965
1799
|
sourcePosition,
|
|
@@ -967,6 +1801,24 @@ function WfEdgeRenderer({ id, source, target, sourceX, sourceY, targetX, targetY
|
|
|
967
1801
|
targetY,
|
|
968
1802
|
targetPosition
|
|
969
1803
|
});
|
|
1804
|
+
const routePoints = onRoute ? [
|
|
1805
|
+
{
|
|
1806
|
+
x: sourceX,
|
|
1807
|
+
y: sourceY
|
|
1808
|
+
},
|
|
1809
|
+
...route.points.slice(1, -1),
|
|
1810
|
+
{
|
|
1811
|
+
x: targetX,
|
|
1812
|
+
y: targetY
|
|
1813
|
+
}
|
|
1814
|
+
] : [];
|
|
1815
|
+
const path = onRoute ? roundedPolyline(routePoints) : stepPath;
|
|
1816
|
+
const labelAt = onRoute ? route.label ?? polylineMidpoint(routePoints) : {
|
|
1817
|
+
x: stepLabelX,
|
|
1818
|
+
y: stepLabelY
|
|
1819
|
+
};
|
|
1820
|
+
const labelX = labelAt.x;
|
|
1821
|
+
const labelY = labelAt.y;
|
|
970
1822
|
const backEdge = data?.backEdge === true;
|
|
971
1823
|
const whenLabel = data?.whenLabel;
|
|
972
1824
|
const visits = data?.maxNodeVisits;
|
|
@@ -1109,7 +1961,7 @@ function decorateAuthoringEdges(edges, byEdge, insertable, hasRunOverlay) {
|
|
|
1109
1961
|
};
|
|
1110
1962
|
});
|
|
1111
1963
|
}
|
|
1112
|
-
function WorkflowGraph({ yaml, variant = "full", direction
|
|
1964
|
+
function WorkflowGraph({ yaml, variant = "full", direction: directionProp, defaultCompact, onCompactChange, className, wrap = false, nodeState, edges: declaredEdges, maxNodeVisits, selectedNodeId, onNodeClick, onEdgeConnect, onEdgeDelete, onEdgeClick, onEdgeInsert, onNodeInsert, onTriggerAdd, onTriggerDelete, problems }) {
|
|
1113
1965
|
const editable = onEdgeConnect !== void 0;
|
|
1114
1966
|
const insertOnEdge = editable ? onEdgeInsert : void 0;
|
|
1115
1967
|
const insertArmed = insertOnEdge !== void 0;
|
|
@@ -1130,20 +1982,26 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1130
1982
|
* `userCompact`, which is the only thing React may read for that.
|
|
1131
1983
|
*/
|
|
1132
1984
|
const nextDensityFrom = useRef(userCompact);
|
|
1985
|
+
const [fullscreen, setFullscreen] = useState(false);
|
|
1133
1986
|
const compact = isPreview || userCompact;
|
|
1134
1987
|
const [displayCompact, setDisplayCompact] = useState(compact);
|
|
1135
1988
|
const rfRef = useRef(null);
|
|
1136
1989
|
const wrapperRef = useRef(null);
|
|
1990
|
+
const [canvasAspect, measureCanvas] = useCanvasAspect();
|
|
1991
|
+
const direction = directionProp ?? (canvasAspect !== null && canvasAspect < .9 ? "TB" : "LR");
|
|
1137
1992
|
const framedRef = useRef(false);
|
|
1138
1993
|
const [framingCue, setFramingCue] = useState(0);
|
|
1139
1994
|
const hasRunOverlay = nodeState !== void 0;
|
|
1140
|
-
const
|
|
1995
|
+
const itemCountsKey = useMemo(() => foreachItemCountsKey(nodeState), [nodeState]);
|
|
1996
|
+
const itemCounts = useMemo(() => itemCountsKey ? JSON.parse(itemCountsKey) : void 0, [itemCountsKey]);
|
|
1997
|
+
const { graph: structural, ready: laidOut } = useElkLayout(useMemo(() => buildFlowGraph(yaml, {
|
|
1141
1998
|
nodeState: hasRunOverlay ? {} : void 0,
|
|
1142
1999
|
direction,
|
|
1143
2000
|
compact,
|
|
1144
2001
|
wrap,
|
|
1145
2002
|
reserveEdgeInsert: insertArmed,
|
|
1146
|
-
...declaredEdges ? { edges: declaredEdges } : {}
|
|
2003
|
+
...declaredEdges ? { edges: declaredEdges } : {},
|
|
2004
|
+
...itemCounts ? { itemCounts } : {}
|
|
1147
2005
|
}), [
|
|
1148
2006
|
yaml,
|
|
1149
2007
|
hasRunOverlay,
|
|
@@ -1151,8 +2009,11 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1151
2009
|
compact,
|
|
1152
2010
|
wrap,
|
|
1153
2011
|
insertArmed,
|
|
1154
|
-
declaredEdges
|
|
1155
|
-
|
|
2012
|
+
declaredEdges,
|
|
2013
|
+
itemCounts
|
|
2014
|
+
]), direction, compact, canvasAspect, !editable);
|
|
2015
|
+
const shownCompact = structural.compact ?? compact;
|
|
2016
|
+
const shownDirection = structural.direction ?? direction;
|
|
1156
2017
|
const colorMode = useColorMode(wrapperRef, !structural.error && structural.nodes.length > 0);
|
|
1157
2018
|
const nodeBoxes = useMemo(() => structural.nodes.map((node) => ({
|
|
1158
2019
|
x: node.position.x,
|
|
@@ -1164,6 +2025,7 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1164
2025
|
const styledEdges = useMemo(() => {
|
|
1165
2026
|
return decorateAuthoringEdges(buildStyledEdges(structural.edges, nodeState, maxNodeVisits), problemIndex.byEdge, (edge) => insertArmed && isEditableEdge(edge), hasRunOverlay).map((e) => ({
|
|
1166
2027
|
...e,
|
|
2028
|
+
...e.hidden && e.data?.foldedFailureInput && nodeState?.[e.source]?.status === "failed" ? { hidden: false } : {},
|
|
1167
2029
|
deletable: editable ? isEditableEdge(e) : false
|
|
1168
2030
|
}));
|
|
1169
2031
|
}, [
|
|
@@ -1193,7 +2055,10 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1193
2055
|
setNodes
|
|
1194
2056
|
]);
|
|
1195
2057
|
const didInitialSeedRef = useRef(false);
|
|
2058
|
+
const revealedRef = useRef(laidOut);
|
|
1196
2059
|
useEffect(() => {
|
|
2060
|
+
const firstReveal = laidOut && !revealedRef.current;
|
|
2061
|
+
revealedRef.current = laidOut;
|
|
1197
2062
|
const withRunState = (nodes) => {
|
|
1198
2063
|
const state = nodeStateRef.current;
|
|
1199
2064
|
if (!state) return nodes;
|
|
@@ -1208,27 +2073,28 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1208
2073
|
if (!didInitialSeedRef.current) {
|
|
1209
2074
|
didInitialSeedRef.current = true;
|
|
1210
2075
|
setNodes(withRunState(structural.nodes));
|
|
1211
|
-
setDisplayCompact(
|
|
2076
|
+
setDisplayCompact(shownCompact);
|
|
1212
2077
|
return;
|
|
1213
2078
|
}
|
|
1214
2079
|
const inst = rfRef.current;
|
|
1215
2080
|
const frame = wrapperRef.current?.getBoundingClientRect();
|
|
1216
2081
|
if (!inst || !frame || frame.width === 0 || frame.height === 0) {
|
|
1217
2082
|
setNodes(withRunState(structural.nodes));
|
|
1218
|
-
setDisplayCompact(
|
|
2083
|
+
setDisplayCompact(shownCompact);
|
|
1219
2084
|
return;
|
|
1220
2085
|
}
|
|
1221
|
-
const endViewport =
|
|
2086
|
+
const endViewport = openingViewport(structural, frame.width, frame.height, shownCompact, shownDirection);
|
|
1222
2087
|
framedRef.current = true;
|
|
1223
2088
|
const prev = nodesRef.current;
|
|
1224
2089
|
const prevById = new Map(prev.map((n) => [n.id, n]));
|
|
1225
|
-
|
|
2090
|
+
const sameNodeSet = prev.length === structural.nodes.length && structural.nodes.every((n) => prevById.has(n.id));
|
|
2091
|
+
if (firstReveal || !sameNodeSet || !motionAllowed() || typeof requestAnimationFrame === "undefined") {
|
|
1226
2092
|
setNodes(withRunState(structural.nodes));
|
|
1227
|
-
setDisplayCompact(
|
|
2093
|
+
setDisplayCompact(shownCompact);
|
|
1228
2094
|
inst.setViewport(endViewport);
|
|
1229
2095
|
return;
|
|
1230
2096
|
}
|
|
1231
|
-
const flipTimer = setTimeout(() => setDisplayCompact(
|
|
2097
|
+
const flipTimer = setTimeout(() => setDisplayCompact(shownCompact), 220 * (shownCompact ? DENSITY_FLIP_AT.shrink : DENSITY_FLIP_AT.grow));
|
|
1232
2098
|
const startViewport = inst.getViewport();
|
|
1233
2099
|
const startedAt = performance.now();
|
|
1234
2100
|
let raf = requestAnimationFrame(function step(now) {
|
|
@@ -1267,9 +2133,26 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1267
2133
|
};
|
|
1268
2134
|
}, [
|
|
1269
2135
|
structural,
|
|
1270
|
-
|
|
1271
|
-
setNodes
|
|
2136
|
+
shownCompact,
|
|
2137
|
+
setNodes,
|
|
2138
|
+
laidOut,
|
|
2139
|
+
shownDirection
|
|
1272
2140
|
]);
|
|
2141
|
+
const structuralRef = useRef(structural);
|
|
2142
|
+
structuralRef.current = structural;
|
|
2143
|
+
const compactRef = useRef(shownCompact);
|
|
2144
|
+
compactRef.current = shownCompact;
|
|
2145
|
+
const directionRef = useRef(shownDirection);
|
|
2146
|
+
directionRef.current = shownDirection;
|
|
2147
|
+
const reframe = useCallback(() => {
|
|
2148
|
+
requestAnimationFrame(() => {
|
|
2149
|
+
const inst = rfRef.current;
|
|
2150
|
+
const frame = wrapperRef.current?.getBoundingClientRect();
|
|
2151
|
+
if (!inst || !frame || frame.width === 0 || frame.height === 0) return;
|
|
2152
|
+
const graph = structuralRef.current;
|
|
2153
|
+
inst.setViewport(openingViewport(graph, frame.width, frame.height, compactRef.current, directionRef.current));
|
|
2154
|
+
});
|
|
2155
|
+
}, []);
|
|
1273
2156
|
/**
|
|
1274
2157
|
* Own the INITIAL frame once the instance and canvas have a size. React
|
|
1275
2158
|
* Flow's one-time `fitView` can run after this effect and overwrite the
|
|
@@ -1281,12 +2164,13 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1281
2164
|
const inst = rfRef.current;
|
|
1282
2165
|
const frame = wrapperRef.current?.getBoundingClientRect();
|
|
1283
2166
|
if (!inst || !frame || frame.width === 0 || frame.height === 0) return;
|
|
1284
|
-
inst.setViewport(
|
|
2167
|
+
inst.setViewport(openingViewport(structural, frame.width, frame.height, shownCompact, shownDirection));
|
|
1285
2168
|
framedRef.current = true;
|
|
1286
2169
|
}, [
|
|
1287
2170
|
framingCue,
|
|
1288
2171
|
structural,
|
|
1289
|
-
|
|
2172
|
+
shownCompact,
|
|
2173
|
+
shownDirection
|
|
1290
2174
|
]);
|
|
1291
2175
|
useEffect(() => {
|
|
1292
2176
|
const el = wrapperRef.current;
|
|
@@ -1301,6 +2185,20 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1301
2185
|
observer.observe(el);
|
|
1302
2186
|
return () => observer.disconnect();
|
|
1303
2187
|
}, []);
|
|
2188
|
+
const fullscreenSeen = useRef(fullscreen);
|
|
2189
|
+
useEffect(() => {
|
|
2190
|
+
if (fullscreenSeen.current === fullscreen) return;
|
|
2191
|
+
fullscreenSeen.current = fullscreen;
|
|
2192
|
+
reframe();
|
|
2193
|
+
if (!fullscreen) return;
|
|
2194
|
+
const onKey = (event) => {
|
|
2195
|
+
if (event.key !== "Escape" || event.defaultPrevented) return;
|
|
2196
|
+
if (document.querySelector("[aria-modal=\"true\"]")) return;
|
|
2197
|
+
setFullscreen(false);
|
|
2198
|
+
};
|
|
2199
|
+
window.addEventListener("keydown", onKey);
|
|
2200
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
2201
|
+
}, [fullscreen, reframe]);
|
|
1304
2202
|
const handleNodeClick = useCallback((_event, node) => {
|
|
1305
2203
|
onNodeClick?.(node.id, node.data);
|
|
1306
2204
|
}, [onNodeClick]);
|
|
@@ -1363,7 +2261,7 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1363
2261
|
children: structural.error ?? "Nothing to show"
|
|
1364
2262
|
});
|
|
1365
2263
|
return /* @__PURE__ */ jsx(DirectionContext.Provider, {
|
|
1366
|
-
value:
|
|
2264
|
+
value: shownDirection,
|
|
1367
2265
|
children: /* @__PURE__ */ jsx(DensityContext.Provider, {
|
|
1368
2266
|
value: displayCompact,
|
|
1369
2267
|
children: /* @__PURE__ */ jsx(RunModeContext.Provider, {
|
|
@@ -1381,8 +2279,12 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1381
2279
|
children: /* @__PURE__ */ jsx(EdgeInsertContext.Provider, {
|
|
1382
2280
|
value: insertOnEdge ?? null,
|
|
1383
2281
|
children: /* @__PURE__ */ jsx("div", {
|
|
1384
|
-
ref:
|
|
1385
|
-
|
|
2282
|
+
ref: (el) => {
|
|
2283
|
+
wrapperRef.current = el;
|
|
2284
|
+
measureCanvas(el);
|
|
2285
|
+
},
|
|
2286
|
+
className: `wf-graph ${laidOut ? "" : "opacity-0"} ${fullscreen ? "!fixed !inset-3 !z-40 !h-auto !w-auto overflow-hidden rounded-xl border border-border bg-background shadow-2xl" : className ?? ""}`,
|
|
2287
|
+
"data-layout": laidOut ? "ready" : "pending",
|
|
1386
2288
|
children: /* @__PURE__ */ jsxs(ReactFlow, {
|
|
1387
2289
|
nodes,
|
|
1388
2290
|
edges,
|
|
@@ -1421,42 +2323,70 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1421
2323
|
preventScrolling: false,
|
|
1422
2324
|
minZoom: .2,
|
|
1423
2325
|
maxZoom: 1.5,
|
|
1424
|
-
children: [
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
onClick: addTrigger,
|
|
1439
|
-
title: "Add a trigger",
|
|
1440
|
-
"aria-label": "Add a trigger",
|
|
1441
|
-
className: "flex items-center gap-1 rounded-md border border-border bg-card/80 px-2 py-1 text-[11px] text-muted-foreground shadow-sm backdrop-blur transition hover:text-foreground",
|
|
1442
|
-
children: [/* @__PURE__ */ jsx(Plus, {
|
|
1443
|
-
size: 12,
|
|
1444
|
-
"aria-hidden": true
|
|
1445
|
-
}), "Trigger"]
|
|
1446
|
-
}), /* @__PURE__ */ jsxs("button", {
|
|
1447
|
-
type: "button",
|
|
1448
|
-
onClick: () => {
|
|
1449
|
-
const next = !nextDensityFrom.current;
|
|
1450
|
-
nextDensityFrom.current = next;
|
|
1451
|
-
setUserCompact(next);
|
|
1452
|
-
onCompactChange?.(next);
|
|
2326
|
+
children: [
|
|
2327
|
+
/* @__PURE__ */ jsx(Background, {
|
|
2328
|
+
gap: 18,
|
|
2329
|
+
size: 1.4,
|
|
2330
|
+
color: "hsl(var(--muted-foreground) / 0.38)"
|
|
2331
|
+
}),
|
|
2332
|
+
structural.groups && structural.groups.length > 0 && /* @__PURE__ */ jsx(ParallelFrames, { frames: structural.groups }),
|
|
2333
|
+
!isPreview && structural.nodes.length > MINIMAP_MIN_NODES && /* @__PURE__ */ jsx(MiniMap, {
|
|
2334
|
+
pannable: true,
|
|
2335
|
+
zoomable: true,
|
|
2336
|
+
position: "bottom-left",
|
|
2337
|
+
style: {
|
|
2338
|
+
width: 168,
|
|
2339
|
+
height: 104
|
|
1453
2340
|
},
|
|
1454
|
-
"
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
2341
|
+
className: "!hidden sm:!block",
|
|
2342
|
+
ariaLabel: "Workflow overview"
|
|
2343
|
+
}),
|
|
2344
|
+
!isPreview && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Controls, {
|
|
2345
|
+
showInteractive: false,
|
|
2346
|
+
position: "bottom-right",
|
|
2347
|
+
fitViewOptions: { padding: FIT_VIEW.padding }
|
|
2348
|
+
}), /* @__PURE__ */ jsxs(Panel, {
|
|
2349
|
+
position: "top-left",
|
|
2350
|
+
className: "flex items-center gap-1",
|
|
2351
|
+
children: [
|
|
2352
|
+
addTrigger && /* @__PURE__ */ jsxs("button", {
|
|
2353
|
+
type: "button",
|
|
2354
|
+
"data-testid": "wf-trigger-add",
|
|
2355
|
+
onClick: addTrigger,
|
|
2356
|
+
title: "Add a trigger",
|
|
2357
|
+
"aria-label": "Add a trigger",
|
|
2358
|
+
className: "flex items-center gap-1 rounded-md border border-border bg-card/80 px-2 py-1 text-[11px] text-muted-foreground shadow-sm backdrop-blur transition hover:text-foreground",
|
|
2359
|
+
children: [/* @__PURE__ */ jsx(Plus, {
|
|
2360
|
+
size: 12,
|
|
2361
|
+
"aria-hidden": true
|
|
2362
|
+
}), "Trigger"]
|
|
2363
|
+
}),
|
|
2364
|
+
/* @__PURE__ */ jsxs("button", {
|
|
2365
|
+
type: "button",
|
|
2366
|
+
onClick: () => {
|
|
2367
|
+
const next = !nextDensityFrom.current;
|
|
2368
|
+
nextDensityFrom.current = next;
|
|
2369
|
+
setUserCompact(next);
|
|
2370
|
+
onCompactChange?.(next);
|
|
2371
|
+
},
|
|
2372
|
+
"aria-pressed": userCompact,
|
|
2373
|
+
title: userCompact ? "Expand nodes" : "Collapse nodes",
|
|
2374
|
+
className: "flex items-center gap-1 rounded-md border border-border bg-card/80 px-2 py-1 text-[11px] text-muted-foreground shadow-sm backdrop-blur transition hover:text-foreground",
|
|
2375
|
+
children: [userCompact ? /* @__PURE__ */ jsx(Maximize2, { size: 12 }) : /* @__PURE__ */ jsx(Minimize2, { size: 12 }), userCompact ? "Expand" : "Compact"]
|
|
2376
|
+
}),
|
|
2377
|
+
/* @__PURE__ */ jsxs("button", {
|
|
2378
|
+
type: "button",
|
|
2379
|
+
"data-testid": "wf-fullscreen",
|
|
2380
|
+
onClick: () => setFullscreen((on) => !on),
|
|
2381
|
+
"aria-pressed": fullscreen,
|
|
2382
|
+
title: fullscreen ? "Exit full screen (Esc)" : "Full screen",
|
|
2383
|
+
"aria-label": fullscreen ? "Exit full screen" : "Full screen",
|
|
2384
|
+
className: "flex items-center gap-1 rounded-md border border-border bg-card/80 px-2 py-1 text-[11px] text-muted-foreground shadow-sm backdrop-blur transition hover:text-foreground",
|
|
2385
|
+
children: [fullscreen ? /* @__PURE__ */ jsx(Shrink, { size: 12 }) : /* @__PURE__ */ jsx(Expand, { size: 12 }), fullscreen ? "Exit" : "Full screen"]
|
|
2386
|
+
})
|
|
2387
|
+
]
|
|
2388
|
+
})] })
|
|
2389
|
+
]
|
|
1460
2390
|
})
|
|
1461
2391
|
})
|
|
1462
2392
|
})
|