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