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