@aaqu/fromcubes-portal-react 0.1.0-alpha.20 → 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/helpers.js +34 -28
- package/nodes/lib/page-builder.js +175 -47
- package/nodes/portal-react.html +582 -210
- package/nodes/portal-react.js +603 -60
- package/package.json +5 -5
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,9 +559,12 @@
|
|
|
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;
|
|
565
|
+
// Monaco's TS parser produces noisy false positives on raw JSX
|
|
566
|
+
// (1109, 1005, 1128 etc.), so we silence its squiggles entirely.
|
|
567
|
+
noSyntaxValidation: true,
|
|
503
568
|
noSuggestionDiagnostics: true,
|
|
504
569
|
diagnosticCodesToIgnore: [17004],
|
|
505
570
|
};
|
|
@@ -534,7 +599,7 @@
|
|
|
534
599
|
cb();
|
|
535
600
|
return;
|
|
536
601
|
}
|
|
537
|
-
|
|
602
|
+
const s = document.createElement("script");
|
|
538
603
|
s.src = "portal-react/vs/loader.js";
|
|
539
604
|
s.onload = function () {
|
|
540
605
|
console.log(PREFIX, "loader.js loaded, configuring require paths");
|
|
@@ -587,9 +652,9 @@
|
|
|
587
652
|
|
|
588
653
|
<script type="text/javascript">
|
|
589
654
|
(function () {
|
|
590
|
-
|
|
655
|
+
let compEditorInstance = null;
|
|
591
656
|
|
|
592
|
-
|
|
657
|
+
const COMP_STARTER = [
|
|
593
658
|
"function StatusCard({ label, value, unit }) {",
|
|
594
659
|
" return (",
|
|
595
660
|
' <div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-6">',
|
|
@@ -619,8 +684,8 @@
|
|
|
619
684
|
},
|
|
620
685
|
|
|
621
686
|
oneditprepare: function () {
|
|
622
|
-
|
|
623
|
-
|
|
687
|
+
const node = this;
|
|
688
|
+
const code = node.compCode || COMP_STARTER;
|
|
624
689
|
console.log("[FC-Monaco] COMP oneditprepare, node.id=" + node.id);
|
|
625
690
|
|
|
626
691
|
if (!window.__fcTwClasses) {
|
|
@@ -654,14 +719,14 @@
|
|
|
654
719
|
);
|
|
655
720
|
window.__fcApplyJsxDefaults();
|
|
656
721
|
|
|
657
|
-
|
|
722
|
+
const compUri = monaco.Uri.parse("file:///fc-comp-" + node.id + ".jsx");
|
|
658
723
|
console.log("[FC-Monaco] COMP: model URI=" + compUri.toString());
|
|
659
|
-
|
|
724
|
+
const existingModel = monaco.editor.getModel(compUri);
|
|
660
725
|
if (existingModel) {
|
|
661
726
|
console.log("[FC-Monaco] COMP: disposing existing model");
|
|
662
727
|
existingModel.dispose();
|
|
663
728
|
}
|
|
664
|
-
|
|
729
|
+
const compModel = monaco.editor.createModel(
|
|
665
730
|
code,
|
|
666
731
|
"javascript",
|
|
667
732
|
compUri,
|
|
@@ -679,7 +744,7 @@
|
|
|
679
744
|
|
|
680
745
|
// Log markers after a short delay (diagnostics are async)
|
|
681
746
|
setTimeout(function () {
|
|
682
|
-
|
|
747
|
+
const markers = monaco.editor.getModelMarkers({ resource: compUri });
|
|
683
748
|
console.log(
|
|
684
749
|
"[FC-Monaco] COMP: markers after 500ms, count=" + markers.length,
|
|
685
750
|
);
|
|
@@ -707,7 +772,7 @@
|
|
|
707
772
|
|
|
708
773
|
oneditsave: function () {
|
|
709
774
|
console.log("[FC-Monaco] COMP oneditsave");
|
|
710
|
-
|
|
775
|
+
const code = compEditorInstance
|
|
711
776
|
? compEditorInstance.getValue()
|
|
712
777
|
: $("#fcc-fallback").val();
|
|
713
778
|
$("#node-input-compCode").val(code);
|
|
@@ -731,7 +796,7 @@
|
|
|
731
796
|
},
|
|
732
797
|
|
|
733
798
|
oneditresize: function (size) {
|
|
734
|
-
|
|
799
|
+
let h = size.height - 180;
|
|
735
800
|
if (h < 150) h = 150;
|
|
736
801
|
$("#fcc-editor-wrap").css("height", h + "px");
|
|
737
802
|
if (compEditorInstance) compEditorInstance.layout();
|
|
@@ -769,6 +834,198 @@
|
|
|
769
834
|
</p>
|
|
770
835
|
</script>
|
|
771
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
|
+
|
|
772
1029
|
<!-- ============================================================
|
|
773
1030
|
portal-react – main node
|
|
774
1031
|
============================================================ -->
|
|
@@ -799,6 +1056,9 @@
|
|
|
799
1056
|
<button type="button" class="red-ui-button" id="fc-btn-components">
|
|
800
1057
|
<i class="fa fa-cube"></i> Components
|
|
801
1058
|
</button>
|
|
1059
|
+
<button type="button" class="red-ui-button" id="fc-btn-utilities">
|
|
1060
|
+
<i class="fa fa-wrench"></i> Utilities
|
|
1061
|
+
</button>
|
|
802
1062
|
<span style="flex:1"></span>
|
|
803
1063
|
<button type="button" class="red-ui-button" id="fc-btn-preview">
|
|
804
1064
|
<i class="fa fa-eye"></i> Preview
|
|
@@ -906,10 +1166,10 @@
|
|
|
906
1166
|
|
|
907
1167
|
<script type="text/javascript">
|
|
908
1168
|
(function () {
|
|
909
|
-
|
|
910
|
-
|
|
1169
|
+
let editorInstance = null;
|
|
1170
|
+
let headEditorInstance = null;
|
|
911
1171
|
|
|
912
|
-
|
|
1172
|
+
const STARTER = [
|
|
913
1173
|
"// useNodeRed() \u2192 { data, send, portalClient }",
|
|
914
1174
|
"// data = last msg.payload from input wire",
|
|
915
1175
|
"// send(payload, topic?) = push msg to output wire",
|
|
@@ -946,15 +1206,15 @@
|
|
|
946
1206
|
required: true,
|
|
947
1207
|
validate: function (v) {
|
|
948
1208
|
if (typeof v !== "string") return false;
|
|
949
|
-
|
|
1209
|
+
const t = v.trim();
|
|
950
1210
|
if (t.length === 0) return false;
|
|
951
1211
|
if (/\s/.test(t)) return false;
|
|
952
1212
|
if (t.charAt(0) === "/" || t.charAt(t.length - 1) === "/") return false;
|
|
953
|
-
|
|
954
|
-
for (
|
|
955
|
-
|
|
1213
|
+
const segs = t.split("/");
|
|
1214
|
+
for (let i = 0; i < segs.length; i++) {
|
|
1215
|
+
const s = segs[i];
|
|
956
1216
|
if (!s || s === "." || s === "..") return false;
|
|
957
|
-
|
|
1217
|
+
const lower = s.toLowerCase();
|
|
958
1218
|
if (lower === "public" || lower === "_ws") return false;
|
|
959
1219
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(s)) return false;
|
|
960
1220
|
}
|
|
@@ -978,9 +1238,9 @@
|
|
|
978
1238
|
},
|
|
979
1239
|
|
|
980
1240
|
oneditprepare: function () {
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1241
|
+
const node = this;
|
|
1242
|
+
const code = node.componentCode || STARTER;
|
|
1243
|
+
const headCode = node.customHead || "";
|
|
984
1244
|
console.log("[FC-Monaco] PORTAL oneditprepare, node.id=" + node.id);
|
|
985
1245
|
|
|
986
1246
|
if (!window.__fcTwClasses) {
|
|
@@ -1000,7 +1260,7 @@
|
|
|
1000
1260
|
}
|
|
1001
1261
|
|
|
1002
1262
|
// Tabs
|
|
1003
|
-
|
|
1263
|
+
const fcTabs = RED.tabs.create({
|
|
1004
1264
|
id: "fc-tabs",
|
|
1005
1265
|
onchange: function (tab) {
|
|
1006
1266
|
$(".fc-tab-pane").hide();
|
|
@@ -1018,8 +1278,8 @@
|
|
|
1018
1278
|
|
|
1019
1279
|
// Legacy endpoint detection + convenience pre-fill
|
|
1020
1280
|
if (node.endpoint && typeof node.endpoint === "string") {
|
|
1021
|
-
|
|
1022
|
-
|
|
1281
|
+
const legacy = node.endpoint;
|
|
1282
|
+
const prefix = "/fromcubes/";
|
|
1023
1283
|
if (legacy.indexOf(prefix) === 0) {
|
|
1024
1284
|
if (!node.subPath) {
|
|
1025
1285
|
$("#node-input-subPath").val(legacy.slice(prefix.length));
|
|
@@ -1037,8 +1297,8 @@
|
|
|
1037
1297
|
|
|
1038
1298
|
// URL hint
|
|
1039
1299
|
function updateHint() {
|
|
1040
|
-
|
|
1041
|
-
|
|
1300
|
+
const sp = ($("#node-input-subPath").val() || "").trim();
|
|
1301
|
+
const root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
|
|
1042
1302
|
if (sp) {
|
|
1043
1303
|
$("#fc-url-hint")
|
|
1044
1304
|
.css("color", "")
|
|
@@ -1058,15 +1318,15 @@
|
|
|
1058
1318
|
updateHint();
|
|
1059
1319
|
|
|
1060
1320
|
// Modules editableList (like function node's libs)
|
|
1061
|
-
|
|
1321
|
+
const libsList = node.libs || [];
|
|
1062
1322
|
$("#node-input-libs-container").css("min-height","68px").editableList({
|
|
1063
1323
|
addItem: function(container, i, opt) {
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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);
|
|
1068
1328
|
modInput.val(lib.module || "");
|
|
1069
|
-
varInput.val(lib.
|
|
1329
|
+
varInput.val(lib.const || "");
|
|
1070
1330
|
container.data("mod", modInput);
|
|
1071
1331
|
container.data("var", varInput);
|
|
1072
1332
|
},
|
|
@@ -1094,18 +1354,18 @@
|
|
|
1094
1354
|
"[FC-Monaco] PORTAL: applying JSX defaults before model creation",
|
|
1095
1355
|
);
|
|
1096
1356
|
window.__fcApplyJsxDefaults();
|
|
1097
|
-
|
|
1357
|
+
const opts = window.__fcEditorOpts;
|
|
1098
1358
|
|
|
1099
|
-
|
|
1359
|
+
const jsxUri = monaco.Uri.parse(
|
|
1100
1360
|
"file:///fc-portal-" + node.id + ".jsx",
|
|
1101
1361
|
);
|
|
1102
1362
|
console.log("[FC-Monaco] PORTAL: JSX model URI=" + jsxUri.toString());
|
|
1103
|
-
|
|
1363
|
+
const existingJsx = monaco.editor.getModel(jsxUri);
|
|
1104
1364
|
if (existingJsx) {
|
|
1105
1365
|
console.log("[FC-Monaco] PORTAL: disposing existing JSX model");
|
|
1106
1366
|
existingJsx.dispose();
|
|
1107
1367
|
}
|
|
1108
|
-
|
|
1368
|
+
const jsxModel = monaco.editor.createModel(code, "javascript", jsxUri);
|
|
1109
1369
|
console.log(
|
|
1110
1370
|
"[FC-Monaco] PORTAL: JSX model created, language=" +
|
|
1111
1371
|
jsxModel.getLanguageId(),
|
|
@@ -1118,12 +1378,12 @@
|
|
|
1118
1378
|
if (window.__fcAttachSelfClose)
|
|
1119
1379
|
window.__fcAttachSelfClose(editorInstance);
|
|
1120
1380
|
|
|
1121
|
-
|
|
1381
|
+
const headUri = monaco.Uri.parse(
|
|
1122
1382
|
"file:///fc-head-" + node.id + ".html",
|
|
1123
1383
|
);
|
|
1124
|
-
|
|
1384
|
+
const existingHead = monaco.editor.getModel(headUri);
|
|
1125
1385
|
if (existingHead) existingHead.dispose();
|
|
1126
|
-
|
|
1386
|
+
const headModel = monaco.editor.createModel(headCode, "html", headUri);
|
|
1127
1387
|
headEditorInstance = monaco.editor.create(
|
|
1128
1388
|
document.getElementById("fc-head-monaco"),
|
|
1129
1389
|
Object.assign({ model: headModel }, opts),
|
|
@@ -1132,7 +1392,7 @@
|
|
|
1132
1392
|
|
|
1133
1393
|
// Log markers after a short delay (diagnostics are async)
|
|
1134
1394
|
setTimeout(function () {
|
|
1135
|
-
|
|
1395
|
+
const markers = monaco.editor.getModelMarkers({ resource: jsxUri });
|
|
1136
1396
|
console.log(
|
|
1137
1397
|
"[FC-Monaco] PORTAL: markers after 500ms, count=" +
|
|
1138
1398
|
markers.length,
|
|
@@ -1181,14 +1441,14 @@
|
|
|
1181
1441
|
});
|
|
1182
1442
|
|
|
1183
1443
|
$("#fc-btn-preview").on("click", function () {
|
|
1184
|
-
|
|
1185
|
-
|
|
1444
|
+
const sp = ($("#node-input-subPath").val() || "").trim();
|
|
1445
|
+
const root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
|
|
1186
1446
|
if (sp) window.open(root + "/fromcubes/" + sp, "_blank");
|
|
1187
1447
|
});
|
|
1188
1448
|
|
|
1189
1449
|
$("#fc-btn-components").on("click", function () {
|
|
1190
1450
|
$.getJSON("portal-react/registry", function (reg) {
|
|
1191
|
-
|
|
1451
|
+
const names = Object.keys(reg).sort();
|
|
1192
1452
|
if (!names.length) {
|
|
1193
1453
|
RED.notify("No component nodes on canvas.", "warning");
|
|
1194
1454
|
return;
|
|
@@ -1196,12 +1456,12 @@
|
|
|
1196
1456
|
|
|
1197
1457
|
function extractProps(code) {
|
|
1198
1458
|
if (!code) return [];
|
|
1199
|
-
|
|
1459
|
+
const m = code.match(/function\s+\w+\s*\(\s*\{([^}]*)\}/);
|
|
1200
1460
|
if (!m) return [];
|
|
1201
1461
|
return m[1].split(",").map(function (s) { return s.trim().split(/\s*=\s*/)[0]; }).filter(Boolean);
|
|
1202
1462
|
}
|
|
1203
1463
|
|
|
1204
|
-
|
|
1464
|
+
let html =
|
|
1205
1465
|
'<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">' +
|
|
1206
1466
|
'<input type="text" id="fc-comp-search" placeholder="Search..." ' +
|
|
1207
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;' +
|
|
@@ -1209,16 +1469,16 @@
|
|
|
1209
1469
|
'<div id="fc-comp-list" style="flex:1;overflow-y:auto;">';
|
|
1210
1470
|
|
|
1211
1471
|
names.forEach(function (n) {
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1472
|
+
const c = reg[n];
|
|
1473
|
+
const props = extractProps(c.code);
|
|
1474
|
+
const detailParts = [];
|
|
1215
1475
|
if (props.length) {
|
|
1216
1476
|
detailParts.push('<div style="margin:4px 0 0 0;font-size:11px;opacity:.6;">' +
|
|
1217
1477
|
props.map(function (p) {
|
|
1218
1478
|
return '<code style="background:rgba(128,128,128,.15);padding:0 4px;border-radius:2px;margin-right:3px;">' + p + '</code>';
|
|
1219
1479
|
}).join("") + '</div>');
|
|
1220
1480
|
}
|
|
1221
|
-
|
|
1481
|
+
const hasDetail = detailParts.length > 0;
|
|
1222
1482
|
|
|
1223
1483
|
html +=
|
|
1224
1484
|
'<div class="fc-comp-item" data-name="' + n + '" data-search="' + n.toLowerCase() + '" ' +
|
|
@@ -1232,7 +1492,7 @@
|
|
|
1232
1492
|
});
|
|
1233
1493
|
html += '</div></div>';
|
|
1234
1494
|
|
|
1235
|
-
|
|
1495
|
+
const $dlg = $("<div></div>").html(html).dialog({
|
|
1236
1496
|
title: "Components",
|
|
1237
1497
|
modal: true,
|
|
1238
1498
|
width: 380,
|
|
@@ -1244,7 +1504,7 @@
|
|
|
1244
1504
|
|
|
1245
1505
|
// Search
|
|
1246
1506
|
$dlg.find("#fc-comp-search").on("input", function () {
|
|
1247
|
-
|
|
1507
|
+
const q = $(this).val().toLowerCase();
|
|
1248
1508
|
$dlg.find(".fc-comp-item").each(function () {
|
|
1249
1509
|
$(this).toggle($(this).data("search").indexOf(q) !== -1);
|
|
1250
1510
|
});
|
|
@@ -1260,24 +1520,24 @@
|
|
|
1260
1520
|
// Arrow toggle detail
|
|
1261
1521
|
$dlg.on("click", ".fc-comp-arrow", function (e) {
|
|
1262
1522
|
e.stopPropagation();
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1523
|
+
const $item = $(this).closest(".fc-comp-item");
|
|
1524
|
+
const $detail = $item.find(".fc-comp-detail");
|
|
1525
|
+
const open = $detail.is(":visible");
|
|
1266
1526
|
$detail.slideToggle(100);
|
|
1267
1527
|
$(this).css("transform", open ? "" : "rotate(90deg)");
|
|
1268
1528
|
});
|
|
1269
1529
|
|
|
1270
1530
|
// Click name: delete selection, insert <Tag></Tag> in one line, cursor between tags
|
|
1271
1531
|
$dlg.on("click", ".fc-comp-name", function () {
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1532
|
+
const name = $(this).data("name");
|
|
1533
|
+
const openTag = "<" + name + ">";
|
|
1534
|
+
const closeTag = "</" + name + ">";
|
|
1535
|
+
const text = openTag + closeTag;
|
|
1276
1536
|
$dlg.dialog("close");
|
|
1277
1537
|
if (editorInstance) {
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1538
|
+
const sel = editorInstance.getSelection();
|
|
1539
|
+
const startLine = sel.startLineNumber;
|
|
1540
|
+
const startCol = sel.startColumn;
|
|
1281
1541
|
editorInstance.executeEdits("fc-components", [{
|
|
1282
1542
|
range: sel,
|
|
1283
1543
|
text: text,
|
|
@@ -1287,8 +1547,8 @@
|
|
|
1287
1547
|
editorInstance.focus();
|
|
1288
1548
|
}, 50);
|
|
1289
1549
|
} else {
|
|
1290
|
-
|
|
1291
|
-
|
|
1550
|
+
const ta = $("#fc-fallback")[0];
|
|
1551
|
+
const s = ta.selectionStart, e = ta.selectionEnd, v = ta.value;
|
|
1292
1552
|
ta.value = v.slice(0, s) + text + v.slice(e);
|
|
1293
1553
|
setTimeout(function () {
|
|
1294
1554
|
ta.selectionStart = ta.selectionEnd = s + openTag.length;
|
|
@@ -1298,6 +1558,118 @@
|
|
|
1298
1558
|
});
|
|
1299
1559
|
});
|
|
1300
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
|
+
});
|
|
1301
1673
|
},
|
|
1302
1674
|
|
|
1303
1675
|
oneditsave: function () {
|
|
@@ -1309,24 +1681,24 @@
|
|
|
1309
1681
|
this.subPath = ($("#node-input-subPath").val() || "").trim();
|
|
1310
1682
|
|
|
1311
1683
|
// Collect libs from editableList
|
|
1312
|
-
|
|
1313
|
-
|
|
1684
|
+
const libs = [];
|
|
1685
|
+
const items = $("#node-input-libs-container").editableList("items");
|
|
1314
1686
|
items.each(function() {
|
|
1315
|
-
|
|
1316
|
-
|
|
1687
|
+
const mod = $(this).data("mod").val().trim();
|
|
1688
|
+
const v = $(this).data("var").val().trim();
|
|
1317
1689
|
if (mod) {
|
|
1318
1690
|
libs.push({ module: mod, var: v });
|
|
1319
1691
|
}
|
|
1320
1692
|
});
|
|
1321
1693
|
this.libs = libs;
|
|
1322
1694
|
|
|
1323
|
-
|
|
1695
|
+
const code = editorInstance
|
|
1324
1696
|
? editorInstance.getValue()
|
|
1325
1697
|
: $("#fc-fallback").val();
|
|
1326
1698
|
$("#node-input-componentCode").val(code);
|
|
1327
1699
|
this.componentCode = code;
|
|
1328
1700
|
|
|
1329
|
-
|
|
1701
|
+
const head = headEditorInstance
|
|
1330
1702
|
? headEditorInstance.getValue()
|
|
1331
1703
|
: $("#fc-head-fallback").val();
|
|
1332
1704
|
$("#node-input-customHead").val(head);
|
|
@@ -1363,11 +1735,11 @@
|
|
|
1363
1735
|
},
|
|
1364
1736
|
|
|
1365
1737
|
oneditresize: function (size) {
|
|
1366
|
-
|
|
1367
|
-
|
|
1738
|
+
const tabsH = $("#fc-tabs").outerHeight(true) || 0;
|
|
1739
|
+
const rows = $(
|
|
1368
1740
|
"#dialog-form>div:not(#fc-tabs-content):not(:has(#fc-tabs))",
|
|
1369
1741
|
);
|
|
1370
|
-
|
|
1742
|
+
let h = size.height;
|
|
1371
1743
|
rows.each(function () {
|
|
1372
1744
|
h -= $(this).outerHeight(true);
|
|
1373
1745
|
});
|
|
@@ -1504,8 +1876,8 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1504
1876
|
<script type="text/javascript">
|
|
1505
1877
|
(function () {
|
|
1506
1878
|
// Resolve httpNodeRoot for public URL prefix
|
|
1507
|
-
|
|
1508
|
-
|
|
1879
|
+
const nodeRoot = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
|
|
1880
|
+
const publicBase = nodeRoot + "/fromcubes/public/";
|
|
1509
1881
|
|
|
1510
1882
|
function formatSize(bytes) {
|
|
1511
1883
|
if (bytes < 1024) return bytes + " B";
|
|
@@ -1513,15 +1885,15 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1513
1885
|
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
|
1514
1886
|
}
|
|
1515
1887
|
|
|
1516
|
-
|
|
1517
|
-
|
|
1888
|
+
let allEntries = [];
|
|
1889
|
+
const collapsed = {};
|
|
1518
1890
|
|
|
1519
|
-
|
|
1520
|
-
|
|
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);
|
|
1521
1893
|
|
|
1522
1894
|
// ── Toolbar ──
|
|
1523
|
-
|
|
1524
|
-
|
|
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);
|
|
1525
1897
|
$('<button class="red-ui-button red-ui-button-small" style="flex-shrink:0;"><i class="fa fa-upload"></i> Upload</button>')
|
|
1526
1898
|
.on("click", function (e) { e.preventDefault(); fileInput.trigger("click"); })
|
|
1527
1899
|
.appendTo(toolbar);
|
|
@@ -1531,7 +1903,7 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1531
1903
|
|
|
1532
1904
|
// ── Helpers ──
|
|
1533
1905
|
function pathExists(p) {
|
|
1534
|
-
for (
|
|
1906
|
+
for (let i = 0; i < allEntries.length; i++) {
|
|
1535
1907
|
if (allEntries[i].name === p) return true;
|
|
1536
1908
|
}
|
|
1537
1909
|
return false;
|
|
@@ -1539,10 +1911,10 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1539
1911
|
|
|
1540
1912
|
function uploadFiles(files, targetDir) {
|
|
1541
1913
|
if (!files || files.length === 0) return;
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
for (
|
|
1545
|
-
|
|
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;
|
|
1546
1918
|
if (pathExists(uploadPath)) {
|
|
1547
1919
|
duplicates.push({ file: files[i], path: uploadPath });
|
|
1548
1920
|
} else {
|
|
@@ -1550,15 +1922,15 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1550
1922
|
}
|
|
1551
1923
|
}
|
|
1552
1924
|
if (duplicates.length > 0) {
|
|
1553
|
-
|
|
1925
|
+
const names = duplicates.map(function (d) { return d.file.name; }).join(", ");
|
|
1554
1926
|
if (confirm("These files already exist: " + names + "\nOverwrite?")) {
|
|
1555
1927
|
toUpload = toUpload.concat(duplicates);
|
|
1556
1928
|
}
|
|
1557
1929
|
}
|
|
1558
1930
|
if (toUpload.length === 0) return;
|
|
1559
|
-
|
|
1931
|
+
let pending = toUpload.length;
|
|
1560
1932
|
toUpload.forEach(function (item) {
|
|
1561
|
-
|
|
1933
|
+
const reader = new FileReader();
|
|
1562
1934
|
reader.onload = function () {
|
|
1563
1935
|
$.ajax({
|
|
1564
1936
|
type: "POST",
|
|
@@ -1598,15 +1970,15 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1598
1970
|
e.preventDefault();
|
|
1599
1971
|
e.stopPropagation();
|
|
1600
1972
|
content.css("background", "");
|
|
1601
|
-
|
|
1973
|
+
const dt = e.originalEvent.dataTransfer;
|
|
1602
1974
|
if (dt.files && dt.files.length > 0) {
|
|
1603
1975
|
uploadFiles(dt.files, "");
|
|
1604
1976
|
}
|
|
1605
1977
|
});
|
|
1606
1978
|
|
|
1607
1979
|
function moveItem(fromPath, toDir) {
|
|
1608
|
-
|
|
1609
|
-
|
|
1980
|
+
const filename = fromPath.split("/").pop();
|
|
1981
|
+
const newPath = toDir ? toDir + "/" + filename : filename;
|
|
1610
1982
|
if (fromPath === newPath) return;
|
|
1611
1983
|
if (pathExists(newPath)) {
|
|
1612
1984
|
if (!confirm("'" + filename + "' already exists in this folder. Overwrite?")) return;
|
|
@@ -1617,7 +1989,7 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1617
1989
|
data: JSON.stringify({ from: fromPath, to: newPath }),
|
|
1618
1990
|
success: function () { refreshList(); },
|
|
1619
1991
|
error: function (xhr) {
|
|
1620
|
-
|
|
1992
|
+
const msg = xhr.responseJSON ? xhr.responseJSON.error : "move failed";
|
|
1621
1993
|
RED.notify("Move failed: " + msg, "error");
|
|
1622
1994
|
},
|
|
1623
1995
|
});
|
|
@@ -1625,22 +1997,22 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1625
1997
|
|
|
1626
1998
|
// ── Inline new-folder input ──
|
|
1627
1999
|
function showNewFolderInput(parentDir) {
|
|
1628
|
-
|
|
2000
|
+
const existingInput = fileList.find(".fc-new-folder-row");
|
|
1629
2001
|
if (existingInput.length) existingInput.remove();
|
|
1630
2002
|
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
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>');
|
|
1634
2006
|
row.css("padding-left", indent + "px");
|
|
1635
2007
|
$('<i class="fa fa-folder" style="color:#fbbf24;font-size:13px;width:16px;text-align:center;"></i>').appendTo(row);
|
|
1636
|
-
|
|
2008
|
+
const inp = $('<input type="text" placeholder="folder name" class="red-ui-searchBox-input" style="flex:1;padding:3px 6px;font-size:12px;">');
|
|
1637
2009
|
inp.appendTo(row);
|
|
1638
|
-
|
|
1639
|
-
|
|
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>');
|
|
1640
2012
|
function submit() {
|
|
1641
|
-
|
|
2013
|
+
const name = inp.val().trim();
|
|
1642
2014
|
if (!name) { row.remove(); return; }
|
|
1643
|
-
|
|
2015
|
+
const p = parentDir ? parentDir + "/" + name : name;
|
|
1644
2016
|
if (pathExists(p)) {
|
|
1645
2017
|
RED.notify("Folder '" + name + "' already exists", "warning");
|
|
1646
2018
|
return;
|
|
@@ -1661,7 +2033,7 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1661
2033
|
|
|
1662
2034
|
// Insert after the parent folder row, or at top
|
|
1663
2035
|
if (parentDir) {
|
|
1664
|
-
|
|
2036
|
+
const parentRow = fileList.find('[data-path="' + parentDir + '"]');
|
|
1665
2037
|
if (parentRow.length) { row.insertAfter(parentRow); } else { fileList.prepend(row); }
|
|
1666
2038
|
} else {
|
|
1667
2039
|
fileList.prepend(row);
|
|
@@ -1670,7 +2042,7 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1670
2042
|
}
|
|
1671
2043
|
|
|
1672
2044
|
// ── Context menu (native Node-RED style) ──
|
|
1673
|
-
|
|
2045
|
+
let activeMenu = null;
|
|
1674
2046
|
function closeMenu() {
|
|
1675
2047
|
if (activeMenu) { activeMenu.remove(); activeMenu = null; }
|
|
1676
2048
|
}
|
|
@@ -1678,14 +2050,14 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1678
2050
|
|
|
1679
2051
|
function showMenu(anchor, items) {
|
|
1680
2052
|
closeMenu();
|
|
1681
|
-
|
|
2053
|
+
const menu = $('<ul class="red-ui-menu red-ui-menu-dropdown" style="position:absolute;z-index:10000;display:block;"></ul>');
|
|
1682
2054
|
items.forEach(function (item) {
|
|
1683
2055
|
if (item.divider) {
|
|
1684
2056
|
menu.append('<li class="red-ui-menu-divider"></li>');
|
|
1685
2057
|
return;
|
|
1686
2058
|
}
|
|
1687
|
-
|
|
1688
|
-
|
|
2059
|
+
const li = $('<li></li>');
|
|
2060
|
+
const a = $('<a href="#"></a>');
|
|
1689
2061
|
if (item.danger) a.css("color", "var(--red-ui-text-color-error)");
|
|
1690
2062
|
a.append('<i class="fa ' + item.icon + '" style="width:18px;text-align:center;"></i> ');
|
|
1691
2063
|
a.append($('<span class="red-ui-menu-label"></span>').text(item.label));
|
|
@@ -1703,32 +2075,32 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1703
2075
|
activeMenu = menu;
|
|
1704
2076
|
|
|
1705
2077
|
// Position near the anchor
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
2078
|
+
const off = anchor.offset();
|
|
2079
|
+
let top = off.top + anchor.outerHeight() + 2;
|
|
2080
|
+
let left = off.left - menu.outerWidth() + anchor.outerWidth();
|
|
1709
2081
|
if (left < 0) left = off.left;
|
|
1710
2082
|
if (top + menu.outerHeight() > $(window).height()) top = off.top - menu.outerHeight() - 2;
|
|
1711
2083
|
menu.css({ top: top, left: left });
|
|
1712
2084
|
}
|
|
1713
2085
|
|
|
1714
|
-
|
|
2086
|
+
const adminRoot = (RED.settings.httpAdminRoot || "/").replace(/\/$/, "");
|
|
1715
2087
|
|
|
1716
2088
|
function showRenameInput(rowEl, fullPath, currentName) {
|
|
1717
|
-
|
|
2089
|
+
const nameSpan = rowEl.find("span").filter(function () { return $(this).text() === currentName; }).first();
|
|
1718
2090
|
if (!nameSpan.length) return;
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
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) : "";
|
|
1726
2098
|
|
|
1727
2099
|
nameSpan.replaceWith(inp);
|
|
1728
2100
|
inp.after(cancelBtn).after(okBtn);
|
|
1729
2101
|
inp.focus();
|
|
1730
2102
|
// Select only the name part before extension
|
|
1731
|
-
|
|
2103
|
+
const el = inp[0];
|
|
1732
2104
|
if (hasExt && el.setSelectionRange) {
|
|
1733
2105
|
el.setSelectionRange(0, dotIdx);
|
|
1734
2106
|
} else {
|
|
@@ -1741,22 +2113,22 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1741
2113
|
inp.replaceWith($('<span style="flex:1;font-size:12px;word-break:break-all;"></span>').text(origText));
|
|
1742
2114
|
}
|
|
1743
2115
|
function submit() {
|
|
1744
|
-
|
|
2116
|
+
const newName = inp.val().trim();
|
|
1745
2117
|
if (!newName) {
|
|
1746
2118
|
RED.notify("Name cannot be empty", "warning");
|
|
1747
2119
|
return;
|
|
1748
2120
|
}
|
|
1749
2121
|
// Warn if extension changed or removed
|
|
1750
2122
|
if (hasExt) {
|
|
1751
|
-
|
|
1752
|
-
|
|
2123
|
+
const newDot = newName.lastIndexOf(".");
|
|
2124
|
+
const newExt = newDot > 0 ? newName.slice(newDot) : "";
|
|
1753
2125
|
if (newExt.toLowerCase() !== origExt.toLowerCase()) {
|
|
1754
2126
|
if (!confirm("Extension changed from '" + origExt + "' to '" + (newExt || "none") + "'. Continue?")) return;
|
|
1755
2127
|
}
|
|
1756
2128
|
}
|
|
1757
2129
|
if (newName === origText) { restore(); return; }
|
|
1758
|
-
|
|
1759
|
-
|
|
2130
|
+
const parentDir = fullPath.indexOf("/") >= 0 ? fullPath.slice(0, fullPath.lastIndexOf("/")) : "";
|
|
2131
|
+
const newPath = parentDir ? parentDir + "/" + newName : newName;
|
|
1760
2132
|
if (pathExists(newPath)) {
|
|
1761
2133
|
RED.notify("'" + newName + "' already exists", "warning");
|
|
1762
2134
|
return;
|
|
@@ -1767,7 +2139,7 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1767
2139
|
data: JSON.stringify({ from: fullPath, to: newPath }),
|
|
1768
2140
|
success: function () { refreshList(); },
|
|
1769
2141
|
error: function (xhr) {
|
|
1770
|
-
|
|
2142
|
+
const msg = xhr.responseJSON ? xhr.responseJSON.error : "rename failed";
|
|
1771
2143
|
RED.notify("Rename failed: " + msg, "error");
|
|
1772
2144
|
restore();
|
|
1773
2145
|
},
|
|
@@ -1784,11 +2156,11 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1784
2156
|
// ── Tree rendering ──
|
|
1785
2157
|
function buildTree(entries) {
|
|
1786
2158
|
// Build nested structure: { children: { name: { type, children, entry } } }
|
|
1787
|
-
|
|
2159
|
+
const root = { children: {} };
|
|
1788
2160
|
entries.forEach(function (e) {
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
for (
|
|
2161
|
+
const parts = e.name.split("/");
|
|
2162
|
+
let node = root;
|
|
2163
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1792
2164
|
if (!node.children[parts[i]]) {
|
|
1793
2165
|
node.children[parts[i]] = { children: {} };
|
|
1794
2166
|
}
|
|
@@ -1799,15 +2171,15 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1799
2171
|
return root;
|
|
1800
2172
|
}
|
|
1801
2173
|
|
|
1802
|
-
|
|
2174
|
+
const ROOT_KEY = "__root__";
|
|
1803
2175
|
|
|
1804
2176
|
function renderTree() {
|
|
1805
2177
|
fileList.empty();
|
|
1806
|
-
|
|
2178
|
+
const isOpen = !collapsed[ROOT_KEY];
|
|
1807
2179
|
|
|
1808
2180
|
// Root folder row — always visible
|
|
1809
|
-
|
|
1810
|
-
|
|
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>');
|
|
1811
2183
|
rootArrow.on("click", function () { collapsed[ROOT_KEY] = isOpen; renderTree(); });
|
|
1812
2184
|
rootRow.append(rootArrow);
|
|
1813
2185
|
$('<i class="fa ' + (isOpen ? 'fa-folder-open' : 'fa-folder') + '" style="color:#fbbf24;font-size:12px;width:16px;text-align:center;"></i>').appendTo(rootRow);
|
|
@@ -1822,35 +2194,35 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1822
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>'));
|
|
1823
2195
|
return;
|
|
1824
2196
|
}
|
|
1825
|
-
|
|
2197
|
+
const tree = buildTree(allEntries);
|
|
1826
2198
|
renderNode(tree, "", 1);
|
|
1827
2199
|
}
|
|
1828
2200
|
|
|
1829
2201
|
function renderNode(node, parentPath, depth) {
|
|
1830
2202
|
// Collect and sort: dirs first, then files
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
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";
|
|
1834
2206
|
if (aIsDir && !bIsDir) return -1;
|
|
1835
2207
|
if (!aIsDir && bIsDir) return 1;
|
|
1836
2208
|
return a.localeCompare(b);
|
|
1837
2209
|
});
|
|
1838
2210
|
|
|
1839
2211
|
names.forEach(function (name) {
|
|
1840
|
-
|
|
1841
|
-
|
|
2212
|
+
const child = node.children[name];
|
|
2213
|
+
const e = child.entry;
|
|
1842
2214
|
if (!e) return;
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
2215
|
+
const fullPath = e.name;
|
|
2216
|
+
const indent = 8 + depth * 18;
|
|
2217
|
+
const isDir = e.type === "dir";
|
|
2218
|
+
const isOpen = !collapsed[fullPath];
|
|
1847
2219
|
|
|
1848
|
-
|
|
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>');
|
|
1849
2221
|
row.css("padding-left", indent + "px");
|
|
1850
2222
|
|
|
1851
2223
|
if (isDir) {
|
|
1852
2224
|
// Expand/collapse arrow
|
|
1853
|
-
|
|
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>');
|
|
1854
2226
|
arrow.on("click", function (e) {
|
|
1855
2227
|
e.stopPropagation();
|
|
1856
2228
|
collapsed[fullPath] = isOpen;
|
|
@@ -1863,7 +2235,7 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1863
2235
|
.appendTo(row);
|
|
1864
2236
|
|
|
1865
2237
|
// Context menu trigger
|
|
1866
|
-
|
|
2238
|
+
const dirMenuBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;"><i class="fa fa-ellipsis-v"></i></button>');
|
|
1867
2239
|
(function (fp, nm) {
|
|
1868
2240
|
dirMenuBtn.on("click", function (ev) {
|
|
1869
2241
|
ev.preventDefault();
|
|
@@ -1902,7 +2274,7 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1902
2274
|
ev.preventDefault();
|
|
1903
2275
|
ev.stopPropagation();
|
|
1904
2276
|
row.css("background", "");
|
|
1905
|
-
|
|
2277
|
+
const srcPath = ev.originalEvent.dataTransfer.getData("text/x-asset-path");
|
|
1906
2278
|
if (srcPath) {
|
|
1907
2279
|
moveItem(srcPath, fullPath);
|
|
1908
2280
|
} else if (ev.originalEvent.dataTransfer.files && ev.originalEvent.dataTransfer.files.length > 0) {
|
|
@@ -1930,13 +2302,13 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1930
2302
|
});
|
|
1931
2303
|
row.on("dragend", function () { row.css("opacity", "1"); });
|
|
1932
2304
|
|
|
1933
|
-
|
|
1934
|
-
|
|
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>');
|
|
1935
2307
|
(function (fp, nm) {
|
|
1936
2308
|
copyBtn.on("click", function (ev) {
|
|
1937
2309
|
ev.preventDefault();
|
|
1938
2310
|
ev.stopPropagation();
|
|
1939
|
-
|
|
2311
|
+
const url = publicBase + fp;
|
|
1940
2312
|
navigator.clipboard.writeText(url).then(function () {
|
|
1941
2313
|
RED.notify("Copied: " + url, { type: "success", timeout: 2000 });
|
|
1942
2314
|
});
|
|
@@ -1949,7 +2321,7 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
1949
2321
|
showRenameInput(row, fp, nm);
|
|
1950
2322
|
}},
|
|
1951
2323
|
{ icon: "fa-download", label: "Download", action: function () {
|
|
1952
|
-
|
|
2324
|
+
const a = document.createElement("a");
|
|
1953
2325
|
a.href = adminRoot + "/portal-react/assets/download/" + fp.split("/").map(encodeURIComponent).join("/");
|
|
1954
2326
|
a.download = nm;
|
|
1955
2327
|
document.body.appendChild(a);
|