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.
@@ -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
 
@@ -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 (to square off the rounded corners at the bottom of the title bar)
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 - 8));
692
+ tmpTitleBarBottom.setAttribute('y', String(pTitleBarHeight - tmpBottomStripHeight));
609
693
  tmpTitleBarBottom.setAttribute('width', String(pWidth));
610
- tmpTitleBarBottom.setAttribute('height', '8');
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
+ });