pict-section-flow 0.0.1

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.
Files changed (23) hide show
  1. package/LICENSE +21 -0
  2. package/example_application/css/flowexample.css +65 -0
  3. package/example_application/html/index.html +32 -0
  4. package/example_application/package.json +41 -0
  5. package/example_application/source/Pict-Application-FlowExample-Configuration.json +15 -0
  6. package/example_application/source/Pict-Application-FlowExample.js +241 -0
  7. package/example_application/source/providers/PictRouter-FlowExample-Configuration.json +22 -0
  8. package/example_application/source/views/PictView-FlowExample-About.js +184 -0
  9. package/example_application/source/views/PictView-FlowExample-BottomBar.js +77 -0
  10. package/example_application/source/views/PictView-FlowExample-Documentation.js +325 -0
  11. package/example_application/source/views/PictView-FlowExample-Layout.js +86 -0
  12. package/example_application/source/views/PictView-FlowExample-MainWorkspace.js +191 -0
  13. package/example_application/source/views/PictView-FlowExample-TopBar.js +95 -0
  14. package/package.json +22 -0
  15. package/source/Pict-Section-Flow.js +19 -0
  16. package/source/providers/PictProvider-Flow-EventHandler.js +158 -0
  17. package/source/providers/PictProvider-Flow-NodeTypes.js +174 -0
  18. package/source/services/PictService-Flow-ConnectionRenderer.js +251 -0
  19. package/source/services/PictService-Flow-InteractionManager.js +567 -0
  20. package/source/services/PictService-Flow-Layout.js +207 -0
  21. package/source/views/PictView-Flow-Node.js +267 -0
  22. package/source/views/PictView-Flow-Toolbar.js +223 -0
  23. package/source/views/PictView-Flow.js +1116 -0
