pict-section-flow 1.2.0 → 1.4.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.
@@ -11,7 +11,9 @@ const INTERACTION_STATES =
11
11
  DRAGGING_HANDLE: 'dragging-handle',
12
12
  CONNECTING: 'connecting',
13
13
  PANNING: 'panning',
14
- RESIZING_PANEL: 'resizing-panel'
14
+ RESIZING_PANEL: 'resizing-panel',
15
+ RESIZING_NODE: 'resizing-node',
16
+ MARQUEE: 'marquee'
15
17
  };
16
18
 
17
19
  class PictServiceFlowInteractionManager extends libFableServiceProviderBase
@@ -71,6 +73,13 @@ class PictServiceFlowInteractionManager extends libFableServiceProviderBase
71
73
  this._ResizeStartY = 0;
72
74
  this._ResizePanelStartHeight = 0;
73
75
 
76
+ // Node resize state (drag a node's bottom-right handle to set Width/Height)
77
+ this._ResizeNodeHash = null;
78
+ this._ResizeNodeStartX = 0;
79
+ this._ResizeNodeStartY = 0;
80
+ this._ResizeNodeStartWidth = 0;
81
+ this._ResizeNodeStartHeight = 0;
82
+
74
83
  // Double-click detection for connections
75
84
  this._LastConnectionClickTime = 0;
76
85
  this._LastConnectionClickHash = null;
@@ -196,6 +205,15 @@ class PictServiceFlowInteractionManager extends libFableServiceProviderBase
196
205
  let tmpNodeHash = this._getNodeHash(tmpTarget);
197
206
  let tmpNow = Date.now();
198
207
 
208
+ // Shift-click toggles this node in the multi-selection (no drag, no panel).
209
+ if (tmpNodeHash && this._FlowView.options.EnableMultiSelect && pEvent.shiftKey)
210
+ {
211
+ this._LastClickTime = 0;
212
+ this._LastClickNodeHash = null;
213
+ this._FlowView.toggleNodeSelection(tmpNodeHash);
214
+ break;
215
+ }
216
+
199
217
  // Check for double-click on same node
200
218
  if (tmpNodeHash && tmpNodeHash === this._LastClickNodeHash
201
219
  && (tmpNow - this._LastClickTime) < this._DoubleClickThreshold)
@@ -223,6 +241,10 @@ class PictServiceFlowInteractionManager extends libFableServiceProviderBase
223
241
  this._startPanelResize(pEvent, tmpTarget);
224
242
  break;
225
243
 
244
+ case 'node-resize':
245
+ this._startNodeResize(pEvent, tmpTarget);
246
+ break;
247
+
226
248
  case 'panel-close':
227
249
  {
228
250
  let tmpPanelHash = this._getPanelHash(tmpTarget);
@@ -343,10 +365,19 @@ class PictServiceFlowInteractionManager extends libFableServiceProviderBase
343
365
  }
344
366
 
345
367
  default:
346
- // Click on background - start panning or deselect
347
- if (pEvent.button === 0 && this._FlowView.options.EnablePanning)
368
+ // Click / drag on the empty background.
369
+ if (pEvent.button === 0)
348
370
  {
349
- this._startPanning(pEvent);
371
+ if (this._FlowView.options.EnableMultiSelect && !pEvent.shiftKey)
372
+ {
373
+ // Plain drag marquee-selects; a click (no drag) clears the selection on release.
374
+ this._startMarquee(pEvent);
375
+ }
376
+ else if (this._FlowView.options.EnablePanning)
377
+ {
378
+ // With multi-select on, shift+drag pans; otherwise this is the normal pan.
379
+ this._startPanning(pEvent);
380
+ }
350
381
  }
351
382
  break;
352
383
  }
@@ -382,9 +413,17 @@ class PictServiceFlowInteractionManager extends libFableServiceProviderBase
382
413
  this._onPanelResize(pEvent);
383
414
  break;
384
415
 
416
+ case INTERACTION_STATES.RESIZING_NODE:
417
+ this._onNodeResize(pEvent);
418
+ break;
419
+
385
420
  case INTERACTION_STATES.PANNING:
386
421
  this._onPan(pEvent);
387
422
  break;
423
+
424
+ case INTERACTION_STATES.MARQUEE:
425
+ this._onMarquee(pEvent);
426
+ break;
388
427
  }
389
428
  }
390
429
 
@@ -420,6 +459,10 @@ class PictServiceFlowInteractionManager extends libFableServiceProviderBase
420
459
  this._endPanelResize(pEvent);
421
460
  break;
422
461
 
