@aaqu/fromcubes-portal-react 0.1.0-alpha.21 → 0.1.0-alpha.23

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.
@@ -3,7 +3,7 @@
3
3
  ============================================================ -->
4
4
  <script type="text/javascript">
5
5
  (function () {
6
- var PREFIX = "[FC-Monaco]";
6
+ const PREFIX = "[FC-Monaco]";
7
7
 
8
8
  // Shared editor options — used by both component and portal-react editors
9
9
  window.__fcEditorOpts = {
@@ -20,10 +20,18 @@
20
20
  };
21
21
 
22
22
  window.__fcTwClasses = null;
23
- var jsxSetupDone = false;
24
-
25
- // One-time Monaco setup: compiler opts, diagnostics, extra libs, completion provider.
26
- // Idempotent safe to call multiple times, only runs setup once.
23
+ let jsxSetupDone = false;
24
+
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(
@@ -36,7 +44,7 @@
36
44
  }
37
45
  jsxSetupDone = true;
38
46
 
39
- var jsDef = monaco.typescript.javascriptDefaults;
47
+ const jsDef = monaco.typescript.javascriptDefaults;
40
48
 
41
49
  // Log initial state
42
50
  console.log(
@@ -53,9 +61,9 @@
53
61
  applyCompilerAndDiag();
54
62
 
55
63
  // Global type stubs
56
- var libContent = [
57
- "declare var React: any;",
58
- "declare var ReactDOM: any;",
64
+ const libContent = [
65
+ "declare const React: any;",
66
+ "declare const ReactDOM: any;",
59
67
  "declare function useNodeRed(opts?: { ignoreRecovery?: boolean }): { data: any; send: (payload: any, topic?: string) => void; user: { userId?: string; userName?: string; username?: string; email?: string; role?: string; groups?: any[] } | null; portalClient: string | null };",
60
68
  ].join("\n");
61
69
  console.log(PREFIX, "addExtraLib globals.d.ts");
@@ -68,44 +76,44 @@
68
76
  monaco.languages.registerCompletionItemProvider("javascript", {
69
77
  triggerCharacters: ['"', "'", "`", " "],
70
78
  provideCompletionItems: function (model, position) {
71
- var line = model.getLineContent(position.lineNumber);
72
- var before = line.substring(0, position.column - 1);
79
+ const line = model.getLineContent(position.lineNumber);
80
+ const before = line.substring(0, position.column - 1);
73
81
 
74
82
  // 1) className="..." or className={`...`}
75
- var isClassName =
83
+ const isClassName =
76
84
  before.match(/className\s*=\s*["'][^"']*$/) ||
77
85
  before.match(/className\s*=\s*\{`[^`]*$/);
78
86
 
79
87
  // 2) Any open string literal: "..., '..., or `...
80
- var inString = !isClassName && before.match(/["'`][^"'`]*$/);
88
+ const inString = !isClassName && before.match(/["'`][^"'`]*$/);
81
89
 
82
90
  if (!isClassName && !inString) return { suggestions: [] };
83
91
 
84
92
  // Extract text inside the string
85
- var raw = (isClassName || inString)[0];
86
- var inside;
93
+ const raw = (isClassName || inString)[0];
94
+ let inside;
87
95
  if (isClassName) {
88
96
  inside = raw.replace(/^className\s*=\s*(?:\{`|["'])/, "");
89
97
  } else {
90
98
  inside = raw.substring(1); // skip opening quote
91
99
  }
92
100
  inside = inside.replace(/\$\{[^}]*\}/g, " ");
93
- var parts = inside.split(/\s+/);
94
- var word = parts[parts.length - 1] || "";
101
+ const parts = inside.split(/\s+/);
102
+ const word = parts[parts.length - 1] || "";
95
103
 
96
104
  // For non-className strings, only activate if at least one existing
97
105
  // token in the string looks like a TW class (avoid noise in random strings)
98
106
  if (!isClassName && parts.length > 0) {
99
- var classes = window.__fcTwClasses || [];
100
- var twSet = window.__fcTwSet;
107
+ const classes = window.__fcTwClasses || [];
108
+ let twSet = window.__fcTwSet;
101
109
  if (!twSet && classes.length) {
102
110
  twSet = new Set(classes);
103
111
  window.__fcTwSet = twSet;
104
112
  console.log(PREFIX, "TW: built lookup Set, size=" + twSet.size);
105
113
  }
106
114
  if (twSet) {
107
- var hasTwToken = false;
108
- for (var p = 0; p < parts.length; p++) {
115
+ let hasTwToken = false;
116
+ for (let p = 0; p < parts.length; p++) {
109
117
  if (parts[p] && twSet.has(parts[p])) {
110
118
  hasTwToken = true;
111
119
  break;
@@ -113,7 +121,7 @@
113
121
  }
114
122
  // If typing the very first word, accept if it looks like a prefix
115
123
  if (!hasTwToken && parts.length === 1 && word.length >= 2) {
116
- for (var j = 0; j < classes.length; j++) {
124
+ for (let j = 0; j < classes.length; j++) {
117
125
  if (classes[j].indexOf(word) === 0) {
118
126
  hasTwToken = true;
119
127
  break;
@@ -124,17 +132,17 @@
124
132
  }
125
133
  }
126
134
 
127
- var range = {
135
+ const range = {
128
136
  startLineNumber: position.lineNumber,
129
137
  endLineNumber: position.lineNumber,
130
138
  startColumn: position.column - word.length,
131
139
  endColumn: position.column,
132
140
  };
133
141
 
134
- var classes = window.__fcTwClasses || [];
135
- var suggestions = [];
136
- for (var i = 0; i < classes.length; i++) {
137
- var cls = classes[i];
142
+ const classes = window.__fcTwClasses || [];
143
+ const suggestions = [];
144
+ for (let i = 0; i < classes.length; i++) {
145
+ const cls = classes[i];
138
146
  if (!word.length || cls.indexOf(word) === 0) {
139
147
  suggestions.push({
140
148
  label: cls,
@@ -162,7 +170,7 @@
162
170
 
163
171
  // JSX/HTML tag completion — type tag name, Tab → <tag>|</tag>
164
172
  console.log(PREFIX, "registering JSX tag completion provider");
165
- var HTML_TAGS = [
173
+ const HTML_TAGS = [
166
174
  "div",
167
175
  "span",
168
176
  "p",
@@ -238,40 +246,40 @@
238
246
  "use",
239
247
  "text",
240
248
  ];
241
- var VOID_TAGS = new Set(["br", "hr", "img", "input", "wbr", "source"]);
249
+ const VOID_TAGS = new Set(["br", "hr", "img", "input", "wbr", "source"]);
242
250
 
243
251
  monaco.languages.registerCompletionItemProvider("javascript", {
244
252
  triggerCharacters: ["<"],
245
253
  provideCompletionItems: function (model, position) {
246
- var line = model.getLineContent(position.lineNumber);
247
- var before = line.substring(0, position.column - 1);
254
+ const line = model.getLineContent(position.lineNumber);
255
+ const before = line.substring(0, position.column - 1);
248
256
 
249
257
  // After "<" — suggest tags
250
- var afterBracket = before.match(/<([a-zA-Z]*)$/);
258
+ const afterBracket = before.match(/<([a-zA-Z]*)$/);
251
259
  // Bare word at line start or after whitespace/{ — Emmet-like (lower or upper)
252
- var bareWord =
260
+ const bareWord =
253
261
  !afterBracket &&
254
262
  before.match(/(?:^|[\s{(,])([a-zA-Z][a-zA-Z0-9]*)$/);
255
263
 
256
264
  if (!afterBracket && !bareWord) return { suggestions: [] };
257
265
 
258
- var typed = (afterBracket ? afterBracket[1] : bareWord[1]) || "";
259
- var replaceStart =
266
+ const typed = (afterBracket ? afterBracket[1] : bareWord[1]) || "";
267
+ const replaceStart =
260
268
  position.column - typed.length - (afterBracket ? 1 : 0);
261
269
 
262
- var range = {
270
+ const range = {
263
271
  startLineNumber: position.lineNumber,
264
272
  endLineNumber: position.lineNumber,
265
273
  startColumn: replaceStart,
266
274
  endColumn: position.column,
267
275
  };
268
276
 
269
- var suggestions = [];
270
- var seen = new Set();
277
+ const suggestions = [];
278
+ const seen = new Set();
271
279
 
272
280
  // HTML tags
273
- for (var i = 0; i < HTML_TAGS.length; i++) {
274
- var tag = HTML_TAGS[i];
281
+ for (let i = 0; i < HTML_TAGS.length; i++) {
282
+ const tag = HTML_TAGS[i];
275
283
  if (typed && tag.indexOf(typed) !== 0) continue;
276
284
  seen.add(tag);
277
285
  if (VOID_TAGS.has(tag)) {
@@ -310,11 +318,11 @@
310
318
  }
311
319
 
312
320
  // Custom components from registry (PascalCase)
313
- var upperMatch =
321
+ const upperMatch =
314
322
  afterBracket && before.match(/<([A-Z][a-zA-Z0-9]*)$/);
315
- var bareUpper =
323
+ const bareUpper =
316
324
  !afterBracket && before.match(/(?:^|[\s{(,])([A-Z][a-zA-Z0-9]*)$/);
317
- var compTyped = upperMatch
325
+ const compTyped = upperMatch
318
326
  ? upperMatch[1]
319
327
  : bareUpper
320
328
  ? bareUpper[1]
@@ -322,9 +330,9 @@
322
330
 
323
331
  if (compTyped || (!typed && afterBracket)) {
324
332
  // Fetch component names from registry (cached on window)
325
- var reg = window.__fcComponentNames || [];
326
- for (var c = 0; c < reg.length; c++) {
327
- var name = reg[c];
333
+ const reg = window.__fcComponentNames || [];
334
+ for (let c = 0; c < reg.length; c++) {
335
+ const name = reg[c];
328
336
  if (seen.has(name)) continue;
329
337
  if (compTyped && name.indexOf(compTyped) !== 0) continue;
330
338
  if (
@@ -396,16 +404,16 @@
396
404
  window.__fcAttachSelfClose = function (editor) {
397
405
  editor.onDidChangeModelContent(function (e) {
398
406
  if (e.changes.length !== 1) return;
399
- var ch = e.changes[0];
407
+ const ch = e.changes[0];
400
408
  if (ch.text !== "/") return;
401
- var model = editor.getModel();
409
+ const model = editor.getModel();
402
410
  if (!model) return;
403
- var lineNum = ch.range.startLineNumber;
404
- var line = model.getLineContent(lineNum);
411
+ const lineNum = ch.range.startLineNumber;
412
+ const line = model.getLineContent(lineNum);
405
413
  // Pattern 1: <tag>/</tag> (cursor was between > and </)
406
414
  // Pattern 2: <tag/></tag> (cursor was before > in opening tag)
407
- var m = line.match(/^(.*)<([a-zA-Z][a-zA-Z0-9]*)>\/<\/\2>(.*)$/);
408
- var matchStr, tag, prefix;
415
+ let m = line.match(/^(.*)<([a-zA-Z][a-zA-Z0-9]*)>\/<\/\2>(.*)$/);
416
+ let matchStr, tag, prefix;
409
417
  if (m) {
410
418
  prefix = m[1];
411
419
  tag = m[2];
@@ -417,10 +425,10 @@
417
425
  tag = m[2];
418
426
  matchStr = "<" + tag + "/></" + tag + ">";
419
427
  }
420
- var startCol = prefix.length + 1;
421
- var endCol = startCol + matchStr.length;
422
- var replacement = "<" + tag + " />";
423
- var cursorCol = startCol + replacement.length - 2;
428
+ const startCol = prefix.length + 1;
429
+ const endCol = startCol + matchStr.length;
430
+ const replacement = "<" + tag + " />";
431
+ const cursorCol = startCol + replacement.length - 2;
424
432
  setTimeout(function () {
425
433
  editor.executeEdits("self-close-collapse", [
426
434
  {
@@ -439,7 +447,15 @@
439
447
  console.log(PREFIX, "self-close collapse attached to editor");
440
448
  };
441
449
 
442
- // Fetch component names for tag completion
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);
@@ -454,10 +470,72 @@
454
470
  }
455
471
  refreshComponentNames();
456
472
 
473
+ // Fetch utility nodes + their parsed top-level symbols.
474
+ // Window cache shape: { byNode: { utilName: [symbol, ...] }, allSymbols: [..] }
475
+ window.__fcRefreshUtilities = function () {
476
+ $.getJSON("portal-react/utilities", function (reg) {
477
+ const byNode = {};
478
+ const all = new Set();
479
+ Object.keys(reg).forEach(function (n) {
480
+ const syms = reg[n].symbols || [];
481
+ byNode[n] = syms;
482
+ syms.forEach(function (s) { all.add(s); });
483
+ });
484
+ window.__fcUtilities = { byNode: byNode, allSymbols: [...all] };
485
+ console.log(
486
+ PREFIX,
487
+ "utilities loaded: " + Object.keys(byNode).length + " nodes, " + all.size + " symbols",
488
+ );
489
+ }).fail(function () {
490
+ window.__fcUtilities = { byNode: {}, allSymbols: [] };
491
+ });
492
+ };
493
+ window.__fcRefreshUtilities();
494
+
495
+ // Utility-symbol completion: suggests bare identifiers anywhere in JS
496
+ // context EXCEPT after "<" (that's JSX tag completion, handled below).
497
+ monaco.languages.registerCompletionItemProvider("javascript", {
498
+ triggerCharacters: ["."], // jQuery-ish — but we mostly fire from word context
499
+ provideCompletionItems: function (model, position) {
500
+ const line = model.getLineContent(position.lineNumber);
501
+ const before = line.substring(0, position.column - 1);
502
+ // Skip JSX tag context — covered by other providers
503
+ if (/<[a-zA-Z]*$/.test(before)) return { suggestions: [] };
504
+ // Skip property access — let Monaco's language service handle it
505
+ if (/\.\s*\w*$/.test(before)) return { suggestions: [] };
506
+ // Skip inside strings
507
+ if (/["'`][^"'`]*$/.test(before)) return { suggestions: [] };
508
+ const wordMatch = before.match(/([A-Za-z_$][\w$]*)$/);
509
+ const word = wordMatch ? wordMatch[1] : "";
510
+ const all = (window.__fcUtilities && window.__fcUtilities.allSymbols) || [];
511
+ if (!all.length) return { suggestions: [] };
512
+ const range = {
513
+ startLineNumber: position.lineNumber,
514
+ endLineNumber: position.lineNumber,
515
+ startColumn: position.column - word.length,
516
+ endColumn: position.column,
517
+ };
518
+ const suggestions = [];
519
+ for (let i = 0; i < all.length; i++) {
520
+ const sym = all[i];
521
+ if (word && sym.indexOf(word) !== 0) continue;
522
+ suggestions.push({
523
+ label: sym,
524
+ kind: monaco.languages.CompletionItemKind.Function,
525
+ insertText: sym,
526
+ range: range,
527
+ detail: "fc-portal-utility",
528
+ sortText: "1" + sym,
529
+ });
530
+ }
531
+ return { suggestions: suggestions };
532
+ },
533
+ });
534
+
457
535
  // Marker listener for debugging
458
536
  monaco.editor.onDidChangeMarkers(function (uris) {
459
537
  uris.forEach(function (uri) {
460
- var markers = monaco.editor.getModelMarkers({ resource: uri });
538
+ const markers = monaco.editor.getModelMarkers({ resource: uri });
461
539
  if (markers.length > 0) {
462
540
  console.group(PREFIX + " MARKERS on " + uri.toString());
463
541
  markers.forEach(function (m) {
@@ -485,9 +563,20 @@
485
563
  console.log(PREFIX, "one-time setup complete");
486
564
  }
487
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
+ */
488
577
  function applyCompilerAndDiag() {
489
- var jsDef = monaco.typescript.javascriptDefaults;
490
- var compilerOpts = {
578
+ const jsDef = monaco.typescript.javascriptDefaults;
579
+ const compilerOpts = {
491
580
  jsx: monaco.typescript.JsxEmit.React,
492
581
  target: monaco.typescript.ScriptTarget.ESNext,
493
582
  module: monaco.typescript.ModuleKind.ESNext,
@@ -497,7 +586,7 @@
497
586
  console.log(PREFIX, "setCompilerOptions:", JSON.stringify(compilerOpts));
498
587
  jsDef.setCompilerOptions(compilerOpts);
499
588
 
500
- var diagOpts = {
589
+ const diagOpts = {
501
590
  noSemanticValidation: true,
502
591
  // Server-side esbuild does the real syntax check at deploy time;
503
592
  // Monaco's TS parser produces noisy false positives on raw JSX
@@ -521,11 +610,28 @@
521
610
  );
522
611
  }
523
612
 
524
- // Exported for oneditprepare to call before model creation
613
+ /**
614
+ * Re-apply JSX defaults before Monaco model creation. Exported for
615
+ * `oneditprepare` to call. Idempotent — wraps `ensureJsxSetup`.
616
+ * @returns {void}
617
+ */
525
618
  window.__fcApplyJsxDefaults = function () {
526
619
  ensureJsxSetup();
527
620
  };
528
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
+ */
529
635
  window.__fcLoadMonaco = function (cb) {
530
636
  console.log(PREFIX, "loadMonaco called, monaco exists:", !!window.monaco);
531
637
  if (window.monaco) {
@@ -537,7 +643,7 @@
537
643
  cb();
538
644
  return;
539
645
  }
540
- var s = document.createElement("script");
646
+ const s = document.createElement("script");
541
647
  s.src = "portal-react/vs/loader.js";
542
648
  s.onload = function () {
543
649
  console.log(PREFIX, "loader.js loaded, configuring require paths");
@@ -590,9 +696,9 @@
590
696
 
591
697
  <script type="text/javascript">
592
698
  (function () {
593
- var compEditorInstance = null;
699
+ let compEditorInstance = null;
594
700
 
595
- var COMP_STARTER = [
701
+ const COMP_STARTER = [
596
702
  "function StatusCard({ label, value, unit }) {",
597
703
  " return (",
598
704
  ' <div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-6">',
@@ -605,25 +711,64 @@
605
711
  "}",
606
712
  ].join("\n");
607
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
+
608
733
  RED.nodes.registerType("fc-portal-component", {
609
734
  category: "fromcubes",
610
735
  color: "#a8d8ea",
611
736
  defaults: {
612
737
  name: { value: "" },
613
- compName: { value: "StatusCard", required: true },
738
+ compName: {
739
+ value: "StatusCard",
740
+ required: true,
741
+ validate: fcValidateName,
742
+ },
614
743
  compCode: { value: COMP_STARTER },
615
744
  },
616
745
  inputs: 0,
617
746
  outputs: 0,
618
747
  icon: "font-awesome/fa-cube",
619
- paletteLabel: "fromcubes component",
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
+ */
620
757
  label: function () {
621
758
  return this.name || this.compName || "component";
622
759
  },
623
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
+ */
624
769
  oneditprepare: function () {
625
- var node = this;
626
- var code = node.compCode || COMP_STARTER;
770
+ const node = this;
771
+ const code = node.compCode || COMP_STARTER;
627
772
  console.log("[FC-Monaco] COMP oneditprepare, node.id=" + node.id);
628
773
 
629
774
  if (!window.__fcTwClasses) {
@@ -657,14 +802,14 @@
657
802
  );
658
803
  window.__fcApplyJsxDefaults();
659
804
 
660
- var compUri = monaco.Uri.parse("file:///fc-comp-" + node.id + ".jsx");
805
+ const compUri = monaco.Uri.parse("file:///fc-comp-" + node.id + ".jsx");
661
806
  console.log("[FC-Monaco] COMP: model URI=" + compUri.toString());
662
- var existingModel = monaco.editor.getModel(compUri);
807
+ const existingModel = monaco.editor.getModel(compUri);
663
808
  if (existingModel) {
664
809
  console.log("[FC-Monaco] COMP: disposing existing model");
665
810
  existingModel.dispose();
666
811
  }
667
- var compModel = monaco.editor.createModel(
812
+ const compModel = monaco.editor.createModel(
668
813
  code,
669
814
  "javascript",
670
815
  compUri,
@@ -682,7 +827,7 @@
682
827
 
683
828
  // Log markers after a short delay (diagnostics are async)
684
829
  setTimeout(function () {
685
- var markers = monaco.editor.getModelMarkers({ resource: compUri });
830
+ const markers = monaco.editor.getModelMarkers({ resource: compUri });
686
831
  console.log(
687
832
  "[FC-Monaco] COMP: markers after 500ms, count=" + markers.length,
688
833
  );
@@ -708,9 +853,15 @@
708
853
  });
709
854
  },
710
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
+ */
711
862
  oneditsave: function () {
712
863
  console.log("[FC-Monaco] COMP oneditsave");
713
- var code = compEditorInstance
864
+ const code = compEditorInstance
714
865
  ? compEditorInstance.getValue()
715
866
  : $("#fcc-fallback").val();
716
867
  $("#node-input-compCode").val(code);
@@ -723,6 +874,12 @@
723
874
  }
724
875
  },
725
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
+ */
726
883
  oneditcancel: function () {
727
884
  console.log("[FC-Monaco] COMP oneditcancel");
728
885
  if (compEditorInstance) {
@@ -733,8 +890,15 @@
733
890
  }
734
891
  },
735
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
+ */
736
900
  oneditresize: function (size) {
737
- var h = size.height - 180;
901
+ let h = size.height - 180;
738
902
  if (h < 150) h = 150;
739
903
  $("#fcc-editor-wrap").css("height", h + "px");
740
904
  if (compEditorInstance) compEditorInstance.layout();
@@ -772,6 +936,258 @@
772
936
  </p>
773
937
  </script>
774
938
 
939
+ <!-- ============================================================
940
+ fc-portal-utility – canvas node (helpers / hooks / constants)
941
+ ============================================================ -->
942
+ <script type="text/html" data-template-name="fc-portal-utility">
943
+ <div class="form-row">
944
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
945
+ <input type="text" id="node-input-name" placeholder="Label on canvas" />
946
+ </div>
947
+ <div class="form-row">
948
+ <label for="node-input-utilName"><i class="fa fa-wrench"></i> Module name</label>
949
+ <input type="text" id="node-input-utilName" placeholder="mathHelpers" />
950
+ </div>
951
+ <div class="form-row" style="margin-bottom:4px;">
952
+ <label><i class="fa fa-code"></i> Code</label>
953
+ <div style="font-size:11px;opacity:.5;margin-top:2px;">
954
+ Top-level helpers, custom hooks and constants. Available globally to portals
955
+ that reference any of the symbols declared here.
956
+ </div>
957
+ </div>
958
+ <div class="form-row node-text-editor-row">
959
+ <div
960
+ id="fcu-editor-wrap"
961
+ style="width:100%;height:350px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
962
+ >
963
+ <div id="fcu-monaco" style="width:100%;height:100%;"></div>
964
+ <textarea
965
+ id="fcu-fallback"
966
+ style="display:none;width:100%;height:100%;font-family:'Cascadia Code',Consolas,monospace;font-size:13px;background:#1e1e1e;color:#d4d4d4;border:none;padding:12px;resize:none;"
967
+ ></textarea>
968
+ </div>
969
+ </div>
970
+ <input type="hidden" id="node-input-utilCode" />
971
+ </script>
972
+
973
+ <script type="text/javascript">
974
+ (function () {
975
+ let utilEditorInstance = null;
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
+
1006
+ const UTIL_STARTER = [
1007
+ "// Helpers / custom hooks / constants — top-level, no React component.",
1008
+ "// Each symbol declared here is available globally in any portal that",
1009
+ "// references it (selective inclusion: unused utility nodes are skipped).",
1010
+ "",
1011
+ "const PI2 = Math.PI * 2;",
1012
+ "",
1013
+ "function clamp(n, min, max) {",
1014
+ " return Math.max(min, Math.min(max, n));",
1015
+ "}",
1016
+ "",
1017
+ "// Custom React hook — call from inside App() or library components",
1018
+ "function useDebounce(value, ms = 300) {",
1019
+ " const [v, setV] = React.useState(value);",
1020
+ " React.useEffect(() => {",
1021
+ " const t = setTimeout(() => setV(value), ms);",
1022
+ " return () => clearTimeout(t);",
1023
+ " }, [value, ms]);",
1024
+ " return v;",
1025
+ "}",
1026
+ ].join("\n");
1027
+
1028
+ RED.nodes.registerType("fc-portal-utility", {
1029
+ category: "fromcubes",
1030
+ color: "#fbbf24",
1031
+ defaults: {
1032
+ name: { value: "" },
1033
+ utilName: {
1034
+ value: "myHelpers",
1035
+ required: true,
1036
+ validate: fcValidateName,
1037
+ },
1038
+ utilCode: { value: UTIL_STARTER },
1039
+ },
1040
+ inputs: 0,
1041
+ outputs: 0,
1042
+ icon: "font-awesome/fa-wrench",
1043
+ paletteLabel: "React Utility",
1044
+ inputLabels: [],
1045
+ outputLabels: [],
1046
+ /**
1047
+ * Canvas label — `name → utilName → "utility"`.
1048
+ * @returns {string}
1049
+ */
1050
+ label: function () {
1051
+ return this.name || this.utilName || "utility";
1052
+ },
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
+ */
1060
+ oneditprepare: function () {
1061
+ const node = this;
1062
+ const code = node.utilCode || UTIL_STARTER;
1063
+ console.log("[FC-Monaco] UTIL oneditprepare, node.id=" + node.id);
1064
+
1065
+ if (!window.__fcTwClasses) {
1066
+ $.getJSON("portal-react/tw-classes", function (classes) {
1067
+ window.__fcTwClasses = classes;
1068
+ });
1069
+ }
1070
+
1071
+ window.__fcLoadMonaco(function (failed) {
1072
+ if (failed) {
1073
+ $("#fcu-monaco").hide();
1074
+ $("#fcu-fallback").show().val(code);
1075
+ return;
1076
+ }
1077
+ window.__fcApplyJsxDefaults();
1078
+
1079
+ const utilUri = monaco.Uri.parse("file:///fc-util-" + node.id + ".js");
1080
+ const existingModel = monaco.editor.getModel(utilUri);
1081
+ if (existingModel) existingModel.dispose();
1082
+ const utilModel = monaco.editor.createModel(code, "javascript", utilUri);
1083
+
1084
+ utilEditorInstance = monaco.editor.create(
1085
+ document.getElementById("fcu-monaco"),
1086
+ Object.assign({ model: utilModel }, window.__fcEditorOpts),
1087
+ );
1088
+ });
1089
+ },
1090
+
1091
+ /**
1092
+ * Editor → flow: write Monaco contents back to `utilCode`, dispose
1093
+ * model+editor.
1094
+ * @returns {void}
1095
+ */
1096
+ oneditsave: function () {
1097
+ const code = utilEditorInstance
1098
+ ? utilEditorInstance.getValue()
1099
+ : $("#fcu-fallback").val();
1100
+ $("#node-input-utilCode").val(code);
1101
+ this.utilCode = code;
1102
+ if (utilEditorInstance) {
1103
+ utilEditorInstance.getModel().dispose();
1104
+ utilEditorInstance.dispose();
1105
+ utilEditorInstance = null;
1106
+ }
1107
+ },
1108
+
1109
+ /**
1110
+ * Discard Monaco state without writing back.
1111
+ * @returns {void}
1112
+ */
1113
+ oneditcancel: function () {
1114
+ if (utilEditorInstance) {
1115
+ utilEditorInstance.getModel().dispose();
1116
+ utilEditorInstance.dispose();
1117
+ utilEditorInstance = null;
1118
+ }
1119
+ },
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
+ */
1127
+ oneditresize: function (size) {
1128
+ let h = size.height - 200;
1129
+ if (h < 150) h = 150;
1130
+ $("#fcu-editor-wrap").css("height", h + "px");
1131
+ if (utilEditorInstance) utilEditorInstance.layout();
1132
+ },
1133
+ });
1134
+ })();
1135
+ </script>
1136
+
1137
+ <script type="text/html" data-help-name="fc-portal-utility">
1138
+ <p>
1139
+ Defines shared <strong>helpers</strong>, <strong>custom hooks</strong> and
1140
+ <strong>constants</strong> available to all <strong>portal-react</strong>
1141
+ nodes. Unlike <code>fc-portal-component</code>, the code is injected raw at
1142
+ top level (no IIFE wrapper) — a single utility node may declare many symbols.
1143
+ </p>
1144
+ <h3>Properties</h3>
1145
+ <dl>
1146
+ <dt>Module name</dt>
1147
+ <dd>
1148
+ Identifier for this utility node. Shares a namespace with
1149
+ <code>fc-portal-component</code> names — must be unique across all
1150
+ components and utilities.
1151
+ </dd>
1152
+ <dt>Code</dt>
1153
+ <dd>
1154
+ Top-level JavaScript: <code>const</code>, <code>let</code>,
1155
+ <code>function</code>, <code>class</code> declarations. <code>import</code>
1156
+ statements at the top are auto-hoisted into the bundle.
1157
+ </dd>
1158
+ </dl>
1159
+ <h3>Selective inclusion</h3>
1160
+ <p>
1161
+ A utility node is bundled into a portal only when the portal's JSX (or any
1162
+ of its referenced library components) mentions at least one of the symbols
1163
+ declared in the utility. Unused utility nodes are skipped.
1164
+ </p>
1165
+ <p>
1166
+ Group related symbols in one node — referencing any one symbol pulls in the
1167
+ whole node's code. Split unrelated helpers across multiple nodes for finer
1168
+ granularity.
1169
+ </p>
1170
+ <h3>Example</h3>
1171
+ <pre>function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
1172
+
1173
+ function useDebounce(value, ms = 300) {
1174
+ const [v, setV] = React.useState(value);
1175
+ React.useEffect(() => {
1176
+ const t = setTimeout(() => setV(value), ms);
1177
+ return () => clearTimeout(t);
1178
+ }, [value, ms]);
1179
+ return v;
1180
+ }</pre>
1181
+ <p>
1182
+ Then in any <strong>portal-react</strong> node:
1183
+ </p>
1184
+ <pre>function App() {
1185
+ const { data } = useNodeRed();
1186
+ const slow = useDebounce(data?.value);
1187
+ return &lt;div&gt;{clamp(slow ?? 0, 0, 100)}&lt;/div&gt;;
1188
+ }</pre>
1189
+ </script>
1190
+
775
1191
  <!-- ============================================================
776
1192
  portal-react – main node
777
1193
  ============================================================ -->
@@ -802,6 +1218,9 @@
802
1218
  <button type="button" class="red-ui-button" id="fc-btn-components">
803
1219
  <i class="fa fa-cube"></i> Components
804
1220
  </button>
1221
+ <button type="button" class="red-ui-button" id="fc-btn-utilities">
1222
+ <i class="fa fa-wrench"></i> Utilities
1223
+ </button>
805
1224
  <span style="flex:1"></span>
806
1225
  <button type="button" class="red-ui-button" id="fc-btn-preview">
807
1226
  <i class="fa fa-eye"></i> Preview
@@ -909,10 +1328,10 @@
909
1328
 
910
1329
  <script type="text/javascript">
911
1330
  (function () {
912
- var editorInstance = null;
913
- var headEditorInstance = null;
1331
+ let editorInstance = null;
1332
+ let headEditorInstance = null;
914
1333
 
915
- var STARTER = [
1334
+ const STARTER = [
916
1335
  "// useNodeRed() \u2192 { data, send, portalClient }",
917
1336
  "// data = last msg.payload from input wire",
918
1337
  "// send(payload, topic?) = push msg to output wire",
@@ -949,15 +1368,15 @@
949
1368
  required: true,
950
1369
  validate: function (v) {
951
1370
  if (typeof v !== "string") return false;
952
- var t = v.trim();
1371
+ const t = v.trim();
953
1372
  if (t.length === 0) return false;
954
1373
  if (/\s/.test(t)) return false;
955
1374
  if (t.charAt(0) === "/" || t.charAt(t.length - 1) === "/") return false;
956
- var segs = t.split("/");
957
- for (var i = 0; i < segs.length; i++) {
958
- var s = segs[i];
1375
+ const segs = t.split("/");
1376
+ for (let i = 0; i < segs.length; i++) {
1377
+ const s = segs[i];
959
1378
  if (!s || s === "." || s === "..") return false;
960
- var lower = s.toLowerCase();
1379
+ const lower = s.toLowerCase();
961
1380
  if (lower === "public" || lower === "_ws") return false;
962
1381
  if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(s)) return false;
963
1382
  }
@@ -975,15 +1394,37 @@
975
1394
  inputs: 1,
976
1395
  outputs: 1,
977
1396
  icon: "font-awesome/fa-desktop",
978
- paletteLabel: "fromcubes portal",
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
+ */
979
1409
  label: function () {
980
1410
  return this.name || ("/fromcubes/" + (this.subPath || "?"));
981
1411
  },
982
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
+ */
983
1424
  oneditprepare: function () {
984
- var node = this;
985
- var code = node.componentCode || STARTER;
986
- var headCode = node.customHead || "";
1425
+ const node = this;
1426
+ const code = node.componentCode || STARTER;
1427
+ const headCode = node.customHead || "";
987
1428
  console.log("[FC-Monaco] PORTAL oneditprepare, node.id=" + node.id);
988
1429
 
989
1430
  if (!window.__fcTwClasses) {
@@ -1003,7 +1444,7 @@
1003
1444
  }
1004
1445
 
1005
1446
  // Tabs
1006
- var fcTabs = RED.tabs.create({
1447
+ const fcTabs = RED.tabs.create({
1007
1448
  id: "fc-tabs",
1008
1449
  onchange: function (tab) {
1009
1450
  $(".fc-tab-pane").hide();
@@ -1021,8 +1462,8 @@
1021
1462
 
1022
1463
  // Legacy endpoint detection + convenience pre-fill
1023
1464
  if (node.endpoint && typeof node.endpoint === "string") {
1024
- var legacy = node.endpoint;
1025
- var prefix = "/fromcubes/";
1465
+ const legacy = node.endpoint;
1466
+ const prefix = "/fromcubes/";
1026
1467
  if (legacy.indexOf(prefix) === 0) {
1027
1468
  if (!node.subPath) {
1028
1469
  $("#node-input-subPath").val(legacy.slice(prefix.length));
@@ -1040,8 +1481,8 @@
1040
1481
 
1041
1482
  // URL hint
1042
1483
  function updateHint() {
1043
- var sp = ($("#node-input-subPath").val() || "").trim();
1044
- var root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1484
+ const sp = ($("#node-input-subPath").val() || "").trim();
1485
+ const root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1045
1486
  if (sp) {
1046
1487
  $("#fc-url-hint")
1047
1488
  .css("color", "")
@@ -1061,15 +1502,15 @@
1061
1502
  updateHint();
1062
1503
 
1063
1504
  // Modules editableList (like function node's libs)
1064
- var libsList = node.libs || [];
1505
+ const libsList = node.libs || [];
1065
1506
  $("#node-input-libs-container").css("min-height","68px").editableList({
1066
1507
  addItem: function(container, i, opt) {
1067
- var lib = opt || {};
1068
- var row = $('<div/>',{style:"display:flex;gap:8px;align-items:center;"}).appendTo(container);
1069
- var modInput = $('<input/>',{type:"text",placeholder:"e.g. chart.js/auto@^4.4.0",style:"flex:1;"}).appendTo(row);
1070
- var varInput = $('<input/>',{type:"text",placeholder:"Import as (e.g. Chart)",style:"width:140px;"}).appendTo(row);
1508
+ const lib = opt || {};
1509
+ const row = $('<div/>',{style:"display:flex;gap:8px;align-items:center;"}).appendTo(container);
1510
+ const modInput = $('<input/>',{type:"text",placeholder:"e.g. chart.js/auto@^4.4.0",style:"flex:1;"}).appendTo(row);
1511
+ const varInput = $('<input/>',{type:"text",placeholder:"Import as (e.g. Chart)",style:"width:140px;"}).appendTo(row);
1071
1512
  modInput.val(lib.module || "");
1072
- varInput.val(lib.var || "");
1513
+ varInput.val(lib.const || "");
1073
1514
  container.data("mod", modInput);
1074
1515
  container.data("var", varInput);
1075
1516
  },
@@ -1097,18 +1538,18 @@
1097
1538
  "[FC-Monaco] PORTAL: applying JSX defaults before model creation",
1098
1539
  );
1099
1540
  window.__fcApplyJsxDefaults();
1100
- var opts = window.__fcEditorOpts;
1541
+ const opts = window.__fcEditorOpts;
1101
1542
 
1102
- var jsxUri = monaco.Uri.parse(
1543
+ const jsxUri = monaco.Uri.parse(
1103
1544
  "file:///fc-portal-" + node.id + ".jsx",
1104
1545
  );
1105
1546
  console.log("[FC-Monaco] PORTAL: JSX model URI=" + jsxUri.toString());
1106
- var existingJsx = monaco.editor.getModel(jsxUri);
1547
+ const existingJsx = monaco.editor.getModel(jsxUri);
1107
1548
  if (existingJsx) {
1108
1549
  console.log("[FC-Monaco] PORTAL: disposing existing JSX model");
1109
1550
  existingJsx.dispose();
1110
1551
  }
1111
- var jsxModel = monaco.editor.createModel(code, "javascript", jsxUri);
1552
+ const jsxModel = monaco.editor.createModel(code, "javascript", jsxUri);
1112
1553
  console.log(
1113
1554
  "[FC-Monaco] PORTAL: JSX model created, language=" +
1114
1555
  jsxModel.getLanguageId(),
@@ -1121,12 +1562,12 @@
1121
1562
  if (window.__fcAttachSelfClose)
1122
1563
  window.__fcAttachSelfClose(editorInstance);
1123
1564
 
1124
- var headUri = monaco.Uri.parse(
1565
+ const headUri = monaco.Uri.parse(
1125
1566
  "file:///fc-head-" + node.id + ".html",
1126
1567
  );
1127
- var existingHead = monaco.editor.getModel(headUri);
1568
+ const existingHead = monaco.editor.getModel(headUri);
1128
1569
  if (existingHead) existingHead.dispose();
1129
- var headModel = monaco.editor.createModel(headCode, "html", headUri);
1570
+ const headModel = monaco.editor.createModel(headCode, "html", headUri);
1130
1571
  headEditorInstance = monaco.editor.create(
1131
1572
  document.getElementById("fc-head-monaco"),
1132
1573
  Object.assign({ model: headModel }, opts),
@@ -1135,7 +1576,7 @@
1135
1576
 
1136
1577
  // Log markers after a short delay (diagnostics are async)
1137
1578
  setTimeout(function () {
1138
- var markers = monaco.editor.getModelMarkers({ resource: jsxUri });
1579
+ const markers = monaco.editor.getModelMarkers({ resource: jsxUri });
1139
1580
  console.log(
1140
1581
  "[FC-Monaco] PORTAL: markers after 500ms, count=" +
1141
1582
  markers.length,
@@ -1184,14 +1625,14 @@
1184
1625
  });
1185
1626
 
1186
1627
  $("#fc-btn-preview").on("click", function () {
1187
- var sp = ($("#node-input-subPath").val() || "").trim();
1188
- var root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1628
+ const sp = ($("#node-input-subPath").val() || "").trim();
1629
+ const root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1189
1630
  if (sp) window.open(root + "/fromcubes/" + sp, "_blank");
1190
1631
  });
1191
1632
 
1192
1633
  $("#fc-btn-components").on("click", function () {
1193
1634
  $.getJSON("portal-react/registry", function (reg) {
1194
- var names = Object.keys(reg).sort();
1635
+ const names = Object.keys(reg).sort();
1195
1636
  if (!names.length) {
1196
1637
  RED.notify("No component nodes on canvas.", "warning");
1197
1638
  return;
@@ -1199,12 +1640,12 @@
1199
1640
 
1200
1641
  function extractProps(code) {
1201
1642
  if (!code) return [];
1202
- var m = code.match(/function\s+\w+\s*\(\s*\{([^}]*)\}/);
1643
+ const m = code.match(/function\s+\w+\s*\(\s*\{([^}]*)\}/);
1203
1644
  if (!m) return [];
1204
1645
  return m[1].split(",").map(function (s) { return s.trim().split(/\s*=\s*/)[0]; }).filter(Boolean);
1205
1646
  }
1206
1647
 
1207
- var html =
1648
+ let html =
1208
1649
  '<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">' +
1209
1650
  '<input type="text" id="fc-comp-search" placeholder="Search..." ' +
1210
1651
  'style="width:100%;box-sizing:border-box;margin-bottom:6px;padding:5px 8px;border:1px solid rgba(128,128,128,.4);border-radius:3px;' +
@@ -1212,16 +1653,16 @@
1212
1653
  '<div id="fc-comp-list" style="flex:1;overflow-y:auto;">';
1213
1654
 
1214
1655
  names.forEach(function (n) {
1215
- var c = reg[n];
1216
- var props = extractProps(c.code);
1217
- var detailParts = [];
1656
+ const c = reg[n];
1657
+ const props = extractProps(c.code);
1658
+ const detailParts = [];
1218
1659
  if (props.length) {
1219
1660
  detailParts.push('<div style="margin:4px 0 0 0;font-size:11px;opacity:.6;">' +
1220
1661
  props.map(function (p) {
1221
1662
  return '<code style="background:rgba(128,128,128,.15);padding:0 4px;border-radius:2px;margin-right:3px;">' + p + '</code>';
1222
1663
  }).join("") + '</div>');
1223
1664
  }
1224
- var hasDetail = detailParts.length > 0;
1665
+ const hasDetail = detailParts.length > 0;
1225
1666
 
1226
1667
  html +=
1227
1668
  '<div class="fc-comp-item" data-name="' + n + '" data-search="' + n.toLowerCase() + '" ' +
@@ -1235,7 +1676,7 @@
1235
1676
  });
1236
1677
  html += '</div></div>';
1237
1678
 
1238
- var $dlg = $("<div></div>").html(html).dialog({
1679
+ const $dlg = $("<div></div>").html(html).dialog({
1239
1680
  title: "Components",
1240
1681
  modal: true,
1241
1682
  width: 380,
@@ -1247,7 +1688,7 @@
1247
1688
 
1248
1689
  // Search
1249
1690
  $dlg.find("#fc-comp-search").on("input", function () {
1250
- var q = $(this).val().toLowerCase();
1691
+ const q = $(this).val().toLowerCase();
1251
1692
  $dlg.find(".fc-comp-item").each(function () {
1252
1693
  $(this).toggle($(this).data("search").indexOf(q) !== -1);
1253
1694
  });
@@ -1263,24 +1704,24 @@
1263
1704
  // Arrow toggle detail
1264
1705
  $dlg.on("click", ".fc-comp-arrow", function (e) {
1265
1706
  e.stopPropagation();
1266
- var $item = $(this).closest(".fc-comp-item");
1267
- var $detail = $item.find(".fc-comp-detail");
1268
- var open = $detail.is(":visible");
1707
+ const $item = $(this).closest(".fc-comp-item");
1708
+ const $detail = $item.find(".fc-comp-detail");
1709
+ const open = $detail.is(":visible");
1269
1710
  $detail.slideToggle(100);
1270
1711
  $(this).css("transform", open ? "" : "rotate(90deg)");
1271
1712
  });
1272
1713
 
1273
1714
  // Click name: delete selection, insert <Tag></Tag> in one line, cursor between tags
1274
1715
  $dlg.on("click", ".fc-comp-name", function () {
1275
- var name = $(this).data("name");
1276
- var openTag = "<" + name + ">";
1277
- var closeTag = "</" + name + ">";
1278
- var text = openTag + closeTag;
1716
+ const name = $(this).data("name");
1717
+ const openTag = "<" + name + ">";
1718
+ const closeTag = "</" + name + ">";
1719
+ const text = openTag + closeTag;
1279
1720
  $dlg.dialog("close");
1280
1721
  if (editorInstance) {
1281
- var sel = editorInstance.getSelection();
1282
- var startLine = sel.startLineNumber;
1283
- var startCol = sel.startColumn;
1722
+ const sel = editorInstance.getSelection();
1723
+ const startLine = sel.startLineNumber;
1724
+ const startCol = sel.startColumn;
1284
1725
  editorInstance.executeEdits("fc-components", [{
1285
1726
  range: sel,
1286
1727
  text: text,
@@ -1290,8 +1731,8 @@
1290
1731
  editorInstance.focus();
1291
1732
  }, 50);
1292
1733
  } else {
1293
- var ta = $("#fc-fallback")[0];
1294
- var s = ta.selectionStart, e = ta.selectionEnd, v = ta.value;
1734
+ const ta = $("#fc-fallback")[0];
1735
+ const s = ta.selectionStart, e = ta.selectionEnd, v = ta.value;
1295
1736
  ta.value = v.slice(0, s) + text + v.slice(e);
1296
1737
  setTimeout(function () {
1297
1738
  ta.selectionStart = ta.selectionEnd = s + openTag.length;
@@ -1301,8 +1742,127 @@
1301
1742
  });
1302
1743
  });
1303
1744
  });
1745
+
1746
+ $("#fc-btn-utilities").on("click", function () {
1747
+ $.getJSON("portal-react/utilities", function (reg) {
1748
+ // Refresh global cache too so completion provider picks up changes
1749
+ if (window.__fcRefreshUtilities) window.__fcRefreshUtilities();
1750
+ const names = Object.keys(reg).sort();
1751
+ if (!names.length) {
1752
+ RED.notify("No utility nodes on canvas.", "warning");
1753
+ return;
1754
+ }
1755
+
1756
+ let html =
1757
+ '<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">' +
1758
+ '<input type="text" id="fc-util-search" placeholder="Search..." ' +
1759
+ 'style="width:100%;box-sizing:border-box;margin-bottom:6px;padding:5px 8px;border:1px solid rgba(128,128,128,.4);border-radius:3px;' +
1760
+ 'background:var(--red-ui-form-input-background-color,#fff);color:var(--red-ui-form-text-color,#333);font-size:12px;flex-shrink:0;" />' +
1761
+ '<div id="fc-util-list" style="flex:1;overflow-y:auto;">';
1762
+
1763
+ names.forEach(function (n) {
1764
+ const u = reg[n];
1765
+ const syms = u.symbols || [];
1766
+ const errBadge = u.error
1767
+ ? '<span style="margin-left:6px;font-size:10px;color:#ef4444;" title="' +
1768
+ String(u.error).replace(/"/g, "&quot;") + '">syntax error</span>'
1769
+ : "";
1770
+ const symBadges = syms.map(function (s) {
1771
+ return '<span class="fc-util-sym" data-sym="' + s +
1772
+ '" style="display:inline-block;background:rgba(251,191,36,.15);border:1px solid rgba(251,191,36,.35);' +
1773
+ 'padding:1px 6px;border-radius:3px;margin:2px 4px 2px 0;font-size:11px;font-family:monospace;cursor:pointer;">' +
1774
+ s + '</span>';
1775
+ }).join("");
1776
+ const detail = syms.length
1777
+ ? '<div style="margin:4px 0 0 0;">' + symBadges + '</div>'
1778
+ : '<div style="margin:4px 0 0 0;font-size:11px;opacity:.5;">(no top-level symbols detected)</div>';
1779
+ const searchKey = (n + " " + syms.join(" ")).toLowerCase();
1780
+
1781
+ html +=
1782
+ '<div class="fc-util-item" data-name="' + n + '" data-search="' + searchKey + '" ' +
1783
+ 'style="border-bottom:1px solid rgba(128,128,128,.1);">' +
1784
+ '<div style="display:flex;align-items:center;padding:4px 6px;cursor:pointer;" class="fc-util-row">' +
1785
+ '<i class="fa fa-caret-right fc-util-arrow" style="width:14px;opacity:.4;font-size:12px;transition:transform .15s;"></i>' +
1786
+ '<i class="fa fa-wrench" style="width:14px;color:#fbbf24;font-size:11px;"></i>' +
1787
+ '<span style="font-weight:600;font-size:12px;flex:1;margin-left:4px;">' + n + '</span>' +
1788
+ errBadge +
1789
+ '</div>' +
1790
+ '<div class="fc-util-detail" style="display:none;padding:0 6px 6px 32px;">' + detail + '</div>' +
1791
+ '</div>';
1792
+ });
1793
+ html += '</div></div>';
1794
+
1795
+ const $dlg = $("<div></div>").html(html).dialog({
1796
+ title: "Utilities",
1797
+ modal: true,
1798
+ width: 420,
1799
+ buttons: [
1800
+ { text: "Close", click: function () { $(this).dialog("close"); } },
1801
+ ],
1802
+ close: function () { $(this).remove(); },
1803
+ });
1804
+
1805
+ $dlg.find("#fc-util-search").on("input", function () {
1806
+ const q = $(this).val().toLowerCase();
1807
+ $dlg.find(".fc-util-item").each(function () {
1808
+ $(this).toggle($(this).data("search").indexOf(q) !== -1);
1809
+ });
1810
+ }).focus();
1811
+
1812
+ $dlg.on("mouseenter", ".fc-util-row", function () {
1813
+ $(this).css("background", "rgba(251,191,36,.08)");
1814
+ }).on("mouseleave", ".fc-util-row", function () {
1815
+ $(this).css("background", "");
1816
+ });
1817
+
1818
+ $dlg.on("click", ".fc-util-row", function (e) {
1819
+ if ($(e.target).closest(".fc-util-sym").length) return;
1820
+ const $item = $(this).closest(".fc-util-item");
1821
+ const $detail = $item.find(".fc-util-detail");
1822
+ const $arrow = $item.find(".fc-util-arrow");
1823
+ const open = $detail.is(":visible");
1824
+ $detail.slideToggle(100);
1825
+ $arrow.css("transform", open ? "" : "rotate(90deg)");
1826
+ });
1827
+
1828
+ // Click symbol → insert bare identifier at cursor
1829
+ $dlg.on("click", ".fc-util-sym", function (e) {
1830
+ e.stopPropagation();
1831
+ const sym = $(this).data("sym");
1832
+ $dlg.dialog("close");
1833
+ if (editorInstance) {
1834
+ const sel = editorInstance.getSelection();
1835
+ const startLine = sel.startLineNumber;
1836
+ const startCol = sel.startColumn;
1837
+ editorInstance.executeEdits("fc-utilities", [{
1838
+ range: sel,
1839
+ text: sym,
1840
+ }]);
1841
+ setTimeout(function () {
1842
+ editorInstance.setPosition({ lineNumber: startLine, column: startCol + sym.length });
1843
+ editorInstance.focus();
1844
+ }, 50);
1845
+ } else {
1846
+ const ta = $("#fc-fallback")[0];
1847
+ const s = ta.selectionStart, en = ta.selectionEnd, v = ta.value;
1848
+ ta.value = v.slice(0, s) + sym + v.slice(en);
1849
+ setTimeout(function () {
1850
+ ta.selectionStart = ta.selectionEnd = s + sym.length;
1851
+ ta.focus();
1852
+ }, 50);
1853
+ }
1854
+ });
1855
+ });
1856
+ });
1304
1857
  },
1305
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
+ */
1306
1866
  oneditsave: function () {
1307
1867
  console.log("[FC-Monaco] PORTAL oneditsave");
1308
1868
 
@@ -1312,24 +1872,24 @@
1312
1872
  this.subPath = ($("#node-input-subPath").val() || "").trim();
1313
1873
 
1314
1874
  // Collect libs from editableList
1315
- var libs = [];
1316
- var items = $("#node-input-libs-container").editableList("items");
1875
+ const libs = [];
1876
+ const items = $("#node-input-libs-container").editableList("items");
1317
1877
  items.each(function() {
1318
- var mod = $(this).data("mod").val().trim();
1319
- var v = $(this).data("var").val().trim();
1878
+ const mod = $(this).data("mod").val().trim();
1879
+ const v = $(this).data("var").val().trim();
1320
1880
  if (mod) {
1321
1881
  libs.push({ module: mod, var: v });
1322
1882
  }
1323
1883
  });
1324
1884
  this.libs = libs;
1325
1885
 
1326
- var code = editorInstance
1886
+ const code = editorInstance
1327
1887
  ? editorInstance.getValue()
1328
1888
  : $("#fc-fallback").val();
1329
1889
  $("#node-input-componentCode").val(code);
1330
1890
  this.componentCode = code;
1331
1891
 
1332
- var head = headEditorInstance
1892
+ const head = headEditorInstance
1333
1893
  ? headEditorInstance.getValue()
1334
1894
  : $("#fc-head-fallback").val();
1335
1895
  $("#node-input-customHead").val(head);
@@ -1349,6 +1909,12 @@
1349
1909
  }
1350
1910
  },
1351
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
+ */
1352
1918
  oneditcancel: function () {
1353
1919
  console.log("[FC-Monaco] PORTAL oneditcancel");
1354
1920
  if (editorInstance) {
@@ -1365,12 +1931,19 @@
1365
1931
  }
1366
1932
  },
1367
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
+ */
1368
1941
  oneditresize: function (size) {
1369
- var tabsH = $("#fc-tabs").outerHeight(true) || 0;
1370
- var rows = $(
1942
+ const tabsH = $("#fc-tabs").outerHeight(true) || 0;
1943
+ const rows = $(
1371
1944
  "#dialog-form>div:not(#fc-tabs-content):not(:has(#fc-tabs))",
1372
1945
  );
1373
- var h = size.height;
1946
+ let h = size.height;
1374
1947
  rows.each(function () {
1375
1948
  h -= $(this).outerHeight(true);
1376
1949
  });
@@ -1507,24 +2080,29 @@ const { data, send, user, portalClient } = useNodeRed();
1507
2080
  <script type="text/javascript">
1508
2081
  (function () {
1509
2082
  // Resolve httpNodeRoot for public URL prefix
1510
- var nodeRoot = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1511
- var publicBase = nodeRoot + "/fromcubes/public/";
1512
-
2083
+ const nodeRoot = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
2084
+ const publicBase = nodeRoot + "/fromcubes/public/";
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
+ */
1513
2091
  function formatSize(bytes) {
1514
2092
  if (bytes < 1024) return bytes + " B";
1515
2093
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
1516
2094
  return (bytes / (1024 * 1024)).toFixed(1) + " MB";
1517
2095
  }
1518
2096
 
1519
- var allEntries = [];
1520
- var collapsed = {};
2097
+ let allEntries = [];
2098
+ const collapsed = {};
1521
2099
 
1522
- var content = $('<div class="red-ui-sidebar-info" style="height:100%;overflow:auto;padding:0;"></div>');
1523
- var fileList = $('<div style="padding:0;"></div>').appendTo(content);
2100
+ const content = $('<div class="red-ui-sidebar-info" style="height:100%;overflow:auto;padding:0;"></div>');
2101
+ const fileList = $('<div style="padding:0;"></div>').appendTo(content);
1524
2102
 
1525
2103
  // ── Toolbar ──
1526
- var toolbar = $('<div style="display:flex;align-items:center;gap:6px;margin:0 6px;padding:2px 0 0;"></div>');
1527
- var fileInput = $('<input type="file" multiple style="display:none;">').appendTo(toolbar);
2104
+ const toolbar = $('<div style="display:flex;align-items:center;gap:6px;margin:0 6px;padding:2px 0 0;"></div>');
2105
+ const fileInput = $('<input type="file" multiple style="display:none;">').appendTo(toolbar);
1528
2106
  $('<button class="red-ui-button red-ui-button-small" style="flex-shrink:0;"><i class="fa fa-upload"></i> Upload</button>')
1529
2107
  .on("click", function (e) { e.preventDefault(); fileInput.trigger("click"); })
1530
2108
  .appendTo(toolbar);
@@ -1533,19 +2111,34 @@ const { data, send, user, portalClient } = useNodeRed();
1533
2111
  .appendTo(toolbar);
1534
2112
 
1535
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
+ */
1536
2119
  function pathExists(p) {
1537
- for (var i = 0; i < allEntries.length; i++) {
2120
+ for (let i = 0; i < allEntries.length; i++) {
1538
2121
  if (allEntries[i].name === p) return true;
1539
2122
  }
1540
2123
  return false;
1541
2124
  }
1542
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
+ */
1543
2136
  function uploadFiles(files, targetDir) {
1544
2137
  if (!files || files.length === 0) return;
1545
- var toUpload = [];
1546
- var duplicates = [];
1547
- for (var i = 0; i < files.length; i++) {
1548
- var uploadPath = targetDir ? targetDir + "/" + files[i].name : files[i].name;
2138
+ let toUpload = [];
2139
+ const duplicates = [];
2140
+ for (let i = 0; i < files.length; i++) {
2141
+ const uploadPath = targetDir ? targetDir + "/" + files[i].name : files[i].name;
1549
2142
  if (pathExists(uploadPath)) {
1550
2143
  duplicates.push({ file: files[i], path: uploadPath });
1551
2144
  } else {
@@ -1553,15 +2146,15 @@ const { data, send, user, portalClient } = useNodeRed();
1553
2146
  }
1554
2147
  }
1555
2148
  if (duplicates.length > 0) {
1556
- var names = duplicates.map(function (d) { return d.file.name; }).join(", ");
2149
+ const names = duplicates.map(function (d) { return d.file.name; }).join(", ");
1557
2150
  if (confirm("These files already exist: " + names + "\nOverwrite?")) {
1558
2151
  toUpload = toUpload.concat(duplicates);
1559
2152
  }
1560
2153
  }
1561
2154
  if (toUpload.length === 0) return;
1562
- var pending = toUpload.length;
2155
+ let pending = toUpload.length;
1563
2156
  toUpload.forEach(function (item) {
1564
- var reader = new FileReader();
2157
+ const reader = new FileReader();
1565
2158
  reader.onload = function () {
1566
2159
  $.ajax({
1567
2160
  type: "POST",
@@ -1601,15 +2194,24 @@ const { data, send, user, portalClient } = useNodeRed();
1601
2194
  e.preventDefault();
1602
2195
  e.stopPropagation();
1603
2196
  content.css("background", "");
1604
- var dt = e.originalEvent.dataTransfer;
2197
+ const dt = e.originalEvent.dataTransfer;
1605
2198
  if (dt.files && dt.files.length > 0) {
1606
2199
  uploadFiles(dt.files, "");
1607
2200
  }
1608
2201
  });
1609
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
+ */
1610
2212
  function moveItem(fromPath, toDir) {
1611
- var filename = fromPath.split("/").pop();
1612
- var newPath = toDir ? toDir + "/" + filename : filename;
2213
+ const filename = fromPath.split("/").pop();
2214
+ const newPath = toDir ? toDir + "/" + filename : filename;
1613
2215
  if (fromPath === newPath) return;
1614
2216
  if (pathExists(newPath)) {
1615
2217
  if (!confirm("'" + filename + "' already exists in this folder. Overwrite?")) return;
@@ -1620,7 +2222,7 @@ const { data, send, user, portalClient } = useNodeRed();
1620
2222
  data: JSON.stringify({ from: fromPath, to: newPath }),
1621
2223
  success: function () { refreshList(); },
1622
2224
  error: function (xhr) {
1623
- var msg = xhr.responseJSON ? xhr.responseJSON.error : "move failed";
2225
+ const msg = xhr.responseJSON ? xhr.responseJSON.error : "move failed";
1624
2226
  RED.notify("Move failed: " + msg, "error");
1625
2227
  },
1626
2228
  });
@@ -1628,22 +2230,22 @@ const { data, send, user, portalClient } = useNodeRed();
1628
2230
 
1629
2231
  // ── Inline new-folder input ──
1630
2232
  function showNewFolderInput(parentDir) {
1631
- var existingInput = fileList.find(".fc-new-folder-row");
2233
+ const existingInput = fileList.find(".fc-new-folder-row");
1632
2234
  if (existingInput.length) existingInput.remove();
1633
2235
 
1634
- var depth = parentDir ? parentDir.split("/").length : 0;
1635
- var indent = 8 + depth * 18;
1636
- var row = $('<div class="fc-new-folder-row" style="display:flex;align-items:center;gap:6px;padding:8px;border-bottom:1px solid var(--red-ui-secondary-border-color);background:var(--red-ui-tertiary-background);"></div>');
2236
+ const depth = parentDir ? parentDir.split("/").length : 0;
2237
+ const indent = 8 + depth * 18;
2238
+ const row = $('<div class="fc-new-folder-row" style="display:flex;align-items:center;gap:6px;padding:8px;border-bottom:1px solid var(--red-ui-secondary-border-color);background:var(--red-ui-tertiary-background);"></div>');
1637
2239
  row.css("padding-left", indent + "px");
1638
2240
  $('<i class="fa fa-folder" style="color:#fbbf24;font-size:13px;width:16px;text-align:center;"></i>').appendTo(row);
1639
- var inp = $('<input type="text" placeholder="folder name" class="red-ui-searchBox-input" style="flex:1;padding:3px 6px;font-size:12px;">');
2241
+ const inp = $('<input type="text" placeholder="folder name" class="red-ui-searchBox-input" style="flex:1;padding:3px 6px;font-size:12px;">');
1640
2242
  inp.appendTo(row);
1641
- var okBtn = $('<button class="red-ui-button red-ui-button-small" style="color:var(--red-ui-text-color-success);" title="Create"><i class="fa fa-check"></i></button>');
1642
- var cancelBtn = $('<button class="red-ui-button red-ui-button-small" title="Cancel"><i class="fa fa-times"></i></button>');
2243
+ const okBtn = $('<button class="red-ui-button red-ui-button-small" style="color:var(--red-ui-text-color-success);" title="Create"><i class="fa fa-check"></i></button>');
2244
+ const cancelBtn = $('<button class="red-ui-button red-ui-button-small" title="Cancel"><i class="fa fa-times"></i></button>');
1643
2245
  function submit() {
1644
- var name = inp.val().trim();
2246
+ const name = inp.val().trim();
1645
2247
  if (!name) { row.remove(); return; }
1646
- var p = parentDir ? parentDir + "/" + name : name;
2248
+ const p = parentDir ? parentDir + "/" + name : name;
1647
2249
  if (pathExists(p)) {
1648
2250
  RED.notify("Folder '" + name + "' already exists", "warning");
1649
2251
  return;
@@ -1664,7 +2266,7 @@ const { data, send, user, portalClient } = useNodeRed();
1664
2266
 
1665
2267
  // Insert after the parent folder row, or at top
1666
2268
  if (parentDir) {
1667
- var parentRow = fileList.find('[data-path="' + parentDir + '"]');
2269
+ const parentRow = fileList.find('[data-path="' + parentDir + '"]');
1668
2270
  if (parentRow.length) { row.insertAfter(parentRow); } else { fileList.prepend(row); }
1669
2271
  } else {
1670
2272
  fileList.prepend(row);
@@ -1673,7 +2275,7 @@ const { data, send, user, portalClient } = useNodeRed();
1673
2275
  }
1674
2276
 
1675
2277
  // ── Context menu (native Node-RED style) ──
1676
- var activeMenu = null;
2278
+ let activeMenu = null;
1677
2279
  function closeMenu() {
1678
2280
  if (activeMenu) { activeMenu.remove(); activeMenu = null; }
1679
2281
  }
@@ -1681,14 +2283,14 @@ const { data, send, user, portalClient } = useNodeRed();
1681
2283
 
1682
2284
  function showMenu(anchor, items) {
1683
2285
  closeMenu();
1684
- var menu = $('<ul class="red-ui-menu red-ui-menu-dropdown" style="position:absolute;z-index:10000;display:block;"></ul>');
2286
+ const menu = $('<ul class="red-ui-menu red-ui-menu-dropdown" style="position:absolute;z-index:10000;display:block;"></ul>');
1685
2287
  items.forEach(function (item) {
1686
2288
  if (item.divider) {
1687
2289
  menu.append('<li class="red-ui-menu-divider"></li>');
1688
2290
  return;
1689
2291
  }
1690
- var li = $('<li></li>');
1691
- var a = $('<a href="#"></a>');
2292
+ const li = $('<li></li>');
2293
+ const a = $('<a href="#"></a>');
1692
2294
  if (item.danger) a.css("color", "var(--red-ui-text-color-error)");
1693
2295
  a.append('<i class="fa ' + item.icon + '" style="width:18px;text-align:center;"></i> ');
1694
2296
  a.append($('<span class="red-ui-menu-label"></span>').text(item.label));
@@ -1706,32 +2308,32 @@ const { data, send, user, portalClient } = useNodeRed();
1706
2308
  activeMenu = menu;
1707
2309
 
1708
2310
  // Position near the anchor
1709
- var off = anchor.offset();
1710
- var top = off.top + anchor.outerHeight() + 2;
1711
- var left = off.left - menu.outerWidth() + anchor.outerWidth();
2311
+ const off = anchor.offset();
2312
+ let top = off.top + anchor.outerHeight() + 2;
2313
+ let left = off.left - menu.outerWidth() + anchor.outerWidth();
1712
2314
  if (left < 0) left = off.left;
1713
2315
  if (top + menu.outerHeight() > $(window).height()) top = off.top - menu.outerHeight() - 2;
1714
2316
  menu.css({ top: top, left: left });
1715
2317
  }
1716
2318
 
1717
- var adminRoot = (RED.settings.httpAdminRoot || "/").replace(/\/$/, "");
2319
+ const adminRoot = (RED.settings.httpAdminRoot || "/").replace(/\/$/, "");
1718
2320
 
1719
2321
  function showRenameInput(rowEl, fullPath, currentName) {
1720
- var nameSpan = rowEl.find("span").filter(function () { return $(this).text() === currentName; }).first();
2322
+ const nameSpan = rowEl.find("span").filter(function () { return $(this).text() === currentName; }).first();
1721
2323
  if (!nameSpan.length) return;
1722
- var origText = nameSpan.text();
1723
- var inp = $('<input type="text" class="red-ui-searchBox-input" style="flex:1;padding:3px 6px;font-size:12px;">').val(origText);
1724
- var okBtn = $('<button class="red-ui-button red-ui-button-small" style="color:var(--red-ui-text-color-success);" title="Save"><i class="fa fa-check"></i></button>');
1725
- var cancelBtn = $('<button class="red-ui-button red-ui-button-small" title="Cancel"><i class="fa fa-times"></i></button>');
1726
- var dotIdx = origText.lastIndexOf(".");
1727
- var hasExt = dotIdx > 0; // has extension (not hidden file)
1728
- var origExt = hasExt ? origText.slice(dotIdx) : "";
2324
+ const origText = nameSpan.text();
2325
+ const inp = $('<input type="text" class="red-ui-searchBox-input" style="flex:1;padding:3px 6px;font-size:12px;">').val(origText);
2326
+ const okBtn = $('<button class="red-ui-button red-ui-button-small" style="color:var(--red-ui-text-color-success);" title="Save"><i class="fa fa-check"></i></button>');
2327
+ const cancelBtn = $('<button class="red-ui-button red-ui-button-small" title="Cancel"><i class="fa fa-times"></i></button>');
2328
+ const dotIdx = origText.lastIndexOf(".");
2329
+ const hasExt = dotIdx > 0; // has extension (not hidden file)
2330
+ const origExt = hasExt ? origText.slice(dotIdx) : "";
1729
2331
 
1730
2332
  nameSpan.replaceWith(inp);
1731
2333
  inp.after(cancelBtn).after(okBtn);
1732
2334
  inp.focus();
1733
2335
  // Select only the name part before extension
1734
- var el = inp[0];
2336
+ const el = inp[0];
1735
2337
  if (hasExt && el.setSelectionRange) {
1736
2338
  el.setSelectionRange(0, dotIdx);
1737
2339
  } else {
@@ -1744,22 +2346,22 @@ const { data, send, user, portalClient } = useNodeRed();
1744
2346
  inp.replaceWith($('<span style="flex:1;font-size:12px;word-break:break-all;"></span>').text(origText));
1745
2347
  }
1746
2348
  function submit() {
1747
- var newName = inp.val().trim();
2349
+ const newName = inp.val().trim();
1748
2350
  if (!newName) {
1749
2351
  RED.notify("Name cannot be empty", "warning");
1750
2352
  return;
1751
2353
  }
1752
2354
  // Warn if extension changed or removed
1753
2355
  if (hasExt) {
1754
- var newDot = newName.lastIndexOf(".");
1755
- var newExt = newDot > 0 ? newName.slice(newDot) : "";
2356
+ const newDot = newName.lastIndexOf(".");
2357
+ const newExt = newDot > 0 ? newName.slice(newDot) : "";
1756
2358
  if (newExt.toLowerCase() !== origExt.toLowerCase()) {
1757
2359
  if (!confirm("Extension changed from '" + origExt + "' to '" + (newExt || "none") + "'. Continue?")) return;
1758
2360
  }
1759
2361
  }
1760
2362
  if (newName === origText) { restore(); return; }
1761
- var parentDir = fullPath.indexOf("/") >= 0 ? fullPath.slice(0, fullPath.lastIndexOf("/")) : "";
1762
- var newPath = parentDir ? parentDir + "/" + newName : newName;
2363
+ const parentDir = fullPath.indexOf("/") >= 0 ? fullPath.slice(0, fullPath.lastIndexOf("/")) : "";
2364
+ const newPath = parentDir ? parentDir + "/" + newName : newName;
1763
2365
  if (pathExists(newPath)) {
1764
2366
  RED.notify("'" + newName + "' already exists", "warning");
1765
2367
  return;
@@ -1770,7 +2372,7 @@ const { data, send, user, portalClient } = useNodeRed();
1770
2372
  data: JSON.stringify({ from: fullPath, to: newPath }),
1771
2373
  success: function () { refreshList(); },
1772
2374
  error: function (xhr) {
1773
- var msg = xhr.responseJSON ? xhr.responseJSON.error : "rename failed";
2375
+ const msg = xhr.responseJSON ? xhr.responseJSON.error : "rename failed";
1774
2376
  RED.notify("Rename failed: " + msg, "error");
1775
2377
  restore();
1776
2378
  },
@@ -1787,11 +2389,11 @@ const { data, send, user, portalClient } = useNodeRed();
1787
2389
  // ── Tree rendering ──
1788
2390
  function buildTree(entries) {
1789
2391
  // Build nested structure: { children: { name: { type, children, entry } } }
1790
- var root = { children: {} };
2392
+ const root = { children: {} };
1791
2393
  entries.forEach(function (e) {
1792
- var parts = e.name.split("/");
1793
- var node = root;
1794
- for (var i = 0; i < parts.length; i++) {
2394
+ const parts = e.name.split("/");
2395
+ let node = root;
2396
+ for (let i = 0; i < parts.length; i++) {
1795
2397
  if (!node.children[parts[i]]) {
1796
2398
  node.children[parts[i]] = { children: {} };
1797
2399
  }
@@ -1802,15 +2404,15 @@ const { data, send, user, portalClient } = useNodeRed();
1802
2404
  return root;
1803
2405
  }
1804
2406
 
1805
- var ROOT_KEY = "__root__";
2407
+ const ROOT_KEY = "__root__";
1806
2408
 
1807
2409
  function renderTree() {
1808
2410
  fileList.empty();
1809
- var isOpen = !collapsed[ROOT_KEY];
2411
+ const isOpen = !collapsed[ROOT_KEY];
1810
2412
 
1811
2413
  // Root folder row — always visible
1812
- var rootRow = $('<div style="display:flex;align-items:center;gap:5px;padding:4px 8px;border-bottom:1px solid var(--red-ui-secondary-border-color);"></div>');
1813
- var rootArrow = $('<i class="fa ' + (isOpen ? 'fa-caret-down' : 'fa-caret-right') + '" style="color:var(--red-ui-secondary-text-color);font-size:11px;width:10px;text-align:center;cursor:pointer;"></i>');
2414
+ const rootRow = $('<div style="display:flex;align-items:center;gap:5px;padding:4px 8px;border-bottom:1px solid var(--red-ui-secondary-border-color);"></div>');
2415
+ const rootArrow = $('<i class="fa ' + (isOpen ? 'fa-caret-down' : 'fa-caret-right') + '" style="color:var(--red-ui-secondary-text-color);font-size:11px;width:10px;text-align:center;cursor:pointer;"></i>');
1814
2416
  rootArrow.on("click", function () { collapsed[ROOT_KEY] = isOpen; renderTree(); });
1815
2417
  rootRow.append(rootArrow);
1816
2418
  $('<i class="fa ' + (isOpen ? 'fa-folder-open' : 'fa-folder') + '" style="color:#fbbf24;font-size:12px;width:16px;text-align:center;"></i>').appendTo(rootRow);
@@ -1825,35 +2427,35 @@ const { data, send, user, portalClient } = useNodeRed();
1825
2427
  fileList.append($('<div style="padding:16px;color:var(--red-ui-secondary-text-color);text-align:center;">No files uploaded.<br><span style="font-size:11px;">Drop files here or click Upload.</span></div>'));
1826
2428
  return;
1827
2429
  }
1828
- var tree = buildTree(allEntries);
2430
+ const tree = buildTree(allEntries);
1829
2431
  renderNode(tree, "", 1);
1830
2432
  }
1831
2433
 
1832
2434
  function renderNode(node, parentPath, depth) {
1833
2435
  // Collect and sort: dirs first, then files
1834
- var names = Object.keys(node.children).sort(function (a, b) {
1835
- var aIsDir = node.children[a].entry && node.children[a].entry.type === "dir";
1836
- var bIsDir = node.children[b].entry && node.children[b].entry.type === "dir";
2436
+ const names = Object.keys(node.children).sort(function (a, b) {
2437
+ const aIsDir = node.children[a].entry && node.children[a].entry.type === "dir";
2438
+ const bIsDir = node.children[b].entry && node.children[b].entry.type === "dir";
1837
2439
  if (aIsDir && !bIsDir) return -1;
1838
2440
  if (!aIsDir && bIsDir) return 1;
1839
2441
  return a.localeCompare(b);
1840
2442
  });
1841
2443
 
1842
2444
  names.forEach(function (name) {
1843
- var child = node.children[name];
1844
- var e = child.entry;
2445
+ const child = node.children[name];
2446
+ const e = child.entry;
1845
2447
  if (!e) return;
1846
- var fullPath = e.name;
1847
- var indent = 8 + depth * 18;
1848
- var isDir = e.type === "dir";
1849
- var isOpen = !collapsed[fullPath];
2448
+ const fullPath = e.name;
2449
+ const indent = 8 + depth * 18;
2450
+ const isDir = e.type === "dir";
2451
+ const isOpen = !collapsed[fullPath];
1850
2452
 
1851
- var row = $('<div data-path="' + fullPath + '" style="display:flex;align-items:center;gap:5px;padding:4px 8px;border-bottom:1px solid var(--red-ui-secondary-border-color);"></div>');
2453
+ const row = $('<div data-path="' + fullPath + '" style="display:flex;align-items:center;gap:5px;padding:4px 8px;border-bottom:1px solid var(--red-ui-secondary-border-color);"></div>');
1852
2454
  row.css("padding-left", indent + "px");
1853
2455
 
1854
2456
  if (isDir) {
1855
2457
  // Expand/collapse arrow
1856
- var arrow = $('<i class="fa ' + (isOpen ? 'fa-caret-down' : 'fa-caret-right') + '" style="color:var(--red-ui-secondary-text-color);font-size:11px;width:10px;text-align:center;cursor:pointer;"></i>');
2458
+ const arrow = $('<i class="fa ' + (isOpen ? 'fa-caret-down' : 'fa-caret-right') + '" style="color:var(--red-ui-secondary-text-color);font-size:11px;width:10px;text-align:center;cursor:pointer;"></i>');
1857
2459
  arrow.on("click", function (e) {
1858
2460
  e.stopPropagation();
1859
2461
  collapsed[fullPath] = isOpen;
@@ -1866,7 +2468,7 @@ const { data, send, user, portalClient } = useNodeRed();
1866
2468
  .appendTo(row);
1867
2469
 
1868
2470
  // Context menu trigger
1869
- var dirMenuBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;"><i class="fa fa-ellipsis-v"></i></button>');
2471
+ const dirMenuBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;"><i class="fa fa-ellipsis-v"></i></button>');
1870
2472
  (function (fp, nm) {
1871
2473
  dirMenuBtn.on("click", function (ev) {
1872
2474
  ev.preventDefault();
@@ -1905,7 +2507,7 @@ const { data, send, user, portalClient } = useNodeRed();
1905
2507
  ev.preventDefault();
1906
2508
  ev.stopPropagation();
1907
2509
  row.css("background", "");
1908
- var srcPath = ev.originalEvent.dataTransfer.getData("text/x-asset-path");
2510
+ const srcPath = ev.originalEvent.dataTransfer.getData("text/x-asset-path");
1909
2511
  if (srcPath) {
1910
2512
  moveItem(srcPath, fullPath);
1911
2513
  } else if (ev.originalEvent.dataTransfer.files && ev.originalEvent.dataTransfer.files.length > 0) {
@@ -1933,13 +2535,13 @@ const { data, send, user, portalClient } = useNodeRed();
1933
2535
  });
1934
2536
  row.on("dragend", function () { row.css("opacity", "1"); });
1935
2537
 
1936
- var copyBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;" title="Copy public path"><i class="fa fa-clipboard"></i></button>');
1937
- var fileMenuBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;"><i class="fa fa-ellipsis-v"></i></button>');
2538
+ const copyBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;" title="Copy public path"><i class="fa fa-clipboard"></i></button>');
2539
+ const fileMenuBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;"><i class="fa fa-ellipsis-v"></i></button>');
1938
2540
  (function (fp, nm) {
1939
2541
  copyBtn.on("click", function (ev) {
1940
2542
  ev.preventDefault();
1941
2543
  ev.stopPropagation();
1942
- var url = publicBase + fp;
2544
+ const url = publicBase + fp;
1943
2545
  navigator.clipboard.writeText(url).then(function () {
1944
2546
  RED.notify("Copied: " + url, { type: "success", timeout: 2000 });
1945
2547
  });
@@ -1952,7 +2554,7 @@ const { data, send, user, portalClient } = useNodeRed();
1952
2554
  showRenameInput(row, fp, nm);
1953
2555
  }},
1954
2556
  { icon: "fa-download", label: "Download", action: function () {
1955
- var a = document.createElement("a");
2557
+ const a = document.createElement("a");
1956
2558
  a.href = adminRoot + "/portal-react/assets/download/" + fp.split("/").map(encodeURIComponent).join("/");
1957
2559
  a.download = nm;
1958
2560
  document.body.appendChild(a);
@@ -1978,6 +2580,12 @@ const { data, send, user, portalClient } = useNodeRed();
1978
2580
  });
1979
2581
  }
1980
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
+ */
1981
2589
  function refreshList() {
1982
2590
  $.getJSON("portal-react/assets", function (entries) {
1983
2591
  allEntries = entries || [];