@designa8/vibedesign-stripe-deepseek-85ea 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @designa8/vibedesign-stripe-deepseek-85ea
2
2
 
3
- Complete design system generated by Atoms Design, version **0.1.3**.
3
+ Complete design system generated by Atoms Design, version **0.1.4**.
4
4
  Tokens, atoms, molecules, organisms, and templates — import from the package root;
5
5
  never hardcode visual values in consumer code.
6
6
 
package/dist/index.cjs CHANGED
@@ -12108,6 +12108,9 @@ var PLANE_H = 372;
12108
12108
  var PLANE_PAD = 18;
12109
12109
  var PANEL_W = 296;
12110
12110
  var SNAP = 8;
12111
+ var MARQUEE_MIN = 4;
12112
+ var clampTo = (value, min, max) => Math.min(Math.max(value, min), max);
12113
+ var boxesIntersect = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
12111
12114
  var DEFAULT_NODES = [
12112
12115
  {
12113
12116
  id: "source",
@@ -12307,6 +12310,10 @@ var PaymentRoutingCanvas = React62.forwardRef(
12307
12310
  selectionRef.current = selection;
12308
12311
  const onSelectionChangeRef = React62.useRef(onSelectionChange);
12309
12312
  onSelectionChangeRef.current = onSelectionChange;
12313
+ const [marqueeBox, setMarqueeBox] = React62.useState(null);
12314
+ const [marqueeIds, setMarqueeIds] = React62.useState(null);
12315
+ const marqueeRef = React62.useRef(null);
12316
+ const [modifierHeld, setModifierHeld] = React62.useState(false);
12310
12317
  const rootRef = React62.useRef(null);
12311
12318
  const planeRef = React62.useRef(null);
12312
12319
  const triggerRef = React62.useRef(null);
@@ -12318,6 +12325,18 @@ var PaymentRoutingCanvas = React62.forwardRef(
12318
12325
  React62.useEffect(() => {
12319
12326
  setLocalNodes(incomingNodes);
12320
12327
  }, [incomingNodes]);
12328
+ React62.useEffect(() => {
12329
+ const syncModifier = (event) => setModifierHeld(Boolean(event.metaKey || event.ctrlKey));
12330
+ const releaseModifier = () => setModifierHeld(false);
12331
+ window.addEventListener("keydown", syncModifier);
12332
+ window.addEventListener("keyup", syncModifier);
12333
+ window.addEventListener("blur", releaseModifier);
12334
+ return () => {
12335
+ window.removeEventListener("keydown", syncModifier);
12336
+ window.removeEventListener("keyup", syncModifier);
12337
+ window.removeEventListener("blur", releaseModifier);
12338
+ };
12339
+ }, []);
12321
12340
  React62.useEffect(() => {
12322
12341
  const plane = planeRef.current;
12323
12342
  if (!plane) return;
@@ -12387,6 +12406,7 @@ var PaymentRoutingCanvas = React62.forwardRef(
12387
12406
  });
12388
12407
  const focusedNode = panelNodeId ? nodeById.get(panelNodeId) : void 0;
12389
12408
  const selectionSet = React62.useMemo(() => new Set(selection), [selection]);
12409
+ const marqueeSet = React62.useMemo(() => new Set(marqueeIds ?? []), [marqueeIds]);
12390
12410
  const commitSelection = React62.useCallback(
12391
12411
  (next) => {
12392
12412
  const unique = next.filter((id, index) => next.indexOf(id) === index);
@@ -12530,6 +12550,91 @@ var PaymentRoutingCanvas = React62.forwardRef(
12530
12550
  window.addEventListener("pointermove", move);
12531
12551
  window.addEventListener("pointerup", end, { once: true });
12532
12552
  };
12553
+ const beginMarquee = (event) => {
12554
+ if (event.button !== 0) return;
12555
+ if (nodeDragActive.current) return;
12556
+ const plane = planeRef.current;
12557
+ if (!plane) return;
12558
+ const box = plane.getBoundingClientRect();
12559
+ if (!box.width || !box.height) return;
12560
+ const gestureScale = fit * zoom || 1;
12561
+ const originX = clampTo(event.clientX - box.left, 0, box.width);
12562
+ const originY = clampTo(event.clientY - box.top, 0, box.height);
12563
+ const gesture = {
12564
+ originX,
12565
+ originY,
12566
+ box: { left: box.left, top: box.top, width: box.width, height: box.height },
12567
+ offsetX: PLANE_PAD + pan.x,
12568
+ offsetY: Math.max((planeHeight - SCENE_H * gestureScale) / 2, 0) + pan.y,
12569
+ scale: gestureScale,
12570
+ base: selectionRef.current,
12571
+ rect: { x: originX, y: originY, w: 0, h: 0 }
12572
+ };
12573
+ marqueeRef.current = gesture;
12574
+ setMarqueeBox(gesture.rect);
12575
+ setMarqueeIds([]);
12576
+ const hitsFor = (rect) => {
12577
+ const scene = {
12578
+ x: (rect.x - gesture.offsetX) / gesture.scale,
12579
+ y: (rect.y - gesture.offsetY) / gesture.scale,
12580
+ w: rect.w / gesture.scale,
12581
+ h: rect.h / gesture.scale
12582
+ };
12583
+ return nodesRef.current.filter(
12584
+ (node) => boxesIntersect(scene, {
12585
+ x: node.x,
12586
+ y: node.y,
12587
+ w: NODE_W,
12588
+ h: node.h ?? NODE_H
12589
+ })
12590
+ ).map((node) => node.id);
12591
+ };
12592
+ const cleanup = () => {
12593
+ window.removeEventListener("pointermove", move);
12594
+ window.removeEventListener("pointerup", end);
12595
+ window.removeEventListener("pointercancel", cancel);
12596
+ window.removeEventListener("keydown", onKeyDown);
12597
+ };
12598
+ const settle = () => {
12599
+ cleanup();
12600
+ marqueeRef.current = null;
12601
+ setMarqueeBox(null);
12602
+ setMarqueeIds(null);
12603
+ };
12604
+ function cancel() {
12605
+ settle();
12606
+ }
12607
+ function onKeyDown(keyEvent) {
12608
+ if (keyEvent.key !== "Escape") return;
12609
+ keyEvent.preventDefault();
12610
+ cancel();
12611
+ }
12612
+ function move(moveEvent) {
12613
+ const x = clampTo(moveEvent.clientX - gesture.box.left, 0, gesture.box.width);
12614
+ const y = clampTo(moveEvent.clientY - gesture.box.top, 0, gesture.box.height);
12615
+ const rect = {
12616
+ x: Math.min(gesture.originX, x),
12617
+ y: Math.min(gesture.originY, y),
12618
+ w: Math.abs(x - gesture.originX),
12619
+ h: Math.abs(y - gesture.originY)
12620
+ };
12621
+ gesture.rect = rect;
12622
+ setMarqueeBox(rect);
12623
+ setMarqueeIds(hitsFor(rect));
12624
+ }
12625
+ function end() {
12626
+ const rect = gesture.rect;
12627
+ settle();
12628
+ if (Math.max(rect.w, rect.h) < MARQUEE_MIN) return;
12629
+ const hits = hitsFor(rect);
12630
+ if (hits.length === 0) return;
12631
+ commitSelection([...gesture.base, ...hits]);
12632
+ }
12633
+ window.addEventListener("pointermove", move);
12634
+ window.addEventListener("pointerup", end);
12635
+ window.addEventListener("pointercancel", cancel);
12636
+ window.addEventListener("keydown", onKeyDown);
12637
+ };
12533
12638
  const moveFocus = (node, key) => {
12534
12639
  const column = localNodes.filter((item) => Math.abs(item.x - node.x) < 1).sort((a, b) => a.y - b.y);
12535
12640
  let target;
@@ -12648,7 +12753,10 @@ var PaymentRoutingCanvas = React62.forwardRef(
12648
12753
  "aria-label": "Payment routing graph",
12649
12754
  "aria-describedby": "routing-selection-status",
12650
12755
  tabIndex: 0,
12651
- onPointerDown: beginPan,
12756
+ onPointerDown: (event) => {
12757
+ if (event.metaKey || event.ctrlKey) beginMarquee(event);
12758
+ else beginPan(event);
12759
+ },
12652
12760
  onKeyDown: (event) => {
12653
12761
  const modifier = event.metaKey || event.ctrlKey;
12654
12762
  if (modifier && (event.key === "a" || event.key === "A")) {
@@ -12658,6 +12766,7 @@ var PaymentRoutingCanvas = React62.forwardRef(
12658
12766
  }
12659
12767
  if (event.key === "Escape") {
12660
12768
  event.preventDefault();
12769
+ if (marqueeRef.current) return;
12661
12770
  setIssuesOpen(false);
12662
12771
  clearSelection();
12663
12772
  return;
@@ -12679,7 +12788,9 @@ var PaymentRoutingCanvas = React62.forwardRef(
12679
12788
  }
12680
12789
  },
12681
12790
  className: cn54(
12682
- "relative min-h-0 min-w-0 flex-1 cursor-grab overflow-hidden bg-canvas",
12791
+ "relative min-h-0 min-w-0 flex-1 overflow-hidden bg-canvas",
12792
+ // Held Cmd / Ctrl over the background means "drag to select".
12793
+ modifierHeld || marqueeBox ? "cursor-crosshair" : "cursor-grab",
12683
12794
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-blue focus-visible:ring-inset"
12684
12795
  ),
12685
12796
  style: {
@@ -12735,7 +12846,7 @@ var PaymentRoutingCanvas = React62.forwardRef(
12735
12846
  (issue) => issue.kind === "no-fallback"
12736
12847
  );
12737
12848
  const active = traversed.has(node.id);
12738
- const isSelected = selectionSet.has(node.id);
12849
+ const isSelected = selectionSet.has(node.id) || marqueeSet.has(node.id);
12739
12850
  return /* @__PURE__ */ (0, import_jsx_runtime62.jsx)(
12740
12851
  "div",
12741
12852
  {
@@ -12793,6 +12904,25 @@ var PaymentRoutingCanvas = React62.forwardRef(
12793
12904
  ]
12794
12905
  }
12795
12906
  ),
12907
+ /* @__PURE__ */ (0, import_jsx_runtime62.jsx)(
12908
+ "div",
12909
+ {
12910
+ "aria-hidden": "true",
12911
+ "data-state": marqueeBox ? "active" : "idle",
12912
+ className: cn54(
12913
+ "pointer-events-none absolute rounded-xs border border-dashed border-primary bg-primary/10",
12914
+ "transition-opacity duration-150 ease-out",
12915
+ marqueeBox ? "opacity-100" : "opacity-0"
12916
+ ),
12917
+ style: {
12918
+ left: marqueeBox?.x ?? 0,
12919
+ top: marqueeBox?.y ?? 0,
12920
+ width: marqueeBox?.w ?? 0,
12921
+ height: marqueeBox?.h ?? 0,
12922
+ zIndex: "var(--z-sticky, 1100)"
12923
+ }
12924
+ }
12925
+ ),
12796
12926
  /* @__PURE__ */ (0, import_jsx_runtime62.jsx)("ol", { className: "sr-only", children: graphEdges.map((edge) => {
12797
12927
  const from = nodeById.get(edge.from);
12798
12928
  const to = nodeById.get(edge.to);
package/dist/index.js CHANGED
@@ -11972,6 +11972,9 @@ var PLANE_H = 372;
11972
11972
  var PLANE_PAD = 18;
11973
11973
  var PANEL_W = 296;
11974
11974
  var SNAP = 8;
11975
+ var MARQUEE_MIN = 4;
11976
+ var clampTo = (value, min, max) => Math.min(Math.max(value, min), max);
11977
+ var boxesIntersect = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
11975
11978
  var DEFAULT_NODES = [
11976
11979
  {
11977
11980
  id: "source",
@@ -12171,6 +12174,10 @@ var PaymentRoutingCanvas = React62.forwardRef(
12171
12174
  selectionRef.current = selection;
12172
12175
  const onSelectionChangeRef = React62.useRef(onSelectionChange);
12173
12176
  onSelectionChangeRef.current = onSelectionChange;
12177
+ const [marqueeBox, setMarqueeBox] = React62.useState(null);
12178
+ const [marqueeIds, setMarqueeIds] = React62.useState(null);
12179
+ const marqueeRef = React62.useRef(null);
12180
+ const [modifierHeld, setModifierHeld] = React62.useState(false);
12174
12181
  const rootRef = React62.useRef(null);
12175
12182
  const planeRef = React62.useRef(null);
12176
12183
  const triggerRef = React62.useRef(null);
@@ -12182,6 +12189,18 @@ var PaymentRoutingCanvas = React62.forwardRef(
12182
12189
  React62.useEffect(() => {
12183
12190
  setLocalNodes(incomingNodes);
12184
12191
  }, [incomingNodes]);
12192
+ React62.useEffect(() => {
12193
+ const syncModifier = (event) => setModifierHeld(Boolean(event.metaKey || event.ctrlKey));
12194
+ const releaseModifier = () => setModifierHeld(false);
12195
+ window.addEventListener("keydown", syncModifier);
12196
+ window.addEventListener("keyup", syncModifier);
12197
+ window.addEventListener("blur", releaseModifier);
12198
+ return () => {
12199
+ window.removeEventListener("keydown", syncModifier);
12200
+ window.removeEventListener("keyup", syncModifier);
12201
+ window.removeEventListener("blur", releaseModifier);
12202
+ };
12203
+ }, []);
12185
12204
  React62.useEffect(() => {
12186
12205
  const plane = planeRef.current;
12187
12206
  if (!plane) return;
@@ -12251,6 +12270,7 @@ var PaymentRoutingCanvas = React62.forwardRef(
12251
12270
  });
12252
12271
  const focusedNode = panelNodeId ? nodeById.get(panelNodeId) : void 0;
12253
12272
  const selectionSet = React62.useMemo(() => new Set(selection), [selection]);
12273
+ const marqueeSet = React62.useMemo(() => new Set(marqueeIds ?? []), [marqueeIds]);
12254
12274
  const commitSelection = React62.useCallback(
12255
12275
  (next) => {
12256
12276
  const unique = next.filter((id, index) => next.indexOf(id) === index);
@@ -12394,6 +12414,91 @@ var PaymentRoutingCanvas = React62.forwardRef(
12394
12414
  window.addEventListener("pointermove", move);
12395
12415
  window.addEventListener("pointerup", end, { once: true });
12396
12416
  };
12417
+ const beginMarquee = (event) => {
12418
+ if (event.button !== 0) return;
12419
+ if (nodeDragActive.current) return;
12420
+ const plane = planeRef.current;
12421
+ if (!plane) return;
12422
+ const box = plane.getBoundingClientRect();
12423
+ if (!box.width || !box.height) return;
12424
+ const gestureScale = fit * zoom || 1;
12425
+ const originX = clampTo(event.clientX - box.left, 0, box.width);
12426
+ const originY = clampTo(event.clientY - box.top, 0, box.height);
12427
+ const gesture = {
12428
+ originX,
12429
+ originY,
12430
+ box: { left: box.left, top: box.top, width: box.width, height: box.height },
12431
+ offsetX: PLANE_PAD + pan.x,
12432
+ offsetY: Math.max((planeHeight - SCENE_H * gestureScale) / 2, 0) + pan.y,
12433
+ scale: gestureScale,
12434
+ base: selectionRef.current,
12435
+ rect: { x: originX, y: originY, w: 0, h: 0 }
12436
+ };
12437
+ marqueeRef.current = gesture;
12438
+ setMarqueeBox(gesture.rect);
12439
+ setMarqueeIds([]);
12440
+ const hitsFor = (rect) => {
12441
+ const scene = {
12442
+ x: (rect.x - gesture.offsetX) / gesture.scale,
12443
+ y: (rect.y - gesture.offsetY) / gesture.scale,
12444
+ w: rect.w / gesture.scale,
12445
+ h: rect.h / gesture.scale
12446
+ };
12447
+ return nodesRef.current.filter(
12448
+ (node) => boxesIntersect(scene, {
12449
+ x: node.x,
12450
+ y: node.y,
12451
+ w: NODE_W,
12452
+ h: node.h ?? NODE_H
12453
+ })
12454
+ ).map((node) => node.id);
12455
+ };
12456
+ const cleanup = () => {
12457
+ window.removeEventListener("pointermove", move);
12458
+ window.removeEventListener("pointerup", end);
12459
+ window.removeEventListener("pointercancel", cancel);
12460
+ window.removeEventListener("keydown", onKeyDown);
12461
+ };
12462
+ const settle = () => {
12463
+ cleanup();
12464
+ marqueeRef.current = null;
12465
+ setMarqueeBox(null);
12466
+ setMarqueeIds(null);
12467
+ };
12468
+ function cancel() {
12469
+ settle();
12470
+ }
12471
+ function onKeyDown(keyEvent) {
12472
+ if (keyEvent.key !== "Escape") return;
12473
+ keyEvent.preventDefault();
12474
+ cancel();
12475
+ }
12476
+ function move(moveEvent) {
12477
+ const x = clampTo(moveEvent.clientX - gesture.box.left, 0, gesture.box.width);
12478
+ const y = clampTo(moveEvent.clientY - gesture.box.top, 0, gesture.box.height);
12479
+ const rect = {
12480
+ x: Math.min(gesture.originX, x),
12481
+ y: Math.min(gesture.originY, y),
12482
+ w: Math.abs(x - gesture.originX),
12483
+ h: Math.abs(y - gesture.originY)
12484
+ };
12485
+ gesture.rect = rect;
12486
+ setMarqueeBox(rect);
12487
+ setMarqueeIds(hitsFor(rect));
12488
+ }
12489
+ function end() {
12490
+ const rect = gesture.rect;
12491
+ settle();
12492
+ if (Math.max(rect.w, rect.h) < MARQUEE_MIN) return;
12493
+ const hits = hitsFor(rect);
12494
+ if (hits.length === 0) return;
12495
+ commitSelection([...gesture.base, ...hits]);
12496
+ }
12497
+ window.addEventListener("pointermove", move);
12498
+ window.addEventListener("pointerup", end);
12499
+ window.addEventListener("pointercancel", cancel);
12500
+ window.addEventListener("keydown", onKeyDown);
12501
+ };
12397
12502
  const moveFocus = (node, key) => {
12398
12503
  const column = localNodes.filter((item) => Math.abs(item.x - node.x) < 1).sort((a, b) => a.y - b.y);
12399
12504
  let target;
@@ -12512,7 +12617,10 @@ var PaymentRoutingCanvas = React62.forwardRef(
12512
12617
  "aria-label": "Payment routing graph",
12513
12618
  "aria-describedby": "routing-selection-status",
12514
12619
  tabIndex: 0,
12515
- onPointerDown: beginPan,
12620
+ onPointerDown: (event) => {
12621
+ if (event.metaKey || event.ctrlKey) beginMarquee(event);
12622
+ else beginPan(event);
12623
+ },
12516
12624
  onKeyDown: (event) => {
12517
12625
  const modifier = event.metaKey || event.ctrlKey;
12518
12626
  if (modifier && (event.key === "a" || event.key === "A")) {
@@ -12522,6 +12630,7 @@ var PaymentRoutingCanvas = React62.forwardRef(
12522
12630
  }
12523
12631
  if (event.key === "Escape") {
12524
12632
  event.preventDefault();
12633
+ if (marqueeRef.current) return;
12525
12634
  setIssuesOpen(false);
12526
12635
  clearSelection();
12527
12636
  return;
@@ -12543,7 +12652,9 @@ var PaymentRoutingCanvas = React62.forwardRef(
12543
12652
  }
12544
12653
  },
12545
12654
  className: cn54(
12546
- "relative min-h-0 min-w-0 flex-1 cursor-grab overflow-hidden bg-canvas",
12655
+ "relative min-h-0 min-w-0 flex-1 overflow-hidden bg-canvas",
12656
+ // Held Cmd / Ctrl over the background means "drag to select".
12657
+ modifierHeld || marqueeBox ? "cursor-crosshair" : "cursor-grab",
12547
12658
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-blue focus-visible:ring-inset"
12548
12659
  ),
12549
12660
  style: {
@@ -12599,7 +12710,7 @@ var PaymentRoutingCanvas = React62.forwardRef(
12599
12710
  (issue) => issue.kind === "no-fallback"
12600
12711
  );
12601
12712
  const active = traversed.has(node.id);
12602
- const isSelected = selectionSet.has(node.id);
12713
+ const isSelected = selectionSet.has(node.id) || marqueeSet.has(node.id);
12603
12714
  return /* @__PURE__ */ jsx62(
12604
12715
  "div",
12605
12716
  {
@@ -12657,6 +12768,25 @@ var PaymentRoutingCanvas = React62.forwardRef(
12657
12768
  ]
12658
12769
  }
12659
12770
  ),
12771
+ /* @__PURE__ */ jsx62(
12772
+ "div",
12773
+ {
12774
+ "aria-hidden": "true",
12775
+ "data-state": marqueeBox ? "active" : "idle",
12776
+ className: cn54(
12777
+ "pointer-events-none absolute rounded-xs border border-dashed border-primary bg-primary/10",
12778
+ "transition-opacity duration-150 ease-out",
12779
+ marqueeBox ? "opacity-100" : "opacity-0"
12780
+ ),
12781
+ style: {
12782
+ left: marqueeBox?.x ?? 0,
12783
+ top: marqueeBox?.y ?? 0,
12784
+ width: marqueeBox?.w ?? 0,
12785
+ height: marqueeBox?.h ?? 0,
12786
+ zIndex: "var(--z-sticky, 1100)"
12787
+ }
12788
+ }
12789
+ ),
12660
12790
  /* @__PURE__ */ jsx62("ol", { className: "sr-only", children: graphEdges.map((edge) => {
12661
12791
  const from = nodeById.get(edge.from);
12662
12792
  const to = nodeById.get(edge.to);
package/dist/styles.css CHANGED
@@ -1183,6 +1183,9 @@
1183
1183
  .animate-spin {
1184
1184
  animation: var(--animate-spin);
1185
1185
  }
1186
+ .cursor-crosshair {
1187
+ cursor: crosshair;
1188
+ }
1186
1189
  .cursor-default {
1187
1190
  cursor: default;
1188
1191
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@designa8/vibedesign-stripe-deepseek-85ea",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Complete design system \"My Project stripe-deepseek\" generated by Atoms Design.",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",
@@ -29,6 +29,42 @@ const PLANE_PAD = 18;
29
29
  const PANEL_W = 296;
30
30
  /** Drag and keyboard nudge both land on this grid. */
31
31
  const SNAP = 8;
32
+ /** A rubber-band narrower than this on both axes is a background click. */
33
+ const MARQUEE_MIN = 4;
34
+
35
+ /** A rectangle in whatever space the caller is working in. */
36
+ interface Box {
37
+ x: number;
38
+ y: number;
39
+ w: number;
40
+ h: number;
41
+ }
42
+
43
+ const clampTo = (value: number, min: number, max: number) =>
44
+ Math.min(Math.max(value, min), max);
45
+
46
+ const boxesIntersect = (a: Box, b: Box) =>
47
+ a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
48
+
49
+ /**
50
+ * One rubber-band gesture. The plane transform is captured at pointer-down so
51
+ * the screen-space rectangle can be mapped back into scene coordinates at any
52
+ * zoom / pan level without re-reading layout mid-drag.
53
+ */
54
+ interface MarqueeGesture {
55
+ /** Pointer-down point, in plane-local screen coordinates. */
56
+ originX: number;
57
+ originY: number;
58
+ box: { left: number; top: number; width: number; height: number };
59
+ /** Plane-local x of the scene origin. */
60
+ offsetX: number;
61
+ /** Plane-local y of the scene origin. */
62
+ offsetY: number;
63
+ scale: number;
64
+ /** Selection the marquee is added to on release. */
65
+ base: string[];
66
+ rect: Box;
67
+ }
32
68
 
33
69
  export interface CanvasNode {
34
70
  id: string;
@@ -334,6 +370,14 @@ export const PaymentRoutingCanvas = React.forwardRef<
334
370
  const onSelectionChangeRef = React.useRef(onSelectionChange);
335
371
  onSelectionChangeRef.current = onSelectionChange;
336
372
 
373
+ /** The rubber-band rectangle, in plane-local screen coordinates. */
374
+ const [marqueeBox, setMarqueeBox] = React.useState<Box | null>(null);
375
+ /** Live preview of what the rubber-band currently covers. */
376
+ const [marqueeIds, setMarqueeIds] = React.useState<string[] | null>(null);
377
+ const marqueeRef = React.useRef<MarqueeGesture | null>(null);
378
+ /** True while Cmd / Ctrl is held, so the plane can show a crosshair. */
379
+ const [modifierHeld, setModifierHeld] = React.useState(false);
380
+
337
381
  const rootRef = React.useRef<HTMLDivElement>(null);
338
382
  const planeRef = React.useRef<HTMLDivElement>(null);
339
383
  const triggerRef = React.useRef<HTMLButtonElement>(null);
@@ -348,6 +392,20 @@ export const PaymentRoutingCanvas = React.forwardRef<
348
392
  setLocalNodes(incomingNodes);
349
393
  }, [incomingNodes]);
350
394
 
395
+ React.useEffect(() => {
396
+ const syncModifier = (event: KeyboardEvent) =>
397
+ setModifierHeld(Boolean(event.metaKey || event.ctrlKey));
398
+ const releaseModifier = () => setModifierHeld(false);
399
+ window.addEventListener("keydown", syncModifier);
400
+ window.addEventListener("keyup", syncModifier);
401
+ window.addEventListener("blur", releaseModifier);
402
+ return () => {
403
+ window.removeEventListener("keydown", syncModifier);
404
+ window.removeEventListener("keyup", syncModifier);
405
+ window.removeEventListener("blur", releaseModifier);
406
+ };
407
+ }, []);
408
+
351
409
  React.useEffect(() => {
352
410
  const plane = planeRef.current;
353
411
  if (!plane) return;
@@ -428,6 +486,8 @@ export const PaymentRoutingCanvas = React.forwardRef<
428
486
 
429
487
  const focusedNode = panelNodeId ? nodeById.get(panelNodeId) : undefined;
430
488
  const selectionSet = React.useMemo(() => new Set(selection), [selection]);
489
+ /** Nodes under the rubber-band preview as selected before the release. */
490
+ const marqueeSet = React.useMemo(() => new Set(marqueeIds ?? []), [marqueeIds]);
431
491
 
432
492
  /** Single funnel for selection writes — keeps the controlled path honest. */
433
493
  const commitSelection = React.useCallback(
@@ -606,6 +666,118 @@ export const PaymentRoutingCanvas = React.forwardRef<
606
666
  window.addEventListener("pointerup", end, { once: true });
607
667
  };
608
668
 
669
+ /**
670
+ * Rubber-band selection. Cmd (macOS) / Ctrl elsewhere plus a pointer-down on
671
+ * empty plane background draws a rectangle instead of panning. The gesture
672
+ * tracks through window listeners so it keeps following the pointer outside
673
+ * the plane, with the drawn rectangle clamped to the plane bounds.
674
+ */
675
+ const beginMarquee = (event: React.PointerEvent) => {
676
+ if (event.button !== 0) return;
677
+ // A node gesture owns the pointer — never start a marquee over it.
678
+ if (nodeDragActive.current) return;
679
+ const plane = planeRef.current;
680
+ if (!plane) return;
681
+ const box = plane.getBoundingClientRect();
682
+ if (!box.width || !box.height) return;
683
+
684
+ const gestureScale = fit * zoom || 1;
685
+ const originX = clampTo(event.clientX - box.left, 0, box.width);
686
+ const originY = clampTo(event.clientY - box.top, 0, box.height);
687
+
688
+ const gesture: MarqueeGesture = {
689
+ originX,
690
+ originY,
691
+ box: { left: box.left, top: box.top, width: box.width, height: box.height },
692
+ offsetX: PLANE_PAD + pan.x,
693
+ offsetY: Math.max((planeHeight - SCENE_H * gestureScale) / 2, 0) + pan.y,
694
+ scale: gestureScale,
695
+ base: selectionRef.current,
696
+ rect: { x: originX, y: originY, w: 0, h: 0 },
697
+ };
698
+
699
+ marqueeRef.current = gesture;
700
+ setMarqueeBox(gesture.rect);
701
+ setMarqueeIds([]);
702
+
703
+ /** Screen-space rectangle → the scene-space ids whose card box it covers. */
704
+ const hitsFor = (rect: Box) => {
705
+ const scene: Box = {
706
+ x: (rect.x - gesture.offsetX) / gesture.scale,
707
+ y: (rect.y - gesture.offsetY) / gesture.scale,
708
+ w: rect.w / gesture.scale,
709
+ h: rect.h / gesture.scale,
710
+ };
711
+ return nodesRef.current
712
+ .filter((node) =>
713
+ boxesIntersect(scene, {
714
+ x: node.x,
715
+ y: node.y,
716
+ w: NODE_W,
717
+ h: node.h ?? NODE_H,
718
+ }),
719
+ )
720
+ .map((node) => node.id);
721
+ };
722
+
723
+ const cleanup = () => {
724
+ window.removeEventListener("pointermove", move);
725
+ window.removeEventListener("pointerup", end);
726
+ window.removeEventListener("pointercancel", cancel);
727
+ window.removeEventListener("keydown", onKeyDown);
728
+ };
729
+
730
+ const settle = () => {
731
+ cleanup();
732
+ marqueeRef.current = null;
733
+ setMarqueeBox(null);
734
+ setMarqueeIds(null);
735
+ };
736
+
737
+ function cancel() {
738
+ // Escape abandons the gesture. Nothing was committed during the drag, so
739
+ // the selection from before the marquee is still in place.
740
+ settle();
741
+ }
742
+
743
+ function onKeyDown(keyEvent: KeyboardEvent) {
744
+ if (keyEvent.key !== "Escape") return;
745
+ keyEvent.preventDefault();
746
+ cancel();
747
+ }
748
+
749
+ function move(moveEvent: PointerEvent) {
750
+ const x = clampTo(moveEvent.clientX - gesture.box.left, 0, gesture.box.width);
751
+ const y = clampTo(moveEvent.clientY - gesture.box.top, 0, gesture.box.height);
752
+ const rect: Box = {
753
+ x: Math.min(gesture.originX, x),
754
+ y: Math.min(gesture.originY, y),
755
+ w: Math.abs(x - gesture.originX),
756
+ h: Math.abs(y - gesture.originY),
757
+ };
758
+ gesture.rect = rect;
759
+ setMarqueeBox(rect);
760
+ setMarqueeIds(hitsFor(rect));
761
+ }
762
+
763
+ function end() {
764
+ const rect = gesture.rect;
765
+ settle();
766
+ // Too small to be a marquee — treat it as a click on the background and
767
+ // leave the selection exactly as it was.
768
+ if (Math.max(rect.w, rect.h) < MARQUEE_MIN) return;
769
+ const hits = hitsFor(rect);
770
+ if (hits.length === 0) return;
771
+ // Cmd / Ctrl marquee is additive: extend what was already selected.
772
+ commitSelection([...gesture.base, ...hits]);
773
+ }
774
+
775
+ window.addEventListener("pointermove", move);
776
+ window.addEventListener("pointerup", end);
777
+ window.addEventListener("pointercancel", cancel);
778
+ window.addEventListener("keydown", onKeyDown);
779
+ };
780
+
609
781
  const moveFocus = (node: CanvasNode, key: string) => {
610
782
  const column = localNodes
611
783
  .filter((item) => Math.abs(item.x - node.x) < 1)
@@ -733,7 +905,12 @@ export const PaymentRoutingCanvas = React.forwardRef<
733
905
  aria-label="Payment routing graph"
734
906
  aria-describedby="routing-selection-status"
735
907
  tabIndex={0}
736
- onPointerDown={beginPan}
908
+ onPointerDown={(event) => {
909
+ // Cmd / Ctrl on empty background draws a rubber band; a plain
910
+ // drag keeps panning the plane.
911
+ if (event.metaKey || event.ctrlKey) beginMarquee(event);
912
+ else beginPan(event);
913
+ }}
737
914
  onKeyDown={(event) => {
738
915
  const modifier = event.metaKey || event.ctrlKey;
739
916
  if (modifier && (event.key === "a" || event.key === "A")) {
@@ -743,6 +920,9 @@ export const PaymentRoutingCanvas = React.forwardRef<
743
920
  }
744
921
  if (event.key === "Escape") {
745
922
  event.preventDefault();
923
+ // A live marquee cancels itself and restores the selection it
924
+ // started from — do not also clear the selection here.
925
+ if (marqueeRef.current) return;
746
926
  setIssuesOpen(false);
747
927
  clearSelection();
748
928
  return;
@@ -770,7 +950,9 @@ export const PaymentRoutingCanvas = React.forwardRef<
770
950
  }
771
951
  }}
772
952
  className={cn(
773
- "relative min-h-0 min-w-0 flex-1 cursor-grab overflow-hidden bg-canvas",
953
+ "relative min-h-0 min-w-0 flex-1 overflow-hidden bg-canvas",
954
+ // Held Cmd / Ctrl over the background means "drag to select".
955
+ modifierHeld || marqueeBox ? "cursor-crosshair" : "cursor-grab",
774
956
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-blue focus-visible:ring-inset",
775
957
  )}
776
958
  style={{
@@ -840,7 +1022,7 @@ export const PaymentRoutingCanvas = React.forwardRef<
840
1022
  (issue) => issue.kind === "no-fallback",
841
1023
  );
842
1024
  const active = traversed.has(node.id);
843
- const isSelected = selectionSet.has(node.id);
1025
+ const isSelected = selectionSet.has(node.id) || marqueeSet.has(node.id);
844
1026
 
845
1027
  return (
846
1028
  <div
@@ -912,6 +1094,24 @@ export const PaymentRoutingCanvas = React.forwardRef<
912
1094
  })}
913
1095
  </div>
914
1096
 
1097
+ {/* Rubber band — screen space, above the edges and the cards. */}
1098
+ <div
1099
+ aria-hidden="true"
1100
+ data-state={marqueeBox ? "active" : "idle"}
1101
+ className={cn(
1102
+ "pointer-events-none absolute rounded-xs border border-dashed border-primary bg-primary/10",
1103
+ "transition-opacity duration-150 ease-out",
1104
+ marqueeBox ? "opacity-100" : "opacity-0",
1105
+ )}
1106
+ style={{
1107
+ left: marqueeBox?.x ?? 0,
1108
+ top: marqueeBox?.y ?? 0,
1109
+ width: marqueeBox?.w ?? 0,
1110
+ height: marqueeBox?.h ?? 0,
1111
+ zIndex: "var(--z-sticky, 1100)",
1112
+ }}
1113
+ />
1114
+
915
1115
  <ol className="sr-only">
916
1116
  {graphEdges.map((edge) => {
917
1117
  const from = nodeById.get(edge.from);
@@ -21,6 +21,11 @@ export default function Preview() {
21
21
  node moves the whole group. Cmd/Ctrl + A selects every node, Shift + arrows
22
22
  nudge the selection, and Escape or a click on empty canvas clears it.
23
23
  </p>
24
+ <p className="text-caption text-muted">
25
+ Hold Cmd (macOS) or Ctrl, then drag from empty background to draw a
26
+ selection rectangle — every card it touches is added to the selection on
27
+ release. Escape during the drag cancels it.
28
+ </p>
24
29
  </div>
25
30
 
26
31
  <PaymentRoutingCanvas