462
+ case INTERACTION_STATES.RESIZING_NODE:
463
+ this._endNodeResize(pEvent);
464
+ break;
465
+
423
466
  case INTERACTION_STATES.CONNECTING:
424
467
  this._endConnection(pEvent);
425
468
  break;
@@ -427,6 +470,10 @@ class PictServiceFlowInteractionManager extends libFableServiceProviderBase
427
470
  case INTERACTION_STATES.PANNING:
428
471
  this._endPanning(pEvent);
429
472
  break;
473
+
474
+ case INTERACTION_STATES.MARQUEE:
475
+ this._endMarquee(pEvent);
476
+ break;
430
477
  }
431
478
  }
432
479
 
@@ -513,63 +560,208 @@ class PictServiceFlowInteractionManager extends libFableServiceProviderBase
513
560
  let tmpNodeHash = this._getNodeHash(pTarget);
514
561
  if (!tmpNodeHash) return;
515
562
 
516
- this._FlowView.selectNode(tmpNodeHash);
563
+ // If the grabbed node is part of a multi-selection, drag the whole set; otherwise select just
564
+ // it. (Grabbing a node outside the current selection collapses to that single node.)
565
+ let tmpSelected = this._FlowView.getSelectedNodeHashes ? this._FlowView.getSelectedNodeHashes() : [];
566
+ let tmpIsMulti = this._FlowView.options.EnableMultiSelect && tmpSelected.length > 1 && tmpSelected.indexOf(tmpNodeHash) >= 0;
567
+ if (!tmpIsMulti)
568
+ {
569
+ this._FlowView.selectNode(tmpNodeHash);
570
+ tmpSelected = [tmpNodeHash];
571
+ }
517
572
 
518
- let tmpNode = this._FlowView.getNode(tmpNodeHash);
519
- if (!tmpNode) return;
573
+ // Capture each dragged node's start position so one delta applies to all of them.
574
+ this._DragNodes = [];
575
+ for (let i = 0; i < tmpSelected.length; i++)
576
+ {
577
+ let tmpN = this._FlowView.getNode(tmpSelected[i]);
578
+ if (tmpN) { this._DragNodes.push({ Hash: tmpN.Hash, StartX: tmpN.X, StartY: tmpN.Y }); }
579
+ }
580
+ if (this._DragNodes.length === 0) return;
520
581
 
521
582
  this._State = INTERACTION_STATES.DRAGGING_NODE;
522
583
  this._DragNodeHash = tmpNodeHash;
523
584
  this._DragStartX = pEvent.clientX;
524
585
  this._DragStartY = pEvent.clientY;
525
- this._DragNodeStartX = tmpNode.X;
526
- this._DragNodeStartY = tmpNode.Y;
527
586
 
528
587
  this._SVGElement.classList.add('panning');
529
588
 
530
- let tmpNodeGroup = this._FlowView._NodesLayer.querySelector(`[data-node-hash="${tmpNodeHash}"]`);
531
- if (tmpNodeGroup)
589
+ for (let i = 0; i < this._DragNodes.length; i++)
532
590
  {
533
- tmpNodeGroup.classList.add('dragging');
591
+ let tmpNodeGroup = this._FlowView._NodesLayer.querySelector(`[data-node-hash="${this._DragNodes[i].Hash}"]`);
592
+ if (tmpNodeGroup) { tmpNodeGroup.classList.add('dragging'); }
534
593
  }
535
594
  }
536
595
 
537
596
  _onNodeDrag(pEvent)
