@aaqu/fromcubes-react 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +405 -2
  3. package/THIRD-PARTY-NOTICES.md +164 -0
  4. package/examples/D3 Poland Map.json +90 -0
  5. package/examples/Live Chart.json +101 -0
  6. package/examples/PixiJS Sprites.json +114 -0
  7. package/examples/Sensor Portal.json +93 -0
  8. package/examples/Shared Components.json +68 -0
  9. package/examples/Three.js Scene.json +118 -0
  10. package/examples/Utility Hooks.json +94 -0
  11. package/examples/WebGPU Shader.json +110 -0
  12. package/nodes/editor/ark-pixel.LICENSE.txt +96 -0
  13. package/nodes/editor/ark-pixel.woff2 +0 -0
  14. package/nodes/editor/file-tree.js +403 -0
  15. package/nodes/editor/format.js +182 -0
  16. package/nodes/editor/icons.svg +46 -0
  17. package/nodes/editor/mark.svg +5 -0
  18. package/nodes/editor/provider-settings.js +192 -0
  19. package/nodes/editor/workspace.css +653 -0
  20. package/nodes/editor/workspace.js +1565 -0
  21. package/nodes/icons/fromcubes-component.svg +27 -0
  22. package/nodes/icons/fromcubes-react.svg +25 -0
  23. package/nodes/icons/fromcubes-utility.svg +21 -0
  24. package/nodes/lib/admin-api.js +238 -0
  25. package/nodes/lib/admin-security.js +161 -0
  26. package/nodes/lib/api.js +29 -0
  27. package/nodes/lib/helpers.js +990 -0
  28. package/nodes/lib/hooks.js +145 -0
  29. package/nodes/lib/page-builder.js +499 -0
  30. package/nodes/lib/portal-page-route.js +131 -0
  31. package/nodes/lib/providers.js +536 -0
  32. package/nodes/lib/registry-nodes.js +331 -0
  33. package/nodes/lib/router.js +103 -0
  34. package/nodes/lib/ws-heartbeat.js +58 -0
  35. package/nodes/portal-react.html +2539 -0
  36. package/nodes/portal-react.js +1652 -0
  37. package/package.json +76 -4
