pict-section-flow 1.2.0 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pict-section-flow",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Pict Section Flow Diagram",
5
5
  "main": "source/Pict-Section-Flow.js",
6
6
  "scripts": {
@@ -270,6 +270,29 @@ class PictProviderFlowCSS extends libFableServiceProviderBase
270
270
  .pict-flow-node-title-bar-bottom {
271
271
  fill: var(--pf-node-title-bar-color);
272
272
  }
273
+ .pict-flow-node-resize-handle {
274
+ fill: var(--theme-color-brand-primary, #2880a6);
275
+ stroke: var(--theme-color-background-panel, #ffffff);
276
+ stroke-width: 1.5;
277
+ cursor: nwse-resize;
278
+ opacity: 0.85;
279
+ }
280
+ .pict-flow-node-resize-handle:hover { opacity: 1; }
281
+ .pict-flow-marquee {
282
+ fill: var(--theme-color-brand-primary, #2880a6);
283
+ fill-opacity: 0.10;
284
+ stroke: var(--theme-color-brand-primary, #2880a6);
285
+ stroke-width: 1;
286
+ stroke-dasharray: 4 3;
287
+ pointer-events: none;
288
+ }
289
+ .pict-flow-align-guide {
290
+ stroke: #e5397f;
291
+ stroke-width: 1;
292
+ stroke-dasharray: 3 2;
293
+ pointer-events: none;
294
+ shape-rendering: crispEdges;
295
+ }
273
296
  .pict-flow-node-title {
274
297
  fill: var(--pf-node-title-fill);
275
298
  font-size: var(--pf-node-title-size);
@@ -106,7 +106,7 @@ class PictServiceFlowDataManager extends libFableServiceProviderBase
106
106
  OpenPanels: Array.isArray(pFlowData.OpenPanels) ? pFlowData.OpenPanels : [],
107
107
  SavedLayouts: Array.isArray(pFlowData.SavedLayouts) ? pFlowData.SavedLayouts : [],
108
108
  ViewState: Object.assign(
109
- { PanX: 0, PanY: 0, Zoom: 1, SelectedNodeHash: null, SelectedConnectionHash: null, SelectedTetherHash: null },
109
+ { PanX: 0, PanY: 0, Zoom: 1, SelectedNodeHash: null, SelectedNodeHashes: [], SelectedConnectionHash: null, SelectedTetherHash: null },
110
110
  pFlowData.ViewState || {}
111
111
  ),
112
112
  LayoutAlgorithm: (typeof pFlowData.LayoutAlgorithm === 'string' && pFlowData.LayoutAlgorithm !== '') ? pFlowData.LayoutAlgorithm : tmpDefaultAlgorithm,
@@ -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)
@@ -1023,6 +1280,81 @@ class PictServiceFlowInteractionManager extends libFableServiceProviderBase
1023
1280
  this._State = INTERACTION_STATES.IDLE;
1024
1281
  }
1025
1282
 
1283
+ // ---- Marquee selection (multi-select) ----
1284
+ // A rubber-band rectangle drawn in world coordinates (inside the transformed viewport group), so it
1285
+ // tracks the nodes under it regardless of pan/zoom. On release every node whose box intersects the
1286
+ // rectangle is selected; a rectangle too small to be a deliberate drag is treated as a click that
1287
+ // clears the selection.
1288
+
1289
+ _startMarquee(pEvent)
1290
+ {
1291
+ let tmpStart = this._FlowView.screenToSVGCoords(pEvent.clientX, pEvent.clientY);
1292
+ this._MarqueeStartX = tmpStart.x;
1293
+ this._MarqueeStartY = tmpStart.y;
1294
+ this._MarqueeCurrentX = tmpStart.x;
1295
+ this._MarqueeCurrentY = tmpStart.y;
1296
+ this._State = INTERACTION_STATES.MARQUEE;
1297
+
1298
+ let tmpRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1299
+ tmpRect.setAttribute('class', 'pict-flow-marquee');
1300
+ tmpRect.setAttribute('x', tmpStart.x);
1301
+ tmpRect.setAttribute('y', tmpStart.y);
1302
+ tmpRect.setAttribute('width', 0);
1303
+ tmpRect.setAttribute('height', 0);
1304
+ this._MarqueeElement = tmpRect;
1305
+ if (this._FlowView._ViewportElement) { this._FlowView._ViewportElement.appendChild(tmpRect); }
1306
+ }
1307
+
1308
+ _onMarquee(pEvent)
1309
+ {
1310
+ if (!this._MarqueeElement) return;
1311
+ let tmpCur = this._FlowView.screenToSVGCoords(pEvent.clientX, pEvent.clientY);
1312
+ this._MarqueeCurrentX = tmpCur.x;
1313
+ this._MarqueeCurrentY = tmpCur.y;
1314
+ let tmpX = Math.min(this._MarqueeStartX, tmpCur.x);
1315
+ let tmpY = Math.min(this._MarqueeStartY, tmpCur.y);
1316
+ this._MarqueeElement.setAttribute('x', tmpX);
1317
+ this._MarqueeElement.setAttribute('y', tmpY);
1318
+ this._MarqueeElement.setAttribute('width', Math.abs(tmpCur.x - this._MarqueeStartX));
1319
+ this._MarqueeElement.setAttribute('height', Math.abs(tmpCur.y - this._MarqueeStartY));
1320
+ }
1321
+
1322
+ _endMarquee(pEvent)
1323
+ {
1324
+ let tmpX = Math.min(this._MarqueeStartX, this._MarqueeCurrentX);
1325
+ let tmpY = Math.min(this._MarqueeStartY, this._MarqueeCurrentY);
1326
+ let tmpW = Math.abs(this._MarqueeCurrentX - this._MarqueeStartX);
1327
+ let tmpH = Math.abs(this._MarqueeCurrentY - this._MarqueeStartY);
1328
+
1329
+ if (this._MarqueeElement && this._MarqueeElement.parentNode)
1330
+ {
1331
+ this._MarqueeElement.parentNode.removeChild(this._MarqueeElement);
1332
+ }
1333
+ this._MarqueeElement = null;
1334
+ this._State = INTERACTION_STATES.IDLE;
1335
+
1336
+ // Too small to be a deliberate drag: treat as a background click and clear the selection.
1337
+ if (tmpW < 4 && tmpH < 4)
1338
+ {
1339
+ this._FlowView.deselectAll();
1340
+ return;
1341
+ }
1342
+
1343
+ let tmpHits = [];
1344
+ let tmpNodes = this._FlowView._FlowData.Nodes || [];
1345
+ let tmpDefaultW = this._FlowView.options.DefaultNodeWidth || 180;
1346
+ let tmpDefaultH = this._FlowView.options.DefaultNodeHeight || 80;
1347
+ for (let i = 0; i < tmpNodes.length; i++)
1348
+ {
1349
+ let tmpN = tmpNodes[i];
1350
+ let tmpNW = (typeof tmpN.Width === 'number') ? tmpN.Width : tmpDefaultW;
1351
+ let tmpNH = (typeof tmpN.Height === 'number') ? tmpN.Height : tmpDefaultH;
1352
+ let tmpIntersects = !(tmpN.X > tmpX + tmpW || (tmpN.X + tmpNW) < tmpX || tmpN.Y > tmpY + tmpH || (tmpN.Y + tmpNH) < tmpY);
1353
+ if (tmpIntersects) { tmpHits.push(tmpN.Hash); }
1354
+ }
1355
+ this._FlowView.selectNodes(tmpHits);
1356
+ }
1357
+
1026
1358
  // ---- Connection Selection ----
1027
1359
 
1028
1360
  _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
  }
@@ -86,6 +86,13 @@ class PictViewFlowNode extends libPictView
86
86
  pNodeData.Width = tmpWidth;
87
87
  pNodeData.Height = tmpHeight;
88
88
 
89
+ // Optional rotation (degrees) about the node's center. Applied on top of the position
90
+ // translate so free-form canvases can tilt a card; zero / unset leaves it axis-aligned.
91
+ if (typeof pNodeData.Rotation === 'number' && pNodeData.Rotation)
92
+ {
93
+ tmpGroup.setAttribute('transform', PictViewFlowNode.nodeTransform(pNodeData.X, pNodeData.Y, pNodeData.Rotation, tmpWidth, tmpHeight));
94
+ }
95
+
89
96
  // Determine node body mode from theme (bracket vs rect)
90
97
  let tmpNodeBodyMode = 'rect';
91
98
  if (this._FlowView._ThemeProvider)
@@ -160,8 +167,10 @@ class PictViewFlowNode extends libPictView
160
167
  tmpGroup.appendChild(tmpTypeLabel);
161
168
  }
162
169
 
163
- // FlowCard metadata: icon in title bar, code badge in body (hover-only via CSS)
164
- if (pNodeTypeConfig && pNodeTypeConfig.CardMetadata)
170
+ // FlowCard metadata: icon in title bar, code badge in body (hover-only via CSS). Skip the
171
+ // title icon entirely when there is no title bar (height 0), e.g. edge-to-edge moodboard
172
+ // cards, otherwise the default fallback glyph paints in the card's top-left corner.
173
+ if (pNodeTypeConfig && pNodeTypeConfig.CardMetadata && tmpTitleBarHeight > 0)
165
174
  {
166
175
  let tmpMeta = pNodeTypeConfig.CardMetadata;
167
176
  let tmpIconProvider = this._FlowView._IconProvider;
@@ -312,6 +321,24 @@ class PictViewFlowNode extends libPictView
312
321
  tmpGroup.appendChild(tmpIndicator);
313
322
  }
314
323
 
324
+ // Resize handle: a small grip at the bottom-right corner, shown only when this node is
325
+ // selected and the flow allows node resizing. Its data-element-type routes pointer-down to
326
+ // the InteractionManager's node-resize path. Appended last so it paints over the body.
327
+ if (pIsSelected && this._FlowView.options && this._FlowView.options.EnableNodeResizing)
328
+ {
329
+ let tmpHandleSize = 14;
330
+ let tmpHandle = this._FlowView._SVGHelperProvider.createSVGElement('rect');
331
+ tmpHandle.setAttribute('class', 'pict-flow-node-resize-handle');
332
+ tmpHandle.setAttribute('x', String(tmpWidth - (tmpHandleSize - 4)));
333
+ tmpHandle.setAttribute('y', String(tmpHeight - (tmpHandleSize - 4)));
334
+ tmpHandle.setAttribute('width', String(tmpHandleSize));
335
+ tmpHandle.setAttribute('height', String(tmpHandleSize));
336
+ tmpHandle.setAttribute('rx', '3');
337
+ tmpHandle.setAttribute('data-node-hash', pNodeData.Hash);
338
+ tmpHandle.setAttribute('data-element-type', 'node-resize');
339
+ tmpGroup.appendChild(tmpHandle);
340
+ }
341
+
315
342
  pNodesLayer.appendChild(tmpGroup);
316
343
  }
317
344
 
@@ -556,6 +583,26 @@ class PictViewFlowNode extends libPictView
556
583
  return Math.min(Math.max(8, tmpRadius), Math.floor(pTitleBarHeight / 2));
557
584
  }