538
597
  {
539
- if (!this._DragNodeHash) return;
598
+ if (!this._DragNodes || this._DragNodes.length === 0) return;
540
599
 
541
600
  let tmpVS = this._FlowView.viewState;
542
601
  let tmpDX = (pEvent.clientX - this._DragStartX) / tmpVS.Zoom;
543
602
  let tmpDY = (pEvent.clientY - this._DragStartY) / tmpVS.Zoom;
544
603
 
545
- let tmpNewX = this._DragNodeStartX + tmpDX;
546
- let tmpNewY = this._DragNodeStartY + tmpDY;
604
+ // A single-node drag can show alignment guides and snap to other nodes' edges/centers.
605
+ // Multi-drag keeps the rigid group offset (aligning a moving group is ambiguous).
606
+ if (this._DragNodes.length === 1 && this._FlowView.options.EnableAlignmentGuides)
607
+ {
608
+ let tmpHash = this._DragNodes[0].Hash;
609
+ let tmpNode = this._FlowView.getNode(tmpHash);
610
+ let tmpX = this._snapToGrid(this._DragNodes[0].StartX + tmpDX);
611
+ let tmpY = this._snapToGrid(this._DragNodes[0].StartY + tmpDY);
612
+ if (tmpNode)
613
+ {
614
+ let tmpAlign = this._alignmentFor(tmpNode, tmpX, tmpY);
615
+ this._drawGuides(tmpAlign.Guides);
616
+ this._FlowView.updateNodePosition(tmpHash, tmpAlign.X, tmpAlign.Y);
617
+ return;
618
+ }
619
+ }
547
620
 
548
- this._FlowView.updateNodePosition(this._DragNodeHash, tmpNewX, tmpNewY);
621
+ for (let i = 0; i < this._DragNodes.length; i++)
622
+ {
623
+ let tmpNewX = this._snapToGrid(this._DragNodes[i].StartX + tmpDX);
624
+ let tmpNewY = this._snapToGrid(this._DragNodes[i].StartY + tmpDY);
625
+ this._FlowView.updateNodePosition(this._DragNodes[i].Hash, tmpNewX, tmpNewY);
626
+ }
627
+ }
628
+
629
+ // ---- Alignment guides (single-node drag) ----
630
+ // Compare the dragged node's six reference lines (left / center-x / right and top / center-y /
631
+ // bottom) against every other node's. Within a small threshold the dragged node snaps so the lines
632
+ // coincide, and a guide line is reported spanning both nodes. Returns the (possibly snapped)
633
+ // position plus the guides to draw; a pure computation so it can be unit-tested without a DOM.
634
+ _alignmentFor(pDragNode, pX, pY)
635
+ {
636
+ let tmpZoom = (this._FlowView.viewState && this._FlowView.viewState.Zoom) ? this._FlowView.viewState.Zoom : 1;
637
+ let tmpThreshold = 6 / tmpZoom;
638
+ let tmpDefaultW = this._FlowView.options.DefaultNodeWidth || 180;
639
+ let tmpDefaultH = this._FlowView.options.DefaultNodeHeight || 80;
640
+ let tmpW = (typeof pDragNode.Width === 'number') ? pDragNode.Width : tmpDefaultW;
641
+ let tmpH = (typeof pDragNode.Height === 'number') ? pDragNode.Height : tmpDefaultH;
642
+
643
+ let tmpDragV = [ pX, pX + tmpW / 2, pX + tmpW ]; // left, center-x, right
644
+ let tmpDragH = [ pY, pY + tmpH / 2, pY + tmpH ]; // top, center-y, bottom
645
+
646
+ let tmpBestV = null;
647
+ let tmpBestH = null;
648
+ let tmpNodes = this._FlowView._FlowData.Nodes || [];
649
+
650
+ for (let i = 0; i < tmpNodes.length; i++)
651
+ {
652
+ let tmpO = tmpNodes[i];
653
+ if (tmpO.Hash === pDragNode.Hash) continue;
654
+ let tmpOW = (typeof tmpO.Width === 'number') ? tmpO.Width : tmpDefaultW;
655
+ let tmpOH = (typeof tmpO.Height === 'number') ? tmpO.Height : tmpDefaultH;
656
+ let tmpOtherV = [ tmpO.X, tmpO.X + tmpOW / 2, tmpO.X + tmpOW ];
657
+ let tmpOtherH = [ tmpO.Y, tmpO.Y + tmpOH / 2, tmpO.Y + tmpOH ];
658
+
659
+ for (let a = 0; a < tmpDragV.length; a++)
660
+ {
661
+ for (let b = 0; b < tmpOtherV.length; b++)
662
+ {
663
+ let tmpD = Math.abs(tmpDragV[a] - tmpOtherV[b]);
664
+ if (tmpD <= tmpThreshold && (!tmpBestV || tmpD < tmpBestV.Delta))
665
+ {
666
+ tmpBestV = { Delta: tmpD, Pos: tmpOtherV[b], SnapX: pX + (tmpOtherV[b] - tmpDragV[a]), OtherTop: tmpO.Y, OtherBottom: tmpO.Y + tmpOH };
667
+ }
668
+ }
669
+ }
670
+ for (let a = 0; a < tmpDragH.length; a++)
671
+ {
672
+ for (let b = 0; b < tmpOtherH.length; b++)
673
+ {
674
+ let tmpD = Math.abs(tmpDragH[a] - tmpOtherH[b]);
675
+ if (tmpD <= tmpThreshold && (!tmpBestH || tmpD < tmpBestH.Delta))
676
+ {
677
+ tmpBestH = { Delta: tmpD, Pos: tmpOtherH[b], SnapY: pY + (tmpOtherH[b] - tmpDragH[a]), OtherLeft: tmpO.X, OtherRight: tmpO.X + tmpOW };
678
+ }
679
+ }
680
+ }
681
+ }
682
+
683
+ let tmpResult = { X: pX, Y: pY, Guides: [] };
684
+ if (tmpBestV)
685
+ {
686
+ tmpResult.X = tmpBestV.SnapX;
687
+ tmpResult.Guides.push({ Type: 'v', Pos: tmpBestV.Pos, A: Math.min(tmpResult.Y, tmpBestV.OtherTop), B: Math.max(tmpResult.Y + tmpH, tmpBestV.OtherBottom) });
688
+ }
689
+ if (tmpBestH)
690
+ {
691
+ tmpResult.Y = tmpBestH.SnapY;
692
+ tmpResult.Guides.push({ Type: 'h', Pos: tmpBestH.Pos, A: Math.min(tmpResult.X, tmpBestH.OtherLeft), B: Math.max(tmpResult.X + tmpW, tmpBestH.OtherRight) });
693
+ }
694
+ return tmpResult;
695
+ }
696
+
697
+ _drawGuides(pGuides)
698
+ {
699
+ this._clearGuides();
700
+ if (!pGuides || pGuides.length === 0 || !this._FlowView._ViewportElement) return;
701
+ this._GuideElements = [];
702
+ for (let i = 0; i < pGuides.length; i++)
703
+ {
704
+ let tmpG = pGuides[i];
705
+ let tmpLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
706
+ tmpLine.setAttribute('class', 'pict-flow-align-guide');
707
+ if (tmpG.Type === 'v') { tmpLine.setAttribute('x1', tmpG.Pos); tmpLine.setAttribute('x2', tmpG.Pos); tmpLine.setAttribute('y1', tmpG.A); tmpLine.setAttribute('y2', tmpG.B); }
708
+ else { tmpLine.setAttribute('y1', tmpG.Pos); tmpLine.setAttribute('y2', tmpG.Pos); tmpLine.setAttribute('x1', tmpG.A); tmpLine.setAttribute('x2', tmpG.B); }
709
+ this._FlowView._ViewportElement.appendChild(tmpLine);
710
+ this._GuideElements.push(tmpLine);
711
+ }
712
+ }
713
+
714
+ _clearGuides()
715
+ {
716
+ if (this._GuideElements)
717
+ {
718
+ for (let i = 0; i < this._GuideElements.length; i++)
719
+ {
720
+ let tmpEl = this._GuideElements[i];
721
+ if (tmpEl.parentNode) { tmpEl.parentNode.removeChild(tmpEl); }
722
+ }
723
+ }
724
+ this._GuideElements = null;
725
+ }
726
+
727
+ // Snap a coordinate or size to the grid when the flow has EnableGridSnap on; otherwise pass through.
728
+ _snapToGrid(pValue)
729
+ {
730
+ if (!this._FlowView.options.EnableGridSnap) { return pValue; }
731
+ // A defined GridSnapSize of 0 means "no snap"; only an absent size falls back to the default.
732
+ let tmpGrid = (typeof this._FlowView.options.GridSnapSize === 'number') ? this._FlowView.options.GridSnapSize : 20;
733
+ if (tmpGrid <= 0) { return pValue; }
734
+ return Math.round(pValue / tmpGrid) * tmpGrid;
549
735
  }
