pict-section-flow 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/source/providers/PictProvider-Flow-CSS.js +49 -0
- package/source/providers/PictProvider-Flow-ConnectorShapes.js +8 -0
- package/source/providers/PictProvider-Flow-Icons.js +8 -0
- package/source/services/PictService-Flow-ConnectionRenderer.js +76 -4
- package/source/services/PictService-Flow-DataManager.js +1 -1
- package/source/services/PictService-Flow-InteractionManager.js +358 -24
- package/source/services/PictService-Flow-RenderManager.js +3 -1
- package/source/services/PictService-Flow-SelectionManager.js +86 -5
- package/source/views/PictView-Flow-FloatingToolbar.js +53 -0
- package/source/views/PictView-Flow-Node.js +56 -2
- package/source/views/PictView-Flow-PropertiesPanel.js +27 -5
- package/source/views/PictView-Flow-Toolbar.js +99 -11
- package/source/views/PictView-Flow.js +85 -9
- package/test/CardPalette_tests.js +43 -0
- package/test/ConnectionStyle_tests.js +90 -0
- package/test/InteractionManager_tests.js +279 -0
- package/test/NodeView_tests.js +17 -0
- package/test/SelectionManager_tests.js +185 -0
- package/test/ToolbarExtraButtons_tests.js +138 -0
- package/test/UndirectedConnections_tests.js +70 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const libFable = require('fable');
|
|
2
|
+
const libChai = require('chai');
|
|
3
|
+
const libExpect = libChai.expect;
|
|
4
|
+
|
|
5
|
+
const libConnectorShapes = require('../source/providers/PictProvider-Flow-ConnectorShapes.js');
|
|
6
|
+
const libConnectionRenderer = require('../source/services/PictService-Flow-ConnectionRenderer.js');
|
|
7
|
+
|
|
8
|
+
// Per-connection appearance: a host (a moodboard) styles its own edges by stamping Data on a
|
|
9
|
+
// connection -- stroke color / width / style and the marker at each end ('none' | 'arrow' | 'dot' |
|
|
10
|
+
// 'square'). The renderer applies these only when present, so default (workflow) edges are untouched.
|
|
11
|
+
suite('Flow connection appearance',
|
|
12
|
+
function ()
|
|
13
|
+
{
|
|
14
|
+
suite('marker defs',
|
|
15
|
+
function ()
|
|
16
|
+
{
|
|
17
|
+
test('generateMarkerDefs includes the generic arrow / dot / square markers (color-matched)',
|
|
18
|
+
function ()
|
|
19
|
+
{
|
|
20
|
+
let tmpProvider = new libConnectorShapes(new libFable({}), {}, 'CS-Test');
|
|
21
|
+
let tmpDefs = tmpProvider.generateMarkerDefs('view1');
|
|
22
|
+
libExpect(tmpDefs).to.contain('flow-marker-arrow-end-view1');
|
|
23
|
+
libExpect(tmpDefs).to.contain('flow-marker-arrow-start-view1');
|
|
24
|
+
libExpect(tmpDefs).to.contain('flow-marker-dot-view1');
|
|
25
|
+
libExpect(tmpDefs).to.contain('flow-marker-square-view1');
|
|
26
|
+
libExpect(tmpDefs).to.contain('context-stroke');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
suite('_connectionMarkerId',
|
|
31
|
+
function ()
|
|
32
|
+
{
|
|
33
|
+
let _Resolve = libConnectionRenderer.prototype._connectionMarkerId;
|
|
34
|
+
|
|
35
|
+
test('resolves marker names to def ids per end; none / unknown -> null',
|
|
36
|
+
function ()
|
|
37
|
+
{
|
|
38
|
+
libExpect(_Resolve('arrow', 'end', 'v')).to.equal('flow-marker-arrow-end-v');
|
|
39
|
+
libExpect(_Resolve('arrow', 'start', 'v')).to.equal('flow-marker-arrow-start-v');
|
|
40
|
+
libExpect(_Resolve('dot', 'end', 'v')).to.equal('flow-marker-dot-v');
|
|
41
|
+
libExpect(_Resolve('square', 'end', 'v')).to.equal('flow-marker-square-v');
|
|
42
|
+
libExpect(_Resolve('none', 'end', 'v')).to.equal(null);
|
|
43
|
+
libExpect(_Resolve(undefined, 'end', 'v')).to.equal(null);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
suite('_applyConnectionStyle',
|
|
48
|
+
function ()
|
|
49
|
+
{
|
|
50
|
+
let _Self = { _connectionMarkerId: libConnectionRenderer.prototype._connectionMarkerId };
|
|
51
|
+
function makePath()
|
|
52
|
+
{
|
|
53
|
+
let tmpAttrs = {};
|
|
54
|
+
// Stroke color / width / dash are set via inline style (they must outrank the .pict-flow-connection
|
|
55
|
+
// CSS rule); markers are SVG attributes.
|
|
56
|
+
return { style: {}, setAttribute: function (pK, pV) { tmpAttrs[pK] = pV; }, removeAttribute: function (pK) { delete tmpAttrs[pK]; }, attrs: tmpAttrs };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test('applies stroke color / width / dash via inline style + markers via attributes',
|
|
60
|
+
function ()
|
|
61
|
+
{
|
|
62
|
+
let tmpPath = makePath();
|
|
63
|
+
libConnectionRenderer.prototype._applyConnectionStyle.call(_Self, tmpPath, { StrokeColor: '#abcdef', StrokeWidth: 4, StrokeStyle: 'dashed', SourceMarker: 'dot', TargetMarker: 'arrow' }, 'v');
|
|
64
|
+
libExpect(tmpPath.style.stroke).to.equal('#abcdef');
|
|
65
|
+
libExpect(tmpPath.style.strokeWidth).to.equal('4');
|
|
66
|
+
libExpect(tmpPath.style.strokeDasharray).to.equal('7,5');
|
|
67
|
+
libExpect(tmpPath.attrs['marker-start']).to.equal('url(#flow-marker-dot-v)');
|
|
68
|
+
libExpect(tmpPath.attrs['marker-end']).to.equal('url(#flow-marker-arrow-end-v)');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('solid sets the dash to none; a none marker clears that end',
|
|
72
|
+
function ()
|
|
73
|
+
{
|
|
74
|
+
let tmpPath = makePath();
|
|
75
|
+
tmpPath.setAttribute('marker-end', 'url(#old)');
|
|
76
|
+
libConnectionRenderer.prototype._applyConnectionStyle.call(_Self, tmpPath, { StrokeStyle: 'solid', TargetMarker: 'none' }, 'v');
|
|
77
|
+
libExpect(tmpPath.style.strokeDasharray).to.equal('none');
|
|
78
|
+
libExpect(tmpPath.attrs['marker-end']).to.equal(undefined);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('Data with no style keys leaves the element untouched',
|
|
82
|
+
function ()
|
|
83
|
+
{
|
|
84
|
+
let tmpPath = makePath();
|
|
85
|
+
libConnectionRenderer.prototype._applyConnectionStyle.call(_Self, tmpPath, {}, 'v');
|
|
86
|
+
libExpect(Object.keys(tmpPath.attrs).length).to.equal(0);
|
|
87
|
+
libExpect(Object.keys(tmpPath.style).length).to.equal(0);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -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
|
+
});
|