558
585
 
586
+ /**
587
+ * The SVG group transform for a node: a position translate, plus a rotation about the node's
588
+ * center when a non-zero rotation (degrees) is given.
589
+ * @param {number} pX
590
+ * @param {number} pY
591
+ * @param {number} pRotation - degrees; 0 / non-number means no rotation
592
+ * @param {number} pWidth
593
+ * @param {number} pHeight
594
+ * @returns {string}
595
+ */
596
+ static nodeTransform(pX, pY, pRotation, pWidth, pHeight)
597
+ {
598
+ let tmpRotation = (typeof pRotation === 'number') ? pRotation : 0;
599
+ if (!tmpRotation)
600
+ {
601
+ return `translate(${pX}, ${pY})`;
602
+ }
603
+ return `translate(${pX}, ${pY}) rotate(${tmpRotation} ${pWidth / 2} ${pHeight / 2})`;
604
+ }
605
+
559
606
  _renderRectNodeBody(pGroup, pNodeData, pWidth, pHeight, pTitleBarHeight, pNodeTypeConfig)
560
607
  {
561
608
  // Per-card corner radius (a node-data or node-type override of the theme default), so a card
@@ -59,8 +59,18 @@ const _DefaultConfiguration =
59
59
  EnableZooming: true,
60
60
  EnableNodeDragging: true,
61
61
  EnableConnectionCreation: true,
62
+ // When on, the selected node shows a bottom-right grip that resizes it by drag. Off by default
63
+ // so existing diagrams are unaffected; free-form canvases (moodboards) turn it on.
64
+ EnableNodeResizing: false,
62
65
  EnableGridSnap: false,
63
66
  GridSnapSize: 20,
67
+ // When on, several nodes can be selected at once: shift-click a node to toggle it, drag on the
68
+ // empty canvas to marquee-select (shift+drag pans), and dragging any selected node moves them all.
69
+ // Off by default so single-selection diagrams are unaffected; free-form canvases turn it on.
70
+ EnableMultiSelect: false,
71
+ // When on, dragging a single node shows alignment guide lines (and snaps) as its edges or centers
72
+ // line up with other nodes. Off by default; free-form canvases turn it on.
73
+ EnableAlignmentGuides: false,
64
74
  EnableLayoutMenu: true,
65
75
 
66
76
  MinZoom: 0.1,
@@ -70,6 +80,8 @@ const _DefaultConfiguration =
70
80
  DefaultNodeType: 'default',
71
81
  DefaultNodeWidth: 180,
72
82
  DefaultNodeHeight: 80,
83
+ MinimumNodeWidth: 48,
84
+ MinimumNodeHeight: 32,
73
85
 
74
86
  // Properties panel for connections (edges). Connections are not typed, so one config serves
75
87
  // them all: { PanelType, DefaultWidth, DefaultHeight, Title, Configuration }. When set, a
@@ -227,6 +239,9 @@ class PictViewFlow extends libPictView
227
239
  PanY: 0,
228
240
  Zoom: 1,
229
241
  SelectedNodeHash: null,
242
+ // The full selection set (multi-select). SelectedNodeHash stays the primary / most
243
+ // recently touched member for backward compatibility; single-select keeps it == [hash].
244
+ SelectedNodeHashes: [],
230
245
  SelectedConnectionHash: null,
231
246
  SelectedTetherHash: null
232
247
  },
