@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.
- package/LICENSE +190 -0
- package/README.md +405 -2
- package/THIRD-PARTY-NOTICES.md +164 -0
- package/examples/D3 Poland Map.json +90 -0
- package/examples/Live Chart.json +101 -0
- package/examples/PixiJS Sprites.json +114 -0
- package/examples/Sensor Portal.json +93 -0
- package/examples/Shared Components.json +68 -0
- package/examples/Three.js Scene.json +118 -0
- package/examples/Utility Hooks.json +94 -0
- package/examples/WebGPU Shader.json +110 -0
- package/nodes/editor/ark-pixel.LICENSE.txt +96 -0
- package/nodes/editor/ark-pixel.woff2 +0 -0
- package/nodes/editor/file-tree.js +403 -0
- package/nodes/editor/format.js +182 -0
- package/nodes/editor/icons.svg +46 -0
- package/nodes/editor/mark.svg +5 -0
- package/nodes/editor/provider-settings.js +192 -0
- package/nodes/editor/workspace.css +653 -0
- package/nodes/editor/workspace.js +1565 -0
- package/nodes/icons/fromcubes-component.svg +27 -0
- package/nodes/icons/fromcubes-react.svg +25 -0
- package/nodes/icons/fromcubes-utility.svg +21 -0
- package/nodes/lib/admin-api.js +238 -0
- package/nodes/lib/admin-security.js +161 -0
- package/nodes/lib/api.js +29 -0
- package/nodes/lib/helpers.js +990 -0
- package/nodes/lib/hooks.js +145 -0
- package/nodes/lib/page-builder.js +499 -0
- package/nodes/lib/portal-page-route.js +131 -0
- package/nodes/lib/providers.js +536 -0
- package/nodes/lib/registry-nodes.js +331 -0
- package/nodes/lib/router.js +103 -0
- package/nodes/lib/ws-heartbeat.js +58 -0
- package/nodes/portal-react.html +2539 -0
- package/nodes/portal-react.js +1652 -0
- 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><MyComponent /></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 <div>{clamp(slow ?? 0, 0, 100)}</div>;
|
|
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><head></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, """) + '">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> → <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><App /></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><head></code>. This is raw trusted-author HTML; scripts and
|
|
2526
|
+
event handlers run in the public portal page. Example:
|
|
2527
|
+
</p>
|
|
2528
|
+
<pre>
|
|
2529
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet"></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
|
+
|