pict-section-flow 1.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pict-section-flow",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Pict Section Flow Diagram",
5
5
  "main": "source/Pict-Section-Flow.js",
6
6
  "scripts": {
@@ -768,6 +768,19 @@ class PictProviderFlowCSS extends libFableServiceProviderBase
768
768
  cursor: pointer;
769
769
  transition: stroke 0.15s;
770
770
  }
771
+ /* A connection's label (Data.Label), drawn at the midpoint. The white halo (paint-order) keeps it
772
+ legible where it crosses the line. */
773
+ .pict-flow-connection-label {
774
+ font-size: 12px;
775
+ font-family: inherit;
776
+ fill: var(--pf-text-primary, #2c3e50);
777
+ paint-order: stroke;
778
+ stroke: var(--theme-color-background-primary, #ffffff);
779
+ stroke-width: 3px;
780
+ stroke-linejoin: round;
781
+ pointer-events: none;
782
+ user-select: none;
783
+ }
771
784
  .pict-flow-connection:hover {
772
785
  stroke: var(--pf-connection-stroke-hover);
773
786
  stroke-width: 3;
@@ -1285,6 +1298,19 @@ class PictProviderFlowCSS extends libFableServiceProviderBase
1285
1298
  border-right: none;
1286
1299
  padding-right: 0;
1287
1300
  }
1301
+ /* The host-supplied extra-button group (ToolbarExtraButtons) renders even
1302
+ when empty; collapse it so consumers that pass no buttons see no gap. */
1303
+ .pict-flow-toolbar-group:empty {
1304
+ display: none;
1305
+ }
1306
+ .pict-flow-toolbar-btn-active {
1307
+ background-color: var(--pf-button-active-bg);
1308
+ border-color: var(--pf-button-hover-border);
1309
+ }
1310
+ /* An icon-only host button (ToolbarExtraButtons with no Label) renders an empty text span. */
1311
+ .pict-flow-toolbar-btn-text:empty {
1312
+ display: none;
1313
+ }
1288
1314
  .pict-flow-toolbar-btn {
1289
1315
  display: inline-flex;
1290
1316
  align-items: center;
@@ -465,6 +465,14 @@ class PictProviderFlowConnectorShapes extends libFableServiceProviderBase
465
465
  + '<polygon class="pict-flow-arrowhead pict-flow-arrowhead-tether" points="' + tmpTetherMarker.Points + '" fill="' + tmpTetherMarker.Fill + '" />'
466
466
  + '</marker>';
467
467
 
468
+ // Generic per-connection end markers, selectable per connection via Data.SourceMarker /
469
+ // Data.TargetMarker (a host like a moodboard styles its own edges). fill="context-stroke" makes
470
+ // each marker take the connection's own stroke color, so a recolored line recolors its markers.
471
+ tmpMarkup += '<marker id="flow-marker-arrow-end-' + pViewIdentifier + '" markerWidth="10" markerHeight="10" refX="8.5" refY="5" orient="auto" markerUnits="strokeWidth"><path d="M1,1 L9,5 L1,9 z" fill="context-stroke" /></marker>';
472
+ tmpMarkup += '<marker id="flow-marker-arrow-start-' + pViewIdentifier + '" markerWidth="10" markerHeight="10" refX="1.5" refY="5" orient="auto-start-reverse" markerUnits="strokeWidth"><path d="M1,1 L9,5 L1,9 z" fill="context-stroke" /></marker>';
473
+ tmpMarkup += '<marker id="flow-marker-dot-' + pViewIdentifier + '" markerWidth="8" markerHeight="8" refX="4" refY="4" orient="auto" markerUnits="strokeWidth"><circle cx="4" cy="4" r="3" fill="context-stroke" /></marker>';
474
+ tmpMarkup += '<marker id="flow-marker-square-' + pViewIdentifier + '" markerWidth="8" markerHeight="8" refX="4" refY="4" orient="auto" markerUnits="strokeWidth"><rect x="1" y="1" width="6" height="6" fill="context-stroke" /></marker>';
475
+
468
476
  return tmpMarkup;
469
477
  }
470
478
  }
@@ -45,6 +45,14 @@ const _DefaultIcons =
45
45
 
46
46
  // ── UI Icons ───────────────────────────────────────────────────────────
47
47
 
48
+ 'edit': '<svg xmlns="http://www.w3.org/2000/svg" width="{FlowIconSize}" height="{FlowIconSize}" viewBox="0 0 24 24" fill="none" stroke="var(--theme-color-text-primary, #2c3e50)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>',
49
+
50
+ 'check': '<svg xmlns="http://www.w3.org/2000/svg" width="{FlowIconSize}" height="{FlowIconSize}" viewBox="0 0 24 24" fill="none" stroke="var(--theme-color-text-primary, #2c3e50)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>',
51
+
52
+ 'background': '<svg xmlns="http://www.w3.org/2000/svg" width="{FlowIconSize}" height="{FlowIconSize}" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" fill="var(--theme-color-background-secondary, #d5e8f7)" stroke="var(--theme-color-text-primary, #2c3e50)" stroke-width="2"/></svg>',
53
+
54
+ 'connect': '<svg xmlns="http://www.w3.org/2000/svg" width="{FlowIconSize}" height="{FlowIconSize}" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M8 8l8 8" stroke="var(--theme-color-text-primary, #2c3e50)" stroke-width="2"/><circle cx="6" cy="6" r="3" fill="var(--theme-color-background-secondary, #d5e8f7)" stroke="var(--theme-color-text-primary, #2c3e50)" stroke-width="2"/><circle cx="18" cy="18" r="3" fill="var(--theme-color-background-secondary, #d5e8f7)" stroke="var(--theme-color-text-primary, #2c3e50)" stroke-width="2"/></svg>',
55
+
48
56
  'fullscreen': '<svg xmlns="http://www.w3.org/2000/svg" width="{FlowIconSize}" height="{FlowIconSize}" viewBox="0 0 24 24" fill="none" stroke="var(--theme-color-text-primary, #2c3e50)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>',
49
57
 
50
58
  'exit-fullscreen': '<svg xmlns="http://www.w3.org/2000/svg" width="{FlowIconSize}" height="{FlowIconSize}" viewBox="0 0 24 24" fill="none" stroke="var(--theme-color-text-primary, #2c3e50)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 14h6v6"/><path d="M20 10h-6V4"/><path d="M14 10l7-7"/><path d="M3 21l7-7"/></svg>',
@@ -116,12 +116,13 @@ class PictServiceFlowConnectionRenderer extends libFableServiceProviderBase
116
116
 
117
117
  // Hit area (wider invisible path for easier selection)
118
118
  let tmpShapeProvider = this._FlowView._ConnectorShapesProvider;
119
+ let tmpPathElement = null;
119
120
  if (tmpShapeProvider)
120
121
  {
121
122
  let tmpHitArea = tmpShapeProvider.createConnectionHitAreaElement(tmpPath, pConnection.Hash);
122
123
  pConnectionsLayer.appendChild(tmpHitArea);
123
124
 
124
- let tmpPathElement = tmpShapeProvider.createConnectionPathElement(
125
+ tmpPathElement = tmpShapeProvider.createConnectionPathElement(
125
126
  tmpPath, pConnection.Hash, pIsSelected, tmpViewIdentifier);
126
127
  if (tmpConnTypeClass)
127
128
  {
@@ -145,7 +146,7 @@ class PictServiceFlowConnectionRenderer extends libFableServiceProviderBase
145
146
  tmpHitArea.setAttribute('data-element-type', 'connection-hitarea');
146
147
  pConnectionsLayer.appendChild(tmpHitArea);
147
148
 
148
- let tmpPathElement = this._FlowView._SVGHelperProvider.createSVGElement('path');
149
+ tmpPathElement = this._FlowView._SVGHelperProvider.createSVGElement('path');
149
150
  tmpPathElement.setAttribute('class', `pict-flow-connection${tmpConnTypeClass} ${pIsSelected ? 'selected' : ''}`);
150
151
  tmpPathElement.setAttribute('d', tmpPath);
151
152
  tmpPathElement.setAttribute('data-connection-hash', pConnection.Hash);
@@ -160,14 +161,25 @@ class PictServiceFlowConnectionRenderer extends libFableServiceProviderBase
160
161
  pConnectionsLayer.appendChild(tmpPathElement);
161
162
  }
162
163
 
164
+ // Per-connection host styling (a moodboard styles its own edges): stroke color / width / dash and
165
+ // the end markers, applied only when the connection's Data carries them so default (workflow)
166
+ // connections are untouched. A label, when set, is drawn at the midpoint.
167
+ let tmpHasMarkers = (typeof tmpData.SourceMarker !== 'undefined' || typeof tmpData.TargetMarker !== 'undefined');
168
+ this._applyConnectionStyle(tmpPathElement, tmpData, tmpViewIdentifier);
169
+ this._renderConnectionLabel(pConnection, tmpData, pConnectionsLayer, tmpSourcePos, tmpTargetPos);
170
+
163
171
  // Render the colored endpoint dot at each end of the connection
164
172
  // into the dedicated endpoints layer (sits *above* the nodes
165
173
  // layer so the dot doesn't get hidden under the card chrome
166
174
  // when an edge theme places it on the node's perimeter).
167
175
  // PortRenderer suppresses its own circle for any port that
168
- // participates in a connection — we own the dot here.
176
+ // participates in a connection — we own the dot here. Skipped when the
177
+ // connection supplies its own end markers (those own the endpoints).
169
178
  let tmpEndpointsLayer = this._FlowView._EndpointsLayer || pConnectionsLayer;
170
- this._renderEndpointDots(pConnection, tmpEndpointsLayer, tmpSourcePos, tmpTargetPos);
179
+ if (!tmpHasMarkers)
180
+ {
181
+ this._renderEndpointDots(pConnection, tmpEndpointsLayer, tmpSourcePos, tmpTargetPos);
182
+ }
171
183
 
172
184
  // When the resolved attachment differs from the card-defined port
173
185
  // position (e.g. a Perimeter theme moved the dot to the edge of the
@@ -187,6 +199,66 @@ class PictServiceFlowConnectionRenderer extends libFableServiceProviderBase
187
199
  }
188
200
  }
189
201
 
202
+ // Apply per-connection host styling to the path element from the connection's Data: StrokeColor,
203
+ // StrokeWidth, StrokeStyle ('solid' | 'dashed' | 'dotted') and SourceMarker / TargetMarker
204
+ // ('none' | 'arrow' | 'dot' | 'square'). Each is applied only when present, so connections that do
205
+ // not carry these keys render exactly as before.
206
+ _applyConnectionStyle(pPathElement, pData, pViewIdentifier)
207
+ {
208
+ if (!pPathElement || !pData || !pPathElement.style) { return; }
209
+ // Stroke color / width / dash go through inline style, NOT SVG presentation attributes: the
210
+ // .pict-flow-connection CSS rule sets stroke + stroke-width, and a stylesheet rule beats a
211
+ // presentation attribute, so an attribute would be silently ignored. Inline style outranks the
212
+ // stylesheet, so the per-connection appearance wins.
213
+ if (pData.StrokeColor) { pPathElement.style.stroke = pData.StrokeColor; }
214
+ if (pData.StrokeWidth) { pPathElement.style.strokeWidth = String(pData.StrokeWidth); }
215
+ if (pData.StrokeStyle)
216
+ {
217
+ pPathElement.style.strokeDasharray = (pData.StrokeStyle === 'dashed') ? '7,5' : ((pData.StrokeStyle === 'dotted') ? '1.5,4' : 'none');
218
+ }
219
+ // Markers are not styled by CSS, so attributes are correct (they reference the marker defs).
220
+ if (typeof pData.TargetMarker !== 'undefined')
221
+ {
222
+ let tmpEnd = this._connectionMarkerId(pData.TargetMarker, 'end', pViewIdentifier);
223
+ if (tmpEnd) { pPathElement.setAttribute('marker-end', 'url(#' + tmpEnd + ')'); }
224
+ else { pPathElement.removeAttribute('marker-end'); }
225
+ }
226
+ if (typeof pData.SourceMarker !== 'undefined')
227
+ {
228
+ let tmpStart = this._connectionMarkerId(pData.SourceMarker, 'start', pViewIdentifier);
229
+ if (tmpStart) { pPathElement.setAttribute('marker-start', 'url(#' + tmpStart + ')'); }
230
+ else { pPathElement.removeAttribute('marker-start'); }
231
+ }
232
+ }
233
+
234
+ // Resolve a marker name + end ('start' | 'end') to its SVG marker def id; null for 'none'/unknown.
235
+ _connectionMarkerId(pMarker, pEnd, pViewIdentifier)
236
+ {
237
+ if (pMarker === 'arrow') { return 'flow-marker-arrow-' + pEnd + '-' + pViewIdentifier; }
238
+ if (pMarker === 'dot') { return 'flow-marker-dot-' + pViewIdentifier; }
239
+ if (pMarker === 'square') { return 'flow-marker-square-' + pViewIdentifier; }
240
+ return null;
241
+ }
242
+
243
+ // Draw a connection's label (Data.Label) at the midpoint of its endpoints. A white halo (paint-order
244
+ // stroke, set in CSS) keeps it legible over the line. Skipped when there is no label.
245
+ _renderConnectionLabel(pConnection, pData, pLayer, pSourcePos, pTargetPos)
246
+ {
247
+ // Render when a Label key is present (even ''), so a host that edits the label in place has an
248
+ // element to update; a connection with no Label key (a default workflow edge) gets none.
249
+ if (!pData || typeof pData.Label === 'undefined' || !pSourcePos || !pTargetPos) { return; }
250
+ if (!this._FlowView._SVGHelperProvider) { return; }
251
+ let tmpText = this._FlowView._SVGHelperProvider.createSVGElement('text');
252
+ tmpText.setAttribute('class', 'pict-flow-connection-label');
253
+ tmpText.setAttribute('x', String((pSourcePos.x + pTargetPos.x) / 2));
254
+ tmpText.setAttribute('y', String((pSourcePos.y + pTargetPos.y) / 2));
255
+ tmpText.setAttribute('text-anchor', 'middle');
256
+ tmpText.setAttribute('dominant-baseline', 'middle');
257
+ tmpText.setAttribute('data-connection-hash', pConnection.Hash);
258
+ tmpText.textContent = pData.Label;
259
+ pLayer.appendChild(tmpText);
260
+ }
261
+
190
262
  /**
191
263
  * Append the colored endpoint dots for both ends of a connection
192
264
  * onto the destination layer (absolute coords). Reuses
@@ -1163,7 +1163,8 @@ class PictServiceFlowInteractionManager extends libFableServiceProviderBase
1163
1163
 
1164
1164
  if (!tmpNodeHash || !tmpPortHash) return;
1165
1165
 
1166
- if (tmpPortDirection !== 'output')
1166
+ // A connection normally starts from an output port; undirected mode lets any port start one.
1167
+ if (!this._FlowView.options.EnableUndirectedConnections && tmpPortDirection !== 'output')
1167
1168
  {
1168
1169
  return;
1169
1170
  }
@@ -1217,7 +1218,8 @@ class PictServiceFlowInteractionManager extends libFableServiceProviderBase
1217
1218
  let tmpTargetNodeHash = tmpTarget.getAttribute('data-node-hash');
1218
1219
  let tmpTargetPortDirection = tmpTarget.getAttribute('data-port-direction');
1219
1220
 
1220
- if (tmpTargetPortHash && tmpTargetNodeHash && tmpTargetPortDirection === 'input')
1221
+ // A connection normally completes on an input port; undirected mode lets any port receive it.
1222
+ if (tmpTargetPortHash && tmpTargetNodeHash && (this._FlowView.options.EnableUndirectedConnections || tmpTargetPortDirection === 'input'))
1221
1223
  {
1222
1224
  this._FlowView.addConnection(
1223
1225
  this._ConnectSourceNodeHash,
@@ -11,6 +11,10 @@ const _DefaultConfiguration =
11
11
 
12
12
  FlowViewIdentifier: 'Pict-Flow',
13
13
 
14
+ // Host-supplied buttons (mirrors the docked toolbar's ToolbarExtraButtons), so floating mode keeps
15
+ // the same custom buttons. Each entry is { Hash, Icon, Label?, Tooltip?, Active? }.
16
+ ToolbarExtraButtons: [],
17
+
14
18
  CSS: false,
15
19
 
16
20
  Templates:
@@ -64,6 +68,7 @@ const _DefaultConfiguration =
64
68
  onclick="_Pict.views['{~D:Record.FlowViewIdentifier~}']._ToolbarView._FloatingToolbarView._handleButtonClick('fullscreen')">
65
69
  <span id="Flow-FloatingIcon-fullscreen-{~D:Record.FlowViewIdentifier~}"></span>
66
70
  </button>
71
+ {~TS:Flow-FloatingToolbar-Extra-Button:Record.ToolbarExtraButtons~}
67
72
  <div class="pict-flow-floating-separator"></div>
68
73
  <button class="pict-flow-floating-btn" data-flow-action="dock-toolbar" title="Dock Toolbar"
69
74
  onclick="_Pict.views['{~D:Record.FlowViewIdentifier~}']._ToolbarView._FloatingToolbarView._handleButtonClick('dock-toolbar')">
@@ -71,6 +76,15 @@ const _DefaultConfiguration =
71
76
  </button>
72
77
  </div>
73
78
  `
79
+ },
80
+ {
81
+ Hash: 'Flow-FloatingToolbar-Extra-Button',
82
+ // Icon-only host button (the floating toolbar is compact). Icon span
83
+ // is filled post-render by _populateIcons (keyed by Hash).
84
+ Template: /*html*/`<button class="pict-flow-floating-btn" title="{~D:Record.Tooltip~}" data-flow-action="extra" data-extra-hash="{~D:Record.Hash~}"
85
+ onclick="_Pict.views['{~D:Record.FlowViewIdentifier~}']._ToolbarView._FloatingToolbarView._handleExtraClick('{~D:Record.Hash~}', this)">
86
+ <span id="Flow-FloatingExtraIcon-{~D:Record.Hash~}-{~D:Record.FlowViewIdentifier~}"></span>
87
+ </button>`
74
88
  }
