@kubex/zinc 1.1.94 → 1.1.96

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.
Files changed (39) hide show
  1. package/dist/custom-elements.json +653 -89
  2. package/dist/vscode.html-custom-data.json +73 -18
  3. package/dist/web-types.json +145 -35
  4. package/dist/zn.d.ts +204 -39
  5. package/dist/zn.min.css +1 -1
  6. package/dist/zn.min.js +374 -308
  7. package/docs/pages/components/flow-builder.md +17 -1
  8. package/docs/pages/components/page-builder.md +31 -0
  9. package/docs/pages/components/slash-menu.md +132 -6
  10. package/docs/pages/components/textarea.md +16 -0
  11. package/package.json +1 -1
  12. package/scss/_root.scss +7 -1
  13. package/src/components/button/button.scss +5 -2
  14. package/src/components/flow-builder/flow-builder.component.ts +16 -1
  15. package/src/components/flow-builder/flow-builder.test.ts +206 -1
  16. package/src/components/flow-builder/flow-geometry.test.ts +102 -0
  17. package/src/components/flow-builder/flow.types.ts +82 -15
  18. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +163 -108
  19. package/src/components/flow-builder/modules/flow-step/flow-step.component.ts +2 -0
  20. package/src/components/inline-edit/inline-edit.component.ts +6 -1
  21. package/src/components/input/input.component.ts +12 -2
  22. package/src/components/page/page.scss +7 -2
  23. package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +16 -9
  24. package/src/components/page-builder/modules/page-section-card/page-section-card.scss +13 -0
  25. package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +9 -0
  26. package/src/components/page-builder/page-builder.component.ts +62 -4
  27. package/src/components/page-builder/page-builder.test.ts +94 -0
  28. package/src/components/remarkd-editor/remarkd-editor.component.ts +227 -12
  29. package/src/components/remarkd-editor/remarkd-editor.scss +81 -0
  30. package/src/components/remarkd-editor/remarkd-editor.test.ts +258 -0
  31. package/src/components/settings-container/settings-container.scss +2 -1
  32. package/src/components/slash-item/slash-item.component.ts +1 -1
  33. package/src/components/slash-menu/slash-menu-items.ts +48 -0
  34. package/src/components/slash-menu/slash-menu.component.ts +134 -27
  35. package/src/components/slash-menu/slash-menu.scss +90 -12
  36. package/src/components/slash-menu/slash-menu.test.ts +107 -0
  37. package/src/components/textarea/textarea.component.ts +12 -2
  38. package/src/components/textarea/textarea.test.ts +2 -2
  39. package/src/components/translations/translations.component.ts +5 -1