@@ -0,0 +1,2539 @@
1
+ <!-- ============================================================
2
+ Portal Code Workspace — sidebar tab + full-screen editor overlay.
3
+ Served from nodes/editor/ via the /fromcubes-react/editor admin route.
4
+ ============================================================ -->
5
+ <link rel="stylesheet" href="fromcubes-react/editor/workspace.css" />
6
+ <script type="text/javascript" src="fromcubes-react/editor/file-tree.js"></script>
7
+ <script type="text/javascript" src="fromcubes-react/editor/workspace.js"></script>
8
+ <script type="text/javascript" src="fromcubes-react/editor/format.js"></script>
9
+ <script type="text/javascript" src="fromcubes-react/editor/provider-settings.js"></script>
10
+
11
+ <!-- ============================================================
12
+ Shared: Monaco loader (used by both node editors)
13
+ ============================================================ -->
14
+ <script type="text/javascript">
15
+ (function () {
16
+ const PREFIX = "[FC-Monaco]";
17
+
18
+ // Shared editor options — used by both component and portal-react editors
19
+ window.__fcEditorOpts = {
20
+ theme: "vs-dark",
21
+ minimap: { enabled: false },
22
+ fontSize: 13,
23
+ fontFamily: "'Cascadia Code','Fira Code',Consolas,monospace",
24
+ fontLigatures: true,
25
+ scrollBeyondLastLine: false,
26
+ automaticLayout: true,
27
+ tabSize: 2,
28
+ padding: { top: 8, bottom: 8 },
29
+ quickSuggestions: { strings: true },
30
+ };
31
+
32
+ // className="…" completion list, from the CSS engine in force (Settings →
33
+ // Fromcubes). Refetched every time a dialog or the React Editor opens and
34
+ // after the engine changes — the engine can change while the editor is
35
+ // open, so "load once" would keep offering the old engine's classes.
36
+ window.__fcCssClasses = null;
37
+ window.__fcCssClassSet = null;
38
+ window.__fcCssEngine = null;
39
+ let cssClassesPending = false;
40
+ window.__fcRefreshCssClasses = function () {
41
+ if (cssClassesPending) return;
42
+ cssClassesPending = true;
43
+ $.getJSON("fromcubes-react/css-classes", function (data) {
44
+ if (!data || !Array.isArray(data.classes)) return;
45
+ window.__fcCssEngine = data.engine;
46
+ window.__fcCssClasses = data.classes;
47
+ window.__fcCssClassSet = null;
48
+ })
49
+ .fail(function (xhr) {
50
+ console.error("[FC-Monaco] css-classes FAILED:", xhr.status, xhr.statusText);
51
+ })
52
+ .always(function () {
53
+ cssClassesPending = false;
54
+ });
55
+ };
56
+ let jsxSetupDone = false;
57
+
58
+ /**
59
+ * One-time Monaco setup: compiler opts, diagnostics, extra libs,
60
+ * completion providers (CSS class — from the engine in force, JSX tag, utility symbol).
61
+ * Idempotent — safe to call multiple times, only the first call wires
62
+ * up listeners; subsequent calls just re-apply the compiler/diagnostics
63
+ * blocks because Node-RED resets them whenever the editor reloads.
64
+ *
65
+ * @returns {void}
66
+ * @private
67
+ */
68
+ function ensureJsxSetup() {
69
+ if (jsxSetupDone) {
70
+ console.log(
71
+ PREFIX,
72
+ "ensureJsxSetup: already done, re-applying compiler+diag only",
73
+ );
74
+ // Always re-apply compiler/diag in case Node-RED reset them
75
+ applyCompilerAndDiag();
76
+ return;
77
+ }
78
+ jsxSetupDone = true;
79
+
80
+ const jsDef = monaco.typescript.javascriptDefaults;
81
+
82
+ // Log initial state
83
+ console.log(
84
+ PREFIX,
85
+ "INITIAL compilerOptions:",
86
+ JSON.stringify(jsDef.getCompilerOptions()),
87
+ );
88
+ console.log(
89
+ PREFIX,
90
+ "INITIAL diagnosticsOptions:",
91
+ JSON.stringify(jsDef.getDiagnosticsOptions()),
92
+ );
93
+
94
+ applyCompilerAndDiag();
95
+
96
+ // Global type stubs
97
+ const libContent = [
98
+ "declare const React: any;",
99
+ "declare const ReactDOM: any;",
100
+ "declare function useNodeRed(): { data: any; send: (payload: any, topic?: string) => void; user: { userId: string; login?: string; displayName?: string; email?: string; role?: string; groups: string[] } | null; portalClient: string | null; authRequired: boolean };",
101
+ ].join("\n");
102
+ console.log(PREFIX, "addExtraLib globals.d.ts");
103
+ jsDef.addExtraLib(libContent, "file:///globals.d.ts");
104
+
105
+ // className completion provider — classes from the CSS engine in force
106
+ // Triggers in: className="...", className={`...`}, and any string literal
107
+ // containing at least one known TW class (e.g. "bg-red-500 text-white")
108
+ console.log(PREFIX, "registering Tailwind completion provider");
109
+ monaco.languages.registerCompletionItemProvider("javascript", {
110
+ triggerCharacters: ['"', "'", "`", " "],
111
+ provideCompletionItems: function (model, position) {
112
+ const line = model.getLineContent(position.lineNumber);
113
+ const before = line.substring(0, position.column - 1);
114
+
115
+ // 1) className="..." or className={`...`}
116
+ const isClassName =
117
+ before.match(/className\s*=\s*["'][^"']*$/) ||
118
+ before.match(/className\s*=\s*\{`[^`]*$/);
119
+
120
+ // 2) Any open string literal: "..., '..., or `...
121
+ const inString = !isClassName && before.match(/["'`][^"'`]*$/);
122
+
123
+ if (!isClassName && !inString) return { suggestions: [] };
124
+
125
+ // Extract text inside the string
126
+ const raw = (isClassName || inString)[0];
127
+ let inside;
128
+ if (isClassName) {
129
+ inside = raw.replace(/^className\s*=\s*(?:\{`|["'])/, "");
130
+ } else {
131
+ inside = raw.substring(1); // skip opening quote
132
+ }
133
+ inside = inside.replace(/\$\{[^}]*\}/g, " ");
134
+ const parts = inside.split(/\s+/);
135
+ const word = parts[parts.length - 1] || "";
136
+
137
+ // For non-className strings, only activate if at least one existing
138
+ // token in the string looks like a TW class (avoid noise in random strings)
139
+ if (!isClassName && parts.length > 0) {
140
+ const classes = window.__fcCssClasses || [];
141
+ let twSet = window.__fcCssClassSet;
142
+ if (!twSet && classes.length) {
143
+ twSet = new Set(classes);
144
+ window.__fcCssClassSet = twSet;
145
+ console.log(PREFIX, "CSS: built class lookup Set, size=" + twSet.size);
146
+ }
147
+ if (twSet) {
148
+ let hasTwToken = false;
149
+ for (let p = 0; p < parts.length; p++) {
150
+ if (parts[p] && twSet.has(parts[p])) {
151
+ hasTwToken = true;
152
+ break;
153
+ }
154
+ }
155
+ // If typing the very first word, accept if it looks like a prefix
156
+ if (!hasTwToken && parts.length === 1 && word.length >= 2) {
157
+ for (let j = 0; j < classes.length; j++) {
158
+ if (classes[j].indexOf(word) === 0) {
159
+ hasTwToken = true;
160
+ break;
161
+ }
162
+ }
163
+ }
164
+ if (!hasTwToken) return { suggestions: [] };
165
+ }
166
+ }
167
+
168
+ const range = {
169
+ startLineNumber: position.lineNumber,
170
+ endLineNumber: position.lineNumber,
171
+ startColumn: position.column - word.length,
172
+ endColumn: position.column,
173
+ };
174
+
175
+ const classes = window.__fcCssClasses || [];
176
+ const suggestions = [];
177
+ for (let i = 0; i < classes.length; i++) {
178
+ const cls = classes[i];
179
+ if (!word.length || cls.indexOf(word) === 0) {
180
+ suggestions.push({
181
+ label: cls,
182
+ kind: monaco.languages.CompletionItemKind.Value,
183
+ insertText: cls,
184
+ range: range,
185
+ sortText: cls.padStart(60, "0"),
186
+ });
187
+ }
188
+ }
189
+ console.log(
190
+ PREFIX,
191
+ "CSS class completion: ctx=" +
192
+ (isClassName ? "className" : "string") +
193
+ " word='" +
194
+ word +
195
+ "', matched=" +
196
+ suggestions.length +
197
+ "/" +
198
+ classes.length,
199
+ );
200
+ return { suggestions: suggestions };
201
+ },
202
+ });
203
+
204
+ // JSX/HTML tag completion — type tag name, Tab → <tag>|</tag>
205
+ console.log(PREFIX, "registering JSX tag completion provider");
206
+ const HTML_TAGS = [
207
+ "div",
208
+ "span",
209
+ "p",
210
+ "a",
211
+ "button",
212
+ "input",
213
+ "img",
214
+ "form",
215
+ "label",
216
+ "h1",
217
+ "h2",
218
+ "h3",
219
+ "h4",
220
+ "h5",
221
+ "h6",
222
+ "ul",
223
+ "ol",
224
+ "li",
225
+ "dl",
226
+ "dt",
227
+ "dd",
228
+ "table",
229
+ "thead",
230
+ "tbody",
231
+ "tfoot",
232
+ "tr",
233
+ "th",
234
+ "td",
235
+ "section",
236
+ "article",
237
+ "aside",
238
+ "header",
239
+ "footer",
240
+ "nav",
241
+ "main",
242
+ "strong",
243
+ "em",
244
+ "b",
245
+ "i",
246
+ "u",
247
+ "s",
248
+ "small",
249
+ "mark",
250
+ "code",
251
+ "pre",
252
+ "blockquote",
253
+ "br",
254
+ "hr",
255
+ "wbr",
256
+ "select",
257
+ "option",
258
+ "optgroup",
259
+ "textarea",
260
+ "fieldset",
261
+ "legend",
262
+ "details",
263
+ "summary",
264
+ "dialog",
265
+ "canvas",
266
+ "video",
267
+ "audio",
268
+ "source",
269
+ "picture",
270
+ "figure",
271
+ "figcaption",
272
+ "svg",
273
+ "path",
274
+ "circle",
275
+ "rect",
276
+ "line",
277
+ "g",
278
+ "defs",
279
+ "use",
280
+ "text",
281
+ ];
282
+ const VOID_TAGS = new Set(["br", "hr", "img", "input", "wbr", "source"]);
283
+
284
+ monaco.languages.registerCompletionItemProvider("javascript", {
285
+ triggerCharacters: ["<"],
286
+ provideCompletionItems: function (model, position) {
287
+ const line = model.getLineContent(position.lineNumber);
288
+ const before = line.substring(0, position.column - 1);
289
+
290
+ // After "<" — suggest tags
291
+ const afterBracket = before.match(/<([a-zA-Z]*)$/);
292
+ // Bare word at line start or after whitespace/{ — Emmet-like (lower or upper)
293
+ const bareWord =
294
+ !afterBracket &&
295
+ before.match(/(?:^|[\s{(,])([a-zA-Z][a-zA-Z0-9]*)$/);
296
+
297
+ if (!afterBracket && !bareWord) return { suggestions: [] };
298
+
299
+ const typed = (afterBracket ? afterBracket[1] : bareWord[1]) || "";
300
+ const replaceStart =
301
+ position.column - typed.length - (afterBracket ? 1 : 0);
302
+
303
+ const range = {
304
+ startLineNumber: position.lineNumber,
305
+ endLineNumber: position.lineNumber,
306
+ startColumn: replaceStart,
307
+ endColumn: position.column,
308
+ };
309
+
310
+ const suggestions = [];
311
+ const seen = new Set();
312
+
313
+ // Monaco filters a suggestion against the text its range replaces.
314
+ // After "<" that text is "<Rac", so a plain "Rack" label never
315
+ // matches and the item is dropped before it is ever shown — the
316
+ // list appears on "<" and empties as soon as you type a letter.
317
+ // filterText restates the label the way the range sees it.
318
+ const filterFor = function (label) {
319
+ return afterBracket ? "<" + label : label;
320
+ };
321
+
322
+ // HTML tags
323
+ for (let i = 0; i < HTML_TAGS.length; i++) {
324
+ const tag = HTML_TAGS[i];
325
+ if (typed && tag.indexOf(typed) !== 0) continue;
326
+ seen.add(tag);
327
+ if (VOID_TAGS.has(tag)) {
328
+ suggestions.push({
329
+ label: tag,
330
+ kind: monaco.languages.CompletionItemKind.Property,
331
+ insertText: "<" + tag + " $0/>",
332
+ insertTextRules:
333
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
334
+ range: range,
335
+ filterText: filterFor(tag),
336
+ detail: "<" + tag + " />",
337
+ sortText: "0" + tag,
338
+ });
339
+ } else {
340
+ suggestions.push({
341
+ label: tag,
342
+ kind: monaco.languages.CompletionItemKind.Property,
343
+ insertText: "<" + tag + ">$0</" + tag + ">",
344
+ insertTextRules:
345
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
346
+ range: range,
347
+ filterText: filterFor(tag),
348
+ detail: "<" + tag + ">…</" + tag + ">",
349
+ sortText: "0" + tag + "a",
350
+ });
351
+ suggestions.push({
352
+ label: tag,
353
+ kind: monaco.languages.CompletionItemKind.Property,
354
+ insertText: "<" + tag + " $0/>",
355
+ insertTextRules:
356
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
357
+ range: range,
358
+ filterText: filterFor(tag),
359
+ detail: "<" + tag + " />",
360
+ sortText: "0" + tag + "b",
361
+ });
362
+ }
363
+ }
364
+
365
+ // Custom components from registry (PascalCase)
366
+ const upperMatch =
367
+ afterBracket && before.match(/<([A-Z][a-zA-Z0-9]*)$/);
368
+ const bareUpper =
369
+ !afterBracket && before.match(/(?:^|[\s{(,])([A-Z][a-zA-Z0-9]*)$/);
370
+ const compTyped = upperMatch
371
+ ? upperMatch[1]
372
+ : bareUpper
373
+ ? bareUpper[1]
374
+ : "";
375
+
376
+ if (compTyped || (!typed && afterBracket)) {
377
+ // Fetch component names from registry (cached on window)
378
+ const reg = window.__fcComponentNames || [];
379
+ for (let c = 0; c < reg.length; c++) {
380
+ const name = reg[c];
381
+ if (seen.has(name)) continue;
382
+ if (compTyped && name.indexOf(compTyped) !== 0) continue;
383
+ if (
384
+ !compTyped &&
385
+ typed &&
386
+ name.toLowerCase().indexOf(typed) !== 0
387
+ )
388
+ continue;
389
+ seen.add(name);
390
+ suggestions.push({
391
+ label: name,
392
+ kind: monaco.languages.CompletionItemKind.Class,
393
+ insertText: "<" + name + ">$0</" + name + ">",
394
+ insertTextRules:
395
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
396
+ range: range,
397
+ filterText: filterFor(name),
398
+ detail: "<" + name + ">…</" + name + ">",
399
+ sortText: "0" + name + "a",
400
+ });
401
+ suggestions.push({
402
+ label: name,
403
+ kind: monaco.languages.CompletionItemKind.Class,
404
+ insertText: "<" + name + " $0/>",
405
+ insertTextRules:
406
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
407
+ range: range,
408
+ filterText: filterFor(name),
409
+ detail: "<" + name + " />",
410
+ sortText: "0" + name + "b",
411
+ });
412
+ }
413
+ // Any PascalCase word not in registry — offer generic JSX wrap.
414
+ // While a name is half-typed this can outrank the real component:
415
+ // Monaco scores an exact filter match above a prefix one, and
416
+ // sortText does not override that. It disappears as soon as the
417
+ // name is complete, because then `seen` already holds it.
418
+ if (compTyped && !seen.has(compTyped)) {
419
+ suggestions.push({
420
+ label: compTyped,
421
+ kind: monaco.languages.CompletionItemKind.Class,
422
+ insertText: "<" + compTyped + ">$0</" + compTyped + ">",
423
+ insertTextRules:
424
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
425
+ range: range,
426
+ filterText: filterFor(compTyped),
427
+ detail: "<" + compTyped + ">…</" + compTyped + ">",
428
+ sortText: "0" + compTyped + "a",
429
+ });
430
+ suggestions.push({
431
+ label: compTyped,
432
+ kind: monaco.languages.CompletionItemKind.Class,
433
+ insertText: "<" + compTyped + " $0/>",
434
+ insertTextRules:
435
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
436
+ range: range,
437
+ filterText: filterFor(compTyped),
438
+ detail: "<" + compTyped + " />",
439
+ sortText: "0" + compTyped + "b",
440
+ });
441
+ }
442
+ }
443
+
444
+ console.log(
445
+ PREFIX,
446
+ "JSX tag completion: typed='" +
447
+ typed +
448
+ "', suggestions=" +
449
+ suggestions.length,
450
+ );
451
+ return { suggestions: suggestions };
452
+ },
453
+ });
454
+
455
+ // Collapse <tag>|</tag> → <tag /> when "/" typed inside empty tag pair
456
+ console.log(PREFIX, "registering JSX self-close collapse handler");
457
+ window.__fcAttachSelfClose = function (editor) {
458
+ editor.onDidChangeModelContent(function (e) {
459
+ if (e.changes.length !== 1) return;
460
+ const ch = e.changes[0];
461
+ if (ch.text !== "/") return;
462
+ const model = editor.getModel();
463
+ if (!model) return;
464
+ const lineNum = ch.range.startLineNumber;
465
+ const line = model.getLineContent(lineNum);
466
+ // Pattern 1: <tag>/</tag> (cursor was between > and </)
467
+ // Pattern 2: <tag/></tag> (cursor was before > in opening tag)
468
+ let m = line.match(/^(.*)<([a-zA-Z][a-zA-Z0-9]*)>\/<\/\2>(.*)$/);
469
+ let matchStr, tag, prefix;
470
+ if (m) {
471
+ prefix = m[1];
472
+ tag = m[2];
473
+ matchStr = "<" + tag + ">/</" + tag + ">";
474
+ } else {
475
+ m = line.match(/^(.*)<([a-zA-Z][a-zA-Z0-9]*)\/><\/\2>(.*)$/);
476
+ if (!m) return;
477
+ prefix = m[1];
478
+ tag = m[2];
479
+ matchStr = "<" + tag + "/></" + tag + ">";
480
+ }
481
+ const startCol = prefix.length + 1;
482
+ const endCol = startCol + matchStr.length;
483
+ const replacement = "<" + tag + " />";
484
+ const cursorCol = startCol + replacement.length - 2;
485
+ setTimeout(function () {
486
+ editor.executeEdits("self-close-collapse", [
487
+ {
488
+ range: {
489
+ startLineNumber: lineNum,
490
+ endLineNumber: lineNum,
491
+ startColumn: startCol,
492
+ endColumn: endCol,
493
+ },
494
+ text: replacement,
495
+ },
496
+ ]);
497
+ editor.setPosition({ lineNumber: lineNum, column: cursorCol });
498
+ }, 0);
499
+ });
500
+ console.log(PREFIX, "self-close collapse attached to editor");
501
+ };
502
+
503
+ /**
504
+ * Report the JSX context at `offset` in `code`. Client-side mirror of
505
+ * the JSX-aware scanner in lib/helpers.js (stripComments) — keep the
506
+ * two in sync.
507
+ *
508
+ * "js" – plain JS / `{…}` expression → `//` comments work
509
+ * "tag" – inside an opening `<Tag …>` → `//` comments work
510
+ * "children" – between `<Tag…>` and `</Tag>` → only `{/* … *​/}`
511
+ * works; `//` there is literal text that esbuild renders
512
+ *
513
+ * @param {string} code
514
+ * @param {number} offset
515
+ * @returns {"js"|"tag"|"children"}
516
+ */
517
+ window.__fcJsxContextAt = function (code, offset) {
518
+ const n = Math.min(code.length, offset);
519
+ const stack = [{ t: "js", depth: 0 }];
520
+ let prev = "";
521
+ let prev2 = "";
522
+ let word = "";
523
+ let prevIdent = false;
524
+ const IDENT_CH = /[A-Za-z0-9_$]/;
525
+ function sig(ch) {
526
+ prev2 = prev;
527
+ prev = ch;
528
+ if (IDENT_CH.test(ch)) {
529
+ word = prevIdent ? word + ch : ch;
530
+ prevIdent = true;
531
+ } else {
532
+ word = "";
533
+ prevIdent = false;
534
+ }
535
+ }
536
+ const KW = new Set([
537
+ "return",
538
+ "case",
539
+ "typeof",
540
+ "do",
541
+ "else",
542
+ "void",
543
+ "yield",
544
+ "await",
545
+ "in",
546
+ "of",
547
+ "new",
548
+ "delete",
549
+ "throw",
550
+ "instanceof",
551
+ ]);
552
+ function startsJsx(next) {
553
+ if (!/[A-Za-z_$>]/.test(next)) return false;
554
+ if (prev === "") return true;
555
+ if (prev === "<") return false;
556
+ if (/[A-Za-z0-9_$)\]}"'`]/.test(prev)) return KW.has(word);
557
+ if (prev === ">") return prev2 === "=";
558
+ return true;
559
+ }
560
+ function poppedIntoJs() {
561
+ if (stack[stack.length - 1].t === "js") {
562
+ prev = ")";
563
+ prev2 = "";
564
+ word = "";
565
+ prevIdent = false;
566
+ }
567
+ }
568
+ function enterExpr() {
569
+ stack.push({ t: "js", depth: 0 });
570
+ prev = "";
571
+ prev2 = "";
572
+ word = "";
573
+ prevIdent = false;
574
+ i++;
575
+ }
576
+ let i = 0;
577
+ while (i < n) {
578
+ const top = stack[stack.length - 1];
579
+ const c = code[i];
580
+ const d = i + 1 < code.length ? code[i + 1] : "";
581
+ if (top.t === "children") {
582
+ if (c === "<" && d === "/") {
583
+ i += 2;
584
+ while (i < code.length && code[i] !== ">") i++;
585
+ i++;
586
+ stack.pop();
587
+ poppedIntoJs();
588
+ continue;
589
+ }
590
+ if (c === "<" && /[A-Za-z_$>]/.test(d)) {
591
+ stack.push({ t: "tag", slash: false });
592
+ i++;
593
+ continue;
594
+ }
595
+ if (c === "{") {
596
+ enterExpr();
597
+ continue;
598
+ }
599
+ i++;
600
+ continue;
601
+ }
602
+ if (top.t === "tag") {
603
+ if (c === '"' || c === "'") {
604
+ i++;
605
+ while (i < code.length && code[i] !== c) i++;
606
+ i++;
607
+ top.slash = false;
608
+ continue;
609
+ }
610
+ if (c === "/" && d === "/") {
611
+ i += 2;
612
+ while (i < code.length && code[i] !== "\n") i++;
613
+ continue;
614
+ }
615
+ if (c === "/" && d === "*") {
616
+ i += 2;
617
+ while (
618
+ i < code.length &&
619
+ !(code[i] === "*" && code[i + 1] === "/")
620
+ )
621
+ i++;
622
+ i += 2;
623
+ continue;
624
+ }
625
+ if (c === "{") {
626
+ enterExpr();
627
+ continue;
628
+ }
629
+ if (c === ">") {
630
+ const selfClose = top.slash;
631
+ stack.pop();
632
+ if (selfClose) poppedIntoJs();
633
+ else stack.push({ t: "children" });
634
+ i++;
635
+ continue;
636
+ }
637
+ if (!/\s/.test(c)) top.slash = c === "/";
638
+ i++;
639
+ continue;
640
+ }
641
+ // js frame
642
+ if (c === '"' || c === "'" || c === "`") {
643
+ const q = c;
644
+ i++;
645
+ while (i < code.length && code[i] !== q) {
646
+ if (code[i] === "\\") i++;
647
+ i++;
648
+ }
649
+ i++;
650
+ sig(q);
651
+ continue;
652
+ }
653
+ if (c === "/" && d === "/") {
654
+ i += 2;
655
+ while (i < code.length && code[i] !== "\n") i++;
656
+ continue;
657
+ }
658
+ if (c === "/" && d === "*") {
659
+ i += 2;
660
+ while (
661
+ i < code.length &&
662
+ !(code[i] === "*" && code[i + 1] === "/")
663
+ )
664
+ i++;
665
+ i += 2;
666
+ continue;
667
+ }
668
+ if (c === "{") {
669
+ top.depth++;
670
+ sig(c);
671
+ i++;
672
+ continue;
673
+ }
674
+ if (c === "}") {
675
+ if (top.depth === 0 && stack.length > 1) {
676
+ stack.pop();
677
+ if (stack[stack.length - 1].t === "tag")
678
+ stack[stack.length - 1].slash = false;
679
+ i++;
680
+ continue;
681
+ }
682
+ if (top.depth > 0) top.depth--;
683
+ sig(c);
684
+ i++;
685
+ continue;
686
+ }
687
+ if (c === "<" && startsJsx(d)) {
688
+ stack.push({ t: "tag", slash: false });
689
+ i++;
690
+ continue;
691
+ }
692
+ if (/\s/.test(c)) {
693
+ prevIdent = false;
694
+ i++;
695
+ continue;
696
+ }
697
+ sig(c);
698
+ i++;
699
+ }
700
+ return stack[stack.length - 1].t;
701
+ };
702
+
703
+ // Custom keybindings shared by every Monaco instance in this package.
704
+ //
705
+ // Ctrl+D (Cmd+D on macOS) duplicates the current line / selection by
706
+ // delegating to Monaco's built-in copyLinesDownAction (multi-cursor
707
+ // aware, single undo step). Overrides Monaco's default Ctrl+D
708
+ // ("Add Selection To Next Find Match") on purpose.
709
+ //
710
+ // Ctrl+/ (Cmd+/) toggles comments JSX-aware: Monaco's built-in
711
+ // commentLine blindly prefixes `//`, which inside JSX children is NOT
712
+ // a comment — esbuild renders it as literal text and the "commented"
713
+ // element stays live. The comment style for the whole selection comes
714
+ // from the FIRST selected line's context via __fcJsxContextAt:
715
+ // children → wrap each line in `{/* … *​/}`, JS / opening-tag
716
+ // attributes → `//`. Non-javascript models (the custom-head HTML
717
+ // editor) fall through to the built-in action.
718
+ window.__fcAttachKeybindings = function (editor) {
719
+ editor.addAction({
720
+ id: "fc-duplicate-line",
721
+ label: "Duplicate Line/Selection",
722
+ keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyD],
723
+ run: function (ed) {
724
+ const action = ed.getAction("editor.action.copyLinesDownAction");
725
+ if (action) action.run();
726
+ },
727
+ });
728
+ editor.addAction({
729
+ id: "fc-jsx-toggle-comment",
730
+ label: "Toggle Line Comment (JSX-aware)",
731
+ keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Slash],
732
+ run: function (ed) {
733
+ const model = ed.getModel();
734
+ if (!model) return;
735
+ if (model.getLanguageId() !== "javascript") {
736
+ const action = ed.getAction("editor.action.commentLine");
737
+ if (action) action.run();
738
+ return;
739
+ }
740
+ const lineNums = new Set();
741
+ (ed.getSelections() || []).forEach(function (sel) {
742
+ let end = sel.endLineNumber;
743
+ // a selection ending at column 1 does not include that line
744
+ if (end > sel.startLineNumber && sel.endColumn === 1) end--;
745
+ for (let l = sel.startLineNumber; l <= end; l++)
746
+ lineNums.add(l);
747
+ });
748
+ const text = model.getValue();
749
+ const lines = [];
750
+ lineNums.forEach(function (l) {
751
+ const content = model.getLineContent(l);
752
+ const t = content.trim();
753
+ if (!t) return;
754
+ const wrapped = t.startsWith("{/*") && t.endsWith("*/}");
755
+ const slashed = !wrapped && t.startsWith("//");
756
+ lines.push({
757
+ l: l,
758
+ content: content,
759
+ t: t,
760
+ wrapped: wrapped,
761
+ slashed: slashed,
762
+ });
763
+ });
764
+ if (!lines.length) return;
765
+ lines.sort(function (a, b) {
766
+ return a.l - b.l;
767
+ });
768
+ const uncomment = lines.every(function (x) {
769
+ return x.wrapped || x.slashed;
770
+ });
771
+ // Comment style comes from the FIRST selected line's context
772
+ // (VS Code behavior): a selection starting at `return (` is JS,
773
+ // so the whole block gets `//` — once the outer lines are
774
+ // commented the inner JSX never parses. Only a selection that
775
+ // STARTS between live tags needs `{/* … */}`.
776
+ // Markers are inserted at ONE column for the whole selection —
777
+ // the minimum indent across the selected lines — so they line
778
+ // up and each line keeps its own indentation after the marker.
779
+ let ctx = null;
780
+ let minIndent = 0;
781
+ if (!uncomment) {
782
+ const first = lines[0];
783
+ const firstIndent =
784
+ first.content.length - first.content.trimStart().length;
785
+ ctx = window.__fcJsxContextAt(
786
+ text,
787
+ model.getOffsetAt({
788
+ lineNumber: first.l,
789
+ column: firstIndent + 1,
790
+ }),
791
+ );
792
+ minIndent = Infinity;
793
+ lines.forEach(function (x) {
794
+ const ind = x.content.length - x.content.trimStart().length;
795
+ if (ind < minIndent) minIndent = ind;
796
+ });
797
+ }
798
+ const edits = [];
799
+ lines.forEach(function (x) {
800
+ let newText = null;
801
+ if (uncomment) {
802
+ const indent = x.content.slice(
803
+ 0,
804
+ x.content.length - x.content.trimStart().length,
805
+ );
806
+ let inner;
807
+ if (x.wrapped) {
808
+ inner = x.t.slice(3, -3);
809
+ if (inner.startsWith(" ")) inner = inner.slice(1);
810
+ if (inner.endsWith(" ")) inner = inner.slice(0, -1);
811
+ } else {
812
+ inner = x.t.slice(2);
813
+ if (inner.startsWith(" ")) inner = inner.slice(1);
814
+ }
815
+ newText = indent + inner;
816
+ } else if (!x.wrapped && !x.slashed) {
817
+ const head = x.content.slice(0, minIndent);
818
+ const rest = x.content.slice(minIndent);
819
+ newText =
820
+ ctx === "children"
821
+ ? head + "{/* " + rest + " */}"
822
+ : head + "// " + rest;
823
+ }
824
+ if (newText !== null)
825
+ edits.push({
826
+ range: new monaco.Range(
827
+ x.l,
828
+ 1,
829
+ x.l,
830
+ model.getLineMaxColumn(x.l),
831
+ ),
832
+ text: newText,
833
+ forceMoveMarkers: true,
834
+ });
835
+ });
836
+ if (!edits.length) return;
837
+ ed.pushUndoStop();
838
+ ed.executeEdits("fc-jsx-toggle-comment", edits);
839
+ ed.pushUndoStop();
840
+ },
841
+ });
842
+ };
843
+
844
+ /**
845
+ * Fetch the current component registry from `/fromcubes-react/registry`
846
+ * and stash the names on `window.__fcComponentNames` so the JSX-tag
847
+ * completion provider can offer `<Button` etc. Re-fetched on every
848
+ * editor open so newly-added components show up without an editor
849
+ * restart.
850
+ * @returns {void}
851
+ * @private
852
+ */
853
+ function refreshComponentNames() {
854
+ $.getJSON("fromcubes-react/registry", function (reg) {
855
+ window.__fcComponentNames = Object.keys(reg);
856
+ console.log(
857
+ PREFIX,
858
+ "component names loaded:",
859
+ window.__fcComponentNames,
860
+ );
861
+ }).fail(function () {
862
+ window.__fcComponentNames = [];
863
+ });
864
+ }
865
+ refreshComponentNames();
866
+ // Exposed for the workspace overlay, which reopens files without going
867
+ // through any node's oneditprepare.
868
+ window.__fcRefreshComponentNames = refreshComponentNames;
869
+
870
+ // Fetch utility nodes + their parsed top-level symbols.
871
+ // Window cache shape: { byNode: { utilName: [symbol, ...] }, allSymbols: [..] }
872
+ window.__fcRefreshUtilities = function () {
873
+ $.getJSON("fromcubes-react/utilities", function (reg) {
874
+ const byNode = {};
875
+ const all = new Set();
876
+ Object.keys(reg).forEach(function (n) {
877
+ const syms = reg[n].symbols || [];
878
+ byNode[n] = syms;
879
+ syms.forEach(function (s) { all.add(s); });
880
+ });
881
+ window.__fcUtilities = { byNode: byNode, allSymbols: [...all] };
882
+ console.log(
883
+ PREFIX,
884
+ "utilities loaded: " + Object.keys(byNode).length + " nodes, " + all.size + " symbols",
885
+ );
886
+ }).fail(function () {
887
+ window.__fcUtilities = { byNode: {}, allSymbols: [] };
888
+ });
889
+ };
890
+ window.__fcRefreshUtilities();
891
+
892
+ // Utility-symbol completion: suggests bare identifiers anywhere in JS
893
+ // context EXCEPT after "<" (that's JSX tag completion, handled below).
894
+ monaco.languages.registerCompletionItemProvider("javascript", {
895
+ triggerCharacters: ["."], // jQuery-ish — but we mostly fire from word context
896
+ provideCompletionItems: function (model, position) {
897
+ const line = model.getLineContent(position.lineNumber);
898
+ const before = line.substring(0, position.column - 1);
899
+ // Skip JSX tag context — covered by other providers
900
+ if (/<[a-zA-Z]*$/.test(before)) return { suggestions: [] };
901
+ // Skip property access — let Monaco's language service handle it
902
+ if (/\.\s*\w*$/.test(before)) return { suggestions: [] };
903
+ // Skip inside strings
904
+ if (/["'`][^"'`]*$/.test(before)) return { suggestions: [] };
905
+ const wordMatch = before.match(/([A-Za-z_$][\w$]*)$/);
906
+ const word = wordMatch ? wordMatch[1] : "";
907
+ const all = (window.__fcUtilities && window.__fcUtilities.allSymbols) || [];
908
+ if (!all.length) return { suggestions: [] };
909
+ const range = {
910
+ startLineNumber: position.lineNumber,
911
+ endLineNumber: position.lineNumber,
912
+ startColumn: position.column - word.length,
913
+ endColumn: position.column,
914
+ };
915
+ const suggestions = [];
916
+ for (let i = 0; i < all.length; i++) {
917
+ const sym = all[i];
918
+ if (word && sym.indexOf(word) !== 0) continue;
919
+ suggestions.push({
920
+ label: sym,
921
+ kind: monaco.languages.CompletionItemKind.Function,
922
+ insertText: sym,
923
+ range: range,
924
+ detail: "fc-portal-utility",
925
+ sortText: "1" + sym,
926
+ });
927
+ }
928
+ return { suggestions: suggestions };
929
+ },
930
+ });
931
+
932
+ // Marker listener for debugging
933
+ monaco.editor.onDidChangeMarkers(function (uris) {
934
+ uris.forEach(function (uri) {
935
+ const markers = monaco.editor.getModelMarkers({ resource: uri });
936
+ if (markers.length > 0) {
937
+ console.group(PREFIX + " MARKERS on " + uri.toString());
938
+ markers.forEach(function (m) {
939
+ console.log(
940
+ " code=" +
941
+ (m.code || "?") +
942
+ " severity=" +
943
+ m.severity +
944
+ " source=" +
945
+ (m.source || "?") +
946
+ " msg=" +
947
+ m.message +
948
+ " [L" +
949
+ m.startLineNumber +
950
+ ":" +
951
+ m.startColumn +
952
+ "]",
953
+ );
954
+ });
955
+ console.groupEnd();
956
+ }
957
+ });
958
+ });
959
+
960
+ console.log(PREFIX, "one-time setup complete");
961
+ }
962
+
963
+ /**
964
+ * Push compilerOptions + diagnosticsOptions into Monaco's TS defaults.
965
+ * Called by `ensureJsxSetup` once and re-applied on every editor open
966
+ * because Node-RED's outer Monaco initialiser can reset these between
967
+ * editor sessions. We disable Monaco's TS-side squiggles entirely —
968
+ * the authoritative syntax check is the server-side esbuild pass at
969
+ * deploy time, and Monaco's TS parser produces noisy false positives
970
+ * (1109/1005/1128) on raw JSX.
971
+ * @returns {void}
972
+ * @private
973
+ */
974
+ function applyCompilerAndDiag() {
975
+ const jsDef = monaco.typescript.javascriptDefaults;
976
+ const compilerOpts = {
977
+ jsx: monaco.typescript.JsxEmit.React,
978
+ target: monaco.typescript.ScriptTarget.ESNext,
979
+ module: monaco.typescript.ModuleKind.ESNext,
980
+ allowNonTsExtensions: true,
981
+ allowJs: true,
982
+ };
983
+ console.log(PREFIX, "setCompilerOptions:", JSON.stringify(compilerOpts));
984
+ jsDef.setCompilerOptions(compilerOpts);
985
+
986
+ const diagOpts = {
987
+ noSemanticValidation: true,
988
+ // Server-side esbuild does the real syntax check at deploy time;
989
+ // Monaco's TS parser produces noisy false positives on raw JSX
990
+ // (1109, 1005, 1128 etc.), so we silence its squiggles entirely.
991
+ noSyntaxValidation: true,
992
+ noSuggestionDiagnostics: true,
993
+ diagnosticCodesToIgnore: [17004],
994
+ };
995
+ console.log(PREFIX, "setDiagnosticsOptions:", JSON.stringify(diagOpts));
996
+ jsDef.setDiagnosticsOptions(diagOpts);
997
+
998
+ console.log(
999
+ PREFIX,
1000
+ "readback compilerOptions:",
1001
+ JSON.stringify(jsDef.getCompilerOptions()),
1002
+ );
1003
+ console.log(
1004
+ PREFIX,
1005
+ "readback diagnosticsOptions:",
1006
+ JSON.stringify(jsDef.getDiagnosticsOptions()),
1007
+ );
1008
+ }
1009
+
1010
+ /**
1011
+ * Re-apply JSX defaults before Monaco model creation. Exported for
1012
+ * `oneditprepare` to call. Idempotent — wraps `ensureJsxSetup`.
1013
+ * @returns {void}
1014
+ */
1015
+ window.__fcApplyJsxDefaults = function () {
1016
+ ensureJsxSetup();
1017
+ };
1018
+
1019
+ /**
1020
+ * Lazy-load Monaco from the admin endpoint `/fromcubes-react/vs/` the
1021
+ * first time an editor opens. If Monaco is already on `window`
1022
+ * (Node-RED bundled its own copy, or another node loaded it first),
1023
+ * skip the loader script and run our setup inline.
1024
+ *
1025
+ * The callback signature is `cb(failed)` — `failed === true` when the
1026
+ * loader script threw at load time; editors should fall back to a
1027
+ * plain textarea in that case.
1028
+ *
1029
+ * @param {(failed?: boolean) => void} cb
1030
+ * @returns {void}
1031
+ */
1032
+ window.__fcLoadMonaco = function (cb) {
1033
+ console.log(PREFIX, "loadMonaco called, monaco exists:", !!window.monaco);
1034
+ if (window.monaco) {
1035
+ console.log(
1036
+ PREFIX,
1037
+ "Monaco already loaded (by Node-RED?), running setup inline",
1038
+ );
1039
+ ensureJsxSetup();
1040
+ cb();
1041
+ return;
1042
+ }
1043
+ const s = document.createElement("script");
1044
+ s.src = "fromcubes-react/vs/loader.js";
1045
+ s.onload = function () {
1046
+ console.log(PREFIX, "loader.js loaded, configuring require paths");
1047
+ require.config({ paths: { vs: "fromcubes-react/vs" } });
1048
+ require(["vs/editor/editor.main"], function () {
1049
+ console.log(PREFIX, "editor.main loaded");
1050
+ ensureJsxSetup();
1051
+ console.log(PREFIX, "calling cb()");
1052
+ cb();
1053
+ });
1054
+ };
1055
+ s.onerror = function () {
1056
+ console.error(PREFIX, "FAILED to load loader.js");
1057
+ cb(true);
1058
+ };
1059
+ document.head.appendChild(s);
1060
+ };
1061
+ })();
1062
+ </script>
1063
+
1064
+ <!-- ============================================================
1065
+ fc-portal-component – canvas node (shared component)
1066
+ ============================================================ -->
1067
+ <script type="text/html" data-template-name="fc-portal-component">
1068
+ <div class="form-row">
1069
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
1070
+ <input type="text" id="node-input-name" placeholder="Label on canvas" />
1071
+ </div>
1072
+ <div class="form-row">
1073
+ <label for="node-input-compName"><i class="fa fa-cube"></i> JSX Tag</label>
1074
+ <input type="text" id="node-input-compName" placeholder="MyComponent" />
1075
+ </div>
1076
+ <div class="form-row" style="margin-bottom:4px;display:flex;align-items:center;">
1077
+ <label style="flex:1"><i class="fa fa-code"></i> JSX Code</label>
1078
+ <button type="button" class="red-ui-button red-ui-button-small" id="fcc-btn-react-editor" title="Open this component in the full-screen React Editor">
1079
+ <i class="fa fa-expand"></i> React Editor
1080
+ </button>
1081
+ </div>
1082
+ <div class="form-row node-text-editor-row">
1083
+ <div
1084
+ id="fcc-editor-wrap"
1085
+ style="width:100%;height:350px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
1086
+ >
1087
+ <div id="fcc-monaco" style="width:100%;height:100%;"></div>
1088
+ <textarea
1089
+ id="fcc-fallback"
1090
+ 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;"
1091
+ ></textarea>
1092
+ </div>
1093
+ </div>
1094
+ <input type="hidden" id="node-input-compCode" />
1095
+ </script>
1096
+
1097
+ <script type="text/javascript">
1098
+ (function () {
1099
+ let compEditorInstance = null;
1100
+
1101
+ const COMP_STARTER = [
1102
+ "function StatusCard({ label, value, unit }) {",
1103
+ " return (",
1104
+ ' <div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-6">',
1105
+ ' <div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{label}</div>',
1106
+ ' <div className="text-3xl font-bold text-white">',
1107
+ ' {value}<span className="text-lg text-zinc-400 ml-1">{unit}</span>',
1108
+ " </div>",
1109
+ " </div>",
1110
+ " );",
1111
+ "}",
1112
+ ].join("\n");
1113
+
1114
+ // FC_NAME_RE / FC_NAME_MAX / FC_NAME_BLACKLIST mirror lib/helpers.js
1115
+ // isSafeName so the editor flags invalid names before deploy.
1116
+ const FC_NAME_RE = /^[A-Za-z_$][\w$]*$/;
1117
+ const FC_NAME_MAX = 64;
1118
+ const FC_NAME_BLACKLIST = [
1119
+ "__proto__", "constructor", "prototype", "hasOwnProperty",
1120
+ "isPrototypeOf", "propertyIsEnumerable", "toString", "valueOf",
1121
+ "toLocaleString",
1122
+ ];
1123
+ function fcValidateName(v) {
1124
+ return (
1125
+ typeof v === "string" &&
1126
+ v.length > 0 &&
1127
+ v.length <= FC_NAME_MAX &&
1128
+ FC_NAME_RE.test(v) &&
1129
+ FC_NAME_BLACKLIST.indexOf(v) === -1
1130
+ );
1131
+ }
1132
+
1133
+ RED.nodes.registerType("fc-portal-component", {
1134
+ category: "fromcubes",
1135
+ color: "#a8d8ea",
1136
+ defaults: {
1137
+ name: { value: "" },
1138
+ compName: {
1139
+ value: "StatusCard",
1140
+ required: true,
1141
+ validate: fcValidateName,
1142
+ },
1143
+ compCode: {
1144
+ value: COMP_STARTER,
1145
+ validate: function (v) {
1146
+ return typeof v === "string" && v.trim().length > 0;
1147
+ },
1148
+ },
1149
+ },
1150
+ inputs: 0,
1151
+ outputs: 0,
1152
+ icon: "fromcubes-component.svg",
1153
+ paletteLabel: "React Component",
1154
+ inputLabels: [],
1155
+ outputLabels: [],
1156
+ /**
1157
+ * Canvas label resolver — falls back through `name → compName →
1158
+ * "component"` so a freshly-dropped node shows something readable
1159
+ * before the user opens the edit dialog.
1160
+ * @returns {string}
1161
+ */
1162
+ label: function () {
1163
+ return this.name || this.compName || "component";
1164
+ },
1165
+
1166
+ /**
1167
+ * Node-RED editor lifecycle: called every time the user opens the
1168
+ * edit dialog for this node. Loads Monaco lazily, creates a JSX
1169
+ * model with a stable URI (`file:///fc-comp-<nodeId>.jsx`) and
1170
+ * attaches it to the `#fcc-monaco` container. Falls back to a
1171
+ * `<textarea>` if Monaco fails to load.
1172
+ * @returns {void}
1173
+ */
1174
+ oneditprepare: function () {
1175
+ const node = this;
1176
+ const code =
1177
+ typeof node.compCode === "string" ? node.compCode : COMP_STARTER;
1178
+ console.log("[FC-Monaco] COMP oneditprepare, node.id=" + node.id);
1179
+
1180
+ // Hand off to the full-screen React Editor: the dialog saves and
1181
+ // closes itself first (its oneditsave owns the same field).
1182
+ $("#fcc-btn-react-editor").on("click", function () {
1183
+ if (!window.__fcPortalWorkspace) {
1184
+ RED.notify("React Editor is not available", "warning");
1185
+ return;
1186
+ }
1187
+ window.__fcPortalWorkspace.openNodeFile(node.id, "compCode");
1188
+ });
1189
+
1190
+ window.__fcRefreshCssClasses();
1191
+
1192
+ window.__fcLoadMonaco(function (failed) {
1193
+ if (failed) {
1194
+ console.error(
1195
+ "[FC-Monaco] COMP: Monaco load failed, using fallback textarea",
1196
+ );
1197
+ $("#fcc-monaco").hide();
1198
+ $("#fcc-fallback").show().val(code);
1199
+ return;
1200
+ }
1201
+
1202
+ console.log(
1203
+ "[FC-Monaco] COMP: applying JSX defaults before model creation",
1204
+ );
1205
+ window.__fcApplyJsxDefaults();
1206
+
1207
+ const compUri = monaco.Uri.parse("file:///fc-comp-" + node.id + ".jsx");
1208
+ console.log("[FC-Monaco] COMP: model URI=" + compUri.toString());
1209
+ const existingModel = monaco.editor.getModel(compUri);
1210
+ if (existingModel) {
1211
+ console.log("[FC-Monaco] COMP: disposing existing model");
1212
+ existingModel.dispose();
1213
+ }
1214
+ const compModel = monaco.editor.createModel(
1215
+ code,
1216
+ "javascript",
1217
+ compUri,
1218
+ );
1219
+ console.log(
1220
+ "[FC-Monaco] COMP: model created, language=" +
1221
+ compModel.getLanguageId(),
1222
+ );
1223
+
1224
+ compEditorInstance = monaco.editor.create(
1225
+ document.getElementById("fcc-monaco"),
1226
+ Object.assign({ model: compModel }, window.__fcEditorOpts),
1227
+ );
1228
+ if (window.__fcAttachKeybindings)
1229
+ window.__fcAttachKeybindings(compEditorInstance);
1230
+ console.log("[FC-Monaco] COMP: editor created");
1231
+
1232
+ // Log markers after a short delay (diagnostics are async)
1233
+ setTimeout(function () {
1234
+ const markers = monaco.editor.getModelMarkers({ resource: compUri });
1235
+ console.log(
1236
+ "[FC-Monaco] COMP: markers after 500ms, count=" + markers.length,
1237
+ );
1238
+ markers.forEach(function (m) {
1239
+ console.log(
1240
+ "[FC-Monaco] COMP marker: code=" + m.code + " msg=" + m.message,
1241
+ );
1242
+ });
1243
+ // Also log current compiler options state
1244
+ console.log(
1245
+ "[FC-Monaco] COMP: current compilerOptions:",
1246
+ JSON.stringify(
1247
+ monaco.typescript.javascriptDefaults.getCompilerOptions(),
1248
+ ),
1249
+ );
1250
+ console.log(
1251
+ "[FC-Monaco] COMP: current diagnosticsOptions:",
1252
+ JSON.stringify(
1253
+ monaco.typescript.javascriptDefaults.getDiagnosticsOptions(),
1254
+ ),
1255
+ );
1256
+ }, 500);
1257
+ });
1258
+ },
1259
+
1260
+ /**
1261
+ * Editor → flow: copy Monaco editor contents into the hidden
1262
+ * `#node-input-compCode` input and the node's `compCode` property,
1263
+ * then dispose Monaco's model + editor to free GPU/CPU resources.
1264
+ * @returns {void}
1265
+ */
1266
+ oneditsave: function () {
1267
+ console.log("[FC-Monaco] COMP oneditsave");
1268
+ const code = compEditorInstance
1269
+ ? compEditorInstance.getValue()
1270
+ : $("#fcc-fallback").val();
1271
+ $("#node-input-compCode").val(code);
1272
+ this.compCode = code;
1273
+ if (compEditorInstance) {
1274
+ compEditorInstance.getModel().dispose();
1275
+ compEditorInstance.dispose();
1276
+ compEditorInstance = null;
1277
+ console.log("[FC-Monaco] COMP: editor + model disposed");
1278
+ }
1279
+ },
1280
+
1281
+ /**
1282
+ * User cancelled the dialog — discard Monaco state without writing
1283
+ * back to the node. Same dispose pattern as oneditsave to avoid
1284
+ * leaking model graphs.
1285
+ * @returns {void}
1286
+ */
1287
+ oneditcancel: function () {
1288
+ console.log("[FC-Monaco] COMP oneditcancel");
1289
+ if (compEditorInstance) {
1290
+ compEditorInstance.getModel().dispose();
1291
+ compEditorInstance.dispose();
1292
+ compEditorInstance = null;
1293
+ console.log("[FC-Monaco] COMP: editor + model disposed");
1294
+ }
1295
+ },
1296
+
1297
+ /**
1298
+ * Resize handler — Monaco doesn't react to container size changes
1299
+ * unless we call `editor.layout()` explicitly. Subtracts ~180 px of
1300
+ * chrome (label, buttons, padding) from the dialog height.
1301
+ * @param {{height: number, width: number}} size
1302
+ * @returns {void}
1303
+ */
1304
+ oneditresize: function (size) {
1305
+ let h = size.height - 180;
1306
+ if (h < 150) h = 150;
1307
+ $("#fcc-editor-wrap").css("height", h + "px");
1308
+ if (compEditorInstance) compEditorInstance.layout();
1309
+ },
1310
+ });
1311
+ })();
1312
+ </script>
1313
+
1314
+ <script type="text/html" data-help-name="fc-portal-component">
1315
+ <p>
1316
+ Defines a shared JSX component available to all
1317
+ <strong>portal-react</strong> nodes.
1318
+ </p>
1319
+ <h3>Properties</h3>
1320
+ <dl>
1321
+ <dt>JSX Tag</dt>
1322
+ <dd>
1323
+ The name used as a JSX tag in portal-react pages (e.g.
1324
+ <code>&lt;MyComponent /&gt;</code>).
1325
+ </dd>
1326
+ <dt>Input fields</dt>
1327
+ <dd>Comma-separated list of expected input props (documentation only).</dd>
1328
+ <dt>Output fields</dt>
1329
+ <dd>Comma-separated list of output props (documentation only).</dd>
1330
+ <dt>JSX Code</dt>
1331
+ <dd>
1332
+ The component function. Use <code>useNodeRed()</code> for data binding.
1333
+ </dd>
1334
+ </dl>
1335
+ <h3>Usage</h3>
1336
+ <p>
1337
+ Place this node on the canvas, set the JSX tag name and code, then deploy.
1338
+ All <strong>portal-react</strong> nodes will automatically have access to
1339
+ this component.
1340
+ </p>
1341
+ </script>
1342
+
1343
+ <!-- ============================================================
1344
+ fc-portal-utility – canvas node (helpers / hooks / constants)
1345
+ ============================================================ -->
1346
+ <script type="text/html" data-template-name="fc-portal-utility">
1347
+ <div class="form-row">
1348
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
1349
+ <input type="text" id="node-input-name" placeholder="Label on canvas" />
1350
+ </div>
1351
+ <div class="form-row">
1352
+ <label for="node-input-utilName"><i class="fa fa-wrench"></i> Module name</label>
1353
+ <input type="text" id="node-input-utilName" placeholder="mathHelpers" />
1354
+ </div>
1355
+ <div class="form-row" style="margin-bottom:4px;">
1356
+ <div style="display:flex;align-items:center;">
1357
+ <label style="flex:1"><i class="fa fa-code"></i> Code</label>
1358
+ <button type="button" class="red-ui-button red-ui-button-small" id="fcu-btn-react-editor" title="Open this utility in the full-screen React Editor">
1359
+ <i class="fa fa-expand"></i> React Editor
1360
+ </button>
1361
+ </div>
1362
+ <div style="font-size:11px;opacity:.5;margin-top:2px;">
1363
+ Top-level helpers, custom hooks and constants. Available globally to portals
1364
+ that reference any of the symbols declared here.
1365
+ </div>
1366
+ </div>
1367
+ <div class="form-row node-text-editor-row">
1368
+ <div
1369
+ id="fcu-editor-wrap"
1370
+ style="width:100%;height:350px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
1371
+ >
1372
+ <div id="fcu-monaco" style="width:100%;height:100%;"></div>
1373
+ <textarea
1374
+ id="fcu-fallback"
1375
+ 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;"
1376
+ ></textarea>
1377
+ </div>
1378
+ </div>
1379
+ <input type="hidden" id="node-input-utilCode" />
1380
+ </script>
1381
+
1382
+ <script type="text/javascript">
1383
+ (function () {
1384
+ let utilEditorInstance = null;
1385
+
1386
+ // Local copy of the identifier validator — each editor <script> block is
1387
+ // wrapped in its own IIFE, so the helper defined in the fc-portal-component
1388
+ // block above is out of scope here. Mirrors lib/helpers.js isSafeName.
1389
+ const FC_NAME_RE = /^[A-Za-z_$][\w$]*$/;
1390
+ const FC_NAME_MAX = 64;
1391
+ const FC_NAME_BLACKLIST = [
1392
+ "__proto__", "constructor", "prototype", "hasOwnProperty",
1393
+ "isPrototypeOf", "propertyIsEnumerable", "toString", "valueOf",
1394
+ "toLocaleString",
1395
+ ];
1396
+ /**
1397
+ * Mirror of `lib/helpers.js#isSafeName` — used as `defaults.utilName.validate`
1398
+ * so the editor flags invalid identifiers in red before the user can
1399
+ * deploy a node that the server would later reject. Rules: non-empty
1400
+ * string, ≤ 64 chars, JS identifier syntax, not on the prototype-key
1401
+ * blacklist.
1402
+ * @param {unknown} v
1403
+ * @returns {boolean}
1404
+ */
1405
+ function fcValidateName(v) {
1406
+ return (
1407
+ typeof v === "string" &&
1408
+ v.length > 0 &&
1409
+ v.length <= FC_NAME_MAX &&
1410
+ FC_NAME_RE.test(v) &&
1411
+ FC_NAME_BLACKLIST.indexOf(v) === -1
1412
+ );
1413
+ }
1414
+
1415
+ const UTIL_STARTER = [
1416
+ "// Helpers / custom hooks / constants — top-level, no React component.",
1417
+ "// Each symbol declared here is available globally in any portal that",
1418
+ "// references it (selective inclusion: unused utility nodes are skipped).",
1419
+ "",
1420
+ "const PI2 = Math.PI * 2;",
1421
+ "",
1422
+ "function clamp(n, min, max) {",
1423
+ " return Math.max(min, Math.min(max, n));",
1424
+ "}",
1425
+ "",
1426
+ "// Custom React hook — call from inside App() or library components",
1427
+ "function useDebounce(value, ms = 300) {",
1428
+ " const [v, setV] = React.useState(value);",
1429
+ " React.useEffect(() => {",
1430
+ " const t = setTimeout(() => setV(value), ms);",
1431
+ " return () => clearTimeout(t);",
1432
+ " }, [value, ms]);",
1433
+ " return v;",
1434
+ "}",
1435
+ ].join("\n");
1436
+
1437
+ RED.nodes.registerType("fc-portal-utility", {
1438
+ category: "fromcubes",
1439
+ color: "#fbbf24",
1440
+ defaults: {
1441
+ name: { value: "" },
1442
+ utilName: {
1443
+ value: "myHelpers",
1444
+ required: true,
1445
+ validate: fcValidateName,
1446
+ },
1447
+ utilCode: {
1448
+ value: UTIL_STARTER,
1449
+ validate: function (v) {
1450
+ return typeof v === "string" && v.trim().length > 0;
1451
+ },
1452
+ },
1453
+ },
1454
+ inputs: 0,
1455
+ outputs: 0,
1456
+ icon: "fromcubes-utility.svg",
1457
+ paletteLabel: "React Utility",
1458
+ inputLabels: [],
1459
+ outputLabels: [],
1460
+ /**
1461
+ * Canvas label — `name → utilName → "utility"`.
1462
+ * @returns {string}
1463
+ */
1464
+ label: function () {
1465
+ return this.name || this.utilName || "utility";
1466
+ },
1467
+
1468
+ /**
1469
+ * Editor lifecycle for `fc-portal-utility`: lazy-load Monaco,
1470
+ * create a JS model with URI `file:///fc-util-<nodeId>.js` (note
1471
+ * `.js` not `.jsx` — utility code is plain JS).
1472
+ * @returns {void}
1473
+ */
1474
+ oneditprepare: function () {
1475
+ const node = this;
1476
+ const code =
1477
+ typeof node.utilCode === "string" ? node.utilCode : UTIL_STARTER;
1478
+ console.log("[FC-Monaco] UTIL oneditprepare, node.id=" + node.id);
1479
+
1480
+ // Hand off to the full-screen React Editor: the dialog saves and
1481
+ // closes itself first (its oneditsave owns the same field).
1482
+ $("#fcu-btn-react-editor").on("click", function () {
1483
+ if (!window.__fcPortalWorkspace) {
1484
+ RED.notify("React Editor is not available", "warning");
1485
+ return;
1486
+ }
1487
+ window.__fcPortalWorkspace.openNodeFile(node.id, "utilCode");
1488
+ });
1489
+
1490
+ window.__fcRefreshCssClasses();
1491
+
1492
+ window.__fcLoadMonaco(function (failed) {
1493
+ if (failed) {
1494
+ $("#fcu-monaco").hide();
1495
+ $("#fcu-fallback").show().val(code);
1496
+ return;
1497
+ }
1498
+ window.__fcApplyJsxDefaults();
1499
+
1500
+ const utilUri = monaco.Uri.parse("file:///fc-util-" + node.id + ".js");
1501
+ const existingModel = monaco.editor.getModel(utilUri);
1502
+ if (existingModel) existingModel.dispose();
1503
+ const utilModel = monaco.editor.createModel(code, "javascript", utilUri);
1504
+
1505
+ utilEditorInstance = monaco.editor.create(
1506
+ document.getElementById("fcu-monaco"),
1507
+ Object.assign({ model: utilModel }, window.__fcEditorOpts),
1508
+ );
1509
+ if (window.__fcAttachKeybindings)
1510
+ window.__fcAttachKeybindings(utilEditorInstance);
1511
+ });
1512
+ },
1513
+
1514
+ /**
1515
+ * Editor → flow: write Monaco contents back to `utilCode`, dispose
1516
+ * model+editor.
1517
+ * @returns {void}
1518
+ */
1519
+ oneditsave: function () {
1520
+ const code = utilEditorInstance
1521
+ ? utilEditorInstance.getValue()
1522
+ : $("#fcu-fallback").val();
1523
+ $("#node-input-utilCode").val(code);
1524
+ this.utilCode = code;
1525
+ if (utilEditorInstance) {
1526
+ utilEditorInstance.getModel().dispose();
1527
+ utilEditorInstance.dispose();
1528
+ utilEditorInstance = null;
1529
+ }
1530
+ },
1531
+
1532
+ /**
1533
+ * Discard Monaco state without writing back.
1534
+ * @returns {void}
1535
+ */
1536
+ oneditcancel: function () {
1537
+ if (utilEditorInstance) {
1538
+ utilEditorInstance.getModel().dispose();
1539
+ utilEditorInstance.dispose();
1540
+ utilEditorInstance = null;
1541
+ }
1542
+ },
1543
+
1544
+ /**
1545
+ * Layout Monaco on dialog resize. -200 px chrome for the utility
1546
+ * dialog (taller because of the symbol-list panel).
1547
+ * @param {{height: number, width: number}} size
1548
+ * @returns {void}
1549
+ */
1550
+ oneditresize: function (size) {
1551
+ let h = size.height - 200;
1552
+ if (h < 150) h = 150;
1553
+ $("#fcu-editor-wrap").css("height", h + "px");
1554
+ if (utilEditorInstance) utilEditorInstance.layout();
1555
+ },
1556
+ });
1557
+ })();
1558
+ </script>
1559
+
1560
+ <script type="text/html" data-help-name="fc-portal-utility">
1561
+ <p>
1562
+ Defines shared <strong>helpers</strong>, <strong>custom hooks</strong> and
1563
+ <strong>constants</strong> available to all <strong>portal-react</strong>
1564
+ nodes. Unlike <code>fc-portal-component</code>, the code is injected raw at
1565
+ top level (no IIFE wrapper) — a single utility node may declare many symbols.
1566
+ </p>
1567
+ <h3>Properties</h3>
1568
+ <dl>
1569
+ <dt>Module name</dt>
1570
+ <dd>
1571
+ Identifier for this utility node. Shares a namespace with
1572
+ <code>fc-portal-component</code> names — must be unique across all
1573
+ components and utilities.
1574
+ </dd>
1575
+ <dt>Code</dt>
1576
+ <dd>
1577
+ Top-level JavaScript: <code>const</code>, <code>let</code>,
1578
+ <code>function</code>, <code>class</code> declarations. <code>import</code>
1579
+ statements at the top are auto-hoisted into the bundle.
1580
+ </dd>
1581
+ </dl>
1582
+ <h3>Selective inclusion</h3>
1583
+ <p>
1584
+ A utility node is bundled into a portal only when the portal's JSX (or any
1585
+ of its referenced library components) mentions at least one of the symbols
1586
+ declared in the utility. Unused utility nodes are skipped.
1587
+ </p>
1588
+ <p>
1589
+ Group related symbols in one node — referencing any one symbol pulls in the
1590
+ whole node's code. Split unrelated helpers across multiple nodes for finer
1591
+ granularity.
1592
+ </p>
1593
+ <h3>Example</h3>
1594
+ <pre>function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
1595
+
1596
+ function useDebounce(value, ms = 300) {
1597
+ const [v, setV] = React.useState(value);
1598
+ React.useEffect(() => {
1599
+ const t = setTimeout(() => setV(value), ms);
1600
+ return () => clearTimeout(t);
1601
+ }, [value, ms]);
1602
+ return v;
1603
+ }</pre>
1604
+ <p>
1605
+ Then in any <strong>portal-react</strong> node:
1606
+ </p>
1607
+ <pre>function App() {
1608
+ const { data } = useNodeRed();
1609
+ const slow = useDebounce(data?.value);
1610
+ return &lt;div&gt;{clamp(slow ?? 0, 0, 100)}&lt;/div&gt;;
1611
+ }</pre>
1612
+ </script>
1613
+
1614
+ <!-- ============================================================
1615
+ portal-react – main node
1616
+ ============================================================ -->
1617
+ <script type="text/html" data-template-name="portal-react">
1618
+ <!-- Tabs -->
1619
+ <div class="form-row">
1620
+ <ul id="fc-tabs" style="min-width:600px;margin-bottom:0;"></ul>
1621
+ </div>
1622
+ <div id="fc-tabs-content">
1623
+ <!-- ── Tab: JSX ── -->
1624
+ <div id="fc-tab-jsx" class="fc-tab-pane">
1625
+ <div class="form-row node-text-editor-row">
1626
+ <div
1627
+ id="fc-editor-wrap"
1628
+ style="width:100%;height:420px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
1629
+ >
1630
+ <div id="fc-monaco" style="width:100%;height:100%;"></div>
1631
+ <textarea
1632
+ id="fc-fallback"
1633
+ 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;"
1634
+ ></textarea>
1635
+ </div>
1636
+ </div>
1637
+ <div class="form-row" style="display:flex;gap:8px;margin-top:4px;flex-wrap:wrap;">
1638
+ <button type="button" class="red-ui-button" id="fc-btn-starter">
1639
+ <i class="fa fa-magic"></i> Default
1640
+ </button>
1641
+ <button type="button" class="red-ui-button" id="fc-btn-components">
1642
+ <i class="fa fa-cube"></i> Components
1643
+ </button>
1644
+ <button type="button" class="red-ui-button" id="fc-btn-utilities">
1645
+ <i class="fa fa-wrench"></i> Utilities
1646
+ </button>
1647
+ <button type="button" class="red-ui-button" id="fc-btn-react-editor" title="Open this file in the full-screen React Editor">
1648
+ <i class="fa fa-expand"></i> React Editor
1649
+ </button>
1650
+ <span style="flex:1"></span>
1651
+ <button type="button" class="red-ui-button" id="fc-btn-preview">
1652
+ <i class="fa fa-eye"></i> Preview
1653
+ </button>
1654
+ </div>
1655
+ </div>
1656
+
1657
+ <!-- ── Tab: Properties ── -->
1658
+ <div id="fc-tab-props" class="fc-tab-pane" style="display:none;">
1659
+ <div class="form-row">
1660
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
1661
+ <input type="text" id="node-input-name" placeholder="My Portal" />
1662
+ </div>
1663
+ <div class="form-row">
1664
+ <label for="node-input-subPath"
1665
+ ><i class="fa fa-globe"></i> Sub-path</label
1666
+ >
1667
+ <div style="display:inline-flex;align-items:stretch;width:70%;">
1668
+ <span
1669
+ style="display:inline-flex;align-items:center;padding:0 8px;background:var(--red-ui-secondary-background,#f3f3f3);border:1px solid var(--red-ui-form-input-border-color,#ccc);border-right:none;border-radius:4px 0 0 4px;color:var(--red-ui-secondary-text-color,#888);font-family:monospace;user-select:none;"
1670
+ >/fromcubes/</span
1671
+ >
1672
+ <input
1673
+ type="text"
1674
+ id="node-input-subPath"
1675
+ placeholder="sensors"
1676
+ style="flex:1;border-radius:0 4px 4px 0;"
1677
+ />
1678
+ </div>
1679
+ <input type="hidden" id="node-input-endpoint" />
1680
+ <div
1681
+ style="font-size:11px;opacity:.5;margin-top:2px;margin-left:105px;"
1682
+ id="fc-url-hint"
1683
+ ></div>
1684
+ </div>
1685
+ <div class="form-row">
1686
+ <label for="node-input-pageTitle"
1687
+ ><i class="fa fa-file-text-o"></i> Title</label
1688
+ >
1689
+ <input type="text" id="node-input-pageTitle" placeholder="Portal" />
1690
+ </div>
1691
+ <div class="form-row node-input-libs-container-row">
1692
+ <label style="width:auto;"><i class="fa fa-archive"></i> Modules</label>
1693
+ <ol id="node-input-libs-container"></ol>
1694
+ <div
1695
+ style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;"
1696
+ >
1697
+ npm packages to bundle. Node-RED auto-installs them at deploy time.
1698
+ </div>
1699
+ </div>
1700
+ <div class="form-row" style="margin-top:12px;">
1701
+ <label style="width:auto;">
1702
+ <input
1703
+ type="checkbox"
1704
+ id="node-input-portalAuth"
1705
+ style="width:auto;margin:0 8px 0 0;vertical-align:middle;"
1706
+ />
1707
+ Enable Portal Auth headers
1708
+ </label>
1709
+ <div style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;">
1710
+ Read <code>X-Portal-*</code> headers from reverse proxy.
1711
+ </div>
1712
+ </div>
1713
+ <div class="form-row" style="margin-top:12px;">
1714
+ <label style="width:auto;">
1715
+ <input
1716
+ type="checkbox"
1717
+ id="node-input-authOnly"
1718
+ style="width:auto;margin:0 8px 0 0;vertical-align:middle;"
1719
+ />
1720
+ Authenticated-only delivery
1721
+ </label>
1722
+ <div style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;">
1723
+ Messages without <code>msg._client</code> go only to sessions with
1724
+ portal identity headers. Anonymous sessions get an
1725
+ <code>auth_required</code> frame on connect
1726
+ (<code>useNodeRed().authRequired</code>). Force a true broadcast with
1727
+ <code>msg._client = { authenticated: false }</code>.
1728
+ </div>
1729
+ </div>
1730
+ <div class="form-row" style="margin-top:12px;">
1731
+ <label style="width:auto;">
1732
+ <input
1733
+ type="checkbox"
1734
+ id="node-input-showWsStatus"
1735
+ style="width:auto;margin:0 8px 0 0;vertical-align:middle;"
1736
+ />
1737
+ Show WebSocket status indicator
1738
+ </label>
1739
+ <div style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;">
1740
+ Displays a small <em>fromcubes</em> badge in the bottom-right corner
1741
+ showing connection state.
1742
+ </div>
1743
+ </div>
1744
+ </div>
1745
+
1746
+ <!-- ── Tab: Head HTML ── -->
1747
+ <div id="fc-tab-head" class="fc-tab-pane" style="display:none;">
1748
+ <div class="form-row">
1749
+ <div style="font-size:11px;opacity:.6;margin-bottom:6px;">
1750
+ Extra tags injected into <code>&lt;head&gt;</code>. CDN links, fonts,
1751
+ stylesheets, meta tags. Trusted authors only: scripts and event
1752
+ handlers run in the public portal page.
1753
+ </div>
1754
+ <div
1755
+ id="fc-head-wrap"
1756
+ style="width:100%;height:420px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
1757
+ >
1758
+ <div id="fc-head-monaco" style="width:100%;height:100%;"></div>
1759
+ <textarea
1760
+ id="fc-head-fallback"
1761
+ 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;"
1762
+ placeholder='<link rel="stylesheet" href="...">'
1763
+ ></textarea>
1764
+ </div>
1765
+ </div>
1766
+ </div>
1767
+ </div>
1768
+
1769
+ <input type="hidden" id="node-input-componentCode" />
1770
+ <input type="hidden" id="node-input-customHead" />
1771
+ </script>
1772
+
1773
+ <script type="text/javascript">
1774
+ (function () {
1775
+ let editorInstance = null;
1776
+ let headEditorInstance = null;
1777
+
1778
+ const STARTER = [
1779
+ "// useNodeRed() \u2192 { data, send, portalClient }",
1780
+ "// data = last msg.payload from input wire",
1781
+ "// send(payload, topic?) = push msg to output wire",
1782
+ "// Components from fc-portal-component nodes are available by name.",
1783
+ "",
1784
+ "function App() {",
1785
+ " const { data, send } = useNodeRed();",
1786
+ " const d = data || {};",
1787
+ "",
1788
+ " return (",
1789
+ ' <div className="min-h-screen bg-zinc-950 p-8">',
1790
+ ' <h1 className="text-2xl font-light text-cyan-400 mb-6">fromcubes</h1>',
1791
+ ' <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">',
1792
+ ' <StatusCard label="Value" value={d.value ?? "—"} unit="" />',
1793
+ " </div>",
1794
+ " <button",
1795
+ ' className="mt-6 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-500 transition-colors"',
1796
+ " onClick={() => send({ clicked: true })}",
1797
+ " >",
1798
+ " Send to Node-RED",
1799
+ " </button>",
1800
+ " </div>",
1801
+ " );",
1802
+ "}",
1803
+ ].join("\n");
1804
+
1805
+ RED.nodes.registerType("portal-react", {
1806
+ category: "fromcubes",
1807
+ color: "#61dafb",
1808
+ defaults: {
1809
+ name: { value: "" },
1810
+ subPath: {
1811
+ value: "",
1812
+ required: true,
1813
+ validate: function (v) {
1814
+ if (typeof v !== "string") return false;
1815
+ const t = v.trim();
1816
+ if (t.length === 0) return false;
1817
+ if (/\s/.test(t)) return false;
1818
+ if (t.charAt(0) === "/" || t.charAt(t.length - 1) === "/") return false;
1819
+ const segs = t.split("/");
1820
+ for (let i = 0; i < segs.length; i++) {
1821
+ const s = segs[i];
1822
+ if (!s || s === "." || s === "..") return false;
1823
+ const lower = s.toLowerCase();
1824
+ if (lower === "public" || lower === "_ws") return false;
1825
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(s)) return false;
1826
+ }
1827
+ return true;
1828
+ },
1829
+ },
1830
+ endpoint: { value: "" },
1831
+ pageTitle: { value: "fromcubes" },
1832
+ componentCode: {
1833
+ value: STARTER,
1834
+ validate: function (v) {
1835
+ return typeof v === "string" && v.trim().length > 0;
1836
+ },
1837
+ },
1838
+ customHead: { value: "" },
1839
+ portalAuth: { value: false },
1840
+ authOnly: { value: false },
1841
+ showWsStatus: { value: false },
1842
+ libs: { value: [] },
1843
+ },
1844
+ inputs: 1,
1845
+ outputs: 1,
1846
+ icon: "fromcubes-react.svg",
1847
+ paletteLabel: "React Portal",
1848
+ // Source AND sink — we keep the default left-aligned input even though
1849
+ // Node-RED appearance docs suggest align: 'right' for nodes that sit at
1850
+ // the end of a flow. This portal is hybrid: the input wire pushes msgs
1851
+ // into the browser, while the output wire forwards UI events out. Left
1852
+ // alignment matches the dominant "msg → render" direction.
1853
+ inputLabels: ["broadcast / unicast msg → page"],
1854
+ outputLabels: ["UI event from page"],
1855
+ /**
1856
+ * Canvas label — shows `name` or the deployed URL (`/fromcubes/<subPath>`).
1857
+ * @returns {string}
1858
+ */
1859
+ label: function () {
1860
+ return this.name || ("/fromcubes/" + (this.subPath || "?"));
1861
+ },
1862
+
1863
+ /**
1864
+ * Editor lifecycle for the portal node. Heaviest of the three —
1865
+ * creates two Monaco editors (JSX + Head HTML), wires up:
1866
+ * - URL hint that previews `/fromcubes/<subPath>`
1867
+ * - Legacy `endpoint` field migration warning
1868
+ * - `RED.tabs` for JSX / Properties / Head HTML panels
1869
+ * - Component picker dialog (`#fc-btn-components`)
1870
+ * - Utility picker dialog (`#fc-btn-utilities`)
1871
+ * @returns {void}
1872
+ */
1873
+ oneditprepare: function () {
1874
+ const node = this;
1875
+ const code =
1876
+ typeof node.componentCode === "string" ? node.componentCode : STARTER;
1877
+ const headCode = node.customHead || "";
1878
+ console.log("[FC-Monaco] PORTAL oneditprepare, node.id=" + node.id);
1879
+
1880
+ // Hand off to the full-screen React Editor. Which file opens follows
1881
+ // the tab you were on: JSX → App.jsx, Head HTML → head.html.
1882
+ $("#fc-btn-react-editor").on("click", function () {
1883
+ if (!window.__fcPortalWorkspace) {
1884
+ RED.notify("React Editor is not available", "warning");
1885
+ return;
1886
+ }
1887
+ const field = $("#fc-tab-head").is(":visible")
1888
+ ? "customHead"
1889
+ : "componentCode";
1890
+ window.__fcPortalWorkspace.openNodeFile(node.id, field);
1891
+ });
1892
+
1893
+ window.__fcRefreshCssClasses();
1894
+
1895
+ // Tabs
1896
+ const fcTabs = RED.tabs.create({
1897
+ id: "fc-tabs",
1898
+ onchange: function (tab) {
1899
+ $(".fc-tab-pane").hide();
1900
+ $("#" + tab.id).show();
1901
+ setTimeout(function () {
1902
+ if (editorInstance) editorInstance.layout();
1903
+ if (headEditorInstance) headEditorInstance.layout();
1904
+ }, 0);
1905
+ },
1906
+ });
1907
+ fcTabs.addTab({ id: "fc-tab-jsx", label: "JSX" });
1908
+ fcTabs.addTab({ id: "fc-tab-props", label: "Properties" });
1909
+ fcTabs.addTab({ id: "fc-tab-head", label: "Head HTML" });
1910
+ fcTabs.activateTab("fc-tab-jsx");
1911
+
1912
+ // Legacy endpoint detection + convenience pre-fill
1913
+ if (node.endpoint && typeof node.endpoint === "string") {
1914
+ const legacy = node.endpoint;
1915
+ const prefix = "/fromcubes/";
1916
+ if (legacy.indexOf(prefix) === 0) {
1917
+ if (!node.subPath) {
1918
+ $("#node-input-subPath").val(legacy.slice(prefix.length));
1919
+ }
1920
+ } else if (legacy !== "" && legacy !== "/fromcubes") {
1921
+ $("#fc-url-hint")
1922
+ .css("color", "#c00")
1923
+ .text(
1924
+ "Legacy endpoint detected: '" +
1925
+ legacy +
1926
+ "'. Set a Sub-path and redeploy.",
1927
+ );
1928
+ }
1929
+ }
1930
+
1931
+ // URL hint
1932
+ function updateHint() {
1933
+ const sp = ($("#node-input-subPath").val() || "").trim();
1934
+ const root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1935
+ if (sp) {
1936
+ $("#fc-url-hint")
1937
+ .css("color", "")
1938
+ .text(
1939
+ "Page served at: http://<host>:1880" +
1940
+ root +
1941
+ "/fromcubes/" +
1942
+ sp,
1943
+ );
1944
+ } else if (!node.endpoint || node.endpoint.indexOf("/fromcubes/") === 0) {
1945
+ $("#fc-url-hint")
1946
+ .css("color", "")
1947
+ .text("Sub-path required (will be served under /fromcubes/<sub-path>)");
1948
+ }
1949
+ }
1950
+ $("#node-input-subPath").on("input", updateHint);
1951
+ updateHint();
1952
+
1953
+ // Modules editableList (like function node's libs)
1954
+ const libsList = node.libs || [];
1955
+ $("#node-input-libs-container").css("min-height","68px").editableList({
1956
+ addItem: function(container, i, opt) {
1957
+ const lib = opt || {};
1958
+ const row = $('<div/>',{style:"display:flex;gap:8px;align-items:center;"}).appendTo(container);
1959
+ const modInput = $('<input/>',{type:"text",placeholder:"e.g. chart.js/auto@^4.4.0",style:"flex:1;"}).appendTo(row);
1960
+ const varInput = $('<input/>',{type:"text",placeholder:"Import as (e.g. Chart)",style:"width:140px;"}).appendTo(row);
1961
+ modInput.val(lib.module || "");
1962
+ varInput.val(lib.const || "");
1963
+ container.data("mod", modInput);
1964
+ container.data("var", varInput);
1965
+ },
1966
+ removable: true,
1967
+ sortable: true
1968
+ });
1969
+ libsList.forEach(function(lib) {
1970
+ $("#node-input-libs-container").editableList("addItem", lib);
1971
+ });
1972
+
1973
+ // Monaco
1974
+ window.__fcLoadMonaco(function (failed) {
1975
+ if (failed) {
1976
+ console.error(
1977
+ "[FC-Monaco] PORTAL: Monaco load failed, using fallback textarea",
1978
+ );
1979
+ $("#fc-monaco").hide();
1980
+ $("#fc-fallback").show().val(code);
1981
+ $("#fc-head-monaco").hide();
1982
+ $("#fc-head-fallback").show().val(headCode);
1983
+ return;
1984
+ }
1985
+
1986
+ console.log(
1987
+ "[FC-Monaco] PORTAL: applying JSX defaults before model creation",
1988
+ );
1989
+ window.__fcApplyJsxDefaults();
1990
+ const opts = window.__fcEditorOpts;
1991
+
1992
+ const jsxUri = monaco.Uri.parse(
1993
+ "file:///fc-portal-" + node.id + ".jsx",
1994
+ );
1995
+ console.log("[FC-Monaco] PORTAL: JSX model URI=" + jsxUri.toString());
1996
+ const existingJsx = monaco.editor.getModel(jsxUri);
1997
+ if (existingJsx) {
1998
+ console.log("[FC-Monaco] PORTAL: disposing existing JSX model");
1999
+ existingJsx.dispose();
2000
+ }
2001
+ const jsxModel = monaco.editor.createModel(code, "javascript", jsxUri);
2002
+ console.log(
2003
+ "[FC-Monaco] PORTAL: JSX model created, language=" +
2004
+ jsxModel.getLanguageId(),
2005
+ );
2006
+ editorInstance = monaco.editor.create(
2007
+ document.getElementById("fc-monaco"),
2008
+ Object.assign({ model: jsxModel }, opts),
2009
+ );
2010
+ console.log("[FC-Monaco] PORTAL: JSX editor created");
2011
+ if (window.__fcAttachSelfClose)
2012
+ window.__fcAttachSelfClose(editorInstance);
2013
+ if (window.__fcAttachKeybindings)
2014
+ window.__fcAttachKeybindings(editorInstance);
2015
+
2016
+ const headUri = monaco.Uri.parse(
2017
+ "file:///fc-head-" + node.id + ".html",
2018
+ );
2019
+ const existingHead = monaco.editor.getModel(headUri);
2020
+ if (existingHead) existingHead.dispose();
2021
+ const headModel = monaco.editor.createModel(headCode, "html", headUri);
2022
+ headEditorInstance = monaco.editor.create(
2023
+ document.getElementById("fc-head-monaco"),
2024
+ Object.assign({ model: headModel }, opts),
2025
+ );
2026
+ if (window.__fcAttachKeybindings)
2027
+ window.__fcAttachKeybindings(headEditorInstance);
2028
+ console.log("[FC-Monaco] PORTAL: Head editor created");
2029
+
2030
+ // Log markers after a short delay (diagnostics are async)
2031
+ setTimeout(function () {
2032
+ const markers = monaco.editor.getModelMarkers({ resource: jsxUri });
2033
+ console.log(
2034
+ "[FC-Monaco] PORTAL: markers after 500ms, count=" +
2035
+ markers.length,
2036
+ );
2037
+ markers.forEach(function (m) {
2038
+ console.log(
2039
+ "[FC-Monaco] PORTAL marker: code=" +
2040
+ m.code +
2041
+ " severity=" +
2042
+ m.severity +
2043
+ " msg=" +
2044
+ m.message,
2045
+ );
2046
+ });
2047
+ console.log(
2048
+ "[FC-Monaco] PORTAL: current compilerOptions:",
2049
+ JSON.stringify(
2050
+ monaco.typescript.javascriptDefaults.getCompilerOptions(),
2051
+ ),
2052
+ );
2053
+ console.log(
2054
+ "[FC-Monaco] PORTAL: current diagnosticsOptions:",
2055
+ JSON.stringify(
2056
+ monaco.typescript.javascriptDefaults.getDiagnosticsOptions(),
2057
+ ),
2058
+ );
2059
+ }, 500);
2060
+ });
2061
+
2062
+ // Buttons
2063
+ $("#fc-btn-starter").on("click", function () {
2064
+ $("<div>Replace current JSX with default starter code?</div>").dialog({
2065
+ title: "Load Default",
2066
+ modal: true,
2067
+ width: 360,
2068
+ buttons: [
2069
+ { text: "Cancel", click: function () { $(this).dialog("close"); } },
2070
+ { text: "Replace", class: "primary", click: function () {
2071
+ if (editorInstance) editorInstance.setValue(STARTER);
2072
+ else $("#fc-fallback").val(STARTER);
2073
+ $(this).dialog("close");
2074
+ }},
2075
+ ],
2076
+ close: function () { $(this).remove(); },
2077
+ });
2078
+ });
2079
+
2080
+ $("#fc-btn-preview").on("click", function () {
2081
+ const sp = ($("#node-input-subPath").val() || "").trim();
2082
+ const root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
2083
+ if (sp) window.open(root + "/fromcubes/" + sp, "_blank");
2084
+ });
2085
+
2086
+ $("#fc-btn-components").on("click", function () {
2087
+ $.getJSON("fromcubes-react/registry", function (reg) {
2088
+ const names = Object.keys(reg).sort();
2089
+ if (!names.length) {
2090
+ RED.notify("No component nodes on canvas.", "warning");
2091
+ return;
2092
+ }
2093
+
2094
+ function extractProps(code) {
2095
+ if (!code) return [];
2096
+ const m = code.match(/function\s+\w+\s*\(\s*\{([^}]*)\}/);
2097
+ if (!m) return [];
2098
+ return m[1].split(",").map(function (s) { return s.trim().split(/\s*=\s*/)[0]; }).filter(Boolean);
2099
+ }
2100
+
2101
+ let html =
2102
+ '<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">' +
2103
+ '<input type="text" id="fc-comp-search" placeholder="Search..." ' +
2104
+ 'style="width:100%;box-sizing:border-box;margin-bottom:6px;padding:5px 8px;border:1px solid rgba(128,128,128,.4);border-radius:3px;' +
2105
+ 'background:var(--red-ui-form-input-background-color,#fff);color:var(--red-ui-form-text-color,#333);font-size:12px;flex-shrink:0;" />' +
2106
+ '<div id="fc-comp-list" style="flex:1;overflow-y:auto;">';
2107
+
2108
+ names.forEach(function (n) {
2109
+ const c = reg[n];
2110
+ const props = extractProps(c.code);
2111
+ const detailParts = [];
2112
+ if (props.length) {
2113
+ detailParts.push('<div style="margin:4px 0 0 0;font-size:11px;opacity:.6;">' +
2114
+ props.map(function (p) {
2115
+ return '<code style="background:rgba(128,128,128,.15);padding:0 4px;border-radius:2px;margin-right:3px;">' + p + '</code>';
2116
+ }).join("") + '</div>');
2117
+ }
2118
+ const hasDetail = detailParts.length > 0;
2119
+
2120
+ html +=
2121
+ '<div class="fc-comp-item" data-name="' + n + '" data-search="' + n.toLowerCase() + '" ' +
2122
+ 'style="border-bottom:1px solid rgba(128,128,128,.1);">' +
2123
+ '<div style="display:flex;align-items:center;padding:4px 6px;cursor:pointer;" class="fc-comp-row">' +
2124
+ (hasDetail ? '<i class="fa fa-caret-right fc-comp-arrow" style="width:14px;opacity:.4;font-size:12px;transition:transform .15s;"></i>' : '<span style="width:14px;"></span>') +
2125
+ '<span class="fc-comp-name" data-name="' + n + '" style="font-weight:600;font-size:12px;flex:1;">' + n + '</span>' +
2126
+ '</div>' +
2127
+ (hasDetail ? '<div class="fc-comp-detail" style="display:none;padding:0 6px 4px 20px;">' + detailParts.join("") + '</div>' : '') +
2128
+ '</div>';
2129
+ });
2130
+ html += '</div></div>';
2131
+
2132
+ const $dlg = $("<div></div>").html(html).dialog({
2133
+ title: "Components",
2134
+ modal: true,
2135
+ width: 380,
2136
+ buttons: [
2137
+ { text: "Close", click: function () { $(this).dialog("close"); } },
2138
+ ],
2139
+ close: function () { $(this).remove(); },
2140
+ });
2141
+
2142
+ // Search
2143
+ $dlg.find("#fc-comp-search").on("input", function () {
2144
+ const q = $(this).val().toLowerCase();
2145
+ $dlg.find(".fc-comp-item").each(function () {
2146
+ $(this).toggle($(this).data("search").indexOf(q) !== -1);
2147
+ });
2148
+ }).focus();
2149
+
2150
+ // Hover
2151
+ $dlg.on("mouseenter", ".fc-comp-row", function () {
2152
+ $(this).css("background", "rgba(128,128,128,.1)");
2153
+ }).on("mouseleave", ".fc-comp-row", function () {
2154
+ $(this).css("background", "");
2155
+ });
2156
+
2157
+ // Arrow toggle detail
2158
+ $dlg.on("click", ".fc-comp-arrow", function (e) {
2159
+ e.stopPropagation();
2160
+ const $item = $(this).closest(".fc-comp-item");
2161
+ const $detail = $item.find(".fc-comp-detail");
2162
+ const open = $detail.is(":visible");
2163
+ $detail.slideToggle(100);
2164
+ $(this).css("transform", open ? "" : "rotate(90deg)");
2165
+ });
2166
+
2167
+ // Click name: delete selection, insert <Tag></Tag> in one line, cursor between tags
2168
+ $dlg.on("click", ".fc-comp-name", function () {
2169
+ const name = $(this).data("name");
2170
+ const openTag = "<" + name + ">";
2171
+ const closeTag = "</" + name + ">";
2172
+ const text = openTag + closeTag;
2173
+ $dlg.dialog("close");
2174
+ if (editorInstance) {
2175
+ const sel = editorInstance.getSelection();
2176
+ const startLine = sel.startLineNumber;
2177
+ const startCol = sel.startColumn;
2178
+ editorInstance.executeEdits("fc-components", [{
2179
+ range: sel,
2180
+ text: text,
2181
+ }]);
2182
+ setTimeout(function () {
2183
+ editorInstance.setPosition({ lineNumber: startLine, column: startCol + openTag.length });
2184
+ editorInstance.focus();
2185
+ }, 50);
2186
+ } else {
2187
+ const ta = $("#fc-fallback")[0];
2188
+ const s = ta.selectionStart, e = ta.selectionEnd, v = ta.value;
2189
+ ta.value = v.slice(0, s) + text + v.slice(e);
2190
+ setTimeout(function () {
2191
+ ta.selectionStart = ta.selectionEnd = s + openTag.length;
2192
+ ta.focus();
2193
+ }, 50);
2194
+ }
2195
+ });
2196
+ });
2197
+ });
2198
+
2199
+ $("#fc-btn-utilities").on("click", function () {
2200
+ $.getJSON("fromcubes-react/utilities", function (reg) {
2201
+ // Refresh global cache too so completion provider picks up changes
2202
+ if (window.__fcRefreshUtilities) window.__fcRefreshUtilities();
2203
+ const names = Object.keys(reg).sort();
2204
+ if (!names.length) {
2205
+ RED.notify("No utility nodes on canvas.", "warning");
2206
+ return;
2207
+ }
2208
+
2209
+ let html =
2210
+ '<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">' +
2211
+ '<input type="text" id="fc-util-search" placeholder="Search..." ' +
2212
+ 'style="width:100%;box-sizing:border-box;margin-bottom:6px;padding:5px 8px;border:1px solid rgba(128,128,128,.4);border-radius:3px;' +
2213
+ 'background:var(--red-ui-form-input-background-color,#fff);color:var(--red-ui-form-text-color,#333);font-size:12px;flex-shrink:0;" />' +
2214
+ '<div id="fc-util-list" style="flex:1;overflow-y:auto;">';
2215
+
2216
+ names.forEach(function (n) {
2217
+ const u = reg[n];
2218
+ const syms = u.symbols || [];
2219
+ const errBadge = u.error
2220
+ ? '<span style="margin-left:6px;font-size:10px;color:#ef4444;" title="' +
2221
+ String(u.error).replace(/"/g, "&quot;") + '">syntax error</span>'
2222
+ : "";
2223
+ const symBadges = syms.map(function (s) {
2224
+ return '<span class="fc-util-sym" data-sym="' + s +
2225
+ '" style="display:inline-block;background:rgba(251,191,36,.15);border:1px solid rgba(251,191,36,.35);' +
2226
+ 'padding:1px 6px;border-radius:3px;margin:2px 4px 2px 0;font-size:11px;font-family:monospace;cursor:pointer;">' +
2227
+ s + '</span>';
2228
+ }).join("");
2229
+ const detail = syms.length
2230
+ ? '<div style="margin:4px 0 0 0;">' + symBadges + '</div>'
2231
+ : '<div style="margin:4px 0 0 0;font-size:11px;opacity:.5;">(no top-level symbols detected)</div>';
2232
+ const searchKey = (n + " " + syms.join(" ")).toLowerCase();
2233
+
2234
+ html +=
2235
+ '<div class="fc-util-item" data-name="' + n + '" data-search="' + searchKey + '" ' +
2236
+ 'style="border-bottom:1px solid rgba(128,128,128,.1);">' +
2237
+ '<div style="display:flex;align-items:center;padding:4px 6px;cursor:pointer;" class="fc-util-row">' +
2238
+ '<i class="fa fa-caret-right fc-util-arrow" style="width:14px;opacity:.4;font-size:12px;transition:transform .15s;"></i>' +
2239
+ '<i class="fa fa-wrench" style="width:14px;color:#fbbf24;font-size:11px;"></i>' +
2240
+ '<span style="font-weight:600;font-size:12px;flex:1;margin-left:4px;">' + n + '</span>' +
2241
+ errBadge +
2242
+ '</div>' +
2243
+ '<div class="fc-util-detail" style="display:none;padding:0 6px 6px 32px;">' + detail + '</div>' +
2244
+ '</div>';
2245
+ });
2246
+ html += '</div></div>';
2247
+
2248
+ const $dlg = $("<div></div>").html(html).dialog({
2249
+ title: "Utilities",
2250
+ modal: true,
2251
+ width: 420,
2252
+ buttons: [
2253
+ { text: "Close", click: function () { $(this).dialog("close"); } },
2254
+ ],
2255
+ close: function () { $(this).remove(); },
2256
+ });
2257
+
2258
+ $dlg.find("#fc-util-search").on("input", function () {
2259
+ const q = $(this).val().toLowerCase();
2260
+ $dlg.find(".fc-util-item").each(function () {
2261
+ $(this).toggle($(this).data("search").indexOf(q) !== -1);
2262
+ });
2263
+ }).focus();
2264
+
2265
+ $dlg.on("mouseenter", ".fc-util-row", function () {
2266
+ $(this).css("background", "rgba(251,191,36,.08)");
2267
+ }).on("mouseleave", ".fc-util-row", function () {
2268
+ $(this).css("background", "");
2269
+ });
2270
+
2271
+ $dlg.on("click", ".fc-util-row", function (e) {
2272
+ if ($(e.target).closest(".fc-util-sym").length) return;
2273
+ const $item = $(this).closest(".fc-util-item");
2274
+ const $detail = $item.find(".fc-util-detail");
2275
+ const $arrow = $item.find(".fc-util-arrow");
2276
+ const open = $detail.is(":visible");
2277
+ $detail.slideToggle(100);
2278
+ $arrow.css("transform", open ? "" : "rotate(90deg)");
2279
+ });
2280
+
2281
+ // Click symbol → insert bare identifier at cursor
2282
+ $dlg.on("click", ".fc-util-sym", function (e) {
2283
+ e.stopPropagation();
2284
+ const sym = $(this).data("sym");
2285
+ $dlg.dialog("close");
2286
+ if (editorInstance) {
2287
+ const sel = editorInstance.getSelection();
2288
+ const startLine = sel.startLineNumber;
2289
+ const startCol = sel.startColumn;
2290
+ editorInstance.executeEdits("fc-utilities", [{
2291
+ range: sel,
2292
+ text: sym,
2293
+ }]);
2294
+ setTimeout(function () {
2295
+ editorInstance.setPosition({ lineNumber: startLine, column: startCol + sym.length });
2296
+ editorInstance.focus();
2297
+ }, 50);
2298
+ } else {
2299
+ const ta = $("#fc-fallback")[0];
2300
+ const s = ta.selectionStart, en = ta.selectionEnd, v = ta.value;
2301
+ ta.value = v.slice(0, s) + sym + v.slice(en);
2302
+ setTimeout(function () {
2303
+ ta.selectionStart = ta.selectionEnd = s + sym.length;
2304
+ ta.focus();
2305
+ }, 50);
2306
+ }
2307
+ });
2308
+ });
2309
+ });
2310
+ },
2311
+
2312
+ /**
2313
+ * Editor → flow. Normalises `subPath` (trim), clears the legacy
2314
+ * `endpoint` field, collects `libs` from the editableList widget,
2315
+ * writes both Monaco editors back to hidden inputs + node props,
2316
+ * then disposes both editors.
2317
+ * @returns {void}
2318
+ */
2319
+ oneditsave: function () {
2320
+ console.log("[FC-Monaco] PORTAL oneditsave");
2321
+
2322
+ // Clear legacy endpoint field + normalize subPath
2323
+ $("#node-input-endpoint").val("");
2324
+ this.endpoint = "";
2325
+ this.subPath = ($("#node-input-subPath").val() || "").trim();
2326
+
2327
+ // Collect libs from editableList
2328
+ const libs = [];
2329
+ const items = $("#node-input-libs-container").editableList("items");
2330
+ items.each(function() {
2331
+ const mod = $(this).data("mod").val().trim();
2332
+ const v = $(this).data("var").val().trim();
2333
+ if (mod) {
2334
+ libs.push({ module: mod, var: v });
2335
+ }
2336
+ });
2337
+ this.libs = libs;
2338
+
2339
+ const code = editorInstance
2340
+ ? editorInstance.getValue()
2341
+ : $("#fc-fallback").val();
2342
+ $("#node-input-componentCode").val(code);
2343
+ this.componentCode = code;
2344
+
2345
+ const head = headEditorInstance
2346
+ ? headEditorInstance.getValue()
2347
+ : $("#fc-head-fallback").val();
2348
+ $("#node-input-customHead").val(head);
2349
+ this.customHead = head;
2350
+
2351
+ if (editorInstance) {
2352
+ editorInstance.getModel().dispose();
2353
+ editorInstance.dispose();
2354
+ editorInstance = null;
2355
+ console.log("[FC-Monaco] PORTAL: JSX editor + model disposed");
2356
+ }
2357
+ if (headEditorInstance) {
2358
+ headEditorInstance.getModel().dispose();
2359
+ headEditorInstance.dispose();
2360
+ headEditorInstance = null;
2361
+ console.log("[FC-Monaco] PORTAL: Head editor + model disposed");
2362
+ }
2363
+ },
2364
+
2365
+ /**
2366
+ * Discard both Monaco editors without writing back to the node.
2367
+ * Identical dispose pattern to oneditsave so we never leak Monaco
2368
+ * graphs even if the user spams Edit / Cancel rapidly.
2369
+ * @returns {void}
2370
+ */
2371
+ oneditcancel: function () {
2372
+ console.log("[FC-Monaco] PORTAL oneditcancel");
2373
+ if (editorInstance) {
2374
+ editorInstance.getModel().dispose();
2375
+ editorInstance.dispose();
2376
+ editorInstance = null;
2377
+ console.log("[FC-Monaco] PORTAL: JSX editor + model disposed");
2378
+ }
2379
+ if (headEditorInstance) {
2380
+ headEditorInstance.getModel().dispose();
2381
+ headEditorInstance.dispose();
2382
+ headEditorInstance = null;
2383
+ console.log("[FC-Monaco] PORTAL: Head editor + model disposed");
2384
+ }
2385
+ },
2386
+
2387
+ /**
2388
+ * Dialog resize → compute available editor height by subtracting
2389
+ * the heights of non-tab form rows + tab strip + 30 px padding,
2390
+ * then re-layout both Monaco editors. Falls back to 200 px minimum.
2391
+ * @param {{height: number, width: number}} size
2392
+ * @returns {void}
2393
+ */
2394
+ oneditresize: function (size) {
2395
+ const tabsH = $("#fc-tabs").outerHeight(true) || 0;
2396
+ const rows = $(
2397
+ "#dialog-form>div:not(#fc-tabs-content):not(:has(#fc-tabs))",
2398
+ );
2399
+ let h = size.height;
2400
+ rows.each(function () {
2401
+ h -= $(this).outerHeight(true);
2402
+ });
2403
+ h -= tabsH + 30;
2404
+ if (h < 200) h = 200;
2405
+ $("#fc-editor-wrap").css("height", h + "px");
2406
+ $("#fc-head-wrap").css("height", h + "px");
2407
+ if (editorInstance) editorInstance.layout();
2408
+ if (headEditorInstance) headEditorInstance.layout();
2409
+ },
2410
+ });
2411
+ })();
2412
+ </script>
2413
+
2414
+ <!-- ============================================================
2415
+ Help
2416
+ ============================================================ -->
2417
+ <script type="text/html" data-help-name="portal-react">
2418
+ <h3>Quick Reference</h3>
2419
+ <ul>
2420
+ <li><code>useNodeRed()</code> &rarr; <code>{ data, send, user, portalClient }</code></li>
2421
+ <li>Components from <code>fc-portal-component</code> nodes are auto-imported</li>
2422
+ <li>Must export <code>&lt;App /&gt;</code></li>
2423
+ <li>JSX is transpiled server-side at deploy</li>
2424
+ </ul>
2425
+
2426
+ <p>
2427
+ Renders a React 19 application on a configurable HTTP endpoint with live
2428
+ WebSocket data binding. JSX is transpiled <strong>server-side at deploy
2429
+ time</strong> using esbuild — browsers receive pre-compiled JS with zero
2430
+ runtime compilation. Tailwind CSS 4 utility classes are generated
2431
+ server-side and served as static CSS.
2432
+ </p>
2433
+
2434
+ <h3>Inputs</h3>
2435
+ <p>
2436
+ <code>msg.payload</code> is pushed via WebSocket. Set <code>msg._client</code>
2437
+ to target specific clients, or omit it to broadcast to all.
2438
+ </p>
2439
+ <pre>
2440
+ const { data, send, user, portalClient } = useNodeRed();
2441
+ // data = last msg.payload (reactive)
2442
+ // send(payload, topic?) — emit msg on output wire
2443
+ // user = portal auth data or null
2444
+ // portalClient = unique session/tab ID (assigned by server)</pre
2445
+ >
2446
+
2447
+ <h3>Outputs</h3>
2448
+ <p>
2449
+ <code>send(payload, topic?)</code> emits a <code>msg</code> on the node's
2450
+ output wire via WebSocket.
2451
+ </p>
2452
+
2453
+ <h3>npm Packages</h3>
2454
+ <p>
2455
+ Add npm packages in the <strong>Modules</strong> list (e.g.
2456
+ <code>chart.js/auto@^4.4.0</code>, <code>d3</code>, <code>three</code>).
2457
+ Node-RED auto-installs them at deploy time. All packages are bundled with
2458
+ React into a single vendor IIFE via esbuild, cached by hash of installed
2459
+ versions. Use them in JSX via standard imports.
2460
+ </p>
2461
+
2462
+ <h3>Deploy behavior</h3>
2463
+ <ul>
2464
+ <li>
2465
+ Each deploy re-transpiles JSX via esbuild and rebuilds Tailwind CSS
2466
+ (both cached by content hash — unchanged code is instant)
2467
+ </li>
2468
+ <li>
2469
+ Vendor bundle (React + npm packages) rebuilt only when package versions
2470
+ change
2471
+ </li>
2472
+ <li>
2473
+ Active WebSocket clients receive close code 1001 and auto-reconnect
2474
+ (exponential backoff)
2475
+ </li>
2476
+ <li>
2477
+ Stale HTTP routes and WS handlers are cleaned up before new ones register
2478
+ </li>
2479
+ <li>
2480
+ Transpile errors show in node status and as an error page on the endpoint
2481
+ </li>
2482
+ </ul>
2483
+
2484
+ <h3>Shared Components</h3>
2485
+ <p>
2486
+ Components defined in <strong>fc-portal-component</strong> nodes on the
2487
+ canvas are auto-injected into every portal-react page. Use them as JSX tags
2488
+ by their component name — no imports needed.
2489
+ </p>
2490
+
2491
+ <h3>Portal Auth</h3>
2492
+ <p>
2493
+ When enabled in the Auth tab, reads <code>X-Portal-*</code> headers set by
2494
+ a reverse proxy (e.g. Nginx) and exposes user data:
2495
+ </p>
2496
+ <ul>
2497
+ <li><code>useNodeRed()</code> returns <code>{ data, send, user, portalClient }</code></li>
2498
+ <li>
2499
+ All WebSocket messages include <code>msg._client</code> with
2500
+ <code>{ portalClient, ...userFields }</code>
2501
+ </li>
2502
+ <li>
2503
+ To target a specific tab: keep <code>msg._client</code> (or set
2504
+ <code>msg._client = { portalClient: "..." }</code>)
2505
+ </li>
2506
+ <li>
2507
+ To target all tabs of a user: set
2508
+ <code>msg._client = { userId: "..." }</code> (no portalClient)
2509
+ </li>
2510
+ <li>
2511
+ To target every authenticated session (skips anonymous):
2512
+ <code>msg._client = { authenticated: true }</code>
2513
+ </li>
2514
+ <li>
2515
+ To broadcast to all: remove <code>msg._client</code>. With
2516
+ <b>Authenticated-only delivery</b> enabled this defaults to the
2517
+ authenticated sessions instead; force a true broadcast with
2518
+ <code>msg._client = { authenticated: false }</code>
2519
+ </li>
2520
+ </ul>
2521
+
2522
+ <h3>Custom Head HTML</h3>
2523
+ <p>
2524
+ Inject CDN links, fonts, or extra stylesheets into
2525
+ <code>&lt;head&gt;</code>. This is raw trusted-author HTML; scripts and
2526
+ event handlers run in the public portal page. Example:
2527
+ </p>
2528
+ <pre>
2529
+ &lt;link href="https://fonts.googleapis.com/css2?family=Inter&amp;display=swap" rel="stylesheet"&gt;</pre
2530
+ >
2531
+
2532
+ <h3>WebSocket Status</h3>
2533
+ <p>
2534
+ Optional <em>fromcubes</em> badge in the bottom-right corner showing
2535
+ connection state. Enable via the <strong>Show WebSocket status
2536
+ indicator</strong> checkbox (off by default).
2537
+ </p>
2538
+ </script>
2539
+