75
89
  ],
76
90
 
@@ -111,9 +125,34 @@ class PictViewFlowFloatingToolbar extends libPictView
111
125
 
112
126
  render(pRenderableHash, pRenderDestinationAddress, pTemplateRecordAddress)
113
127
  {
128
+ // Stamp the owning view onto each host button so its row resolves.
129
+ let tmpExtraButtons = this.options.ToolbarExtraButtons;
130
+ if (Array.isArray(tmpExtraButtons))
131
+ {
132
+ for (let i = 0; i < tmpExtraButtons.length; i++)
133
+ {
134
+ tmpExtraButtons[i].FlowViewIdentifier = this.options.FlowViewIdentifier;
135
+ }
136
+ }
114
137
  return super.render(pRenderableHash, pRenderDestinationAddress, this.options);
115
138
  }
116
139
 
140
+ /**
141
+ * Handle a click on a host-supplied (ToolbarExtraButtons) floating button.
142
+ * Routes to the docked toolbar's _handleExtraAction (the single dispatch
143
+ * point that fires the FlowView's onToolbarButton hook).
144
+ *
145
+ * @param {string} pHash - The button's Hash
146
+ * @param {HTMLElement} pElement - The clicked button element
147
+ */
148
+ _handleExtraClick(pHash, pElement)
149
+ {
150
+ if (this._ToolbarView)
151
+ {
152
+ this._ToolbarView._handleExtraAction(pHash, pElement);
153
+ }
154
+ }
155
+
117
156
  onAfterRender(pRenderable, pRenderDestinationAddress, pRecord, pContent)