@@ -0,0 +1,102 @@
1
+ import {
2
+ BRANCH_SPREAD,
3
+ branchDropXs,
4
+ branchPillTop,
5
+ BUS_OFFSET,
6
+ type FlowConnection,
7
+ type FlowNodeInstance,
8
+ type FlowNodeType,
9
+ NODE_HEIGHT,
10
+ NODE_WIDTH,
11
+ } from './flow.types';
12
+ import {expect} from '@open-wc/testing';
13
+
14
+ const TRIPLE: FlowNodeType = {
15
+ type: 'step',
16
+ label: 'Step',
17
+ group: 'action',
18
+ outputs: [
19
+ {id: 'success', label: 'Success'},
20
+ {id: 'failure', label: 'Failure'},
21
+ {id: 'skip', label: 'Skip'},
22
+ ],
23
+ };
24
+
25
+ const typeOf = () => TRIPLE;
26
+
27
+ const node = (id: string, x: number, y = 0): FlowNodeInstance => ({id, type: 'step', x, y, data: {}});
28
+
29
+ const wire = (from: string, port: string, to = 'child'): FlowConnection =>
30
+ ({id: `c-${from}-${port}`, source: {node: from, port}, target: {node: to, port: 'in'}});
31
+
32
+ const centreOf = (n: FlowNodeInstance) => n.x + NODE_WIDTH / 2;
33
+
34
+ describe('flow branch geometry', () => {
35
+ it('should keep a fully wired fan on the classic spread', () => {
36
+ const source = node('n1', 400);
37
+ const xs = branchDropXs(source, typeOf, [
38
+ wire('n1', 'success', 'a'), wire('n1', 'failure', 'b'), wire('n1', 'skip', 'c'),
39
+ ]);
40
+
41
+ expect(xs).to.deep.equal([
42
+ centreOf(source) - BRANCH_SPREAD,
43
+ centreOf(source),
44
+ centreOf(source) + BRANCH_SPREAD,
45
+ ]);
46
+ });
47
+
48
+ it('should run a lone wired branch straight down, whatever else the step declares', () => {
49
+ const source = node('n1', 400);
50
+ const xs = branchDropXs(source, typeOf, [wire('n1', 'success')]);
51
+
52
+ // Success is wired, so it keeps the node's centre line and its child sits
53
+ // directly below; the two spare branches tuck in beside it.
54
+ expect(xs[0]).to.equal(centreOf(source));
55
+ expect(xs[1]).to.be.greaterThan(xs[0]);
56
+ expect(xs[2]).to.be.greaterThan(xs[1]);
57
+ });
58
+
59
+ it('should give a spare branch its pill\'s width, not a whole child lane', () => {
60
+ const source = node('n1', 400);
61
+ const wired = branchDropXs(source, typeOf, [
62
+ wire('n1', 'success', 'a'), wire('n1', 'failure', 'b'), wire('n1', 'skip', 'c'),
63
+ ]);
64
+ const spare = branchDropXs(source, typeOf, [wire('n1', 'success')]);
65
+
66
+ // Same three branches, two of them unused: the fan collapses to well under
67
+ // half of what three child lanes claim.
68
+ expect(spare[2] - spare[0]).to.be.lessThan((wired[2] - wired[0]) / 2 + 1);
69
+ expect(spare[1] - spare[0]).to.be.lessThan(BRANCH_SPREAD);
70
+ expect(spare[2] - spare[1]).to.be.lessThan(BRANCH_SPREAD);
71
+ });
72
+
73
+ it('should centre two wired branches on the node with a spare branch declared', () => {
74
+ const source = node('n1', 400);
75
+ const xs = branchDropXs(source, typeOf, [wire('n1', 'success', 'a'), wire('n1', 'failure', 'b')]);
76
+
77
+ expect((xs[0] + xs[1]) / 2).to.equal(centreOf(source));
78
+ expect(xs[1] - xs[0]).to.equal(BRANCH_SPREAD);
79
+ });
80
+
81
+ it('should centre a pill along a short wire to the next row', () => {
82
+ const source = node('n1', 0, 0);
83
+ const child = node('n2', 0, NODE_HEIGHT + 160);
84
+
85
+ const top = branchPillTop(source, 40, child);
86
+ expect(top).to.be.greaterThan(source.y + NODE_HEIGHT);
87
+ expect(top).to.be.lessThan(child.y);
88
+ });
89
+
90
+ it('should keep a pill under its own node when the wire skips past a row', () => {
91
+ const source = node('n1', 0, 0);
92
+ const near = node('n2', 0, NODE_HEIGHT + 160);
93
+ const far = node('n3', 0, NODE_HEIGHT + 800);
94
+
95
+ // A long branch must not park its pill halfway down, in among the rows it
96
+ // passes — that is what stacked two nodes' pills on top of each other. It
97
+ // sits on the bus, leaving the gap below clear for the wire to route.
98
+ const top = branchPillTop(source, 40, far);
99
+ expect(top).to.equal(source.y + NODE_HEIGHT + BUS_OFFSET);
100
+ expect(top).to.be.lessThan(near.y);
101
+ });
102
+ });
@@ -125,6 +125,14 @@ export interface FlowNodeType {
125
125
  inputs?: FlowPort[];
126
126
  /** Output ports. Defaults to a single unlabelled output. e.g. TRUE/FALSE for a split. */
127
127
  outputs?: FlowPort[];
128
+ /**
129
+ * The type's branches are the only ones it has: the canvas offers no delete
130
+ * on their pills, and clicking a fully-wired node's output adds nothing.
131
+ * For steps whose branches mirror fixed fields in the consumer's own model
132
+ * (a success / failure / skip triple, say), where an extra or missing branch
133
+ * has nowhere to be stored.
134
+ */
135
+ fixedOutputs?: boolean;
128
136
  /** Initial `data` for a freshly placed node. */
129
137
  defaultData?: Record<string, unknown>;
130
138
  /**
@@ -230,6 +238,18 @@ export const NODE_HEIGHT = 60;
230
238
  */
231
239
  export const BRANCH_SPREAD = NODE_WIDTH + 80;
232
240
 
241
+ /** Clearance between two branches that both carry a child card. */
242
+ const BRANCH_CARD_GAP = BRANCH_SPREAD - NODE_WIDTH;
243
+
244
+ /**
245
+ * Clearance beside a branch with nothing under it but its own pill. A pill is a
246
+ * chip, not a card, so its neighbours can sit far closer than two child lanes.
247
+ */
248
+ const BRANCH_PILL_GAP = 40;
249
+
250
+ /** Half-width claimed by an open branch with no pill — room for its "+" target. */
251
+ const OPEN_BRANCH_MIN_HALF = 20;
252
+
233
253
  // Branch geometry below a node: outputs fork from a stem onto a horizontal bus,
234
254
  // and labelled outputs drop from it into a name pill.
235
255
  export const BUS_OFFSET = 40;
@@ -243,14 +263,25 @@ export const PILL_LINE_HEIGHT = 20;
243
263
  const PILL_APPROACH = 20;
244
264
  /** The least wire kept above a sole output's pill in a tight gap. */
245
265
  const PILL_MIN_STUB = 20;
266
+ /**
267
+ * The longest run a pill will centre itself along. A branch reaching past the
268
+ * next row would otherwise park its pill halfway down, in among nodes it has
269
+ * nothing to do with; beyond this it keeps the fixed drop under its own node.
270
+ */
271
+ const PILL_CENTRE_MAX_RUN = 240;
246
272
 
247
273
  /**
248
- * Canvas y of a branch pill's top edge. A pill on a wire to a child below is
249
- * centred along the run from its node's bottom to the child's top — equal wire
250
- * above and below, however long or tight. A sole output's wire is a straight
251
- * stem with no bus to respect, so its pill may rise above the bus line to stay
252
- * centred; fan branches stop at the bus. Open branches and loop/side wires
253
- * keep the fixed drop below the bus.
274
+ * Canvas y of a branch pill's top edge. A pill on a wire to a child in the next
275
+ * row down is centred along the run from its node's bottom to the child's top —
276
+ * equal wire above and below. A sole output's wire is a straight stem with no
277
+ * bus to respect, so its pill may rise above the bus line to stay centred; fan
278
+ * branches stop at the bus.
279
+ *
280
+ * A branch reaching past the next row sits right under the bus instead: it
281
+ * reads as belonging to the node it hangs from rather than to whatever it flies
282
+ * over, and it leaves the whole gap below free for the wire to route around
283
+ * what is in the way. Open branches and loop/side wires keep the fixed drop
284
+ * below the bus, where their "+" has room.
254
285
  */
255
286
  export function branchPillTop(
256
287
  node: Pick<FlowNodeInstance, 'y'>,
@@ -261,6 +292,7 @@ export function branchPillTop(
261
292
  const bottom = node.y + NODE_HEIGHT;
262
293
  const busY = bottom + BUS_OFFSET;
263
294
  if (child && child.y >= bottom) {
295
+ if (child.y - bottom > PILL_CENTRE_MAX_RUN) return busY;
264
296
  const minTop = soleOutput ? bottom + PILL_MIN_STUB : busY;
265
297
  const centered = Math.round((bottom + child.y) / 2 - pillH / 2);
266
298
  return Math.max(minTop, Math.min(centered, child.y - PILL_APPROACH - pillH));
@@ -321,17 +353,52 @@ function rectsOverlap(a: Rect, b: Rect): boolean {
321
353
  export type FlowTypeOf = (type: string) => FlowNodeType | undefined;
322
354
 
323
355
  /**
324
- * Canvas x for each of a node's branch drops: the natural fan position under
325
- * the source. Pills never shift sideways to chase their child the wire into
326
- * a pill is always a straight vertical, and any lateral offset to the child is
327
- * taken up by the elbow below the pill (which still enters the child from
328
- * straight above).
356
+ * Half the room a branch needs either side of its drop: a wired branch has to
357
+ * clear the child card hanging under it, an open one only its own pill.
358
+ */
359
+ function branchHalfWidth(port: FlowPort, wired: boolean): number {
360
+ if (wired) return NODE_WIDTH / 2;
361
+ return port.label ? pillSize(port.label).w / 2 : OPEN_BRANCH_MIN_HALF;
362
+ }
363
+
364
+ /**
365
+ * Canvas x for each of a node's branch drops. Neighbours are spaced by what
366
+ * they actually occupy — two child lanes keep the full spread, while a branch
367
+ * with nothing wired to it needs only its pill — and the run is then shifted so
368
+ * the *wired* branches stay centred on the node. A step that declares branches
369
+ * it is not using therefore still runs straight down, instead of fanning empty
370
+ * lanes across the canvas; and a fully wired fan is spaced exactly as before.
371
+ *
372
+ * Pills never shift sideways to chase their child — the wire into a pill is
373
+ * always a straight vertical, and any lateral offset to the child is taken up
374
+ * by the elbow below the pill (which still enters the child from straight
375
+ * above).
329
376
  */
330
- export function branchDropXs(node: FlowNodeInstance, typeOf: FlowTypeOf): number[] {
377
+ export function branchDropXs(
378
+ node: FlowNodeInstance,
379
+ typeOf: FlowTypeOf,
380
+ connections: FlowConnection[] = []
381
+ ): number[] {
331
382
  const outputs = nodeOutputs(node, typeOf(node.type));
332
383
  const cx = node.x + NODE_WIDTH / 2;
333
- return outputs.map((_, i) =>
334
- Math.round(cx + (outputs.length === 1 ? 0 : (i - (outputs.length - 1) / 2) * BRANCH_SPREAD)));
384
+ if (outputs.length < 2) return outputs.map(() => cx);
385
+
386
+ const wired = outputs.map(p => connections.some(c => c.source.node === node.id && c.source.port === p.id));
387
+
388
+ const offsets = [0];
389
+ for (let i = 1; i < outputs.length; i++) {
390
+ const clearance = wired[i - 1] && wired[i] ? BRANCH_CARD_GAP : BRANCH_PILL_GAP;
391
+ const gap = branchHalfWidth(outputs[i - 1], wired[i - 1]) + branchHalfWidth(outputs[i], wired[i]) + clearance;
392
+ offsets.push(offsets[i - 1] + snapToGrid(gap));
393
+ }
394
+
395
+ // Anchor on the branches carrying children, so wiring or unwiring a spare
396
+ // branch never drags the cards below the others sideways.
397
+ const anchored = offsets.filter((_, i) => wired[i]);
398
+ const span = anchored.length ? anchored : offsets;
399
+ const anchor = (Math.min(...span) + Math.max(...span)) / 2;
400
+
401
+ return offsets.map(o => Math.round(cx + o - anchor));
335
402
  }
336
403
 
337
404
  /**
@@ -346,7 +413,7 @@ export function nodeObstacles(
346
413
  ): Rect[] {
347
414
  const rects: Rect[] = [{x: node.x, y: node.y, w: NODE_WIDTH, h: NODE_HEIGHT, m: CARD_MARGIN}];
348
415
  const ports = nodeOutputs(node, typeOf(node.type));
349
- const xs = branchDropXs(node, typeOf);
416
+ const xs = branchDropXs(node, typeOf, connections);
350
417
  ports.forEach((port, i) => {
351
418
  if (!port.label) return;
352
419
  const size = pillSize(port.label);
@@ -46,6 +46,8 @@ const ADD_POINT_OFFSET = 40;
46
46
  // input, and how far a detour clears a node card when routing around it. All
47
47
  // whole grid units so wire runs sit on grid lines.
48
48
  const WIRE_STUB = 20;
49
+ /** Strip above a card where incoming arrows land; foreign wires keep out of it. */
50
+ const APPROACH_ZONE = 48;
49
51
  const WIRE_APPROACH = 20;
50
52
  // Half the wire "+"'s footprint.
51
53
  const WIRE_PLUS_RADIUS = 10;
@@ -312,6 +314,9 @@ export default class ZnFlowCanvas extends ZincElement {
312
314
  const open = nodeOutputs(node, this._typeFor(node)).find(
313
315
  p => !this.connections.some(c => c.source.node === nodeId && c.source.port === p.id)
314
316
  );
317
+ // A fixed-output node has no spare branch to start once all of its own are
318
+ // wired — wire one of those instead, from its pill.
319
+ if (!open && this._typeFor(node)?.fixedOutputs) return;
315
320
  this._startLink(nodeId, open?.id ?? NEW_OUTPUT_PORT);
316
321
  };
317
322
 
@@ -531,7 +536,7 @@ export default class ZnFlowCanvas extends ZincElement {
531
536
  // Drops always sit at the natural fan position under the source, so the
532
537
  // arrow into a pill is a straight vertical; the wire below the pill takes
533
538
  // up any lateral offset to the child.
534
- const dropXs = branchDropXs(node, t => this.registry?.get(t));
539
+ const dropXs = branchDropXs(node, t => this.registry?.get(t), this.connections);
535
540
  const branches = outputs.map((port, i) => {
536
541
  const conn = this.connections.find(c => c.source.node === node.id && c.source.port === port.id);
537
542
  const child = conn ? this.nodes.find(n => n.id === conn.target.node) : undefined;
@@ -590,14 +595,19 @@ export default class ZnFlowCanvas extends ZincElement {
590
595
  }
591
596
 
592
597
  /**
593
- * Whether an orthogonal segment passes through any node card (with margin)
594
- * or the approach zone above one, where incoming arrows land. A foreign wire
595
- * running just over a card's input port reads as connecting to it, so routes
596
- * keep well clear of that strip too.
598
+ * Whether an orthogonal segment passes through any node card (with margin).
599
+ * With `keepApproachClear`, the strip above each card - where incoming arrows
600
+ * land - counts as occupied too: a foreign wire running just over a card's
601
+ * input port reads as connecting to it.
597
602
  */
598
- private _segmentBlocked(a: { x: number; y: number }, b: { x: number; y: number }, ignore: Set<string>): boolean {
603
+ private _segmentBlocked(
604
+ a: { x: number; y: number },
605
+ b: { x: number; y: number },
606
+ ignore: Set<string>,
607
+ keepApproachClear = true
608
+ ): boolean {
599
609
  const M = 8;
600
- const APPROACH_ZONE = 48;
610
+ const approach = keepApproachClear ? APPROACH_ZONE : M;
601
611
  const minX = Math.min(a.x, b.x);
602
612
  const maxX = Math.max(a.x, b.x);
603
613
  const minY = Math.min(a.y, b.y);
@@ -605,13 +615,17 @@ export default class ZnFlowCanvas extends ZincElement {
605
615
  return this.nodes.some(n =>
606
616
  !ignore.has(n.id)
607
617
  && minX < n.x + NODE_WIDTH + M && maxX > n.x - M
608
- && minY < n.y + NODE_HEIGHT + M && maxY > n.y - APPROACH_ZONE
618
+ && minY < n.y + NODE_HEIGHT + M && maxY > n.y - approach
609
619
  );
610
620
  }
611
621
 
612
- private _routeClear(points: { x: number; y: number }[], ignore: Set<string>): boolean {
622
+ private _routeClear(
623
+ points: { x: number; y: number }[],
624
+ ignore: Set<string>,
625
+ keepApproachClear = true
626
+ ): boolean {
613
627
  for (let i = 0; i < points.length - 1; i++) {
614
- if (this._segmentBlocked(points[i], points[i + 1], ignore)) return false;
628
+ if (this._segmentBlocked(points[i], points[i + 1], ignore, keepApproachClear)) return false;
615
629
  }
616
630
  return true;
617
631
  }
@@ -619,8 +633,13 @@ export default class ZnFlowCanvas extends ZincElement {
619
633
  /**
620
634
  * Orthogonal waypoints from a branch exit to a child's input. The wire always
621
635
  * enters the input from above (arrow pointing down), and never passes through
622
- * a node card: each candidate route is checked against every card, scanning
623
- * alternative lanes / side-steps / detours until one is clear.
636
+ * a node card.
637
+ *
638
+ * The search runs twice: first keeping clear of the strip above every card as
639
+ * well as the cards themselves, then - if the corridor is genuinely too tight
640
+ * for that - clear of the cards alone. Only when nothing at all fits does the
641
+ * wire fall back to the direct line, so a wire drawn over a card now means
642
+ * there was no way around it rather than that the search gave up.
624
643
  */
625
644
  private _routePoints(
626
645
  from: { x: number; y: number },
@@ -632,101 +651,134 @@ export default class ZnFlowCanvas extends ZincElement {
632
651
  ) {
633
652
  const {x: tx, y: ty} = this._inputAnchor(child, targetPort);
634
653
  const ignore = new Set(sourceId ? [child.id, sourceId] : [child.id]);
654
+ const candidates = ty - WIRE_APPROACH >= from.y
655
+ ? this._forwardRoutes(from, tx, ty, midOffset, ignore)
656
+ : this._aroundRoutes(from, tx, ty, ignore, loop);
635
657
 
636
- if (ty - WIRE_APPROACH >= from.y) {
637
- // The exact midpoint, not grid-snapped: both endpoints sit on the grid,
638
- // so the midpoint is always a half-tile multiple — snapping it to full
639
- // tiles made the two verticals grow alternately as a node was dragged.
640
- const base = Math.round((from.y + ty) / 2) + midOffset;
641
- // The horizontal run stays within the middle half of the gap, so the
642
- // drop and the final approach grow in proportion to the distance —
643
- // lane offsets can't shove the run right up against either end.
644
- const span = ty - from.y;
645
- const lo = Math.min(ty - 12, Math.ceil((from.y + span / 4) / GRID_SIZE) * GRID_SIZE);
646
- const hi = Math.max(lo, Math.floor((ty - span / 4) / GRID_SIZE) * GRID_SIZE);
647
- const clampY = (v: number) => Math.max(lo, Math.min(hi, v));
648
- let fallback: { x: number; y: number }[] | null = null;
649
-
650
- if (tx === from.x) {
651
- const straight = [from, {x: tx, y: ty}];
652
- if (this._routeClear(straight, ignore)) return straight;
653
- fallback = straight;
654
- } else {
655
- // Elbow: try the lane midY first, then scan outward for a clear line.
656
- for (let step = 0; step <= 20; step++) {
657
- for (const cand of step === 0 ? [base] : [base + step * GRID_SIZE, base - step * GRID_SIZE]) {
658
- const my = clampY(cand);
659
- if (my !== cand && step > 0) continue; // out of range
660
- const route = [from, {x: from.x, y: my}, {x: tx, y: my}, {x: tx, y: ty}];
661
- fallback ??= route;
662
- if (this._routeClear(route, ignore)) return route;
663
- }
658
+ for (const keepApproachClear of [true, false]) {
659
+ for (const route of candidates) {
660
+ if (this._routeClear(route, ignore, keepApproachClear)) return route;
661
+ }
662
+ }
663
+ return candidates[0];
664
+ }
665
+
666
+ /**
667
+ * Candidate routes to a child below, tidiest first: the straight drop or the
668
+ * single elbow, then the same elbow on neighbouring lines, then out around
669
+ * whatever sits between the two (the shape a loop-back uses - far more
670
+ * readable than threading a corridor when a wire skips a row it has nothing
671
+ * to do with), and finally a side-step that jogs out and back.
672
+ */
673
+ private _forwardRoutes(
674
+ from: { x: number; y: number },
675
+ tx: number,
676
+ ty: number,
677
+ midOffset: number,
678
+ ignore: Set<string>
679
+ ): { x: number; y: number }[][] {
680
+ const routes: { x: number; y: number }[][] = [];
681
+
682
+ // The exact midpoint, not grid-snapped: both endpoints sit on the grid, so
683
+ // the midpoint is always a half-tile multiple — snapping it to full tiles
684
+ // made the two verticals grow alternately as a node was dragged.
685
+ const base = Math.round((from.y + ty) / 2) + midOffset;
686
+ // The horizontal run stays within the middle half of the gap, so the drop
687
+ // and the final approach grow in proportion to the distance — lane offsets
688
+ // can't shove the run right up against either end.
689
+ const span = ty - from.y;
690
+ const lo = Math.min(ty - 12, Math.ceil((from.y + span / 4) / GRID_SIZE) * GRID_SIZE);
691
+ const hi = Math.max(lo, Math.floor((ty - span / 4) / GRID_SIZE) * GRID_SIZE);
692
+ const clampY = (v: number) => Math.max(lo, Math.min(hi, v));
693
+
694
+ if (tx === from.x) {
695
+ routes.push([from, {x: tx, y: ty}]);
696
+ } else {
697
+ for (let step = 0; step <= 20; step++) {
698
+ for (const cand of step === 0 ? [base] : [base + step * GRID_SIZE, base - step * GRID_SIZE]) {
699
+ const my = clampY(cand);
700
+ if (my !== cand && step > 0) continue; // out of range
701
+ routes.push([from, {x: from.x, y: my}, {x: tx, y: my}, {x: tx, y: ty}]);
664
702
  }
665
703
  }
704
+ }
666
705
 
667
- // No simple route is clear (e.g. a card sits right under the exit)
668
- // side-step: drop to m1, jog sideways to sx, descend, and approach the
669
- // input from above. The drop and the final approach share one run-in
670
- // depth (equal lengths by construction); scan that depth and the jog.
671
- const half = Math.floor(span / 2 / 10) * 10;
672
- const baseDepth = Math.max(10, Math.round(span / 4 / 10) * 10);
673
- for (let dStep = 0; dStep <= 12; dStep++) {
674
- for (const depth of dStep === 0 ? [baseDepth] : [baseDepth + dStep * 10, baseDepth - dStep * 10]) {
675
- if (depth < 10 || depth > half) continue;
676
- const m1 = from.y + depth;
677
- const m2 = ty - depth;
678
- if (m2 < m1) continue;
679
- for (let sStep = 1; sStep <= 14; sStep++) {
680
- for (const dir of [1, -1]) {
681
- const sx = from.x + dir * sStep * GRID_SIZE;
682
- const route = [
683
- from,
684
- {x: from.x, y: m1},
685
- {x: sx, y: m1},
686
- {x: sx, y: m2},
687
- {x: tx, y: m2},
688
- {x: tx, y: ty},
689
- ];
690
- if (this._routeClear(route, ignore)) return route;
691
- }
706
+ routes.push(...this._aroundRoutes(from, tx, ty, ignore, false));
707
+
708
+ // Side-step: drop to m1, jog sideways to sx, descend, and approach the
709
+ // input from above. The drop and the final approach share one run-in depth
710
+ // (equal lengths by construction); scan that depth and the jog.
711
+ const half = Math.floor(span / 2 / 10) * 10;
712
+ const baseDepth = Math.max(10, Math.round(span / 4 / 10) * 10);
713
+ for (let dStep = 0; dStep <= 12; dStep++) {
714
+ for (const depth of dStep === 0 ? [baseDepth] : [baseDepth + dStep * 10, baseDepth - dStep * 10]) {
715
+ if (depth < 10 || depth > half) continue;
716
+ const m1 = from.y + depth;
717
+ const m2 = ty - depth;
718
+ if (m2 < m1) continue;
719
+ for (let sStep = 1; sStep <= 14; sStep++) {
720
+ for (const dir of [1, -1]) {
721
+ const sx = from.x + dir * sStep * GRID_SIZE;
722
+ routes.push([
723
+ from,
724
+ {x: from.x, y: m1},
725
+ {x: sx, y: m1},
726
+ {x: sx, y: m2},
727
+ {x: tx, y: m2},
728
+ {x: tx, y: ty},
729
+ ]);
692
730
  }
693
731
  }
694
732
  }
695
- return fallback!;
696
733
  }
697
734
 
698
- // Child is beside/above: stub down, clear the card, rise above the input,
699
- // then approach it from the top — widening the detour until nothing is
700
- // crossed.
735
+ return routes;
736
+ }
737
+
738
+ /**
739
+ * Routes that leave the exit, run down the outside of everything they span
740
+ * vertically, and come back in above the input. Loop-backs always travel this
741
+ * way rather than squeezing through corridors inside the flow; a forward wire
742
+ * uses it when the corridor between it and its child is occupied.
743
+ */
744
+ private _aroundRoutes(
745
+ from: { x: number; y: number },
746
+ tx: number,
747
+ ty: number,
748
+ ignore: Set<string>,
749
+ loop: boolean
750
+ ): { x: number; y: number }[][] {
701
751
  const stubY = from.y + WIRE_STUB;
702
752
  const overY = ty - WIRE_APPROACH;
703
- let rightSide: boolean;
704
- let baseDetour: number;
705
- if (loop) {
706
- // Loop-backs travel around the OUTSIDE of everything they span
707
- // vertically, instead of squeezing through corridors inside the flow —
708
- // on whichever side costs less horizontal travel.
709
- const spanned = this.nodes.filter(
710
- n => n.y + NODE_HEIGHT > overY - GRID_SIZE && n.y < stubY + GRID_SIZE
711
- );
712
- const rightEdge = Math.max(...spanned.map(n => n.x + NODE_WIDTH)) + WIRE_DETOUR;
713
- const leftEdge = Math.min(...spanned.map(n => n.x)) - WIRE_DETOUR;
714
- const costRight = Math.abs(rightEdge - from.x) + Math.abs(rightEdge - tx);
715
- const costLeft = Math.abs(from.x - leftEdge) + Math.abs(tx - leftEdge);
716
- rightSide = costRight <= costLeft;
717
- baseDetour = rightSide ? rightEdge : leftEdge;
718
- } else {
719
- rightSide = from.x >= child.x + NODE_WIDTH / 2;
720
- baseDetour = rightSide ? child.x + NODE_WIDTH + WIRE_DETOUR : child.x - WIRE_DETOUR;
721
- }
722
- let fallback: { x: number; y: number }[] | null = null;
723
- for (let step = 0; step <= 20; step++) {
724
- const dx = baseDetour + (rightSide ? 1 : -1) * step * GRID_SIZE;
725
- const route = [from, {x: from.x, y: stubY}, {x: dx, y: stubY}, {x: dx, y: overY}, {x: tx, y: overY}, {x: tx, y: ty}];
726
- fallback ??= route;
727
- if (this._routeClear(route, ignore)) return route;
753
+ const spanned = this.nodes.filter(
754
+ n => !ignore.has(n.id) && n.y + NODE_HEIGHT > Math.min(stubY, overY) - GRID_SIZE
755
+ && n.y < Math.max(stubY, overY) + GRID_SIZE
756
+ );
757
+ if (!spanned.length) return [];
758
+
759
+ const rightEdge = Math.max(...spanned.map(n => n.x + NODE_WIDTH)) + WIRE_DETOUR;
760
+ const leftEdge = Math.min(...spanned.map(n => n.x)) - WIRE_DETOUR;
761
+ const cost = (x: number) => Math.abs(x - from.x) + Math.abs(x - tx);
762
+ // A loop weighs both outsides; a forward wire hugs the side its child is
763
+ // already on, so the detour reads as a lean rather than a lap of the flow.
764
+ const preferRight = loop ? cost(rightEdge) <= cost(leftEdge) : tx >= (leftEdge + rightEdge) / 2;
765
+
766
+ const routes: { x: number; y: number }[][] = [];
767
+ for (const dir of preferRight ? [1, -1] : [-1, 1]) {
768
+ const edge = dir > 0 ? rightEdge : leftEdge;
769
+ for (let step = 0; step <= 20; step++) {
770
+ const dx = edge + dir * step * GRID_SIZE;
771
+ routes.push([
772
+ from,
773
+ {x: from.x, y: stubY},
774
+ {x: dx, y: stubY},
775
+ {x: dx, y: overY},
776
+ {x: tx, y: overY},
777
+ {x: tx, y: ty},
778
+ ]);
779
+ }
728
780
  }
729
- return fallback!;
781
+ return routes;
730
782
  }
731
783
 
732
784
  private static _pathFrom(points: { x: number; y: number }[]): string {
@@ -1018,7 +1070,7 @@ export default class ZnFlowCanvas extends ZincElement {
1018
1070
  const loop = loops.has(b.conn);
1019
1071
  const pts = this._routePoints({x: b.x, y: b.exitY}, b.child, b.conn.target.port, offset, node.id, loop);
1020
1072
  paths.push(svg`<path
1021
- class="wire ${loop ? 'wire--loop' : ''}"
1073
+ class="wire wire--link ${loop ? 'wire--loop' : ''}"
1022
1074
  d="${ZnFlowCanvas._pathFrom(pts)}"
1023
1075
  marker-end="url(#${loop ? 'flow-arrow-loop' : 'flow-arrow'})"
1024
1076
  ></path>`);
@@ -1075,10 +1127,11 @@ export default class ZnFlowCanvas extends ZincElement {
1075
1127
  const loops = loopConnections(this.nodes, this.connections);
1076
1128
  const items: {
1077
1129
  key: string; nodeId: string; portId: string; label: string; x: number; top: number; height: number;
1078
- loop: boolean; open: boolean;
1130
+ loop: boolean; open: boolean; fixed: boolean;
1079
1131
  }[] = [];
1080
1132
  for (const node of this.nodes) {
1081
1133
  const {branches} = this._outputLayout(node);
1134
+ const fixed = !!this._typeFor(node)?.fixedOutputs;
1082
1135
  for (const b of branches) {
1083
1136
  if (b.pillTop === null) continue;
1084
1137
  items.push({
@@ -1091,6 +1144,7 @@ export default class ZnFlowCanvas extends ZincElement {
1091
1144
  height: b.pillH,
1092
1145
  loop: !!b.conn && loops.has(b.conn),
1093
1146
  open: !b.conn,
1147
+ fixed,
1094
1148
  });
1095
1149
  }
1096
1150
  }
@@ -1111,17 +1165,18 @@ export default class ZnFlowCanvas extends ZincElement {
1111
1165
  this._emit('flow-branch-pick', {nodeId: i.nodeId, port: i.portId});
1112
1166
  }}"
1113
1167
  >${i.label}</button>
1114
- <button
1115
- class="branch-pill-delete"
1116
- title="Delete branch"
1117
- @pointerdown="${(e: PointerEvent) => e.button === 0 && e.stopPropagation()}"
1118
- @click="${(e: Event) => {
1119
- e.stopPropagation();
1120
- this._emit('flow-branch-delete', {nodeId: i.nodeId, port: i.portId});
1121
- }}"
1122
- >
1123
- <zn-icon src="x@lu" size="14"></zn-icon>
1124
- </button>
1168
+ ${i.fixed ? '' : html`
1169
+ <button
1170
+ class="branch-pill-delete"
1171
+ title="Delete branch"
1172
+ @pointerdown="${(e: PointerEvent) => e.button === 0 && e.stopPropagation()}"
1173
+ @click="${(e: Event) => {
1174
+ e.stopPropagation();
1175
+ this._emit('flow-branch-delete', {nodeId: i.nodeId, port: i.portId});
1176
+ }}"
1177
+ >
1178
+ <zn-icon src="x@lu" size="14"></zn-icon>
1179
+ </button>`}
1125
1180
  ${i.open ? html`
1126
1181
  <span
1127
1182
  class="branch-pill-port"
@@ -42,6 +42,8 @@ export default class ZnFlowStep extends ZincElement {
42
42
  @property() inputs: string;
43
43
  /** JSON array of outputs (`"a"` or `{"id","label"}`), e.g. `'[{"id":"true","label":"TRUE"}]'`. Omit for one default output. */
44
44
  @property() outputs: string;
45
+ /** The declared outputs are the only branches this step has — none can be deleted or added on the canvas. */
46
+ @property({attribute: 'fixed-outputs', type: Boolean}) fixedOutputs = false;
45
47
 
46
48
  connectedCallback() {
47
49
  super.connectedCallback();