550
736
 
551
737
  _endNodeDrag(pEvent)
552
738
  {
553
739
  this._SVGElement.classList.remove('panning');
740
+ this._clearGuides();
554
741
 
555
- let tmpNodeGroup = this._FlowView._NodesLayer.querySelector(`[data-node-hash="${this._DragNodeHash}"]`);
556
- if (tmpNodeGroup)
742
+ let tmpDragged = this._DragNodes || [];
743
+ for (let i = 0; i < tmpDragged.length; i++)
557
744
  {
558
- tmpNodeGroup.classList.remove('dragging');
745
+ let tmpNodeGroup = this._FlowView._NodesLayer.querySelector(`[data-node-hash="${tmpDragged[i].Hash}"]`);
746
+ if (tmpNodeGroup) { tmpNodeGroup.classList.remove('dragging'); }
559
747
  }
560
748
 
561
749
  this._FlowView.renderFlow();
562
750
  this._FlowView.marshalFromView();
563
751
 
564
- let tmpNode = this._FlowView.getNode(this._DragNodeHash);
565
- if (tmpNode && this._FlowView._EventHandlerProvider)
752
+ if (this._FlowView._EventHandlerProvider)
566
753
  {
567
- this._FlowView._EventHandlerProvider.fireEvent('onNodeMoved', tmpNode);
754
+ for (let i = 0; i < tmpDragged.length; i++)
755
+ {
756
+ let tmpNode = this._FlowView.getNode(tmpDragged[i].Hash);
757
+ if (tmpNode) { this._FlowView._EventHandlerProvider.fireEvent('onNodeMoved', tmpNode); }
758
+ }
568
759
  this._FlowView._EventHandlerProvider.fireEvent('onFlowChanged', this._FlowView.flowData);
569
760
  }
570
761
 
571
762
  this._State = INTERACTION_STATES.IDLE;
572
763
  this._DragNodeHash = null;
764
+ this._DragNodes = null;
573
765
  }