118
157
  {
119
158
  let tmpFlowViewIdentifier = this.options.FlowViewIdentifier;
@@ -222,6 +261,20 @@ class PictViewFlowFloatingToolbar extends libPictView
222
261
  tmpElements[0].innerHTML = tmpIconProvider.getIconSVGMarkup(tmpIconMap[tmpKeys[i]], 16);
223
262
  }
224
263
  }
264
+
265
+ // Host-supplied extra buttons (keyed by Hash, icon from the button's Icon key).
266
+ let tmpExtraButtons = this.options.ToolbarExtraButtons;
267
+ if (Array.isArray(tmpExtraButtons))
268
+ {
269
+ for (let i = 0; i < tmpExtraButtons.length; i++)
270
+ {
271
+ let tmpExtraIcon = this.pict.ContentAssignment.getElement(`#Flow-FloatingExtraIcon-${tmpExtraButtons[i].Hash}-${tmpFlowViewIdentifier}`);
272
+ if (tmpExtraIcon.length > 0)
273
+ {
274
+ tmpExtraIcon[0].innerHTML = tmpIconProvider.getIconSVGMarkup(tmpExtraButtons[i].Icon, 16);
275
+ }
276
+ }
277
+ }
225
278
  }
226
279
 
227
280
  /**
@@ -52,6 +52,13 @@ class PictViewFlowNode extends libPictView
52
52
  {
53
53
  tmpClassList += ' pict-flow-node-color-' + tmpColorRole;
54
54
  }
55
+ // A host can stamp an extra CSS class onto a node via node.NodeClass (e.g. a moodboard marks a
56
+ // card whose connection points should stay visible on a read-only board). Survives re-renders
57
+ // because it lives on the node data.
58
+ if (typeof pNodeData.NodeClass === 'string' && pNodeData.NodeClass)
59
+ {
60
+ tmpClassList += ' ' + pNodeData.NodeClass;
61
+ }
55
62
  tmpGroup.setAttribute('class', tmpClassList);
56
63
  tmpGroup.setAttribute('transform', `translate(${pNodeData.X}, ${pNodeData.Y})`);
57
64
  tmpGroup.setAttribute('data-node-hash', pNodeData.Hash);
@@ -226,14 +226,36 @@ class PictViewFlowPropertiesPanel extends libPictView
226
226
  let tmpFO = pPanelsLayer.querySelector(`[data-panel-hash="${pPanelData.Hash}"]`);
227
227
  if (tmpFO)
228
228
  {
229
- // Render appearance and help tabs. Tab-switching click handlers
230
- // are inline `onclick=` attributes in Flow-PanelChrome-Template
231
- // that call FlowView._handlePanelTabClick switchPanelTab.
232
- this._renderAppearanceTab(pPanelData, tmpFO);
233
- this._renderHelpTab(pPanelData, tmpFO);
229
+ // The Appearance tab edits a node's body/title appearance, which a connection (edge) has none
230
+ // of -- so for a connection panel, hide that tab (and the now-single-tab bar) and leave just
231
+ // the connection's own panel. Node panels keep the appearance + help tabs.
232
+ if (pPanelData.ConnectionHash)
233
+ {
234
+ this._hidePanelTabsForConnection(tmpFO);
235
+ }
236
+ else
237
+ {
238
+ // Tab-switching click handlers are inline `onclick=` attributes in Flow-PanelChrome-Template
239
+ // that call FlowView._handlePanelTabClick → switchPanelTab.
240
+ this._renderAppearanceTab(pPanelData, tmpFO);
241
+ this._renderHelpTab(pPanelData, tmpFO);
242
+ }
234
243
  }
235
244
  }
236
245
 
246
+ // Hide the node-oriented Appearance (and Help) tabs plus the tab bar for a connection panel, so it
247
+ // shows only its single Properties pane with no lone tab.
248
+ _hidePanelTabsForConnection(pForeignObject)
249
+ {
250
+ if (!pForeignObject) { return; }
251
+ let tmpAppearanceTab = pForeignObject.querySelector('.pict-flow-panel-tab[data-tab-target="appearance"]');
252
+ if (tmpAppearanceTab) { tmpAppearanceTab.style.display = 'none'; }
253
+ let tmpAppearancePane = pForeignObject.querySelector('.pict-flow-panel-tab-pane[data-tab="appearance"]');
254
+ if (tmpAppearancePane) { tmpAppearancePane.style.display = 'none'; }
255
+ let tmpTabbar = pForeignObject.querySelector('.pict-flow-panel-tabbar');
256
+ if (tmpTabbar) { tmpTabbar.style.display = 'none'; }
257
+ }
258
+
237
259
  /**
238
260
  * Instantiate (or reuse) the panel type implementation and render into the body container.
239
261
  *
@@ -15,6 +15,10 @@ const _DefaultConfiguration =
15
15
  EnableAddNode: true,
16
16
  EnableCardPalette: true,
17
17
 
18
+ // Host-supplied buttons (set by the FlowView from its own ToolbarExtraButtons option). Each entry
19
+ // is { Hash, Icon, Label?, Tooltip?, Active? }. Rendered as a group via Flow-Toolbar-Extra-Button.
20
+ ToolbarExtraButtons: [],
21
+
18
22
  CSS: false,
19
23
 
20
24
  Templates:
@@ -77,6 +81,7 @@ const _DefaultConfiguration =
77
81
  <span class="pict-flow-toolbar-btn-icon" id="Flow-Toolbar-Icon-zoom-fit-{~D:Record.FlowViewIdentifier~}"></span>
78
82
  </button>
79
83
  </div>
84
+ <div class="pict-flow-toolbar-group pict-flow-toolbar-extra">{~TS:Flow-Toolbar-Extra-Button:Record.ToolbarExtraButtons~}</div>
80
85
  <div class="pict-flow-toolbar-group pict-flow-toolbar-right">
81
86
  <button class="pict-flow-toolbar-btn" data-flow-action="settings-popup" id="Flow-Toolbar-Settings-{~D:Record.FlowViewIdentifier~}" title="Theme Settings"
82
87
  onclick="_Pict.views['{~D:Record.FlowViewIdentifier~}']._ToolbarView._handleToolbarAction('settings-popup')">
@@ -105,6 +110,18 @@ const _DefaultConfiguration =
105
110
  <div class="pict-flow-toolbar-popup-anchor" id="Flow-Toolbar-PopupAnchor-{~D:Record.FlowViewIdentifier~}">
106
111
  </div>
107
112
  `
113
+ },
114
+ {
115
+ Hash: 'Flow-Toolbar-Extra-Button',
116
+ // Host-supplied button. The icon span is filled post-render by
117
+ // _populateToolbarIcons (keyed by Hash), matching how the built-in
118
+ // button icons are injected. FlowViewIdentifier + ActiveClass are
119
+ // stamped onto each row in render().
120
+ Template: /*html*/`<button class="pict-flow-toolbar-btn{~D:Record.ActiveClass~}" id="Flow-Toolbar-Extra-{~D:Record.Hash~}-{~D:Record.FlowViewIdentifier~}" title="{~D:Record.Tooltip~}" data-flow-action="extra" data-extra-hash="{~D:Record.Hash~}"
121
+ onclick="_Pict.views['{~D:Record.FlowViewIdentifier~}']._ToolbarView._handleExtraAction('{~D:Record.Hash~}', this)">
122
+ <span class="pict-flow-toolbar-btn-icon" id="Flow-Toolbar-ExtraIcon-{~D:Record.Hash~}-{~D:Record.FlowViewIdentifier~}"></span>
123
+ <span class="pict-flow-toolbar-btn-text">{~D:Record.Label~}</span>
124
+ </button>`
108
125
  },
