@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.
- package/CHANGELOG.md +20 -0
- package/LICENSE +190 -0
- package/README.md +187 -2
- package/index.js +50 -0
- package/node-red-base/NodeRedNodeBase.js +143 -0
- package/node-red-base/README.md +215 -0
- package/node-red-editorcomms/EditorCommsBackend.js +155 -0
- package/node-red-editorcomms/EditorCommsFrontend.js +105 -0
- package/node-red-editorcomms/README.md +164 -0
- package/node-red-gpu/GpuBuffer.js +29 -0
- package/node-red-gpu/Kernel.js +35 -0
- package/node-red-gpu/README.md +220 -0
- package/node-red-gpu/backends/CpuBackend.js +109 -0
- package/node-red-gpu/backends/VulkanBackend.js +232 -0
- package/node-red-gpu/errors.js +48 -0
- package/node-red-gpu/index.js +6 -0
- package/node-red-gpu/log.js +18 -0
- package/node-red-gpu/native/binding.gyp +14 -0
- package/node-red-gpu/native/src/gpu_addon.cpp +1066 -0
- package/node-red-gpu/nodeRedGpuMain.js +39 -0
- package/node-red-gpu/nodeRedGpuWorker.js +242 -0
- package/node-red-gpu/transpiler/emitGlsl.js +497 -0
- package/node-red-gpu/transpiler/glslNames.js +60 -0
- package/node-red-gpu/transpiler/index.js +81 -0
- package/node-red-gpu/transpiler/inferTypes.js +302 -0
- package/node-red-gpu/transpiler/validate.js +461 -0
- package/node-red-popup/NodeRedPopupBackend.js +69 -0
- package/node-red-popup/NodeRedPopupFrontend.js +508 -0
- package/node-red-popup/README.md +113 -0
- package/node-red-pubsub-bus/NodeRedPubsubBus.js +617 -0
- package/node-red-pubsub-bus/NodeRedPubsubBusWorkerProxy.js +239 -0
- package/node-red-pubsub-bus/README.md +201 -0
- package/node-red-resourceendpoint/NodeRedResourceEndpoint.js +205 -0
- package/node-red-resourceendpoint/README.md +119 -0
- package/node-red-singleton/NodeRedSingletonBackend.js +30 -0
- package/node-red-singleton/NodeRedSingletonFrontend.js +54 -0
- package/node-red-singleton/README.md +72 -0
- package/node-red-worker/NodeRedWorker.js +443 -0
- package/node-red-worker/NodeRedWorkerRuntime.js +659 -0
- package/node-red-worker/README.md +273 -0
- package/node-red-worker/workerRuntimeLoader.js +55 -0
- 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)
|