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

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,7 +20,7 @@
20
20
  };
21
21
 
22
22
  window.__fcTwClasses = null;
23
- var jsxSetupDone = false;
23
+ let jsxSetupDone = false;
24
24
 
25
25
  // One-time Monaco setup: compiler opts, diagnostics, extra libs, completion provider.
26
26
  // Idempotent — safe to call multiple times, only runs setup once.
@@ -36,7 +36,7 @@
36
36
  }
37
37
  jsxSetupDone = true;
38
38
 
39
- var jsDef = monaco.typescript.javascriptDefaults;
39
+ const jsDef = monaco.typescript.javascriptDefaults;
40
40
 
41
41
  // Log initial state
42
42
  console.log(
@@ -53,9 +53,9 @@
53
53
  applyCompilerAndDiag();
54
54
 
55
55
  // Global type stubs
56
- var libContent = [
57
- "declare var React: any;",
58
- "declare var ReactDOM: any;",
56
+ const libContent = [
57
+ "declare const React: any;",
58
+ "declare const ReactDOM: any;",
59
59
  "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
60
  ].join("\n");
61
61
  console.log(PREFIX, "addExtraLib globals.d.ts");
@@ -68,44 +68,44 @@
68
68
  monaco.languages.registerCompletionItemProvider("javascript", {
69
69
  triggerCharacters: ['"', "'", "`", " "],
70
70
  provideCompletionItems: function (model, position) {
71
- var line = model.getLineContent(position.lineNumber);
72
- var before = line.substring(0, position.column - 1);
71
+ const line = model.getLineContent(position.lineNumber);
72
+ const before = line.substring(0, position.column - 1);
73
73
 
74
74
  // 1) className="..." or className={`...`}
75
- var isClassName =
75
+ const isClassName =
76
76
  before.match(/className\s*=\s*["'][^"']*$/) ||
77
77
  before.match(/className\s*=\s*\{`[^`]*$/);
78
78
 
79
79
  // 2) Any open string literal: "..., '..., or `...
80
- var inString = !isClassName && before.match(/["'`][^"'`]*$/);
80
+ const inString = !isClassName && before.match(/["'`][^"'`]*$/);
81
81
 
82
82
  if (!isClassName && !inString) return { suggestions: [] };
83
83
 
84
84
  // Extract text inside the string
85
- var raw = (isClassName || inString)[0];
86
- var inside;
85
+ const raw = (isClassName || inString)[0];
86
+ let inside;
87
87
  if (isClassName) {
88
88
  inside = raw.replace(/^className\s*=\s*(?:\{`|["'])/, "");
89
89
  } else {
90
90
  inside = raw.substring(1); // skip opening quote
91
91
  }
92
92
  inside = inside.replace(/\$\{[^}]*\}/g, " ");
93
- var parts = inside.split(/\s+/);
94
- var word = parts[parts.length - 1] || "";
93
+ const parts = inside.split(/\s+/);
94
+ const word = parts[parts.length - 1] || "";
95
95
 
96
96
  // For non-className strings, only activate if at least one existing
97
97
  // token in the string looks like a TW class (avoid noise in random strings)
98
98
  if (!isClassName && parts.length > 0) {
99
- var classes = window.__fcTwClasses || [];
100
- var twSet = window.__fcTwSet;
99
+ const classes = window.__fcTwClasses || [];
100
+ let twSet = window.__fcTwSet;
101
101
  if (!twSet && classes.length) {
102
102
  twSet = new Set(classes);
103
103
  window.__fcTwSet = twSet;
104
104
  console.log(PREFIX, "TW: built lookup Set, size=" + twSet.size);
105
105
  }
106
106
  if (twSet) {
107
- var hasTwToken = false;
108
- for (var p = 0; p < parts.length; p++) {
107
+ let hasTwToken = false;
108
+ for (let p = 0; p < parts.length; p++) {
109
109
  if (parts[p] && twSet.has(parts[p])) {
110
110
  hasTwToken = true;
111
111
  break;
@@ -113,7 +113,7 @@
113
113
  }
114
114
  // If typing the very first word, accept if it looks like a prefix
115
115
  if (!hasTwToken && parts.length === 1 && word.length >= 2) {
116
- for (var j = 0; j < classes.length; j++) {
116
+ for (let j = 0; j < classes.length; j++) {
117
117
  if (classes[j].indexOf(word) === 0) {
118
118
  hasTwToken = true;
119
119
  break;
@@ -124,17 +124,17 @@
124
124
  }
125
125
  }
126
126
 
127
- var range = {
127
+ const range = {
128
128
  startLineNumber: position.lineNumber,
129
129
  endLineNumber: position.lineNumber,
130
130
  startColumn: position.column - word.length,
131
131
  endColumn: position.column,
132
132
  };
133
133
 
134
- var classes = window.__fcTwClasses || [];
135
- var suggestions = [];
136
- for (var i = 0; i < classes.length; i++) {
137
- var cls = classes[i];
134
+ const classes = window.__fcTwClasses || [];
135
+ const suggestions = [];
136
+ for (let i = 0; i < classes.length; i++) {
137
+ const cls = classes[i];
138
138
  if (!word.length || cls.indexOf(word) === 0) {
139
139
  suggestions.push({
140
140
  label: cls,
@@ -162,7 +162,7 @@
162
162
 
163
163
  // JSX/HTML tag completion — type tag name, Tab → <tag>|</tag>
164
164
  console.log(PREFIX, "registering JSX tag completion provider");
165
- var HTML_TAGS = [
165
+ const HTML_TAGS = [
166
166
  "div",
167
167
  "span",
168
168
  "p",
@@ -238,40 +238,40 @@
238
238
  "use",
239
239
  "text",
240
240
  ];
241
- var VOID_TAGS = new Set(["br", "hr", "img", "input", "wbr", "source"]);
241
+ const VOID_TAGS = new Set(["br", "hr", "img", "input", "wbr", "source"]);
242
242
 
243
243
  monaco.languages.registerCompletionItemProvider("javascript", {
244
244
  triggerCharacters: ["<"],
245
245
  provideCompletionItems: function (model, position) {
246
- var line = model.getLineContent(position.lineNumber);
247
- var before = line.substring(0, position.column - 1);
246
+ const line = model.getLineContent(position.lineNumber);
247
+ const before = line.substring(0, position.column - 1);
248
248
 
249
249
  // After "<" — suggest tags
250
- var afterBracket = before.match(/<([a-zA-Z]*)$/);
250
+ const afterBracket = before.match(/<([a-zA-Z]*)$/);
251
251
  // Bare word at line start or after whitespace/{ — Emmet-like (lower or upper)
252
- var bareWord =
252
+ const bareWord =
253
253
  !afterBracket &&
254
254
  before.match(/(?:^|[\s{(,])([a-zA-Z][a-zA-Z0-9]*)$/);
255
255
 
256
256
  if (!afterBracket && !bareWord) return { suggestions: [] };
257
257
 
258
- var typed = (afterBracket ? afterBracket[1] : bareWord[1]) || "";
259
- var replaceStart =
258
+ const typed = (afterBracket ? afterBracket[1] : bareWord[1]) || "";
259
+ const replaceStart =
260
260
  position.column - typed.length - (afterBracket ? 1 : 0);
261
261
 
262
- var range = {
262
+ const range = {
263
263
  startLineNumber: position.lineNumber,
264
264
  endLineNumber: position.lineNumber,
265
265
  startColumn: replaceStart,
266
266
  endColumn: position.column,
267
267
  };
268
268
 
269
- var suggestions = [];
270
- var seen = new Set();
269
+ const suggestions = [];
270
+ const seen = new Set();
271
271
 
272
272
  // HTML tags
273
- for (var i = 0; i < HTML_TAGS.length; i++) {
274
- var tag = HTML_TAGS[i];
273
+ for (let i = 0; i < HTML_TAGS.length; i++) {
274
+ const tag = HTML_TAGS[i];
275
275
  if (typed && tag.indexOf(typed) !== 0) continue;
276
276
  seen.add(tag);
277
277
  if (VOID_TAGS.has(tag)) {
@@ -310,11 +310,11 @@
310
310
  }
311
311
 
312
312
  // Custom components from registry (PascalCase)
313
- var upperMatch =
313
+ const upperMatch =
314
314
  afterBracket && before.match(/<([A-Z][a-zA-Z0-9]*)$/);
315
- var bareUpper =
315
+ const bareUpper =
316
316
  !afterBracket && before.match(/(?:^|[\s{(,])([A-Z][a-zA-Z0-9]*)$/);
317
- var compTyped = upperMatch
317
+ const compTyped = upperMatch
318
318
  ? upperMatch[1]
319
319
  : bareUpper
320
320
  ? bareUpper[1]
@@ -322,9 +322,9 @@
322
322
 
323
323
  if (compTyped || (!typed && afterBracket)) {
324
324
  // 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];
325
+ const reg = window.__fcComponentNames || [];
326
+ for (let c = 0; c < reg.length; c++) {
327
+ const name = reg[c];
328
328
  if (seen.has(name)) continue;
329
329
  if (compTyped && name.indexOf(compTyped) !== 0) continue;
330
330
  if (
@@ -396,16 +396,16 @@
396
396
  window.__fcAttachSelfClose = function (editor) {
397
397
  editor.onDidChangeModelContent(function (e) {
398
398
  if (e.changes.length !== 1) return;
399
- var ch = e.changes[0];
399
+ const ch = e.changes[0];
400
400
  if (ch.text !== "/") return;
401
- var model = editor.getModel();
401
+ const model = editor.getModel();
402
402
  if (!model) return;
403
- var lineNum = ch.range.startLineNumber;
404
- var line = model.getLineContent(lineNum);
403
+ const lineNum = ch.range.startLineNumber;
404
+ const line = model.getLineContent(lineNum);
405
405
  // Pattern 1: <tag>/</tag> (cursor was between > and </)
406
406
  // 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;
407
+ let m = line.match(/^(.*)<([a-zA-Z][a-zA-Z0-9]*)>\/<\/\2>(.*)$/);
408
+ let matchStr, tag, prefix;
409
409
  if (m) {
410
410
  prefix = m[1];
411
411
  tag = m[2];
@@ -417,10 +417,10 @@
417
417
  tag = m[2];
418
418
  matchStr = "<" + tag + "/></" + tag + ">";
419
419
  }
420
- var startCol = prefix.length + 1;
421
- var endCol = startCol + matchStr.length;
422
- var replacement = "<" + tag + " />";
423
- var cursorCol = startCol + replacement.length - 2;
420
+ const startCol = prefix.length + 1;
421
+ const endCol = startCol + matchStr.length;
422
+ const replacement = "<" + tag + " />";
423
+ const cursorCol = startCol + replacement.length - 2;
424
424
  setTimeout(function () {
425
425
  editor.executeEdits("self-close-collapse", [
426
426
  {
@@ -454,10 +454,72 @@
454
454
  }
455
455
  refreshComponentNames();
456
456
 
457
+ // Fetch utility nodes + their parsed top-level symbols.
458
+ // Window cache shape: { byNode: { utilName: [symbol, ...] }, allSymbols: [..] }
459
+ window.__fcRefreshUtilities = function () {
460
+ $.getJSON("portal-react/utilities", function (reg) {
461
+ const byNode = {};
462
+ const all = new Set();
463
+ Object.keys(reg).forEach(function (n) {
464
+ const syms = reg[n].symbols || [];
465
+ byNode[n] = syms;
466
+ syms.forEach(function (s) { all.add(s); });
467
+ });
468
+ window.__fcUtilities = { byNode: byNode, allSymbols: [...all] };
469
+ console.log(
470
+ PREFIX,
471
+ "utilities loaded: " + Object.keys(byNode).length + " nodes, " + all.size + " symbols",
472
+ );
473
+ }).fail(function () {
474
+ window.__fcUtilities = { byNode: {}, allSymbols: [] };
475
+ });
476
+ };
477
+ window.__fcRefreshUtilities();
478
+
479
+ // Utility-symbol completion: suggests bare identifiers anywhere in JS
480
+ // context EXCEPT after "<" (that's JSX tag completion, handled below).
481
+ monaco.languages.registerCompletionItemProvider("javascript", {
482
+ triggerCharacters: ["."], // jQuery-ish — but we mostly fire from word context
483
+ provideCompletionItems: function (model, position) {
484
+ const line = model.getLineContent(position.lineNumber);
485
+ const before = line.substring(0, position.column - 1);
486
+ // Skip JSX tag context — covered by other providers
487
+ if (/<[a-zA-Z]*$/.test(before)) return { suggestions: [] };
488
+ // Skip property access — let Monaco's language service handle it
489
+ if (/\.\s*\w*$/.test(before)) return { suggestions: [] };
490
+ // Skip inside strings
491
+ if (/["'`][^"'`]*$/.test(before)) return { suggestions: [] };
492
+ const wordMatch = before.match(/([A-Za-z_$][\w$]*)$/);
493
+ const word = wordMatch ? wordMatch[1] : "";
494
+ const all = (window.__fcUtilities && window.__fcUtilities.allSymbols) || [];
495
+ if (!all.length) return { suggestions: [] };
496
+ const range = {
497
+ startLineNumber: position.lineNumber,
498
+ endLineNumber: position.lineNumber,
499
+ startColumn: position.column - word.length,
500
+ endColumn: position.column,
501
+ };
502
+ const suggestions = [];
503
+ for (let i = 0; i < all.length; i++) {
504
+ const sym = all[i];
505
+ if (word && sym.indexOf(word) !== 0) continue;
506
+ suggestions.push({
507
+ label: sym,
508
+ kind: monaco.languages.CompletionItemKind.Function,
509
+ insertText: sym,
510
+ range: range,
511
+ detail: "fc-portal-utility",
512
+ sortText: "1" + sym,
513
+ });
514
+ }
515
+ return { suggestions: suggestions };
516
+ },
517
+ });
518
+
457
519
  // Marker listener for debugging
458
520
  monaco.editor.onDidChangeMarkers(function (uris) {
459
521
  uris.forEach(function (uri) {
460
- var markers = monaco.editor.getModelMarkers({ resource: uri });
522
+ const markers = monaco.editor.getModelMarkers({ resource: uri });
461
523
  if (markers.length > 0) {
462
524
  console.group(PREFIX + " MARKERS on " + uri.toString());
463
525
  markers.forEach(function (m) {
@@ -486,8 +548,8 @@
486
548
  }
487
549
 
488
550
  function applyCompilerAndDiag() {
489
- var jsDef = monaco.typescript.javascriptDefaults;
490
- var compilerOpts = {
551
+ const jsDef = monaco.typescript.javascriptDefaults;
552
+ const compilerOpts = {
491
553
  jsx: monaco.typescript.JsxEmit.React,
492
554
  target: monaco.typescript.ScriptTarget.ESNext,
493
555
  module: monaco.typescript.ModuleKind.ESNext,
@@ -497,7 +559,7 @@
497
559
  console.log(PREFIX, "setCompilerOptions:", JSON.stringify(compilerOpts));
498
560
  jsDef.setCompilerOptions(compilerOpts);
499
561
 
500
- var diagOpts = {
562
+ const diagOpts = {
501
563
  noSemanticValidation: true,
502
564
  // Server-side esbuild does the real syntax check at deploy time;
503
565
  // Monaco's TS parser produces noisy false positives on raw JSX
@@ -537,7 +599,7 @@
537
599
  cb();
538
600
  return;
539
601
  }
540
- var s = document.createElement("script");
602
+ const s = document.createElement("script");
541
603
  s.src = "portal-react/vs/loader.js";
542
604
  s.onload = function () {
543
605
  console.log(PREFIX, "loader.js loaded, configuring require paths");
@@ -590,9 +652,9 @@
590
652
 
591
653
  <script type="text/javascript">
592
654
  (function () {
593
- var compEditorInstance = null;
655
+ let compEditorInstance = null;
594
656
 
595
- var COMP_STARTER = [
657
+ const COMP_STARTER = [
596
658
  "function StatusCard({ label, value, unit }) {",
597
659
  " return (",
598
660
  ' <div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-6">',
@@ -622,8 +684,8 @@
622
684
  },
623
685
 
624
686
  oneditprepare: function () {
625
- var node = this;
626
- var code = node.compCode || COMP_STARTER;
687
+ const node = this;
688
+ const code = node.compCode || COMP_STARTER;
627
689
  console.log("[FC-Monaco] COMP oneditprepare, node.id=" + node.id);
628
690
 
629
691
  if (!window.__fcTwClasses) {
@@ -657,14 +719,14 @@
657
719
  );
658
720
  window.__fcApplyJsxDefaults();
659
721
 
660
- var compUri = monaco.Uri.parse("file:///fc-comp-" + node.id + ".jsx");
722
+ const compUri = monaco.Uri.parse("file:///fc-comp-" + node.id + ".jsx");
661
723
  console.log("[FC-Monaco] COMP: model URI=" + compUri.toString());
662
- var existingModel = monaco.editor.getModel(compUri);
724
+ const existingModel = monaco.editor.getModel(compUri);
663
725
  if (existingModel) {
664
726
  console.log("[FC-Monaco] COMP: disposing existing model");
665
727
  existingModel.dispose();
666
728
  }
667
- var compModel = monaco.editor.createModel(
729
+ const compModel = monaco.editor.createModel(
668
730
  code,
669
731
  "javascript",
670
732
  compUri,
@@ -682,7 +744,7 @@
682
744
 
683
745
  // Log markers after a short delay (diagnostics are async)
684
746
  setTimeout(function () {
685
- var markers = monaco.editor.getModelMarkers({ resource: compUri });
747
+ const markers = monaco.editor.getModelMarkers({ resource: compUri });
686
748
  console.log(
687
749
  "[FC-Monaco] COMP: markers after 500ms, count=" + markers.length,
688
750
  );
@@ -710,7 +772,7 @@
710
772
 
711
773
  oneditsave: function () {
712
774
  console.log("[FC-Monaco] COMP oneditsave");
713
- var code = compEditorInstance
775
+ const code = compEditorInstance
714
776
  ? compEditorInstance.getValue()
715
777
  : $("#fcc-fallback").val();
716
778
  $("#node-input-compCode").val(code);
@@ -734,7 +796,7 @@
734
796
  },
735
797
 
736
798
  oneditresize: function (size) {
737
- var h = size.height - 180;
799
+ let h = size.height - 180;
738
800
  if (h < 150) h = 150;
739
801
  $("#fcc-editor-wrap").css("height", h + "px");
740
802
  if (compEditorInstance) compEditorInstance.layout();
@@ -772,6 +834,198 @@
772
834
  </p>
773
835
  </script>
774
836
 
837
+ <!-- ============================================================
838
+ fc-portal-utility – canvas node (helpers / hooks / constants)
839
+ ============================================================ -->
840
+ <script type="text/html" data-template-name="fc-portal-utility">
841
+ <div class="form-row">
842
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
843
+ <input type="text" id="node-input-name" placeholder="Label on canvas" />
844
+ </div>
845
+ <div class="form-row">
846
+ <label for="node-input-utilName"><i class="fa fa-wrench"></i> Module name</label>
847
+ <input type="text" id="node-input-utilName" placeholder="mathHelpers" />
848
+ </div>
849
+ <div class="form-row" style="margin-bottom:4px;">
850
+ <label><i class="fa fa-code"></i> Code</label>
851
+ <div style="font-size:11px;opacity:.5;margin-top:2px;">
852
+ Top-level helpers, custom hooks and constants. Available globally to portals
853
+ that reference any of the symbols declared here.
854
+ </div>
855
+ </div>
856
+ <div class="form-row node-text-editor-row">
857
+ <div
858
+ id="fcu-editor-wrap"
859
+ style="width:100%;height:350px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
860
+ >
861
+ <div id="fcu-monaco" style="width:100%;height:100%;"></div>
862
+ <textarea
863
+ id="fcu-fallback"
864
+ 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;"
865
+ ></textarea>
866
+ </div>
867
+ </div>
868
+ <input type="hidden" id="node-input-utilCode" />
869
+ </script>
870
+
871
+ <script type="text/javascript">
872
+ (function () {
873
+ let utilEditorInstance = null;
874
+
875
+ const UTIL_STARTER = [
876
+ "// Helpers / custom hooks / constants — top-level, no React component.",
877
+ "// Each symbol declared here is available globally in any portal that",
878
+ "// references it (selective inclusion: unused utility nodes are skipped).",
879
+ "",
880
+ "const PI2 = Math.PI * 2;",
881
+ "",
882
+ "function clamp(n, min, max) {",
883
+ " return Math.max(min, Math.min(max, n));",
884
+ "}",
885
+ "",
886
+ "// Custom React hook — call from inside App() or library components",
887
+ "function useDebounce(value, ms = 300) {",
888
+ " const [v, setV] = React.useState(value);",
889
+ " React.useEffect(() => {",
890
+ " const t = setTimeout(() => setV(value), ms);",
891
+ " return () => clearTimeout(t);",
892
+ " }, [value, ms]);",
893
+ " return v;",
894
+ "}",
895
+ ].join("\n");
896
+
897
+ RED.nodes.registerType("fc-portal-utility", {
898
+ category: "fromcubes",
899
+ color: "#fbbf24",
900
+ defaults: {
901
+ name: { value: "" },
902
+ utilName: { value: "myHelpers", required: true },
903
+ utilCode: { value: UTIL_STARTER },
904
+ },
905
+ inputs: 0,
906
+ outputs: 0,
907
+ icon: "font-awesome/fa-wrench",
908
+ paletteLabel: "fromcubes utility",
909
+ label: function () {
910
+ return this.name || this.utilName || "utility";
911
+ },
912
+
913
+ oneditprepare: function () {
914
+ const node = this;
915
+ const code = node.utilCode || UTIL_STARTER;
916
+ console.log("[FC-Monaco] UTIL oneditprepare, node.id=" + node.id);
917
+
918
+ if (!window.__fcTwClasses) {
919
+ $.getJSON("portal-react/tw-classes", function (classes) {
920
+ window.__fcTwClasses = classes;
921
+ });
922
+ }
923
+
924
+ window.__fcLoadMonaco(function (failed) {
925
+ if (failed) {
926
+ $("#fcu-monaco").hide();
927
+ $("#fcu-fallback").show().val(code);
928
+ return;
929
+ }
930
+ window.__fcApplyJsxDefaults();
931
+
932
+ const utilUri = monaco.Uri.parse("file:///fc-util-" + node.id + ".js");
933
+ const existingModel = monaco.editor.getModel(utilUri);
934
+ if (existingModel) existingModel.dispose();
935
+ const utilModel = monaco.editor.createModel(code, "javascript", utilUri);
936
+
937
+ utilEditorInstance = monaco.editor.create(
938
+ document.getElementById("fcu-monaco"),
939
+ Object.assign({ model: utilModel }, window.__fcEditorOpts),
940
+ );
941
+ });
942
+ },
943
+
944
+ oneditsave: function () {
945
+ const code = utilEditorInstance
946
+ ? utilEditorInstance.getValue()
947
+ : $("#fcu-fallback").val();
948
+ $("#node-input-utilCode").val(code);
949
+ this.utilCode = code;
950
+ if (utilEditorInstance) {
951
+ utilEditorInstance.getModel().dispose();
952
+ utilEditorInstance.dispose();
953
+ utilEditorInstance = null;
954
+ }
955
+ },
956
+
957
+ oneditcancel: function () {
958
+ if (utilEditorInstance) {
959
+ utilEditorInstance.getModel().dispose();
960
+ utilEditorInstance.dispose();
961
+ utilEditorInstance = null;
962
+ }
963
+ },
964
+
965
+ oneditresize: function (size) {
966
+ let h = size.height - 200;
967
+ if (h < 150) h = 150;
968
+ $("#fcu-editor-wrap").css("height", h + "px");
969
+ if (utilEditorInstance) utilEditorInstance.layout();
970
+ },
971
+ });
972
+ })();
973
+ </script>
974
+
975
+ <script type="text/html" data-help-name="fc-portal-utility">
976
+ <p>
977
+ Defines shared <strong>helpers</strong>, <strong>custom hooks</strong> and
978
+ <strong>constants</strong> available to all <strong>portal-react</strong>
979
+ nodes. Unlike <code>fc-portal-component</code>, the code is injected raw at
980
+ top level (no IIFE wrapper) — a single utility node may declare many symbols.
981
+ </p>
982
+ <h3>Properties</h3>
983
+ <dl>
984
+ <dt>Module name</dt>
985
+ <dd>
986
+ Identifier for this utility node. Shares a namespace with
987
+ <code>fc-portal-component</code> names — must be unique across all
988
+ components and utilities.
989
+ </dd>
990
+ <dt>Code</dt>
991
+ <dd>
992
+ Top-level JavaScript: <code>const</code>, <code>let</code>,
993
+ <code>function</code>, <code>class</code> declarations. <code>import</code>
994
+ statements at the top are auto-hoisted into the bundle.
995
+ </dd>
996
+ </dl>
997
+ <h3>Selective inclusion</h3>
998
+ <p>
999
+ A utility node is bundled into a portal only when the portal's JSX (or any
1000
+ of its referenced library components) mentions at least one of the symbols
1001
+ declared in the utility. Unused utility nodes are skipped.
1002
+ </p>
1003
+ <p>
1004
+ Group related symbols in one node — referencing any one symbol pulls in the
1005
+ whole node's code. Split unrelated helpers across multiple nodes for finer
1006
+ granularity.
1007
+ </p>
1008
+ <h3>Example</h3>
1009
+ <pre>function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
1010
+
1011
+ function useDebounce(value, ms = 300) {
1012
+ const [v, setV] = React.useState(value);
1013
+ React.useEffect(() => {
1014
+ const t = setTimeout(() => setV(value), ms);
1015
+ return () => clearTimeout(t);
1016
+ }, [value, ms]);
1017
+ return v;
1018
+ }</pre>
1019
+ <p>
1020
+ Then in any <strong>portal-react</strong> node:
1021
+ </p>
1022
+ <pre>function App() {
1023
+ const { data } = useNodeRed();
1024
+ const slow = useDebounce(data?.value);
1025
+ return &lt;div&gt;{clamp(slow ?? 0, 0, 100)}&lt;/div&gt;;
1026
+ }</pre>
1027
+ </script>
1028
+
775
1029
  <!-- ============================================================
776
1030
  portal-react – main node
777
1031
  ============================================================ -->
@@ -802,6 +1056,9 @@
802
1056
  <button type="button" class="red-ui-button" id="fc-btn-components">
803
1057
  <i class="fa fa-cube"></i> Components
804
1058
  </button>
1059
+ <button type="button" class="red-ui-button" id="fc-btn-utilities">
1060
+ <i class="fa fa-wrench"></i> Utilities
1061
+ </button>
805
1062
  <span style="flex:1"></span>
806
1063
  <button type="button" class="red-ui-button" id="fc-btn-preview">
807
1064
  <i class="fa fa-eye"></i> Preview
@@ -909,10 +1166,10 @@
909
1166
 
910
1167
  <script type="text/javascript">
911
1168
  (function () {
912
- var editorInstance = null;
913
- var headEditorInstance = null;
1169
+ let editorInstance = null;
1170
+ let headEditorInstance = null;
914
1171
 
915
- var STARTER = [
1172
+ const STARTER = [
916
1173
  "// useNodeRed() \u2192 { data, send, portalClient }",
917
1174
  "// data = last msg.payload from input wire",
918
1175
  "// send(payload, topic?) = push msg to output wire",
@@ -949,15 +1206,15 @@
949
1206
  required: true,
950
1207
  validate: function (v) {
951
1208
  if (typeof v !== "string") return false;
952
- var t = v.trim();
1209
+ const t = v.trim();
953
1210
  if (t.length === 0) return false;
954
1211
  if (/\s/.test(t)) return false;
955
1212
  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];
1213
+ const segs = t.split("/");
1214
+ for (let i = 0; i < segs.length; i++) {
1215
+ const s = segs[i];
959
1216
  if (!s || s === "." || s === "..") return false;
960
- var lower = s.toLowerCase();
1217
+ const lower = s.toLowerCase();
961
1218
  if (lower === "public" || lower === "_ws") return false;
962
1219
  if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(s)) return false;
963
1220
  }
@@ -981,9 +1238,9 @@
981
1238
  },
982
1239
 
983
1240
  oneditprepare: function () {
984
- var node = this;
985
- var code = node.componentCode || STARTER;
986
- var headCode = node.customHead || "";
1241
+ const node = this;
1242
+ const code = node.componentCode || STARTER;
1243
+ const headCode = node.customHead || "";
987
1244
  console.log("[FC-Monaco] PORTAL oneditprepare, node.id=" + node.id);
988
1245
 
989
1246
  if (!window.__fcTwClasses) {
@@ -1003,7 +1260,7 @@
1003
1260
  }
1004
1261
 
1005
1262
  // Tabs
1006
- var fcTabs = RED.tabs.create({
1263
+ const fcTabs = RED.tabs.create({
1007
1264
  id: "fc-tabs",
1008
1265
  onchange: function (tab) {
1009
1266
  $(".fc-tab-pane").hide();
@@ -1021,8 +1278,8 @@
1021
1278
 
1022
1279
  // Legacy endpoint detection + convenience pre-fill
1023
1280
  if (node.endpoint && typeof node.endpoint === "string") {
1024
- var legacy = node.endpoint;
1025
- var prefix = "/fromcubes/";
1281
+ const legacy = node.endpoint;
1282
+ const prefix = "/fromcubes/";
1026
1283
  if (legacy.indexOf(prefix) === 0) {
1027
1284
  if (!node.subPath) {
1028
1285
  $("#node-input-subPath").val(legacy.slice(prefix.length));
@@ -1040,8 +1297,8 @@
1040
1297
 
1041
1298
  // URL hint
1042
1299
  function updateHint() {
1043
- var sp = ($("#node-input-subPath").val() || "").trim();
1044
- var root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1300
+ const sp = ($("#node-input-subPath").val() || "").trim();
1301
+ const root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1045
1302
  if (sp) {
1046
1303
  $("#fc-url-hint")
1047
1304
  .css("color", "")
@@ -1061,15 +1318,15 @@
1061
1318
  updateHint();
1062
1319
 
1063
1320
  // Modules editableList (like function node's libs)
1064
- var libsList = node.libs || [];
1321
+ const libsList = node.libs || [];
1065
1322
  $("#node-input-libs-container").css("min-height","68px").editableList({
1066
1323
  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);
1324
+ const lib = opt || {};
1325
+ const row = $('<div/>',{style:"display:flex;gap:8px;align-items:center;"}).appendTo(container);
1326
+ const modInput = $('<input/>',{type:"text",placeholder:"e.g. chart.js/auto@^4.4.0",style:"flex:1;"}).appendTo(row);
1327
+ const varInput = $('<input/>',{type:"text",placeholder:"Import as (e.g. Chart)",style:"width:140px;"}).appendTo(row);
1071
1328
  modInput.val(lib.module || "");
1072
- varInput.val(lib.var || "");
1329
+ varInput.val(lib.const || "");
1073
1330
  container.data("mod", modInput);
1074
1331
  container.data("var", varInput);
1075
1332
  },
@@ -1097,18 +1354,18 @@
1097
1354
  "[FC-Monaco] PORTAL: applying JSX defaults before model creation",
1098
1355
  );
1099
1356
  window.__fcApplyJsxDefaults();
1100
- var opts = window.__fcEditorOpts;
1357
+ const opts = window.__fcEditorOpts;
1101
1358
 
1102
- var jsxUri = monaco.Uri.parse(
1359
+ const jsxUri = monaco.Uri.parse(
1103
1360
  "file:///fc-portal-" + node.id + ".jsx",
1104
1361
  );
1105
1362
  console.log("[FC-Monaco] PORTAL: JSX model URI=" + jsxUri.toString());
1106
- var existingJsx = monaco.editor.getModel(jsxUri);
1363
+ const existingJsx = monaco.editor.getModel(jsxUri);
1107
1364
  if (existingJsx) {
1108
1365
  console.log("[FC-Monaco] PORTAL: disposing existing JSX model");
1109
1366
  existingJsx.dispose();
1110
1367
  }
1111
- var jsxModel = monaco.editor.createModel(code, "javascript", jsxUri);
1368
+ const jsxModel = monaco.editor.createModel(code, "javascript", jsxUri);
1112
1369
  console.log(
1113
1370
  "[FC-Monaco] PORTAL: JSX model created, language=" +
1114
1371
  jsxModel.getLanguageId(),
@@ -1121,12 +1378,12 @@
1121
1378
  if (window.__fcAttachSelfClose)
1122
1379
  window.__fcAttachSelfClose(editorInstance);
1123
1380
 
1124
- var headUri = monaco.Uri.parse(
1381
+ const headUri = monaco.Uri.parse(
1125
1382
  "file:///fc-head-" + node.id + ".html",
1126
1383
  );
1127
- var existingHead = monaco.editor.getModel(headUri);
1384
+ const existingHead = monaco.editor.getModel(headUri);
1128
1385
  if (existingHead) existingHead.dispose();
1129
- var headModel = monaco.editor.createModel(headCode, "html", headUri);
1386
+ const headModel = monaco.editor.createModel(headCode, "html", headUri);
1130
1387
  headEditorInstance = monaco.editor.create(
1131
1388
  document.getElementById("fc-head-monaco"),
1132
1389
  Object.assign({ model: headModel }, opts),
@@ -1135,7 +1392,7 @@
1135
1392
 
1136
1393
  // Log markers after a short delay (diagnostics are async)
1137
1394
  setTimeout(function () {
1138
- var markers = monaco.editor.getModelMarkers({ resource: jsxUri });
1395
+ const markers = monaco.editor.getModelMarkers({ resource: jsxUri });
1139
1396
  console.log(
1140
1397
  "[FC-Monaco] PORTAL: markers after 500ms, count=" +
1141
1398
  markers.length,
@@ -1184,14 +1441,14 @@
1184
1441
  });
1185
1442
 
1186
1443
  $("#fc-btn-preview").on("click", function () {
1187
- var sp = ($("#node-input-subPath").val() || "").trim();
1188
- var root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1444
+ const sp = ($("#node-input-subPath").val() || "").trim();
1445
+ const root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1189
1446
  if (sp) window.open(root + "/fromcubes/" + sp, "_blank");
1190
1447
  });
1191
1448
 
1192
1449
  $("#fc-btn-components").on("click", function () {
1193
1450
  $.getJSON("portal-react/registry", function (reg) {
1194
- var names = Object.keys(reg).sort();
1451
+ const names = Object.keys(reg).sort();
1195
1452
  if (!names.length) {
1196
1453
  RED.notify("No component nodes on canvas.", "warning");
1197
1454
  return;
@@ -1199,12 +1456,12 @@
1199
1456
 
1200
1457
  function extractProps(code) {
1201
1458
  if (!code) return [];
1202
- var m = code.match(/function\s+\w+\s*\(\s*\{([^}]*)\}/);
1459
+ const m = code.match(/function\s+\w+\s*\(\s*\{([^}]*)\}/);
1203
1460
  if (!m) return [];
1204
1461
  return m[1].split(",").map(function (s) { return s.trim().split(/\s*=\s*/)[0]; }).filter(Boolean);
1205
1462
  }
1206
1463
 
1207
- var html =
1464
+ let html =
1208
1465
  '<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">' +
1209
1466
  '<input type="text" id="fc-comp-search" placeholder="Search..." ' +
1210
1467
  '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 +1469,16 @@
1212
1469
  '<div id="fc-comp-list" style="flex:1;overflow-y:auto;">';
1213
1470
 
1214
1471
  names.forEach(function (n) {
1215
- var c = reg[n];
1216
- var props = extractProps(c.code);
1217
- var detailParts = [];
1472
+ const c = reg[n];
1473
+ const props = extractProps(c.code);
1474
+ const detailParts = [];
1218
1475
  if (props.length) {
1219
1476
  detailParts.push('<div style="margin:4px 0 0 0;font-size:11px;opacity:.6;">' +
1220
1477
  props.map(function (p) {
1221
1478
  return '<code style="background:rgba(128,128,128,.15);padding:0 4px;border-radius:2px;margin-right:3px;">' + p + '</code>';
1222
1479
  }).join("") + '</div>');
1223
1480
  }
1224
- var hasDetail = detailParts.length > 0;
1481
+ const hasDetail = detailParts.length > 0;
1225
1482
 
1226
1483
  html +=
1227
1484
  '<div class="fc-comp-item" data-name="' + n + '" data-search="' + n.toLowerCase() + '" ' +
@@ -1235,7 +1492,7 @@
1235
1492
  });
1236
1493
  html += '</div></div>';
1237
1494
 
1238
- var $dlg = $("<div></div>").html(html).dialog({
1495
+ const $dlg = $("<div></div>").html(html).dialog({
1239
1496
  title: "Components",
1240
1497
  modal: true,
1241
1498
  width: 380,
@@ -1247,7 +1504,7 @@
1247
1504
 
1248
1505
  // Search
1249
1506
  $dlg.find("#fc-comp-search").on("input", function () {
1250
- var q = $(this).val().toLowerCase();
1507
+ const q = $(this).val().toLowerCase();
1251
1508
  $dlg.find(".fc-comp-item").each(function () {
1252
1509
  $(this).toggle($(this).data("search").indexOf(q) !== -1);
1253
1510
  });
@@ -1263,24 +1520,24 @@
1263
1520
  // Arrow toggle detail
1264
1521
  $dlg.on("click", ".fc-comp-arrow", function (e) {
1265
1522
  e.stopPropagation();
1266
- var $item = $(this).closest(".fc-comp-item");
1267
- var $detail = $item.find(".fc-comp-detail");
1268
- var open = $detail.is(":visible");
1523
+ const $item = $(this).closest(".fc-comp-item");
1524
+ const $detail = $item.find(".fc-comp-detail");
1525
+ const open = $detail.is(":visible");
1269
1526
  $detail.slideToggle(100);
1270
1527
  $(this).css("transform", open ? "" : "rotate(90deg)");
1271
1528
  });
1272
1529
 
1273
1530
  // Click name: delete selection, insert <Tag></Tag> in one line, cursor between tags
1274
1531
  $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;
1532
+ const name = $(this).data("name");
1533
+ const openTag = "<" + name + ">";
1534
+ const closeTag = "</" + name + ">";
1535
+ const text = openTag + closeTag;
1279
1536
  $dlg.dialog("close");
1280
1537
  if (editorInstance) {
1281
- var sel = editorInstance.getSelection();
1282
- var startLine = sel.startLineNumber;
1283
- var startCol = sel.startColumn;
1538
+ const sel = editorInstance.getSelection();
1539
+ const startLine = sel.startLineNumber;
1540
+ const startCol = sel.startColumn;
1284
1541
  editorInstance.executeEdits("fc-components", [{
1285
1542
  range: sel,
1286
1543
  text: text,
@@ -1290,8 +1547,8 @@
1290
1547
  editorInstance.focus();
1291
1548
  }, 50);
1292
1549
  } else {
1293
- var ta = $("#fc-fallback")[0];
1294
- var s = ta.selectionStart, e = ta.selectionEnd, v = ta.value;
1550
+ const ta = $("#fc-fallback")[0];
1551
+ const s = ta.selectionStart, e = ta.selectionEnd, v = ta.value;
1295
1552
  ta.value = v.slice(0, s) + text + v.slice(e);
1296
1553
  setTimeout(function () {
1297
1554
  ta.selectionStart = ta.selectionEnd = s + openTag.length;
@@ -1301,6 +1558,118 @@
1301
1558
  });
1302
1559
  });
1303
1560
  });
1561
+
1562
+ $("#fc-btn-utilities").on("click", function () {
1563
+ $.getJSON("portal-react/utilities", function (reg) {
1564
+ // Refresh global cache too so completion provider picks up changes
1565
+ if (window.__fcRefreshUtilities) window.__fcRefreshUtilities();
1566
+ const names = Object.keys(reg).sort();
1567
+ if (!names.length) {
1568
+ RED.notify("No utility nodes on canvas.", "warning");
1569
+ return;
1570
+ }
1571
+
1572
+ let html =
1573
+ '<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">' +
1574
+ '<input type="text" id="fc-util-search" placeholder="Search..." ' +
1575
+ 'style="width:100%;box-sizing:border-box;margin-bottom:6px;padding:5px 8px;border:1px solid rgba(128,128,128,.4);border-radius:3px;' +
1576
+ 'background:var(--red-ui-form-input-background-color,#fff);color:var(--red-ui-form-text-color,#333);font-size:12px;flex-shrink:0;" />' +
1577
+ '<div id="fc-util-list" style="flex:1;overflow-y:auto;">';
1578
+
1579
+ names.forEach(function (n) {
1580
+ const u = reg[n];
1581
+ const syms = u.symbols || [];
1582
+ const errBadge = u.error
1583
+ ? '<span style="margin-left:6px;font-size:10px;color:#ef4444;" title="' +
1584
+ String(u.error).replace(/"/g, "&quot;") + '">syntax error</span>'
1585
+ : "";
1586
+ const symBadges = syms.map(function (s) {
1587
+ return '<span class="fc-util-sym" data-sym="' + s +
1588
+ '" style="display:inline-block;background:rgba(251,191,36,.15);border:1px solid rgba(251,191,36,.35);' +
1589
+ 'padding:1px 6px;border-radius:3px;margin:2px 4px 2px 0;font-size:11px;font-family:monospace;cursor:pointer;">' +
1590
+ s + '</span>';
1591
+ }).join("");
1592
+ const detail = syms.length
1593
+ ? '<div style="margin:4px 0 0 0;">' + symBadges + '</div>'
1594
+ : '<div style="margin:4px 0 0 0;font-size:11px;opacity:.5;">(no top-level symbols detected)</div>';
1595
+ const searchKey = (n + " " + syms.join(" ")).toLowerCase();
1596
+
1597
+ html +=
1598
+ '<div class="fc-util-item" data-name="' + n + '" data-search="' + searchKey + '" ' +
1599
+ 'style="border-bottom:1px solid rgba(128,128,128,.1);">' +
1600
+ '<div style="display:flex;align-items:center;padding:4px 6px;cursor:pointer;" class="fc-util-row">' +
1601
+ '<i class="fa fa-caret-right fc-util-arrow" style="width:14px;opacity:.4;font-size:12px;transition:transform .15s;"></i>' +
1602
+ '<i class="fa fa-wrench" style="width:14px;color:#fbbf24;font-size:11px;"></i>' +
1603
+ '<span style="font-weight:600;font-size:12px;flex:1;margin-left:4px;">' + n + '</span>' +
1604
+ errBadge +
1605
+ '</div>' +
1606
+ '<div class="fc-util-detail" style="display:none;padding:0 6px 6px 32px;">' + detail + '</div>' +
1607
+ '</div>';
1608
+ });
1609
+ html += '</div></div>';
1610
+
1611
+ const $dlg = $("<div></div>").html(html).dialog({
1612
+ title: "Utilities",
1613
+ modal: true,
1614
+ width: 420,
1615
+ buttons: [
1616
+ { text: "Close", click: function () { $(this).dialog("close"); } },
1617
+ ],
1618
+ close: function () { $(this).remove(); },
1619
+ });
1620
+
1621
+ $dlg.find("#fc-util-search").on("input", function () {
1622
+ const q = $(this).val().toLowerCase();
1623
+ $dlg.find(".fc-util-item").each(function () {
1624
+ $(this).toggle($(this).data("search").indexOf(q) !== -1);
1625
+ });
1626
+ }).focus();
1627
+
1628
+ $dlg.on("mouseenter", ".fc-util-row", function () {
1629
+ $(this).css("background", "rgba(251,191,36,.08)");
1630
+ }).on("mouseleave", ".fc-util-row", function () {
1631
+ $(this).css("background", "");
1632
+ });
1633
+
1634
+ $dlg.on("click", ".fc-util-row", function (e) {
1635
+ if ($(e.target).closest(".fc-util-sym").length) return;
1636
+ const $item = $(this).closest(".fc-util-item");
1637
+ const $detail = $item.find(".fc-util-detail");
1638
+ const $arrow = $item.find(".fc-util-arrow");
1639
+ const open = $detail.is(":visible");
1640
+ $detail.slideToggle(100);
1641
+ $arrow.css("transform", open ? "" : "rotate(90deg)");
1642
+ });
1643
+
1644
+ // Click symbol → insert bare identifier at cursor
1645
+ $dlg.on("click", ".fc-util-sym", function (e) {
1646
+ e.stopPropagation();
1647
+ const sym = $(this).data("sym");
1648
+ $dlg.dialog("close");
1649
+ if (editorInstance) {
1650
+ const sel = editorInstance.getSelection();
1651
+ const startLine = sel.startLineNumber;
1652
+ const startCol = sel.startColumn;
1653
+ editorInstance.executeEdits("fc-utilities", [{
1654
+ range: sel,
1655
+ text: sym,
1656
+ }]);
1657
+ setTimeout(function () {
1658
+ editorInstance.setPosition({ lineNumber: startLine, column: startCol + sym.length });
1659
+ editorInstance.focus();
1660
+ }, 50);
1661
+ } else {
1662
+ const ta = $("#fc-fallback")[0];
1663
+ const s = ta.selectionStart, en = ta.selectionEnd, v = ta.value;
1664
+ ta.value = v.slice(0, s) + sym + v.slice(en);
1665
+ setTimeout(function () {
1666
+ ta.selectionStart = ta.selectionEnd = s + sym.length;
1667
+ ta.focus();
1668
+ }, 50);
1669
+ }
1670
+ });
1671
+ });
1672
+ });
1304
1673
  },
1305
1674
 
1306
1675
  oneditsave: function () {
@@ -1312,24 +1681,24 @@
1312
1681
  this.subPath = ($("#node-input-subPath").val() || "").trim();
1313
1682
 
1314
1683
  // Collect libs from editableList
1315
- var libs = [];
1316
- var items = $("#node-input-libs-container").editableList("items");
1684
+ const libs = [];
1685
+ const items = $("#node-input-libs-container").editableList("items");
1317
1686
  items.each(function() {
1318
- var mod = $(this).data("mod").val().trim();
1319
- var v = $(this).data("var").val().trim();
1687
+ const mod = $(this).data("mod").val().trim();
1688
+ const v = $(this).data("var").val().trim();
1320
1689
  if (mod) {
1321
1690
  libs.push({ module: mod, var: v });
1322
1691
  }
1323
1692
  });
1324
1693
  this.libs = libs;
1325
1694
 
1326
- var code = editorInstance
1695
+ const code = editorInstance
1327
1696
  ? editorInstance.getValue()
1328
1697
  : $("#fc-fallback").val();
1329
1698
  $("#node-input-componentCode").val(code);
1330
1699
  this.componentCode = code;
1331
1700
 
1332
- var head = headEditorInstance
1701
+ const head = headEditorInstance
1333
1702
  ? headEditorInstance.getValue()
1334
1703
  : $("#fc-head-fallback").val();
1335
1704
  $("#node-input-customHead").val(head);
@@ -1366,11 +1735,11 @@
1366
1735
  },
1367
1736
 
1368
1737
  oneditresize: function (size) {
1369
- var tabsH = $("#fc-tabs").outerHeight(true) || 0;
1370
- var rows = $(
1738
+ const tabsH = $("#fc-tabs").outerHeight(true) || 0;
1739
+ const rows = $(
1371
1740
  "#dialog-form>div:not(#fc-tabs-content):not(:has(#fc-tabs))",
1372
1741
  );
1373
- var h = size.height;
1742
+ let h = size.height;
1374
1743
  rows.each(function () {
1375
1744
  h -= $(this).outerHeight(true);
1376
1745
  });
@@ -1507,8 +1876,8 @@ const { data, send, user, portalClient } = useNodeRed();
1507
1876
  <script type="text/javascript">
1508
1877
  (function () {
1509
1878
  // Resolve httpNodeRoot for public URL prefix
1510
- var nodeRoot = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1511
- var publicBase = nodeRoot + "/fromcubes/public/";
1879
+ const nodeRoot = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1880
+ const publicBase = nodeRoot + "/fromcubes/public/";
1512
1881
 
1513
1882
  function formatSize(bytes) {
1514
1883
  if (bytes < 1024) return bytes + " B";
@@ -1516,15 +1885,15 @@ const { data, send, user, portalClient } = useNodeRed();
1516
1885
  return (bytes / (1024 * 1024)).toFixed(1) + " MB";
1517
1886
  }
1518
1887
 
1519
- var allEntries = [];
1520
- var collapsed = {};
1888
+ let allEntries = [];
1889
+ const collapsed = {};
1521
1890
 
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);
1891
+ const content = $('<div class="red-ui-sidebar-info" style="height:100%;overflow:auto;padding:0;"></div>');
1892
+ const fileList = $('<div style="padding:0;"></div>').appendTo(content);
1524
1893
 
1525
1894
  // ── 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);
1895
+ const toolbar = $('<div style="display:flex;align-items:center;gap:6px;margin:0 6px;padding:2px 0 0;"></div>');
1896
+ const fileInput = $('<input type="file" multiple style="display:none;">').appendTo(toolbar);
1528
1897
  $('<button class="red-ui-button red-ui-button-small" style="flex-shrink:0;"><i class="fa fa-upload"></i> Upload</button>')
1529
1898
  .on("click", function (e) { e.preventDefault(); fileInput.trigger("click"); })
1530
1899
  .appendTo(toolbar);
@@ -1534,7 +1903,7 @@ const { data, send, user, portalClient } = useNodeRed();
1534
1903
 
1535
1904
  // ── Helpers ──
1536
1905
  function pathExists(p) {
1537
- for (var i = 0; i < allEntries.length; i++) {
1906
+ for (let i = 0; i < allEntries.length; i++) {
1538
1907
  if (allEntries[i].name === p) return true;
1539
1908
  }
1540
1909
  return false;
@@ -1542,10 +1911,10 @@ const { data, send, user, portalClient } = useNodeRed();
1542
1911
 
1543
1912
  function uploadFiles(files, targetDir) {
1544
1913
  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;
1914
+ let toUpload = [];
1915
+ const duplicates = [];
1916
+ for (let i = 0; i < files.length; i++) {
1917
+ const uploadPath = targetDir ? targetDir + "/" + files[i].name : files[i].name;
1549
1918
  if (pathExists(uploadPath)) {
1550
1919
  duplicates.push({ file: files[i], path: uploadPath });
1551
1920
  } else {
@@ -1553,15 +1922,15 @@ const { data, send, user, portalClient } = useNodeRed();
1553
1922
  }
1554
1923
  }
1555
1924
  if (duplicates.length > 0) {
1556
- var names = duplicates.map(function (d) { return d.file.name; }).join(", ");
1925
+ const names = duplicates.map(function (d) { return d.file.name; }).join(", ");
1557
1926
  if (confirm("These files already exist: " + names + "\nOverwrite?")) {
1558
1927
  toUpload = toUpload.concat(duplicates);
1559
1928
  }
1560
1929
  }
1561
1930
  if (toUpload.length === 0) return;
1562
- var pending = toUpload.length;
1931
+ let pending = toUpload.length;
1563
1932
  toUpload.forEach(function (item) {
1564
- var reader = new FileReader();
1933
+ const reader = new FileReader();
1565
1934
  reader.onload = function () {
1566
1935
  $.ajax({
1567
1936
  type: "POST",
@@ -1601,15 +1970,15 @@ const { data, send, user, portalClient } = useNodeRed();
1601
1970
  e.preventDefault();
1602
1971
  e.stopPropagation();
1603
1972
  content.css("background", "");
1604
- var dt = e.originalEvent.dataTransfer;
1973
+ const dt = e.originalEvent.dataTransfer;
1605
1974
  if (dt.files && dt.files.length > 0) {
1606
1975
  uploadFiles(dt.files, "");
1607
1976
  }
1608
1977
  });
1609
1978
 
1610
1979
  function moveItem(fromPath, toDir) {
1611
- var filename = fromPath.split("/").pop();
1612
- var newPath = toDir ? toDir + "/" + filename : filename;
1980
+ const filename = fromPath.split("/").pop();
1981
+ const newPath = toDir ? toDir + "/" + filename : filename;
1613
1982
  if (fromPath === newPath) return;
1614
1983
  if (pathExists(newPath)) {
1615
1984
  if (!confirm("'" + filename + "' already exists in this folder. Overwrite?")) return;
@@ -1620,7 +1989,7 @@ const { data, send, user, portalClient } = useNodeRed();
1620
1989
  data: JSON.stringify({ from: fromPath, to: newPath }),
1621
1990
  success: function () { refreshList(); },
1622
1991
  error: function (xhr) {
1623
- var msg = xhr.responseJSON ? xhr.responseJSON.error : "move failed";
1992
+ const msg = xhr.responseJSON ? xhr.responseJSON.error : "move failed";
1624
1993
  RED.notify("Move failed: " + msg, "error");
1625
1994
  },
1626
1995
  });
@@ -1628,22 +1997,22 @@ const { data, send, user, portalClient } = useNodeRed();
1628
1997
 
1629
1998
  // ── Inline new-folder input ──
1630
1999
  function showNewFolderInput(parentDir) {
1631
- var existingInput = fileList.find(".fc-new-folder-row");
2000
+ const existingInput = fileList.find(".fc-new-folder-row");
1632
2001
  if (existingInput.length) existingInput.remove();
1633
2002
 
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>');
2003
+ const depth = parentDir ? parentDir.split("/").length : 0;
2004
+ const indent = 8 + depth * 18;
2005
+ 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
2006
  row.css("padding-left", indent + "px");
1638
2007
  $('<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;">');
2008
+ const inp = $('<input type="text" placeholder="folder name" class="red-ui-searchBox-input" style="flex:1;padding:3px 6px;font-size:12px;">');
1640
2009
  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>');
2010
+ 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>');
2011
+ const cancelBtn = $('<button class="red-ui-button red-ui-button-small" title="Cancel"><i class="fa fa-times"></i></button>');
1643
2012
  function submit() {
1644
- var name = inp.val().trim();
2013
+ const name = inp.val().trim();
1645
2014
  if (!name) { row.remove(); return; }
1646
- var p = parentDir ? parentDir + "/" + name : name;
2015
+ const p = parentDir ? parentDir + "/" + name : name;
1647
2016
  if (pathExists(p)) {
1648
2017
  RED.notify("Folder '" + name + "' already exists", "warning");
1649
2018
  return;
@@ -1664,7 +2033,7 @@ const { data, send, user, portalClient } = useNodeRed();
1664
2033
 
1665
2034
  // Insert after the parent folder row, or at top
1666
2035
  if (parentDir) {
1667
- var parentRow = fileList.find('[data-path="' + parentDir + '"]');
2036
+ const parentRow = fileList.find('[data-path="' + parentDir + '"]');
1668
2037
  if (parentRow.length) { row.insertAfter(parentRow); } else { fileList.prepend(row); }
1669
2038
  } else {
1670
2039
  fileList.prepend(row);
@@ -1673,7 +2042,7 @@ const { data, send, user, portalClient } = useNodeRed();
1673
2042
  }
1674
2043
 
1675
2044
  // ── Context menu (native Node-RED style) ──
1676
- var activeMenu = null;
2045
+ let activeMenu = null;
1677
2046
  function closeMenu() {
1678
2047
  if (activeMenu) { activeMenu.remove(); activeMenu = null; }
1679
2048
  }
@@ -1681,14 +2050,14 @@ const { data, send, user, portalClient } = useNodeRed();
1681
2050
 
1682
2051
  function showMenu(anchor, items) {
1683
2052
  closeMenu();
1684
- var menu = $('<ul class="red-ui-menu red-ui-menu-dropdown" style="position:absolute;z-index:10000;display:block;"></ul>');
2053
+ const menu = $('<ul class="red-ui-menu red-ui-menu-dropdown" style="position:absolute;z-index:10000;display:block;"></ul>');
1685
2054
  items.forEach(function (item) {
1686
2055
  if (item.divider) {
1687
2056
  menu.append('<li class="red-ui-menu-divider"></li>');
1688
2057
  return;
1689
2058
  }
1690
- var li = $('<li></li>');
1691
- var a = $('<a href="#"></a>');
2059
+ const li = $('<li></li>');
2060
+ const a = $('<a href="#"></a>');
1692
2061
  if (item.danger) a.css("color", "var(--red-ui-text-color-error)");
1693
2062
  a.append('<i class="fa ' + item.icon + '" style="width:18px;text-align:center;"></i> ');
1694
2063
  a.append($('<span class="red-ui-menu-label"></span>').text(item.label));
@@ -1706,32 +2075,32 @@ const { data, send, user, portalClient } = useNodeRed();
1706
2075
  activeMenu = menu;
1707
2076
 
1708
2077
  // 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();
2078
+ const off = anchor.offset();
2079
+ let top = off.top + anchor.outerHeight() + 2;
2080
+ let left = off.left - menu.outerWidth() + anchor.outerWidth();
1712
2081
  if (left < 0) left = off.left;
1713
2082
  if (top + menu.outerHeight() > $(window).height()) top = off.top - menu.outerHeight() - 2;
1714
2083
  menu.css({ top: top, left: left });
1715
2084
  }
1716
2085
 
1717
- var adminRoot = (RED.settings.httpAdminRoot || "/").replace(/\/$/, "");
2086
+ const adminRoot = (RED.settings.httpAdminRoot || "/").replace(/\/$/, "");
1718
2087
 
1719
2088
  function showRenameInput(rowEl, fullPath, currentName) {
1720
- var nameSpan = rowEl.find("span").filter(function () { return $(this).text() === currentName; }).first();
2089
+ const nameSpan = rowEl.find("span").filter(function () { return $(this).text() === currentName; }).first();
1721
2090
  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) : "";
2091
+ const origText = nameSpan.text();
2092
+ const inp = $('<input type="text" class="red-ui-searchBox-input" style="flex:1;padding:3px 6px;font-size:12px;">').val(origText);
2093
+ 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>');
2094
+ const cancelBtn = $('<button class="red-ui-button red-ui-button-small" title="Cancel"><i class="fa fa-times"></i></button>');
2095
+ const dotIdx = origText.lastIndexOf(".");
2096
+ const hasExt = dotIdx > 0; // has extension (not hidden file)
2097
+ const origExt = hasExt ? origText.slice(dotIdx) : "";
1729
2098
 
1730
2099
  nameSpan.replaceWith(inp);
1731
2100
  inp.after(cancelBtn).after(okBtn);
1732
2101
  inp.focus();
1733
2102
  // Select only the name part before extension
1734
- var el = inp[0];
2103
+ const el = inp[0];
1735
2104
  if (hasExt && el.setSelectionRange) {
1736
2105
  el.setSelectionRange(0, dotIdx);
1737
2106
  } else {
@@ -1744,22 +2113,22 @@ const { data, send, user, portalClient } = useNodeRed();
1744
2113
  inp.replaceWith($('<span style="flex:1;font-size:12px;word-break:break-all;"></span>').text(origText));
1745
2114
  }
1746
2115
  function submit() {
1747
- var newName = inp.val().trim();
2116
+ const newName = inp.val().trim();
1748
2117
  if (!newName) {
1749
2118
  RED.notify("Name cannot be empty", "warning");
1750
2119
  return;
1751
2120
  }
1752
2121
  // Warn if extension changed or removed
1753
2122
  if (hasExt) {
1754
- var newDot = newName.lastIndexOf(".");
1755
- var newExt = newDot > 0 ? newName.slice(newDot) : "";
2123
+ const newDot = newName.lastIndexOf(".");
2124
+ const newExt = newDot > 0 ? newName.slice(newDot) : "";
1756
2125
  if (newExt.toLowerCase() !== origExt.toLowerCase()) {
1757
2126
  if (!confirm("Extension changed from '" + origExt + "' to '" + (newExt || "none") + "'. Continue?")) return;
1758
2127
  }
1759
2128
  }
1760
2129
  if (newName === origText) { restore(); return; }
1761
- var parentDir = fullPath.indexOf("/") >= 0 ? fullPath.slice(0, fullPath.lastIndexOf("/")) : "";
1762
- var newPath = parentDir ? parentDir + "/" + newName : newName;
2130
+ const parentDir = fullPath.indexOf("/") >= 0 ? fullPath.slice(0, fullPath.lastIndexOf("/")) : "";
2131
+ const newPath = parentDir ? parentDir + "/" + newName : newName;
1763
2132
  if (pathExists(newPath)) {
1764
2133
  RED.notify("'" + newName + "' already exists", "warning");
1765
2134
  return;
@@ -1770,7 +2139,7 @@ const { data, send, user, portalClient } = useNodeRed();
1770
2139
  data: JSON.stringify({ from: fullPath, to: newPath }),
1771
2140
  success: function () { refreshList(); },
1772
2141
  error: function (xhr) {
1773
- var msg = xhr.responseJSON ? xhr.responseJSON.error : "rename failed";
2142
+ const msg = xhr.responseJSON ? xhr.responseJSON.error : "rename failed";
1774
2143
  RED.notify("Rename failed: " + msg, "error");
1775
2144
  restore();
1776
2145
  },
@@ -1787,11 +2156,11 @@ const { data, send, user, portalClient } = useNodeRed();
1787
2156
  // ── Tree rendering ──
1788
2157
  function buildTree(entries) {
1789
2158
  // Build nested structure: { children: { name: { type, children, entry } } }
1790
- var root = { children: {} };
2159
+ const root = { children: {} };
1791
2160
  entries.forEach(function (e) {
1792
- var parts = e.name.split("/");
1793
- var node = root;
1794
- for (var i = 0; i < parts.length; i++) {
2161
+ const parts = e.name.split("/");
2162
+ let node = root;
2163
+ for (let i = 0; i < parts.length; i++) {
1795
2164
  if (!node.children[parts[i]]) {
1796
2165
  node.children[parts[i]] = { children: {} };
1797
2166
  }
@@ -1802,15 +2171,15 @@ const { data, send, user, portalClient } = useNodeRed();
1802
2171
  return root;
1803
2172
  }
1804
2173
 
1805
- var ROOT_KEY = "__root__";
2174
+ const ROOT_KEY = "__root__";
1806
2175
 
1807
2176
  function renderTree() {
1808
2177
  fileList.empty();
1809
- var isOpen = !collapsed[ROOT_KEY];
2178
+ const isOpen = !collapsed[ROOT_KEY];
1810
2179
 
1811
2180
  // 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>');
2181
+ 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>');
2182
+ 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
2183
  rootArrow.on("click", function () { collapsed[ROOT_KEY] = isOpen; renderTree(); });
1815
2184
  rootRow.append(rootArrow);
1816
2185
  $('<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 +2194,35 @@ const { data, send, user, portalClient } = useNodeRed();
1825
2194
  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
2195
  return;
1827
2196
  }
1828
- var tree = buildTree(allEntries);
2197
+ const tree = buildTree(allEntries);
1829
2198
  renderNode(tree, "", 1);
1830
2199
  }
1831
2200
 
1832
2201
  function renderNode(node, parentPath, depth) {
1833
2202
  // 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";
2203
+ const names = Object.keys(node.children).sort(function (a, b) {
2204
+ const aIsDir = node.children[a].entry && node.children[a].entry.type === "dir";
2205
+ const bIsDir = node.children[b].entry && node.children[b].entry.type === "dir";
1837
2206
  if (aIsDir && !bIsDir) return -1;
1838
2207
  if (!aIsDir && bIsDir) return 1;
1839
2208
  return a.localeCompare(b);
1840
2209
  });
1841
2210
 
1842
2211
  names.forEach(function (name) {
1843
- var child = node.children[name];
1844
- var e = child.entry;
2212
+ const child = node.children[name];
2213
+ const e = child.entry;
1845
2214
  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];
2215
+ const fullPath = e.name;
2216
+ const indent = 8 + depth * 18;
2217
+ const isDir = e.type === "dir";
2218
+ const isOpen = !collapsed[fullPath];
1850
2219
 
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>');
2220
+ 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
2221
  row.css("padding-left", indent + "px");
1853
2222
 
1854
2223
  if (isDir) {
1855
2224
  // 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>');
2225
+ 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
2226
  arrow.on("click", function (e) {
1858
2227
  e.stopPropagation();
1859
2228
  collapsed[fullPath] = isOpen;
@@ -1866,7 +2235,7 @@ const { data, send, user, portalClient } = useNodeRed();
1866
2235
  .appendTo(row);
1867
2236
 
1868
2237
  // 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>');
2238
+ const dirMenuBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;"><i class="fa fa-ellipsis-v"></i></button>');
1870
2239
  (function (fp, nm) {
1871
2240
  dirMenuBtn.on("click", function (ev) {
1872
2241
  ev.preventDefault();
@@ -1905,7 +2274,7 @@ const { data, send, user, portalClient } = useNodeRed();
1905
2274
  ev.preventDefault();
1906
2275
  ev.stopPropagation();
1907
2276
  row.css("background", "");
1908
- var srcPath = ev.originalEvent.dataTransfer.getData("text/x-asset-path");
2277
+ const srcPath = ev.originalEvent.dataTransfer.getData("text/x-asset-path");
1909
2278
  if (srcPath) {
1910
2279
  moveItem(srcPath, fullPath);
1911
2280
  } else if (ev.originalEvent.dataTransfer.files && ev.originalEvent.dataTransfer.files.length > 0) {
@@ -1933,13 +2302,13 @@ const { data, send, user, portalClient } = useNodeRed();
1933
2302
  });
1934
2303
  row.on("dragend", function () { row.css("opacity", "1"); });
1935
2304
 
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>');
2305
+ 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>');
2306
+ const fileMenuBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;"><i class="fa fa-ellipsis-v"></i></button>');
1938
2307
  (function (fp, nm) {
1939
2308
  copyBtn.on("click", function (ev) {
1940
2309
  ev.preventDefault();
1941
2310
  ev.stopPropagation();
1942
- var url = publicBase + fp;
2311
+ const url = publicBase + fp;
1943
2312
  navigator.clipboard.writeText(url).then(function () {
1944
2313
  RED.notify("Copied: " + url, { type: "success", timeout: 2000 });
1945
2314
  });
@@ -1952,7 +2321,7 @@ const { data, send, user, portalClient } = useNodeRed();
1952
2321
  showRenameInput(row, fp, nm);
1953
2322
  }},
1954
2323
  { icon: "fa-download", label: "Download", action: function () {
1955
- var a = document.createElement("a");
2324
+ const a = document.createElement("a");
1956
2325
  a.href = adminRoot + "/portal-react/assets/download/" + fp.split("/").map(encodeURIComponent).join("/");
1957
2326
  a.download = nm;
1958
2327
  document.body.appendChild(a);