@aaqu/fromcubes-portal-react 0.1.0-alpha.22 → 0.1.0-alpha.24
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/README.md +66 -10
- package/examples/D3 Poland Map.json +90 -0
- package/examples/Live Chart.json +103 -0
- package/examples/PixiJS Sprites.json +118 -0
- package/examples/Sensor Portal.json +97 -0
- package/examples/Shared Components.json +78 -0
- package/examples/Three.js Scene.json +122 -0
- package/examples/Utility Hooks.json +96 -0
- package/examples/WebGPU Shader.json +114 -0
- package/nodes/lib/assets.js +141 -11
- package/nodes/lib/helpers.js +334 -12
- package/nodes/lib/hooks.js +38 -0
- package/nodes/lib/page-builder.js +143 -10
- package/nodes/lib/router.js +25 -1
- package/nodes/portal-react.html +248 -9
- package/nodes/portal-react.js +865 -197
- package/package.json +20 -5
- package/examples/001-shared-components-flow.json +0 -68
- package/examples/002-sensor-portal-flow.json +0 -71
- package/examples/003-chart-portal-flow.json +0 -93
- package/examples/004-d3-poland-flow.json +0 -80
- package/examples/005-threejs-portal-flow.json +0 -87
- package/examples/006-pixi-portal-flow.json +0 -86
- package/examples/007-webgpu-tsl-flow.json +0 -85
- package/examples/008-utility-debounce-flow.json +0 -72
|
@@ -1,7 +1,34 @@
|
|
|
1
|
+
/** @module nodes/lib/page-builder */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HTML page builders for portal-react. Two entry points:
|
|
5
|
+
*
|
|
6
|
+
* - {@link buildPage} — full portal HTML with inlined JS bundle, WS bridge,
|
|
7
|
+
* error overlay, optional connection badge.
|
|
8
|
+
* - {@link buildErrorPage} — minimal HTML served when a build fails AND no
|
|
9
|
+
* previous good build exists for degraded-mode
|
|
10
|
+
* fallback. Polls + WS-reconnects for recovery.
|
|
11
|
+
*
|
|
12
|
+
* Both share the same `#__error_overlay` / `#__error_banner` CSS so the look
|
|
13
|
+
* is consistent across initial-load failure and live build/runtime errors.
|
|
14
|
+
*/
|
|
15
|
+
|
|
1
16
|
/**
|
|
2
|
-
*
|
|
17
|
+
* @typedef {Object} ErrorOverlayParams
|
|
18
|
+
* @property {string} title Overlay heading (e.g. "Build Error").
|
|
19
|
+
* @property {string} [hint] Optional one-line user hint under the title.
|
|
20
|
+
* @property {string} message Multi-line error message rendered inside `<pre>`.
|
|
21
|
+
* @property {string} [statusLine] Optional small status line beneath the message.
|
|
22
|
+
* @property {boolean} [statusOk] When true, statusLine is rendered green; otherwise muted.
|
|
3
23
|
*/
|
|
4
24
|
|
|
25
|
+
/**
|
|
26
|
+
* HTML-escape a value for safe interpolation into element text or attribute
|
|
27
|
+
* context. Stringifies non-strings.
|
|
28
|
+
*
|
|
29
|
+
* @param {*} s
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
5
32
|
function esc(s) {
|
|
6
33
|
return String(s)
|
|
7
34
|
.replace(/&/g, "&")
|
|
@@ -10,6 +37,14 @@ function esc(s) {
|
|
|
10
37
|
.replace(/"/g, """);
|
|
11
38
|
}
|
|
12
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Neutralize `</script>` sequences in user-supplied content that will be
|
|
42
|
+
* inlined inside a `<script>` block. Avoids accidental script-tag escape
|
|
43
|
+
* from a string that contains the closing tag literally.
|
|
44
|
+
*
|
|
45
|
+
* @param {*} s
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
13
48
|
function escScript(s) {
|
|
14
49
|
return String(s).replace(/<\/(script)/gi, "<\\/$1");
|
|
15
50
|
}
|
|
@@ -56,8 +91,14 @@ const ERROR_OVERLAY_CSS = `
|
|
|
56
91
|
}
|
|
57
92
|
`;
|
|
58
93
|
|
|
59
|
-
|
|
60
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Shared error overlay markup (HTML rendered inside `#__error_overlay`).
|
|
96
|
+
* Used by `buildPage` (WS error frame, runtime try/catch) and
|
|
97
|
+
* `buildErrorPage` so both surface look identical.
|
|
98
|
+
*
|
|
99
|
+
* @param {ErrorOverlayParams} params
|
|
100
|
+
* @returns {string} HTML fragment (no surrounding `<div>` — caller wraps).
|
|
101
|
+
*/
|
|
61
102
|
function errorOverlayInnerHtml({ title, hint, message, statusLine, statusOk }) {
|
|
62
103
|
return (
|
|
63
104
|
`<h1>${esc(title)}</h1>` +
|
|
@@ -71,6 +112,25 @@ function errorOverlayInnerHtml({ title, hint, message, statusLine, statusOk }) {
|
|
|
71
112
|
|
|
72
113
|
const DEFAULT_HINT = "Fix the component code in Node-RED and deploy again.";
|
|
73
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Build the full portal HTML page. Inlines the transpiled JS bundle, wires up
|
|
117
|
+
* the `window.__NR` WebSocket bridge (used by `useNodeRed()`), installs the
|
|
118
|
+
* shared error overlay/banner machinery, and optionally renders the
|
|
119
|
+
* connection-status badge in the bottom-right corner.
|
|
120
|
+
*
|
|
121
|
+
* The browser receives no compiler — the inlined `transpiledJs` is the
|
|
122
|
+
* already-bundled IIFE produced by esbuild at deploy time.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} title Document title (`<title>` tag).
|
|
125
|
+
* @param {string} transpiledJs Pre-compiled IIFE bundle from esbuild.
|
|
126
|
+
* @param {string} wsPath WebSocket URL path (e.g. `/fromcubes/<sub>/_ws`).
|
|
127
|
+
* @param {string} customHead Raw HTML inserted into `<head>` (trusted-author).
|
|
128
|
+
* @param {string} cssHash Tailwind CSS bundle hash, or "" to skip the link tag.
|
|
129
|
+
* @param {?Object} user PortalUser object or null when Portal Auth is off.
|
|
130
|
+
* @param {boolean} showWsStatus Render the `#__cs` connection badge.
|
|
131
|
+
* @param {string} adminRoot `RED.settings.httpAdminRoot` (no trailing slash).
|
|
132
|
+
* @returns {string} Complete HTML5 document.
|
|
133
|
+
*/
|
|
74
134
|
function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus, adminRoot) {
|
|
75
135
|
return `<!DOCTYPE html>
|
|
76
136
|
<html lang="en">
|
|
@@ -158,6 +218,16 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
158
218
|
// Flushed in onopen so node status can go red even when the
|
|
159
219
|
// exception fires synchronously during initial bundle execution.
|
|
160
220
|
_pendingRuntimeError: null,
|
|
221
|
+
// Page-load timestamp + guarded reload: never reload within 2s of
|
|
222
|
+
// load, so a briefly-inconsistent server (error page served while a
|
|
223
|
+
// "ready" version hash is advertised over WS) cannot drive a tight
|
|
224
|
+
// reload loop. Caps recovery to one reload / 2s.
|
|
225
|
+
_loadT: Date.now(),
|
|
226
|
+
_reload() {
|
|
227
|
+
const wait = 2000 - (Date.now() - this._loadT);
|
|
228
|
+
if (wait > 0) setTimeout(() => location.reload(), wait);
|
|
229
|
+
else location.reload();
|
|
230
|
+
},
|
|
161
231
|
_user: ${user ? escScript(JSON.stringify(user)) : "null"},
|
|
162
232
|
|
|
163
233
|
connect() {
|
|
@@ -193,7 +263,7 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
193
263
|
// exception caught locally renders the same overlay node and would
|
|
194
264
|
// otherwise loop reload to runtime-error to reload forever.
|
|
195
265
|
if (this._buildErrorActive || (this._version && this._version !== m.hash)) {
|
|
196
|
-
|
|
266
|
+
this._reload();
|
|
197
267
|
return;
|
|
198
268
|
}
|
|
199
269
|
this._version = m.hash;
|
|
@@ -284,6 +354,20 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
284
354
|
</html>`;
|
|
285
355
|
}
|
|
286
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Build a minimal error page served when a portal build fails AND no
|
|
359
|
+
* previous good build exists for degraded-mode fallback. Includes:
|
|
360
|
+
*
|
|
361
|
+
* - The shared error overlay markup populated with the error message.
|
|
362
|
+
* - A WS reconnect loop that reloads the page on the next `version` frame.
|
|
363
|
+
* - An HTTP HEAD polling loop with linear backoff (1.5×, capped at 10 s)
|
|
364
|
+
* so the page also recovers when Node-RED itself was restarted (WS dies).
|
|
365
|
+
*
|
|
366
|
+
* @param {string} title Browser title (gets ` — Error` suffix).
|
|
367
|
+
* @param {string} error Multi-line error message rendered inside the overlay.
|
|
368
|
+
* @param {string} wsPath WebSocket URL path; pass empty string to skip WS wiring.
|
|
369
|
+
* @returns {string} Complete HTML5 document.
|
|
370
|
+
*/
|
|
287
371
|
function buildErrorPage(title, error, wsPath) {
|
|
288
372
|
return `<!DOCTYPE html>
|
|
289
373
|
<html lang="en">
|
|
@@ -305,6 +389,16 @@ function buildErrorPage(title, error, wsPath) {
|
|
|
305
389
|
const st = document.getElementById('__err_status');
|
|
306
390
|
const pre = document.querySelector('#__error_overlay pre');
|
|
307
391
|
let retries = 0;
|
|
392
|
+
// Reload guard: never reload within 2s of this page loading. If the
|
|
393
|
+
// server is briefly inconsistent (serves this error page yet
|
|
394
|
+
// advertises a "ready" version hash over WS), an unguarded reload
|
|
395
|
+
// turns into a tight loop. The guard caps it to one reload / 2s.
|
|
396
|
+
const __loadT = Date.now();
|
|
397
|
+
function __reload() {
|
|
398
|
+
const wait = 2000 - (Date.now() - __loadT);
|
|
399
|
+
if (wait > 0) { setTimeout(function(){ location.reload(); }, wait); }
|
|
400
|
+
else { location.reload(); }
|
|
401
|
+
}
|
|
308
402
|
function setStatus(text, ok) {
|
|
309
403
|
if (!st) return;
|
|
310
404
|
st.textContent = text;
|
|
@@ -320,7 +414,7 @@ function buildErrorPage(title, error, wsPath) {
|
|
|
320
414
|
ws.onmessage = function(e) {
|
|
321
415
|
try {
|
|
322
416
|
const m = JSON.parse(e.data);
|
|
323
|
-
if (m.type === 'version' && m.hash)
|
|
417
|
+
if (m.type === 'version' && m.hash) __reload();
|
|
324
418
|
if (m.type === 'error' && pre) pre.textContent = m.message;
|
|
325
419
|
} catch(_) {}
|
|
326
420
|
};
|
|
@@ -333,11 +427,50 @@ function buildErrorPage(title, error, wsPath) {
|
|
|
333
427
|
ws.onerror = function() { ws.close(); };
|
|
334
428
|
}
|
|
335
429
|
${wsPath ? "connect();" : ""}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
430
|
+
|
|
431
|
+
/*
|
|
432
|
+
* Recovery polling loop.
|
|
433
|
+
*
|
|
434
|
+
* Why polling alongside the WebSocket? The WS reload pathway only
|
|
435
|
+
* fires when the next deploy actually broadcasts a "version" frame
|
|
436
|
+
* with a non-empty hash. If Node-RED is restarting (process down →
|
|
437
|
+
* up), the WS dies and reconnects do not help — we need an HTTP
|
|
438
|
+
* probe to notice when the runtime returns.
|
|
439
|
+
*
|
|
440
|
+
* Why backoff? A page sitting on a long-broken build would otherwise
|
|
441
|
+
* burn one HEAD request per 3 s indefinitely — across many open
|
|
442
|
+
* tabs that adds up. Linear-ish backoff (1.5×) caps cost while
|
|
443
|
+
* staying responsive to a freshly-recovered runtime.
|
|
444
|
+
*
|
|
445
|
+
* Cap at 10 s so the worst-case wait between successful redeploy
|
|
446
|
+
* and page recovery is bounded.
|
|
447
|
+
*/
|
|
448
|
+
var __pollDelay = 3000;
|
|
449
|
+
var __pollIv = null;
|
|
450
|
+
function __schedulePoll() {
|
|
451
|
+
__pollIv = setTimeout(function poll() {
|
|
452
|
+
fetch(location.href, { method: 'HEAD', cache: 'no-store' })
|
|
453
|
+
.then(function(r) {
|
|
454
|
+
if (r.ok) {
|
|
455
|
+
// Stop the loop BEFORE reload — without this, a slow
|
|
456
|
+
// teardown could leave another setTimeout firing during
|
|
457
|
+
// the unload, briefly racing with the new page.
|
|
458
|
+
if (__pollIv) { clearTimeout(__pollIv); __pollIv = null; }
|
|
459
|
+
location.reload();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
// 4xx/5xx still means the server is up — keep delay short.
|
|
463
|
+
__pollDelay = 3000;
|
|
464
|
+
__schedulePoll();
|
|
465
|
+
})
|
|
466
|
+
.catch(function() {
|
|
467
|
+
// Network error → server probably down. Grow backoff.
|
|
468
|
+
__pollDelay = Math.min(Math.round(__pollDelay * 1.5), 10000);
|
|
469
|
+
__schedulePoll();
|
|
470
|
+
});
|
|
471
|
+
}, __pollDelay);
|
|
472
|
+
}
|
|
473
|
+
__schedulePoll();
|
|
341
474
|
})();
|
|
342
475
|
<\/script>
|
|
343
476
|
</body>
|
package/nodes/lib/router.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** @module nodes/lib/router */
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Pure routing function for portal-react WS outbound messages.
|
|
3
5
|
*
|
|
@@ -10,11 +12,33 @@
|
|
|
10
12
|
* 3. msg._client.username → user-cast fallback (O(N) scan)
|
|
11
13
|
* 4. otherwise → broadcast
|
|
12
14
|
*
|
|
13
|
-
* Returns a shallow summary { mode, delivered } for observability/tests.
|
|
15
|
+
* Returns a shallow summary `{ mode, delivered }` for observability/tests.
|
|
14
16
|
* The caller is responsible for any side-effects keyed off the mode
|
|
15
17
|
* (e.g. caching the last broadcast payload for new-client recovery).
|
|
16
18
|
*/
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} RouteContext
|
|
22
|
+
* @property {Map<string, import("ws").WebSocket>} clients portalClient → ws
|
|
23
|
+
* @property {Map<string, Set<import("ws").WebSocket>>} userIndex userId → ws set
|
|
24
|
+
* @property {(ws: any, frame: string, msg: Object) => boolean} sendTo
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {Object} RouteResult
|
|
29
|
+
* @property {"unicast"|"user-cast"|"broadcast"} mode
|
|
30
|
+
* @property {number} delivered
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pure router — chooses delivery mode based on `msg._client` and dispatches
|
|
35
|
+
* via `ctx.sendTo` (which is responsible for hook checks and send-failure
|
|
36
|
+
* handling). Has no side-effects other than calling `sendTo`.
|
|
37
|
+
*
|
|
38
|
+
* @param {Object} msg
|
|
39
|
+
* @param {RouteContext} ctx
|
|
40
|
+
* @returns {RouteResult}
|
|
41
|
+
*/
|
|
18
42
|
function route(msg, ctx) {
|
|
19
43
|
const { clients, userIndex, sendTo } = ctx;
|
|
20
44
|
const target = msg && msg._client;
|
package/nodes/portal-react.html
CHANGED
|
@@ -22,8 +22,16 @@
|
|
|
22
22
|
window.__fcTwClasses = null;
|
|
23
23
|
let jsxSetupDone = false;
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* One-time Monaco setup: compiler opts, diagnostics, extra libs,
|
|
27
|
+
* completion providers (Tailwind class, JSX tag, utility symbol).
|
|
28
|
+
* Idempotent — safe to call multiple times, only the first call wires
|
|
29
|
+
* up listeners; subsequent calls just re-apply the compiler/diagnostics
|
|
30
|
+
* blocks because Node-RED resets them whenever the editor reloads.
|
|
31
|
+
*
|
|
32
|
+
* @returns {void}
|
|
33
|
+
* @private
|
|
34
|
+
*/
|
|
27
35
|
function ensureJsxSetup() {
|
|
28
36
|
if (jsxSetupDone) {
|
|
29
37
|
console.log(
|
|
@@ -439,7 +447,15 @@
|
|
|
439
447
|
console.log(PREFIX, "self-close collapse attached to editor");
|
|
440
448
|
};
|
|
441
449
|
|
|
442
|
-
|
|
450
|
+
/**
|
|
451
|
+
* Fetch the current component registry from `/portal-react/registry`
|
|
452
|
+
* and stash the names on `window.__fcComponentNames` so the JSX-tag
|
|
453
|
+
* completion provider can offer `<Button` etc. Re-fetched on every
|
|
454
|
+
* editor open so newly-added components show up without an editor
|
|
455
|
+
* restart.
|
|
456
|
+
* @returns {void}
|
|
457
|
+
* @private
|
|
458
|
+
*/
|
|
443
459
|
function refreshComponentNames() {
|
|
444
460
|
$.getJSON("portal-react/registry", function (reg) {
|
|
445
461
|
window.__fcComponentNames = Object.keys(reg);
|
|
@@ -547,6 +563,17 @@
|
|
|
547
563
|
console.log(PREFIX, "one-time setup complete");
|
|
548
564
|
}
|
|
549
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Push compilerOptions + diagnosticsOptions into Monaco's TS defaults.
|
|
568
|
+
* Called by `ensureJsxSetup` once and re-applied on every editor open
|
|
569
|
+
* because Node-RED's outer Monaco initialiser can reset these between
|
|
570
|
+
* editor sessions. We disable Monaco's TS-side squiggles entirely —
|
|
571
|
+
* the authoritative syntax check is the server-side esbuild pass at
|
|
572
|
+
* deploy time, and Monaco's TS parser produces noisy false positives
|
|
573
|
+
* (1109/1005/1128) on raw JSX.
|
|
574
|
+
* @returns {void}
|
|
575
|
+
* @private
|
|
576
|
+
*/
|
|
550
577
|
function applyCompilerAndDiag() {
|
|
551
578
|
const jsDef = monaco.typescript.javascriptDefaults;
|
|
552
579
|
const compilerOpts = {
|
|
@@ -583,11 +610,28 @@
|
|
|
583
610
|
);
|
|
584
611
|
}
|
|
585
612
|
|
|
586
|
-
|
|
613
|
+
/**
|
|
614
|
+
* Re-apply JSX defaults before Monaco model creation. Exported for
|
|
615
|
+
* `oneditprepare` to call. Idempotent — wraps `ensureJsxSetup`.
|
|
616
|
+
* @returns {void}
|
|
617
|
+
*/
|
|
587
618
|
window.__fcApplyJsxDefaults = function () {
|
|
588
619
|
ensureJsxSetup();
|
|
589
620
|
};
|
|
590
621
|
|
|
622
|
+
/**
|
|
623
|
+
* Lazy-load Monaco from the admin endpoint `/portal-react/vs/` the
|
|
624
|
+
* first time an editor opens. If Monaco is already on `window`
|
|
625
|
+
* (Node-RED bundled its own copy, or another node loaded it first),
|
|
626
|
+
* skip the loader script and run our setup inline.
|
|
627
|
+
*
|
|
628
|
+
* The callback signature is `cb(failed)` — `failed === true` when the
|
|
629
|
+
* loader script threw at load time; editors should fall back to a
|
|
630
|
+
* plain textarea in that case.
|
|
631
|
+
*
|
|
632
|
+
* @param {(failed?: boolean) => void} cb
|
|
633
|
+
* @returns {void}
|
|
634
|
+
*/
|
|
591
635
|
window.__fcLoadMonaco = function (cb) {
|
|
592
636
|
console.log(PREFIX, "loadMonaco called, monaco exists:", !!window.monaco);
|
|
593
637
|
if (window.monaco) {
|
|
@@ -667,22 +711,61 @@
|
|
|
667
711
|
"}",
|
|
668
712
|
].join("\n");
|
|
669
713
|
|
|
714
|
+
// FC_NAME_RE / FC_NAME_MAX / FC_NAME_BLACKLIST mirror lib/helpers.js
|
|
715
|
+
// isSafeName so the editor flags invalid names before deploy.
|
|
716
|
+
const FC_NAME_RE = /^[A-Za-z_$][\w$]*$/;
|
|
717
|
+
const FC_NAME_MAX = 64;
|
|
718
|
+
const FC_NAME_BLACKLIST = [
|
|
719
|
+
"__proto__", "constructor", "prototype", "hasOwnProperty",
|
|
720
|
+
"isPrototypeOf", "propertyIsEnumerable", "toString", "valueOf",
|
|
721
|
+
"toLocaleString",
|
|
722
|
+
];
|
|
723
|
+
function fcValidateName(v) {
|
|
724
|
+
return (
|
|
725
|
+
typeof v === "string" &&
|
|
726
|
+
v.length > 0 &&
|
|
727
|
+
v.length <= FC_NAME_MAX &&
|
|
728
|
+
FC_NAME_RE.test(v) &&
|
|
729
|
+
FC_NAME_BLACKLIST.indexOf(v) === -1
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
670
733
|
RED.nodes.registerType("fc-portal-component", {
|
|
671
734
|
category: "fromcubes",
|
|
672
735
|
color: "#a8d8ea",
|
|
673
736
|
defaults: {
|
|
674
737
|
name: { value: "" },
|
|
675
|
-
compName: {
|
|
738
|
+
compName: {
|
|
739
|
+
value: "StatusCard",
|
|
740
|
+
required: true,
|
|
741
|
+
validate: fcValidateName,
|
|
742
|
+
},
|
|
676
743
|
compCode: { value: COMP_STARTER },
|
|
677
744
|
},
|
|
678
745
|
inputs: 0,
|
|
679
746
|
outputs: 0,
|
|
680
747
|
icon: "font-awesome/fa-cube",
|
|
681
|
-
paletteLabel: "
|
|
748
|
+
paletteLabel: "React Component",
|
|
749
|
+
inputLabels: [],
|
|
750
|
+
outputLabels: [],
|
|
751
|
+
/**
|
|
752
|
+
* Canvas label resolver — falls back through `name → compName →
|
|
753
|
+
* "component"` so a freshly-dropped node shows something readable
|
|
754
|
+
* before the user opens the edit dialog.
|
|
755
|
+
* @returns {string}
|
|
756
|
+
*/
|
|
682
757
|
label: function () {
|
|
683
758
|
return this.name || this.compName || "component";
|
|
684
759
|
},
|
|
685
760
|
|
|
761
|
+
/**
|
|
762
|
+
* Node-RED editor lifecycle: called every time the user opens the
|
|
763
|
+
* edit dialog for this node. Loads Monaco lazily, creates a JSX
|
|
764
|
+
* model with a stable URI (`file:///fc-comp-<nodeId>.jsx`) and
|
|
765
|
+
* attaches it to the `#fcc-monaco` container. Falls back to a
|
|
766
|
+
* `<textarea>` if Monaco fails to load.
|
|
767
|
+
* @returns {void}
|
|
768
|
+
*/
|
|
686
769
|
oneditprepare: function () {
|
|
687
770
|
const node = this;
|
|
688
771
|
const code = node.compCode || COMP_STARTER;
|
|
@@ -770,6 +853,12 @@
|
|
|
770
853
|
});
|
|
771
854
|
},
|
|
772
855
|
|
|
856
|
+
/**
|
|
857
|
+
* Editor → flow: copy Monaco editor contents into the hidden
|
|
858
|
+
* `#node-input-compCode` input and the node's `compCode` property,
|
|
859
|
+
* then dispose Monaco's model + editor to free GPU/CPU resources.
|
|
860
|
+
* @returns {void}
|
|
861
|
+
*/
|
|
773
862
|
oneditsave: function () {
|
|
774
863
|
console.log("[FC-Monaco] COMP oneditsave");
|
|
775
864
|
const code = compEditorInstance
|
|
@@ -785,6 +874,12 @@
|
|
|
785
874
|
}
|
|
786
875
|
},
|
|
787
876
|
|
|
877
|
+
/**
|
|
878
|
+
* User cancelled the dialog — discard Monaco state without writing
|
|
879
|
+
* back to the node. Same dispose pattern as oneditsave to avoid
|
|
880
|
+
* leaking model graphs.
|
|
881
|
+
* @returns {void}
|
|
882
|
+
*/
|
|
788
883
|
oneditcancel: function () {
|
|
789
884
|
console.log("[FC-Monaco] COMP oneditcancel");
|
|
790
885
|
if (compEditorInstance) {
|
|
@@ -795,6 +890,13 @@
|
|
|
795
890
|
}
|
|
796
891
|
},
|
|
797
892
|
|
|
893
|
+
/**
|
|
894
|
+
* Resize handler — Monaco doesn't react to container size changes
|
|
895
|
+
* unless we call `editor.layout()` explicitly. Subtracts ~180 px of
|
|
896
|
+
* chrome (label, buttons, padding) from the dialog height.
|
|
897
|
+
* @param {{height: number, width: number}} size
|
|
898
|
+
* @returns {void}
|
|
899
|
+
*/
|
|
798
900
|
oneditresize: function (size) {
|
|
799
901
|
let h = size.height - 180;
|
|
800
902
|
if (h < 150) h = 150;
|
|
@@ -872,6 +974,35 @@
|
|
|
872
974
|
(function () {
|
|
873
975
|
let utilEditorInstance = null;
|
|
874
976
|
|
|
977
|
+
// Local copy of the identifier validator — each editor <script> block is
|
|
978
|
+
// wrapped in its own IIFE, so the helper defined in the fc-portal-component
|
|
979
|
+
// block above is out of scope here. Mirrors lib/helpers.js isSafeName.
|
|
980
|
+
const FC_NAME_RE = /^[A-Za-z_$][\w$]*$/;
|
|
981
|
+
const FC_NAME_MAX = 64;
|
|
982
|
+
const FC_NAME_BLACKLIST = [
|
|
983
|
+
"__proto__", "constructor", "prototype", "hasOwnProperty",
|
|
984
|
+
"isPrototypeOf", "propertyIsEnumerable", "toString", "valueOf",
|
|
985
|
+
"toLocaleString",
|
|
986
|
+
];
|
|
987
|
+
/**
|
|
988
|
+
* Mirror of `lib/helpers.js#isSafeName` — used as `defaults.utilName.validate`
|
|
989
|
+
* so the editor flags invalid identifiers in red before the user can
|
|
990
|
+
* deploy a node that the server would later reject. Rules: non-empty
|
|
991
|
+
* string, ≤ 64 chars, JS identifier syntax, not on the prototype-key
|
|
992
|
+
* blacklist.
|
|
993
|
+
* @param {unknown} v
|
|
994
|
+
* @returns {boolean}
|
|
995
|
+
*/
|
|
996
|
+
function fcValidateName(v) {
|
|
997
|
+
return (
|
|
998
|
+
typeof v === "string" &&
|
|
999
|
+
v.length > 0 &&
|
|
1000
|
+
v.length <= FC_NAME_MAX &&
|
|
1001
|
+
FC_NAME_RE.test(v) &&
|
|
1002
|
+
FC_NAME_BLACKLIST.indexOf(v) === -1
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
875
1006
|
const UTIL_STARTER = [
|
|
876
1007
|
"// Helpers / custom hooks / constants — top-level, no React component.",
|
|
877
1008
|
"// Each symbol declared here is available globally in any portal that",
|
|
@@ -899,17 +1030,33 @@
|
|
|
899
1030
|
color: "#fbbf24",
|
|
900
1031
|
defaults: {
|
|
901
1032
|
name: { value: "" },
|
|
902
|
-
utilName: {
|
|
1033
|
+
utilName: {
|
|
1034
|
+
value: "myHelpers",
|
|
1035
|
+
required: true,
|
|
1036
|
+
validate: fcValidateName,
|
|
1037
|
+
},
|
|
903
1038
|
utilCode: { value: UTIL_STARTER },
|
|
904
1039
|
},
|
|
905
1040
|
inputs: 0,
|
|
906
1041
|
outputs: 0,
|
|
907
1042
|
icon: "font-awesome/fa-wrench",
|
|
908
|
-
paletteLabel: "
|
|
1043
|
+
paletteLabel: "React Utility",
|
|
1044
|
+
inputLabels: [],
|
|
1045
|
+
outputLabels: [],
|
|
1046
|
+
/**
|
|
1047
|
+
* Canvas label — `name → utilName → "utility"`.
|
|
1048
|
+
* @returns {string}
|
|
1049
|
+
*/
|
|
909
1050
|
label: function () {
|
|
910
1051
|
return this.name || this.utilName || "utility";
|
|
911
1052
|
},
|
|
912
1053
|
|
|
1054
|
+
/**
|
|
1055
|
+
* Editor lifecycle for `fc-portal-utility`: lazy-load Monaco,
|
|
1056
|
+
* create a JS model with URI `file:///fc-util-<nodeId>.js` (note
|
|
1057
|
+
* `.js` not `.jsx` — utility code is plain JS).
|
|
1058
|
+
* @returns {void}
|
|
1059
|
+
*/
|
|
913
1060
|
oneditprepare: function () {
|
|
914
1061
|
const node = this;
|
|
915
1062
|
const code = node.utilCode || UTIL_STARTER;
|
|
@@ -941,6 +1088,11 @@
|
|
|
941
1088
|
});
|
|
942
1089
|
},
|
|
943
1090
|
|
|
1091
|
+
/**
|
|
1092
|
+
* Editor → flow: write Monaco contents back to `utilCode`, dispose
|
|
1093
|
+
* model+editor.
|
|
1094
|
+
* @returns {void}
|
|
1095
|
+
*/
|
|
944
1096
|
oneditsave: function () {
|
|
945
1097
|
const code = utilEditorInstance
|
|
946
1098
|
? utilEditorInstance.getValue()
|
|
@@ -954,6 +1106,10 @@
|
|
|
954
1106
|
}
|
|
955
1107
|
},
|
|
956
1108
|
|
|
1109
|
+
/**
|
|
1110
|
+
* Discard Monaco state without writing back.
|
|
1111
|
+
* @returns {void}
|
|
1112
|
+
*/
|
|
957
1113
|
oneditcancel: function () {
|
|
958
1114
|
if (utilEditorInstance) {
|
|
959
1115
|
utilEditorInstance.getModel().dispose();
|
|
@@ -962,6 +1118,12 @@
|
|
|
962
1118
|
}
|
|
963
1119
|
},
|
|
964
1120
|
|
|
1121
|
+
/**
|
|
1122
|
+
* Layout Monaco on dialog resize. -200 px chrome for the utility
|
|
1123
|
+
* dialog (taller because of the symbol-list panel).
|
|
1124
|
+
* @param {{height: number, width: number}} size
|
|
1125
|
+
* @returns {void}
|
|
1126
|
+
*/
|
|
965
1127
|
oneditresize: function (size) {
|
|
966
1128
|
let h = size.height - 200;
|
|
967
1129
|
if (h < 150) h = 150;
|
|
@@ -1232,11 +1394,33 @@ function useDebounce(value, ms = 300) {
|
|
|
1232
1394
|
inputs: 1,
|
|
1233
1395
|
outputs: 1,
|
|
1234
1396
|
icon: "font-awesome/fa-desktop",
|
|
1235
|
-
paletteLabel: "
|
|
1397
|
+
paletteLabel: "React Portal",
|
|
1398
|
+
// Source AND sink — we keep the default left-aligned input even though
|
|
1399
|
+
// Node-RED appearance docs suggest align: 'right' for nodes that sit at
|
|
1400
|
+
// the end of a flow. This portal is hybrid: the input wire pushes msgs
|
|
1401
|
+
// into the browser, while the output wire forwards UI events out. Left
|
|
1402
|
+
// alignment matches the dominant "msg → render" direction.
|
|
1403
|
+
inputLabels: ["broadcast / unicast msg → page"],
|
|
1404
|
+
outputLabels: ["UI event from page"],
|
|
1405
|
+
/**
|
|
1406
|
+
* Canvas label — shows `name` or the deployed URL (`/fromcubes/<subPath>`).
|
|
1407
|
+
* @returns {string}
|
|
1408
|
+
*/
|
|
1236
1409
|
label: function () {
|
|
1237
1410
|
return this.name || ("/fromcubes/" + (this.subPath || "?"));
|
|
1238
1411
|
},
|
|
1239
1412
|
|
|
1413
|
+
/**
|
|
1414
|
+
* Editor lifecycle for the portal node. Heaviest of the three —
|
|
1415
|
+
* creates two Monaco editors (JSX + Head HTML), wires up:
|
|
1416
|
+
* - URL hint that previews `/fromcubes/<subPath>`
|
|
1417
|
+
* - Legacy `endpoint` field migration warning
|
|
1418
|
+
* - `RED.tabs` for JSX / Properties / Head HTML panels
|
|
1419
|
+
* - Component picker dialog (`#fc-btn-components`)
|
|
1420
|
+
* - Utility picker dialog (`#fc-btn-utilities`)
|
|
1421
|
+
* - Portal Assets sidebar tab via `RED.sidebar.addTab` (once)
|
|
1422
|
+
* @returns {void}
|
|
1423
|
+
*/
|
|
1240
1424
|
oneditprepare: function () {
|
|
1241
1425
|
const node = this;
|
|
1242
1426
|
const code = node.componentCode || STARTER;
|
|
@@ -1672,6 +1856,13 @@ function useDebounce(value, ms = 300) {
|
|
|
1672
1856
|
});
|
|
1673
1857
|
},
|
|
1674
1858
|
|
|
1859
|
+
/**
|
|
1860
|
+
* Editor → flow. Normalises `subPath` (trim), clears the legacy
|
|
1861
|
+
* `endpoint` field, collects `libs` from the editableList widget,
|
|
1862
|
+
* writes both Monaco editors back to hidden inputs + node props,
|
|
1863
|
+
* then disposes both editors.
|
|
1864
|
+
* @returns {void}
|
|
1865
|
+
*/
|
|
1675
1866
|
oneditsave: function () {
|
|
1676
1867
|
console.log("[FC-Monaco] PORTAL oneditsave");
|
|
1677
1868
|
|
|
@@ -1718,6 +1909,12 @@ function useDebounce(value, ms = 300) {
|
|
|
1718
1909
|
}
|
|
1719
1910
|
},
|
|
1720
1911
|
|
|
1912
|
+
/**
|
|
1913
|
+
* Discard both Monaco editors without writing back to the node.
|
|
1914
|
+
* Identical dispose pattern to oneditsave so we never leak Monaco
|
|
1915
|
+
* graphs even if the user spams Edit / Cancel rapidly.
|
|
1916
|
+
* @returns {void}
|
|
1917
|
+
*/
|
|
1721
1918
|
oneditcancel: function () {
|
|
1722
1919
|
console.log("[FC-Monaco] PORTAL oneditcancel");
|
|
1723
1920
|
if (editorInstance) {
|
|
@@ -1734,6 +1931,13 @@ function useDebounce(value, ms = 300) {
|
|
|
1734
1931
|
}
|
|
1735
1932
|
},
|
|
1736
1933
|
|
|
1934
|
+
/**
|
|
1935
|
+
* Dialog resize → compute available editor height by subtracting
|
|
1936
|
+
* the heights of non-tab form rows + tab strip + 30 px padding,
|
|
1937
|
+
* then re-layout both Monaco editors. Falls back to 200 px minimum.
|
|
1938
|
+
* @param {{height: number, width: number}} size
|
|
1939
|
+
* @returns {void}
|
|
1940
|
+
*/
|
|
1737
1941
|
oneditresize: function (size) {
|
|
1738
1942
|
const tabsH = $("#fc-tabs").outerHeight(true) || 0;
|
|
1739
1943
|
const rows = $(
|
|
@@ -1879,6 +2083,11 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1879
2083
|
const nodeRoot = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
|
|
1880
2084
|
const publicBase = nodeRoot + "/fromcubes/public/";
|
|
1881
2085
|
|
|
2086
|
+
/**
|
|
2087
|
+
* Human-readable byte formatter for the assets sidebar.
|
|
2088
|
+
* @param {number} bytes
|
|
2089
|
+
* @returns {string} e.g. `"42 B"`, `"3.2 KB"`, `"1.4 MB"`.
|
|
2090
|
+
*/
|
|
1882
2091
|
function formatSize(bytes) {
|
|
1883
2092
|
if (bytes < 1024) return bytes + " B";
|
|
1884
2093
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
|
@@ -1902,6 +2111,11 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1902
2111
|
.appendTo(toolbar);
|
|
1903
2112
|
|
|
1904
2113
|
// ── Helpers ──
|
|
2114
|
+
/**
|
|
2115
|
+
* Linear scan over the most-recently-fetched asset list.
|
|
2116
|
+
* @param {string} p Relative path inside the assets root.
|
|
2117
|
+
* @returns {boolean}
|
|
2118
|
+
*/
|
|
1905
2119
|
function pathExists(p) {
|
|
1906
2120
|
for (let i = 0; i < allEntries.length; i++) {
|
|
1907
2121
|
if (allEntries[i].name === p) return true;
|
|
@@ -1909,6 +2123,16 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1909
2123
|
return false;
|
|
1910
2124
|
}
|
|
1911
2125
|
|
|
2126
|
+
/**
|
|
2127
|
+
* POST one or more files to the upload endpoint. Prompts before
|
|
2128
|
+
* overwriting existing paths. Each request is sent independently so
|
|
2129
|
+
* an upstream failure of one file does not block the rest; the sidebar
|
|
2130
|
+
* is refreshed once all responses have arrived.
|
|
2131
|
+
* @param {FileList|Array<File>} files
|
|
2132
|
+
* @param {string} targetDir Relative directory inside the assets root
|
|
2133
|
+
* (empty string for root).
|
|
2134
|
+
* @returns {void}
|
|
2135
|
+
*/
|
|
1912
2136
|
function uploadFiles(files, targetDir) {
|
|
1913
2137
|
if (!files || files.length === 0) return;
|
|
1914
2138
|
let toUpload = [];
|
|
@@ -1976,6 +2200,15 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1976
2200
|
}
|
|
1977
2201
|
});
|
|
1978
2202
|
|
|
2203
|
+
/**
|
|
2204
|
+
* Move or rename an asset. Computes the new path from `fromPath`'s basename
|
|
2205
|
+
* + `toDir`. No-ops when source and destination are equal. Prompts before
|
|
2206
|
+
* overwriting an existing destination. Posts to the `move` admin endpoint
|
|
2207
|
+
* (auth- and CSRF-gated server-side); the sidebar refreshes on success.
|
|
2208
|
+
* @param {string} fromPath Current relative path of the item.
|
|
2209
|
+
* @param {string} toDir Destination directory (empty string for root).
|
|
2210
|
+
* @returns {void}
|
|
2211
|
+
*/
|
|
1979
2212
|
function moveItem(fromPath, toDir) {
|
|
1980
2213
|
const filename = fromPath.split("/").pop();
|
|
1981
2214
|
const newPath = toDir ? toDir + "/" + filename : filename;
|
|
@@ -2347,6 +2580,12 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
2347
2580
|
});
|
|
2348
2581
|
}
|
|
2349
2582
|
|
|
2583
|
+
/**
|
|
2584
|
+
* Re-fetch the assets listing from the admin API and re-render the tree.
|
|
2585
|
+
* Called after every mutation (upload/move/delete/mkdir) and on the
|
|
2586
|
+
* Node-RED `sidebar:resize` event.
|
|
2587
|
+
* @returns {void}
|
|
2588
|
+
*/
|
|
2350
2589
|
function refreshList() {
|
|
2351
2590
|
$.getJSON("portal-react/assets", function (entries) {
|
|
2352
2591
|
allEntries = entries || [];
|