@@ -558,15 +573,13 @@ class PictViewFlow extends libPictView
558
573
  }
559
574
  }
560
575
 
561
- // Setup the node renderer
576
+ // Setup the node renderer. A consumer can override the node title-bar height (e.g. a moodboard
577
+ // sets it to 0 for edge-to-edge image and note cards) via the flow-level NodeTitleBarHeight
578
+ // option; otherwise the renderer keeps its own default.
579
+ let tmpNodeViewOptions = { ViewIdentifier: `Flow-NodeRenderer-${tmpViewIdentifier}`, AutoRender: false };
580
+ if (typeof this.options.NodeTitleBarHeight === 'number') { tmpNodeViewOptions.NodeTitleBarHeight = this.options.NodeTitleBarHeight; }
562
581
  this._NodeView = this.fable.instantiateServiceProviderWithoutRegistration('PictViewFlowNode',
563
- Object.assign({},
564
- libPictViewFlowNode.default_configuration,
565
- {
566
- ViewIdentifier: `Flow-NodeRenderer-${tmpViewIdentifier}`,
567
- AutoRender: false
568
- }
569
- ));
582
+ Object.assign({}, libPictViewFlowNode.default_configuration, tmpNodeViewOptions));
570
583
  this._NodeView._FlowView = this;
