pict-section-flow 1.1.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/providers/layouts/Layout-Layered.js +25 -79
- package/source/providers/layouts/Layout-Rank.js +141 -0
- package/source/providers/layouts/Layout-Staggered.js +131 -0
- package/source/services/PictService-Flow-DataManager.js +1 -1
- package/source/services/PictService-Flow-InteractionManager.js +354 -22
- package/source/services/PictService-Flow-Layout.js +2 -0
- 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 +90 -6
- package/source/views/PictView-Flow.js +48 -8
- package/test/InteractionManager_tests.js +279 -0
- package/test/Layout_tests.js +208 -4
- package/test/NodeView_tests.js +66 -0
- package/test/SelectionManager_tests.js +185 -0
|
@@ -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
|
|
|
@@ -529,8 +556,64 @@ class PictViewFlowNode extends libPictView
|
|
|
529
556
|
* @param {number} pTitleBarHeight
|
|
530
557
|
* @param {Object} pNodeTypeConfig
|
|
531
558
|
*/
|
|
559
|
+
// Append a CSS fragment to an SVG element's inline style (which wins over the stylesheet), keeping
|
|
560
|
+
// any existing inline style.
|
|
561
|
+
_appendElementStyle(pElement, pStyleFragment)
|
|
562
|
+
{
|
|
563
|
+
let tmpExisting = pElement.getAttribute('style') || '';
|
|
564
|
+
if (tmpExisting && tmpExisting.charAt(tmpExisting.length - 1) !== ';') { tmpExisting += ';'; }
|
|
565
|
+
pElement.setAttribute('style', tmpExisting + pStyleFragment);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Height of the strip that squares off the bottom corners of the title bar. It must cover the
|
|
570
|
+
* bottom rounded corners (so the title bar meets the body in a straight seam) but must never reach
|
|
571
|
+
* the top ones, so it is capped at half the title bar height. Without the cap a corner radius
|
|
572
|
+
* larger than the title bar (a capsule card: radius 24 on a 22px bar) yields a strip taller than
|
|
573
|
+
* the whole title bar, which paints over the rounded TOP corners and makes the card read as square
|
|
574
|
+
* on top and rounded only on the bottom.
|
|
575
|
+
*
|
|
576
|
+
* @param {number|null} pCornerRadius - the card corner radius, or null for the theme default
|
|
577
|
+
* @param {number} pTitleBarHeight - the title bar height in user units
|
|
578
|
+
* @returns {number}
|
|
579
|
+
*/
|
|
580
|
+
static titleBarBottomStripHeight(pCornerRadius, pTitleBarHeight)
|
|
581
|
+
{
|
|
582
|
+
let tmpRadius = (typeof pCornerRadius === 'number') ? pCornerRadius : 0;
|
|
583
|
+
return Math.min(Math.max(8, tmpRadius), Math.floor(pTitleBarHeight / 2));
|
|
584
|
+
}
|
|
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
|
+
|
|
532
606
|
_renderRectNodeBody(pGroup, pNodeData, pWidth, pHeight, pTitleBarHeight, pNodeTypeConfig)
|
|
533
607
|
{
|
|
608
|
+
// Per-card corner radius (a node-data or node-type override of the theme default), so a card
|
|
609
|
+
// type can read as a rounded rectangle, a capsule, or a sharp box. null leaves the
|
|
610
|
+
// theme/CSS default in place. Set as the --pf-node-body-radius custom property on the node
|
|
611
|
+
// group; the body and title-bar both read it through their `rx: var(--pf-node-body-radius)`,
|
|
612
|
+
// and it inherits to both, so one assignment rounds the whole card.
|
|
613
|
+
let tmpCornerRadius = (typeof pNodeData.CornerRadius === 'number') ? pNodeData.CornerRadius
|
|
614
|
+
: ((pNodeTypeConfig && typeof pNodeTypeConfig.CornerRadius === 'number') ? pNodeTypeConfig.CornerRadius : null);
|
|
615
|
+
if (tmpCornerRadius != null) { this._appendElementStyle(pGroup, '--pf-node-body-radius:' + tmpCornerRadius + 'px'); }
|
|
616
|
+
|
|
534
617
|
// Node body (main rectangle)
|
|
535
618
|
let tmpBody = this._FlowView._SVGHelperProvider.createSVGElement('rect');
|
|
536
619
|
tmpBody.setAttribute('class', 'pict-flow-node-body');
|
|
@@ -598,16 +681,17 @@ class PictViewFlowNode extends libPictView
|
|
|
598
681
|
{
|
|
599
682
|
tmpTitleBar.setAttribute('fill', pNodeData.TitleBarColor);
|
|
600
683
|
}
|
|
601
|
-
|
|
602
684
|
pGroup.appendChild(tmpTitleBar);
|
|
603
685
|
|
|
604
|
-
// Title bar bottom fill
|
|
686
|
+
// Title bar bottom fill: squares off the rounded corners at the bottom of the title bar so it
|
|
687
|
+
// meets the body in a straight seam. See titleBarBottomStripHeight for why it is capped.
|
|
688
|
+
let tmpBottomStripHeight = PictViewFlowNode.titleBarBottomStripHeight(tmpCornerRadius, pTitleBarHeight);
|
|
605
689
|
let tmpTitleBarBottom = this._FlowView._SVGHelperProvider.createSVGElement('rect');
|
|
606
690
|
tmpTitleBarBottom.setAttribute('class', 'pict-flow-node-title-bar-bottom');
|
|
607
691
|
tmpTitleBarBottom.setAttribute('x', '0');
|
|
608
|
-
tmpTitleBarBottom.setAttribute('y', String(pTitleBarHeight -
|
|
692
|
+
tmpTitleBarBottom.setAttribute('y', String(pTitleBarHeight - tmpBottomStripHeight));
|
|
609
693
|
tmpTitleBarBottom.setAttribute('width', String(pWidth));
|
|
610
|
-
tmpTitleBarBottom.setAttribute('height',
|
|
694
|
+
tmpTitleBarBottom.setAttribute('height', String(tmpBottomStripHeight));
|
|
611
695
|
tmpTitleBarBottom.setAttribute('data-node-hash', pNodeData.Hash);
|
|
612
696
|
tmpTitleBarBottom.setAttribute('data-element-type', 'node-body');
|
|
613
697
|
|
|
@@ -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
|
+
});
|