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 +1 -1
- package/source/providers/PictProvider-Flow-CSS.js +23 -0
- package/source/services/PictService-Flow-DataManager.js +1 -1
- package/source/services/PictService-Flow-InteractionManager.js +354 -22
- package/source/services/PictService-Flow-RenderManager.js +3 -1
- package/source/services/PictService-Flow-SelectionManager.js +86 -5
- package/source/views/PictView-Flow-Node.js +49 -2
- package/source/views/PictView-Flow.js +48 -8
- package/test/InteractionManager_tests.js +279 -0
- package/test/NodeView_tests.js +17 -0
- package/test/SelectionManager_tests.js +185 -0
package/package.json
CHANGED
|
@@ -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
|
|
347
|
-
if (pEvent.button === 0
|
|
368
|
+
// Click / drag on the empty background.
|
|
369
|
+
if (pEvent.button === 0)
|
|
348
370
|
{
|
|
349
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
519
|
-
|
|
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
|
|
531
|
-
if (tmpNodeGroup)
|
|
589
|
+
for (let i = 0; i < this._DragNodes.length; i++)
|
|
532
590
|
{
|
|
533
|
-
tmpNodeGroup.
|
|
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.
|
|
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
|
-
|
|
546
|
-
|
|
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.
|
|
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
|
|
556
|
-
|
|
742
|
+
let tmpDragged = this._DragNodes || [];
|
|
743
|
+
for (let i = 0; i < tmpDragged.length; i++)
|
|
557
744
|
{
|
|
558
|
-
tmpNodeGroup.
|
|
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
|
-
|
|
565
|
-
if (tmpNode && this._FlowView._EventHandlerProvider)
|
|
752
|
+
if (this._FlowView._EventHandlerProvider)
|
|
566
753
|
{
|
|
567
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
182
|
+
if (tmpVS.SelectedConnectionHash)
|
|
102
183
|
{
|
|
103
|
-
return this._FlowView.removeConnection(
|
|
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
|
-
|
|
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
|
+
});
|
package/test/NodeView_tests.js
CHANGED
|
@@ -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
|
+
});
|