574
766
 
575
767
  // ---- Panel Dragging ----
@@ -684,6 +876,71 @@ class PictServiceFlowInteractionManager extends libFableServiceProviderBase
684
876
  this._ResizePanelHash = null;
685
877
  }
686
878
 
879
+ // ---- Node Resizing ----
880
+ // Drag the bottom-right handle (rendered on the selected node when the flow's
881
+ // EnableNodeResizing option is on) to set Width/Height. Deltas are divided by the
882
+ // zoom so the corner tracks the pointer at any zoom level.
883
+
884
+ _startNodeResize(pEvent, pTarget)
885
+ {
886
+ if (!this._FlowView.options.EnableNodeResizing) return;
887
+
888
+ let tmpNodeHash = this._getNodeHash(pTarget);
889
+ if (!tmpNodeHash) return;
890
+
891
+ let tmpNode = this._FlowView.getNode(tmpNodeHash);
892
+ if (!tmpNode) return;
893
+
894
+ this._State = INTERACTION_STATES.RESIZING_NODE;
895
+ this._ResizeNodeHash = tmpNodeHash;
896
+ this._ResizeNodeStartX = pEvent.clientX;
897
+ this._ResizeNodeStartY = pEvent.clientY;
898
+ this._ResizeNodeStartWidth = tmpNode.Width || 180;
899
+ this._ResizeNodeStartHeight = tmpNode.Height || 80;
900
+
901
+ this._SVGElement.classList.add('panning');
902
+ if (pEvent.stopPropagation) pEvent.stopPropagation();
903
+ }
904
+
905
+ _onNodeResize(pEvent)
906
+ {
907
+ if (!this._ResizeNodeHash) return;
908
+
909
+ let tmpVS = this._FlowView.viewState;
910
+ let tmpDX = (pEvent.clientX - this._ResizeNodeStartX) / tmpVS.Zoom;
911
+ let tmpDY = (pEvent.clientY - this._ResizeNodeStartY) / tmpVS.Zoom;
912
+
913
+ let tmpMinW = this._FlowView.options.MinimumNodeWidth || 48;
914
+ let tmpMinH = this._FlowView.options.MinimumNodeHeight || 32;
915
+
916
+ let tmpNode = this._FlowView.getNode(this._ResizeNodeHash);
917
+ if (!tmpNode) return;
918
+
919
+ tmpNode.Width = Math.max(tmpMinW, this._snapToGrid(Math.round(this._ResizeNodeStartWidth + tmpDX)));
920
+ tmpNode.Height = Math.max(tmpMinH, this._snapToGrid(Math.round(this._ResizeNodeStartHeight + tmpDY)));
921
+
922
+ // Re-render so the body (and any HTML/image content) reflows to the new size.
923
+ this._FlowView.renderFlow();
924
+ }
925
+
926
+ _endNodeResize(pEvent)
927
+ {
928
+ this._SVGElement.classList.remove('panning');
929
+
930
+ this._FlowView.renderFlow();
931
+ this._FlowView.marshalFromView();
932
+
933
+ let tmpNode = this._FlowView.getNode(this._ResizeNodeHash);
934
+ if (tmpNode && this._FlowView._EventHandlerProvider)
935
+ {
936
+ this._FlowView._EventHandlerProvider.fireEvent('onNodeResized', tmpNode);
937
+ this._FlowView._EventHandlerProvider.fireEvent('onFlowChanged', this._FlowView.flowData);
938
+ }
939
+
940
+ this._State = INTERACTION_STATES.IDLE;
941
+ this._ResizeNodeHash = null;
942
+ }
943
+
687
944
  // ---- Handle Dragging ----
688
945
 
689
946
  _startHandleDrag(pEvent, pConnectionHash, pPanelHash, pHandleType, pIsTether)
@@ -906,7 +1163,8 @@ class PictServiceFlowInteractionManager extends libFableServiceProviderBase
906
1163
 
907
1164
  if (!tmpNodeHash || !tmpPortHash) return;
908
1165
 
909
- if (tmpPortDirection !== 'output')
1166
+ // A connection normally starts from an output port; undirected mode lets any port start one.
1167
+ if (!this._FlowView.options.EnableUndirectedConnections && tmpPortDirection !== 'output')
910
1168
  {
911
1169
  return;
912
1170
  }