@@ -0,0 +1,207 @@
1
+ const libFableServiceProviderBase = require('fable-serviceproviderbase');
2
+
3
+ class PictServiceFlowLayout extends libFableServiceProviderBase
4
+ {
5
+ constructor(pFable, pOptions, pServiceHash)
6
+ {
7
+ super(pFable, pOptions, pServiceHash);
8
+
9
+ this.serviceType = 'PictServiceFlowLayout';
10
+
11
+ this._FlowView = (pOptions && pOptions.FlowView) ? pOptions.FlowView : null;
12
+
13
+ // Layout configuration
14
+ this._HorizontalSpacing = 250;
15
+ this._VerticalSpacing = 120;
16
+ this._StartX = 100;
17
+ this._StartY = 100;
18
+ }
19
+
20
+ /**
21
+ * Snap a coordinate to the nearest grid point
22
+ * @param {number} pValue - The coordinate value
23
+ * @param {number} pGridSize - The grid size
24
+ * @returns {number}
25
+ */
26
+ snapToGrid(pValue, pGridSize)
27
+ {
28
+ if (!pGridSize || pGridSize <= 0) return pValue;
29
+ return Math.round(pValue / pGridSize) * pGridSize;
30
+ }
31
+
32
+ /**
33
+ * Auto-layout nodes using a simple left-to-right topological approach
34
+ * @param {Array} pNodes - Array of node data objects
35
+ * @param {Array} pConnections - Array of connection data objects
36
+ */
37
+ autoLayout(pNodes, pConnections)
38
+ {
39
+ if (!pNodes || pNodes.length === 0) return;
40
+
41
+ // Build adjacency information
42
+ let tmpNodeMap = {};
43
+ let tmpInDegree = {};
44
+ let tmpOutEdges = {};
45
+
46
+ for (let i = 0; i < pNodes.length; i++)
47
+ {
48
+ let tmpNode = pNodes[i];
49
+ tmpNodeMap[tmpNode.Hash] = tmpNode;
50
+ tmpInDegree[tmpNode.Hash] = 0;
51
+ tmpOutEdges[tmpNode.Hash] = [];
52
+ }
53
+
54
+ for (let i = 0; i < pConnections.length; i++)
55
+ {
56
+ let tmpConn = pConnections[i];
57
+ if (tmpInDegree.hasOwnProperty(tmpConn.TargetNodeHash))
58
+ {
59
+ tmpInDegree[tmpConn.TargetNodeHash]++;
60
+ }
61
+ if (tmpOutEdges.hasOwnProperty(tmpConn.SourceNodeHash))
62
+ {
63
+ tmpOutEdges[tmpConn.SourceNodeHash].push(tmpConn.TargetNodeHash);
64
+ }
65
+ }
66
+
67
+ // Topological sort (Kahn's algorithm)
68
+ let tmpLayers = [];
69
+ let tmpQueue = [];
70
+ let tmpAssigned = {};
71
+
72
+ // Start with nodes that have no incoming edges
73
+ for (let tmpHash in tmpInDegree)
74
+ {
75
+ if (tmpInDegree[tmpHash] === 0)
76
+ {
77
+ tmpQueue.push(tmpHash);
78
+ }
79
+ }
80
+
81
+ while (tmpQueue.length > 0)
82
+ {
83
+ let tmpCurrentLayer = [];
84
+
85
+ let tmpNextQueue = [];
86
+ for (let i = 0; i < tmpQueue.length; i++)
87
+ {
88
+ let tmpNodeHash = tmpQueue[i];
89
+ if (tmpAssigned[tmpNodeHash]) continue;
90
+
91
+ tmpAssigned[tmpNodeHash] = true;
92
+ tmpCurrentLayer.push(tmpNodeHash);
93
+
94
+ // Process outgoing edges
95
+ let tmpEdges = tmpOutEdges[tmpNodeHash] || [];
96
+ for (let j = 0; j < tmpEdges.length; j++)
97
+ {
98
+ let tmpTargetHash = tmpEdges[j];
99
+ tmpInDegree[tmpTargetHash]--;
100
+ if (tmpInDegree[tmpTargetHash] <= 0 && !tmpAssigned[tmpTargetHash])
101
+ {
102
+ tmpNextQueue.push(tmpTargetHash);
103
+ }
104
+ }
105
+ }
106
+
107
+ if (tmpCurrentLayer.length > 0)
108
+ {
109
+ tmpLayers.push(tmpCurrentLayer);
110
+ }
111
+
112
+ tmpQueue = tmpNextQueue;
113
+ }
114
+
115
+ // Handle any remaining unassigned nodes (cycles or disconnected)
116
+ let tmpRemainingNodes = [];
117
+ for (let i = 0; i < pNodes.length; i++)
118
+ {
119
+ if (!tmpAssigned[pNodes[i].Hash])
120
+ {
121
+ tmpRemainingNodes.push(pNodes[i].Hash);
122
+ }
123
+ }
124
+ if (tmpRemainingNodes.length > 0)
125
+ {
126
+ tmpLayers.push(tmpRemainingNodes);
127
+ }
128
+
129
+ // Assign positions based on layers
130
+ let tmpCurrentX = this._StartX;
131
+
132
+ for (let tmpLayerIndex = 0; tmpLayerIndex < tmpLayers.length; tmpLayerIndex++)
133
+ {
134
+ let tmpLayer = tmpLayers[tmpLayerIndex];
135
+ let tmpMaxWidth = 0;
136
+
137
+ // Calculate the total height for this layer to center vertically
138
+ let tmpTotalHeight = 0;
139
+ for (let i = 0; i < tmpLayer.length; i++)
140
+ {
141
+ let tmpNode = tmpNodeMap[tmpLayer[i]];
142
+ if (tmpNode)
143
+ {
144
+ tmpTotalHeight += tmpNode.Height || 80;
145
+ if (i < tmpLayer.length - 1)
146
+ {
147
+ tmpTotalHeight += this._VerticalSpacing;
148
+ }
149
+ }
150
+ }
151
+
152
+ let tmpCurrentY = this._StartY;
153
+
154
+ for (let i = 0; i < tmpLayer.length; i++)
155
+ {
156
+ let tmpNode = tmpNodeMap[tmpLayer[i]];
157
+ if (!tmpNode) continue;
158
+
159
+ tmpNode.X = tmpCurrentX;
160
+ tmpNode.Y = tmpCurrentY;
161
+
162
+ let tmpWidth = tmpNode.Width || 180;
163
+ let tmpHeight = tmpNode.Height || 80;
164
+
165
+ tmpMaxWidth = Math.max(tmpMaxWidth, tmpWidth);
166
+ tmpCurrentY += tmpHeight + this._VerticalSpacing;
167
+ }
168
+
169
+ tmpCurrentX += tmpMaxWidth + this._HorizontalSpacing;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Center all nodes around a given point
175
+ * @param {Array} pNodes
176
+ * @param {number} pCenterX
177
+ * @param {number} pCenterY
178
+ */
179
+ centerNodes(pNodes, pCenterX, pCenterY)
180
+ {
181
+ if (!pNodes || pNodes.length === 0) return;
182
+
183
+ let tmpMinX = Infinity, tmpMinY = Infinity;
184
+ let tmpMaxX = -Infinity, tmpMaxY = -Infinity;
185
+
186
+ for (let i = 0; i < pNodes.length; i++)
187
+ {
188
+ tmpMinX = Math.min(tmpMinX, pNodes[i].X);
189
+ tmpMinY = Math.min(tmpMinY, pNodes[i].Y);
190
+ tmpMaxX = Math.max(tmpMaxX, pNodes[i].X + (pNodes[i].Width || 180));
191
+ tmpMaxY = Math.max(tmpMaxY, pNodes[i].Y + (pNodes[i].Height || 80));
192
+ }
193
+
194
+ let tmpCurrentCenterX = (tmpMinX + tmpMaxX) / 2;
195
+ let tmpCurrentCenterY = (tmpMinY + tmpMaxY) / 2;
196
+ let tmpOffsetX = pCenterX - tmpCurrentCenterX;
197
+ let tmpOffsetY = pCenterY - tmpCurrentCenterY;
198
+
199
+ for (let i = 0; i < pNodes.length; i++)
200
+ {
201
+ pNodes[i].X += tmpOffsetX;
202
+ pNodes[i].Y += tmpOffsetY;
203
+ }
204
+ }
205
+ }
206
+
207
+ module.exports = PictServiceFlowLayout;
@@ -0,0 +1,267 @@
1
+ const libPictView = require('pict-view');
2
+
3
+ const _DefaultConfiguration =
4
+ {
5
+ ViewIdentifier: 'Flow-NodeRenderer',
6
+
7
+ AutoRender: false,
8
+
9
+ // Title bar height for nodes
10
+ NodeTitleBarHeight: 22
11
+ };
12
+
13
+ class PictViewFlowNode extends libPictView
14
+ {
15
+ constructor(pFable, pOptions, pServiceHash)
16
+ {
17
+ let tmpOptions = Object.assign({}, JSON.parse(JSON.stringify(_DefaultConfiguration)), pOptions);
18
+ super(pFable, tmpOptions, pServiceHash);
19
+
20
+ this.serviceType = 'PictViewFlowNode';
21
+
22
+ this._FlowView = null;
23
+ }
24
+
25
+ /**
26
+ * Create the SVG namespace element helper
27
+ * @param {string} pTagName
28
+ * @returns {SVGElement}
29
+ */
30
+ _createSVGElement(pTagName)
31
+ {
32
+ return document.createElementNS('http://www.w3.org/2000/svg', pTagName);
33
+ }
34
+
35
+ /**
36
+ * Render a node into the nodes SVG layer
37
+ * @param {Object} pNodeData - The node data object
38
+ * @param {SVGGElement} pNodesLayer - The SVG <g> element to append to
39
+ * @param {boolean} pIsSelected - Whether this node is selected
40
+ * @param {Object} pNodeTypeConfig - The node type configuration
41
+ */
42
+ renderNode(pNodeData, pNodesLayer, pIsSelected, pNodeTypeConfig)
43
+ {
44
+ let tmpGroup = this._createSVGElement('g');
45
+ tmpGroup.setAttribute('class', `pict-flow-node ${pIsSelected ? 'selected' : ''} pict-flow-node-${pNodeData.Type || 'default'}`);
46
+ tmpGroup.setAttribute('transform', `translate(${pNodeData.X}, ${pNodeData.Y})`);
47
+ tmpGroup.setAttribute('data-node-hash', pNodeData.Hash);
48
+ tmpGroup.setAttribute('data-element-type', 'node');
49
+
50
+ let tmpWidth = pNodeData.Width || 180;
51
+ let tmpHeight = pNodeData.Height || 80;
52
+ let tmpTitleBarHeight = this.options.NodeTitleBarHeight;
53
+
54
+ // Node body (main rectangle)
55
+ let tmpBody = this._createSVGElement('rect');
56
+ tmpBody.setAttribute('class', 'pict-flow-node-body');
57
+ tmpBody.setAttribute('x', '0');
58
+ tmpBody.setAttribute('y', '0');
59
+ tmpBody.setAttribute('width', String(tmpWidth));
60
+ tmpBody.setAttribute('height', String(tmpHeight));
61
+ tmpBody.setAttribute('data-node-hash', pNodeData.Hash);
62
+ tmpBody.setAttribute('data-element-type', 'node-body');
63
+
64
+ // Apply custom styles from node type
65
+ if (pNodeTypeConfig && pNodeTypeConfig.BodyStyle)
66
+ {
67
+ for (let tmpStyleKey in pNodeTypeConfig.BodyStyle)
68
+ {
69
+ tmpBody.setAttribute(tmpStyleKey, pNodeTypeConfig.BodyStyle[tmpStyleKey]);
70
+ }
71
+ }
72
+
73
+ tmpGroup.appendChild(tmpBody);
74
+
75
+ // Title bar background (top portion)
76
+ let tmpTitleBar = this._createSVGElement('rect');
77
+ tmpTitleBar.setAttribute('class', 'pict-flow-node-title-bar');
78
+ tmpTitleBar.setAttribute('x', '0');
79
+ tmpTitleBar.setAttribute('y', '0');
80
+ tmpTitleBar.setAttribute('width', String(tmpWidth));
81
+ tmpTitleBar.setAttribute('height', String(tmpTitleBarHeight));
82
+ tmpTitleBar.setAttribute('data-node-hash', pNodeData.Hash);
83
+ tmpTitleBar.setAttribute('data-element-type', 'node-body');
84
+
85
+ // Apply custom title bar color
86
+ if (pNodeTypeConfig && pNodeTypeConfig.TitleBarColor)
87
+ {
88
+ tmpTitleBar.setAttribute('fill', pNodeTypeConfig.TitleBarColor);
89
+ }
90
+
91
+ tmpGroup.appendChild(tmpTitleBar);
92
+
93
+ // Title bar bottom fill (to square off the rounded corners at the bottom of the title bar)
94
+ let tmpTitleBarBottom = this._createSVGElement('rect');
95
+ tmpTitleBarBottom.setAttribute('class', 'pict-flow-node-title-bar-bottom');
96
+ tmpTitleBarBottom.setAttribute('x', '0');
97
+ tmpTitleBarBottom.setAttribute('y', String(tmpTitleBarHeight - 6));
98
+ tmpTitleBarBottom.setAttribute('width', String(tmpWidth));
99
+ tmpTitleBarBottom.setAttribute('height', '6');
100
+ tmpTitleBarBottom.setAttribute('data-node-hash', pNodeData.Hash);
101
+ tmpTitleBarBottom.setAttribute('data-element-type', 'node-body');
102
+
103
+ if (pNodeTypeConfig && pNodeTypeConfig.TitleBarColor)
104
+ {
105
+ tmpTitleBarBottom.setAttribute('fill', pNodeTypeConfig.TitleBarColor);
106
+ }
107
+
108
+ tmpGroup.appendChild(tmpTitleBarBottom);
109
+
110
+ // Title text
111
+ let tmpTitle = this._createSVGElement('text');
112
+ tmpTitle.setAttribute('class', 'pict-flow-node-title');
113
+ tmpTitle.setAttribute('x', String(tmpWidth / 2));
114
+ tmpTitle.setAttribute('y', String(tmpTitleBarHeight / 2 + 1));
115
+ tmpTitle.setAttribute('text-anchor', 'middle');
116
+ tmpTitle.setAttribute('dominant-baseline', 'central');
117
+ tmpTitle.textContent = pNodeData.Title || 'Untitled';
118
+ tmpGroup.appendChild(tmpTitle);
119
+
120
+ // Type label (below title bar)
121
+ if (pNodeTypeConfig && pNodeTypeConfig.Label && pNodeTypeConfig.Label !== pNodeData.Title)
122
+ {
123
+ let tmpTypeLabel = this._createSVGElement('text');
124
+ tmpTypeLabel.setAttribute('class', 'pict-flow-node-type-label');
125
+ tmpTypeLabel.setAttribute('x', String(tmpWidth / 2));
126
+ tmpTypeLabel.setAttribute('y', String(tmpTitleBarHeight + 18));
127
+ tmpTypeLabel.setAttribute('text-anchor', 'middle');
128
+ tmpTypeLabel.setAttribute('dominant-baseline', 'central');
129
+ tmpTypeLabel.textContent = pNodeTypeConfig.Label;
130
+ tmpGroup.appendChild(tmpTypeLabel);
131
+ }
132
+
133
+ // Render ports
134
+ this._renderPorts(pNodeData, tmpGroup, tmpWidth, tmpHeight);
135
+
136
+ pNodesLayer.appendChild(tmpGroup);
137
+ }
138
+
139
+ /**
140
+ * Render ports for a node
141
+ * @param {Object} pNodeData
142
+ * @param {SVGGElement} pGroup - The node's SVG group
143
+ * @param {number} pWidth
144
+ * @param {number} pHeight
145
+ */
146
+ _renderPorts(pNodeData, pGroup, pWidth, pHeight)
147
+ {
148
+ if (!pNodeData.Ports || !Array.isArray(pNodeData.Ports)) return;
149
+
150
+ // Group ports by side and direction for positioning
151
+ let tmpPortsBySide = { left: [], right: [], top: [], bottom: [] };
152
+ for (let i = 0; i < pNodeData.Ports.length; i++)
153
+ {
154
+ let tmpPort = pNodeData.Ports[i];
155
+ let tmpSide = tmpPort.Side || (tmpPort.Direction === 'input' ? 'left' : 'right');
156
+ if (tmpPortsBySide[tmpSide])
157
+ {
158
+ tmpPortsBySide[tmpSide].push(tmpPort);
159
+ }
160
+ }
161
+
162
+ for (let tmpSide in tmpPortsBySide)
163
+ {
164
+ let tmpPorts = tmpPortsBySide[tmpSide];
165
+ for (let i = 0; i < tmpPorts.length; i++)
166
+ {
167
+ let tmpPort = tmpPorts[i];
168
+ let tmpPosition = this._getPortLocalPosition(tmpSide, i, tmpPorts.length, pWidth, pHeight);
169
+
170
+ // Port circle
171
+ let tmpCircle = this._createSVGElement('circle');
172
+ tmpCircle.setAttribute('class', `pict-flow-port ${tmpPort.Direction}`);
173
+ tmpCircle.setAttribute('cx', String(tmpPosition.x));
174
+ tmpCircle.setAttribute('cy', String(tmpPosition.y));
175
+ tmpCircle.setAttribute('r', '5');
176
+ tmpCircle.setAttribute('data-port-hash', tmpPort.Hash);
177
+ tmpCircle.setAttribute('data-node-hash', pNodeData.Hash);
178
+ tmpCircle.setAttribute('data-port-direction', tmpPort.Direction);
179
+ tmpCircle.setAttribute('data-element-type', 'port');
180
+ pGroup.appendChild(tmpCircle);
181
+
182
+ // Port label
183
+ if (tmpPort.Label)
184
+ {
185
+ let tmpLabel = this._createSVGElement('text');
186
+ tmpLabel.setAttribute('class', 'pict-flow-port-label');
187
+ tmpLabel.textContent = tmpPort.Label;
188
+
189
+ let tmpLabelOffset = 12;
190
+ switch (tmpSide)
191
+ {
192
+ case 'left':
193
+ tmpLabel.setAttribute('x', String(tmpPosition.x + tmpLabelOffset));
194
+ tmpLabel.setAttribute('y', String(tmpPosition.y));
195
+ tmpLabel.setAttribute('text-anchor', 'start');
196
+ break;
197
+ case 'right':
198
+ tmpLabel.setAttribute('x', String(tmpPosition.x - tmpLabelOffset));
199
+ tmpLabel.setAttribute('y', String(tmpPosition.y));
200
+ tmpLabel.setAttribute('text-anchor', 'end');
201
+ break;
202
+ case 'top':
203
+ tmpLabel.setAttribute('x', String(tmpPosition.x));
204
+ tmpLabel.setAttribute('y', String(tmpPosition.y + tmpLabelOffset));
205
+ tmpLabel.setAttribute('text-anchor', 'middle');
206
+ break;
207
+ case 'bottom':
208
+ tmpLabel.setAttribute('x', String(tmpPosition.x));
209
+ tmpLabel.setAttribute('y', String(tmpPosition.y - tmpLabelOffset));
210
+ tmpLabel.setAttribute('text-anchor', 'middle');
211
+ break;
212
+ }
213
+ tmpLabel.setAttribute('dominant-baseline', 'central');
214
+ pGroup.appendChild(tmpLabel);
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Calculate port position relative to node origin.
222
+ *
223
+ * For left and right side ports, positioning is offset below the title bar
224
+ * so that ports never overlap the header area.
225
+ *
226
+ * @param {string} pSide - 'left', 'right', 'top', 'bottom'
227
+ * @param {number} pIndex - Index of this port on its side
228
+ * @param {number} pTotal - Total ports on this side
229
+ * @param {number} pWidth - Node width
230
+ * @param {number} pHeight - Node height
231
+ * @returns {{x: number, y: number}}
232
+ */
233
+ _getPortLocalPosition(pSide, pIndex, pTotal, pWidth, pHeight)
234
+ {
235
+ let tmpSpacing;
236
+ let tmpTitleBarHeight = this.options.NodeTitleBarHeight;
237
+
238
+ switch (pSide)
239
+ {
240
+ case 'left':
241
+ {
242
+ // Distribute ports in the body area below the title bar
243
+ let tmpBodyHeight = pHeight - tmpTitleBarHeight;
244
+ tmpSpacing = tmpBodyHeight / (pTotal + 1);
245
+ return { x: 0, y: tmpTitleBarHeight + tmpSpacing * (pIndex + 1) };
246
+ }
247
+ case 'right':
248
+ {
249
+ let tmpBodyHeight = pHeight - tmpTitleBarHeight;
250
+ tmpSpacing = tmpBodyHeight / (pTotal + 1);
251
+ return { x: pWidth, y: tmpTitleBarHeight + tmpSpacing * (pIndex + 1) };
252
+ }
253
+ case 'top':
254
+ tmpSpacing = pWidth / (pTotal + 1);
255
+ return { x: tmpSpacing * (pIndex + 1), y: 0 };
256
+ case 'bottom':
257
+ tmpSpacing = pWidth / (pTotal + 1);
258
+ return { x: tmpSpacing * (pIndex + 1), y: pHeight };
259
+ default:
260
+ return { x: pWidth, y: pHeight / 2 };
261
+ }
262
+ }
263
+ }
264
+
265
+ module.exports = PictViewFlowNode;
266
+
267
+ module.exports.default_configuration = _DefaultConfiguration;
@@ -0,0 +1,223 @@
1
+ const libPictView = require('pict-view');
2
+
3
+ const _DefaultConfiguration =
4
+ {
5
+ ViewIdentifier: 'Flow-Toolbar',
6
+
7
+ DefaultRenderable: 'Flow-Toolbar-Content',
8
+ DefaultDestinationAddress: '#Flow-Toolbar-Container',
9
+
10
+ AutoRender: false,
11
+
12
+ FlowViewIdentifier: 'Pict-Flow',
13
+
14
+ CSS: /*css*/`
15
+ .pict-flow-toolbar {
16
+ display: flex;
17
+ align-items: center;
18
+ gap: 0.5em;
19
+ padding: 0.5em 0.75em;
20
+ background-color: #ffffff;
21
+ border-bottom: 1px solid #e0e0e0;
22
+ flex-wrap: wrap;
23
+ }
24
+ .pict-flow-toolbar-group {
25
+ display: flex;
26
+ align-items: center;
27
+ gap: 0.25em;
28
+ padding-right: 0.75em;
29
+ border-right: 1px solid #e0e0e0;
30
+ }
31
+ .pict-flow-toolbar-group:last-child {
32
+ border-right: none;
33
+ padding-right: 0;
34
+ }
35
+ .pict-flow-toolbar-btn {
36
+ display: inline-flex;
37
+ align-items: center;
38
+ justify-content: center;
39
+ padding: 0.35em 0.65em;
40
+ border: 1px solid #bdc3c7;
41
+ border-radius: 4px;
42
+ background-color: #fff;
43
+ color: #2c3e50;
44
+ font-size: 0.85em;
45
+ cursor: pointer;
46
+ transition: background-color 0.15s, border-color 0.15s;
47
+ user-select: none;
48
+ -webkit-user-select: none;
49
+ }
50
+ .pict-flow-toolbar-btn:hover {
51
+ background-color: #ecf0f1;
52
+ border-color: #95a5a6;
53
+ }
54
+ .pict-flow-toolbar-btn:active {
55
+ background-color: #d5dbdb;
56
+ }
57
+ .pict-flow-toolbar-btn.danger {
58
+ color: #e74c3c;
59
+ border-color: #e74c3c;
60
+ }
61
+ .pict-flow-toolbar-btn.danger:hover {
62
+ background-color: #fdedec;
63
+ }
64
+ .pict-flow-toolbar-label {
65
+ font-size: 0.8em;
66
+ color: #7f8c8d;
67
+ margin-right: 0.25em;
68
+ }
69
+ .pict-flow-toolbar-select {
70
+ padding: 0.3em 0.5em;
71
+ border: 1px solid #bdc3c7;
72
+ border-radius: 4px;
73
+ font-size: 0.85em;
74
+ background-color: #fff;
75
+ color: #2c3e50;
76
+ }
77
+ `,
78
+
79
+ Templates:
80
+ [
81
+ {
82
+ Hash: 'Flow-Toolbar-Template',
83
+ Template: /*html*/`
84
+ <div class="pict-flow-toolbar">
85
+ <div class="pict-flow-toolbar-group">
86
+ <span class="pict-flow-toolbar-label">Node:</span>
87
+ <select class="pict-flow-toolbar-select" id="Flow-Toolbar-NodeType-{~D:Record.FlowViewIdentifier~}">
88
+ <option value="default">Default</option>
89
+ <option value="start">Start</option>
90
+ <option value="end">End</option>
91
+ <option value="decision">Decision</option>
92
+ </select>
93
+ <button class="pict-flow-toolbar-btn" data-flow-action="add-node">+ Add Node</button>
94
+ </div>
95
+ <div class="pict-flow-toolbar-group">
96
+ <button class="pict-flow-toolbar-btn danger" data-flow-action="delete-selected">Delete</button>
97
+ </div>
98
+ <div class="pict-flow-toolbar-group">
99
+ <button class="pict-flow-toolbar-btn" data-flow-action="zoom-in">Zoom +</button>
100
+ <button class="pict-flow-toolbar-btn" data-flow-action="zoom-out">Zoom −</button>
101
+ <button class="pict-flow-toolbar-btn" data-flow-action="zoom-fit">Fit</button>
102
+ </div>
103
+ <div class="pict-flow-toolbar-group">
104
+ <button class="pict-flow-toolbar-btn" data-flow-action="auto-layout">Auto Layout</button>
105
+ </div>
106
+ </div>
107
+ `
108
+ }
109
+ ],
110
+
111
+ Renderables:
112
+ [
113
+ {
114
+ RenderableHash: 'Flow-Toolbar-Content',
115
+ TemplateHash: 'Flow-Toolbar-Template',
116
+ DestinationAddress: '#Flow-Toolbar-Container',
117
+ RenderMethod: 'replace'
118
+ }
119
+ ]
120
+ };
121
+
122
+ class PictViewFlowToolbar extends libPictView
123
+ {
124
+ constructor(pFable, pOptions, pServiceHash)
125
+ {
126
+ let tmpOptions = Object.assign({}, JSON.parse(JSON.stringify(_DefaultConfiguration)), pOptions);
127
+ super(pFable, tmpOptions, pServiceHash);
128
+
129
+ this.serviceType = 'PictViewFlowToolbar';
130
+
131
+ this._FlowView = null;
132
+ }
133
+
134
+ onAfterRender(pRenderable, pRenderDestinationAddress, pRecord, pContent)
135
+ {
136
+ // Bind toolbar button events via event delegation
137
+ let tmpToolbarElements = this.pict.ContentAssignment.getElement(`.pict-flow-toolbar`);
138
+ if (tmpToolbarElements.length > 0)
139
+ {
140
+ let tmpToolbar = tmpToolbarElements[0];
141
+ tmpToolbar.addEventListener('click', (pEvent) =>
142
+ {
143
+ let tmpTarget = pEvent.target;
144
+ if (!tmpTarget) return;
145
+
146
+ // Walk up to find the button with the action
147
+ let tmpButton = tmpTarget.closest('[data-flow-action]');
148
+ if (!tmpButton) return;
149
+
150
+ let tmpAction = tmpButton.getAttribute('data-flow-action');
151
+ this._handleToolbarAction(tmpAction);
152
+ });
153
+ }
154
+
155
+ return super.onAfterRender(pRenderable, pRenderDestinationAddress, pRecord, pContent);
156
+ }
157
+
158
+ /**
159
+ * Handle a toolbar action
160
+ * @param {string} pAction
161
+ */
162
+ _handleToolbarAction(pAction)
163
+ {
164
+ if (!this._FlowView) return;
165
+
166
+ let tmpFlowViewIdentifier = this.options.FlowViewIdentifier;
167
+
168
+ switch (pAction)
169
+ {
170
+ case 'add-node':
171
+ {
172
+ // Get selected node type from dropdown
173
+ let tmpSelectElements = this.pict.ContentAssignment.getElement(`#Flow-Toolbar-NodeType-${tmpFlowViewIdentifier}`);
174
+ let tmpNodeType = 'default';
175
+ if (tmpSelectElements.length > 0)
176
+ {
177
+ tmpNodeType = tmpSelectElements[0].value;
178
+ }
179
+
180
+ // Place the new node at a reasonable position
181
+ let tmpVS = this._FlowView.viewState;
182
+ let tmpX = (-tmpVS.PanX + 200) / tmpVS.Zoom;
183
+ let tmpY = (-tmpVS.PanY + 200) / tmpVS.Zoom;
184
+
185
+ // Offset if there are existing nodes to avoid overlap
186
+ let tmpNodeCount = this._FlowView.flowData.Nodes.length;
187
+ tmpX += (tmpNodeCount % 5) * 40;
188
+ tmpY += (tmpNodeCount % 5) * 40;
189
+
190
+ this._FlowView.addNode(tmpNodeType, tmpX, tmpY);
191
+ }
192
+ break;
193
+
194
+ case 'delete-selected':
195
+ this._FlowView.deleteSelected();
196
+ break;
197
+
198
+ case 'zoom-in':
199
+ this._FlowView.setZoom(this._FlowView.viewState.Zoom + this._FlowView.options.ZoomStep);
200
+ break;
201
+
202
+ case 'zoom-out':
203
+ this._FlowView.setZoom(this._FlowView.viewState.Zoom - this._FlowView.options.ZoomStep);
204
+ break;
205
+
206
+ case 'zoom-fit':
207
+ this._FlowView.zoomToFit();
208
+ break;
209
+
210
+ case 'auto-layout':
211
+ this._FlowView.autoLayout();
212
+ break;
213
+
214
+ default:
215
+ this.log.warn(`PictViewFlowToolbar: unknown action '${pAction}'`);
216
+ break;
217
+ }
218
+ }
219
+ }
220
+
221
+ module.exports = PictViewFlowToolbar;
222
+
223
+ module.exports.default_configuration = _DefaultConfiguration;