@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 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
- const existingGroup = document.getElementById('nru_popup.' + node.id + '.group');
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
- const problem = ['width', 'height', 'autoCloseTime']
159
- .find(key => typeof config[key] !== 'number' || !(config[key] >= 0));
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
- console.error(`Popup of node ${node.id}: nru_popupConfig.${problem} must be a number, 0 or more; got`, config[problem]);
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 a number, 0 or more.`, 'warning');
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
- let group = document.createElementNS("http://www.w3.org/2000/svg", "g");
184
- group.setAttribute("id", "nru_popup." + node.id + ".group");
185
- group.setAttribute("transform", "translate(0, 50)");
186
- group.style.display = (window.popupStates[node.id] === 'open') ? 'block' : 'none';
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
- // Create base elements
189
- let rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
190
- rect.setAttribute("id", "nru_popup." + node.id + ".rect");
191
- rect.setAttribute("x", "0");
192
- rect.setAttribute("y", "0");
193
- rect.setAttribute("width", width);
194
- rect.setAttribute("height", height);
195
- rect.setAttribute("fill", "#f1f1f1");
196
- rect.setAttribute("stroke", "#d4d4d4");
197
- rect.setAttribute("stroke-width", "1");
198
- rect.setAttribute("rx", "0");
199
-
200
- let foreignObject = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
201
- foreignObject.setAttribute("id", "nru_popup." + node.id + ".foreignObject");
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
- // Create resize handle as a small path in bottom right
221
- const resizeHandle = document.createElementNS("http://www.w3.org/2000/svg", "path");
222
- resizeHandle.setAttribute("id", "nru_popup." + node.id + ".resizeHandle");
223
- resizeHandle.setAttribute("d", `M${width - 6},${height} l6,-6 l0,6 z`);
224
- resizeHandle.setAttribute("fill", "#b3b3b3 ");
225
- resizeHandle.setAttribute("stroke", "#b3b3b3 ");
226
- resizeHandle.setAttribute("style", "cursor: se-resize");
227
-
228
- // Create status bar rectangle
229
- let statusBar = document.createElementNS("http://www.w3.org/2000/svg", "rect");
230
- statusBar.setAttribute("id", "nru_popup." + node.id + ".statusBar");
231
- statusBar.setAttribute("x", "0");
232
- statusBar.setAttribute("y", (parseInt(rect.getAttribute("height")) - 20).toString());
233
- statusBar.setAttribute("width", width);
234
- statusBar.setAttribute("height", "20"); // Height of status bar
235
- statusBar.setAttribute("fill", "#e3e3e3"); // Light gray background
236
- statusBar.setAttribute("stroke", "#d4d4d4");
237
- statusBar.setAttribute("stroke-width", "1");
238
-
239
- // Create text element for node info
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
- // Add elements to group
258
- group.appendChild(rect);
259
- group.appendChild(statusBar);
260
- group.appendChild(statusText);
261
- group.appendChild(foreignObject);
262
- group.appendChild(resizeHandle);
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 isHidden = group.style.display === 'none';
299
- group.style.display = isHidden ? 'block' : 'none';
300
- // Save the popup state
301
- window.popupStates[node.id] = isHidden ? 'open' : 'closed';
302
- if (isHidden) {
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 {dx, dy} = {dx: event.clientX - startX, dy: event.clientY - startY};
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
- statusBar.setAttribute('width', newWidth);
337
- statusBar.setAttribute("y", (newHeight - 20).toString());
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
- node.nru_popupConfig = {
356
- width: newWidth,
357
- height: newHeight,
358
- autoCloseTime: autoCloseTime
359
- };
360
- node.changed = true;
361
- RED.nodes.dirty(true);
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 {
@@ -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
- All three values must be numbers, 0 or more. 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
+ `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
- When the user resizes a popup, the new size goes into the node's `nru_popupConfig`, so it is saved with the flow.
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