@@ -960,7 +1218,8 @@ class PictServiceFlowInteractionManager extends libFableServiceProviderBase
960
1218
  let tmpTargetNodeHash = tmpTarget.getAttribute('data-node-hash');
961
1219
  let tmpTargetPortDirection = tmpTarget.getAttribute('data-port-direction');
962
1220
 
963
- if (tmpTargetPortHash && tmpTargetNodeHash && tmpTargetPortDirection === 'input')
1221
+ // A connection normally completes on an input port; undirected mode lets any port receive it.
1222
+ if (tmpTargetPortHash && tmpTargetNodeHash && (this._FlowView.options.EnableUndirectedConnections || tmpTargetPortDirection === 'input'))
964
1223
  {
965
1224
  this._FlowView.addConnection(
966
1225
  this._ConnectSourceNodeHash,
@@ -1023,6 +1282,81 @@ class PictServiceFlowInteractionManager extends libFableServiceProviderBase
1023
1282
  this._State = INTERACTION_STATES.IDLE;
1024
1283
  }
1025
1284
 
1285
+ // ---- Marquee selection (multi-select) ----
1286
+ // A rubber-band rectangle drawn in world coordinates (inside the transformed viewport group), so it
1287
+ // tracks the nodes under it regardless of pan/zoom. On release every node whose box intersects the
1288
+ // rectangle is selected; a rectangle too small to be a deliberate drag is treated as a click that
1289
+ // clears the selection.
1290
+
1291
+ _startMarquee(pEvent)
1292
+ {
1293
+ let tmpStart = this._FlowView.screenToSVGCoords(pEvent.clientX, pEvent.clientY);
1294
+ this._MarqueeStartX = tmpStart.x;
1295
+ this._MarqueeStartY = tmpStart.y;
1296
+ this._MarqueeCurrentX = tmpStart.x;
1297
+ this._MarqueeCurrentY = tmpStart.y;
1298
+ this._State = INTERACTION_STATES.MARQUEE;
1299
+
1300
+ let tmpRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1301
+ tmpRect.setAttribute('class', 'pict-flow-marquee');
1302
+ tmpRect.setAttribute('x', tmpStart.x);
1303
+ tmpRect.setAttribute('y', tmpStart.y);
1304
+ tmpRect.setAttribute('width', 0);
1305
+ tmpRect.setAttribute('height', 0);
1306
+ this._MarqueeElement = tmpRect;
1307
+ if (this._FlowView._ViewportElement) { this._FlowView._ViewportElement.appendChild(tmpRect); }
1308
+ }
1309
+
1310
+ _onMarquee(pEvent)
1311
+ {
1312
+ if (!this._MarqueeElement) return;
1313
+ let tmpCur = this._FlowView.screenToSVGCoords(pEvent.clientX, pEvent.clientY);
1314
+ this._MarqueeCurrentX = tmpCur.x;
1315
+ this._MarqueeCurrentY = tmpCur.y;
1316
+ let tmpX = Math.min(this._MarqueeStartX, tmpCur.x);
1317
+ let tmpY = Math.min(this._MarqueeStartY, tmpCur.y);
1318
+ this._MarqueeElement.setAttribute('x', tmpX);
1319
+ this._MarqueeElement.setAttribute('y', tmpY);
1320
+ this._MarqueeElement.setAttribute('width', Math.abs(tmpCur.x - this._MarqueeStartX));
1321
+ this._MarqueeElement.setAttribute('height', Math.abs(tmpCur.y - this._MarqueeStartY));
1322
+ }
1323
+
1324
+ _endMarquee(pEvent)
1325
+ {
1326
+ let tmpX = Math.min(this._MarqueeStartX, this._MarqueeCurrentX);
1327
+ let tmpY = Math.min(this._MarqueeStartY, this._MarqueeCurrentY);
1328
+ let tmpW = Math.abs(this._MarqueeCurrentX - this._MarqueeStartX);
1329
+ let tmpH = Math.abs(this._MarqueeCurrentY - this._MarqueeStartY);
1330
+
1331
+ if (this._MarqueeElement && this._MarqueeElement.parentNode)
1332
+ {
1333
+ this._MarqueeElement.parentNode.removeChild(this._MarqueeElement);
1334
+ }
1335
+ this._MarqueeElement = null;
1336
+ this._State = INTERACTION_STATES.IDLE;
1337
+
1338
+ // Too small to be a deliberate drag: treat as a background click and clear the selection.
1339
+ if (tmpW < 4 && tmpH < 4)
1340
+ {
1341
+ this._FlowView.deselectAll();
1342
+ return;
1343
+ }
1344
+
1345
+ let tmpHits = [];
1346
+ let tmpNodes = this._FlowView._FlowData.Nodes || [];
1347
+ let tmpDefaultW = this._FlowView.options.DefaultNodeWidth || 180;
1348
+ let tmpDefaultH = this._FlowView.options.DefaultNodeHeight || 80;
1349
+ for (let i = 0; i < tmpNodes.length; i++)
1350
+ {
1351
+ let tmpN = tmpNodes[i];
1352
+ let tmpNW = (typeof tmpN.Width === 'number') ? tmpN.Width : tmpDefaultW;
1353
+ let tmpNH = (typeof tmpN.Height === 'number') ? tmpN.Height : tmpDefaultH;
1354
+ let tmpIntersects = !(tmpN.X > tmpX + tmpW || (tmpN.X + tmpNW) < tmpX || tmpN.Y > tmpY + tmpH || (tmpN.Y + tmpNH) < tmpY);
1355
+ if (tmpIntersects) { tmpHits.push(tmpN.Hash); }
1356
+ }
1357
+ this._FlowView.selectNodes(tmpHits);
1358
+ }
1359
+
1026
1360
  // ---- Connection Selection ----
1027
1361
 
1028
1362
  _selectConnection(pTarget)
@@ -63,7 +63,9 @@ class PictServiceFlowRenderManager extends libFableServiceProviderBase
63
63
  for (let i = 0; i < this._FlowView._FlowData.Nodes.length; i++)
64
64
  {
65
65
  let tmpNode = this._FlowView._FlowData.Nodes[i];
66
- let tmpIsSelected = (this._FlowView._FlowData.ViewState.SelectedNodeHash === tmpNode.Hash);
66
+ let tmpSelectedSet = this._FlowView._FlowData.ViewState.SelectedNodeHashes;
67
+ let tmpIsSelected = (this._FlowView._FlowData.ViewState.SelectedNodeHash === tmpNode.Hash)
68
+ || (Array.isArray(tmpSelectedSet) && tmpSelectedSet.indexOf(tmpNode.Hash) >= 0);
67
69
  let tmpNodeTypeConfig = this._FlowView._NodeTypeProvider.getNodeType(tmpNode.Type);
68
70
 
69
71
  // Enrich saved port data with metadata from the node type's DefaultPorts.
@@ -25,6 +25,9 @@ class PictServiceFlowSelectionManager extends libFableServiceProviderBase
25
25
  {
26
26
  let tmpPreviousSelection = this._FlowView._FlowData.ViewState.SelectedNodeHash;
27
27
  this._FlowView._FlowData.ViewState.SelectedNodeHash = pNodeHash;
28
+ // Single selection keeps the set in lockstep, so the renderer (which highlights the set) and
29
+ // any multi-select consumer see one consistent picture.
30
+ this._FlowView._FlowData.ViewState.SelectedNodeHashes = pNodeHash ? [pNodeHash] : [];
28
31
  this._FlowView._FlowData.ViewState.SelectedConnectionHash = null;
29
32
  this._FlowView._FlowData.ViewState.SelectedTetherHash = null;
30
33
 
@@ -37,6 +40,72 @@ class PictServiceFlowSelectionManager extends libFableServiceProviderBase
37
40
  }
38
41
  }
39
42
 
43
+ /**
44
+ * The current selection set as an array of node hashes.
45
+ * @returns {Array<string>}
46
+ */
47
+ getSelectedNodeHashes()
48
+ {
49
+ let tmpSet = this._FlowView._FlowData.ViewState.SelectedNodeHashes;
50
+ return Array.isArray(tmpSet) ? tmpSet.slice() : [];
51
+ }
52
+
53
+ /**
54
+ * Toggle a node's membership in the selection set (shift-click). The primary SelectedNodeHash
55
+ * tracks the most recently affected member (or null when the set empties).
56
+ * @param {string} pNodeHash
57
+ */
58
+ toggleNodeSelection(pNodeHash)
59
+ {
60
+ if (!pNodeHash) return;
61
+ let tmpVS = this._FlowView._FlowData.ViewState;
62
+ let tmpSet = Array.isArray(tmpVS.SelectedNodeHashes) ? tmpVS.SelectedNodeHashes.slice() : [];
63
+ let tmpIndex = tmpSet.indexOf(pNodeHash);
64
+ if (tmpIndex >= 0)
65
+ {
66
+ tmpSet.splice(tmpIndex, 1);
67
+ tmpVS.SelectedNodeHash = tmpSet.length ? tmpSet[tmpSet.length - 1] : null;
68
+ }
69
+ else
70
+ {
71
+ tmpSet.push(pNodeHash);
72
+ tmpVS.SelectedNodeHash = pNodeHash;
73
+ }
74
+ tmpVS.SelectedNodeHashes = tmpSet;
75
+ tmpVS.SelectedConnectionHash = null;
76
+ tmpVS.SelectedTetherHash = null;
77
+
78
+ this._FlowView.renderFlow();
79
+
80
+ if (this._FlowView._EventHandlerProvider)
81
+ {
82
+ let tmpNode = tmpVS.SelectedNodeHash ? this._FlowView._FlowData.Nodes.find((pNode) => pNode.Hash === tmpVS.SelectedNodeHash) : null;
83
+ this._FlowView._EventHandlerProvider.fireEvent('onNodeSelected', tmpNode);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Replace the selection set with the given node hashes (marquee result).
89
+ * @param {Array<string>} pNodeHashes
90
+ */
91
+ selectNodes(pNodeHashes)
92
+ {
93
+ let tmpVS = this._FlowView._FlowData.ViewState;
94
+ let tmpSet = Array.isArray(pNodeHashes) ? pNodeHashes.slice() : [];
95
+ tmpVS.SelectedNodeHashes = tmpSet;
96
+ tmpVS.SelectedNodeHash = tmpSet.length ? tmpSet[tmpSet.length - 1] : null;
97
+ tmpVS.SelectedConnectionHash = null;
98
+ tmpVS.SelectedTetherHash = null;
99
+
100
+ this._FlowView.renderFlow();
101
+
102
+ if (this._FlowView._EventHandlerProvider)
103
+ {
104
+ let tmpNode = tmpVS.SelectedNodeHash ? this._FlowView._FlowData.Nodes.find((pNode) => pNode.Hash === tmpVS.SelectedNodeHash) : null;
105
+ this._FlowView._EventHandlerProvider.fireEvent('onNodeSelected', tmpNode);
106
+ }
107
+ }
108
+
40
109
  /**
41
110
  * Select a connection
42
111
  * @param {string|null} pConnectionHash - Hash of the connection to select, or null to deselect
@@ -83,24 +152,36 @@ class PictServiceFlowSelectionManager extends libFableServiceProviderBase
83
152
  deselectAll()
84
153
  {
85
154
  this._FlowView._FlowData.ViewState.SelectedNodeHash = null;
155
+ this._FlowView._FlowData.ViewState.SelectedNodeHashes = [];
86
156
  this._FlowView._FlowData.ViewState.SelectedConnectionHash = null;
87
157
  this._FlowView._FlowData.ViewState.SelectedTetherHash = null;
88
158
  this._FlowView.renderFlow();
89
159
  }
90
160
 
91
161
  /**
92
- * Delete the currently selected node or connection
162
+ * Delete the current selection: every node in the multi-select set (falling back to the single
163
+ * SelectedNodeHash), or the selected connection.
93
164
  * @returns {boolean}
94
165
  */
95
166
  deleteSelected()
96
167
  {
97
- if (this._FlowView._FlowData.ViewState.SelectedNodeHash)
168
+ let tmpVS = this._FlowView._FlowData.ViewState;
169
+ let tmpSet = Array.isArray(tmpVS.SelectedNodeHashes) ? tmpVS.SelectedNodeHashes.slice() : [];
170
+ if (tmpSet.length === 0 && tmpVS.SelectedNodeHash) { tmpSet = [tmpVS.SelectedNodeHash]; }
171
+
172
+ if (tmpSet.length > 0)
98
173
  {
99
- return this._FlowView.removeNode(this._FlowView._FlowData.ViewState.SelectedNodeHash);
174
+ tmpVS.SelectedNodeHash = null;
175
+ tmpVS.SelectedNodeHashes = [];
176
+ let tmpRemovedAny = false;
177
+ // removeNode marshals/renders/fires events per call; for a multi-delete that is acceptable
178
+ // (selections are small) and keeps each removal's event semantics intact.
179
+ tmpSet.forEach((pHash) => { if (this._FlowView.removeNode(pHash)) { tmpRemovedAny = true; } });
180
+ return tmpRemovedAny;
100
181
  }
101
- if (this._FlowView._FlowData.ViewState.SelectedConnectionHash)
182
+ if (tmpVS.SelectedConnectionHash)
102
183
  {
103
- return this._FlowView.removeConnection(this._FlowView._FlowData.ViewState.SelectedConnectionHash);
184
+ return this._FlowView.removeConnection(tmpVS.SelectedConnectionHash);
104
185
  }
105
186
  return false;
106
187
  }