@nrutils/node-red-utils 0.1.0 → 0.2.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/CHANGELOG.md +5 -0
- package/README.md +6 -0
- package/node-red-popup/NodeRedPopupFrontend.js +190 -96
- package/node-red-popup/README.md +6 -3
- package/node-red-worker/NodeRedWorker.js +443 -438
- package/node-red-worker/NodeRedWorkerRuntime.js +658 -650
- package/node-red-worker/README.md +9 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 (2026-11-10)
|
|
4
|
+
|
|
5
|
+
- `nodeRedWorker`: a throw (or a rejected Promise) in a worker `subscribe` handler goes to the new `worker.handlerError(error, type, data)`, with the data of the message. The default reports it on the node with `node.error`, so a Catch node gets it, instead of `console.error`. The next handlers of the same message still run. A worker file can replace `worker.handlerError`.
|
|
6
|
+
- `nodeRedPopup`: the popup unfurls from its node with a spring animation. The user moves it by its status bar; the position is saved in `nru_popupConfig` (`x`, `y`) and a dashed line ties it to the node. A resize follows the zoom of the canvas. The popup is a rounded card in the editor theme (light or dark), with the color of its node on the top edge and the tether line, dots on the status bar that show it can be moved, and a larger resize corner.
|
|
7
|
+
|
|
3
8
|
## 0.1.0
|
|
4
9
|
|
|
5
10
|
First public release.
|
package/README.md
CHANGED
|
@@ -23,6 +23,10 @@ npm install @nrutils/node-red-utils
|
|
|
23
23
|
|
|
24
24
|
Works with Node-RED 4 and Node.js 18 or later.
|
|
25
25
|
|
|
26
|
+
## Building with AI
|
|
27
|
+
|
|
28
|
+
Use an AI coding assistant to build nodes! Here's some [AI dreamed up paste-ready prompts](./prompts/README.md). Not particularly useful nodes though.
|
|
29
|
+
|
|
26
30
|
## Example: a counter node
|
|
27
31
|
|
|
28
32
|
A node that counts messages, shows the count as its status, and resets the count when you click its button in the editor.
|
|
@@ -180,3 +184,5 @@ Issues and pull requests are welcome on [GitHub](https://github.com/UpDownMe733/
|
|
|
180
184
|
## License
|
|
181
185
|
|
|
182
186
|
[Apache-2.0](./LICENSE)
|
|
187
|
+
|
|
188
|
+
<small>[Sponsor this project](https://github.com/sponsors/UpDownMe733)</small>
|
|
@@ -125,11 +125,7 @@ if (!window.popupInited) {
|
|
|
125
125
|
const removePopups = async (workspace) => {
|
|
126
126
|
window.popupEventTracker.removeAllListeners();
|
|
127
127
|
RED.nodes.filterNodes( {z:workspace} ).forEach ( node => {
|
|
128
|
-
|
|
129
|
-
if (existingGroup) {
|
|
130
|
-
window.popupStates[node.id] = existingGroup.style.display === 'none' ? 'closed' : 'open';
|
|
131
|
-
existingGroup.remove()
|
|
132
|
-
}
|
|
128
|
+
document.getElementById('nru_popup.' + node.id + '.group')?.remove();
|
|
133
129
|
});
|
|
134
130
|
}
|
|
135
131
|
|
|
@@ -147,7 +143,15 @@ if (!window.popupInited) {
|
|
|
147
143
|
|
|
148
144
|
// Smallest popup size in pixels. Smaller sizes are made this size.
|
|
149
145
|
const MIN_WIDTH = 100, MIN_HEIGHT = 50;
|
|
146
|
+
// Where a popup sits under its node until the user moves it
|
|
147
|
+
const DEFAULT_X = 0, DEFAULT_Y = 50;
|
|
148
|
+
// Look of the card: the radius of the corners, the height of the status bar, and the space
|
|
149
|
+
// at the right of the status bar that the text must not use (move dots and resize lines)
|
|
150
|
+
let clipCount = 0;
|
|
151
|
+
const CORNER = 8, STATUS_HEIGHT = 20, STATUS_TEXT_MARGIN = 48;
|
|
150
152
|
const warnedTypes = new Set();
|
|
153
|
+
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
154
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
151
155
|
|
|
152
156
|
const createPopup = async ( node ) => {
|
|
153
157
|
|
|
@@ -155,13 +159,16 @@ if (!window.popupInited) {
|
|
|
155
159
|
|
|
156
160
|
// The node's own config (saved after a resize) wins over the default of the type
|
|
157
161
|
const config = { ...node._def.defaults.nru_popupConfig.value, ...node.nru_popupConfig };
|
|
158
|
-
|
|
159
|
-
|
|
162
|
+
config.x ??= DEFAULT_X;
|
|
163
|
+
config.y ??= DEFAULT_Y;
|
|
164
|
+
const problem = ['width', 'height', 'autoCloseTime', 'x', 'y']
|
|
165
|
+
.find(key => !Number.isFinite(config[key]) || (config[key] < 0 && !['x', 'y'].includes(key)));
|
|
160
166
|
if (problem) {
|
|
161
|
-
|
|
167
|
+
const rule = ['x', 'y'].includes(problem) ? 'a number' : 'a number, 0 or more';
|
|
168
|
+
console.error(`Popup of node ${node.id}: nru_popupConfig.${problem} must be ${rule}; got`, config[problem]);
|
|
162
169
|
if (!warnedTypes.has(node.type)) {
|
|
163
170
|
warnedTypes.add(node.type);
|
|
164
|
-
RED.notify(`No popup for "${node.type}" nodes: nru_popupConfig.${problem} must be
|
|
171
|
+
RED.notify(`No popup for "${node.type}" nodes: nru_popupConfig.${problem} must be ${rule}.`, 'warning');
|
|
165
172
|
}
|
|
166
173
|
return;
|
|
167
174
|
}
|
|
@@ -180,88 +187,143 @@ if (!window.popupInited) {
|
|
|
180
187
|
const autoCloseTime = config.autoCloseTime;
|
|
181
188
|
|
|
182
189
|
const svgElement = document.getElementById(node.id);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
190
|
+
const accent = node._def.color || '#999'; // the popup wears the color of its node
|
|
191
|
+
const lineColor = `color-mix(in srgb, ${accent} 65%, #000)`; // light node colors would not show on the canvas
|
|
192
|
+
const theme = (name, fallback) => `var(--red-ui-${name}, ${fallback})`; // follows the light and dark editor themes
|
|
193
|
+
const make = (tag, attrs = {}) => {
|
|
194
|
+
const element = document.createElementNS(SVG_NS, tag);
|
|
195
|
+
for (const [name, value] of Object.entries(attrs)) element.setAttribute(name, value);
|
|
196
|
+
return element;
|
|
197
|
+
};
|
|
187
198
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
199
|
+
let group = make("g", { id: "nru_popup." + node.id + ".group" });
|
|
200
|
+
group.style.display = (window.popupStates[node.id] === 'open') ? 'block' : 'none';
|
|
201
|
+
group.style.filter = 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.25)) drop-shadow(0 8px 14px rgba(0, 0, 0, 0.2))';
|
|
202
|
+
|
|
203
|
+
// The body holds everything that is drawn. It unfurls from the node on open, so the
|
|
204
|
+
// group keeps its own transform and display.
|
|
205
|
+
const frame = make("g", { transform: `translate(${config.x}, ${config.y})` }); // the position of the popup
|
|
206
|
+
const clipId = 'nru_popup_clip_' + (clipCount++); // not made from the node id: an id with a space would break url()
|
|
207
|
+
const body = make("g", { "clip-path": `url(#${clipId})` }); // rounded corners
|
|
208
|
+
body.style.transformOrigin = (parseFloat(rectElement.getAttribute('width')) / 2) + "px 0px";
|
|
209
|
+
|
|
210
|
+
const clipRect = make("rect", { x: 0, y: 0, rx: CORNER });
|
|
211
|
+
const clipPath = make("clipPath", { id: clipId });
|
|
212
|
+
clipPath.appendChild(clipRect);
|
|
213
|
+
|
|
214
|
+
const rect = make("rect", { id: "nru_popup." + node.id + ".rect", x: 0, y: 0, rx: CORNER });
|
|
215
|
+
rect.style.fill = theme('secondary-background', '#fff');
|
|
216
|
+
const accentBar = make("rect", { x: 0, y: 0, height: 4 });
|
|
217
|
+
accentBar.style.fill = lineColor;
|
|
218
|
+
const outline = make("rect", { x: 0.5, y: 0.5, rx: CORNER, fill: "none" });
|
|
219
|
+
outline.style.stroke = theme('primary-border-color', '#d4d4d4');
|
|
220
|
+
outline.style.pointerEvents = 'none'; // else it takes the mouse from the status bar
|
|
221
|
+
|
|
222
|
+
const foreignObject = make("foreignObject", { id: "nru_popup." + node.id + ".foreignObject", x: 6, y: 6 });
|
|
202
223
|
const sourceText = svgElement.querySelector('.red-ui-flow-node-status-label');
|
|
203
224
|
for (const attr of sourceText.attributes) {
|
|
204
225
|
foreignObject.setAttribute(attr.name, attr.value)
|
|
205
226
|
};
|
|
206
227
|
foreignObject.setAttribute("x", "6");
|
|
207
228
|
foreignObject.setAttribute("y", "6");
|
|
208
|
-
foreignObject.setAttribute("width", width - 12);
|
|
209
|
-
foreignObject.setAttribute("height", height - 20 - 12); // Leave room for status bar
|
|
210
229
|
|
|
211
230
|
let divElement = document.createElement("div");
|
|
212
231
|
divElement.setAttribute("id", "nru_popup." + node.id + ".div");
|
|
213
|
-
divElement.style.width = (width - 12) + "px";
|
|
214
|
-
divElement.style.height = (height - 20 - 12) + "px";
|
|
215
232
|
divElement.style.margin = "0";
|
|
216
233
|
divElement.style.overflow = "auto";
|
|
217
234
|
divElement.style.boxSizing = "border-box";
|
|
235
|
+
divElement.style.color = theme('primary-text-color', '#222');
|
|
218
236
|
foreignObject.appendChild(divElement);
|
|
219
237
|
|
|
220
|
-
//
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
//
|
|
240
|
-
let statusText = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
241
|
-
statusText.setAttribute("id", "nru_popup." + node.id + ".statusText");
|
|
242
|
-
statusText.setAttribute("x", "6");
|
|
243
|
-
statusText.setAttribute("y", (parseInt(rect.getAttribute("height")) - 6).toString());
|
|
244
|
-
statusText.setAttribute("font-family", "Arial, Helvetica, sans-serif");
|
|
245
|
-
statusText.setAttribute("font-size", "10");
|
|
246
|
-
statusText.setAttribute("stroke-width", "0.2");
|
|
247
|
-
statusText.setAttribute("fill", "#222");
|
|
238
|
+
// The status bar is the handle to move the popup. Dots say so.
|
|
239
|
+
const statusBar = make("rect", { id: "nru_popup." + node.id + ".statusBar", x: 0, height: STATUS_HEIGHT });
|
|
240
|
+
statusBar.style.fill = theme('secondary-background-hover', '#e8e8e8');
|
|
241
|
+
statusBar.style.stroke = theme('primary-border-color', '#d4d4d4');
|
|
242
|
+
statusBar.style.cursor = 'grab';
|
|
243
|
+
const moveDots = make("path", { d: "M0,-4 h0 M0,0 h0 M0,4 h0 M5,-4 h0 M5,0 h0 M5,4 h0", "stroke-width": 2, "stroke-linecap": "round" });
|
|
244
|
+
moveDots.style.stroke = theme('secondary-text-color', '#555');
|
|
245
|
+
moveDots.style.pointerEvents = 'none';
|
|
246
|
+
|
|
247
|
+
// The resize handle is a square that catches the mouse; the lines on it only show where it is
|
|
248
|
+
const resizeHandle = make("rect", { id: "nru_popup." + node.id + ".resizeHandle", width: 24, height: 24, fill: "transparent" });
|
|
249
|
+
resizeHandle.style.stroke = 'none';
|
|
250
|
+
resizeHandle.style.cursor = 'se-resize';
|
|
251
|
+
const resizeLines = make("path", { "stroke-width": 1.5, "stroke-linecap": "round" });
|
|
252
|
+
resizeLines.style.stroke = theme('secondary-text-color', '#555');
|
|
253
|
+
resizeLines.style.pointerEvents = 'none';
|
|
254
|
+
|
|
255
|
+
const statusText = make("text", { id: "nru_popup." + node.id + ".statusText", x: 8, "font-family": "Arial, Helvetica, sans-serif", "font-size": 10 });
|
|
256
|
+
statusText.style.fill = theme('secondary-text-color', '#555');
|
|
257
|
+
statusText.style.pointerEvents = 'none'; // so that a drag on the text moves the popup
|
|
248
258
|
statusText.textContent = '';
|
|
249
259
|
statusText.fullText = '';
|
|
250
260
|
statusText.setStatusText = function( text ) {
|
|
251
|
-
const width = parseInt(rect.getAttribute('width'));
|
|
252
261
|
statusText.textContent = text;
|
|
253
262
|
statusText.fullText = text;
|
|
254
|
-
sizeTextToFit( statusText, width);
|
|
263
|
+
sizeTextToFit( statusText, parseInt(rect.getAttribute('width')) - STATUS_TEXT_MARGIN );
|
|
255
264
|
};
|
|
256
265
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
266
|
+
body.append(rect, accentBar, statusBar, moveDots, statusText, foreignObject, outline, resizeLines, resizeHandle);
|
|
267
|
+
frame.appendChild(body);
|
|
268
|
+
|
|
269
|
+
// A line that ties the popup to its node, wherever the user puts it, and a dot where it starts
|
|
270
|
+
const tether = make("line", { "stroke-dasharray": "3 3", "stroke-width": 1.5 });
|
|
271
|
+
tether.style.stroke = lineColor;
|
|
272
|
+
const anchor = make("circle", { r: 3.5 });
|
|
273
|
+
anchor.style.fill = lineColor;
|
|
274
|
+
tether.style.pointerEvents = anchor.style.pointerEvents = 'none';
|
|
275
|
+
group.append(clipPath, tether, anchor, frame);
|
|
263
276
|
svgElement.appendChild(group);
|
|
264
277
|
|
|
278
|
+
// The tether runs from the bottom middle of the node to the nearest point of the popup
|
|
279
|
+
let popupX = config.x, popupY = config.y;
|
|
280
|
+
const placeTether = () => {
|
|
281
|
+
const nodeMidX = parseFloat(rectElement.getAttribute('width')) / 2;
|
|
282
|
+
const nodeBottom = parseFloat(rectElement.getAttribute('height'));
|
|
283
|
+
const popupWidth = parseFloat(rect.getAttribute('width'));
|
|
284
|
+
const popupHeight = parseFloat(rect.getAttribute('height'));
|
|
285
|
+
tether.setAttribute("x1", nodeMidX);
|
|
286
|
+
tether.setAttribute("y1", nodeBottom);
|
|
287
|
+
tether.setAttribute("x2", Math.min(Math.max(nodeMidX, popupX), popupX + popupWidth));
|
|
288
|
+
tether.setAttribute("y2", Math.min(Math.max(nodeBottom, popupY), popupY + popupHeight));
|
|
289
|
+
anchor.setAttribute("cx", nodeMidX);
|
|
290
|
+
anchor.setAttribute("cy", nodeBottom);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// Put every part at the right place for a popup of this size
|
|
294
|
+
const layout = (w, h) => {
|
|
295
|
+
for (const element of [rect, clipRect, statusBar]) element.setAttribute('width', w);
|
|
296
|
+
for (const element of [rect, clipRect]) element.setAttribute('height', h);
|
|
297
|
+
outline.setAttribute('width', w - 1);
|
|
298
|
+
outline.setAttribute('height', h - 1);
|
|
299
|
+
accentBar.setAttribute('width', w);
|
|
300
|
+
statusBar.setAttribute('y', h - STATUS_HEIGHT);
|
|
301
|
+
statusText.setAttribute('y', h - 6);
|
|
302
|
+
moveDots.setAttribute('transform', `translate(${w - 40}, ${h - STATUS_HEIGHT / 2})`);
|
|
303
|
+
foreignObject.setAttribute('width', w - 12);
|
|
304
|
+
foreignObject.setAttribute('height', h - STATUS_HEIGHT - 12); // total height - status bar - top and bottom padding
|
|
305
|
+
divElement.style.width = (w - 12) + 'px';
|
|
306
|
+
divElement.style.height = (h - STATUS_HEIGHT - 12) + 'px';
|
|
307
|
+
resizeHandle.setAttribute('x', w - 24);
|
|
308
|
+
resizeHandle.setAttribute('y', h - 24);
|
|
309
|
+
resizeLines.setAttribute('d', [6, 10, 14].map(k => `M${w - 4},${h - k} L${w - k},${h - 4}`).join(' '));
|
|
310
|
+
placeTether();
|
|
311
|
+
};
|
|
312
|
+
layout(width, height);
|
|
313
|
+
|
|
314
|
+
// The size and position go into the node, so they are saved with the flow
|
|
315
|
+
const saveConfig = () => {
|
|
316
|
+
node.nru_popupConfig = {
|
|
317
|
+
width: parseFloat(rect.getAttribute('width')),
|
|
318
|
+
height: parseFloat(rect.getAttribute('height')),
|
|
319
|
+
autoCloseTime: autoCloseTime,
|
|
320
|
+
x: popupX,
|
|
321
|
+
y: popupY
|
|
322
|
+
};
|
|
323
|
+
node.changed = true;
|
|
324
|
+
RED.nodes.dirty(true);
|
|
325
|
+
};
|
|
326
|
+
|
|
265
327
|
// Resize functionality
|
|
266
328
|
function sizeTextToFit( svgTextObject, width ) {
|
|
267
329
|
while ( svgTextObject.getBBox().width < width - 5 && svgTextObject.textContent.length !== svgTextObject.fullText.length ) {
|
|
@@ -275,6 +337,27 @@ if (!window.popupInited) {
|
|
|
275
337
|
//---------------------------------------------------------------------------------------------
|
|
276
338
|
// Event Handlers
|
|
277
339
|
//---------------------------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
let bodyAnimation = null;
|
|
342
|
+
const setOpen = (open) => {
|
|
343
|
+
bodyAnimation?.cancel();
|
|
344
|
+
window.popupStates[node.id] = open ? 'open' : 'closed';
|
|
345
|
+
const duration = reduceMotion.matches ? 0 : (open ? 260 : 120);
|
|
346
|
+
if (open) {
|
|
347
|
+
group.style.display = 'block';
|
|
348
|
+
svgElement.parentNode.appendChild(svgElement); // bring to front
|
|
349
|
+
statusText.setStatusText(statusText.fullText); // text set while hidden is not cut to fit
|
|
350
|
+
bodyAnimation = body.animate(
|
|
351
|
+
[{ transform: 'scale(0.4)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }],
|
|
352
|
+
{ duration, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' }); // overshoots, then settles
|
|
353
|
+
} else {
|
|
354
|
+
bodyAnimation = body.animate(
|
|
355
|
+
[{ transform: 'scale(1)', opacity: 1 }, { transform: 'scale(0.4)', opacity: 0 }],
|
|
356
|
+
{ duration, easing: 'ease-in' });
|
|
357
|
+
bodyAnimation.onfinish = () => { group.style.display = 'none'; };
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
278
361
|
let clickTimer = null;
|
|
279
362
|
let autoCloseTimeout = null;
|
|
280
363
|
let mouseDownX = 0, mouseDownY = 0;
|
|
@@ -295,16 +378,11 @@ if (!window.popupInited) {
|
|
|
295
378
|
return;
|
|
296
379
|
}
|
|
297
380
|
clickTimer = setTimeout(() => {
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
svgElement.parentNode.appendChild(svgElement);
|
|
304
|
-
if (autoCloseTime > 0) {
|
|
305
|
-
clearTimeout(autoCloseTimeout);
|
|
306
|
-
autoCloseTimeout = setTimeout(() => { group.style.display = 'none';}, autoCloseTime);
|
|
307
|
-
}
|
|
381
|
+
const opening = window.popupStates[node.id] !== 'open';
|
|
382
|
+
setOpen(opening);
|
|
383
|
+
if (opening && autoCloseTime > 0) {
|
|
384
|
+
clearTimeout(autoCloseTimeout);
|
|
385
|
+
autoCloseTimeout = setTimeout(() => setOpen(false), autoCloseTime);
|
|
308
386
|
}
|
|
309
387
|
clickTimer = null;
|
|
310
388
|
}, 250);
|
|
@@ -330,20 +408,12 @@ if (!window.popupInited) {
|
|
|
330
408
|
let newWidth = 0, newHeight = 0, resized = false;
|
|
331
409
|
const resize = (event)=> {
|
|
332
410
|
resized = true;
|
|
333
|
-
const
|
|
411
|
+
const scale = RED.view.scale(); // the canvas zoom
|
|
412
|
+
const {dx, dy} = {dx: (event.clientX - startX) / scale, dy: (event.clientY - startY) / scale};
|
|
334
413
|
newWidth = Math.max(MIN_WIDTH, startWidth + dx);
|
|
335
414
|
newHeight = Math.max(MIN_HEIGHT, startHeight + dy);
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
statusText.setAttribute("y", (newHeight - 6).toString());
|
|
339
|
-
sizeTextToFit( statusText, newWidth );
|
|
340
|
-
rect.setAttribute('width', newWidth);
|
|
341
|
-
rect.setAttribute('height', newHeight);
|
|
342
|
-
foreignObject.setAttribute('width', newWidth - 12);
|
|
343
|
-
foreignObject.setAttribute('height', newHeight - 20 - 6 - 6); // total height - statusbar height - top padding - bottom padding
|
|
344
|
-
divElement.style.width = (newWidth - 12) + 'px';
|
|
345
|
-
divElement.style.height = (newHeight - 20 - 12) + 'px';
|
|
346
|
-
resizeHandle.setAttribute("d", `M${newWidth - 6},${newHeight} l6,-6 l0,6 z`);
|
|
415
|
+
layout(newWidth, newHeight);
|
|
416
|
+
sizeTextToFit( statusText, newWidth - STATUS_TEXT_MARGIN );
|
|
347
417
|
}
|
|
348
418
|
|
|
349
419
|
const stopResize = () => {
|
|
@@ -352,18 +422,42 @@ if (!window.popupInited) {
|
|
|
352
422
|
|
|
353
423
|
// A click with no drag does not change the size
|
|
354
424
|
if (!resized) return;
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
425
|
+
saveConfig();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Drag the status bar to move the popup
|
|
429
|
+
let moveStartX, moveStartY, moveFromX, moveFromY, moved = false;
|
|
430
|
+
let docMoveMouseMoveListener, docMoveMouseUpListener;
|
|
431
|
+
const statusBarMouseDown = (event) => {
|
|
432
|
+
[moveStartX, moveStartY, moveFromX, moveFromY] = [event.clientX, event.clientY, popupX, popupY];
|
|
433
|
+
moved = false;
|
|
434
|
+
statusBar.style.cursor = 'grabbing';
|
|
435
|
+
docMoveMouseMoveListener = window.popupEventTracker.addListener(document, 'mousemove', move);
|
|
436
|
+
docMoveMouseUpListener = window.popupEventTracker.addListener(document, 'mouseup', stopMove);
|
|
437
|
+
event.preventDefault();
|
|
438
|
+
event.stopPropagation();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const move = (event) => {
|
|
442
|
+
moved = true;
|
|
443
|
+
const scale = RED.view.scale();
|
|
444
|
+
popupX = moveFromX + (event.clientX - moveStartX) / scale;
|
|
445
|
+
popupY = moveFromY + (event.clientY - moveStartY) / scale;
|
|
446
|
+
frame.setAttribute("transform", `translate(${popupX}, ${popupY})`);
|
|
447
|
+
placeTether();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const stopMove = () => {
|
|
451
|
+
window.popupEventTracker.removeListener(docMoveMouseMoveListener);
|
|
452
|
+
window.popupEventTracker.removeListener(docMoveMouseUpListener);
|
|
453
|
+
statusBar.style.cursor = 'grab';
|
|
454
|
+
if (moved) saveConfig();
|
|
362
455
|
}
|
|
363
456
|
|
|
364
457
|
window.popupEventTracker.addListener(rectElement, "mousedown", handleRectElementMouseDown);
|
|
365
458
|
window.popupEventTracker.addListener(rectElement, "click", handleRectElementClick);
|
|
366
459
|
window.popupEventTracker.addListener(resizeHandle, "mousedown", resizeHandleMouseDown);
|
|
460
|
+
window.popupEventTracker.addListener(statusBar, "mousedown", statusBarMouseDown);
|
|
367
461
|
|
|
368
462
|
// Run the node's nru_popupInit function
|
|
369
463
|
try {
|
package/node-red-popup/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
EXPERIMENTAL. The API and the behavior can change in a minor release.
|
|
4
4
|
|
|
5
|
-
A small resizable panel attached to a node in the editor. A click on the node opens or closes it.
|
|
5
|
+
A small resizable, movable panel attached to a node in the editor. A click on the node opens or closes it. The panel unfurls from the node.
|
|
6
6
|
|
|
7
7
|
## Usage
|
|
8
8
|
|
|
@@ -88,12 +88,13 @@ A node gets a popup only if `defaults` has `nru_popupConfig`:
|
|
|
88
88
|
- `width`: pixels, for the full popup. The minimum is 100; a smaller value gives 100.
|
|
89
89
|
- `height`: pixels, for the full popup, with the 20 px status bar. The minimum is 50; a smaller value gives 50.
|
|
90
90
|
- `autoCloseTime`: milliseconds until the popup hides itself. `0` keeps it open.
|
|
91
|
+
- `x`, `y`: optional. The position of the popup's top left corner, in pixels from the top left corner of the node. Negative numbers are allowed. The default is `0` and `50`: under the node.
|
|
91
92
|
|
|
92
93
|
The content area (`nru_popup.<nodeId>.div`) is 12 px narrower and 32 px lower than the popup. A resize by the user cannot go below 100 x 50 either.
|
|
93
94
|
|
|
94
|
-
|
|
95
|
+
`width`, `height` and `autoCloseTime` must be numbers, 0 or more. `x` and `y` must be numbers. If one is not, the node gets no popup, the editor shows a warning one time for the node type, and the browser console names the bad value.
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
The user drags the status bar to move a popup, and drags the bottom right corner to resize it. Dots on the status bar show that it moves. A dashed line ties the popup to its node, wherever it is. The new position or size goes into the node's `nru_popupConfig`, so it is saved with the flow. A move or resize follows the zoom of the canvas.
|
|
97
98
|
|
|
98
99
|
## Elements
|
|
99
100
|
|
|
@@ -104,6 +105,8 @@ Each popup creates elements with these ids:
|
|
|
104
105
|
|
|
105
106
|
## Behavior
|
|
106
107
|
|
|
108
|
+
- The popup follows the editor theme (light or dark). The top edge and the dashed line take the `color` of the node type.
|
|
109
|
+
- With `prefers-reduced-motion`, the popup opens and closes without animation.
|
|
107
110
|
- The popup is rebuilt when you change tabs, add the node, or deploy. `nru_popupInit` runs each time, so make it safe to call again.
|
|
108
111
|
- The script draws into the editor's SVG (the node's `<g id="<nodeId>">` and `#red-ui-workspace`). If a future editor changes that structure, the popup can break. It was written for the Node-RED 4.1 editor.
|
|
109
112
|
|