109
126
  {
110
127
  Hash: 'Flow-AddNode-List',
@@ -121,7 +138,7 @@ const _DefaultConfiguration =
121
138
  + ' onclick="_Pict.views[\'{~D:Record.FlowViewIdentifier~}\']._ToolbarView._addNodeFromPopup(this.getAttribute(\'data-node-type\'))">'
122
139
  + '<span class="pict-flow-popup-list-item-icon">{~D:Record.IconHTML~}</span>'
123
140
  + '<span class="pict-flow-popup-list-item-label">{~D:Record.Label~}</span>'
124
- + '{~NE:Record.Code^<span class="pict-flow-popup-list-item-code">{~D:Record.Code~}</span>~}'
141
+ + '{~D:Record.CodeBlock~}'
125
142
  + '</div>'
126
143
  },
127
144
  {
@@ -137,13 +154,16 @@ const _DefaultConfiguration =
137
154
  },
138
155
  {
139
156
  Hash: 'Flow-Cards-Card',
157
+ // The icon / swatch / code spans are pre-rendered into complete HTML blocks by
158
+ // _buildCardsPopup (a block is '' when its piece is absent). The template can't build them
159
+ // inline: the engine does not parse a nested {~D:~} inside a {~NE:~} (its `~}` terminator
160
+ // collides with the inner tag's), which left the palette showing raw template literals.
140
161
  Template: '<div class="pict-flow-palette-card{~D:Record.DisabledClass~}" data-card-type="{~D:Record.CardType~}" title="{~D:Record.Tooltip~}"'
141
162
  + ' onclick="_Pict.views[\'{~D:Record.FlowViewIdentifier~}\']._ToolbarView._addCardFromPopup(this.getAttribute(\'data-card-type\'))">'
142
- + '{~NE:Record.IconHTML^<span class="pict-flow-palette-card-icon">{~D:Record.IconHTML~}</span>~}'
143
- + '{~NE:Record.IconEmoji^<span class="pict-flow-palette-card-icon">{~D:Record.IconEmoji~}</span>~}'
144
- + '{~NE:Record.SwatchColor^<span class="pict-flow-palette-card-swatch" style="background-color: {~D:Record.SwatchColor~};"></span>~}'
163
+ + '{~D:Record.IconBlock~}'
164
+ + '{~D:Record.SwatchBlock~}'
145
165
  + '<span class="pict-flow-palette-card-title">{~D:Record.Label~}</span>'
146
- + '{~NE:Record.Code^<span class="pict-flow-palette-card-code">{~D:Record.Code~}</span>~}'
166
+ + '{~D:Record.CodeBlock~}'
147
167
  + '</div>'
148
168
  },
