@tangle-network/sandbox-ui 0.134.12 → 0.135.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-WgonmxDh.js} +809 -58
- package/dist/globals.css +46 -0
- package/dist/{node-output-CHlf3tni.js → node-output-Bs51H68g.js} +71 -6
- package/dist/styles.css +46 -0
- package/dist/workflows.d.ts +17 -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-Bs51H68g.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
|
/**
|
|
@@ -95,6 +95,9 @@ function buildFlowGraph(yaml, optionsOrNodeState) {
|
|
|
95
95
|
const targetPosition = isLR ? Position.Left : Position.Top;
|
|
96
96
|
return {
|
|
97
97
|
error: graph.error,
|
|
98
|
+
compact,
|
|
99
|
+
direction,
|
|
100
|
+
...graph.spacing ? { spacing: graph.spacing } : {},
|
|
98
101
|
nodes: graph.nodes.map((n) => ({
|
|
99
102
|
id: n.id,
|
|
100
103
|
type: "wfNode",
|
|
@@ -287,6 +290,396 @@ function clearOfNodeBoxes(point, boxes, direction, half) {
|
|
|
287
290
|
return Math.abs(up) <= Math.abs(down) ? up : down;
|
|
288
291
|
}
|
|
289
292
|
//#endregion
|
|
293
|
+
//#region src/workflows/elk-layout.ts
|
|
294
|
+
/** One ELK instance per page. `null` once loading failed, so a host without
|
|
295
|
+
* the peer pays for one failed import, not one per render. */
|
|
296
|
+
let elkInstance;
|
|
297
|
+
function loadElk() {
|
|
298
|
+
elkInstance ??= import("elkjs/lib/elk.bundled.js").then((mod) => {
|
|
299
|
+
const Elk = mod.default;
|
|
300
|
+
return new Elk();
|
|
301
|
+
}).catch(() => null);
|
|
302
|
+
return elkInstance;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Whether the graph is worth the ELK pass: anything but a plain line. A line
|
|
306
|
+
* has nothing to order or route, and `model.ts` folds a long one into rows
|
|
307
|
+
* that read in order, which a layered layout would not improve.
|
|
308
|
+
*/
|
|
309
|
+
function wantsElkLayout(graph) {
|
|
310
|
+
if (graph.error || graph.nodes.length < 3) return false;
|
|
311
|
+
const outdegree = /* @__PURE__ */ new Map();
|
|
312
|
+
const indegree = /* @__PURE__ */ new Map();
|
|
313
|
+
for (const e of graph.edges) {
|
|
314
|
+
if (e.data?.whenLabel !== void 0 || e.data?.backEdge) return true;
|
|
315
|
+
outdegree.set(e.source, (outdegree.get(e.source) ?? 0) + 1);
|
|
316
|
+
indegree.set(e.target, (indegree.get(e.target) ?? 0) + 1);
|
|
317
|
+
}
|
|
318
|
+
for (const n of outdegree.values()) if (n > 1) return true;
|
|
319
|
+
for (const n of indegree.values()) if (n > 1) return true;
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* A join that runs when ANY of its inputs fails (`join: any_failed`) is a
|
|
324
|
+
* failure handler, not a step in the flow: the factory's `failed` node is fed
|
|
325
|
+
* by ten steps across every stage. Drawn as ten real edges it pulls the whole
|
|
326
|
+
* layout toward one corner and buries the flow under lines that only matter
|
|
327
|
+
* when something breaks. Its inputs are laid out and drawn as one caption on
|
|
328
|
+
* the node instead; the node itself still lays out and opens like any other.
|
|
329
|
+
*/
|
|
330
|
+
function isFailureSink(graph, nodeId) {
|
|
331
|
+
return graph.nodes.find((n) => n.id === nodeId)?.data.config?.join === "any_failed" && !graph.edges.some((e) => e.source === nodeId);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* ELK options for one flow direction. Every value here was chosen against the
|
|
335
|
+
* shapes the catalogue ships (a 22-step factory with two loops and a ten-way
|
|
336
|
+
* failure join, a four-lane review) rather than taken as defaults:
|
|
337
|
+
* - model order breaks ties, so a layer reads in the order the author wrote
|
|
338
|
+
* the steps unless reordering removes a crossing;
|
|
339
|
+
* - cycle breaking follows the same order, so the edge drawn as the loop's
|
|
340
|
+
* return is the one that points back up the definition, as the model's own
|
|
341
|
+
* back-edge classification marks it;
|
|
342
|
+
* - orthogonal routing keeps every edge on horizontal and vertical runs, and
|
|
343
|
+
* a guard chip sits on its edge's longest run (see the renderer). Chips are
|
|
344
|
+
* NOT given to ELK as label boxes: a label is laid out as a node of its own
|
|
345
|
+
* layer, which on the factory doubled the layer count and the width.
|
|
346
|
+
*/
|
|
347
|
+
function elkLayoutOptions(direction, spacing) {
|
|
348
|
+
return {
|
|
349
|
+
"elk.algorithm": "layered",
|
|
350
|
+
"elk.direction": direction === "LR" ? "RIGHT" : "DOWN",
|
|
351
|
+
"elk.edgeRouting": "ORTHOGONAL",
|
|
352
|
+
"elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
|
|
353
|
+
"elk.layered.cycleBreaking.strategy": "MODEL_ORDER",
|
|
354
|
+
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
|
|
355
|
+
"elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
|
|
356
|
+
"elk.layered.nodePlacement.bk.fixedAlignment": "BALANCED",
|
|
357
|
+
"elk.layered.feedbackEdges": "true",
|
|
358
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": String(Math.max(spacing.rank, 40)),
|
|
359
|
+
"elk.spacing.nodeNode": String(Math.max(spacing.cross, 20)),
|
|
360
|
+
"elk.spacing.edgeNode": "14",
|
|
361
|
+
"elk.spacing.edgeEdge": "10",
|
|
362
|
+
"elk.layered.spacing.edgeNodeBetweenLayers": "14",
|
|
363
|
+
"elk.layered.spacing.edgeEdgeBetweenLayers": "10",
|
|
364
|
+
"elk.padding": "[top=0,left=0,bottom=0,right=0]",
|
|
365
|
+
"elk.separateConnectedComponents": "false"
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Cut a long layered graph into rows stacked toward `aspect` (width / height
|
|
370
|
+
* of the canvas it is drawn in), so a fourteen-layer pipeline uses the
|
|
371
|
+
* panel's height instead of shrinking to fit one strip. ELK picks the cuts
|
|
372
|
+
* and routes the edges that cross them.
|
|
373
|
+
*/
|
|
374
|
+
function wrapOptions(aspect) {
|
|
375
|
+
return {
|
|
376
|
+
"elk.aspectRatio": String(aspect),
|
|
377
|
+
"elk.layered.wrapping.strategy": "MULTI_EDGE",
|
|
378
|
+
"elk.layered.wrapping.additionalEdgeSpacing": "16",
|
|
379
|
+
"elk.layered.wrapping.correctionFactor": "1.0"
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/** The graph in ELK's terms: every node at its authoritative size, and every
|
|
383
|
+
* edge except a failure sink's inputs. */
|
|
384
|
+
/**
|
|
385
|
+
* Where a node's edges attach, relative to its box: the handle the card
|
|
386
|
+
* draws. A compact node's box is much bigger than its tile (the name sits
|
|
387
|
+
* under it), so routing to the box would stop every edge short of the tile it
|
|
388
|
+
* points at. One port per side, at the handle, makes ELK route to exactly
|
|
389
|
+
* where the card draws its connection point.
|
|
390
|
+
*/
|
|
391
|
+
function handleOffset(width, height, direction, compact) {
|
|
392
|
+
if (direction === "LR") {
|
|
393
|
+
const y = compact ? 38 : height / 2;
|
|
394
|
+
return {
|
|
395
|
+
inX: 0,
|
|
396
|
+
inY: y,
|
|
397
|
+
outX: width,
|
|
398
|
+
outY: y
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const x = compact ? 38 : width / 2;
|
|
402
|
+
return {
|
|
403
|
+
inX: x,
|
|
404
|
+
inY: 0,
|
|
405
|
+
outX: x,
|
|
406
|
+
outY: height
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function toElkGraph(graph, direction, wrapAspect, compact = false, foldFailureInputs = true) {
|
|
410
|
+
const spacing = graph.spacing ?? {
|
|
411
|
+
rank: 72,
|
|
412
|
+
cross: 24
|
|
413
|
+
};
|
|
414
|
+
const sinks = foldedSinks(graph, foldFailureInputs);
|
|
415
|
+
return {
|
|
416
|
+
id: "root",
|
|
417
|
+
layoutOptions: {
|
|
418
|
+
...elkLayoutOptions(direction, spacing),
|
|
419
|
+
...wrapAspect !== void 0 ? wrapOptions(wrapAspect) : {}
|
|
420
|
+
},
|
|
421
|
+
children: graph.nodes.map((n) => {
|
|
422
|
+
const width = n.width ?? 0;
|
|
423
|
+
const height = n.height ?? 0;
|
|
424
|
+
const at = handleOffset(width, height, direction, compact);
|
|
425
|
+
return {
|
|
426
|
+
id: n.id,
|
|
427
|
+
width,
|
|
428
|
+
height,
|
|
429
|
+
layoutOptions: {
|
|
430
|
+
"elk.portConstraints": "FIXED_POS",
|
|
431
|
+
...sinks.has(n.id) ? { "elk.layered.layering.layerConstraint": "LAST" } : {}
|
|
432
|
+
},
|
|
433
|
+
ports: [{
|
|
434
|
+
id: `${n.id}:in`,
|
|
435
|
+
x: at.inX,
|
|
436
|
+
y: at.inY,
|
|
437
|
+
width: 0,
|
|
438
|
+
height: 0
|
|
439
|
+
}, {
|
|
440
|
+
id: `${n.id}:out`,
|
|
441
|
+
x: at.outX,
|
|
442
|
+
y: at.outY,
|
|
443
|
+
width: 0,
|
|
444
|
+
height: 0
|
|
445
|
+
}]
|
|
446
|
+
};
|
|
447
|
+
}),
|
|
448
|
+
edges: graph.edges.filter((e) => !sinks.has(e.target)).map((e) => ({
|
|
449
|
+
id: e.id,
|
|
450
|
+
sources: [`${e.source}:out`],
|
|
451
|
+
targets: [`${e.target}:in`]
|
|
452
|
+
}))
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/** The middle of a polyline's longest straight run: the one place on a routed
|
|
456
|
+
* edge with room for a chip, and distinct per edge even when several leave
|
|
457
|
+
* the same node together. */
|
|
458
|
+
function longestRunMidpoint(points) {
|
|
459
|
+
let best = 0;
|
|
460
|
+
let at = {
|
|
461
|
+
x: points[0].x,
|
|
462
|
+
y: points[0].y
|
|
463
|
+
};
|
|
464
|
+
for (let i = 1; i < points.length; i += 1) {
|
|
465
|
+
const a = points[i - 1];
|
|
466
|
+
const b = points[i];
|
|
467
|
+
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
|
468
|
+
if (len > best) {
|
|
469
|
+
best = len;
|
|
470
|
+
at = {
|
|
471
|
+
x: (a.x + b.x) / 2,
|
|
472
|
+
y: (a.y + b.y) / 2
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return at;
|
|
477
|
+
}
|
|
478
|
+
/** Flatten an ELK edge's sections into one polyline. */
|
|
479
|
+
function edgePoints(edge) {
|
|
480
|
+
const points = [];
|
|
481
|
+
for (const section of edge.sections ?? []) points.push(section.startPoint, ...section.bendPoints ?? [], section.endPoint);
|
|
482
|
+
return points;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Apply an ELK result to the graph: new positions, a route on every edge, and
|
|
486
|
+
* frames around parallel fan-outs. Pure, so the mapping is checkable without
|
|
487
|
+
* running ELK. Returns null when the result does not cover the graph (a node
|
|
488
|
+
* ELK did not place), which the caller treats as "keep the first layout".
|
|
489
|
+
*/
|
|
490
|
+
/** The failure handlers whose inputs this layout folds. */
|
|
491
|
+
function foldedSinks(graph, fold) {
|
|
492
|
+
if (!fold) return /* @__PURE__ */ new Set();
|
|
493
|
+
return new Set(graph.nodes.filter((n) => isFailureSink(graph, n.id)).map((n) => n.id));
|
|
494
|
+
}
|
|
495
|
+
function applyElkLayout(graph, laid, foldFailureInputs = true) {
|
|
496
|
+
const placed = /* @__PURE__ */ new Map();
|
|
497
|
+
for (const child of laid.children ?? []) {
|
|
498
|
+
if (child.x === void 0 || child.y === void 0) return null;
|
|
499
|
+
placed.set(child.id, {
|
|
500
|
+
x: child.x,
|
|
501
|
+
y: child.y
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
if (graph.nodes.some((n) => !placed.has(n.id))) return null;
|
|
505
|
+
const routed = /* @__PURE__ */ new Map();
|
|
506
|
+
for (const e of laid.edges ?? []) routed.set(e.id, e);
|
|
507
|
+
const sinks = foldedSinks(graph, foldFailureInputs);
|
|
508
|
+
const sinkInputs = /* @__PURE__ */ new Map();
|
|
509
|
+
for (const e of graph.edges) if (sinks.has(e.target)) sinkInputs.set(e.target, (sinkInputs.get(e.target) ?? 0) + 1);
|
|
510
|
+
const nodes = graph.nodes.map((n) => {
|
|
511
|
+
const inputs = sinkInputs.get(n.id);
|
|
512
|
+
return {
|
|
513
|
+
...n,
|
|
514
|
+
position: placed.get(n.id),
|
|
515
|
+
...inputs !== void 0 ? { data: {
|
|
516
|
+
...n.data,
|
|
517
|
+
subtitle: `If any of ${inputs} step${inputs === 1 ? "" : "s"} fails`
|
|
518
|
+
} } : {}
|
|
519
|
+
};
|
|
520
|
+
});
|
|
521
|
+
const edges = graph.edges.map((e) => {
|
|
522
|
+
if (sinkInputs.has(e.target)) return {
|
|
523
|
+
...e,
|
|
524
|
+
hidden: true
|
|
525
|
+
};
|
|
526
|
+
const r = routed.get(e.id);
|
|
527
|
+
const points = r ? edgePoints(r) : [];
|
|
528
|
+
if (points.length < 2) return e;
|
|
529
|
+
const from = placed.get(e.source);
|
|
530
|
+
const to = placed.get(e.target);
|
|
531
|
+
return {
|
|
532
|
+
...e,
|
|
533
|
+
type: WF_EDGE_TYPE,
|
|
534
|
+
data: {
|
|
535
|
+
...e.data ?? { kind: "spine" },
|
|
536
|
+
route: {
|
|
537
|
+
points: points.map((p) => ({
|
|
538
|
+
x: p.x,
|
|
539
|
+
y: p.y
|
|
540
|
+
})),
|
|
541
|
+
label: longestRunMidpoint(points),
|
|
542
|
+
from,
|
|
543
|
+
to
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
});
|
|
548
|
+
const laidOut = {
|
|
549
|
+
...graph,
|
|
550
|
+
nodes,
|
|
551
|
+
edges,
|
|
552
|
+
engine: "elk"
|
|
553
|
+
};
|
|
554
|
+
const groups = parallelFrames(laidOut);
|
|
555
|
+
return groups.length > 0 ? {
|
|
556
|
+
...laidOut,
|
|
557
|
+
groups
|
|
558
|
+
} : laidOut;
|
|
559
|
+
}
|
|
560
|
+
/** Air between a frame and the cards inside it, and the band its caption sits
|
|
561
|
+
* in above them. */
|
|
562
|
+
const FRAME_PAD = 10;
|
|
563
|
+
const FRAME_CAPTION = 18;
|
|
564
|
+
/**
|
|
565
|
+
* Frames around PARALLEL fan-outs: two or more steps that start together
|
|
566
|
+
* because the same steps finished, over unguarded edges. A guarded fan-out is
|
|
567
|
+
* a choice, not parallel work, and gets no frame — its edge chips already say
|
|
568
|
+
* which way it goes. Each member is followed down its own lane (a single
|
|
569
|
+
* unguarded successor that only it feeds) so a review-then-verify lane is
|
|
570
|
+
* framed whole rather than just its first step.
|
|
571
|
+
*
|
|
572
|
+
* A frame that would enclose a card outside the fan-out is dropped: a frame
|
|
573
|
+
* claiming an unrelated step is worse than no frame.
|
|
574
|
+
*/
|
|
575
|
+
function parallelFrames(graph) {
|
|
576
|
+
const forward = graph.edges.filter((e) => !e.data?.backEdge && !e.hidden);
|
|
577
|
+
const preds = /* @__PURE__ */ new Map();
|
|
578
|
+
const succs = /* @__PURE__ */ new Map();
|
|
579
|
+
for (const e of forward) {
|
|
580
|
+
preds.set(e.target, [...preds.get(e.target) ?? [], e]);
|
|
581
|
+
succs.set(e.source, [...succs.get(e.source) ?? [], e]);
|
|
582
|
+
}
|
|
583
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
584
|
+
const siblings = /* @__PURE__ */ new Map();
|
|
585
|
+
for (const n of graph.nodes) {
|
|
586
|
+
if (triggerNodeIndex(n.id) !== null) continue;
|
|
587
|
+
const incoming = preds.get(n.id) ?? [];
|
|
588
|
+
if (incoming.length === 0) continue;
|
|
589
|
+
if (incoming.some((e) => e.data?.whenLabel !== void 0)) continue;
|
|
590
|
+
if (n.data.config && "if" in n.data.config) continue;
|
|
591
|
+
const key = incoming.map((e) => e.source).sort().join("|");
|
|
592
|
+
siblings.set(key, [...siblings.get(key) ?? [], n.id]);
|
|
593
|
+
}
|
|
594
|
+
const frames = [];
|
|
595
|
+
for (const [key, members] of siblings) {
|
|
596
|
+
if (members.length < 2) continue;
|
|
597
|
+
const framed = /* @__PURE__ */ new Set();
|
|
598
|
+
for (const start of members) {
|
|
599
|
+
let at = start;
|
|
600
|
+
framed.add(at);
|
|
601
|
+
for (let hop = 0; hop < graph.nodes.length; hop += 1) {
|
|
602
|
+
const out = succs.get(at) ?? [];
|
|
603
|
+
if (out.length !== 1 || out[0].data?.whenLabel !== void 0) break;
|
|
604
|
+
const next = out[0].target;
|
|
605
|
+
if ((preds.get(next) ?? []).length !== 1 || framed.has(next)) break;
|
|
606
|
+
const config = byId.get(next)?.data.config;
|
|
607
|
+
if (config && "if" in config) break;
|
|
608
|
+
framed.add(next);
|
|
609
|
+
at = next;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const boxes = [...framed].map((id) => byId.get(id)).filter(Boolean);
|
|
613
|
+
const x0 = Math.min(...boxes.map((n) => n.position.x)) - FRAME_PAD;
|
|
614
|
+
const y0 = Math.min(...boxes.map((n) => n.position.y)) - FRAME_PAD - FRAME_CAPTION;
|
|
615
|
+
const x1 = Math.max(...boxes.map((n) => n.position.x + (n.width ?? 0))) + FRAME_PAD;
|
|
616
|
+
const y1 = Math.max(...boxes.map((n) => n.position.y + (n.height ?? 0))) + FRAME_PAD;
|
|
617
|
+
if (graph.nodes.some((n) => {
|
|
618
|
+
if (framed.has(n.id)) return false;
|
|
619
|
+
const nx1 = n.position.x + (n.width ?? 0);
|
|
620
|
+
const ny1 = n.position.y + (n.height ?? 0);
|
|
621
|
+
return n.position.x < x1 && nx1 > x0 && n.position.y < y1 && ny1 > y0;
|
|
622
|
+
})) continue;
|
|
623
|
+
frames.push({
|
|
624
|
+
id: `frame:${key}`,
|
|
625
|
+
x: x0,
|
|
626
|
+
y: y0,
|
|
627
|
+
width: x1 - x0,
|
|
628
|
+
height: y1 - y0,
|
|
629
|
+
lanes: members.length,
|
|
630
|
+
agents: boxes.filter((n) => n.data.kind === "agent.run").length
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
return frames;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Lay the graph out with ELK. Resolves to null when ELK is unavailable or the
|
|
637
|
+
* layout fails, and the caller keeps the synchronous layout.
|
|
638
|
+
*/
|
|
639
|
+
async function layoutWithElk(graph, direction, compact, canvasAspect = DEFAULT_CANVAS_ASPECT, options = {}) {
|
|
640
|
+
const fold = options.foldFailureInputs ?? true;
|
|
641
|
+
const elk = await loadElk();
|
|
642
|
+
if (!elk) return null;
|
|
643
|
+
try {
|
|
644
|
+
const plain = applyElkLayout(graph, await elk.layout(toElkGraph(graph, direction, void 0, compact, fold)), fold);
|
|
645
|
+
if (!plain) return null;
|
|
646
|
+
const plainAspect = boundsAspect(plain);
|
|
647
|
+
if (direction !== "LR" || graph.nodes.length < WRAP_MIN_NODES || plainAspect < canvasAspect * WRAP_WHEN_WIDER) return plain;
|
|
648
|
+
const wrapped = applyElkLayout(graph, await elk.layout(toElkGraph(graph, direction, canvasAspect, compact, fold)), fold);
|
|
649
|
+
if (!wrapped) return plain;
|
|
650
|
+
return fitScale(wrapped, canvasAspect) > fitScale(plain, canvasAspect) ? wrapped : plain;
|
|
651
|
+
} catch {
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/** The aspect a graph is framed in when the host says nothing: the workflow
|
|
656
|
+
* page's canvas, about 1100 x 480. */
|
|
657
|
+
const DEFAULT_CANVAS_ASPECT = 2.2;
|
|
658
|
+
/** How much wider than its canvas a single band must be before wrapping is
|
|
659
|
+
* tried. */
|
|
660
|
+
const WRAP_WHEN_WIDER = 1.6;
|
|
661
|
+
/** Below this many nodes a graph reads better as one band: cut into rows, a
|
|
662
|
+
* six-step loop sends its edges across the row break for no gain in size. */
|
|
663
|
+
const WRAP_MIN_NODES = 12;
|
|
664
|
+
function bounds(graph) {
|
|
665
|
+
const xs = graph.nodes.flatMap((n) => [n.position.x, n.position.x + (n.width ?? 0)]);
|
|
666
|
+
const ys = graph.nodes.flatMap((n) => [n.position.y, n.position.y + (n.height ?? 0)]);
|
|
667
|
+
return {
|
|
668
|
+
width: Math.max(...xs) - Math.min(...xs),
|
|
669
|
+
height: Math.max(...ys) - Math.min(...ys)
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
function boundsAspect(graph) {
|
|
673
|
+
const { width, height } = bounds(graph);
|
|
674
|
+
return width / Math.max(height, 1);
|
|
675
|
+
}
|
|
676
|
+
/** The scale a graph fits a unit-height canvas of `aspect` at: bigger is a
|
|
677
|
+
* more readable graph. */
|
|
678
|
+
function fitScale(graph, aspect) {
|
|
679
|
+
const { width, height } = bounds(graph);
|
|
680
|
+
return Math.min(aspect / Math.max(width, 1), 1 / Math.max(height, 1));
|
|
681
|
+
}
|
|
682
|
+
//#endregion
|
|
290
683
|
//#region src/workflows/WorkflowGraph.tsx
|
|
291
684
|
/**
|
|
292
685
|
* n8n-style node-graph view of a workflow, rendered from its YAML definition.
|
|
@@ -341,6 +734,22 @@ function fitZoomFloor(compact) {
|
|
|
341
734
|
return compact ? .55 : .35;
|
|
342
735
|
}
|
|
343
736
|
/**
|
|
737
|
+
* The floor for a graph ELK laid out. ELK wraps a long graph into rows sized
|
|
738
|
+
* for the canvas, so its fit is the whole workflow at once — the shape Drew
|
|
739
|
+
* asked to see — and names stay legible through semantic zoom
|
|
740
|
+
* (useCompactTitleScale). Clamping it at the compact floor instead would crop
|
|
741
|
+
* a layout built to be seen whole.
|
|
742
|
+
*/
|
|
743
|
+
const ELK_FIT_FLOOR = .28;
|
|
744
|
+
/**
|
|
745
|
+
* The fit floor a laid-out graph opens at. Only a left-to-right ELK layout
|
|
746
|
+
* is built to be seen whole; a top-to-bottom one is a phone's column, read by
|
|
747
|
+
* panning down it from the top, so it keeps the readable compact floor.
|
|
748
|
+
*/
|
|
749
|
+
function elkFloor(graph, direction) {
|
|
750
|
+
return graph.engine === "elk" && direction === "LR" ? ELK_FIT_FLOOR : void 0;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
344
753
|
* The rectangle the LAID-OUT nodes occupy, in flow coordinates.
|
|
345
754
|
*
|
|
346
755
|
* Read straight off the layouter's own position + size rather than through
|
|
@@ -378,6 +787,39 @@ function layoutBounds(nodes) {
|
|
|
378
787
|
height: maxY - minY
|
|
379
788
|
};
|
|
380
789
|
}
|
|
790
|
+
/** What the camera frames: the nodes, and the parallel frames drawn around
|
|
791
|
+
* them, whose captions sit above the first lane. */
|
|
792
|
+
function graphBounds(graph) {
|
|
793
|
+
const nodes = layoutBounds(graph.nodes);
|
|
794
|
+
if (!graph.groups || graph.groups.length === 0) return nodes;
|
|
795
|
+
const x0 = Math.min(nodes.x, ...graph.groups.map((g) => g.x));
|
|
796
|
+
const y0 = Math.min(nodes.y, ...graph.groups.map((g) => g.y));
|
|
797
|
+
const x1 = Math.max(nodes.x + nodes.width, ...graph.groups.map((g) => g.x + g.width));
|
|
798
|
+
const y1 = Math.max(nodes.y + nodes.height, ...graph.groups.map((g) => g.y + g.height));
|
|
799
|
+
return {
|
|
800
|
+
x: x0,
|
|
801
|
+
y: y0,
|
|
802
|
+
width: x1 - x0,
|
|
803
|
+
height: y1 - y0
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* The camera a graph opens at: {@link framingViewport} over the graph and its
|
|
808
|
+
* frames, at the floor its layout calls for. A top-to-bottom column wider
|
|
809
|
+
* than a phone is read down its spine, so the camera centres on the entry
|
|
810
|
+
* step rather than on the column's left edge, where only loop returns run.
|
|
811
|
+
*/
|
|
812
|
+
function openingViewport(graph, width, height, compact, direction) {
|
|
813
|
+
const bounds = graphBounds(graph);
|
|
814
|
+
const view = framingViewport(bounds, width, height, compact, elkFloor(graph, direction));
|
|
815
|
+
const entry = graph.nodes[0];
|
|
816
|
+
if (direction !== "TB" || !entry || bounds.width * view.zoom <= width) return view;
|
|
817
|
+
const entryCenter = entry.position.x + (entry.width ?? 0) / 2;
|
|
818
|
+
return {
|
|
819
|
+
...view,
|
|
820
|
+
x: width / 2 - entryCenter * view.zoom
|
|
821
|
+
};
|
|
822
|
+
}
|
|
381
823
|
/**
|
|
382
824
|
* The camera that frames `bounds` inside a `width`×`height` canvas.
|
|
383
825
|
*
|
|
@@ -397,8 +839,8 @@ function layoutBounds(nodes) {
|
|
|
397
839
|
* is already centered with slack on both sides, so `max` returns it untouched,
|
|
398
840
|
* and nothing here has to re-derive React Flow's own reading of `padding`.
|
|
399
841
|
*/
|
|
400
|
-
function framingViewport(bounds, width, height, compact) {
|
|
401
|
-
const fit = getViewportForBounds(bounds, width, height,
|
|
842
|
+
function framingViewport(bounds, width, height, compact, floor = fitZoomFloor(compact)) {
|
|
843
|
+
const fit = getViewportForBounds(bounds, width, height, floor, fitZoomCeiling(compact), FIT_VIEW.padding);
|
|
402
844
|
/** Slide `offset` just far enough that the bounds' leading edge is on canvas. */
|
|
403
845
|
const anchored = (offset, origin) => offset + Math.max(0, -(offset + origin * fit.zoom));
|
|
404
846
|
return {
|
|
@@ -657,6 +1099,7 @@ function WorkflowNode({ id, data, sourcePosition, targetPosition }) {
|
|
|
657
1099
|
className: handleClass,
|
|
658
1100
|
style: compact ? compactHandleStyle(sourcePos, isLR) : void 0
|
|
659
1101
|
})] });
|
|
1102
|
+
const titleScale = useCompactTitleScale(compact);
|
|
660
1103
|
if (compact) return /* @__PURE__ */ jsxs("div", {
|
|
661
1104
|
className: `relative flex h-full w-full ${isLR ? "flex-col items-center" : "flex-row items-center"}`,
|
|
662
1105
|
children: [
|
|
@@ -712,10 +1155,14 @@ function WorkflowNode({ id, data, sourcePosition, targetPosition }) {
|
|
|
712
1155
|
children: [
|
|
713
1156
|
/* @__PURE__ */ jsx("div", {
|
|
714
1157
|
className: "truncate font-semibold text-[12px] text-foreground leading-tight",
|
|
1158
|
+
style: titleScale > 1 ? {
|
|
1159
|
+
fontSize: 12 * titleScale,
|
|
1160
|
+
...isLR ? { marginInline: -16 } : {}
|
|
1161
|
+
} : void 0,
|
|
715
1162
|
title: d.title,
|
|
716
1163
|
children: d.title
|
|
717
1164
|
}),
|
|
718
|
-
subtitle && /* @__PURE__ */ jsx("div", {
|
|
1165
|
+
subtitle && titleScale <= SUBTITLE_MAX_SCALE && /* @__PURE__ */ jsx("div", {
|
|
719
1166
|
className: "truncate text-[11px] text-muted-foreground leading-tight",
|
|
720
1167
|
title: subtitle,
|
|
721
1168
|
children: subtitle
|
|
@@ -944,6 +1391,214 @@ function releasedOnCanvas(at, frame) {
|
|
|
944
1391
|
* then never revisited.
|
|
945
1392
|
*/
|
|
946
1393
|
const NodeBoxesContext = createContext([]);
|
|
1394
|
+
/** A node's current top-left, read from the store so an edge notices its end
|
|
1395
|
+
* moving (a drag, a tween). Null while the node is not in the store. */
|
|
1396
|
+
function useInternalNodePosition(id) {
|
|
1397
|
+
return useStore(useCallback((state) => {
|
|
1398
|
+
const node = state.nodeLookup.get(id);
|
|
1399
|
+
return node ? node.position : null;
|
|
1400
|
+
}, [id]), (a, b) => a === b || a !== null && b !== null && samePoint(a, b));
|
|
1401
|
+
}
|
|
1402
|
+
function samePoint(a, b) {
|
|
1403
|
+
return Math.abs(a.x - b.x) < .5 && Math.abs(a.y - b.y) < .5;
|
|
1404
|
+
}
|
|
1405
|
+
/** Corner radius of a routed edge's bends, capped by the shorter of the two
|
|
1406
|
+
* segments that meet there so a tight jog never overshoots. */
|
|
1407
|
+
const ROUTE_CORNER = 8;
|
|
1408
|
+
/** An orthogonal polyline as an SVG path with rounded bends. */
|
|
1409
|
+
function roundedPolyline(points) {
|
|
1410
|
+
if (points.length === 0) return "";
|
|
1411
|
+
let d = `M ${points[0].x} ${points[0].y}`;
|
|
1412
|
+
for (let i = 1; i < points.length - 1; i += 1) {
|
|
1413
|
+
const prev = points[i - 1];
|
|
1414
|
+
const at = points[i];
|
|
1415
|
+
const next = points[i + 1];
|
|
1416
|
+
const inLen = Math.hypot(at.x - prev.x, at.y - prev.y);
|
|
1417
|
+
const outLen = Math.hypot(next.x - at.x, next.y - at.y);
|
|
1418
|
+
const r = Math.min(ROUTE_CORNER, inLen / 2, outLen / 2);
|
|
1419
|
+
if (r < .5) {
|
|
1420
|
+
d += ` L ${at.x} ${at.y}`;
|
|
1421
|
+
continue;
|
|
1422
|
+
}
|
|
1423
|
+
const before = {
|
|
1424
|
+
x: at.x - (at.x - prev.x) / inLen * r,
|
|
1425
|
+
y: at.y - (at.y - prev.y) / inLen * r
|
|
1426
|
+
};
|
|
1427
|
+
const after = {
|
|
1428
|
+
x: at.x + (next.x - at.x) / outLen * r,
|
|
1429
|
+
y: at.y + (next.y - at.y) / outLen * r
|
|
1430
|
+
};
|
|
1431
|
+
d += ` L ${before.x} ${before.y} Q ${at.x} ${at.y} ${after.x} ${after.y}`;
|
|
1432
|
+
}
|
|
1433
|
+
const last = points[points.length - 1];
|
|
1434
|
+
return `${d} L ${last.x} ${last.y}`;
|
|
1435
|
+
}
|
|
1436
|
+
/** The point halfway along a polyline, where an unlabelled route's insert
|
|
1437
|
+
* control or problem chip sits. */
|
|
1438
|
+
function polylineMidpoint(points) {
|
|
1439
|
+
const lengths = points.slice(1).map((p, i) => Math.hypot(p.x - points[i].x, p.y - points[i].y));
|
|
1440
|
+
let remaining = lengths.reduce((sum, len) => sum + len, 0) / 2;
|
|
1441
|
+
for (let i = 0; i < lengths.length; i += 1) {
|
|
1442
|
+
if (remaining <= lengths[i] && lengths[i] > 0) {
|
|
1443
|
+
const t = remaining / lengths[i];
|
|
1444
|
+
return {
|
|
1445
|
+
x: points[i].x + (points[i + 1].x - points[i].x) * t,
|
|
1446
|
+
y: points[i].y + (points[i + 1].y - points[i].y) * t
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
remaining -= lengths[i];
|
|
1450
|
+
}
|
|
1451
|
+
return points[points.length - 1] ?? {
|
|
1452
|
+
x: 0,
|
|
1453
|
+
y: 0
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* The graph `buildFlowGraph` laid out, re-laid by ELK when it branches,
|
|
1458
|
+
* guards or loops (see elk-layout.ts).
|
|
1459
|
+
*
|
|
1460
|
+
* While ELK works on a new structure the graph ON SCREEN stays, so a relayout
|
|
1461
|
+
* is one move from what the reader sees to the new layout — never a detour
|
|
1462
|
+
* through an older laid-out graph. Before anything has been shown the canvas
|
|
1463
|
+
* stays hidden (`ready` false) rather than flashing a layout it is about to
|
|
1464
|
+
* replace, but only until {@link REVEAL_DEADLINE_MS}: ELK that is slow, absent
|
|
1465
|
+
* or failing then leaves the synchronous layout on screen, so no failure can
|
|
1466
|
+
* leave the canvas blank. `ready` gates the first reveal only.
|
|
1467
|
+
*/
|
|
1468
|
+
function useElkLayout(base, direction, compact, canvasAspect, foldFailureInputs) {
|
|
1469
|
+
const wanted = wantsElkLayout(base);
|
|
1470
|
+
const [laid, setLaid] = useState(null);
|
|
1471
|
+
const [deadlinePassed, setDeadlinePassed] = useState(false);
|
|
1472
|
+
const shownRef = useRef(null);
|
|
1473
|
+
useEffect(() => {
|
|
1474
|
+
if (!wanted || canvasAspect === null) return;
|
|
1475
|
+
let live = true;
|
|
1476
|
+
layoutWithElk(base, direction, compact, canvasAspect, { foldFailureInputs }).then((graph) => {
|
|
1477
|
+
if (live) setLaid({
|
|
1478
|
+
base,
|
|
1479
|
+
graph: graph ?? base
|
|
1480
|
+
});
|
|
1481
|
+
});
|
|
1482
|
+
return () => {
|
|
1483
|
+
live = false;
|
|
1484
|
+
};
|
|
1485
|
+
}, [
|
|
1486
|
+
base,
|
|
1487
|
+
direction,
|
|
1488
|
+
wanted,
|
|
1489
|
+
canvasAspect,
|
|
1490
|
+
foldFailureInputs
|
|
1491
|
+
]);
|
|
1492
|
+
useEffect(() => {
|
|
1493
|
+
if (deadlinePassed) return;
|
|
1494
|
+
const timer = setTimeout(() => setDeadlinePassed(true), REVEAL_DEADLINE_MS);
|
|
1495
|
+
return () => clearTimeout(timer);
|
|
1496
|
+
}, [deadlinePassed]);
|
|
1497
|
+
let graph;
|
|
1498
|
+
let settled;
|
|
1499
|
+
if (!wanted) {
|
|
1500
|
+
graph = base;
|
|
1501
|
+
settled = true;
|
|
1502
|
+
} else if (laid?.base === base) {
|
|
1503
|
+
graph = laid.graph;
|
|
1504
|
+
settled = true;
|
|
1505
|
+
} else {
|
|
1506
|
+
graph = shownRef.current ?? base;
|
|
1507
|
+
settled = false;
|
|
1508
|
+
}
|
|
1509
|
+
const ready = settled || shownRef.current !== null || deadlinePassed;
|
|
1510
|
+
if (ready) shownRef.current = graph;
|
|
1511
|
+
return {
|
|
1512
|
+
graph,
|
|
1513
|
+
ready
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
/** How long a first ELK layout may keep the canvas hidden before the
|
|
1517
|
+
* synchronous layout is shown instead. */
|
|
1518
|
+
const REVEAL_DEADLINE_MS = 600;
|
|
1519
|
+
/**
|
|
1520
|
+
* The canvas's shape, in three buckets: portrait (a phone), square, and the
|
|
1521
|
+
* wide panel the workflow pages use. Bucketed so a resize re-lays the graph
|
|
1522
|
+
* out only when its shape class changes, never on every pixel. Null until
|
|
1523
|
+
* the canvas has been measured.
|
|
1524
|
+
*
|
|
1525
|
+
* Returns a CALLBACK ref: the canvas element can mount after the graph does
|
|
1526
|
+
* (a first render with no YAML shows a placeholder instead), and a mount-time
|
|
1527
|
+
* effect would never see it — leaving the canvas unmeasured and ELK unrun.
|
|
1528
|
+
*/
|
|
1529
|
+
function useCanvasAspect() {
|
|
1530
|
+
const [aspect, setAspect] = useState(null);
|
|
1531
|
+
const observerRef = useRef(null);
|
|
1532
|
+
return [aspect, useCallback((el) => {
|
|
1533
|
+
observerRef.current?.disconnect();
|
|
1534
|
+
observerRef.current = null;
|
|
1535
|
+
if (!el) return;
|
|
1536
|
+
const measure = () => {
|
|
1537
|
+
const { width, height } = el.getBoundingClientRect();
|
|
1538
|
+
if (width === 0 || height === 0) return;
|
|
1539
|
+
const raw = width / height;
|
|
1540
|
+
setAspect(raw < .9 ? .6 : raw < 1.6 ? 1.2 : 2.2);
|
|
1541
|
+
};
|
|
1542
|
+
measure();
|
|
1543
|
+
if (typeof ResizeObserver === "undefined") {
|
|
1544
|
+
setAspect((current) => current ?? 2.2);
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
observerRef.current = new ResizeObserver(measure);
|
|
1548
|
+
observerRef.current.observe(el);
|
|
1549
|
+
}, [])];
|
|
1550
|
+
}
|
|
1551
|
+
/** "4 agents in parallel" when each lane is one agent; "4 parallel lanes ·
|
|
1552
|
+
* 8 agents" when a lane is a chain (a reviewer and its verifier). */
|
|
1553
|
+
function parallelCaption(frame) {
|
|
1554
|
+
if (frame.agents > frame.lanes) return `${frame.lanes} parallel lanes · ${frame.agents} agents`;
|
|
1555
|
+
return frame.agents === frame.lanes ? `${frame.lanes} agents in parallel` : `${frame.lanes} in parallel`;
|
|
1556
|
+
}
|
|
1557
|
+
/** Frames behind parallel fan-outs, drawn in flow coordinates under the
|
|
1558
|
+
* nodes. Pointer-transparent: a frame annotates, it is never a target. */
|
|
1559
|
+
function ParallelFrames({ frames }) {
|
|
1560
|
+
return /* @__PURE__ */ jsx(ViewportPortal, { children: frames.map((frame) => /* @__PURE__ */ jsx("div", {
|
|
1561
|
+
"data-testid": "wf-parallel-frame",
|
|
1562
|
+
className: "pointer-events-none absolute rounded-xl border border-primary/30 border-dashed bg-primary/[0.04]",
|
|
1563
|
+
style: {
|
|
1564
|
+
transform: `translate(${frame.x}px, ${frame.y}px)`,
|
|
1565
|
+
width: frame.width,
|
|
1566
|
+
height: frame.height,
|
|
1567
|
+
zIndex: -1
|
|
1568
|
+
},
|
|
1569
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
1570
|
+
className: "absolute top-1 left-2.5 font-medium text-[10px] text-primary/80 uppercase tracking-wide",
|
|
1571
|
+
children: parallelCaption(frame)
|
|
1572
|
+
})
|
|
1573
|
+
}, frame.id)) });
|
|
1574
|
+
}
|
|
1575
|
+
/** Below this zoom a compact node's name is too small to read, so it grows to
|
|
1576
|
+
* hold a readable size on screen; the tile, the layout and the edges keep
|
|
1577
|
+
* their scale. Capped so a name never outgrows the corridor beside it. */
|
|
1578
|
+
const TITLE_READABLE_ZOOM = .75;
|
|
1579
|
+
const TITLE_MAX_SCALE = 2.4;
|
|
1580
|
+
/** Past this the subtitle is dropped: two grown lines would crowd the next
|
|
1581
|
+
* row's tiles, and the title is the one a reader scans for. */
|
|
1582
|
+
const SUBTITLE_MAX_SCALE = 1.25;
|
|
1583
|
+
/**
|
|
1584
|
+
* Semantic zoom for a compact node's name. A whole 20-step factory framed in
|
|
1585
|
+
* one canvas sits near zoom 0.35, where 12px names render at 4px — the graph's
|
|
1586
|
+
* SHAPE reads but no step does. Quantised to tenths so a zoom gesture
|
|
1587
|
+
* re-renders a node a handful of times, not on every frame.
|
|
1588
|
+
*/
|
|
1589
|
+
function useCompactTitleScale(enabled) {
|
|
1590
|
+
return useStore(useCallback((state) => {
|
|
1591
|
+
if (!enabled) return 1;
|
|
1592
|
+
const zoom = state.transform[2];
|
|
1593
|
+
if (zoom >= TITLE_READABLE_ZOOM) return 1;
|
|
1594
|
+
return Math.min(TITLE_MAX_SCALE, Math.round(TITLE_READABLE_ZOOM / zoom * 10) / 10);
|
|
1595
|
+
}, [enabled]));
|
|
1596
|
+
}
|
|
1597
|
+
/** Past this many nodes the full canvas carries a minimap, so a reader zoomed
|
|
1598
|
+
* into one corner of a large graph can see where they are in it. Below it
|
|
1599
|
+
* the wrapped layout fits the canvas whole, and a minimap would only cover
|
|
1600
|
+
* part of the graph it summarises. */
|
|
1601
|
+
const MINIMAP_MIN_NODES = 40;
|
|
947
1602
|
/** Chip styling shared by the guard summary and the cycle badge, so an edge's
|
|
948
1603
|
* two possible annotations read as one pair rather than two designs. */
|
|
949
1604
|
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 +1614,11 @@ const EDGE_CHIP_CLASS = "rounded-full border border-border bg-card/90 px-1.5 py-
|
|
|
959
1614
|
*/
|
|
960
1615
|
function WfEdgeRenderer({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, markerEnd, style }) {
|
|
961
1616
|
const insertStep = useContext(EdgeInsertContext);
|
|
962
|
-
const
|
|
1617
|
+
const sourceNode = useInternalNodePosition(source);
|
|
1618
|
+
const targetNode = useInternalNodePosition(target);
|
|
1619
|
+
const route = data?.route;
|
|
1620
|
+
const onRoute = route !== void 0 && sourceNode !== null && targetNode !== null && samePoint(sourceNode, route.from) && samePoint(targetNode, route.to);
|
|
1621
|
+
const [stepPath, stepLabelX, stepLabelY] = getSmoothStepPath({
|
|
963
1622
|
sourceX,
|
|
964
1623
|
sourceY,
|
|
965
1624
|
sourcePosition,
|
|
@@ -967,6 +1626,24 @@ function WfEdgeRenderer({ id, source, target, sourceX, sourceY, targetX, targetY
|
|
|
967
1626
|
targetY,
|
|
968
1627
|
targetPosition
|
|
969
1628
|
});
|
|
1629
|
+
const routePoints = onRoute ? [
|
|
1630
|
+
{
|
|
1631
|
+
x: sourceX,
|
|
1632
|
+
y: sourceY
|
|
1633
|
+
},
|
|
1634
|
+
...route.points.slice(1, -1),
|
|
1635
|
+
{
|
|
1636
|
+
x: targetX,
|
|
1637
|
+
y: targetY
|
|
1638
|
+
}
|
|
1639
|
+
] : [];
|
|
1640
|
+
const path = onRoute ? roundedPolyline(routePoints) : stepPath;
|
|
1641
|
+
const labelAt = onRoute ? route.label ?? polylineMidpoint(routePoints) : {
|
|
1642
|
+
x: stepLabelX,
|
|
1643
|
+
y: stepLabelY
|
|
1644
|
+
};
|
|
1645
|
+
const labelX = labelAt.x;
|
|
1646
|
+
const labelY = labelAt.y;
|
|
970
1647
|
const backEdge = data?.backEdge === true;
|
|
971
1648
|
const whenLabel = data?.whenLabel;
|
|
972
1649
|
const visits = data?.maxNodeVisits;
|
|
@@ -1109,7 +1786,7 @@ function decorateAuthoringEdges(edges, byEdge, insertable, hasRunOverlay) {
|
|
|
1109
1786
|
};
|
|
1110
1787
|
});
|
|
1111
1788
|
}
|
|
1112
|
-
function WorkflowGraph({ yaml, variant = "full", direction
|
|
1789
|
+
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
1790
|
const editable = onEdgeConnect !== void 0;
|
|
1114
1791
|
const insertOnEdge = editable ? onEdgeInsert : void 0;
|
|
1115
1792
|
const insertArmed = insertOnEdge !== void 0;
|
|
@@ -1130,14 +1807,17 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1130
1807
|
* `userCompact`, which is the only thing React may read for that.
|
|
1131
1808
|
*/
|
|
1132
1809
|
const nextDensityFrom = useRef(userCompact);
|
|
1810
|
+
const [fullscreen, setFullscreen] = useState(false);
|
|
1133
1811
|
const compact = isPreview || userCompact;
|
|
1134
1812
|
const [displayCompact, setDisplayCompact] = useState(compact);
|
|
1135
1813
|
const rfRef = useRef(null);
|
|
1136
1814
|
const wrapperRef = useRef(null);
|
|
1815
|
+
const [canvasAspect, measureCanvas] = useCanvasAspect();
|
|
1816
|
+
const direction = directionProp ?? (canvasAspect !== null && canvasAspect < .9 ? "TB" : "LR");
|
|
1137
1817
|
const framedRef = useRef(false);
|
|
1138
1818
|
const [framingCue, setFramingCue] = useState(0);
|
|
1139
1819
|
const hasRunOverlay = nodeState !== void 0;
|
|
1140
|
-
const structural = useMemo(() => buildFlowGraph(yaml, {
|
|
1820
|
+
const { graph: structural, ready: laidOut } = useElkLayout(useMemo(() => buildFlowGraph(yaml, {
|
|
1141
1821
|
nodeState: hasRunOverlay ? {} : void 0,
|
|
1142
1822
|
direction,
|
|
1143
1823
|
compact,
|
|
@@ -1152,7 +1832,9 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1152
1832
|
wrap,
|
|
1153
1833
|
insertArmed,
|
|
1154
1834
|
declaredEdges
|
|
1155
|
-
]);
|
|
1835
|
+
]), direction, compact, canvasAspect, !editable);
|
|
1836
|
+
const shownCompact = structural.compact ?? compact;
|
|
1837
|
+
const shownDirection = structural.direction ?? direction;
|
|
1156
1838
|
const colorMode = useColorMode(wrapperRef, !structural.error && structural.nodes.length > 0);
|
|
1157
1839
|
const nodeBoxes = useMemo(() => structural.nodes.map((node) => ({
|
|
1158
1840
|
x: node.position.x,
|
|
@@ -1164,6 +1846,7 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1164
1846
|
const styledEdges = useMemo(() => {
|
|
1165
1847
|
return decorateAuthoringEdges(buildStyledEdges(structural.edges, nodeState, maxNodeVisits), problemIndex.byEdge, (edge) => insertArmed && isEditableEdge(edge), hasRunOverlay).map((e) => ({
|
|
1166
1848
|
...e,
|
|
1849
|
+
...e.hidden && nodeState?.[e.source]?.status === "failed" ? { hidden: false } : {},
|
|
1167
1850
|
deletable: editable ? isEditableEdge(e) : false
|
|
1168
1851
|
}));
|
|
1169
1852
|
}, [
|
|
@@ -1193,7 +1876,10 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1193
1876
|
setNodes
|
|
1194
1877
|
]);
|
|
1195
1878
|
const didInitialSeedRef = useRef(false);
|
|
1879
|
+
const revealedRef = useRef(laidOut);
|
|
1196
1880
|
useEffect(() => {
|
|
1881
|
+
const firstReveal = laidOut && !revealedRef.current;
|
|
1882
|
+
revealedRef.current = laidOut;
|
|
1197
1883
|
const withRunState = (nodes) => {
|
|
1198
1884
|
const state = nodeStateRef.current;
|
|
1199
1885
|
if (!state) return nodes;
|
|
@@ -1208,27 +1894,28 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1208
1894
|
if (!didInitialSeedRef.current) {
|
|
1209
1895
|
didInitialSeedRef.current = true;
|
|
1210
1896
|
setNodes(withRunState(structural.nodes));
|
|
1211
|
-
setDisplayCompact(
|
|
1897
|
+
setDisplayCompact(shownCompact);
|
|
1212
1898
|
return;
|
|
1213
1899
|
}
|
|
1214
1900
|
const inst = rfRef.current;
|
|
1215
1901
|
const frame = wrapperRef.current?.getBoundingClientRect();
|
|
1216
1902
|
if (!inst || !frame || frame.width === 0 || frame.height === 0) {
|
|
1217
1903
|
setNodes(withRunState(structural.nodes));
|
|
1218
|
-
setDisplayCompact(
|
|
1904
|
+
setDisplayCompact(shownCompact);
|
|
1219
1905
|
return;
|
|
1220
1906
|
}
|
|
1221
|
-
const endViewport =
|
|
1907
|
+
const endViewport = openingViewport(structural, frame.width, frame.height, shownCompact, shownDirection);
|
|
1222
1908
|
framedRef.current = true;
|
|
1223
1909
|
const prev = nodesRef.current;
|
|
1224
1910
|
const prevById = new Map(prev.map((n) => [n.id, n]));
|
|
1225
|
-
|
|
1911
|
+
const sameNodeSet = prev.length === structural.nodes.length && structural.nodes.every((n) => prevById.has(n.id));
|
|
1912
|
+
if (firstReveal || !sameNodeSet || !motionAllowed() || typeof requestAnimationFrame === "undefined") {
|
|
1226
1913
|
setNodes(withRunState(structural.nodes));
|
|
1227
|
-
setDisplayCompact(
|
|
1914
|
+
setDisplayCompact(shownCompact);
|
|
1228
1915
|
inst.setViewport(endViewport);
|
|
1229
1916
|
return;
|
|
1230
1917
|
}
|
|
1231
|
-
const flipTimer = setTimeout(() => setDisplayCompact(
|
|
1918
|
+
const flipTimer = setTimeout(() => setDisplayCompact(shownCompact), 220 * (shownCompact ? DENSITY_FLIP_AT.shrink : DENSITY_FLIP_AT.grow));
|
|
1232
1919
|
const startViewport = inst.getViewport();
|
|
1233
1920
|
const startedAt = performance.now();
|
|
1234
1921
|
let raf = requestAnimationFrame(function step(now) {
|
|
@@ -1267,9 +1954,26 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1267
1954
|
};
|
|
1268
1955
|
}, [
|
|
1269
1956
|
structural,
|
|
1270
|
-
|
|
1271
|
-
setNodes
|
|
1957
|
+
shownCompact,
|
|
1958
|
+
setNodes,
|
|
1959
|
+
laidOut,
|
|
1960
|
+
shownDirection
|
|
1272
1961
|
]);
|
|
1962
|
+
const structuralRef = useRef(structural);
|
|
1963
|
+
structuralRef.current = structural;
|
|
1964
|
+
const compactRef = useRef(shownCompact);
|
|
1965
|
+
compactRef.current = shownCompact;
|
|
1966
|
+
const directionRef = useRef(shownDirection);
|
|
1967
|
+
directionRef.current = shownDirection;
|
|
1968
|
+
const reframe = useCallback(() => {
|
|
1969
|
+
requestAnimationFrame(() => {
|
|
1970
|
+
const inst = rfRef.current;
|
|
1971
|
+
const frame = wrapperRef.current?.getBoundingClientRect();
|
|
1972
|
+
if (!inst || !frame || frame.width === 0 || frame.height === 0) return;
|
|
1973
|
+
const graph = structuralRef.current;
|
|
1974
|
+
inst.setViewport(openingViewport(graph, frame.width, frame.height, compactRef.current, directionRef.current));
|
|
1975
|
+
});
|
|
1976
|
+
}, []);
|
|
1273
1977
|
/**
|
|
1274
1978
|
* Own the INITIAL frame once the instance and canvas have a size. React
|
|
1275
1979
|
* Flow's one-time `fitView` can run after this effect and overwrite the
|
|
@@ -1281,12 +1985,13 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1281
1985
|
const inst = rfRef.current;
|
|
1282
1986
|
const frame = wrapperRef.current?.getBoundingClientRect();
|
|
1283
1987
|
if (!inst || !frame || frame.width === 0 || frame.height === 0) return;
|
|
1284
|
-
inst.setViewport(
|
|
1988
|
+
inst.setViewport(openingViewport(structural, frame.width, frame.height, shownCompact, shownDirection));
|
|
1285
1989
|
framedRef.current = true;
|
|
1286
1990
|
}, [
|
|
1287
1991
|
framingCue,
|
|
1288
1992
|
structural,
|
|
1289
|
-
|
|
1993
|
+
shownCompact,
|
|
1994
|
+
shownDirection
|
|
1290
1995
|
]);
|
|
1291
1996
|
useEffect(() => {
|
|
1292
1997
|
const el = wrapperRef.current;
|
|
@@ -1301,6 +2006,20 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1301
2006
|
observer.observe(el);
|
|
1302
2007
|
return () => observer.disconnect();
|
|
1303
2008
|
}, []);
|
|
2009
|
+
const fullscreenSeen = useRef(fullscreen);
|
|
2010
|
+
useEffect(() => {
|
|
2011
|
+
if (fullscreenSeen.current === fullscreen) return;
|
|
2012
|
+
fullscreenSeen.current = fullscreen;
|
|
2013
|
+
reframe();
|
|
2014
|
+
if (!fullscreen) return;
|
|
2015
|
+
const onKey = (event) => {
|
|
2016
|
+
if (event.key !== "Escape" || event.defaultPrevented) return;
|
|
2017
|
+
if (document.querySelector("[aria-modal=\"true\"]")) return;
|
|
2018
|
+
setFullscreen(false);
|
|
2019
|
+
};
|
|
2020
|
+
window.addEventListener("keydown", onKey);
|
|
2021
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
2022
|
+
}, [fullscreen, reframe]);
|
|
1304
2023
|
const handleNodeClick = useCallback((_event, node) => {
|
|
1305
2024
|
onNodeClick?.(node.id, node.data);
|
|
1306
2025
|
}, [onNodeClick]);
|
|
@@ -1363,7 +2082,7 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1363
2082
|
children: structural.error ?? "Nothing to show"
|
|
1364
2083
|
});
|
|
1365
2084
|
return /* @__PURE__ */ jsx(DirectionContext.Provider, {
|
|
1366
|
-
value:
|
|
2085
|
+
value: shownDirection,
|
|
1367
2086
|
children: /* @__PURE__ */ jsx(DensityContext.Provider, {
|
|
1368
2087
|
value: displayCompact,
|
|
1369
2088
|
children: /* @__PURE__ */ jsx(RunModeContext.Provider, {
|
|
@@ -1381,8 +2100,12 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1381
2100
|
children: /* @__PURE__ */ jsx(EdgeInsertContext.Provider, {
|
|
1382
2101
|
value: insertOnEdge ?? null,
|
|
1383
2102
|
children: /* @__PURE__ */ jsx("div", {
|
|
1384
|
-
ref:
|
|
1385
|
-
|
|
2103
|
+
ref: (el) => {
|
|
2104
|
+
wrapperRef.current = el;
|
|
2105
|
+
measureCanvas(el);
|
|
2106
|
+
},
|
|
2107
|
+
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 ?? ""}`,
|
|
2108
|
+
"data-layout": laidOut ? "ready" : "pending",
|
|
1386
2109
|
children: /* @__PURE__ */ jsxs(ReactFlow, {
|
|
1387
2110
|
nodes,
|
|
1388
2111
|
edges,
|
|
@@ -1421,42 +2144,70 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
|
|
|
1421
2144
|
preventScrolling: false,
|
|
1422
2145
|
minZoom: .2,
|
|
1423
2146
|
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);
|
|
2147
|
+
children: [
|
|
2148
|
+
/* @__PURE__ */ jsx(Background, {
|
|
2149
|
+
gap: 18,
|
|
2150
|
+
size: 1.4,
|
|
2151
|
+
color: "hsl(var(--muted-foreground) / 0.38)"
|
|
2152
|
+
}),
|
|
2153
|
+
structural.groups && structural.groups.length > 0 && /* @__PURE__ */ jsx(ParallelFrames, { frames: structural.groups }),
|
|
2154
|
+
!isPreview && structural.nodes.length > MINIMAP_MIN_NODES && /* @__PURE__ */ jsx(MiniMap, {
|
|
2155
|
+
pannable: true,
|
|
2156
|
+
zoomable: true,
|
|
2157
|
+
position: "bottom-left",
|
|
2158
|
+
style: {
|
|
2159
|
+
width: 168,
|
|
2160
|
+
height: 104
|
|
1453
2161
|
},
|
|
1454
|
-
"
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
2162
|
+
className: "!hidden sm:!block",
|
|
2163
|
+
ariaLabel: "Workflow overview"
|
|
2164
|
+
}),
|
|
2165
|
+
!isPreview && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Controls, {
|
|
2166
|
+
showInteractive: false,
|
|
2167
|
+
position: "bottom-right",
|
|
2168
|
+
fitViewOptions: { padding: FIT_VIEW.padding }
|
|
2169
|
+
}), /* @__PURE__ */ jsxs(Panel, {
|
|
2170
|
+
position: "top-left",
|
|
2171
|
+
className: "flex items-center gap-1",
|
|
2172
|
+
children: [
|
|
2173
|
+
addTrigger && /* @__PURE__ */ jsxs("button", {
|
|
2174
|
+
type: "button",
|
|
2175
|
+
"data-testid": "wf-trigger-add",
|
|
2176
|
+
onClick: addTrigger,
|
|
2177
|
+
title: "Add a trigger",
|
|
2178
|
+
"aria-label": "Add a trigger",
|
|
2179
|
+
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",
|
|
2180
|
+
children: [/* @__PURE__ */ jsx(Plus, {
|
|
2181
|
+
size: 12,
|
|
2182
|
+
"aria-hidden": true
|
|
2183
|
+
}), "Trigger"]
|
|
2184
|
+
}),
|
|
2185
|
+
/* @__PURE__ */ jsxs("button", {
|
|
2186
|
+
type: "button",
|
|
2187
|
+
onClick: () => {
|
|
2188
|
+
const next = !nextDensityFrom.current;
|
|
2189
|
+
nextDensityFrom.current = next;
|
|
2190
|
+
setUserCompact(next);
|
|
2191
|
+
onCompactChange?.(next);
|
|
2192
|
+
},
|
|
2193
|
+
"aria-pressed": userCompact,
|
|
2194
|
+
title: userCompact ? "Expand nodes" : "Collapse nodes",
|
|
2195
|
+
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",
|
|
2196
|
+
children: [userCompact ? /* @__PURE__ */ jsx(Maximize2, { size: 12 }) : /* @__PURE__ */ jsx(Minimize2, { size: 12 }), userCompact ? "Expand" : "Compact"]
|
|
2197
|
+
}),
|
|
2198
|
+
/* @__PURE__ */ jsxs("button", {
|
|
2199
|
+
type: "button",
|
|
2200
|
+
"data-testid": "wf-fullscreen",
|
|
2201
|
+
onClick: () => setFullscreen((on) => !on),
|
|
2202
|
+
"aria-pressed": fullscreen,
|
|
2203
|
+
title: fullscreen ? "Exit full screen (Esc)" : "Full screen",
|
|
2204
|
+
"aria-label": fullscreen ? "Exit full screen" : "Full screen",
|
|
2205
|
+
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",
|
|
2206
|
+
children: [fullscreen ? /* @__PURE__ */ jsx(Shrink, { size: 12 }) : /* @__PURE__ */ jsx(Expand, { size: 12 }), fullscreen ? "Exit" : "Full screen"]
|
|
2207
|
+
})
|
|
2208
|
+
]
|
|
2209
|
+
})] })
|
|
2210
|
+
]
|
|
1460
2211
|
})
|
|
1461
2212
|
})
|
|
1462
2213
|
})
|