@nrutils/node-red-utils 0.0.0-stage → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +190 -0
  3. package/README.md +187 -2
  4. package/index.js +50 -0
  5. package/node-red-base/NodeRedNodeBase.js +143 -0
  6. package/node-red-base/README.md +215 -0
  7. package/node-red-editorcomms/EditorCommsBackend.js +155 -0
  8. package/node-red-editorcomms/EditorCommsFrontend.js +105 -0
  9. package/node-red-editorcomms/README.md +164 -0
  10. package/node-red-gpu/GpuBuffer.js +29 -0
  11. package/node-red-gpu/Kernel.js +35 -0
  12. package/node-red-gpu/README.md +220 -0
  13. package/node-red-gpu/backends/CpuBackend.js +109 -0
  14. package/node-red-gpu/backends/VulkanBackend.js +232 -0
  15. package/node-red-gpu/errors.js +48 -0
  16. package/node-red-gpu/index.js +6 -0
  17. package/node-red-gpu/log.js +18 -0
  18. package/node-red-gpu/native/binding.gyp +14 -0
  19. package/node-red-gpu/native/src/gpu_addon.cpp +1066 -0
  20. package/node-red-gpu/nodeRedGpuMain.js +39 -0
  21. package/node-red-gpu/nodeRedGpuWorker.js +242 -0
  22. package/node-red-gpu/transpiler/emitGlsl.js +497 -0
  23. package/node-red-gpu/transpiler/glslNames.js +60 -0
  24. package/node-red-gpu/transpiler/index.js +81 -0
  25. package/node-red-gpu/transpiler/inferTypes.js +302 -0
  26. package/node-red-gpu/transpiler/validate.js +461 -0
  27. package/node-red-popup/NodeRedPopupBackend.js +69 -0
  28. package/node-red-popup/NodeRedPopupFrontend.js +508 -0
  29. package/node-red-popup/README.md +113 -0
  30. package/node-red-pubsub-bus/NodeRedPubsubBus.js +617 -0
  31. package/node-red-pubsub-bus/NodeRedPubsubBusWorkerProxy.js +239 -0
  32. package/node-red-pubsub-bus/README.md +201 -0
  33. package/node-red-resourceendpoint/NodeRedResourceEndpoint.js +205 -0
  34. package/node-red-resourceendpoint/README.md +119 -0
  35. package/node-red-singleton/NodeRedSingletonBackend.js +30 -0
  36. package/node-red-singleton/NodeRedSingletonFrontend.js +54 -0
  37. package/node-red-singleton/README.md +72 -0
  38. package/node-red-worker/NodeRedWorker.js +443 -0
  39. package/node-red-worker/NodeRedWorkerRuntime.js +659 -0
  40. package/node-red-worker/README.md +273 -0
  41. package/node-red-worker/workerRuntimeLoader.js +55 -0
  42. package/package.json +70 -5
