@aaqu/fromcubes-portal-react 0.1.0-alpha.1

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.
@@ -0,0 +1,1059 @@
1
+ <!-- ============================================================
2
+ Shared: Monaco loader (used by both node editors)
3
+ ============================================================ -->
4
+ <script type="text/javascript">
5
+ (function () {
6
+ var PREFIX = "[FC-Monaco]";
7
+
8
+ // Shared editor options — used by both component and portal-react editors
9
+ window.__fcEditorOpts = {
10
+ theme: "vs-dark",
11
+ minimap: { enabled: false },
12
+ fontSize: 13,
13
+ fontFamily: "'Cascadia Code','Fira Code',Consolas,monospace",
14
+ fontLigatures: true,
15
+ scrollBeyondLastLine: false,
16
+ automaticLayout: true,
17
+ tabSize: 2,
18
+ padding: { top: 8, bottom: 8 },
19
+ quickSuggestions: { strings: true },
20
+ };
21
+
22
+ window.__fcTwClasses = null;
23
+ var jsxSetupDone = false;
24
+
25
+ // One-time Monaco setup: compiler opts, diagnostics, extra libs, completion provider.
26
+ // Idempotent — safe to call multiple times, only runs setup once.
27
+ function ensureJsxSetup() {
28
+ if (jsxSetupDone) {
29
+ console.log(PREFIX, "ensureJsxSetup: already done, re-applying compiler+diag only");
30
+ // Always re-apply compiler/diag in case Node-RED reset them
31
+ applyCompilerAndDiag();
32
+ return;
33
+ }
34
+ jsxSetupDone = true;
35
+
36
+ var jsDef = monaco.typescript.javascriptDefaults;
37
+
38
+ // Log initial state
39
+ console.log(PREFIX, "INITIAL compilerOptions:", JSON.stringify(jsDef.getCompilerOptions()));
40
+ console.log(PREFIX, "INITIAL diagnosticsOptions:", JSON.stringify(jsDef.getDiagnosticsOptions()));
41
+
42
+ applyCompilerAndDiag();
43
+
44
+ // Global type stubs
45
+ var libContent = [
46
+ "declare var React: any;",
47
+ "declare var ReactDOM: any;",
48
+ "declare function useNodeRed(): { data: any; send: (payload: any, topic?: string) => void };",
49
+ ].join("\n");
50
+ console.log(PREFIX, "addExtraLib globals.d.ts");
51
+ jsDef.addExtraLib(libContent, "file:///globals.d.ts");
52
+
53
+ // Tailwind className completion provider
54
+ // Triggers in: className="...", className={`...`}, and any string literal
55
+ // containing at least one known TW class (e.g. "bg-red-500 text-white")
56
+ console.log(PREFIX, "registering Tailwind completion provider");
57
+ monaco.languages.registerCompletionItemProvider("javascript", {
58
+ triggerCharacters: ['"', "'", "`", " "],
59
+ provideCompletionItems: function (model, position) {
60
+ var line = model.getLineContent(position.lineNumber);
61
+ var before = line.substring(0, position.column - 1);
62
+
63
+ // 1) className="..." or className={`...`}
64
+ var isClassName =
65
+ before.match(/className\s*=\s*["'][^"']*$/) ||
66
+ before.match(/className\s*=\s*\{`[^`]*$/);
67
+
68
+ // 2) Any open string literal: "..., '..., or `...
69
+ var inString = !isClassName && before.match(/["'`][^"'`]*$/);
70
+
71
+ if (!isClassName && !inString) return { suggestions: [] };
72
+
73
+ // Extract text inside the string
74
+ var raw = (isClassName || inString)[0];
75
+ var inside;
76
+ if (isClassName) {
77
+ inside = raw.replace(/^className\s*=\s*(?:\{`|["'])/, "");
78
+ } else {
79
+ inside = raw.substring(1); // skip opening quote
80
+ }
81
+ inside = inside.replace(/\$\{[^}]*\}/g, " ");
82
+ var parts = inside.split(/\s+/);
83
+ var word = parts[parts.length - 1] || "";
84
+
85
+ // For non-className strings, only activate if at least one existing
86
+ // token in the string looks like a TW class (avoid noise in random strings)
87
+ if (!isClassName && parts.length > 0) {
88
+ var classes = window.__fcTwClasses || [];
89
+ var twSet = window.__fcTwSet;
90
+ if (!twSet && classes.length) {
91
+ twSet = new Set(classes);
92
+ window.__fcTwSet = twSet;
93
+ console.log(PREFIX, "TW: built lookup Set, size=" + twSet.size);
94
+ }
95
+ if (twSet) {
96
+ var hasTwToken = false;
97
+ for (var p = 0; p < parts.length; p++) {
98
+ if (parts[p] && twSet.has(parts[p])) {
99
+ hasTwToken = true;
100
+ break;
101
+ }
102
+ }
103
+ // If typing the very first word, accept if it looks like a prefix
104
+ if (!hasTwToken && parts.length === 1 && word.length >= 2) {
105
+ for (var j = 0; j < classes.length; j++) {
106
+ if (classes[j].indexOf(word) === 0) {
107
+ hasTwToken = true;
108
+ break;
109
+ }
110
+ }
111
+ }
112
+ if (!hasTwToken) return { suggestions: [] };
113
+ }
114
+ }
115
+
116
+ var range = {
117
+ startLineNumber: position.lineNumber,
118
+ endLineNumber: position.lineNumber,
119
+ startColumn: position.column - word.length,
120
+ endColumn: position.column,
121
+ };
122
+
123
+ var classes = window.__fcTwClasses || [];
124
+ var suggestions = [];
125
+ for (var i = 0; i < classes.length; i++) {
126
+ var cls = classes[i];
127
+ if (!word.length || cls.indexOf(word) === 0) {
128
+ suggestions.push({
129
+ label: cls,
130
+ kind: monaco.languages.CompletionItemKind.Value,
131
+ insertText: cls,
132
+ range: range,
133
+ sortText: cls.padStart(60, "0"),
134
+ });
135
+ }
136
+ }
137
+ console.log(PREFIX, "TW completion: ctx=" + (isClassName ? "className" : "string") + " word='" + word + "', matched=" + suggestions.length + "/" + classes.length);
138
+ return { suggestions: suggestions };
139
+ },
140
+ });
141
+
142
+ // JSX/HTML tag completion — type tag name, Tab → <tag>|</tag>
143
+ console.log(PREFIX, "registering JSX tag completion provider");
144
+ var HTML_TAGS = [
145
+ "div","span","p","a","button","input","img","form","label",
146
+ "h1","h2","h3","h4","h5","h6",
147
+ "ul","ol","li","dl","dt","dd",
148
+ "table","thead","tbody","tfoot","tr","th","td",
149
+ "section","article","aside","header","footer","nav","main",
150
+ "strong","em","b","i","u","s","small","mark","code","pre","blockquote",
151
+ "br","hr","wbr",
152
+ "select","option","optgroup","textarea",
153
+ "fieldset","legend","details","summary","dialog",
154
+ "canvas","video","audio","source","picture","figure","figcaption",
155
+ "svg","path","circle","rect","line","g","defs","use","text",
156
+ ];
157
+ var VOID_TAGS = new Set(["br","hr","img","input","wbr","source"]);
158
+
159
+ monaco.languages.registerCompletionItemProvider("javascript", {
160
+ triggerCharacters: ["<"],
161
+ provideCompletionItems: function (model, position) {
162
+ var line = model.getLineContent(position.lineNumber);
163
+ var before = line.substring(0, position.column - 1);
164
+
165
+ // After "<" — suggest tags
166
+ var afterBracket = before.match(/<([a-zA-Z]*)$/);
167
+ // Bare word at line start or after whitespace/{ — Emmet-like (lower or upper)
168
+ var bareWord = !afterBracket && before.match(/(?:^|[\s{(,])([a-zA-Z][a-zA-Z0-9]*)$/);
169
+
170
+ if (!afterBracket && !bareWord) return { suggestions: [] };
171
+
172
+ var typed = (afterBracket ? afterBracket[1] : bareWord[1]) || "";
173
+ var replaceStart = position.column - typed.length - (afterBracket ? 1 : 0);
174
+
175
+ var range = {
176
+ startLineNumber: position.lineNumber,
177
+ endLineNumber: position.lineNumber,
178
+ startColumn: replaceStart,
179
+ endColumn: position.column,
180
+ };
181
+
182
+ var suggestions = [];
183
+ var seen = new Set();
184
+
185
+ // HTML tags
186
+ for (var i = 0; i < HTML_TAGS.length; i++) {
187
+ var tag = HTML_TAGS[i];
188
+ if (typed && tag.indexOf(typed) !== 0) continue;
189
+ seen.add(tag);
190
+ if (VOID_TAGS.has(tag)) {
191
+ suggestions.push({
192
+ label: tag,
193
+ kind: monaco.languages.CompletionItemKind.Property,
194
+ insertText: "<" + tag + " $0/>",
195
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
196
+ range: range,
197
+ detail: "<" + tag + " />",
198
+ sortText: "0" + tag,
199
+ });
200
+ } else {
201
+ suggestions.push({
202
+ label: tag,
203
+ kind: monaco.languages.CompletionItemKind.Property,
204
+ insertText: "<" + tag + ">$0</" + tag + ">",
205
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
206
+ range: range,
207
+ detail: "<" + tag + ">…</" + tag + ">",
208
+ sortText: "0" + tag + "a",
209
+ });
210
+ suggestions.push({
211
+ label: tag,
212
+ kind: monaco.languages.CompletionItemKind.Property,
213
+ insertText: "<" + tag + " $0/>",
214
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
215
+ range: range,
216
+ detail: "<" + tag + " />",
217
+ sortText: "0" + tag + "b",
218
+ });
219
+ }
220
+ }
221
+
222
+ // Custom components from registry (PascalCase)
223
+ var upperMatch = afterBracket && before.match(/<([A-Z][a-zA-Z0-9]*)$/);
224
+ var bareUpper = !afterBracket && before.match(/(?:^|[\s{(,])([A-Z][a-zA-Z0-9]*)$/);
225
+ var compTyped = upperMatch ? upperMatch[1] : (bareUpper ? bareUpper[1] : "");
226
+
227
+ if (compTyped || (!typed && afterBracket)) {
228
+ // Fetch component names from registry (cached on window)
229
+ var reg = window.__fcComponentNames || [];
230
+ for (var c = 0; c < reg.length; c++) {
231
+ var name = reg[c];
232
+ if (seen.has(name)) continue;
233
+ if (compTyped && name.indexOf(compTyped) !== 0) continue;
234
+ if (!compTyped && typed && name.toLowerCase().indexOf(typed) !== 0) continue;
235
+ seen.add(name);
236
+ suggestions.push({
237
+ label: name,
238
+ kind: monaco.languages.CompletionItemKind.Class,
239
+ insertText: "<" + name + ">$0</" + name + ">",
240
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
241
+ range: range,
242
+ detail: "<" + name + ">…</" + name + ">",
243
+ sortText: "0" + name + "a",
244
+ });
245
+ suggestions.push({
246
+ label: name,
247
+ kind: monaco.languages.CompletionItemKind.Class,
248
+ insertText: "<" + name + " $0/>",
249
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
250
+ range: range,
251
+ detail: "<" + name + " />",
252
+ sortText: "0" + name + "b",
253
+ });
254
+ }
255
+ // Any PascalCase word not in registry — offer generic JSX wrap
256
+ if (compTyped && !seen.has(compTyped)) {
257
+ suggestions.push({
258
+ label: compTyped,
259
+ kind: monaco.languages.CompletionItemKind.Class,
260
+ insertText: "<" + compTyped + ">$0</" + compTyped + ">",
261
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
262
+ range: range,
263
+ detail: "<" + compTyped + ">…</" + compTyped + ">",
264
+ sortText: "0" + compTyped + "a",
265
+ });
266
+ suggestions.push({
267
+ label: compTyped,
268
+ kind: monaco.languages.CompletionItemKind.Class,
269
+ insertText: "<" + compTyped + " $0/>",
270
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
271
+ range: range,
272
+ detail: "<" + compTyped + " />",
273
+ sortText: "0" + compTyped + "b",
274
+ });
275
+ }
276
+ }
277
+
278
+ console.log(PREFIX, "JSX tag completion: typed='" + typed + "', suggestions=" + suggestions.length);
279
+ return { suggestions: suggestions };
280
+ },
281
+ });
282
+
283
+ // Collapse <tag>|</tag> → <tag /> when "/" typed inside empty tag pair
284
+ console.log(PREFIX, "registering JSX self-close collapse handler");
285
+ window.__fcAttachSelfClose = function (editor) {
286
+ editor.onDidChangeModelContent(function (e) {
287
+ if (e.changes.length !== 1) return;
288
+ var ch = e.changes[0];
289
+ if (ch.text !== "/") return;
290
+ var model = editor.getModel();
291
+ if (!model) return;
292
+ var lineNum = ch.range.startLineNumber;
293
+ var line = model.getLineContent(lineNum);
294
+ // Pattern 1: <tag>/</tag> (cursor was between > and </)
295
+ // Pattern 2: <tag/></tag> (cursor was before > in opening tag)
296
+ var m = line.match(/^(.*)<([a-zA-Z][a-zA-Z0-9]*)>\/<\/\2>(.*)$/);
297
+ var matchStr, tag, prefix;
298
+ if (m) {
299
+ prefix = m[1]; tag = m[2];
300
+ matchStr = "<" + tag + ">/</" + tag + ">";
301
+ } else {
302
+ m = line.match(/^(.*)<([a-zA-Z][a-zA-Z0-9]*)\/><\/\2>(.*)$/);
303
+ if (!m) return;
304
+ prefix = m[1]; tag = m[2];
305
+ matchStr = "<" + tag + "/></" + tag + ">";
306
+ }
307
+ var startCol = prefix.length + 1;
308
+ var endCol = startCol + matchStr.length;
309
+ var replacement = "<" + tag + " />";
310
+ var cursorCol = startCol + replacement.length - 2;
311
+ setTimeout(function () {
312
+ editor.executeEdits("self-close-collapse", [{
313
+ range: {
314
+ startLineNumber: lineNum,
315
+ endLineNumber: lineNum,
316
+ startColumn: startCol,
317
+ endColumn: endCol,
318
+ },
319
+ text: replacement,
320
+ }]);
321
+ editor.setPosition({ lineNumber: lineNum, column: cursorCol });
322
+ }, 0);
323
+ });
324
+ console.log(PREFIX, "self-close collapse attached to editor");
325
+ };
326
+
327
+ // Fetch component names for tag completion
328
+ function refreshComponentNames() {
329
+ $.getJSON("portal-react/registry", function (reg) {
330
+ window.__fcComponentNames = Object.keys(reg);
331
+ console.log(PREFIX, "component names loaded:", window.__fcComponentNames);
332
+ }).fail(function () {
333
+ window.__fcComponentNames = [];
334
+ });
335
+ }
336
+ refreshComponentNames();
337
+
338
+ // Marker listener for debugging
339
+ monaco.editor.onDidChangeMarkers(function (uris) {
340
+ uris.forEach(function (uri) {
341
+ var markers = monaco.editor.getModelMarkers({ resource: uri });
342
+ if (markers.length > 0) {
343
+ console.group(PREFIX + " MARKERS on " + uri.toString());
344
+ markers.forEach(function (m) {
345
+ console.log(
346
+ " code=" + (m.code || "?") +
347
+ " severity=" + m.severity +
348
+ " source=" + (m.source || "?") +
349
+ " msg=" + m.message +
350
+ " [L" + m.startLineNumber + ":" + m.startColumn + "]"
351
+ );
352
+ });
353
+ console.groupEnd();
354
+ }
355
+ });
356
+ });
357
+
358
+ console.log(PREFIX, "one-time setup complete");
359
+ }
360
+
361
+ function applyCompilerAndDiag() {
362
+ var jsDef = monaco.typescript.javascriptDefaults;
363
+ var compilerOpts = {
364
+ jsx: monaco.typescript.JsxEmit.React,
365
+ target: monaco.typescript.ScriptTarget.ESNext,
366
+ module: monaco.typescript.ModuleKind.ESNext,
367
+ allowNonTsExtensions: true,
368
+ allowJs: true,
369
+ };
370
+ console.log(PREFIX, "setCompilerOptions:", JSON.stringify(compilerOpts));
371
+ jsDef.setCompilerOptions(compilerOpts);
372
+
373
+ var diagOpts = {
374
+ noSemanticValidation: true,
375
+ noSyntaxValidation: false,
376
+ noSuggestionDiagnostics: true,
377
+ diagnosticCodesToIgnore: [17004],
378
+ };
379
+ console.log(PREFIX, "setDiagnosticsOptions:", JSON.stringify(diagOpts));
380
+ jsDef.setDiagnosticsOptions(diagOpts);
381
+
382
+ console.log(PREFIX, "readback compilerOptions:", JSON.stringify(jsDef.getCompilerOptions()));
383
+ console.log(PREFIX, "readback diagnosticsOptions:", JSON.stringify(jsDef.getDiagnosticsOptions()));
384
+ }
385
+
386
+ // Exported for oneditprepare to call before model creation
387
+ window.__fcApplyJsxDefaults = function () {
388
+ ensureJsxSetup();
389
+ };
390
+
391
+ window.__fcLoadMonaco = function (cb) {
392
+ console.log(PREFIX, "loadMonaco called, monaco exists:", !!window.monaco);
393
+ if (window.monaco) {
394
+ console.log(PREFIX, "Monaco already loaded (by Node-RED?), running setup inline");
395
+ ensureJsxSetup();
396
+ cb();
397
+ return;
398
+ }
399
+ var s = document.createElement("script");
400
+ s.src = "portal-react/vs/loader.js";
401
+ s.onload = function () {
402
+ console.log(PREFIX, "loader.js loaded, configuring require paths");
403
+ require.config({ paths: { vs: "portal-react/vs" } });
404
+ require(["vs/editor/editor.main"], function () {
405
+ console.log(PREFIX, "editor.main loaded");
406
+ ensureJsxSetup();
407
+ console.log(PREFIX, "calling cb()");
408
+ cb();
409
+ });
410
+ };
411
+ s.onerror = function () {
412
+ console.error(PREFIX, "FAILED to load loader.js");
413
+ cb(true);
414
+ };
415
+ document.head.appendChild(s);
416
+ };
417
+ })();
418
+ </script>
419
+
420
+ <!-- ============================================================
421
+ fc-portal-component – canvas node (shared component)
422
+ ============================================================ -->
423
+ <script type="text/html" data-template-name="fc-portal-component">
424
+ <div class="form-row">
425
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
426
+ <input type="text" id="node-input-name" placeholder="Label on canvas" />
427
+ </div>
428
+ <div class="form-row">
429
+ <label for="node-input-compName"><i class="fa fa-cube"></i> JSX Tag</label>
430
+ <input type="text" id="node-input-compName" placeholder="MyComponent" />
431
+ </div>
432
+ <div class="form-row" style="display:flex;gap:8px;">
433
+ <div style="flex:1">
434
+ <label><i class="fa fa-sign-in"></i> Input fields</label>
435
+ <input
436
+ type="text"
437
+ id="node-input-compInputs"
438
+ placeholder="payload,topic"
439
+ style="width:100%"
440
+ />
441
+ </div>
442
+ <div style="flex:1">
443
+ <label><i class="fa fa-sign-out"></i> Output fields</label>
444
+ <input
445
+ type="text"
446
+ id="node-input-compOutputs"
447
+ placeholder="payload,topic"
448
+ style="width:100%"
449
+ />
450
+ </div>
451
+ </div>
452
+ <div class="form-row" style="margin-bottom:4px;">
453
+ <label><i class="fa fa-code"></i> JSX Code</label>
454
+ </div>
455
+ <div class="form-row node-text-editor-row">
456
+ <div
457
+ id="fcc-editor-wrap"
458
+ style="width:100%;height:350px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
459
+ >
460
+ <div id="fcc-monaco" style="width:100%;height:100%;"></div>
461
+ <textarea
462
+ id="fcc-fallback"
463
+ 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;"
464
+ ></textarea>
465
+ </div>
466
+ </div>
467
+ <input type="hidden" id="node-input-compCode" />
468
+ </script>
469
+
470
+ <script type="text/javascript">
471
+ (function () {
472
+ var compEditorInstance = null;
473
+
474
+ var COMP_STARTER = [
475
+ "function StatusCard({ label, value, unit }) {",
476
+ " return (",
477
+ ' <div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-6">',
478
+ ' <div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{label}</div>',
479
+ ' <div className="text-3xl font-bold text-white">',
480
+ ' {value}<span className="text-lg text-zinc-400 ml-1">{unit}</span>',
481
+ " </div>",
482
+ " </div>",
483
+ " );",
484
+ "}",
485
+ ].join("\n");
486
+
487
+ RED.nodes.registerType("fc-portal-component", {
488
+ category: "fromcubes",
489
+ color: "#a8d8ea",
490
+ defaults: {
491
+ name: { value: "" },
492
+ compName: { value: "StatusCard", required: true },
493
+ compCode: { value: COMP_STARTER },
494
+ compInputs: { value: "label,value,unit" },
495
+ compOutputs: { value: "" },
496
+ },
497
+ inputs: 0,
498
+ outputs: 0,
499
+ icon: "font-awesome/fa-cube",
500
+ paletteLabel: "component",
501
+ label: function () {
502
+ return this.name || this.compName || "component";
503
+ },
504
+
505
+ oneditprepare: function () {
506
+ var node = this;
507
+ var code = node.compCode || COMP_STARTER;
508
+ console.log("[FC-Monaco] COMP oneditprepare, node.id=" + node.id);
509
+
510
+ if (!window.__fcTwClasses) {
511
+ console.log("[FC-Monaco] loading tw-classes...");
512
+ $.getJSON("portal-react/tw-classes", function (classes) {
513
+ console.log("[FC-Monaco] tw-classes loaded, count=" + classes.length);
514
+ window.__fcTwClasses = classes;
515
+ }).fail(function (xhr) {
516
+ console.error("[FC-Monaco] tw-classes FAILED:", xhr.status, xhr.statusText);
517
+ });
518
+ }
519
+
520
+ window.__fcLoadMonaco(function (failed) {
521
+ if (failed) {
522
+ console.error("[FC-Monaco] COMP: Monaco load failed, using fallback textarea");
523
+ $("#fcc-monaco").hide();
524
+ $("#fcc-fallback").show().val(code);
525
+ return;
526
+ }
527
+
528
+ console.log("[FC-Monaco] COMP: applying JSX defaults before model creation");
529
+ window.__fcApplyJsxDefaults();
530
+
531
+ var compUri = monaco.Uri.parse("file:///fc-comp-" + node.id + ".jsx");
532
+ console.log("[FC-Monaco] COMP: model URI=" + compUri.toString());
533
+ var existingModel = monaco.editor.getModel(compUri);
534
+ if (existingModel) {
535
+ console.log("[FC-Monaco] COMP: disposing existing model");
536
+ existingModel.dispose();
537
+ }
538
+ var compModel = monaco.editor.createModel(code, "javascript", compUri);
539
+ console.log("[FC-Monaco] COMP: model created, language=" + compModel.getLanguageId());
540
+
541
+ compEditorInstance = monaco.editor.create(
542
+ document.getElementById("fcc-monaco"),
543
+ Object.assign({ model: compModel }, window.__fcEditorOpts),
544
+ );
545
+ console.log("[FC-Monaco] COMP: editor created");
546
+
547
+ // Log markers after a short delay (diagnostics are async)
548
+ setTimeout(function () {
549
+ var markers = monaco.editor.getModelMarkers({ resource: compUri });
550
+ console.log("[FC-Monaco] COMP: markers after 500ms, count=" + markers.length);
551
+ markers.forEach(function (m) {
552
+ console.log("[FC-Monaco] COMP marker: code=" + m.code + " msg=" + m.message);
553
+ });
554
+ // Also log current compiler options state
555
+ console.log("[FC-Monaco] COMP: current compilerOptions:", JSON.stringify(monaco.typescript.javascriptDefaults.getCompilerOptions()));
556
+ console.log("[FC-Monaco] COMP: current diagnosticsOptions:", JSON.stringify(monaco.typescript.javascriptDefaults.getDiagnosticsOptions()));
557
+ }, 500);
558
+ });
559
+ },
560
+
561
+ oneditsave: function () {
562
+ console.log("[FC-Monaco] COMP oneditsave");
563
+ var code = compEditorInstance
564
+ ? compEditorInstance.getValue()
565
+ : $("#fcc-fallback").val();
566
+ $("#node-input-compCode").val(code);
567
+ this.compCode = code;
568
+ if (compEditorInstance) {
569
+ compEditorInstance.getModel().dispose();
570
+ compEditorInstance.dispose();
571
+ compEditorInstance = null;
572
+ console.log("[FC-Monaco] COMP: editor + model disposed");
573
+ }
574
+ },
575
+
576
+ oneditcancel: function () {
577
+ console.log("[FC-Monaco] COMP oneditcancel");
578
+ if (compEditorInstance) {
579
+ compEditorInstance.getModel().dispose();
580
+ compEditorInstance.dispose();
581
+ compEditorInstance = null;
582
+ console.log("[FC-Monaco] COMP: editor + model disposed");
583
+ }
584
+ },
585
+
586
+ oneditresize: function (size) {
587
+ var h = size.height - 180;
588
+ if (h < 150) h = 150;
589
+ $("#fcc-editor-wrap").css("height", h + "px");
590
+ if (compEditorInstance) compEditorInstance.layout();
591
+ },
592
+ });
593
+ })();
594
+ </script>
595
+
596
+ <script type="text/html" data-help-name="fc-portal-component">
597
+ <p>
598
+ Defines a shared JSX component available to all
599
+ <strong>portal-react</strong> nodes.
600
+ </p>
601
+ <h3>Properties</h3>
602
+ <dl>
603
+ <dt>JSX Tag</dt>
604
+ <dd>
605
+ The name used as a JSX tag in portal-react pages (e.g.
606
+ <code>&lt;MyComponent /&gt;</code>).
607
+ </dd>
608
+ <dt>Input fields</dt>
609
+ <dd>Comma-separated list of expected input props (documentation only).</dd>
610
+ <dt>Output fields</dt>
611
+ <dd>Comma-separated list of output props (documentation only).</dd>
612
+ <dt>JSX Code</dt>
613
+ <dd>
614
+ The component function. Use <code>useNodeRed()</code> for data binding.
615
+ </dd>
616
+ </dl>
617
+ <h3>Usage</h3>
618
+ <p>
619
+ Place this node on the canvas, set the JSX tag name and code, then deploy.
620
+ All <strong>portal-react</strong> nodes will automatically have access to
621
+ this component.
622
+ </p>
623
+ </script>
624
+
625
+ <!-- ============================================================
626
+ portal-react – main node
627
+ ============================================================ -->
628
+ <script type="text/html" data-template-name="portal-react">
629
+ <!-- Tabs -->
630
+ <div class="form-row">
631
+ <ul id="fc-tabs" style="min-width:600px;margin-bottom:0;"></ul>
632
+ </div>
633
+ <div id="fc-tabs-content">
634
+ <!-- ── Tab: Properties ── -->
635
+ <div id="fc-tab-props" class="fc-tab-pane" style="display:none;">
636
+ <div class="form-row">
637
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
638
+ <input type="text" id="node-input-name" placeholder="My Portal" />
639
+ </div>
640
+ <div class="form-row">
641
+ <label for="node-input-endpoint"
642
+ ><i class="fa fa-globe"></i> Endpoint</label
643
+ >
644
+ <input type="text" id="node-input-endpoint" placeholder="/portal" />
645
+ <div
646
+ style="font-size:11px;opacity:.5;margin-top:2px;margin-left:105px;"
647
+ id="fc-url-hint"
648
+ ></div>
649
+ </div>
650
+ <div class="form-row">
651
+ <label for="node-input-pageTitle"
652
+ ><i class="fa fa-file-text-o"></i> Title</label
653
+ >
654
+ <input type="text" id="node-input-pageTitle" placeholder="Portal" />
655
+ </div>
656
+ </div>
657
+
658
+ <!-- ── Tab: JSX ── -->
659
+ <div id="fc-tab-jsx" class="fc-tab-pane">
660
+ <div class="form-row" style="margin-bottom:0;">
661
+ <div style="font-size:11px;opacity:.6;margin-bottom:4px;">
662
+ <code>useNodeRed()</code> &rarr;
663
+ <code>{ data, send }</code> &nbsp;|&nbsp; Components from
664
+ <code>fc-portal-component</code> nodes auto-imported &nbsp;|&nbsp;
665
+ Must export <code>&lt;App /&gt;</code> &nbsp;|&nbsp;
666
+ <strong>Transpiled server-side at deploy</strong>
667
+ </div>
668
+ </div>
669
+ <div class="form-row node-text-editor-row">
670
+ <div
671
+ id="fc-editor-wrap"
672
+ style="width:100%;height:420px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
673
+ >
674
+ <div id="fc-monaco" style="width:100%;height:100%;"></div>
675
+ <textarea
676
+ id="fc-fallback"
677
+ 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;"
678
+ ></textarea>
679
+ </div>
680
+ </div>
681
+ <div class="form-row" style="display:flex;gap:8px;margin-top:4px;">
682
+ <button type="button" class="red-ui-button" id="fc-btn-preview">
683
+ <i class="fa fa-eye"></i> Preview
684
+ </button>
685
+ <button type="button" class="red-ui-button" id="fc-btn-components">
686
+ <i class="fa fa-cube"></i> Components
687
+ </button>
688
+ <button type="button" class="red-ui-button" id="fc-btn-starter">
689
+ <i class="fa fa-magic"></i> Starter
690
+ </button>
691
+ </div>
692
+ </div>
693
+
694
+ <!-- ── Tab: Head HTML ── -->
695
+ <div id="fc-tab-head" class="fc-tab-pane" style="display:none;">
696
+ <div class="form-row">
697
+ <div style="font-size:11px;opacity:.6;margin-bottom:6px;">
698
+ Extra tags injected into <code>&lt;head&gt;</code>. CDN links, fonts,
699
+ stylesheets, meta tags.
700
+ </div>
701
+ <div
702
+ id="fc-head-wrap"
703
+ style="width:100%;height:420px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
704
+ >
705
+ <div id="fc-head-monaco" style="width:100%;height:100%;"></div>
706
+ <textarea
707
+ id="fc-head-fallback"
708
+ 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;"
709
+ placeholder='<link rel="stylesheet" href="...">'
710
+ ></textarea>
711
+ </div>
712
+ </div>
713
+ </div>
714
+ </div>
715
+
716
+ <input type="hidden" id="node-input-componentCode" />
717
+ <input type="hidden" id="node-input-customHead" />
718
+ </script>
719
+
720
+ <script type="text/javascript">
721
+ (function () {
722
+ var editorInstance = null;
723
+ var headEditorInstance = null;
724
+
725
+ var STARTER = [
726
+ "// useNodeRed() \u2192 { data, send }",
727
+ "// data = last msg.payload from input wire",
728
+ "// send(payload, topic?) = push msg to output wire",
729
+ "// Components from fc-portal-component nodes are available by name.",
730
+ "",
731
+ "function App() {",
732
+ " const { data, send } = useNodeRed();",
733
+ " const d = data || {};",
734
+ "",
735
+ " return (",
736
+ ' <div className="min-h-screen bg-zinc-950 p-8">',
737
+ ' <h1 className="text-2xl font-light text-cyan-400 mb-6">Portal</h1>',
738
+ ' <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">',
739
+ ' <StatusCard label="Value" value={d.value ?? "—"} unit="" />',
740
+ " </div>",
741
+ " <button",
742
+ ' className="mt-6 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-500 transition-colors"',
743
+ " onClick={() => send({ clicked: true })}",
744
+ " >",
745
+ " Send to Node-RED",
746
+ " </button>",
747
+ " </div>",
748
+ " );",
749
+ "}",
750
+ ].join("\n");
751
+
752
+ RED.nodes.registerType("portal-react", {
753
+ category: "fromcubes",
754
+ color: "#61dafb",
755
+ defaults: {
756
+ name: { value: "" },
757
+ endpoint: { value: "/portal", required: true },
758
+ pageTitle: { value: "Portal" },
759
+ componentCode: { value: STARTER },
760
+ customHead: { value: "" },
761
+ },
762
+ inputs: 1,
763
+ outputs: 1,
764
+ icon: "font-awesome/fa-desktop",
765
+ paletteLabel: "portal react",
766
+ label: function () {
767
+ return this.name || this.endpoint || "portal react";
768
+ },
769
+
770
+ oneditprepare: function () {
771
+ var node = this;
772
+ var code = node.componentCode || STARTER;
773
+ var headCode = node.customHead || "";
774
+ console.log("[FC-Monaco] PORTAL oneditprepare, node.id=" + node.id);
775
+
776
+ if (!window.__fcTwClasses) {
777
+ console.log("[FC-Monaco] loading tw-classes...");
778
+ $.getJSON("portal-react/tw-classes", function (classes) {
779
+ console.log("[FC-Monaco] tw-classes loaded, count=" + classes.length);
780
+ window.__fcTwClasses = classes;
781
+ }).fail(function (xhr) {
782
+ console.error("[FC-Monaco] tw-classes FAILED:", xhr.status, xhr.statusText);
783
+ });
784
+ }
785
+
786
+ // Tabs
787
+ var fcTabs = RED.tabs.create({
788
+ id: "fc-tabs",
789
+ onchange: function (tab) {
790
+ $(".fc-tab-pane").hide();
791
+ $("#" + tab.id).show();
792
+ setTimeout(function () {
793
+ if (editorInstance) editorInstance.layout();
794
+ if (headEditorInstance) headEditorInstance.layout();
795
+ }, 0);
796
+ },
797
+ });
798
+ fcTabs.addTab({ id: "fc-tab-jsx", label: "JSX" });
799
+ fcTabs.addTab({ id: "fc-tab-props", label: "Properties" });
800
+ fcTabs.addTab({ id: "fc-tab-head", label: "Head HTML" });
801
+ fcTabs.activateTab("fc-tab-jsx");
802
+
803
+ // URL hint
804
+ function updateHint() {
805
+ var ep = $("#node-input-endpoint").val() || "/portal";
806
+ $("#fc-url-hint").text("Page served at: http://<host>:1880" + ep);
807
+ }
808
+ $("#node-input-endpoint").on("input", updateHint);
809
+ updateHint();
810
+
811
+ // Monaco
812
+ window.__fcLoadMonaco(function (failed) {
813
+ if (failed) {
814
+ console.error("[FC-Monaco] PORTAL: Monaco load failed, using fallback textarea");
815
+ $("#fc-monaco").hide();
816
+ $("#fc-fallback").show().val(code);
817
+ $("#fc-head-monaco").hide();
818
+ $("#fc-head-fallback").show().val(headCode);
819
+ return;
820
+ }
821
+
822
+ console.log("[FC-Monaco] PORTAL: applying JSX defaults before model creation");
823
+ window.__fcApplyJsxDefaults();
824
+ var opts = window.__fcEditorOpts;
825
+
826
+ var jsxUri = monaco.Uri.parse("file:///fc-portal-" + node.id + ".jsx");
827
+ console.log("[FC-Monaco] PORTAL: JSX model URI=" + jsxUri.toString());
828
+ var existingJsx = monaco.editor.getModel(jsxUri);
829
+ if (existingJsx) {
830
+ console.log("[FC-Monaco] PORTAL: disposing existing JSX model");
831
+ existingJsx.dispose();
832
+ }
833
+ var jsxModel = monaco.editor.createModel(code, "javascript", jsxUri);
834
+ console.log("[FC-Monaco] PORTAL: JSX model created, language=" + jsxModel.getLanguageId());
835
+ editorInstance = monaco.editor.create(
836
+ document.getElementById("fc-monaco"),
837
+ Object.assign({ model: jsxModel }, opts),
838
+ );
839
+ console.log("[FC-Monaco] PORTAL: JSX editor created");
840
+ if (window.__fcAttachSelfClose) window.__fcAttachSelfClose(editorInstance);
841
+
842
+ var headUri = monaco.Uri.parse("file:///fc-head-" + node.id + ".html");
843
+ var existingHead = monaco.editor.getModel(headUri);
844
+ if (existingHead) existingHead.dispose();
845
+ var headModel = monaco.editor.createModel(headCode, "html", headUri);
846
+ headEditorInstance = monaco.editor.create(
847
+ document.getElementById("fc-head-monaco"),
848
+ Object.assign({ model: headModel }, opts),
849
+ );
850
+ console.log("[FC-Monaco] PORTAL: Head editor created");
851
+
852
+ // Log markers after a short delay (diagnostics are async)
853
+ setTimeout(function () {
854
+ var markers = monaco.editor.getModelMarkers({ resource: jsxUri });
855
+ console.log("[FC-Monaco] PORTAL: markers after 500ms, count=" + markers.length);
856
+ markers.forEach(function (m) {
857
+ console.log("[FC-Monaco] PORTAL marker: code=" + m.code + " severity=" + m.severity + " msg=" + m.message);
858
+ });
859
+ console.log("[FC-Monaco] PORTAL: current compilerOptions:", JSON.stringify(monaco.typescript.javascriptDefaults.getCompilerOptions()));
860
+ console.log("[FC-Monaco] PORTAL: current diagnosticsOptions:", JSON.stringify(monaco.typescript.javascriptDefaults.getDiagnosticsOptions()));
861
+ }, 500);
862
+ });
863
+
864
+ // Buttons
865
+ $("#fc-btn-starter").on("click", function () {
866
+ if (editorInstance) editorInstance.setValue(STARTER);
867
+ else $("#fc-fallback").val(STARTER);
868
+ });
869
+
870
+ $("#fc-btn-preview").on("click", function () {
871
+ var ep = $("#node-input-endpoint").val();
872
+ if (ep) window.open(ep, "_blank");
873
+ });
874
+
875
+ $("#fc-btn-components").on("click", function () {
876
+ $.getJSON("portal-react/registry", function (reg) {
877
+ var names = Object.keys(reg);
878
+ if (!names.length) {
879
+ RED.notify("No component nodes on canvas.", "warning");
880
+ return;
881
+ }
882
+ var html = "<p>Click to insert:</p>";
883
+ names.forEach(function (n) {
884
+ var c = reg[n];
885
+ html +=
886
+ '<div style="padding:4px 0;border-bottom:1px solid rgba(128,128,128,.2)">' +
887
+ '<button class="red-ui-button fc-lib-ins" data-name="' +
888
+ n +
889
+ '">' +
890
+ n +
891
+ "</button>" +
892
+ ' <span style="opacity:.5;font-size:11px">in:[' +
893
+ (c.inputs || []).join(",") +
894
+ "] out:[" +
895
+ (c.outputs || []).join(",") +
896
+ "]</span></div>";
897
+ });
898
+ $("<div></div>")
899
+ .html(html)
900
+ .dialog({
901
+ title: "Available Components",
902
+ modal: true,
903
+ width: 400,
904
+ buttons: [
905
+ {
906
+ text: "Close",
907
+ click: function () {
908
+ $(this).dialog("close");
909
+ },
910
+ },
911
+ ],
912
+ close: function () {
913
+ $(this).remove();
914
+ },
915
+ });
916
+ $(document).on("click", ".fc-lib-ins", function () {
917
+ var tag = "<" + $(this).data("name") + " />";
918
+ if (editorInstance) {
919
+ editorInstance.trigger("keyboard", "type", { text: tag });
920
+ editorInstance.focus();
921
+ } else {
922
+ var ta = $("#fc-fallback")[0];
923
+ var p = ta.selectionStart,
924
+ v = ta.value;
925
+ ta.value = v.slice(0, p) + tag + v.slice(p);
926
+ }
927
+ });
928
+ });
929
+ });
930
+ },
931
+
932
+ oneditsave: function () {
933
+ console.log("[FC-Monaco] PORTAL oneditsave");
934
+ var code = editorInstance
935
+ ? editorInstance.getValue()
936
+ : $("#fc-fallback").val();
937
+ $("#node-input-componentCode").val(code);
938
+ this.componentCode = code;
939
+
940
+ var head = headEditorInstance
941
+ ? headEditorInstance.getValue()
942
+ : $("#fc-head-fallback").val();
943
+ $("#node-input-customHead").val(head);
944
+ this.customHead = head;
945
+
946
+ if (editorInstance) {
947
+ editorInstance.getModel().dispose();
948
+ editorInstance.dispose();
949
+ editorInstance = null;
950
+ console.log("[FC-Monaco] PORTAL: JSX editor + model disposed");
951
+ }
952
+ if (headEditorInstance) {
953
+ headEditorInstance.getModel().dispose();
954
+ headEditorInstance.dispose();
955
+ headEditorInstance = null;
956
+ console.log("[FC-Monaco] PORTAL: Head editor + model disposed");
957
+ }
958
+ },
959
+
960
+ oneditcancel: function () {
961
+ console.log("[FC-Monaco] PORTAL oneditcancel");
962
+ if (editorInstance) {
963
+ editorInstance.getModel().dispose();
964
+ editorInstance.dispose();
965
+ editorInstance = null;
966
+ console.log("[FC-Monaco] PORTAL: JSX editor + model disposed");
967
+ }
968
+ if (headEditorInstance) {
969
+ headEditorInstance.getModel().dispose();
970
+ headEditorInstance.dispose();
971
+ headEditorInstance = null;
972
+ console.log("[FC-Monaco] PORTAL: Head editor + model disposed");
973
+ }
974
+ },
975
+
976
+ oneditresize: function (size) {
977
+ var tabsH = $("#fc-tabs").outerHeight(true) || 0;
978
+ var rows = $(
979
+ "#dialog-form>div:not(#fc-tabs-content):not(:has(#fc-tabs))",
980
+ );
981
+ var h = size.height;
982
+ rows.each(function () {
983
+ h -= $(this).outerHeight(true);
984
+ });
985
+ h -= tabsH + 30;
986
+ if (h < 200) h = 200;
987
+ $("#fc-editor-wrap").css("height", h + "px");
988
+ $("#fc-head-wrap").css("height", h + "px");
989
+ if (editorInstance) editorInstance.layout();
990
+ if (headEditorInstance) headEditorInstance.layout();
991
+ },
992
+ });
993
+ })();
994
+ </script>
995
+
996
+ <!-- ============================================================
997
+ Help
998
+ ============================================================ -->
999
+ <script type="text/html" data-help-name="portal-react">
1000
+ <p>
1001
+ Renders a React application on a configurable HTTP endpoint with live
1002
+ WebSocket data binding.
1003
+ </p>
1004
+ <h3>Key difference from Dashboard 2.0</h3>
1005
+ <p>
1006
+ JSX is transpiled <strong>server-side at deploy time</strong> using Sucrase.
1007
+ The browser receives plain JS — no Babel, no runtime compilation. Page
1008
+ weight: ~45 KB (React production).
1009
+ </p>
1010
+
1011
+ <h3>Inputs</h3>
1012
+ <p>
1013
+ <code>msg.payload</code> is pushed to all connected clients via WebSocket.
1014
+ </p>
1015
+ <pre>
1016
+ const { data, send } = useNodeRed();
1017
+ // data = last msg.payload</pre
1018
+ >
1019
+
1020
+ <h3>Outputs</h3>
1021
+ <p>
1022
+ <code>send(payload, topic?)</code> emits a <code>msg</code> on the node's
1023
+ output wire.
1024
+ </p>
1025
+
1026
+ <h3>Deploy behavior</h3>
1027
+ <ul>
1028
+ <li>
1029
+ Each deploy re-transpiles JSX (cached by content hash — unchanged code is
1030
+ instant)
1031
+ </li>
1032
+ <li>
1033
+ Active WebSocket clients receive close code 1001 and auto-reconnect
1034
+ (exponential backoff)
1035
+ </li>
1036
+ <li>
1037
+ Stale HTTP routes and WS handlers are cleaned up before new ones register
1038
+ </li>
1039
+ <li>
1040
+ Transpile errors show in node status and as an error page on the endpoint
1041
+ </li>
1042
+ </ul>
1043
+
1044
+ <h3>Shared Components</h3>
1045
+ <p>
1046
+ Components defined in <strong>fc-portal-component</strong> nodes on the
1047
+ canvas are auto-injected into every portal-react page. Use them as JSX tags
1048
+ by their component name.
1049
+ </p>
1050
+
1051
+ <h3>Custom Head HTML</h3>
1052
+ <p>
1053
+ Inject CDN links, fonts, or extra stylesheets into
1054
+ <code>&lt;head&gt;</code>. Example:
1055
+ </p>
1056
+ <pre>
1057
+ &lt;link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet"&gt;</pre
1058
+ >
1059
+ </script>