149
169
  {
@@ -212,11 +232,32 @@ class PictViewFlowToolbar extends libPictView
212
232
 
213
233
  render(pRenderableHash, pRenderDestinationAddress, pTemplateRecordAddress)
214
234
  {
235
+ // Stamp the per-row render fields onto each host-supplied button so the
236
+ // Flow-Toolbar-Extra-Button rows resolve their owning view and active
237
+ // state (nested {~D:~} addressing inside {~TS:~} is not supported).
238
+ this._stampExtraButtons();
215
239
  // Pass this.options as the template record so {~D:Record.FlowViewIdentifier~}
216
240
  // resolves correctly in the toolbar template.
217
241
  return super.render(pRenderableHash, pRenderDestinationAddress, this.options);
218
242
  }
219
243
 
244
+ /**
245
+ * Stamp FlowViewIdentifier + ActiveClass onto each ToolbarExtraButtons entry
246
+ * so the row template can address them.
247
+ */
248
+ _stampExtraButtons()
249
+ {
250
+ let tmpExtraButtons = this.options.ToolbarExtraButtons;
251
+ if (!Array.isArray(tmpExtraButtons)) return;
252
+ for (let i = 0; i < tmpExtraButtons.length; i++)
253
+ {
254
+ tmpExtraButtons[i].FlowViewIdentifier = this.options.FlowViewIdentifier;
255
+ tmpExtraButtons[i].ActiveClass = tmpExtraButtons[i].Active ? ' pict-flow-toolbar-btn-active' : '';
256
+ // A label-less (icon-only) button renders an empty text span; CSS (:empty) collapses it.
257
+ if (typeof tmpExtraButtons[i].Label !== 'string') { tmpExtraButtons[i].Label = ''; }
258
+ }
259
+ }
260
+
220
261
  onAfterRender(pRenderable, pRenderDestinationAddress, pRecord, pContent)
221
262
  {
222
263
  let tmpFlowViewIdentifier = this.options.FlowViewIdentifier;
@@ -321,6 +362,20 @@ class PictViewFlowToolbar extends libPictView
321
362
  {
322
363
  tmpAutoChevron[0].innerHTML = tmpIconProvider.getIconSVGMarkup('chevron-down', 8);
323
364
  }
365
+
366
+ // Host-supplied extra buttons (keyed by Hash, icon from the button's Icon key).
367
+ let tmpExtraButtons = this.options.ToolbarExtraButtons;
368
+ if (Array.isArray(tmpExtraButtons))
369
+ {
370
+ for (let i = 0; i < tmpExtraButtons.length; i++)
371
+ {
372
+ let tmpExtraIcon = this.pict.ContentAssignment.getElement(`#Flow-Toolbar-ExtraIcon-${tmpExtraButtons[i].Hash}-${tmpFlowViewIdentifier}`);
373
+ if (tmpExtraIcon.length > 0)
374
+ {
375
+ tmpExtraIcon[0].innerHTML = tmpIconProvider.getIconSVGMarkup(tmpExtraButtons[i].Icon, 14);
376
+ }
377
+ }
378
+ }
324
379
  }
325
380
 
326
381
  // ── Popup Management ──────────────────────────────────────────────────
@@ -548,12 +603,16 @@ class PictViewFlowToolbar extends libPictView
548
603
  let tmpResolvedKey = tmpIconProvider.resolveIconKey(tmpMeta);
549
604
  tmpIconHTML = tmpIconProvider.getIconSVGMarkup(tmpResolvedKey, 16);
550
605
  }
606
+ let tmpRowCode = tmpMeta.Code || '';
551
607
  tmpRows.push(
552
608
  {
553
609
  NodeType: tmpTypeKeys[i],
554
610
  Label: tmpTypeConfig.Label || '',
555
611
  IconHTML: tmpIconHTML,
556
- Code: tmpMeta.Code || '',
612
+ Code: tmpRowCode,
613
+ // Pre-rendered so the template renders it with {~D:~} (a nested {~D:~} inside {~NE:~} is
614
+ // not parsed by the engine).
615
+ CodeBlock: tmpRowCode ? ('<span class="pict-flow-popup-list-item-code">' + tmpRowCode + '</span>') : '',
557
616
  FlowViewIdentifier: tmpFlowViewIdentifier
558
617
  });
559
618
  }
@@ -682,16 +741,27 @@ class PictViewFlowToolbar extends libPictView
682
741
  tmpIconHTML = tmpIconProvider.getIconSVGMarkup('default', 14);
683
742
  }
684
743
 