@@ -0,0 +1,508 @@
1
+ /**
2
+ * Copyright 2024 Contributors
3
+ * Licensed under the Apache License, Version 2.0
4
+ * http://www.apache.org/licenses/LICENSE-2.0
5
+ */
6
+
7
+ //
8
+ // Begin waitForWorkspaceChange
9
+ //
10
+ // Helper function to wait for NodeRed to update the workspace because it sends out
11
+ // 'workspace:change' event BEFORE it draws stuff.
12
+ // This just watches the workspace and resolves if no activity for xxxx ms.
13
+ //
14
+ if (!window.nru_waitForWorkspaceChanges) {
15
+
16
+ window.nru_waitForWorkspaceChanges = function (quietPeriod = 100) {
17
+ return new Promise((resolve) => {
18
+
19
+ const observer = new MutationObserver((mutations) => {
20
+ if (timeoutId) { clearTimeout(timeoutId) }
21
+
22
+ timeoutId = setTimeout(() => { // Once we've had no changes for the quiet period, clean up and resolve
23
+ observer.disconnect();
24
+ resolve();
25
+ }, quietPeriod);
26
+ });
27
+
28
+ let timeoutId = null;
29
+
30
+ const workspace = document.querySelector("#red-ui-workspace"); // Get the workspace container
31
+ if (!workspace) {
32
+ console.warn("nru_waitForWorkspaceChanges: Workspace element not found");
33
+ resolve(); // Resolve immediately if we can't find the workspace
34
+ return;
35
+ }
36
+
37
+ // Configure and start the observer
38
+ observer.observe(workspace, {
39
+ childList: true, // Watch for added/removed nodes
40
+ subtree: true, // Watch all descendants
41
+ attributes: true // Watch for attribute changes
42
+ });
43
+ });
44
+ }
45
+ }
46
+
47
+
48
+
49
+
50
+
51
+ //
52
+ // Begin DOM Event Tracker
53
+ //
54
+ // This is needed since NodeRED will delete our Workspace elements
55
+ // without cleaning up events we may have added. Modern browser garbage collectors
56
+ // should work but we'll help it out to ensure no memory leaks by deleting any events we create.
57
+ //
58
+
59
+ if (!window.DOMEventTracker) { // just declare the class once
60
+
61
+ window.DOMEventTracker = class EventTracker {
62
+
63
+ constructor() {
64
+ this.listeners = new Map();
65
+ this.counter = 0;
66
+ }
67
+
68
+ // Add an event listener and track it
69
+ addListener(element, eventType, handler, options = false) {
70
+ const id = this.counter++;
71
+ element.addEventListener(eventType, handler, options);
72
+
73
+ this.listeners.set(id, {
74
+ element,
75
+ eventType,
76
+ handler,
77
+ options
78
+ });
79
+
80
+ return id;
81
+ }
82
+
83
+ // Remove a specific listener by its ID
84
+ removeListener(id) {
85
+ const listener = this.listeners.get(id);
86
+ if (listener) {
87
+ const { element, eventType, handler, options } = listener;
88
+ element.removeEventListener(eventType, handler, options);
89
+ this.listeners.delete(id);
90
+ }
91
+ }
92
+
93
+ // Remove all listeners
94
+ removeAllListeners() {
95
+ this.listeners.forEach((listener, id) => {
96
+ const { element, eventType, handler, options } = listener;
97
+ element.removeEventListener(eventType, handler, options);
98
+ });
99
+ this.listeners.clear();
100
+ }
101
+
102
+ // Check for and remove listeners for deleted elements
103
+ cleanupDeletedElements() {
104
+ this.listeners.forEach((listener, id) => {
105
+ if (!document.contains(listener.element)) {
106
+ this.listeners.delete(id);
107
+ }
108
+ });
109
+ }
110
+ }
111
+
112
+ }
113
+
114
+
115
+ //
116
+ // Begin Popup Support
117
+ //
118
+
119
+ if (!window.popupInited) {
120
+
121
+ window.popupInited = true;
122
+ window.popupStates = {};
123
+ window.popupEventTracker = new window.DOMEventTracker();
124
+
125
+ const removePopups = async (workspace) => {
126
+ window.popupEventTracker.removeAllListeners();
127
+ RED.nodes.filterNodes( {z:workspace} ).forEach ( node => {
128
+ document.getElementById('nru_popup.' + node.id + '.group')?.remove();
129
+ });
130
+ }
131
+
132
+ const addPopups = async (workspace) => {
133
+ await window.nru_waitForWorkspaceChanges(100);
134
+ RED.nodes.filterNodes( {z:workspace} ).forEach ( async node => {
135
+ createPopup( node );
136
+ });
137
+ }
138
+
139
+ const addPopup = async (node) => {
140
+ await window.nru_waitForWorkspaceChanges(100);
141
+ createPopup(node);
142
+ }
143
+
144
+ // Smallest popup size in pixels. Smaller sizes are made this size.
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;
152
+ const warnedTypes = new Set();
153
+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
154
+ const SVG_NS = "http://www.w3.org/2000/svg";
155
+
156
+ const createPopup = async ( node ) => {
157
+
158
+ if (!node?._def?.defaults?.nru_popupConfig?.value) return;
159
+
160
+ // The node's own config (saved after a resize) wins over the default of the type
161
+ const config = { ...node._def.defaults.nru_popupConfig.value, ...node.nru_popupConfig };
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)));
166
+ if (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]);
169
+ if (!warnedTypes.has(node.type)) {
170
+ warnedTypes.add(node.type);
171
+ RED.notify(`No popup for "${node.type}" nodes: nru_popupConfig.${problem} must be ${rule}.`, 'warning');
172
+ }
173
+ return;
174
+ }
175
+
176
+ // Exit if already added bc we are getting multiple workspace change events or something
177
+ if (document.getElementById("nru_popup." + node.id + ".group") ) return;
178
+
179
+ // Just in case we got here without NodeRED being finished adding nodes to the workspace
180
+ if (!document.getElementById(node.id)) return;
181
+
182
+ const rectElement = document.getElementById(node.id).querySelector('rect');
183
+ if(!rectElement) return;
184
+
185
+ const width = Math.max(MIN_WIDTH, config.width);
186
+ const height = Math.max(MIN_HEIGHT, config.height);
187
+ const autoCloseTime = config.autoCloseTime;
188
+
189
+ const svgElement = document.getElementById(node.id);
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
+ };
198
+
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 });
223
+ const sourceText = svgElement.querySelector('.red-ui-flow-node-status-label');
224
+ for (const attr of sourceText.attributes) {
225
+ foreignObject.setAttribute(attr.name, attr.value)
226
+ };
227
+ foreignObject.setAttribute("x", "6");
228
+ foreignObject.setAttribute("y", "6");
229
+
230
+ let divElement = document.createElement("div");
231
+ divElement.setAttribute("id", "nru_popup." + node.id + ".div");
232
+ divElement.style.margin = "0";
233
+ divElement.style.overflow = "auto";
234
+ divElement.style.boxSizing = "border-box";
235
+ divElement.style.color = theme('primary-text-color', '#222');
236
+ foreignObject.appendChild(divElement);
237
+
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
258
+ statusText.textContent = '';
259
+ statusText.fullText = '';
260
+ statusText.setStatusText = function( text ) {
261
+ statusText.textContent = text;
262
+ statusText.fullText = text;
263
+ sizeTextToFit( statusText, parseInt(rect.getAttribute('width')) - STATUS_TEXT_MARGIN );
264
+ };
265
+
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);
276
+ svgElement.appendChild(group);
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
+
327
+ // Resize functionality
328
+ function sizeTextToFit( svgTextObject, width ) {
329
+ while ( svgTextObject.getBBox().width < width - 5 && svgTextObject.textContent.length !== svgTextObject.fullText.length ) {
330
+ svgTextObject.textContent = svgTextObject.fullText.slice(0, svgTextObject.textContent.length + 1);
331
+ }
332
+ while ( svgTextObject.getBBox().width > width - 5 && svgTextObject.textContent !== '') {
333
+ svgTextObject.textContent = svgTextObject.fullText.slice( 0, svgTextObject.textContent.length - 1);
334
+ }
335
+ }
336
+
337
+ //---------------------------------------------------------------------------------------------
338
+ // Event Handlers
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
+
361
+ let clickTimer = null;
362
+ let autoCloseTimeout = null;
363
+ let mouseDownX = 0, mouseDownY = 0;
364
+ const DRAG_THRESHOLD = 3;
365
+ const handleRectElementMouseDown = (event) => {
366
+ mouseDownX = event.clientX;
367
+ mouseDownY = event.clientY;
368
+ }
369
+ const handleRectElementClick = (event) => {
370
+ // Suppress toggle if the pointer moved far enough to count as a drag
371
+ const dx = event.clientX - mouseDownX;
372
+ const dy = event.clientY - mouseDownY;
373
+ if (Math.hypot(dx, dy) > DRAG_THRESHOLD) return;
374
+
375
+ if (clickTimer) { // clear if double-clicked
376
+ clearTimeout(clickTimer);
377
+ clickTimer = null;
378
+ return;
379
+ }
380
+ clickTimer = setTimeout(() => {
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);
386
+ }
387
+ clickTimer = null;
388
+ }, 250);
389
+ }
390
+
391
+ let startWidth, startHeight, startX, startY;
392
+ let docResizeMouseMoveListener, docResizeMouseUpListener;
393
+ const resizeHandleMouseDown = (event) => {
394
+
395
+ // Get initial sizes and position
396
+ [startWidth, startHeight] = [parseInt(rect.getAttribute('width')), parseInt(rect.getAttribute('height'))];
397
+ ( {startX, startY} = {startX: event.clientX, startY: event.clientY} );
398
+ resized = false;
399
+
400
+ // Add event listeners for drag operations
401
+ docResizeMouseMoveListener = window.popupEventTracker.addListener(document, 'mousemove', resize);
402
+ docResizeMouseUpListener = window.popupEventTracker.addListener(document, 'mouseup', stopResize);
403
+
404
+ event.preventDefault();
405
+ event.stopPropagation();
406
+ }
407
+
408
+ let newWidth = 0, newHeight = 0, resized = false;
409
+ const resize = (event)=> {
410
+ resized = true;
411
+ const scale = RED.view.scale(); // the canvas zoom
412
+ const {dx, dy} = {dx: (event.clientX - startX) / scale, dy: (event.clientY - startY) / scale};
413
+ newWidth = Math.max(MIN_WIDTH, startWidth + dx);
414
+ newHeight = Math.max(MIN_HEIGHT, startHeight + dy);
415
+ layout(newWidth, newHeight);
416
+ sizeTextToFit( statusText, newWidth - STATUS_TEXT_MARGIN );
417
+ }
418
+
419
+ const stopResize = () => {
420
+ window.popupEventTracker.removeListener(docResizeMouseMoveListener);
421
+ window.popupEventTracker.removeListener(docResizeMouseUpListener);
422
+
423
+ // A click with no drag does not change the size
424
+ if (!resized) return;
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();
455
+ }
456
+
457
+ window.popupEventTracker.addListener(rectElement, "mousedown", handleRectElementMouseDown);
458
+ window.popupEventTracker.addListener(rectElement, "click", handleRectElementClick);
459
+ window.popupEventTracker.addListener(resizeHandle, "mousedown", resizeHandleMouseDown);
460
+ window.popupEventTracker.addListener(statusBar, "mousedown", statusBarMouseDown);
461
+
462
+ // Run the node's nru_popupInit function
463
+ try {
464
+ if (!node?._def?.nru_popupInit) return;
465
+ if (typeof node._def.nru_popupInit !== 'function') {
466
+ console.error('Popup init failed: nru_popupInit is not a function', {
467
+ exists: !!node._def.nru_popupInit,
468
+ type: typeof node._def.nru_popupInit
469
+ });
470
+ return;
471
+ }
472
+ node._def.nru_popupInit.call(node);
473
+ } catch (error) {
474
+ console.error('Running nru_popupInit failed with error:', error.message, {
475
+ errorName: error.name,
476
+ stack: error.stack,
477
+ node: node?.id
478
+ });
479
+ }
480
+
481
+
482
+ };
483
+
484
+ // add/remove popup elements on workspace change
485
+ RED.events.on('workspace:change', async (event) => {
486
+ if (event?.old !== 0) { removePopups(event.old) };
487
+ addPopups(event.workspace);
488
+ });
489
+
490
+ // add popup elements on when new node is added
491
+ RED.events.on('nodes:add', async (node) => {
492
+ addPopup(node);
493
+ });
494
+
495
+ // Re-bind popups after deploy. Node-RED may replace a node's SVG rect
496
+ // in place during redeploy, orphaning the click handler and removing the
497
+ // popup group from the DOM. Neither workspace:change nor nodes:add fires
498
+ // for unchanged nodes on the same tab, so without this the only recovery
499
+ // was a full page refresh.
500
+ RED.events.on('deploy', async () => {
501
+ const active = RED.workspaces.active();
502
+ if (!active) return;
503
+ await removePopups(active);
504
+ await addPopups(active);
505
+ });
506
+
507
+ }
508
+
@@ -0,0 +1,113 @@
1
+ # node-red-popup
2
+
3
+ EXPERIMENTAL. The API and the behavior can change in a minor release.
4
+
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
+
7
+ ## Usage
8
+
9
+ A popup that shows the last message of the node on the canvas, without opening the edit dialog. It uses [`nodeRedEditorComms`](../node-red-editorcomms/README.md) for the runtime data.
10
+
11
+ The runtime mixin `nodeRedPopup` has no API that you call. It exists to serve the editor script `NodeRedPopupFrontend.js`, so that the `<script>` tag below can load it, also before the first deploy. Add the mixin even though you do not call anything on it:
12
+
13
+ ```js
14
+ const { NodeRedNodeBase, nodeRedPopup, nodeRedEditorComms } = require('@nrutils/node-red-utils');
15
+
16
+ module.exports = NodeRedNodeBase.createNode('popup-demo', [nodeRedPopup, nodeRedEditorComms], (RED, thisNode, config) => {
17
+ let last = null;
18
+
19
+ // A new popup asks for the last message
20
+ thisNode.nodeRedEditorComms.subscribe('get-last', () => last);
21
+
22
+ thisNode.on('input', (msg, send, done) => {
23
+ last = { payload: msg.payload, time: new Date().toLocaleTimeString() };
24
+ thisNode.nodeRedEditorComms.publish('last', last); // updates the popup in every open editor
25
+ send(msg);
26
+ done();
27
+ });
28
+ });
29
+ ```
30
+
31
+ The editor side does the work:
32
+
33
+ ```html
34
+ <script type="text/javascript" src="node-red-resourceendpoint/node-red-popup/NodeRedPopupFrontend.js"></script>
35
+ <script type="text/javascript" src="node-red-resourceendpoint/node-red-editorcomms/EditorCommsFrontend.js"></script>
36
+
37
+ <script type="text/javascript">
38
+ (function () {
39
+ function show(node, last) {
40
+ const div = document.getElementById('nru_popup.' + node.id + '.div');
41
+ if (!div || !last) return; // the node is on another tab, or no message yet
42
+ div.textContent = JSON.stringify(last.payload); // text, never HTML
43
+ document.getElementById('nru_popup.' + node.id + '.statusText').setStatusText(last.time);
44
+ }
45
+
46
+ RED.nodes.registerType('popup-demo', {
47
+ category: 'function',
48
+ color: '#d4c4fb',
49
+ defaults: {
50
+ name: { value: '' },
51
+ nru_popupConfig: { value: { width: 200, height: 120, autoCloseTime: 0 } }
52
+ },
53
+ inputs: 1,
54
+ outputs: 1,
55
+ label: function () { return this.name || 'popup-demo'; },
56
+
57
+ // Runs each time the editor builds the popup; "this" is the node
58
+ nru_popupInit: function () {
59
+ const node = this;
60
+ node.nodeRedEditorComms.publish('get-last').then((last) => show(node, last)).catch(() => {});
61
+ },
62
+ nodeRedEditorComms_subscriptions: {
63
+ last: function (last) { show(this, last); }
64
+ }
65
+ });
66
+ })();
67
+ </script>
68
+
69
+ <script type="text/html" data-template-name="popup-demo">
70
+ <div class="form-row">
71
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
72
+ <input type="text" id="node-input-name" placeholder="Name">
73
+ </div>
74
+ </script>
75
+ ```
76
+
77
+ The editor script reads two properties of the node definition. Both names start with `nru_` (for node-red-utils), so they do not collide with Node-RED's own properties or yours:
78
+
79
+ - `nru_popupConfig` in `defaults`: the size of the popup. Because it is in `defaults`, Node-RED saves it with each node.
80
+ - `nru_popupInit`: a function that the script calls after it has made the popup elements, with `this` set to the editor node. Fill the popup here.
81
+
82
+ When the editor builds the popup, it asks the runtime for the last message. After that, the runtime pushes each new message to the popup. A node that is not deployed answers 404, and the popup stays empty. Runtime data shown in the popup must be set with `textContent` or jQuery `.text()`, never with `innerHTML`: a message payload can hold HTML that would run as script in the editor.
83
+
84
+ ## Configuration
85
+
86
+ A node gets a popup only if `defaults` has `nru_popupConfig`:
87
+
88
+ - `width`: pixels, for the full popup. The minimum is 100; a smaller value gives 100.
89
+ - `height`: pixels, for the full popup, with the 20 px status bar. The minimum is 50; a smaller value gives 50.
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.
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.
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.
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.
98
+
99
+ ## Elements
100
+
101
+ Each popup creates elements with these ids:
102
+
103
+ - `nru_popup.<nodeId>.div`: the content area. Put your elements here.
104
+ - `nru_popup.<nodeId>.statusText`: the status line. Call its `setStatusText(text)`; long text is cut to fit.
105
+
106
+ ## Behavior
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.
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.
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.
112
+
113
+ [Back to node-red-utils](../README.md)