571
584
 
572
585
  // Setup the properties panel renderer
@@ -616,6 +629,33 @@ class PictViewFlow extends libPictView
616
629
  return this._SelectionManager.selectNode(pNodeHash);
617
630
  }
618
631
 
632
+ /**
633
+ * Toggle a node's membership in the selection set (multi-select; shift-click).
634
+ * @param {string} pNodeHash
635
+ */
636
+ toggleNodeSelection(pNodeHash)
637
+ {
638
+ return this._SelectionManager.toggleNodeSelection(pNodeHash);
639
+ }
640
+
641
+ /**
642
+ * Replace the selection set with the given node hashes (multi-select; marquee result).
643
+ * @param {Array<string>} pNodeHashes
644
+ */
645
+ selectNodes(pNodeHashes)
646
+ {
647
+ return this._SelectionManager.selectNodes(pNodeHashes);
648
+ }
649
+
650
+ /**
651
+ * The current selection set as an array of node hashes.
652
+ * @returns {Array<string>}
653
+ */
654
+ getSelectedNodeHashes()
655
+ {
656
+ return this._SelectionManager.getSelectedNodeHashes();
657
+ }
658
+
619
659
  /**
620
660
  * Select a connection
621
661
  * @param {string|null} pConnectionHash - Hash of the connection to select, or null to deselect
@@ -0,0 +1,279 @@
1
+ const libFable = require('fable');
2
+ const libChai = require('chai');
3
+ const libExpect = libChai.expect;
4
+
5
+ const libInteractionManager = require('../source/services/PictService-Flow-InteractionManager.js');
6
+ const STATES = libInteractionManager.INTERACTION_STATES;
7
+
8
+ // A minimal FlowView stand-in: just the surface the node-resize path touches.
9
+ function makeMockFlowView(pNode)
10
+ {
11
+ let tmpFired = [];
12
+ return {
13
+ options: { EnableNodeResizing: true, MinimumNodeWidth: 48, MinimumNodeHeight: 32 },
14
+ viewState: { Zoom: 1 },
15
+ flowData: {},
16
+ _nodes: (function () { let m = {}; m[pNode.Hash] = pNode; return m; })(),
17
+ getNode: function (pHash) { return this._nodes[pHash] || null; },
18
+ renderFlow: function () { this._rendered = (this._rendered || 0) + 1; },
19
+ marshalFromView: function () { this._marshaled = (this._marshaled || 0) + 1; },
20
+ _EventHandlerProvider: { fireEvent: function (pName) { tmpFired.push(pName); } },
21
+ _firedEvents: tmpFired
22
+ };
23
+ }
24
+
25
+ function makeTarget(pHash)
26
+ {
27
+ return { getAttribute: function (pAttr) { return (pAttr === 'data-node-hash') ? pHash : null; } };
28
+ }
29
+
30
+ function makeManager(pFable, pNode)
31
+ {
32
+ let tmpFV = makeMockFlowView(pNode);
33
+ let tmpIM = new libInteractionManager(pFable, { FlowView: tmpFV }, 'IM-Test');
34
+ // initialize() would set this from the real SVG element; stub the class-list surface.
35
+ tmpIM._SVGElement = { classList: { add: function () {}, remove: function () {} } };
36
+ return { im: tmpIM, fv: tmpFV };
37
+ }
38
+
39
+ suite('PictService-Flow-InteractionManager',
40
+ function ()
41
+ {
42
+ let _Fable;
43
+ setup(function () { _Fable = new libFable({}); });
44
+
45
+ suite('node resize',
46
+ function ()
47
+ {
48
+ test('resizes from the start size by the pointer delta divided by zoom',
49
+ function ()
50
+ {
51
+ let tmpNode = { Hash: 'n1', X: 0, Y: 0, Width: 100, Height: 80 };
52
+ let tmp = makeManager(_Fable, tmpNode);
53
+ tmp.fv.viewState.Zoom = 2;
54
+
55
+ tmp.im._startNodeResize({ clientX: 200, clientY: 100, stopPropagation: function () {} }, makeTarget('n1'));
56
+ libExpect(tmp.im._State).to.equal(STATES.RESIZING_NODE);
57
+
58
+ // +100px / +40px at zoom 2 -> +50 / +20 world units
59
+ tmp.im._onNodeResize({ clientX: 300, clientY: 140 });
60
+ libExpect(tmpNode.Width).to.equal(150);
61
+ libExpect(tmpNode.Height).to.equal(100);
62
+ });
63
+
64
+ test('clamps to the minimum size',
65
+ function ()
66
+ {
67
+ let tmpNode = { Hash: 'n1', Width: 100, Height: 80 };
68
+ let tmp = makeManager(_Fable, tmpNode);
69
+
70
+ tmp.im._startNodeResize({ clientX: 0, clientY: 0, stopPropagation: function () {} }, makeTarget('n1'));
71
+ tmp.im._onNodeResize({ clientX: -1000, clientY: -1000 });
72
+ libExpect(tmpNode.Width).to.equal(48);
73
+ libExpect(tmpNode.Height).to.equal(32);
74
+ });
75
+
76
+ test('end fires onNodeResized + onFlowChanged, marshals, and returns to idle',
77
+ function ()
78
+ {
79
+ let tmpNode = { Hash: 'n1', Width: 100, Height: 80 };
80
+ let tmp = makeManager(_Fable, tmpNode);
81
+
82
+ tmp.im._startNodeResize({ clientX: 0, clientY: 0, stopPropagation: function () {} }, makeTarget('n1'));
83
+ tmp.im._endNodeResize({});
84
+
85
+ libExpect(tmp.fv._firedEvents).to.include('onNodeResized');
86
+ libExpect(tmp.fv._firedEvents).to.include('onFlowChanged');
87
+ libExpect(tmp.fv._marshaled).to.be.greaterThan(0);
88
+ libExpect(tmp.im._State).to.equal(STATES.IDLE);
89
+ libExpect(tmp.im._ResizeNodeHash).to.equal(null);
90
+ });
91
+
92
+ test('does not start when EnableNodeResizing is off',
93
+ function ()
94
+ {
95
+ let tmpNode = { Hash: 'n1', Width: 100, Height: 80 };
96
+ let tmp = makeManager(_Fable, tmpNode);
97
+ tmp.fv.options.EnableNodeResizing = false;
98
+
99
+ tmp.im._startNodeResize({ clientX: 0, clientY: 0, stopPropagation: function () {} }, makeTarget('n1'));
100
+ libExpect(tmp.im._State).to.equal(STATES.IDLE);
101
+ libExpect(tmp.im._ResizeNodeHash).to.equal(null);
102
+ });
103
+ });
104
+
105
+ suite('grid snap',
106
+ function ()
107
+ {
108
+ test('snaps a value to the grid when EnableGridSnap is on',
109
+ function ()
110
+ {
111
+ let tmp = makeManager(_Fable, { Hash: 'n1' });
112
+ tmp.fv.options.EnableGridSnap = true;
113
+ tmp.fv.options.GridSnapSize = 10;
114
+ libExpect(tmp.im._snapToGrid(37)).to.equal(40);
115
+ libExpect(tmp.im._snapToGrid(53)).to.equal(50);
116
+ libExpect(tmp.im._snapToGrid(45)).to.equal(50);
117
+ });
118
+
119
+ test('passes the value through when EnableGridSnap is off',
120
+ function ()
121
+ {
122
+ let tmp = makeManager(_Fable, { Hash: 'n1' });
123
+ tmp.fv.options.EnableGridSnap = false;
124
+ libExpect(tmp.im._snapToGrid(37)).to.equal(37);
125
+ });
126
+
127
+ test('passes through when the grid size is zero or missing',
128
+ function ()
129
+ {
130
+ let tmp = makeManager(_Fable, { Hash: 'n1' });
131
+ tmp.fv.options.EnableGridSnap = true;
132
+ tmp.fv.options.GridSnapSize = 0;
133
+ libExpect(tmp.im._snapToGrid(37)).to.equal(37);
134
+ });
135
+ });
136
+
137
+ // ---- Multi-select ----
138
+
139
+ function makeMultiMockFlowView(pNodes)
140
+ {
141
+ let tmpSelected = [];
142
+ let tmpCalls = { selectNodes: [], deselectAll: 0, updateNodePosition: [] };
143
+ let tmpViewportChildren = [];
144
+ return {
145
+ options: { EnableMultiSelect: true, EnableNodeDragging: true, DefaultNodeWidth: 180, DefaultNodeHeight: 80 },
146
+ viewState: { Zoom: 1 },
147
+ _FlowData: { Nodes: pNodes.slice() },
148
+ _ViewportElement: { appendChild: function (e) { tmpViewportChildren.push(e); }, removeChild: function (e) { tmpViewportChildren = tmpViewportChildren.filter((c) => c !== e); } },
149
+ _NodesLayer: { querySelector: function () { return { classList: { add: function () {}, remove: function () {} } }; } },
150
+ _nodes: (function () { let m = {}; pNodes.forEach((n) => { m[n.Hash] = n; }); return m; })(),
151
+ getNode: function (pHash) { return this._nodes[pHash] || null; },
152
+ // Identity coordinate mapping so screen deltas equal world deltas in the test.
153
+ screenToSVGCoords: function (pX, pY) { return { x: pX, y: pY }; },
154
+ getSelectedNodeHashes: function () { return tmpSelected.slice(); },
155
+ selectNode: function (pHash) { tmpSelected = pHash ? [pHash] : []; },
156
+ selectNodes: function (pHashes) { tmpSelected = pHashes.slice(); tmpCalls.selectNodes.push(pHashes.slice()); },
157
+ deselectAll: function () { tmpSelected = []; tmpCalls.deselectAll++; },
158
+ updateNodePosition: function (pHash, pX, pY) { let n = this._nodes[pHash]; if (n) { n.X = pX; n.Y = pY; } tmpCalls.updateNodePosition.push({ Hash: pHash, X: pX, Y: pY }); },
159
+ renderFlow: function () {},
160
+ marshalFromView: function () {},
161
+ _EventHandlerProvider: { fireEvent: function () {} },
162
+ _calls: tmpCalls,
163
+ _setSelected: function (pArr) { tmpSelected = pArr.slice(); }
164
+ };
165
+ }
166
+
167
+ function makeMultiManager(pFable, pNodes)
168
+ {
169
+ let tmpFV = makeMultiMockFlowView(pNodes);
170
+ let tmpIM = new libInteractionManager(pFable, { FlowView: tmpFV }, 'IM-Multi-Test');
171
+ tmpIM._SVGElement = { classList: { add: function () {}, remove: function () {} } };
172
+ return { im: tmpIM, fv: tmpFV };
173
+ }
174
+
175
+ suite('marquee selection',
176
+ function ()
177
+ {
178
+ let _MNodes;
179
+ let _SavedDocument;
180
+ setup(function ()
181
+ {
182
+ _MNodes = [ { Hash: 'n1', X: 0, Y: 0, Width: 100, Height: 80 }, { Hash: 'n2', X: 200, Y: 0, Width: 100, Height: 80 }, { Hash: 'n3', X: 400, Y: 0, Width: 100, Height: 80 } ];
183
+ // The marquee draws a real SVG <rect>; stub the DOM factory for the headless test, restore after.
184
+ _SavedDocument = global.document;
185
+ global.document = { createElementNS: function () { let tmpEl = { setAttribute: function (pK, pV) { tmpEl[pK] = pV; } }; return tmpEl; } };
186
+ });
187
+ teardown(function () { global.document = _SavedDocument; });
188
+
189
+ test('a drag selects every node whose box intersects the rectangle',
190
+ function ()
191
+ {
192
+ let tmp = makeMultiManager(_Fable, _MNodes);
193
+ tmp.im._startMarquee({ clientX: 0, clientY: 0 });
194
+ libExpect(tmp.im._State).to.equal(STATES.MARQUEE);
195
+ tmp.im._onMarquee({ clientX: 250, clientY: 100 });
196
+ tmp.im._endMarquee({ clientX: 250, clientY: 100 });
197
+ libExpect(tmp.fv._calls.selectNodes.length).to.equal(1);
198
+ libExpect(tmp.fv._calls.selectNodes[0]).to.deep.equal(['n1', 'n2']);
199
+ libExpect(tmp.im._State).to.equal(STATES.IDLE);
200
+ });
201
+
202
+ test('a tiny rectangle (a click) clears the selection instead of selecting',
203
+ function ()
204
+ {
205
+ let tmp = makeMultiManager(_Fable, _MNodes);
206
+ tmp.im._startMarquee({ clientX: 10, clientY: 10 });
207
+ tmp.im._onMarquee({ clientX: 12, clientY: 11 });
208
+ tmp.im._endMarquee({ clientX: 12, clientY: 11 });
209
+ libExpect(tmp.fv._calls.deselectAll).to.equal(1);
210
+ libExpect(tmp.fv._calls.selectNodes.length).to.equal(0);
211
+ });
212
+ });
213
+
214
+ suite('multi-drag',
215
+ function ()
216
+ {
217
+ let _MNodes;
218
+ setup(function () { _MNodes = [ { Hash: 'n1', X: 0, Y: 0, Width: 100, Height: 80 }, { Hash: 'n2', X: 200, Y: 50, Width: 100, Height: 80 } ]; });
219
+
220
+ test('dragging a node in the selection moves every selected node by the same delta',
221
+ function ()
222
+ {
223
+ let tmp = makeMultiManager(_Fable, _MNodes);
224
+ tmp.fv._setSelected(['n1', 'n2']);
225
+ tmp.im._startNodeDrag({ clientX: 0, clientY: 0 }, makeTarget('n1'));
226
+ libExpect(tmp.im._State).to.equal(STATES.DRAGGING_NODE);
227
+ libExpect(tmp.im._DragNodes.length).to.equal(2);
228
+
229
+ tmp.im._onNodeDrag({ clientX: 30, clientY: 20 });
230
+ libExpect(_MNodes[0].X).to.equal(30);
231
+ libExpect(_MNodes[0].Y).to.equal(20);
232
+ libExpect(_MNodes[1].X).to.equal(230);
233
+ libExpect(_MNodes[1].Y).to.equal(70);
234
+ });
235
+
236
+ test('dragging a node outside the selection collapses to just that node',
237
+ function ()
238
+ {
239
+ let tmp = makeMultiManager(_Fable, _MNodes);
240
+ tmp.fv._setSelected(['n2']);
241
+ tmp.im._startNodeDrag({ clientX: 0, clientY: 0 }, makeTarget('n1'));
242
+ libExpect(tmp.im._DragNodes.length).to.equal(1);
243
+ libExpect(tmp.im._DragNodes[0].Hash).to.equal('n1');
244
+ });
245
+ });
246
+
247
+ suite('alignment guides',
248
+ function ()
249
+ {
250
+ test('snaps the dragged node so a near edge lines up, and reports guides',
251
+ function ()
252
+ {
253
+ let tmpNodes = [ { Hash: 'drag', X: 0, Y: 0, Width: 100, Height: 80 }, { Hash: 'other', X: 200, Y: 0, Width: 100, Height: 80 } ];
254
+ let tmp = makeMultiManager(_Fable, tmpNodes);
255
+ let tmpDrag = tmpNodes[0];
256
+ // Move the dragged node so its left edge (197) is 3px from the other node's left (200).
257
+ let tmpResult = tmp.im._alignmentFor(tmpDrag, 197, 0);
258
+ libExpect(tmpResult.X).to.equal(200); // snapped to align left edges
259
+ libExpect(tmpResult.Y).to.equal(0); // tops already aligned
260
+ let tmpVGuide = tmpResult.Guides.find(function (g) { return g.Type === 'v'; });
261
+ let tmpHGuide = tmpResult.Guides.find(function (g) { return g.Type === 'h'; });
262
+ libExpect(tmpVGuide).to.be.an('object');
263
+ libExpect(tmpVGuide.Pos).to.equal(200);
264
+ libExpect(tmpHGuide).to.be.an('object');
265
+ libExpect(tmpHGuide.Pos).to.equal(0);
266
+ });
267
+
268
+ test('no snap or guides when nothing is within the threshold',
269
+ function ()
270
+ {
271
+ let tmpNodes = [ { Hash: 'drag', X: 0, Y: 0, Width: 100, Height: 80 }, { Hash: 'other', X: 200, Y: 0, Width: 100, Height: 80 } ];
272
+ let tmp = makeMultiManager(_Fable, tmpNodes);
273
+ let tmpResult = tmp.im._alignmentFor(tmpNodes[0], 500, 500);
274
+ libExpect(tmpResult.X).to.equal(500);
275
+ libExpect(tmpResult.Y).to.equal(500);
276
+ libExpect(tmpResult.Guides.length).to.equal(0);
277
+ });
278
+ });
279
+ });
@@ -46,4 +46,21 @@ function ()
46
46
  }
47
47
  });
48
48
  });
49
+
50
+ suite('nodeTransform',
51
+ function ()
52
+ {
53
+ test('a zero / absent rotation is a plain position translate',
54
+ function ()
55
+ {
56
+ libExpect(libPictViewFlowNode.nodeTransform(40, 60, 0, 100, 80)).to.equal('translate(40, 60)');
57
+ libExpect(libPictViewFlowNode.nodeTransform(40, 60, null, 100, 80)).to.equal('translate(40, 60)');
58
+ });
59
+
60
+ test('a non-zero rotation rotates about the node center',
61
+ function ()
62
+ {
63
+ libExpect(libPictViewFlowNode.nodeTransform(40, 60, 15, 100, 80)).to.equal('translate(40, 60) rotate(15 50 40)');
64
+ });
65
+ });
49
66
  });
@@ -0,0 +1,185 @@
1
+ const libFable = require('fable');
2
+ const libChai = require('chai');
3
+ const libExpect = libChai.expect;
4
+
5
+ const libSelectionManager = require('../source/services/PictService-Flow-SelectionManager.js');
6
+
7
+ // A minimal FlowView stand-in: just the surface the selection manager touches.
8
+ function makeMockFlowView(pNodes)
9
+ {
10
+ let tmpFired = [];
11
+ let tmpRemoved = [];
12
+ return {
13
+ _FlowData:
14
+ {
15
+ Nodes: pNodes.slice(),
16
+ Connections: [],
17
+ OpenPanels: [],
18
+ ViewState: { SelectedNodeHash: null, SelectedNodeHashes: [], SelectedConnectionHash: null, SelectedTetherHash: null }
19
+ },
20
+ renderFlow: function () { this._rendered = (this._rendered || 0) + 1; },
21
+ removeNode: function (pHash) { tmpRemoved.push(pHash); this._FlowData.Nodes = this._FlowData.Nodes.filter((n) => n.Hash !== pHash); return true; },
22
+ removeConnection: function (pHash) { tmpRemoved.push('conn:' + pHash); return true; },
23
+ _EventHandlerProvider: { fireEvent: function (pName, pPayload) { tmpFired.push({ Name: pName, Payload: pPayload }); } },
24
+ _firedEvents: tmpFired,
25
+ _removed: tmpRemoved
26
+ };
27
+ }
28
+
29
+ function makeManager(pFable, pNodes)
30
+ {
31
+ let tmpFV = makeMockFlowView(pNodes);
32
+ let tmpSM = new libSelectionManager(pFable, { FlowView: tmpFV }, 'SM-Test');
33
+ return { sm: tmpSM, fv: tmpFV, vs: tmpFV._FlowData.ViewState };
34
+ }
35
+
36
+ suite('PictService-Flow-SelectionManager',
37
+ function ()
38
+ {
39
+ let _Fable;
40
+ let _Nodes;
41
+ setup(function ()
42
+ {
43
+ _Fable = new libFable({});
44
+ _Nodes = [ { Hash: 'n1', X: 0, Y: 0, Width: 100, Height: 80 }, { Hash: 'n2', X: 200, Y: 0, Width: 100, Height: 80 }, { Hash: 'n3', X: 400, Y: 0, Width: 100, Height: 80 } ];
45
+ });
46
+
47
+ suite('single selection keeps the set in lockstep',
48
+ function ()
49
+ {
50
+ test('selectNode sets the primary and a one-element set',
51
+ function ()
52
+ {
53
+ let tmp = makeManager(_Fable, _Nodes);
54
+ tmp.sm.selectNode('n2');
55
+ libExpect(tmp.vs.SelectedNodeHash).to.equal('n2');
56
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal(['n2']);
57
+ });
58
+
59
+ test('selectNode(null) clears both the primary and the set',
60
+ function ()
61
+ {
62
+ let tmp = makeManager(_Fable, _Nodes);
63
+ tmp.sm.selectNode('n2');
64
+ tmp.sm.selectNode(null);
65
+ libExpect(tmp.vs.SelectedNodeHash).to.equal(null);
66
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal([]);
67
+ });
68
+ });
69
+
70
+ suite('toggleNodeSelection',
71
+ function ()
72
+ {
73
+ test('adds a node, then a second, then removes the first',
74
+ function ()
75
+ {
76
+ let tmp = makeManager(_Fable, _Nodes);
77
+ tmp.sm.toggleNodeSelection('n1');
78
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal(['n1']);
79
+ libExpect(tmp.vs.SelectedNodeHash).to.equal('n1');
80
+
81
+ tmp.sm.toggleNodeSelection('n3');
82
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal(['n1', 'n3']);
83
+ libExpect(tmp.vs.SelectedNodeHash).to.equal('n3');
84
+
85
+ tmp.sm.toggleNodeSelection('n1');
86
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal(['n3']);
87
+ libExpect(tmp.vs.SelectedNodeHash).to.equal('n3');
88
+ });
89
+
90
+ test('toggling the last member empties the set and nulls the primary',
91
+ function ()
92
+ {
93
+ let tmp = makeManager(_Fable, _Nodes);
94
+ tmp.sm.toggleNodeSelection('n1');
95
+ tmp.sm.toggleNodeSelection('n1');
96
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal([]);
97
+ libExpect(tmp.vs.SelectedNodeHash).to.equal(null);
98
+ });
99
+ });
100
+
101
+ suite('selectNodes',
102
+ function ()
103
+ {
104
+ test('replaces the set and sets the primary to the last hash',
105
+ function ()
106
+ {
107
+ let tmp = makeManager(_Fable, _Nodes);
108
+ tmp.sm.selectNode('n1');
109
+ tmp.sm.selectNodes(['n2', 'n3']);
110
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal(['n2', 'n3']);
111
+ libExpect(tmp.vs.SelectedNodeHash).to.equal('n3');
112
+ });
113
+
114
+ test('an empty array clears the selection',
115
+ function ()
116
+ {
117
+ let tmp = makeManager(_Fable, _Nodes);
118
+ tmp.sm.selectNodes(['n1', 'n2']);
119
+ tmp.sm.selectNodes([]);
120
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal([]);
121
+ libExpect(tmp.vs.SelectedNodeHash).to.equal(null);
122
+ });
123
+
124
+ test('getSelectedNodeHashes returns a copy (mutating it does not change state)',
125
+ function ()
126
+ {
127
+ let tmp = makeManager(_Fable, _Nodes);
128
+ tmp.sm.selectNodes(['n1', 'n2']);
129
+ let tmpCopy = tmp.sm.getSelectedNodeHashes();
130
+ tmpCopy.push('n3');
131
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal(['n1', 'n2']);
132
+ });
133
+ });
134
+
135
+ suite('deleteSelected (multi)',
136
+ function ()
137
+ {
138
+ test('removes every node in the selection set',
139
+ function ()
140
+ {
141
+ let tmp = makeManager(_Fable, _Nodes);
142
+ tmp.sm.selectNodes(['n1', 'n3']);
143
+ let tmpResult = tmp.sm.deleteSelected();
144
+ libExpect(tmpResult).to.equal(true);
145
+ libExpect(tmp.fv._removed).to.deep.equal(['n1', 'n3']);
146
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal([]);
147
+ libExpect(tmp.vs.SelectedNodeHash).to.equal(null);
148
+ });
149
+
150
+ test('falls back to the single primary when the set is empty',
151
+ function ()
152
+ {
153
+ let tmp = makeManager(_Fable, _Nodes);
154
+ tmp.fv._FlowData.ViewState.SelectedNodeHash = 'n2';
155
+ tmp.fv._FlowData.ViewState.SelectedNodeHashes = [];
156
+ tmp.sm.deleteSelected();
157
+ libExpect(tmp.fv._removed).to.deep.equal(['n2']);
158
+ });
159
+
160
+ test('deletes the selected connection when no nodes are selected',
161
+ function ()
162
+ {
163
+ let tmp = makeManager(_Fable, _Nodes);
164
+ tmp.fv._FlowData.ViewState.SelectedConnectionHash = 'c1';
165
+ tmp.sm.deleteSelected();
166
+ libExpect(tmp.fv._removed).to.deep.equal(['conn:c1']);
167
+ });
168
+ });
169
+
170
+ suite('deselectAll',
171
+ function ()
172
+ {
173
+ test('clears the primary, the set, and the connection/tether selections',
174
+ function ()
175
+ {
176
+ let tmp = makeManager(_Fable, _Nodes);
177
+ tmp.sm.selectNodes(['n1', 'n2']);
178
+ tmp.fv._FlowData.ViewState.SelectedConnectionHash = 'c1';
179
+ tmp.sm.deselectAll();
180
+ libExpect(tmp.vs.SelectedNodeHash).to.equal(null);
181
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal([]);
182
+ libExpect(tmp.vs.SelectedConnectionHash).to.equal(null);
183
+ });
184
+ });
185
+ });