744
+ // Pre-render each conditional span into a complete HTML block ('' when absent). The
745
+ // template renders these directly with {~D:~}; it cannot wrap them inline because the
746
+ // engine does not parse a nested {~D:~} inside a {~NE:~}.
747
+ let tmpCode = tmpMeta.Code || '';
748
+ let tmpSwatchColor = (!tmpIconHTML && !tmpIsEmoji && tmpCardConfig.TitleBarColor) ? tmpCardConfig.TitleBarColor : '';
749
+ let tmpIconBlock = '';
750
+ if (tmpIconHTML) { tmpIconBlock = '<span class="pict-flow-palette-card-icon">' + tmpIconHTML + '</span>'; }
751
+ else if (tmpIsEmoji) { tmpIconBlock = '<span class="pict-flow-palette-card-icon">' + tmpMeta.Icon + '</span>'; }
752
+ let tmpSwatchBlock = tmpSwatchColor ? ('<span class="pict-flow-palette-card-swatch" style="background-color: ' + tmpSwatchColor + ';"></span>') : '';
753
+ let tmpCodeBlock = tmpCode ? ('<span class="pict-flow-palette-card-code">' + tmpCode + '</span>') : '';
754
+
685
755
  tmpMatching.push(
686
756
  {
687
757
  CardType: tmpCardConfig.Hash,
688
758
  Label: tmpCardConfig.Label || '',
689
- Code: tmpMeta.Code || '',
690
- IconHTML: tmpIconHTML,
691
- IconEmoji: tmpIsEmoji ? tmpMeta.Icon : '',
759
+ Code: tmpCode,
760
+ IconBlock: tmpIconBlock,
761
+ SwatchBlock: tmpSwatchBlock,
762
+ CodeBlock: tmpCodeBlock,
692
763
  DisabledClass: (tmpMeta.Enabled === false) ? ' disabled' : '',
693
764
  Tooltip: tmpMeta.Tooltip || tmpMeta.Description || '',
694
- SwatchColor: (!tmpIconHTML && !tmpIsEmoji && tmpCardConfig.TitleBarColor) ? tmpCardConfig.TitleBarColor : '',
695
765
  FlowViewIdentifier: tmpFlowViewIdentifier
696
766
  });
697
767
  }
@@ -1677,7 +1747,8 @@ class PictViewFlowToolbar extends libPictView
1677
1747
  FlowViewIdentifier: tmpFlowViewIdentifier,
1678
1748
  DefaultDestinationAddress: `#Flow-FloatingToolbar-Container-${tmpFlowViewIdentifier}`,
1679
1749
  EnableAddNode: this.options.EnableAddNode,
1680
- EnableCardPalette: this.options.EnableCardPalette
1750
+ EnableCardPalette: this.options.EnableCardPalette,
1751
+ ToolbarExtraButtons: this.options.ToolbarExtraButtons
1681
1752
  }
1682
1753
  );
1683
1754
  this._FloatingToolbarView._ToolbarView = this;
@@ -1742,6 +1813,23 @@ class PictViewFlowToolbar extends libPictView
1742
1813
  * Handle a toolbar action
1743
1814
  * @param {string} pAction
1744
1815
  */
1816
+ /**
1817
+ * Handle a click on a host-supplied (ToolbarExtraButtons) button. Routes to
1818
+ * the FlowView's onToolbarButton hook with the button hash and the clicked
1819
+ * element (so the host can anchor a popover next to it).
1820
+ *
1821
+ * @param {string} pHash - The button's Hash
1822
+ * @param {HTMLElement} pElement - The clicked button element
1823
+ */
1824
+ _handleExtraAction(pHash, pElement)
1825
+ {
1826
+ if (!this._FlowView) return;
1827
+ if (typeof this._FlowView.options.onToolbarButton === 'function')
1828
+ {
1829
+ this._FlowView.options.onToolbarButton(pHash, pElement);
1830
+ }
1831
+ }
1832
+
1745
1833
  _handleToolbarAction(pAction)
1746
1834
  {
1747
1835
  if (!this._FlowView) return;
@@ -59,6 +59,11 @@ const _DefaultConfiguration =
59
59
  EnableZooming: true,
60
60
  EnableNodeDragging: true,
61
61
  EnableConnectionCreation: true,
62
+ // When on, a connection can be drawn between ANY two ports (any port can start a drag, any port can
63
+ // receive it), rather than only output -> input. Off by default so directed graphs (workflows) keep
64
+ // their source/target semantics; free-form canvases (a moodboard, whose links are undirected) turn it
65
+ // on so a card's ports connect in any direction.
66
+ EnableUndirectedConnections: false,
62
67
  // When on, the selected node shows a bottom-right grip that resizes it by drag. Off by default
63
68
  // so existing diagrams are unaffected; free-form canvases (moodboards) turn it on.
64
69
  EnableNodeResizing: false,
@@ -73,6 +78,15 @@ const _DefaultConfiguration =
73
78
  EnableAlignmentGuides: false,
74
79
  EnableLayoutMenu: true,
75
80
 
81
+ // Host-supplied toolbar buttons. Each entry is { Hash, Icon, Label?, Tooltip?, Active? } where Icon
82
+ // is a flow icon-provider key (edit, check, background, ...). They render in BOTH the docked and the
83
+ // floating toolbar (so they survive every toolbar mode) and, on click, fire onToolbarButton below.
84
+ // Empty by default, so existing consumers are unaffected.
85
+ ToolbarExtraButtons: [],
86
+ // Fired when a ToolbarExtraButtons button is clicked: onToolbarButton(pHash, pElement). The element
87
+ // lets the host anchor a popover next to the button. Off (false) by default.
88
+ onToolbarButton: false,
89
+
76
90
  MinZoom: 0.1,
77
91
  MaxZoom: 5.0,
78
92
  ZoomStep: 0.1,
@@ -562,7 +576,8 @@ class PictViewFlow extends libPictView
562
576
  DefaultDestinationAddress: `#Flow-Toolbar-${tmpViewIdentifier}`,
563
577
  FlowViewIdentifier: tmpViewIdentifier,
564
578
  EnableAddNode: this.options.EnableAddNode,
565
- EnableCardPalette: this.options.EnableCardPalette
579
+ EnableCardPalette: this.options.EnableCardPalette,
580
+ ToolbarExtraButtons: this.options.ToolbarExtraButtons
566
581
  }
567
582
  ));
568
583
  // Use the toolbar's render method after it's set up
@@ -1418,6 +1433,27 @@ class PictViewFlow extends libPictView
1418
1433
  let tmpSource = this.getPortPosition(tmpConnection.SourceNodeHash, tmpConnection.SourcePortHash);
1419
1434
  let tmpTarget = this.getPortPosition(tmpConnection.TargetNodeHash, tmpConnection.TargetPortHash);
1420
1435
  if (!tmpSource || !tmpTarget) return null;
1436
+ // A connection renders as a curve, so the straight-line average of its endpoints sits OFF the
1437
+ // line (the panel tether would point into empty space). Prefer the true midpoint of the rendered
1438
+ // path -- getPointAtLength at half its length, which is genuinely on the line. Fall back to the
1439
+ // endpoint average when the path element or SVG geometry is unavailable (e.g. server-side render).
1440
+ if (this._SVGElement && typeof this._SVGElement.querySelector === 'function')
1441
+ {
1442
+ let tmpPathElement = this._SVGElement.querySelector('.pict-flow-connection[data-connection-hash="' + pConnectionHash + '"]');
1443
+ if (tmpPathElement && typeof tmpPathElement.getTotalLength === 'function' && typeof tmpPathElement.getPointAtLength === 'function')
1444
+ {
1445
+ try
1446
+ {
1447
+ let tmpLength = tmpPathElement.getTotalLength();
1448
+ if (tmpLength > 0)
1449
+ {
1450
+ let tmpPoint = tmpPathElement.getPointAtLength(tmpLength / 2);
1451
+ return { x: tmpPoint.x, y: tmpPoint.y };
1452
+ }
1453
+ }
1454
+ catch (pError) { /* fall through to the straight-line midpoint */ }
1455
+ }
1456
+ }
1421
1457
  return { x: (tmpSource.x + tmpTarget.x) / 2, y: (tmpSource.y + tmpTarget.y) / 2 };
1422
1458
  }
1423
1459
 
@@ -0,0 +1,43 @@
1
+ const libChai = require('chai');
2
+ const libExpect = libChai.expect;
3
+
4
+ const libPictViewFlowToolbar = require('../source/views/PictView-Flow-Toolbar.js');
5
+
6
+ // The card palette and the add-node list render one row per card type. Each row's icon / swatch / code
7
+ // pieces are conditional, but the pict template engine does NOT parse a nested {~D:~} inside a {~NE:~}
8
+ // (the NE `~}` terminator collides with the inner tag's), which left the palette showing raw template
9
+ // literals like "{~D:Record.IconHTML ~}~};">~} Image". The fix pre-renders each piece into a complete
10
+ // HTML block in the popup builder and renders it with a plain {~D:~}. These tests guard that the
11
+ // broken pattern does not return.
12
+ suite('Flow card palette + add-node row templates',
13
+ function ()
14
+ {
15
+ function templateByHash(pHash)
16
+ {
17
+ let tmpTemplate = libPictViewFlowToolbar.default_configuration.Templates.find((pT) => pT.Hash === pHash);
18
+ return tmpTemplate ? tmpTemplate.Template : null;
19
+ }
20
+
21
+ // A {~NE:Addr^ ... {~ ... ~} ... ~} block: an NE whose content contains a nested template tag.
22
+ const _NESTED_NE = /\{~NE:[^^]+\^[^~]*\{~/;
23
+
24
+ test('the card-palette row renders pre-built blocks, with no nested tag inside an NE',
25
+ function ()
26
+ {
27
+ let tmpTemplate = templateByHash('Flow-Cards-Card');
28
+ libExpect(tmpTemplate, 'Flow-Cards-Card template exists').to.be.a('string');
29
+ libExpect(_NESTED_NE.test(tmpTemplate), 'no nested {~..~} inside an {~NE:~}').to.equal(false);
30
+ libExpect(tmpTemplate).to.contain('{~D:Record.IconBlock~}');
31
+ libExpect(tmpTemplate).to.contain('{~D:Record.SwatchBlock~}');
32
+ libExpect(tmpTemplate).to.contain('{~D:Record.CodeBlock~}');
33
+ });
34
+
35
+ test('the add-node row renders a pre-built code block, with no nested tag inside an NE',
36
+ function ()
37
+ {
38
+ let tmpTemplate = templateByHash('Flow-AddNode-Row');
39
+ libExpect(tmpTemplate, 'Flow-AddNode-Row template exists').to.be.a('string');
40
+ libExpect(_NESTED_NE.test(tmpTemplate), 'no nested {~..~} inside an {~NE:~}').to.equal(false);
41
+ libExpect(tmpTemplate).to.contain('{~D:Record.CodeBlock~}');
42
+ });
43
+ });
@@ -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,138 @@
1
+ const libChai = require('chai');
2
+ const libExpect = libChai.expect;
3
+
4
+ const libPictViewFlow = require('../source/views/PictView-Flow.js');
5
+ const libPictViewFlowToolbar = require('../source/views/PictView-Flow-Toolbar.js');
6
+ const libPictViewFlowFloatingToolbar = require('../source/views/PictView-Flow-FloatingToolbar.js');
7
+
8
+ // The toolbar custom-button API (ToolbarExtraButtons + onToolbarButton) lets a host add its own buttons
9
+ // to the flow toolbar. They render in both the docked and the floating toolbar (so they survive every
10
+ // toolbar mode) and, on click, fire the FlowView's onToolbarButton hook. These are harness-free unit
11
+ // tests: the render-time stamping and the click dispatch are prototype methods called against light
12
+ // stubs, so no DOM or full Pict app is needed.
13
+ suite('Flow Toolbar custom buttons (ToolbarExtraButtons)',
14
+ function ()
15
+ {
16
+ suite('default_configuration is additive + backward compatible',
17
+ function ()
18
+ {
19
+ test('the flow view defaults to no extra buttons and no hook',
20
+ function ()
21
+ {
22
+ libExpect(libPictViewFlow.default_configuration.ToolbarExtraButtons).to.be.an('array');
23
+ libExpect(libPictViewFlow.default_configuration.ToolbarExtraButtons.length).to.equal(0);
24
+ libExpect(libPictViewFlow.default_configuration.onToolbarButton).to.equal(false);
25
+ });
26
+
27
+ test('both toolbar views default to an empty extra-button list',
28
+ function ()
29
+ {
30
+ libExpect(libPictViewFlowToolbar.default_configuration.ToolbarExtraButtons).to.be.an('array');
31
+ libExpect(libPictViewFlowToolbar.default_configuration.ToolbarExtraButtons.length).to.equal(0);
32
+ libExpect(libPictViewFlowFloatingToolbar.default_configuration.ToolbarExtraButtons).to.be.an('array');
33
+ libExpect(libPictViewFlowFloatingToolbar.default_configuration.ToolbarExtraButtons.length).to.equal(0);
34
+ });
35
+
36
+ test('both toolbar templates render the extra-button group + row template',
37
+ function ()
38
+ {
39
+ let tmpDockedTemplates = libPictViewFlowToolbar.default_configuration.Templates;
40
+ let tmpDockedBar = tmpDockedTemplates.find((pT) => pT.Hash === 'Flow-Toolbar-Template');
41
+ let tmpDockedRow = tmpDockedTemplates.find((pT) => pT.Hash === 'Flow-Toolbar-Extra-Button');
42
+ libExpect(tmpDockedBar.Template).to.contain('{~TS:Flow-Toolbar-Extra-Button:Record.ToolbarExtraButtons~}');
43
+ libExpect(tmpDockedRow).to.be.an('object');
44
+ libExpect(tmpDockedRow.Template).to.contain('_handleExtraAction');
45
+
46
+ let tmpFloatTemplates = libPictViewFlowFloatingToolbar.default_configuration.Templates;
47
+ let tmpFloatBar = tmpFloatTemplates.find((pT) => pT.Hash === 'Flow-FloatingToolbar-Template');
48
+ let tmpFloatRow = tmpFloatTemplates.find((pT) => pT.Hash === 'Flow-FloatingToolbar-Extra-Button');
49
+ libExpect(tmpFloatBar.Template).to.contain('{~TS:Flow-FloatingToolbar-Extra-Button:Record.ToolbarExtraButtons~}');
50
+ libExpect(tmpFloatRow).to.be.an('object');
51
+ libExpect(tmpFloatRow.Template).to.contain('_handleExtraClick');
52
+ });
53
+ });
54
+
55
+ suite('_stampExtraButtons (render-time per-row fields)',
56
+ function ()
57
+ {
58
+ test('stamps FlowViewIdentifier and an empty ActiveClass onto each button',
59
+ function ()
60
+ {
61
+ let tmpStub =
62
+ {
63
+ options:
64
+ {
65
+ FlowViewIdentifier: 'MB-FlowView-7',
66
+ ToolbarExtraButtons: [ { Hash: 'background', Icon: 'background' }, { Hash: 'done', Icon: 'check' } ]
67
+ }
68
+ };
69
+ libPictViewFlowToolbar.prototype._stampExtraButtons.call(tmpStub);
70
+ libExpect(tmpStub.options.ToolbarExtraButtons[0].FlowViewIdentifier).to.equal('MB-FlowView-7');
71
+ libExpect(tmpStub.options.ToolbarExtraButtons[0].ActiveClass).to.equal('');
72
+ libExpect(tmpStub.options.ToolbarExtraButtons[1].FlowViewIdentifier).to.equal('MB-FlowView-7');
73
+ });
74
+
75
+ test('an Active button gets the active class',
76
+ function ()
77
+ {
78
+ let tmpStub =
79
+ {
80
+ options:
81
+ {
82
+ FlowViewIdentifier: 'Pict-Flow',
83
+ ToolbarExtraButtons: [ { Hash: 'edit', Icon: 'edit', Active: true } ]
84
+ }
85
+ };
86
+ libPictViewFlowToolbar.prototype._stampExtraButtons.call(tmpStub);
87
+ libExpect(tmpStub.options.ToolbarExtraButtons[0].ActiveClass).to.equal(' pict-flow-toolbar-btn-active');
88
+ });
89
+
90
+ test('a non-array extra-button option is tolerated',
91
+ function ()
92
+ {
93
+ let tmpStub = { options: { FlowViewIdentifier: 'Pict-Flow', ToolbarExtraButtons: false } };
94
+ libExpect(function () { libPictViewFlowToolbar.prototype._stampExtraButtons.call(tmpStub); }).to.not.throw();
95
+ });
96
+ });
97
+
98
+ suite('click dispatch fires onToolbarButton(hash, element)',
99
+ function ()
100
+ {
101
+ test('the docked toolbar routes a click to the FlowView hook with the element',
102
+ function ()
103
+ {
104
+ let tmpFired = [];
105
+ let tmpElement = { id: 'the-button' };
106
+ let tmpStub =
107
+ {
108
+ _FlowView: { options: { onToolbarButton: (pHash, pEl) => { tmpFired.push({ Hash: pHash, El: pEl }); } } }
109
+ };
110
+ libPictViewFlowToolbar.prototype._handleExtraAction.call(tmpStub, 'background', tmpElement);
111
+ libExpect(tmpFired.length).to.equal(1);
112
+ libExpect(tmpFired[0].Hash).to.equal('background');
113
+ libExpect(tmpFired[0].El).to.equal(tmpElement);
114
+ });
115
+
116
+ test('no hook configured is a no-op (does not throw)',
117
+ function ()
118
+ {
119
+ let tmpStub = { _FlowView: { options: { onToolbarButton: false } } };
120
+ libExpect(function () { libPictViewFlowToolbar.prototype._handleExtraAction.call(tmpStub, 'edit', {}); }).to.not.throw();
121
+ });
122
+
123
+ test('the floating toolbar routes its click through the docked toolbar dispatch',
124
+ function ()
125
+ {
126
+ let tmpSeen = [];
127
+ let tmpElement = { id: 'floating-button' };
128
+ let tmpFloatStub =
129
+ {
130
+ _ToolbarView: { _handleExtraAction: (pHash, pEl) => { tmpSeen.push({ Hash: pHash, El: pEl }); } }
131
+ };
132
+ libPictViewFlowFloatingToolbar.prototype._handleExtraClick.call(tmpFloatStub, 'done', tmpElement);
133
+ libExpect(tmpSeen.length).to.equal(1);
134
+ libExpect(tmpSeen[0].Hash).to.equal('done');
135
+ libExpect(tmpSeen[0].El).to.equal(tmpElement);
136
+ });
137
+ });
138
+ });
@@ -0,0 +1,70 @@
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
+ // EnableUndirectedConnections lets a connection be drawn between ANY two ports rather than only
9
+ // output -> input. A free-form canvas (a moodboard) turns it on so a card's ports connect in any
10
+ // direction; directed graphs (workflows) leave it off. These tests cover the start side of the drag
11
+ // (the gate that previously hard-required an output port). The completion side uses
12
+ // document.elementFromPoint, so it is exercised in the browser rather than here.
13
+ function makeFlowView(pUndirected)
14
+ {
15
+ return {
16
+ options: { EnableConnectionCreation: true, EnableUndirectedConnections: pUndirected },
17
+ // Returning null skips the drag-line DOM creation, so no document is needed.
18
+ getPortPosition: function () { return null; },
19
+ _ViewportElement: { appendChild: function () {} }
20
+ };
21
+ }
22
+
23
+ function makeManager(pFable, pFlowView)
24
+ {
25
+ let tmpManager = new libInteractionManager(pFable, { FlowView: pFlowView }, 'IM-Test');
26
+ tmpManager._SVGElement = { classList: { add: function () {}, remove: function () {} } };
27
+ return tmpManager;
28
+ }
29
+
30
+ function makePort(pNodeHash, pPortHash, pDirection)
31
+ {
32
+ let tmpAttrs = { 'data-node-hash': pNodeHash, 'data-port-hash': pPortHash, 'data-port-direction': pDirection };
33
+ return { getAttribute: function (pName) { return Object.prototype.hasOwnProperty.call(tmpAttrs, pName) ? tmpAttrs[pName] : null; } };
34
+ }
35
+
36
+ suite('Flow undirected connections (EnableUndirectedConnections)',
37
+ function ()
38
+ {
39
+ let _Fable;
40
+ setup(function () { _Fable = new libFable({}); });
41
+
42
+ test('directed mode (default): an input port does NOT start a connection',
43
+ function ()
44
+ {
45
+ let tmpManager = makeManager(_Fable, makeFlowView(false));
46
+ tmpManager._startConnection({ stopPropagation: function () {} }, makePort('n1', 'p1', 'input'));
47
+ libExpect(tmpManager._State).to.not.equal(STATES.CONNECTING);
48
+ });
49
+
50
+ test('undirected mode: an input port DOES start a connection',
51
+ function ()
52
+ {
53
+ let tmpManager = makeManager(_Fable, makeFlowView(true));
54
+ tmpManager._startConnection({ stopPropagation: function () {} }, makePort('n1', 'p1', 'input'));
55
+ libExpect(tmpManager._State).to.equal(STATES.CONNECTING);
56
+ libExpect(tmpManager._ConnectSourceNodeHash).to.equal('n1');
57
+ libExpect(tmpManager._ConnectSourcePortHash).to.equal('p1');
58
+ });
59
+
60
+ test('an output port starts a connection in either mode',
61
+ function ()
62
+ {
63
+ [ false, true ].forEach(function (pUndirected)
64
+ {
65
+ let tmpManager = makeManager(_Fable, makeFlowView(pUndirected));
66
+ tmpManager._startConnection({ stopPropagation: function () {} }, makePort('n1', 'p1', 'output'));
67
+ libExpect(tmpManager._State).to.equal(STATES.CONNECTING);
68
+ });
69
+ });
70
+ });