@aaqu/fromcubes-portal-react 0.1.0-alpha.3 → 0.1.0-alpha.30
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 +244 -76
- package/examples/D3 Poland Map.json +90 -0
- package/examples/Live Chart.json +101 -0
- package/examples/PixiJS Sprites.json +114 -0
- package/examples/Sensor Portal.json +93 -0
- package/examples/Shared Components.json +68 -0
- package/examples/Three.js Scene.json +118 -0
- package/examples/Utility Hooks.json +94 -0
- package/examples/WebGPU Shader.json +110 -0
- package/nodes/lib/admin-api.js +160 -0
- package/nodes/lib/assets.js +342 -0
- package/nodes/lib/helpers.js +724 -0
- package/nodes/lib/hooks.js +120 -0
- package/nodes/lib/page-builder.js +480 -0
- package/nodes/lib/portal-page-route.js +133 -0
- package/nodes/lib/registry-nodes.js +331 -0
- package/nodes/lib/router.js +80 -0
- package/nodes/lib/ws-heartbeat.js +58 -0
- package/nodes/portal-react.html +1862 -287
- package/nodes/portal-react.js +1563 -471
- package/package.json +42 -12
- package/examples/sensor-portal-flow.json +0 -71
- package/nodes/vendor/react-19.production.min.js +0 -55
- package/scripts/bundle-react.js +0 -31
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,32 +20,51 @@
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
window.__fcTwClasses = null;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
let jsxSetupDone = false;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One-time Monaco setup: compiler opts, diagnostics, extra libs,
|
|
27
|
+
* completion providers (Tailwind class, JSX tag, utility symbol).
|
|
28
|
+
* Idempotent — safe to call multiple times, only the first call wires
|
|
29
|
+
* up listeners; subsequent calls just re-apply the compiler/diagnostics
|
|
30
|
+
* blocks because Node-RED resets them whenever the editor reloads.
|
|
31
|
+
*
|
|
32
|
+
* @returns {void}
|
|
33
|
+
* @private
|
|
34
|
+
*/
|
|
27
35
|
function ensureJsxSetup() {
|
|
28
36
|
if (jsxSetupDone) {
|
|
29
|
-
console.log(
|
|
37
|
+
console.log(
|
|
38
|
+
PREFIX,
|
|
39
|
+
"ensureJsxSetup: already done, re-applying compiler+diag only",
|
|
40
|
+
);
|
|
30
41
|
// Always re-apply compiler/diag in case Node-RED reset them
|
|
31
42
|
applyCompilerAndDiag();
|
|
32
43
|
return;
|
|
33
44
|
}
|
|
34
45
|
jsxSetupDone = true;
|
|
35
46
|
|
|
36
|
-
|
|
47
|
+
const jsDef = monaco.typescript.javascriptDefaults;
|
|
37
48
|
|
|
38
49
|
// Log initial state
|
|
39
|
-
console.log(
|
|
40
|
-
|
|
50
|
+
console.log(
|
|
51
|
+
PREFIX,
|
|
52
|
+
"INITIAL compilerOptions:",
|
|
53
|
+
JSON.stringify(jsDef.getCompilerOptions()),
|
|
54
|
+
);
|
|
55
|
+
console.log(
|
|
56
|
+
PREFIX,
|
|
57
|
+
"INITIAL diagnosticsOptions:",
|
|
58
|
+
JSON.stringify(jsDef.getDiagnosticsOptions()),
|
|
59
|
+
);
|
|
41
60
|
|
|
42
61
|
applyCompilerAndDiag();
|
|
43
62
|
|
|
44
63
|
// Global type stubs
|
|
45
|
-
|
|
46
|
-
"declare
|
|
47
|
-
"declare
|
|
48
|
-
"declare function useNodeRed(): { data: any; send: (payload: any, topic?: string) => void };",
|
|
64
|
+
const libContent = [
|
|
65
|
+
"declare const React: any;",
|
|
66
|
+
"declare const ReactDOM: any;",
|
|
67
|
+
"declare function useNodeRed(opts?: { ignoreRecovery?: boolean }): { data: any; send: (payload: any, topic?: string) => void; user: { userId?: string; userName?: string; username?: string; email?: string; role?: string; groups?: any[] } | null; portalClient: string | null };",
|
|
49
68
|
].join("\n");
|
|
50
69
|
console.log(PREFIX, "addExtraLib globals.d.ts");
|
|
51
70
|
jsDef.addExtraLib(libContent, "file:///globals.d.ts");
|
|
@@ -57,44 +76,44 @@
|
|
|
57
76
|
monaco.languages.registerCompletionItemProvider("javascript", {
|
|
58
77
|
triggerCharacters: ['"', "'", "`", " "],
|
|
59
78
|
provideCompletionItems: function (model, position) {
|
|
60
|
-
|
|
61
|
-
|
|
79
|
+
const line = model.getLineContent(position.lineNumber);
|
|
80
|
+
const before = line.substring(0, position.column - 1);
|
|
62
81
|
|
|
63
82
|
// 1) className="..." or className={`...`}
|
|
64
|
-
|
|
83
|
+
const isClassName =
|
|
65
84
|
before.match(/className\s*=\s*["'][^"']*$/) ||
|
|
66
85
|
before.match(/className\s*=\s*\{`[^`]*$/);
|
|
67
86
|
|
|
68
87
|
// 2) Any open string literal: "..., '..., or `...
|
|
69
|
-
|
|
88
|
+
const inString = !isClassName && before.match(/["'`][^"'`]*$/);
|
|
70
89
|
|
|
71
90
|
if (!isClassName && !inString) return { suggestions: [] };
|
|
72
91
|
|
|
73
92
|
// Extract text inside the string
|
|
74
|
-
|
|
75
|
-
|
|
93
|
+
const raw = (isClassName || inString)[0];
|
|
94
|
+
let inside;
|
|
76
95
|
if (isClassName) {
|
|
77
96
|
inside = raw.replace(/^className\s*=\s*(?:\{`|["'])/, "");
|
|
78
97
|
} else {
|
|
79
98
|
inside = raw.substring(1); // skip opening quote
|
|
80
99
|
}
|
|
81
100
|
inside = inside.replace(/\$\{[^}]*\}/g, " ");
|
|
82
|
-
|
|
83
|
-
|
|
101
|
+
const parts = inside.split(/\s+/);
|
|
102
|
+
const word = parts[parts.length - 1] || "";
|
|
84
103
|
|
|
85
104
|
// For non-className strings, only activate if at least one existing
|
|
86
105
|
// token in the string looks like a TW class (avoid noise in random strings)
|
|
87
106
|
if (!isClassName && parts.length > 0) {
|
|
88
|
-
|
|
89
|
-
|
|
107
|
+
const classes = window.__fcTwClasses || [];
|
|
108
|
+
let twSet = window.__fcTwSet;
|
|
90
109
|
if (!twSet && classes.length) {
|
|
91
110
|
twSet = new Set(classes);
|
|
92
111
|
window.__fcTwSet = twSet;
|
|
93
112
|
console.log(PREFIX, "TW: built lookup Set, size=" + twSet.size);
|
|
94
113
|
}
|
|
95
114
|
if (twSet) {
|
|
96
|
-
|
|
97
|
-
for (
|
|
115
|
+
let hasTwToken = false;
|
|
116
|
+
for (let p = 0; p < parts.length; p++) {
|
|
98
117
|
if (parts[p] && twSet.has(parts[p])) {
|
|
99
118
|
hasTwToken = true;
|
|
100
119
|
break;
|
|
@@ -102,7 +121,7 @@
|
|
|
102
121
|
}
|
|
103
122
|
// If typing the very first word, accept if it looks like a prefix
|
|
104
123
|
if (!hasTwToken && parts.length === 1 && word.length >= 2) {
|
|
105
|
-
for (
|
|
124
|
+
for (let j = 0; j < classes.length; j++) {
|
|
106
125
|
if (classes[j].indexOf(word) === 0) {
|
|
107
126
|
hasTwToken = true;
|
|
108
127
|
break;
|
|
@@ -113,17 +132,17 @@
|
|
|
113
132
|
}
|
|
114
133
|
}
|
|
115
134
|
|
|
116
|
-
|
|
135
|
+
const range = {
|
|
117
136
|
startLineNumber: position.lineNumber,
|
|
118
137
|
endLineNumber: position.lineNumber,
|
|
119
138
|
startColumn: position.column - word.length,
|
|
120
139
|
endColumn: position.column,
|
|
121
140
|
};
|
|
122
141
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
for (
|
|
126
|
-
|
|
142
|
+
const classes = window.__fcTwClasses || [];
|
|
143
|
+
const suggestions = [];
|
|
144
|
+
for (let i = 0; i < classes.length; i++) {
|
|
145
|
+
const cls = classes[i];
|
|
127
146
|
if (!word.length || cls.indexOf(word) === 0) {
|
|
128
147
|
suggestions.push({
|
|
129
148
|
label: cls,
|
|
@@ -134,57 +153,133 @@
|
|
|
134
153
|
});
|
|
135
154
|
}
|
|
136
155
|
}
|
|
137
|
-
console.log(
|
|
156
|
+
console.log(
|
|
157
|
+
PREFIX,
|
|
158
|
+
"TW completion: ctx=" +
|
|
159
|
+
(isClassName ? "className" : "string") +
|
|
160
|
+
" word='" +
|
|
161
|
+
word +
|
|
162
|
+
"', matched=" +
|
|
163
|
+
suggestions.length +
|
|
164
|
+
"/" +
|
|
165
|
+
classes.length,
|
|
166
|
+
);
|
|
138
167
|
return { suggestions: suggestions };
|
|
139
168
|
},
|
|
140
169
|
});
|
|
141
170
|
|
|
142
171
|
// JSX/HTML tag completion — type tag name, Tab → <tag>|</tag>
|
|
143
172
|
console.log(PREFIX, "registering JSX tag completion provider");
|
|
144
|
-
|
|
145
|
-
"div",
|
|
146
|
-
"
|
|
147
|
-
"
|
|
148
|
-
"
|
|
149
|
-
"
|
|
150
|
-
"
|
|
151
|
-
"
|
|
152
|
-
"
|
|
153
|
-
"
|
|
154
|
-
"
|
|
155
|
-
"
|
|
173
|
+
const HTML_TAGS = [
|
|
174
|
+
"div",
|
|
175
|
+
"span",
|
|
176
|
+
"p",
|
|
177
|
+
"a",
|
|
178
|
+
"button",
|
|
179
|
+
"input",
|
|
180
|
+
"img",
|
|
181
|
+
"form",
|
|
182
|
+
"label",
|
|
183
|
+
"h1",
|
|
184
|
+
"h2",
|
|
185
|
+
"h3",
|
|
186
|
+
"h4",
|
|
187
|
+
"h5",
|
|
188
|
+
"h6",
|
|
189
|
+
"ul",
|
|
190
|
+
"ol",
|
|
191
|
+
"li",
|
|
192
|
+
"dl",
|
|
193
|
+
"dt",
|
|
194
|
+
"dd",
|
|
195
|
+
"table",
|
|
196
|
+
"thead",
|
|
197
|
+
"tbody",
|
|
198
|
+
"tfoot",
|
|
199
|
+
"tr",
|
|
200
|
+
"th",
|
|
201
|
+
"td",
|
|
202
|
+
"section",
|
|
203
|
+
"article",
|
|
204
|
+
"aside",
|
|
205
|
+
"header",
|
|
206
|
+
"footer",
|
|
207
|
+
"nav",
|
|
208
|
+
"main",
|
|
209
|
+
"strong",
|
|
210
|
+
"em",
|
|
211
|
+
"b",
|
|
212
|
+
"i",
|
|
213
|
+
"u",
|
|
214
|
+
"s",
|
|
215
|
+
"small",
|
|
216
|
+
"mark",
|
|
217
|
+
"code",
|
|
218
|
+
"pre",
|
|
219
|
+
"blockquote",
|
|
220
|
+
"br",
|
|
221
|
+
"hr",
|
|
222
|
+
"wbr",
|
|
223
|
+
"select",
|
|
224
|
+
"option",
|
|
225
|
+
"optgroup",
|
|
226
|
+
"textarea",
|
|
227
|
+
"fieldset",
|
|
228
|
+
"legend",
|
|
229
|
+
"details",
|
|
230
|
+
"summary",
|
|
231
|
+
"dialog",
|
|
232
|
+
"canvas",
|
|
233
|
+
"video",
|
|
234
|
+
"audio",
|
|
235
|
+
"source",
|
|
236
|
+
"picture",
|
|
237
|
+
"figure",
|
|
238
|
+
"figcaption",
|
|
239
|
+
"svg",
|
|
240
|
+
"path",
|
|
241
|
+
"circle",
|
|
242
|
+
"rect",
|
|
243
|
+
"line",
|
|
244
|
+
"g",
|
|
245
|
+
"defs",
|
|
246
|
+
"use",
|
|
247
|
+
"text",
|
|
156
248
|
];
|
|
157
|
-
|
|
249
|
+
const VOID_TAGS = new Set(["br", "hr", "img", "input", "wbr", "source"]);
|
|
158
250
|
|
|
159
251
|
monaco.languages.registerCompletionItemProvider("javascript", {
|
|
160
252
|
triggerCharacters: ["<"],
|
|
161
253
|
provideCompletionItems: function (model, position) {
|
|
162
|
-
|
|
163
|
-
|
|
254
|
+
const line = model.getLineContent(position.lineNumber);
|
|
255
|
+
const before = line.substring(0, position.column - 1);
|
|
164
256
|
|
|
165
257
|
// After "<" — suggest tags
|
|
166
|
-
|
|
258
|
+
const afterBracket = before.match(/<([a-zA-Z]*)$/);
|
|
167
259
|
// Bare word at line start or after whitespace/{ — Emmet-like (lower or upper)
|
|
168
|
-
|
|
260
|
+
const bareWord =
|
|
261
|
+
!afterBracket &&
|
|
262
|
+
before.match(/(?:^|[\s{(,])([a-zA-Z][a-zA-Z0-9]*)$/);
|
|
169
263
|
|
|
170
264
|
if (!afterBracket && !bareWord) return { suggestions: [] };
|
|
171
265
|
|
|
172
|
-
|
|
173
|
-
|
|
266
|
+
const typed = (afterBracket ? afterBracket[1] : bareWord[1]) || "";
|
|
267
|
+
const replaceStart =
|
|
268
|
+
position.column - typed.length - (afterBracket ? 1 : 0);
|
|
174
269
|
|
|
175
|
-
|
|
270
|
+
const range = {
|
|
176
271
|
startLineNumber: position.lineNumber,
|
|
177
272
|
endLineNumber: position.lineNumber,
|
|
178
273
|
startColumn: replaceStart,
|
|
179
274
|
endColumn: position.column,
|
|
180
275
|
};
|
|
181
276
|
|
|
182
|
-
|
|
183
|
-
|
|
277
|
+
const suggestions = [];
|
|
278
|
+
const seen = new Set();
|
|
184
279
|
|
|
185
280
|
// HTML tags
|
|
186
|
-
for (
|
|
187
|
-
|
|
281
|
+
for (let i = 0; i < HTML_TAGS.length; i++) {
|
|
282
|
+
const tag = HTML_TAGS[i];
|
|
188
283
|
if (typed && tag.indexOf(typed) !== 0) continue;
|
|
189
284
|
seen.add(tag);
|
|
190
285
|
if (VOID_TAGS.has(tag)) {
|
|
@@ -192,7 +287,8 @@
|
|
|
192
287
|
label: tag,
|
|
193
288
|
kind: monaco.languages.CompletionItemKind.Property,
|
|
194
289
|
insertText: "<" + tag + " $0/>",
|
|
195
|
-
insertTextRules:
|
|
290
|
+
insertTextRules:
|
|
291
|
+
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
|
196
292
|
range: range,
|
|
197
293
|
detail: "<" + tag + " />",
|
|
198
294
|
sortText: "0" + tag,
|
|
@@ -202,7 +298,8 @@
|
|
|
202
298
|
label: tag,
|
|
203
299
|
kind: monaco.languages.CompletionItemKind.Property,
|
|
204
300
|
insertText: "<" + tag + ">$0</" + tag + ">",
|
|
205
|
-
insertTextRules:
|
|
301
|
+
insertTextRules:
|
|
302
|
+
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
|
206
303
|
range: range,
|
|
207
304
|
detail: "<" + tag + ">…</" + tag + ">",
|
|
208
305
|
sortText: "0" + tag + "a",
|
|
@@ -211,7 +308,8 @@
|
|
|
211
308
|
label: tag,
|
|
212
309
|
kind: monaco.languages.CompletionItemKind.Property,
|
|
213
310
|
insertText: "<" + tag + " $0/>",
|
|
214
|
-
insertTextRules:
|
|
311
|
+
insertTextRules:
|
|
312
|
+
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
|
215
313
|
range: range,
|
|
216
314
|
detail: "<" + tag + " />",
|
|
217
315
|
sortText: "0" + tag + "b",
|
|
@@ -220,24 +318,36 @@
|
|
|
220
318
|
}
|
|
221
319
|
|
|
222
320
|
// Custom components from registry (PascalCase)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
321
|
+
const upperMatch =
|
|
322
|
+
afterBracket && before.match(/<([A-Z][a-zA-Z0-9]*)$/);
|
|
323
|
+
const bareUpper =
|
|
324
|
+
!afterBracket && before.match(/(?:^|[\s{(,])([A-Z][a-zA-Z0-9]*)$/);
|
|
325
|
+
const compTyped = upperMatch
|
|
326
|
+
? upperMatch[1]
|
|
327
|
+
: bareUpper
|
|
328
|
+
? bareUpper[1]
|
|
329
|
+
: "";
|
|
226
330
|
|
|
227
331
|
if (compTyped || (!typed && afterBracket)) {
|
|
228
332
|
// Fetch component names from registry (cached on window)
|
|
229
|
-
|
|
230
|
-
for (
|
|
231
|
-
|
|
333
|
+
const reg = window.__fcComponentNames || [];
|
|
334
|
+
for (let c = 0; c < reg.length; c++) {
|
|
335
|
+
const name = reg[c];
|
|
232
336
|
if (seen.has(name)) continue;
|
|
233
337
|
if (compTyped && name.indexOf(compTyped) !== 0) continue;
|
|
234
|
-
if (
|
|
338
|
+
if (
|
|
339
|
+
!compTyped &&
|
|
340
|
+
typed &&
|
|
341
|
+
name.toLowerCase().indexOf(typed) !== 0
|
|
342
|
+
)
|
|
343
|
+
continue;
|
|
235
344
|
seen.add(name);
|
|
236
345
|
suggestions.push({
|
|
237
346
|
label: name,
|
|
238
347
|
kind: monaco.languages.CompletionItemKind.Class,
|
|
239
348
|
insertText: "<" + name + ">$0</" + name + ">",
|
|
240
|
-
insertTextRules:
|
|
349
|
+
insertTextRules:
|
|
350
|
+
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
|
241
351
|
range: range,
|
|
242
352
|
detail: "<" + name + ">…</" + name + ">",
|
|
243
353
|
sortText: "0" + name + "a",
|
|
@@ -246,7 +356,8 @@
|
|
|
246
356
|
label: name,
|
|
247
357
|
kind: monaco.languages.CompletionItemKind.Class,
|
|
248
358
|
insertText: "<" + name + " $0/>",
|
|
249
|
-
insertTextRules:
|
|
359
|
+
insertTextRules:
|
|
360
|
+
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
|
250
361
|
range: range,
|
|
251
362
|
detail: "<" + name + " />",
|
|
252
363
|
sortText: "0" + name + "b",
|
|
@@ -258,7 +369,8 @@
|
|
|
258
369
|
label: compTyped,
|
|
259
370
|
kind: monaco.languages.CompletionItemKind.Class,
|
|
260
371
|
insertText: "<" + compTyped + ">$0</" + compTyped + ">",
|
|
261
|
-
insertTextRules:
|
|
372
|
+
insertTextRules:
|
|
373
|
+
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
|
262
374
|
range: range,
|
|
263
375
|
detail: "<" + compTyped + ">…</" + compTyped + ">",
|
|
264
376
|
sortText: "0" + compTyped + "a",
|
|
@@ -267,7 +379,8 @@
|
|
|
267
379
|
label: compTyped,
|
|
268
380
|
kind: monaco.languages.CompletionItemKind.Class,
|
|
269
381
|
insertText: "<" + compTyped + " $0/>",
|
|
270
|
-
insertTextRules:
|
|
382
|
+
insertTextRules:
|
|
383
|
+
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
|
271
384
|
range: range,
|
|
272
385
|
detail: "<" + compTyped + " />",
|
|
273
386
|
sortText: "0" + compTyped + "b",
|
|
@@ -275,7 +388,13 @@
|
|
|
275
388
|
}
|
|
276
389
|
}
|
|
277
390
|
|
|
278
|
-
console.log(
|
|
391
|
+
console.log(
|
|
392
|
+
PREFIX,
|
|
393
|
+
"JSX tag completion: typed='" +
|
|
394
|
+
typed +
|
|
395
|
+
"', suggestions=" +
|
|
396
|
+
suggestions.length,
|
|
397
|
+
);
|
|
279
398
|
return { suggestions: suggestions };
|
|
280
399
|
},
|
|
281
400
|
});
|
|
@@ -285,69 +404,155 @@
|
|
|
285
404
|
window.__fcAttachSelfClose = function (editor) {
|
|
286
405
|
editor.onDidChangeModelContent(function (e) {
|
|
287
406
|
if (e.changes.length !== 1) return;
|
|
288
|
-
|
|
407
|
+
const ch = e.changes[0];
|
|
289
408
|
if (ch.text !== "/") return;
|
|
290
|
-
|
|
409
|
+
const model = editor.getModel();
|
|
291
410
|
if (!model) return;
|
|
292
|
-
|
|
293
|
-
|
|
411
|
+
const lineNum = ch.range.startLineNumber;
|
|
412
|
+
const line = model.getLineContent(lineNum);
|
|
294
413
|
// Pattern 1: <tag>/</tag> (cursor was between > and </)
|
|
295
414
|
// Pattern 2: <tag/></tag> (cursor was before > in opening tag)
|
|
296
|
-
|
|
297
|
-
|
|
415
|
+
let m = line.match(/^(.*)<([a-zA-Z][a-zA-Z0-9]*)>\/<\/\2>(.*)$/);
|
|
416
|
+
let matchStr, tag, prefix;
|
|
298
417
|
if (m) {
|
|
299
|
-
prefix = m[1];
|
|
418
|
+
prefix = m[1];
|
|
419
|
+
tag = m[2];
|
|
300
420
|
matchStr = "<" + tag + ">/</" + tag + ">";
|
|
301
421
|
} else {
|
|
302
422
|
m = line.match(/^(.*)<([a-zA-Z][a-zA-Z0-9]*)\/><\/\2>(.*)$/);
|
|
303
423
|
if (!m) return;
|
|
304
|
-
prefix = m[1];
|
|
424
|
+
prefix = m[1];
|
|
425
|
+
tag = m[2];
|
|
305
426
|
matchStr = "<" + tag + "/></" + tag + ">";
|
|
306
427
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
428
|
+
const startCol = prefix.length + 1;
|
|
429
|
+
const endCol = startCol + matchStr.length;
|
|
430
|
+
const replacement = "<" + tag + " />";
|
|
431
|
+
const cursorCol = startCol + replacement.length - 2;
|
|
311
432
|
setTimeout(function () {
|
|
312
|
-
editor.executeEdits("self-close-collapse", [
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
433
|
+
editor.executeEdits("self-close-collapse", [
|
|
434
|
+
{
|
|
435
|
+
range: {
|
|
436
|
+
startLineNumber: lineNum,
|
|
437
|
+
endLineNumber: lineNum,
|
|
438
|
+
startColumn: startCol,
|
|
439
|
+
endColumn: endCol,
|
|
440
|
+
},
|
|
441
|
+
text: replacement,
|
|
318
442
|
},
|
|
319
|
-
|
|
320
|
-
}]);
|
|
443
|
+
]);
|
|
321
444
|
editor.setPosition({ lineNumber: lineNum, column: cursorCol });
|
|
322
445
|
}, 0);
|
|
323
446
|
});
|
|
324
447
|
console.log(PREFIX, "self-close collapse attached to editor");
|
|
325
448
|
};
|
|
326
449
|
|
|
327
|
-
|
|
450
|
+
/**
|
|
451
|
+
* Fetch the current component registry from `/portal-react/registry`
|
|
452
|
+
* and stash the names on `window.__fcComponentNames` so the JSX-tag
|
|
453
|
+
* completion provider can offer `<Button` etc. Re-fetched on every
|
|
454
|
+
* editor open so newly-added components show up without an editor
|
|
455
|
+
* restart.
|
|
456
|
+
* @returns {void}
|
|
457
|
+
* @private
|
|
458
|
+
*/
|
|
328
459
|
function refreshComponentNames() {
|
|
329
460
|
$.getJSON("portal-react/registry", function (reg) {
|
|
330
461
|
window.__fcComponentNames = Object.keys(reg);
|
|
331
|
-
console.log(
|
|
462
|
+
console.log(
|
|
463
|
+
PREFIX,
|
|
464
|
+
"component names loaded:",
|
|
465
|
+
window.__fcComponentNames,
|
|
466
|
+
);
|
|
332
467
|
}).fail(function () {
|
|
333
468
|
window.__fcComponentNames = [];
|
|
334
469
|
});
|
|
335
470
|
}
|
|
336
471
|
refreshComponentNames();
|
|
337
472
|
|
|
473
|
+
// Fetch utility nodes + their parsed top-level symbols.
|
|
474
|
+
// Window cache shape: { byNode: { utilName: [symbol, ...] }, allSymbols: [..] }
|
|
475
|
+
window.__fcRefreshUtilities = function () {
|
|
476
|
+
$.getJSON("portal-react/utilities", function (reg) {
|
|
477
|
+
const byNode = {};
|
|
478
|
+
const all = new Set();
|
|
479
|
+
Object.keys(reg).forEach(function (n) {
|
|
480
|
+
const syms = reg[n].symbols || [];
|
|
481
|
+
byNode[n] = syms;
|
|
482
|
+
syms.forEach(function (s) { all.add(s); });
|
|
483
|
+
});
|
|
484
|
+
window.__fcUtilities = { byNode: byNode, allSymbols: [...all] };
|
|
485
|
+
console.log(
|
|
486
|
+
PREFIX,
|
|
487
|
+
"utilities loaded: " + Object.keys(byNode).length + " nodes, " + all.size + " symbols",
|
|
488
|
+
);
|
|
489
|
+
}).fail(function () {
|
|
490
|
+
window.__fcUtilities = { byNode: {}, allSymbols: [] };
|
|
491
|
+
});
|
|
492
|
+
};
|
|
493
|
+
window.__fcRefreshUtilities();
|
|
494
|
+
|
|
495
|
+
// Utility-symbol completion: suggests bare identifiers anywhere in JS
|
|
496
|
+
// context EXCEPT after "<" (that's JSX tag completion, handled below).
|
|
497
|
+
monaco.languages.registerCompletionItemProvider("javascript", {
|
|
498
|
+
triggerCharacters: ["."], // jQuery-ish — but we mostly fire from word context
|
|
499
|
+
provideCompletionItems: function (model, position) {
|
|
500
|
+
const line = model.getLineContent(position.lineNumber);
|
|
501
|
+
const before = line.substring(0, position.column - 1);
|
|
502
|
+
// Skip JSX tag context — covered by other providers
|
|
503
|
+
if (/<[a-zA-Z]*$/.test(before)) return { suggestions: [] };
|
|
504
|
+
// Skip property access — let Monaco's language service handle it
|
|
505
|
+
if (/\.\s*\w*$/.test(before)) return { suggestions: [] };
|
|
506
|
+
// Skip inside strings
|
|
507
|
+
if (/["'`][^"'`]*$/.test(before)) return { suggestions: [] };
|
|
508
|
+
const wordMatch = before.match(/([A-Za-z_$][\w$]*)$/);
|
|
509
|
+
const word = wordMatch ? wordMatch[1] : "";
|
|
510
|
+
const all = (window.__fcUtilities && window.__fcUtilities.allSymbols) || [];
|
|
511
|
+
if (!all.length) return { suggestions: [] };
|
|
512
|
+
const range = {
|
|
513
|
+
startLineNumber: position.lineNumber,
|
|
514
|
+
endLineNumber: position.lineNumber,
|
|
515
|
+
startColumn: position.column - word.length,
|
|
516
|
+
endColumn: position.column,
|
|
517
|
+
};
|
|
518
|
+
const suggestions = [];
|
|
519
|
+
for (let i = 0; i < all.length; i++) {
|
|
520
|
+
const sym = all[i];
|
|
521
|
+
if (word && sym.indexOf(word) !== 0) continue;
|
|
522
|
+
suggestions.push({
|
|
523
|
+
label: sym,
|
|
524
|
+
kind: monaco.languages.CompletionItemKind.Function,
|
|
525
|
+
insertText: sym,
|
|
526
|
+
range: range,
|
|
527
|
+
detail: "fc-portal-utility",
|
|
528
|
+
sortText: "1" + sym,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
return { suggestions: suggestions };
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
|
|
338
535
|
// Marker listener for debugging
|
|
339
536
|
monaco.editor.onDidChangeMarkers(function (uris) {
|
|
340
537
|
uris.forEach(function (uri) {
|
|
341
|
-
|
|
538
|
+
const markers = monaco.editor.getModelMarkers({ resource: uri });
|
|
342
539
|
if (markers.length > 0) {
|
|
343
540
|
console.group(PREFIX + " MARKERS on " + uri.toString());
|
|
344
541
|
markers.forEach(function (m) {
|
|
345
542
|
console.log(
|
|
346
|
-
" code=" +
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
543
|
+
" code=" +
|
|
544
|
+
(m.code || "?") +
|
|
545
|
+
" severity=" +
|
|
546
|
+
m.severity +
|
|
547
|
+
" source=" +
|
|
548
|
+
(m.source || "?") +
|
|
549
|
+
" msg=" +
|
|
550
|
+
m.message +
|
|
551
|
+
" [L" +
|
|
552
|
+
m.startLineNumber +
|
|
553
|
+
":" +
|
|
554
|
+
m.startColumn +
|
|
555
|
+
"]",
|
|
351
556
|
);
|
|
352
557
|
});
|
|
353
558
|
console.groupEnd();
|
|
@@ -358,9 +563,20 @@
|
|
|
358
563
|
console.log(PREFIX, "one-time setup complete");
|
|
359
564
|
}
|
|
360
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Push compilerOptions + diagnosticsOptions into Monaco's TS defaults.
|
|
568
|
+
* Called by `ensureJsxSetup` once and re-applied on every editor open
|
|
569
|
+
* because Node-RED's outer Monaco initialiser can reset these between
|
|
570
|
+
* editor sessions. We disable Monaco's TS-side squiggles entirely —
|
|
571
|
+
* the authoritative syntax check is the server-side esbuild pass at
|
|
572
|
+
* deploy time, and Monaco's TS parser produces noisy false positives
|
|
573
|
+
* (1109/1005/1128) on raw JSX.
|
|
574
|
+
* @returns {void}
|
|
575
|
+
* @private
|
|
576
|
+
*/
|
|
361
577
|
function applyCompilerAndDiag() {
|
|
362
|
-
|
|
363
|
-
|
|
578
|
+
const jsDef = monaco.typescript.javascriptDefaults;
|
|
579
|
+
const compilerOpts = {
|
|
364
580
|
jsx: monaco.typescript.JsxEmit.React,
|
|
365
581
|
target: monaco.typescript.ScriptTarget.ESNext,
|
|
366
582
|
module: monaco.typescript.ModuleKind.ESNext,
|
|
@@ -370,33 +586,64 @@
|
|
|
370
586
|
console.log(PREFIX, "setCompilerOptions:", JSON.stringify(compilerOpts));
|
|
371
587
|
jsDef.setCompilerOptions(compilerOpts);
|
|
372
588
|
|
|
373
|
-
|
|
589
|
+
const diagOpts = {
|
|
374
590
|
noSemanticValidation: true,
|
|
375
|
-
|
|
591
|
+
// Server-side esbuild does the real syntax check at deploy time;
|
|
592
|
+
// Monaco's TS parser produces noisy false positives on raw JSX
|
|
593
|
+
// (1109, 1005, 1128 etc.), so we silence its squiggles entirely.
|
|
594
|
+
noSyntaxValidation: true,
|
|
376
595
|
noSuggestionDiagnostics: true,
|
|
377
596
|
diagnosticCodesToIgnore: [17004],
|
|
378
597
|
};
|
|
379
598
|
console.log(PREFIX, "setDiagnosticsOptions:", JSON.stringify(diagOpts));
|
|
380
599
|
jsDef.setDiagnosticsOptions(diagOpts);
|
|
381
600
|
|
|
382
|
-
console.log(
|
|
383
|
-
|
|
601
|
+
console.log(
|
|
602
|
+
PREFIX,
|
|
603
|
+
"readback compilerOptions:",
|
|
604
|
+
JSON.stringify(jsDef.getCompilerOptions()),
|
|
605
|
+
);
|
|
606
|
+
console.log(
|
|
607
|
+
PREFIX,
|
|
608
|
+
"readback diagnosticsOptions:",
|
|
609
|
+
JSON.stringify(jsDef.getDiagnosticsOptions()),
|
|
610
|
+
);
|
|
384
611
|
}
|
|
385
612
|
|
|
386
|
-
|
|
613
|
+
/**
|
|
614
|
+
* Re-apply JSX defaults before Monaco model creation. Exported for
|
|
615
|
+
* `oneditprepare` to call. Idempotent — wraps `ensureJsxSetup`.
|
|
616
|
+
* @returns {void}
|
|
617
|
+
*/
|
|
387
618
|
window.__fcApplyJsxDefaults = function () {
|
|
388
619
|
ensureJsxSetup();
|
|
389
620
|
};
|
|
390
621
|
|
|
622
|
+
/**
|
|
623
|
+
* Lazy-load Monaco from the admin endpoint `/portal-react/vs/` the
|
|
624
|
+
* first time an editor opens. If Monaco is already on `window`
|
|
625
|
+
* (Node-RED bundled its own copy, or another node loaded it first),
|
|
626
|
+
* skip the loader script and run our setup inline.
|
|
627
|
+
*
|
|
628
|
+
* The callback signature is `cb(failed)` — `failed === true` when the
|
|
629
|
+
* loader script threw at load time; editors should fall back to a
|
|
630
|
+
* plain textarea in that case.
|
|
631
|
+
*
|
|
632
|
+
* @param {(failed?: boolean) => void} cb
|
|
633
|
+
* @returns {void}
|
|
634
|
+
*/
|
|
391
635
|
window.__fcLoadMonaco = function (cb) {
|
|
392
636
|
console.log(PREFIX, "loadMonaco called, monaco exists:", !!window.monaco);
|
|
393
637
|
if (window.monaco) {
|
|
394
|
-
console.log(
|
|
638
|
+
console.log(
|
|
639
|
+
PREFIX,
|
|
640
|
+
"Monaco already loaded (by Node-RED?), running setup inline",
|
|
641
|
+
);
|
|
395
642
|
ensureJsxSetup();
|
|
396
643
|
cb();
|
|
397
644
|
return;
|
|
398
645
|
}
|
|
399
|
-
|
|
646
|
+
const s = document.createElement("script");
|
|
400
647
|
s.src = "portal-react/vs/loader.js";
|
|
401
648
|
s.onload = function () {
|
|
402
649
|
console.log(PREFIX, "loader.js loaded, configuring require paths");
|
|
@@ -429,26 +676,6 @@
|
|
|
429
676
|
<label for="node-input-compName"><i class="fa fa-cube"></i> JSX Tag</label>
|
|
430
677
|
<input type="text" id="node-input-compName" placeholder="MyComponent" />
|
|
431
678
|
</div>
|
|
432
|
-
<div class="form-row" style="display:flex;gap:8px;">
|
|
433
|
-
<div style="flex:1">
|
|
434
|
-
<label><i class="fa fa-sign-in"></i> Input fields</label>
|
|
435
|
-
<input
|
|
436
|
-
type="text"
|
|
437
|
-
id="node-input-compInputs"
|
|
438
|
-
placeholder="payload,topic"
|
|
439
|
-
style="width:100%"
|
|
440
|
-
/>
|
|
441
|
-
</div>
|
|
442
|
-
<div style="flex:1">
|
|
443
|
-
<label><i class="fa fa-sign-out"></i> Output fields</label>
|
|
444
|
-
<input
|
|
445
|
-
type="text"
|
|
446
|
-
id="node-input-compOutputs"
|
|
447
|
-
placeholder="payload,topic"
|
|
448
|
-
style="width:100%"
|
|
449
|
-
/>
|
|
450
|
-
</div>
|
|
451
|
-
</div>
|
|
452
679
|
<div class="form-row" style="margin-bottom:4px;">
|
|
453
680
|
<label><i class="fa fa-code"></i> JSX Code</label>
|
|
454
681
|
</div>
|
|
@@ -469,9 +696,9 @@
|
|
|
469
696
|
|
|
470
697
|
<script type="text/javascript">
|
|
471
698
|
(function () {
|
|
472
|
-
|
|
699
|
+
let compEditorInstance = null;
|
|
473
700
|
|
|
474
|
-
|
|
701
|
+
const COMP_STARTER = [
|
|
475
702
|
"function StatusCard({ label, value, unit }) {",
|
|
476
703
|
" return (",
|
|
477
704
|
' <div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-6">',
|
|
@@ -484,59 +711,119 @@
|
|
|
484
711
|
"}",
|
|
485
712
|
].join("\n");
|
|
486
713
|
|
|
714
|
+
// FC_NAME_RE / FC_NAME_MAX / FC_NAME_BLACKLIST mirror lib/helpers.js
|
|
715
|
+
// isSafeName so the editor flags invalid names before deploy.
|
|
716
|
+
const FC_NAME_RE = /^[A-Za-z_$][\w$]*$/;
|
|
717
|
+
const FC_NAME_MAX = 64;
|
|
718
|
+
const FC_NAME_BLACKLIST = [
|
|
719
|
+
"__proto__", "constructor", "prototype", "hasOwnProperty",
|
|
720
|
+
"isPrototypeOf", "propertyIsEnumerable", "toString", "valueOf",
|
|
721
|
+
"toLocaleString",
|
|
722
|
+
];
|
|
723
|
+
function fcValidateName(v) {
|
|
724
|
+
return (
|
|
725
|
+
typeof v === "string" &&
|
|
726
|
+
v.length > 0 &&
|
|
727
|
+
v.length <= FC_NAME_MAX &&
|
|
728
|
+
FC_NAME_RE.test(v) &&
|
|
729
|
+
FC_NAME_BLACKLIST.indexOf(v) === -1
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
487
733
|
RED.nodes.registerType("fc-portal-component", {
|
|
488
734
|
category: "fromcubes",
|
|
489
735
|
color: "#a8d8ea",
|
|
490
736
|
defaults: {
|
|
491
737
|
name: { value: "" },
|
|
492
|
-
compName: {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
738
|
+
compName: {
|
|
739
|
+
value: "StatusCard",
|
|
740
|
+
required: true,
|
|
741
|
+
validate: fcValidateName,
|
|
742
|
+
},
|
|
743
|
+
compCode: {
|
|
744
|
+
value: COMP_STARTER,
|
|
745
|
+
validate: function (v) {
|
|
746
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
747
|
+
},
|
|
748
|
+
},
|
|
496
749
|
},
|
|
497
750
|
inputs: 0,
|
|
498
751
|
outputs: 0,
|
|
499
752
|
icon: "font-awesome/fa-cube",
|
|
500
|
-
paletteLabel: "
|
|
753
|
+
paletteLabel: "React Component",
|
|
754
|
+
inputLabels: [],
|
|
755
|
+
outputLabels: [],
|
|
756
|
+
/**
|
|
757
|
+
* Canvas label resolver — falls back through `name → compName →
|
|
758
|
+
* "component"` so a freshly-dropped node shows something readable
|
|
759
|
+
* before the user opens the edit dialog.
|
|
760
|
+
* @returns {string}
|
|
761
|
+
*/
|
|
501
762
|
label: function () {
|
|
502
763
|
return this.name || this.compName || "component";
|
|
503
764
|
},
|
|
504
765
|
|
|
766
|
+
/**
|
|
767
|
+
* Node-RED editor lifecycle: called every time the user opens the
|
|
768
|
+
* edit dialog for this node. Loads Monaco lazily, creates a JSX
|
|
769
|
+
* model with a stable URI (`file:///fc-comp-<nodeId>.jsx`) and
|
|
770
|
+
* attaches it to the `#fcc-monaco` container. Falls back to a
|
|
771
|
+
* `<textarea>` if Monaco fails to load.
|
|
772
|
+
* @returns {void}
|
|
773
|
+
*/
|
|
505
774
|
oneditprepare: function () {
|
|
506
|
-
|
|
507
|
-
|
|
775
|
+
const node = this;
|
|
776
|
+
const code =
|
|
777
|
+
typeof node.compCode === "string" ? node.compCode : COMP_STARTER;
|
|
508
778
|
console.log("[FC-Monaco] COMP oneditprepare, node.id=" + node.id);
|
|
509
779
|
|
|
510
780
|
if (!window.__fcTwClasses) {
|
|
511
781
|
console.log("[FC-Monaco] loading tw-classes...");
|
|
512
782
|
$.getJSON("portal-react/tw-classes", function (classes) {
|
|
513
|
-
console.log(
|
|
783
|
+
console.log(
|
|
784
|
+
"[FC-Monaco] tw-classes loaded, count=" + classes.length,
|
|
785
|
+
);
|
|
514
786
|
window.__fcTwClasses = classes;
|
|
515
787
|
}).fail(function (xhr) {
|
|
516
|
-
console.error(
|
|
788
|
+
console.error(
|
|
789
|
+
"[FC-Monaco] tw-classes FAILED:",
|
|
790
|
+
xhr.status,
|
|
791
|
+
xhr.statusText,
|
|
792
|
+
);
|
|
517
793
|
});
|
|
518
794
|
}
|
|
519
795
|
|
|
520
796
|
window.__fcLoadMonaco(function (failed) {
|
|
521
797
|
if (failed) {
|
|
522
|
-
console.error(
|
|
798
|
+
console.error(
|
|
799
|
+
"[FC-Monaco] COMP: Monaco load failed, using fallback textarea",
|
|
800
|
+
);
|
|
523
801
|
$("#fcc-monaco").hide();
|
|
524
802
|
$("#fcc-fallback").show().val(code);
|
|
525
803
|
return;
|
|
526
804
|
}
|
|
527
805
|
|
|
528
|
-
console.log(
|
|
806
|
+
console.log(
|
|
807
|
+
"[FC-Monaco] COMP: applying JSX defaults before model creation",
|
|
808
|
+
);
|
|
529
809
|
window.__fcApplyJsxDefaults();
|
|
530
810
|
|
|
531
|
-
|
|
811
|
+
const compUri = monaco.Uri.parse("file:///fc-comp-" + node.id + ".jsx");
|
|
532
812
|
console.log("[FC-Monaco] COMP: model URI=" + compUri.toString());
|
|
533
|
-
|
|
813
|
+
const existingModel = monaco.editor.getModel(compUri);
|
|
534
814
|
if (existingModel) {
|
|
535
815
|
console.log("[FC-Monaco] COMP: disposing existing model");
|
|
536
816
|
existingModel.dispose();
|
|
537
817
|
}
|
|
538
|
-
|
|
539
|
-
|
|
818
|
+
const compModel = monaco.editor.createModel(
|
|
819
|
+
code,
|
|
820
|
+
"javascript",
|
|
821
|
+
compUri,
|
|
822
|
+
);
|
|
823
|
+
console.log(
|
|
824
|
+
"[FC-Monaco] COMP: model created, language=" +
|
|
825
|
+
compModel.getLanguageId(),
|
|
826
|
+
);
|
|
540
827
|
|
|
541
828
|
compEditorInstance = monaco.editor.create(
|
|
542
829
|
document.getElementById("fcc-monaco"),
|
|
@@ -546,21 +833,41 @@
|
|
|
546
833
|
|
|
547
834
|
// Log markers after a short delay (diagnostics are async)
|
|
548
835
|
setTimeout(function () {
|
|
549
|
-
|
|
550
|
-
console.log(
|
|
836
|
+
const markers = monaco.editor.getModelMarkers({ resource: compUri });
|
|
837
|
+
console.log(
|
|
838
|
+
"[FC-Monaco] COMP: markers after 500ms, count=" + markers.length,
|
|
839
|
+
);
|
|
551
840
|
markers.forEach(function (m) {
|
|
552
|
-
console.log(
|
|
841
|
+
console.log(
|
|
842
|
+
"[FC-Monaco] COMP marker: code=" + m.code + " msg=" + m.message,
|
|
843
|
+
);
|
|
553
844
|
});
|
|
554
845
|
// Also log current compiler options state
|
|
555
|
-
console.log(
|
|
556
|
-
|
|
846
|
+
console.log(
|
|
847
|
+
"[FC-Monaco] COMP: current compilerOptions:",
|
|
848
|
+
JSON.stringify(
|
|
849
|
+
monaco.typescript.javascriptDefaults.getCompilerOptions(),
|
|
850
|
+
),
|
|
851
|
+
);
|
|
852
|
+
console.log(
|
|
853
|
+
"[FC-Monaco] COMP: current diagnosticsOptions:",
|
|
854
|
+
JSON.stringify(
|
|
855
|
+
monaco.typescript.javascriptDefaults.getDiagnosticsOptions(),
|
|
856
|
+
),
|
|
857
|
+
);
|
|
557
858
|
}, 500);
|
|
558
859
|
});
|
|
559
860
|
},
|
|
560
861
|
|
|
862
|
+
/**
|
|
863
|
+
* Editor → flow: copy Monaco editor contents into the hidden
|
|
864
|
+
* `#node-input-compCode` input and the node's `compCode` property,
|
|
865
|
+
* then dispose Monaco's model + editor to free GPU/CPU resources.
|
|
866
|
+
* @returns {void}
|
|
867
|
+
*/
|
|
561
868
|
oneditsave: function () {
|
|
562
869
|
console.log("[FC-Monaco] COMP oneditsave");
|
|
563
|
-
|
|
870
|
+
const code = compEditorInstance
|
|
564
871
|
? compEditorInstance.getValue()
|
|
565
872
|
: $("#fcc-fallback").val();
|
|
566
873
|
$("#node-input-compCode").val(code);
|
|
@@ -573,6 +880,12 @@
|
|
|
573
880
|
}
|
|
574
881
|
},
|
|
575
882
|
|
|
883
|
+
/**
|
|
884
|
+
* User cancelled the dialog — discard Monaco state without writing
|
|
885
|
+
* back to the node. Same dispose pattern as oneditsave to avoid
|
|
886
|
+
* leaking model graphs.
|
|
887
|
+
* @returns {void}
|
|
888
|
+
*/
|
|
576
889
|
oneditcancel: function () {
|
|
577
890
|
console.log("[FC-Monaco] COMP oneditcancel");
|
|
578
891
|
if (compEditorInstance) {
|
|
@@ -583,8 +896,15 @@
|
|
|
583
896
|
}
|
|
584
897
|
},
|
|
585
898
|
|
|
899
|
+
/**
|
|
900
|
+
* Resize handler — Monaco doesn't react to container size changes
|
|
901
|
+
* unless we call `editor.layout()` explicitly. Subtracts ~180 px of
|
|
902
|
+
* chrome (label, buttons, padding) from the dialog height.
|
|
903
|
+
* @param {{height: number, width: number}} size
|
|
904
|
+
* @returns {void}
|
|
905
|
+
*/
|
|
586
906
|
oneditresize: function (size) {
|
|
587
|
-
|
|
907
|
+
let h = size.height - 180;
|
|
588
908
|
if (h < 150) h = 150;
|
|
589
909
|
$("#fcc-editor-wrap").css("height", h + "px");
|
|
590
910
|
if (compEditorInstance) compEditorInstance.layout();
|
|
@@ -622,6 +942,264 @@
|
|
|
622
942
|
</p>
|
|
623
943
|
</script>
|
|
624
944
|
|
|
945
|
+
<!-- ============================================================
|
|
946
|
+
fc-portal-utility – canvas node (helpers / hooks / constants)
|
|
947
|
+
============================================================ -->
|
|
948
|
+
<script type="text/html" data-template-name="fc-portal-utility">
|
|
949
|
+
<div class="form-row">
|
|
950
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
951
|
+
<input type="text" id="node-input-name" placeholder="Label on canvas" />
|
|
952
|
+
</div>
|
|
953
|
+
<div class="form-row">
|
|
954
|
+
<label for="node-input-utilName"><i class="fa fa-wrench"></i> Module name</label>
|
|
955
|
+
<input type="text" id="node-input-utilName" placeholder="mathHelpers" />
|
|
956
|
+
</div>
|
|
957
|
+
<div class="form-row" style="margin-bottom:4px;">
|
|
958
|
+
<label><i class="fa fa-code"></i> Code</label>
|
|
959
|
+
<div style="font-size:11px;opacity:.5;margin-top:2px;">
|
|
960
|
+
Top-level helpers, custom hooks and constants. Available globally to portals
|
|
961
|
+
that reference any of the symbols declared here.
|
|
962
|
+
</div>
|
|
963
|
+
</div>
|
|
964
|
+
<div class="form-row node-text-editor-row">
|
|
965
|
+
<div
|
|
966
|
+
id="fcu-editor-wrap"
|
|
967
|
+
style="width:100%;height:350px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
|
|
968
|
+
>
|
|
969
|
+
<div id="fcu-monaco" style="width:100%;height:100%;"></div>
|
|
970
|
+
<textarea
|
|
971
|
+
id="fcu-fallback"
|
|
972
|
+
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;"
|
|
973
|
+
></textarea>
|
|
974
|
+
</div>
|
|
975
|
+
</div>
|
|
976
|
+
<input type="hidden" id="node-input-utilCode" />
|
|
977
|
+
</script>
|
|
978
|
+
|
|
979
|
+
<script type="text/javascript">
|
|
980
|
+
(function () {
|
|
981
|
+
let utilEditorInstance = null;
|
|
982
|
+
|
|
983
|
+
// Local copy of the identifier validator — each editor <script> block is
|
|
984
|
+
// wrapped in its own IIFE, so the helper defined in the fc-portal-component
|
|
985
|
+
// block above is out of scope here. Mirrors lib/helpers.js isSafeName.
|
|
986
|
+
const FC_NAME_RE = /^[A-Za-z_$][\w$]*$/;
|
|
987
|
+
const FC_NAME_MAX = 64;
|
|
988
|
+
const FC_NAME_BLACKLIST = [
|
|
989
|
+
"__proto__", "constructor", "prototype", "hasOwnProperty",
|
|
990
|
+
"isPrototypeOf", "propertyIsEnumerable", "toString", "valueOf",
|
|
991
|
+
"toLocaleString",
|
|
992
|
+
];
|
|
993
|
+
/**
|
|
994
|
+
* Mirror of `lib/helpers.js#isSafeName` — used as `defaults.utilName.validate`
|
|
995
|
+
* so the editor flags invalid identifiers in red before the user can
|
|
996
|
+
* deploy a node that the server would later reject. Rules: non-empty
|
|
997
|
+
* string, ≤ 64 chars, JS identifier syntax, not on the prototype-key
|
|
998
|
+
* blacklist.
|
|
999
|
+
* @param {unknown} v
|
|
1000
|
+
* @returns {boolean}
|
|
1001
|
+
*/
|
|
1002
|
+
function fcValidateName(v) {
|
|
1003
|
+
return (
|
|
1004
|
+
typeof v === "string" &&
|
|
1005
|
+
v.length > 0 &&
|
|
1006
|
+
v.length <= FC_NAME_MAX &&
|
|
1007
|
+
FC_NAME_RE.test(v) &&
|
|
1008
|
+
FC_NAME_BLACKLIST.indexOf(v) === -1
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const UTIL_STARTER = [
|
|
1013
|
+
"// Helpers / custom hooks / constants — top-level, no React component.",
|
|
1014
|
+
"// Each symbol declared here is available globally in any portal that",
|
|
1015
|
+
"// references it (selective inclusion: unused utility nodes are skipped).",
|
|
1016
|
+
"",
|
|
1017
|
+
"const PI2 = Math.PI * 2;",
|
|
1018
|
+
"",
|
|
1019
|
+
"function clamp(n, min, max) {",
|
|
1020
|
+
" return Math.max(min, Math.min(max, n));",
|
|
1021
|
+
"}",
|
|
1022
|
+
"",
|
|
1023
|
+
"// Custom React hook — call from inside App() or library components",
|
|
1024
|
+
"function useDebounce(value, ms = 300) {",
|
|
1025
|
+
" const [v, setV] = React.useState(value);",
|
|
1026
|
+
" React.useEffect(() => {",
|
|
1027
|
+
" const t = setTimeout(() => setV(value), ms);",
|
|
1028
|
+
" return () => clearTimeout(t);",
|
|
1029
|
+
" }, [value, ms]);",
|
|
1030
|
+
" return v;",
|
|
1031
|
+
"}",
|
|
1032
|
+
].join("\n");
|
|
1033
|
+
|
|
1034
|
+
RED.nodes.registerType("fc-portal-utility", {
|
|
1035
|
+
category: "fromcubes",
|
|
1036
|
+
color: "#fbbf24",
|
|
1037
|
+
defaults: {
|
|
1038
|
+
name: { value: "" },
|
|
1039
|
+
utilName: {
|
|
1040
|
+
value: "myHelpers",
|
|
1041
|
+
required: true,
|
|
1042
|
+
validate: fcValidateName,
|
|
1043
|
+
},
|
|
1044
|
+
utilCode: {
|
|
1045
|
+
value: UTIL_STARTER,
|
|
1046
|
+
validate: function (v) {
|
|
1047
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
1048
|
+
},
|
|
1049
|
+
},
|
|
1050
|
+
},
|
|
1051
|
+
inputs: 0,
|
|
1052
|
+
outputs: 0,
|
|
1053
|
+
icon: "font-awesome/fa-wrench",
|
|
1054
|
+
paletteLabel: "React Utility",
|
|
1055
|
+
inputLabels: [],
|
|
1056
|
+
outputLabels: [],
|
|
1057
|
+
/**
|
|
1058
|
+
* Canvas label — `name → utilName → "utility"`.
|
|
1059
|
+
* @returns {string}
|
|
1060
|
+
*/
|
|
1061
|
+
label: function () {
|
|
1062
|
+
return this.name || this.utilName || "utility";
|
|
1063
|
+
},
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Editor lifecycle for `fc-portal-utility`: lazy-load Monaco,
|
|
1067
|
+
* create a JS model with URI `file:///fc-util-<nodeId>.js` (note
|
|
1068
|
+
* `.js` not `.jsx` — utility code is plain JS).
|
|
1069
|
+
* @returns {void}
|
|
1070
|
+
*/
|
|
1071
|
+
oneditprepare: function () {
|
|
1072
|
+
const node = this;
|
|
1073
|
+
const code =
|
|
1074
|
+
typeof node.utilCode === "string" ? node.utilCode : UTIL_STARTER;
|
|
1075
|
+
console.log("[FC-Monaco] UTIL oneditprepare, node.id=" + node.id);
|
|
1076
|
+
|
|
1077
|
+
if (!window.__fcTwClasses) {
|
|
1078
|
+
$.getJSON("portal-react/tw-classes", function (classes) {
|
|
1079
|
+
window.__fcTwClasses = classes;
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
window.__fcLoadMonaco(function (failed) {
|
|
1084
|
+
if (failed) {
|
|
1085
|
+
$("#fcu-monaco").hide();
|
|
1086
|
+
$("#fcu-fallback").show().val(code);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
window.__fcApplyJsxDefaults();
|
|
1090
|
+
|
|
1091
|
+
const utilUri = monaco.Uri.parse("file:///fc-util-" + node.id + ".js");
|
|
1092
|
+
const existingModel = monaco.editor.getModel(utilUri);
|
|
1093
|
+
if (existingModel) existingModel.dispose();
|
|
1094
|
+
const utilModel = monaco.editor.createModel(code, "javascript", utilUri);
|
|
1095
|
+
|
|
1096
|
+
utilEditorInstance = monaco.editor.create(
|
|
1097
|
+
document.getElementById("fcu-monaco"),
|
|
1098
|
+
Object.assign({ model: utilModel }, window.__fcEditorOpts),
|
|
1099
|
+
);
|
|
1100
|
+
});
|
|
1101
|
+
},
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Editor → flow: write Monaco contents back to `utilCode`, dispose
|
|
1105
|
+
* model+editor.
|
|
1106
|
+
* @returns {void}
|
|
1107
|
+
*/
|
|
1108
|
+
oneditsave: function () {
|
|
1109
|
+
const code = utilEditorInstance
|
|
1110
|
+
? utilEditorInstance.getValue()
|
|
1111
|
+
: $("#fcu-fallback").val();
|
|
1112
|
+
$("#node-input-utilCode").val(code);
|
|
1113
|
+
this.utilCode = code;
|
|
1114
|
+
if (utilEditorInstance) {
|
|
1115
|
+
utilEditorInstance.getModel().dispose();
|
|
1116
|
+
utilEditorInstance.dispose();
|
|
1117
|
+
utilEditorInstance = null;
|
|
1118
|
+
}
|
|
1119
|
+
},
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Discard Monaco state without writing back.
|
|
1123
|
+
* @returns {void}
|
|
1124
|
+
*/
|
|
1125
|
+
oneditcancel: function () {
|
|
1126
|
+
if (utilEditorInstance) {
|
|
1127
|
+
utilEditorInstance.getModel().dispose();
|
|
1128
|
+
utilEditorInstance.dispose();
|
|
1129
|
+
utilEditorInstance = null;
|
|
1130
|
+
}
|
|
1131
|
+
},
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Layout Monaco on dialog resize. -200 px chrome for the utility
|
|
1135
|
+
* dialog (taller because of the symbol-list panel).
|
|
1136
|
+
* @param {{height: number, width: number}} size
|
|
1137
|
+
* @returns {void}
|
|
1138
|
+
*/
|
|
1139
|
+
oneditresize: function (size) {
|
|
1140
|
+
let h = size.height - 200;
|
|
1141
|
+
if (h < 150) h = 150;
|
|
1142
|
+
$("#fcu-editor-wrap").css("height", h + "px");
|
|
1143
|
+
if (utilEditorInstance) utilEditorInstance.layout();
|
|
1144
|
+
},
|
|
1145
|
+
});
|
|
1146
|
+
})();
|
|
1147
|
+
</script>
|
|
1148
|
+
|
|
1149
|
+
<script type="text/html" data-help-name="fc-portal-utility">
|
|
1150
|
+
<p>
|
|
1151
|
+
Defines shared <strong>helpers</strong>, <strong>custom hooks</strong> and
|
|
1152
|
+
<strong>constants</strong> available to all <strong>portal-react</strong>
|
|
1153
|
+
nodes. Unlike <code>fc-portal-component</code>, the code is injected raw at
|
|
1154
|
+
top level (no IIFE wrapper) — a single utility node may declare many symbols.
|
|
1155
|
+
</p>
|
|
1156
|
+
<h3>Properties</h3>
|
|
1157
|
+
<dl>
|
|
1158
|
+
<dt>Module name</dt>
|
|
1159
|
+
<dd>
|
|
1160
|
+
Identifier for this utility node. Shares a namespace with
|
|
1161
|
+
<code>fc-portal-component</code> names — must be unique across all
|
|
1162
|
+
components and utilities.
|
|
1163
|
+
</dd>
|
|
1164
|
+
<dt>Code</dt>
|
|
1165
|
+
<dd>
|
|
1166
|
+
Top-level JavaScript: <code>const</code>, <code>let</code>,
|
|
1167
|
+
<code>function</code>, <code>class</code> declarations. <code>import</code>
|
|
1168
|
+
statements at the top are auto-hoisted into the bundle.
|
|
1169
|
+
</dd>
|
|
1170
|
+
</dl>
|
|
1171
|
+
<h3>Selective inclusion</h3>
|
|
1172
|
+
<p>
|
|
1173
|
+
A utility node is bundled into a portal only when the portal's JSX (or any
|
|
1174
|
+
of its referenced library components) mentions at least one of the symbols
|
|
1175
|
+
declared in the utility. Unused utility nodes are skipped.
|
|
1176
|
+
</p>
|
|
1177
|
+
<p>
|
|
1178
|
+
Group related symbols in one node — referencing any one symbol pulls in the
|
|
1179
|
+
whole node's code. Split unrelated helpers across multiple nodes for finer
|
|
1180
|
+
granularity.
|
|
1181
|
+
</p>
|
|
1182
|
+
<h3>Example</h3>
|
|
1183
|
+
<pre>function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
|
|
1184
|
+
|
|
1185
|
+
function useDebounce(value, ms = 300) {
|
|
1186
|
+
const [v, setV] = React.useState(value);
|
|
1187
|
+
React.useEffect(() => {
|
|
1188
|
+
const t = setTimeout(() => setV(value), ms);
|
|
1189
|
+
return () => clearTimeout(t);
|
|
1190
|
+
}, [value, ms]);
|
|
1191
|
+
return v;
|
|
1192
|
+
}</pre>
|
|
1193
|
+
<p>
|
|
1194
|
+
Then in any <strong>portal-react</strong> node:
|
|
1195
|
+
</p>
|
|
1196
|
+
<pre>function App() {
|
|
1197
|
+
const { data } = useNodeRed();
|
|
1198
|
+
const slow = useDebounce(data?.value);
|
|
1199
|
+
return <div>{clamp(slow ?? 0, 0, 100)}</div>;
|
|
1200
|
+
}</pre>
|
|
1201
|
+
</script>
|
|
1202
|
+
|
|
625
1203
|
<!-- ============================================================
|
|
626
1204
|
portal-react – main node
|
|
627
1205
|
============================================================ -->
|
|
@@ -631,6 +1209,37 @@
|
|
|
631
1209
|
<ul id="fc-tabs" style="min-width:600px;margin-bottom:0;"></ul>
|
|
632
1210
|
</div>
|
|
633
1211
|
<div id="fc-tabs-content">
|
|
1212
|
+
<!-- ── Tab: JSX ── -->
|
|
1213
|
+
<div id="fc-tab-jsx" class="fc-tab-pane">
|
|
1214
|
+
<div class="form-row node-text-editor-row">
|
|
1215
|
+
<div
|
|
1216
|
+
id="fc-editor-wrap"
|
|
1217
|
+
style="width:100%;height:420px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
|
|
1218
|
+
>
|
|
1219
|
+
<div id="fc-monaco" style="width:100%;height:100%;"></div>
|
|
1220
|
+
<textarea
|
|
1221
|
+
id="fc-fallback"
|
|
1222
|
+
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;"
|
|
1223
|
+
></textarea>
|
|
1224
|
+
</div>
|
|
1225
|
+
</div>
|
|
1226
|
+
<div class="form-row" style="display:flex;gap:8px;margin-top:4px;flex-wrap:wrap;">
|
|
1227
|
+
<button type="button" class="red-ui-button" id="fc-btn-starter">
|
|
1228
|
+
<i class="fa fa-magic"></i> Default
|
|
1229
|
+
</button>
|
|
1230
|
+
<button type="button" class="red-ui-button" id="fc-btn-components">
|
|
1231
|
+
<i class="fa fa-cube"></i> Components
|
|
1232
|
+
</button>
|
|
1233
|
+
<button type="button" class="red-ui-button" id="fc-btn-utilities">
|
|
1234
|
+
<i class="fa fa-wrench"></i> Utilities
|
|
1235
|
+
</button>
|
|
1236
|
+
<span style="flex:1"></span>
|
|
1237
|
+
<button type="button" class="red-ui-button" id="fc-btn-preview">
|
|
1238
|
+
<i class="fa fa-eye"></i> Preview
|
|
1239
|
+
</button>
|
|
1240
|
+
</div>
|
|
1241
|
+
</div>
|
|
1242
|
+
|
|
634
1243
|
<!-- ── Tab: Properties ── -->
|
|
635
1244
|
<div id="fc-tab-props" class="fc-tab-pane" style="display:none;">
|
|
636
1245
|
<div class="form-row">
|
|
@@ -638,10 +1247,22 @@
|
|
|
638
1247
|
<input type="text" id="node-input-name" placeholder="My Portal" />
|
|
639
1248
|
</div>
|
|
640
1249
|
<div class="form-row">
|
|
641
|
-
<label for="node-input-
|
|
642
|
-
><i class="fa fa-globe"></i>
|
|
1250
|
+
<label for="node-input-subPath"
|
|
1251
|
+
><i class="fa fa-globe"></i> Sub-path</label
|
|
643
1252
|
>
|
|
644
|
-
<
|
|
1253
|
+
<div style="display:inline-flex;align-items:stretch;width:70%;">
|
|
1254
|
+
<span
|
|
1255
|
+
style="display:inline-flex;align-items:center;padding:0 8px;background:var(--red-ui-secondary-background,#f3f3f3);border:1px solid var(--red-ui-form-input-border-color,#ccc);border-right:none;border-radius:4px 0 0 4px;color:var(--red-ui-secondary-text-color,#888);font-family:monospace;user-select:none;"
|
|
1256
|
+
>/fromcubes/</span
|
|
1257
|
+
>
|
|
1258
|
+
<input
|
|
1259
|
+
type="text"
|
|
1260
|
+
id="node-input-subPath"
|
|
1261
|
+
placeholder="sensors"
|
|
1262
|
+
style="flex:1;border-radius:0 4px 4px 0;"
|
|
1263
|
+
/>
|
|
1264
|
+
</div>
|
|
1265
|
+
<input type="hidden" id="node-input-endpoint" />
|
|
645
1266
|
<div
|
|
646
1267
|
style="font-size:11px;opacity:.5;margin-top:2px;margin-left:105px;"
|
|
647
1268
|
id="fc-url-hint"
|
|
@@ -653,41 +1274,41 @@
|
|
|
653
1274
|
>
|
|
654
1275
|
<input type="text" id="node-input-pageTitle" placeholder="Portal" />
|
|
655
1276
|
</div>
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
<div id="fc-tab-jsx" class="fc-tab-pane">
|
|
660
|
-
<div class="form-row" style="margin-bottom:0;">
|
|
661
|
-
<div style="font-size:11px;opacity:.6;margin-bottom:4px;">
|
|
662
|
-
<code>useNodeRed()</code> →
|
|
663
|
-
<code>{ data, send }</code> | Components from
|
|
664
|
-
<code>fc-portal-component</code> nodes auto-imported |
|
|
665
|
-
Must export <code><App /></code> |
|
|
666
|
-
<strong>Transpiled server-side at deploy</strong>
|
|
667
|
-
</div>
|
|
668
|
-
</div>
|
|
669
|
-
<div class="form-row node-text-editor-row">
|
|
1277
|
+
<div class="form-row node-input-libs-container-row">
|
|
1278
|
+
<label style="width:auto;"><i class="fa fa-archive"></i> Modules</label>
|
|
1279
|
+
<ol id="node-input-libs-container"></ol>
|
|
670
1280
|
<div
|
|
671
|
-
|
|
672
|
-
style="width:100%;height:420px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
|
|
1281
|
+
style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;"
|
|
673
1282
|
>
|
|
674
|
-
|
|
675
|
-
<textarea
|
|
676
|
-
id="fc-fallback"
|
|
677
|
-
style="display:none;width:100%;height:100%;font-family:'Cascadia Code',Consolas,monospace;font-size:13px;background:#1e1e1e;color:#d4d4d4;border:none;padding:12px;resize:none;"
|
|
678
|
-
></textarea>
|
|
1283
|
+
npm packages to bundle. Node-RED auto-installs them at deploy time.
|
|
679
1284
|
</div>
|
|
680
1285
|
</div>
|
|
681
|
-
<div class="form-row" style="
|
|
682
|
-
<
|
|
683
|
-
<
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
1286
|
+
<div class="form-row" style="margin-top:12px;">
|
|
1287
|
+
<label style="width:auto;">
|
|
1288
|
+
<input
|
|
1289
|
+
type="checkbox"
|
|
1290
|
+
id="node-input-portalAuth"
|
|
1291
|
+
style="width:auto;margin:0 8px 0 0;vertical-align:middle;"
|
|
1292
|
+
/>
|
|
1293
|
+
Enable Portal Auth headers
|
|
1294
|
+
</label>
|
|
1295
|
+
<div style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;">
|
|
1296
|
+
Read <code>X-Portal-*</code> headers from reverse proxy.
|
|
1297
|
+
</div>
|
|
1298
|
+
</div>
|
|
1299
|
+
<div class="form-row" style="margin-top:12px;">
|
|
1300
|
+
<label style="width:auto;">
|
|
1301
|
+
<input
|
|
1302
|
+
type="checkbox"
|
|
1303
|
+
id="node-input-showWsStatus"
|
|
1304
|
+
style="width:auto;margin:0 8px 0 0;vertical-align:middle;"
|
|
1305
|
+
/>
|
|
1306
|
+
Show WebSocket status indicator
|
|
1307
|
+
</label>
|
|
1308
|
+
<div style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;">
|
|
1309
|
+
Displays a small <em>fromcubes</em> badge in the bottom-right corner
|
|
1310
|
+
showing connection state.
|
|
1311
|
+
</div>
|
|
691
1312
|
</div>
|
|
692
1313
|
</div>
|
|
693
1314
|
|
|
@@ -696,7 +1317,8 @@
|
|
|
696
1317
|
<div class="form-row">
|
|
697
1318
|
<div style="font-size:11px;opacity:.6;margin-bottom:6px;">
|
|
698
1319
|
Extra tags injected into <code><head></code>. CDN links, fonts,
|
|
699
|
-
stylesheets, meta tags.
|
|
1320
|
+
stylesheets, meta tags. Trusted authors only: scripts and event
|
|
1321
|
+
handlers run in the public portal page.
|
|
700
1322
|
</div>
|
|
701
1323
|
<div
|
|
702
1324
|
id="fc-head-wrap"
|
|
@@ -719,11 +1341,11 @@
|
|
|
719
1341
|
|
|
720
1342
|
<script type="text/javascript">
|
|
721
1343
|
(function () {
|
|
722
|
-
|
|
723
|
-
|
|
1344
|
+
let editorInstance = null;
|
|
1345
|
+
let headEditorInstance = null;
|
|
724
1346
|
|
|
725
|
-
|
|
726
|
-
"// useNodeRed() \u2192 { data, send }",
|
|
1347
|
+
const STARTER = [
|
|
1348
|
+
"// useNodeRed() \u2192 { data, send, portalClient }",
|
|
727
1349
|
"// data = last msg.payload from input wire",
|
|
728
1350
|
"// send(payload, topic?) = push msg to output wire",
|
|
729
1351
|
"// Components from fc-portal-component nodes are available by name.",
|
|
@@ -734,7 +1356,7 @@
|
|
|
734
1356
|
"",
|
|
735
1357
|
" return (",
|
|
736
1358
|
' <div className="min-h-screen bg-zinc-950 p-8">',
|
|
737
|
-
' <h1 className="text-2xl font-light text-cyan-400 mb-6">
|
|
1359
|
+
' <h1 className="text-2xl font-light text-cyan-400 mb-6">fromcubes</h1>',
|
|
738
1360
|
' <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">',
|
|
739
1361
|
' <StatusCard label="Value" value={d.value ?? "—"} unit="" />',
|
|
740
1362
|
" </div>",
|
|
@@ -754,37 +1376,94 @@
|
|
|
754
1376
|
color: "#61dafb",
|
|
755
1377
|
defaults: {
|
|
756
1378
|
name: { value: "" },
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1379
|
+
subPath: {
|
|
1380
|
+
value: "",
|
|
1381
|
+
required: true,
|
|
1382
|
+
validate: function (v) {
|
|
1383
|
+
if (typeof v !== "string") return false;
|
|
1384
|
+
const t = v.trim();
|
|
1385
|
+
if (t.length === 0) return false;
|
|
1386
|
+
if (/\s/.test(t)) return false;
|
|
1387
|
+
if (t.charAt(0) === "/" || t.charAt(t.length - 1) === "/") return false;
|
|
1388
|
+
const segs = t.split("/");
|
|
1389
|
+
for (let i = 0; i < segs.length; i++) {
|
|
1390
|
+
const s = segs[i];
|
|
1391
|
+
if (!s || s === "." || s === "..") return false;
|
|
1392
|
+
const lower = s.toLowerCase();
|
|
1393
|
+
if (lower === "public" || lower === "_ws") return false;
|
|
1394
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(s)) return false;
|
|
1395
|
+
}
|
|
1396
|
+
return true;
|
|
1397
|
+
},
|
|
1398
|
+
},
|
|
1399
|
+
endpoint: { value: "" },
|
|
1400
|
+
pageTitle: { value: "fromcubes" },
|
|
1401
|
+
componentCode: {
|
|
1402
|
+
value: STARTER,
|
|
1403
|
+
validate: function (v) {
|
|
1404
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
1405
|
+
},
|
|
1406
|
+
},
|
|
760
1407
|
customHead: { value: "" },
|
|
1408
|
+
portalAuth: { value: false },
|
|
1409
|
+
showWsStatus: { value: false },
|
|
1410
|
+
libs: { value: [] },
|
|
761
1411
|
},
|
|
762
1412
|
inputs: 1,
|
|
763
1413
|
outputs: 1,
|
|
764
1414
|
icon: "font-awesome/fa-desktop",
|
|
765
|
-
paletteLabel: "
|
|
1415
|
+
paletteLabel: "React Portal",
|
|
1416
|
+
// Source AND sink — we keep the default left-aligned input even though
|
|
1417
|
+
// Node-RED appearance docs suggest align: 'right' for nodes that sit at
|
|
1418
|
+
// the end of a flow. This portal is hybrid: the input wire pushes msgs
|
|
1419
|
+
// into the browser, while the output wire forwards UI events out. Left
|
|
1420
|
+
// alignment matches the dominant "msg → render" direction.
|
|
1421
|
+
inputLabels: ["broadcast / unicast msg → page"],
|
|
1422
|
+
outputLabels: ["UI event from page"],
|
|
1423
|
+
/**
|
|
1424
|
+
* Canvas label — shows `name` or the deployed URL (`/fromcubes/<subPath>`).
|
|
1425
|
+
* @returns {string}
|
|
1426
|
+
*/
|
|
766
1427
|
label: function () {
|
|
767
|
-
return this.name || this.
|
|
1428
|
+
return this.name || ("/fromcubes/" + (this.subPath || "?"));
|
|
768
1429
|
},
|
|
769
1430
|
|
|
1431
|
+
/**
|
|
1432
|
+
* Editor lifecycle for the portal node. Heaviest of the three —
|
|
1433
|
+
* creates two Monaco editors (JSX + Head HTML), wires up:
|
|
1434
|
+
* - URL hint that previews `/fromcubes/<subPath>`
|
|
1435
|
+
* - Legacy `endpoint` field migration warning
|
|
1436
|
+
* - `RED.tabs` for JSX / Properties / Head HTML panels
|
|
1437
|
+
* - Component picker dialog (`#fc-btn-components`)
|
|
1438
|
+
* - Utility picker dialog (`#fc-btn-utilities`)
|
|
1439
|
+
* - Portal Assets sidebar tab via `RED.sidebar.addTab` (once)
|
|
1440
|
+
* @returns {void}
|
|
1441
|
+
*/
|
|
770
1442
|
oneditprepare: function () {
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1443
|
+
const node = this;
|
|
1444
|
+
const code =
|
|
1445
|
+
typeof node.componentCode === "string" ? node.componentCode : STARTER;
|
|
1446
|
+
const headCode = node.customHead || "";
|
|
774
1447
|
console.log("[FC-Monaco] PORTAL oneditprepare, node.id=" + node.id);
|
|
775
1448
|
|
|
776
1449
|
if (!window.__fcTwClasses) {
|
|
777
1450
|
console.log("[FC-Monaco] loading tw-classes...");
|
|
778
1451
|
$.getJSON("portal-react/tw-classes", function (classes) {
|
|
779
|
-
console.log(
|
|
1452
|
+
console.log(
|
|
1453
|
+
"[FC-Monaco] tw-classes loaded, count=" + classes.length,
|
|
1454
|
+
);
|
|
780
1455
|
window.__fcTwClasses = classes;
|
|
781
1456
|
}).fail(function (xhr) {
|
|
782
|
-
console.error(
|
|
1457
|
+
console.error(
|
|
1458
|
+
"[FC-Monaco] tw-classes FAILED:",
|
|
1459
|
+
xhr.status,
|
|
1460
|
+
xhr.statusText,
|
|
1461
|
+
);
|
|
783
1462
|
});
|
|
784
1463
|
}
|
|
785
1464
|
|
|
786
1465
|
// Tabs
|
|
787
|
-
|
|
1466
|
+
const fcTabs = RED.tabs.create({
|
|
788
1467
|
id: "fc-tabs",
|
|
789
1468
|
onchange: function (tab) {
|
|
790
1469
|
$(".fc-tab-pane").hide();
|
|
@@ -800,18 +1479,73 @@
|
|
|
800
1479
|
fcTabs.addTab({ id: "fc-tab-head", label: "Head HTML" });
|
|
801
1480
|
fcTabs.activateTab("fc-tab-jsx");
|
|
802
1481
|
|
|
1482
|
+
// Legacy endpoint detection + convenience pre-fill
|
|
1483
|
+
if (node.endpoint && typeof node.endpoint === "string") {
|
|
1484
|
+
const legacy = node.endpoint;
|
|
1485
|
+
const prefix = "/fromcubes/";
|
|
1486
|
+
if (legacy.indexOf(prefix) === 0) {
|
|
1487
|
+
if (!node.subPath) {
|
|
1488
|
+
$("#node-input-subPath").val(legacy.slice(prefix.length));
|
|
1489
|
+
}
|
|
1490
|
+
} else if (legacy !== "" && legacy !== "/fromcubes") {
|
|
1491
|
+
$("#fc-url-hint")
|
|
1492
|
+
.css("color", "#c00")
|
|
1493
|
+
.text(
|
|
1494
|
+
"Legacy endpoint detected: '" +
|
|
1495
|
+
legacy +
|
|
1496
|
+
"'. Set a Sub-path and redeploy.",
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
803
1501
|
// URL hint
|
|
804
1502
|
function updateHint() {
|
|
805
|
-
|
|
806
|
-
|
|
1503
|
+
const sp = ($("#node-input-subPath").val() || "").trim();
|
|
1504
|
+
const root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
|
|
1505
|
+
if (sp) {
|
|
1506
|
+
$("#fc-url-hint")
|
|
1507
|
+
.css("color", "")
|
|
1508
|
+
.text(
|
|
1509
|
+
"Page served at: http://<host>:1880" +
|
|
1510
|
+
root +
|
|
1511
|
+
"/fromcubes/" +
|
|
1512
|
+
sp,
|
|
1513
|
+
);
|
|
1514
|
+
} else if (!node.endpoint || node.endpoint.indexOf("/fromcubes/") === 0) {
|
|
1515
|
+
$("#fc-url-hint")
|
|
1516
|
+
.css("color", "")
|
|
1517
|
+
.text("Sub-path required (will be served under /fromcubes/<sub-path>)");
|
|
1518
|
+
}
|
|
807
1519
|
}
|
|
808
|
-
$("#node-input-
|
|
1520
|
+
$("#node-input-subPath").on("input", updateHint);
|
|
809
1521
|
updateHint();
|
|
810
1522
|
|
|
1523
|
+
// Modules editableList (like function node's libs)
|
|
1524
|
+
const libsList = node.libs || [];
|
|
1525
|
+
$("#node-input-libs-container").css("min-height","68px").editableList({
|
|
1526
|
+
addItem: function(container, i, opt) {
|
|
1527
|
+
const lib = opt || {};
|
|
1528
|
+
const row = $('<div/>',{style:"display:flex;gap:8px;align-items:center;"}).appendTo(container);
|
|
1529
|
+
const modInput = $('<input/>',{type:"text",placeholder:"e.g. chart.js/auto@^4.4.0",style:"flex:1;"}).appendTo(row);
|
|
1530
|
+
const varInput = $('<input/>',{type:"text",placeholder:"Import as (e.g. Chart)",style:"width:140px;"}).appendTo(row);
|
|
1531
|
+
modInput.val(lib.module || "");
|
|
1532
|
+
varInput.val(lib.const || "");
|
|
1533
|
+
container.data("mod", modInput);
|
|
1534
|
+
container.data("var", varInput);
|
|
1535
|
+
},
|
|
1536
|
+
removable: true,
|
|
1537
|
+
sortable: true
|
|
1538
|
+
});
|
|
1539
|
+
libsList.forEach(function(lib) {
|
|
1540
|
+
$("#node-input-libs-container").editableList("addItem", lib);
|
|
1541
|
+
});
|
|
1542
|
+
|
|
811
1543
|
// Monaco
|
|
812
1544
|
window.__fcLoadMonaco(function (failed) {
|
|
813
1545
|
if (failed) {
|
|
814
|
-
console.error(
|
|
1546
|
+
console.error(
|
|
1547
|
+
"[FC-Monaco] PORTAL: Monaco load failed, using fallback textarea",
|
|
1548
|
+
);
|
|
815
1549
|
$("#fc-monaco").hide();
|
|
816
1550
|
$("#fc-fallback").show().val(code);
|
|
817
1551
|
$("#fc-head-monaco").hide();
|
|
@@ -819,30 +1553,40 @@
|
|
|
819
1553
|
return;
|
|
820
1554
|
}
|
|
821
1555
|
|
|
822
|
-
console.log(
|
|
1556
|
+
console.log(
|
|
1557
|
+
"[FC-Monaco] PORTAL: applying JSX defaults before model creation",
|
|
1558
|
+
);
|
|
823
1559
|
window.__fcApplyJsxDefaults();
|
|
824
|
-
|
|
1560
|
+
const opts = window.__fcEditorOpts;
|
|
825
1561
|
|
|
826
|
-
|
|
1562
|
+
const jsxUri = monaco.Uri.parse(
|
|
1563
|
+
"file:///fc-portal-" + node.id + ".jsx",
|
|
1564
|
+
);
|
|
827
1565
|
console.log("[FC-Monaco] PORTAL: JSX model URI=" + jsxUri.toString());
|
|
828
|
-
|
|
1566
|
+
const existingJsx = monaco.editor.getModel(jsxUri);
|
|
829
1567
|
if (existingJsx) {
|
|
830
1568
|
console.log("[FC-Monaco] PORTAL: disposing existing JSX model");
|
|
831
1569
|
existingJsx.dispose();
|
|
832
1570
|
}
|
|
833
|
-
|
|
834
|
-
console.log(
|
|
1571
|
+
const jsxModel = monaco.editor.createModel(code, "javascript", jsxUri);
|
|
1572
|
+
console.log(
|
|
1573
|
+
"[FC-Monaco] PORTAL: JSX model created, language=" +
|
|
1574
|
+
jsxModel.getLanguageId(),
|
|
1575
|
+
);
|
|
835
1576
|
editorInstance = monaco.editor.create(
|
|
836
1577
|
document.getElementById("fc-monaco"),
|
|
837
1578
|
Object.assign({ model: jsxModel }, opts),
|
|
838
1579
|
);
|
|
839
1580
|
console.log("[FC-Monaco] PORTAL: JSX editor created");
|
|
840
|
-
if (window.__fcAttachSelfClose)
|
|
1581
|
+
if (window.__fcAttachSelfClose)
|
|
1582
|
+
window.__fcAttachSelfClose(editorInstance);
|
|
841
1583
|
|
|
842
|
-
|
|
843
|
-
|
|
1584
|
+
const headUri = monaco.Uri.parse(
|
|
1585
|
+
"file:///fc-head-" + node.id + ".html",
|
|
1586
|
+
);
|
|
1587
|
+
const existingHead = monaco.editor.getModel(headUri);
|
|
844
1588
|
if (existingHead) existingHead.dispose();
|
|
845
|
-
|
|
1589
|
+
const headModel = monaco.editor.createModel(headCode, "html", headUri);
|
|
846
1590
|
headEditorInstance = monaco.editor.create(
|
|
847
1591
|
document.getElementById("fc-head-monaco"),
|
|
848
1592
|
Object.assign({ model: headModel }, opts),
|
|
@@ -851,93 +1595,320 @@
|
|
|
851
1595
|
|
|
852
1596
|
// Log markers after a short delay (diagnostics are async)
|
|
853
1597
|
setTimeout(function () {
|
|
854
|
-
|
|
855
|
-
console.log(
|
|
1598
|
+
const markers = monaco.editor.getModelMarkers({ resource: jsxUri });
|
|
1599
|
+
console.log(
|
|
1600
|
+
"[FC-Monaco] PORTAL: markers after 500ms, count=" +
|
|
1601
|
+
markers.length,
|
|
1602
|
+
);
|
|
856
1603
|
markers.forEach(function (m) {
|
|
857
|
-
console.log(
|
|
1604
|
+
console.log(
|
|
1605
|
+
"[FC-Monaco] PORTAL marker: code=" +
|
|
1606
|
+
m.code +
|
|
1607
|
+
" severity=" +
|
|
1608
|
+
m.severity +
|
|
1609
|
+
" msg=" +
|
|
1610
|
+
m.message,
|
|
1611
|
+
);
|
|
858
1612
|
});
|
|
859
|
-
console.log(
|
|
860
|
-
|
|
1613
|
+
console.log(
|
|
1614
|
+
"[FC-Monaco] PORTAL: current compilerOptions:",
|
|
1615
|
+
JSON.stringify(
|
|
1616
|
+
monaco.typescript.javascriptDefaults.getCompilerOptions(),
|
|
1617
|
+
),
|
|
1618
|
+
);
|
|
1619
|
+
console.log(
|
|
1620
|
+
"[FC-Monaco] PORTAL: current diagnosticsOptions:",
|
|
1621
|
+
JSON.stringify(
|
|
1622
|
+
monaco.typescript.javascriptDefaults.getDiagnosticsOptions(),
|
|
1623
|
+
),
|
|
1624
|
+
);
|
|
861
1625
|
}, 500);
|
|
862
1626
|
});
|
|
863
1627
|
|
|
864
1628
|
// Buttons
|
|
865
1629
|
$("#fc-btn-starter").on("click", function () {
|
|
866
|
-
|
|
867
|
-
|
|
1630
|
+
$("<div>Replace current JSX with default starter code?</div>").dialog({
|
|
1631
|
+
title: "Load Default",
|
|
1632
|
+
modal: true,
|
|
1633
|
+
width: 360,
|
|
1634
|
+
buttons: [
|
|
1635
|
+
{ text: "Cancel", click: function () { $(this).dialog("close"); } },
|
|
1636
|
+
{ text: "Replace", class: "primary", click: function () {
|
|
1637
|
+
if (editorInstance) editorInstance.setValue(STARTER);
|
|
1638
|
+
else $("#fc-fallback").val(STARTER);
|
|
1639
|
+
$(this).dialog("close");
|
|
1640
|
+
}},
|
|
1641
|
+
],
|
|
1642
|
+
close: function () { $(this).remove(); },
|
|
1643
|
+
});
|
|
868
1644
|
});
|
|
869
1645
|
|
|
870
1646
|
$("#fc-btn-preview").on("click", function () {
|
|
871
|
-
|
|
872
|
-
|
|
1647
|
+
const sp = ($("#node-input-subPath").val() || "").trim();
|
|
1648
|
+
const root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
|
|
1649
|
+
if (sp) window.open(root + "/fromcubes/" + sp, "_blank");
|
|
873
1650
|
});
|
|
874
1651
|
|
|
875
1652
|
$("#fc-btn-components").on("click", function () {
|
|
876
1653
|
$.getJSON("portal-react/registry", function (reg) {
|
|
877
|
-
|
|
1654
|
+
const names = Object.keys(reg).sort();
|
|
878
1655
|
if (!names.length) {
|
|
879
1656
|
RED.notify("No component nodes on canvas.", "warning");
|
|
880
1657
|
return;
|
|
881
1658
|
}
|
|
882
|
-
|
|
1659
|
+
|
|
1660
|
+
function extractProps(code) {
|
|
1661
|
+
if (!code) return [];
|
|
1662
|
+
const m = code.match(/function\s+\w+\s*\(\s*\{([^}]*)\}/);
|
|
1663
|
+
if (!m) return [];
|
|
1664
|
+
return m[1].split(",").map(function (s) { return s.trim().split(/\s*=\s*/)[0]; }).filter(Boolean);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
let html =
|
|
1668
|
+
'<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">' +
|
|
1669
|
+
'<input type="text" id="fc-comp-search" placeholder="Search..." ' +
|
|
1670
|
+
'style="width:100%;box-sizing:border-box;margin-bottom:6px;padding:5px 8px;border:1px solid rgba(128,128,128,.4);border-radius:3px;' +
|
|
1671
|
+
'background:var(--red-ui-form-input-background-color,#fff);color:var(--red-ui-form-text-color,#333);font-size:12px;flex-shrink:0;" />' +
|
|
1672
|
+
'<div id="fc-comp-list" style="flex:1;overflow-y:auto;">';
|
|
1673
|
+
|
|
883
1674
|
names.forEach(function (n) {
|
|
884
|
-
|
|
1675
|
+
const c = reg[n];
|
|
1676
|
+
const props = extractProps(c.code);
|
|
1677
|
+
const detailParts = [];
|
|
1678
|
+
if (props.length) {
|
|
1679
|
+
detailParts.push('<div style="margin:4px 0 0 0;font-size:11px;opacity:.6;">' +
|
|
1680
|
+
props.map(function (p) {
|
|
1681
|
+
return '<code style="background:rgba(128,128,128,.15);padding:0 4px;border-radius:2px;margin-right:3px;">' + p + '</code>';
|
|
1682
|
+
}).join("") + '</div>');
|
|
1683
|
+
}
|
|
1684
|
+
const hasDetail = detailParts.length > 0;
|
|
1685
|
+
|
|
885
1686
|
html +=
|
|
886
|
-
'<div
|
|
887
|
-
'
|
|
888
|
-
|
|
889
|
-
'">' +
|
|
890
|
-
n +
|
|
891
|
-
|
|
892
|
-
'
|
|
893
|
-
|
|
894
|
-
"] out:[" +
|
|
895
|
-
(c.outputs || []).join(",") +
|
|
896
|
-
"]</span></div>";
|
|
1687
|
+
'<div class="fc-comp-item" data-name="' + n + '" data-search="' + n.toLowerCase() + '" ' +
|
|
1688
|
+
'style="border-bottom:1px solid rgba(128,128,128,.1);">' +
|
|
1689
|
+
'<div style="display:flex;align-items:center;padding:4px 6px;cursor:pointer;" class="fc-comp-row">' +
|
|
1690
|
+
(hasDetail ? '<i class="fa fa-caret-right fc-comp-arrow" style="width:14px;opacity:.4;font-size:12px;transition:transform .15s;"></i>' : '<span style="width:14px;"></span>') +
|
|
1691
|
+
'<span class="fc-comp-name" data-name="' + n + '" style="font-weight:600;font-size:12px;flex:1;">' + n + '</span>' +
|
|
1692
|
+
'</div>' +
|
|
1693
|
+
(hasDetail ? '<div class="fc-comp-detail" style="display:none;padding:0 6px 4px 20px;">' + detailParts.join("") + '</div>' : '') +
|
|
1694
|
+
'</div>';
|
|
897
1695
|
});
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1696
|
+
html += '</div></div>';
|
|
1697
|
+
|
|
1698
|
+
const $dlg = $("<div></div>").html(html).dialog({
|
|
1699
|
+
title: "Components",
|
|
1700
|
+
modal: true,
|
|
1701
|
+
width: 380,
|
|
1702
|
+
buttons: [
|
|
1703
|
+
{ text: "Close", click: function () { $(this).dialog("close"); } },
|
|
1704
|
+
],
|
|
1705
|
+
close: function () { $(this).remove(); },
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
// Search
|
|
1709
|
+
$dlg.find("#fc-comp-search").on("input", function () {
|
|
1710
|
+
const q = $(this).val().toLowerCase();
|
|
1711
|
+
$dlg.find(".fc-comp-item").each(function () {
|
|
1712
|
+
$(this).toggle($(this).data("search").indexOf(q) !== -1);
|
|
1713
|
+
});
|
|
1714
|
+
}).focus();
|
|
1715
|
+
|
|
1716
|
+
// Hover
|
|
1717
|
+
$dlg.on("mouseenter", ".fc-comp-row", function () {
|
|
1718
|
+
$(this).css("background", "rgba(128,128,128,.1)");
|
|
1719
|
+
}).on("mouseleave", ".fc-comp-row", function () {
|
|
1720
|
+
$(this).css("background", "");
|
|
1721
|
+
});
|
|
1722
|
+
|
|
1723
|
+
// Arrow toggle detail
|
|
1724
|
+
$dlg.on("click", ".fc-comp-arrow", function (e) {
|
|
1725
|
+
e.stopPropagation();
|
|
1726
|
+
const $item = $(this).closest(".fc-comp-item");
|
|
1727
|
+
const $detail = $item.find(".fc-comp-detail");
|
|
1728
|
+
const open = $detail.is(":visible");
|
|
1729
|
+
$detail.slideToggle(100);
|
|
1730
|
+
$(this).css("transform", open ? "" : "rotate(90deg)");
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
// Click name: delete selection, insert <Tag></Tag> in one line, cursor between tags
|
|
1734
|
+
$dlg.on("click", ".fc-comp-name", function () {
|
|
1735
|
+
const name = $(this).data("name");
|
|
1736
|
+
const openTag = "<" + name + ">";
|
|
1737
|
+
const closeTag = "</" + name + ">";
|
|
1738
|
+
const text = openTag + closeTag;
|
|
1739
|
+
$dlg.dialog("close");
|
|
1740
|
+
if (editorInstance) {
|
|
1741
|
+
const sel = editorInstance.getSelection();
|
|
1742
|
+
const startLine = sel.startLineNumber;
|
|
1743
|
+
const startCol = sel.startColumn;
|
|
1744
|
+
editorInstance.executeEdits("fc-components", [{
|
|
1745
|
+
range: sel,
|
|
1746
|
+
text: text,
|
|
1747
|
+
}]);
|
|
1748
|
+
setTimeout(function () {
|
|
1749
|
+
editorInstance.setPosition({ lineNumber: startLine, column: startCol + openTag.length });
|
|
1750
|
+
editorInstance.focus();
|
|
1751
|
+
}, 50);
|
|
1752
|
+
} else {
|
|
1753
|
+
const ta = $("#fc-fallback")[0];
|
|
1754
|
+
const s = ta.selectionStart, e = ta.selectionEnd, v = ta.value;
|
|
1755
|
+
ta.value = v.slice(0, s) + text + v.slice(e);
|
|
1756
|
+
setTimeout(function () {
|
|
1757
|
+
ta.selectionStart = ta.selectionEnd = s + openTag.length;
|
|
1758
|
+
ta.focus();
|
|
1759
|
+
}, 50);
|
|
1760
|
+
}
|
|
1761
|
+
});
|
|
1762
|
+
});
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
$("#fc-btn-utilities").on("click", function () {
|
|
1766
|
+
$.getJSON("portal-react/utilities", function (reg) {
|
|
1767
|
+
// Refresh global cache too so completion provider picks up changes
|
|
1768
|
+
if (window.__fcRefreshUtilities) window.__fcRefreshUtilities();
|
|
1769
|
+
const names = Object.keys(reg).sort();
|
|
1770
|
+
if (!names.length) {
|
|
1771
|
+
RED.notify("No utility nodes on canvas.", "warning");
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
let html =
|
|
1776
|
+
'<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">' +
|
|
1777
|
+
'<input type="text" id="fc-util-search" placeholder="Search..." ' +
|
|
1778
|
+
'style="width:100%;box-sizing:border-box;margin-bottom:6px;padding:5px 8px;border:1px solid rgba(128,128,128,.4);border-radius:3px;' +
|
|
1779
|
+
'background:var(--red-ui-form-input-background-color,#fff);color:var(--red-ui-form-text-color,#333);font-size:12px;flex-shrink:0;" />' +
|
|
1780
|
+
'<div id="fc-util-list" style="flex:1;overflow-y:auto;">';
|
|
1781
|
+
|
|
1782
|
+
names.forEach(function (n) {
|
|
1783
|
+
const u = reg[n];
|
|
1784
|
+
const syms = u.symbols || [];
|
|
1785
|
+
const errBadge = u.error
|
|
1786
|
+
? '<span style="margin-left:6px;font-size:10px;color:#ef4444;" title="' +
|
|
1787
|
+
String(u.error).replace(/"/g, """) + '">syntax error</span>'
|
|
1788
|
+
: "";
|
|
1789
|
+
const symBadges = syms.map(function (s) {
|
|
1790
|
+
return '<span class="fc-util-sym" data-sym="' + s +
|
|
1791
|
+
'" style="display:inline-block;background:rgba(251,191,36,.15);border:1px solid rgba(251,191,36,.35);' +
|
|
1792
|
+
'padding:1px 6px;border-radius:3px;margin:2px 4px 2px 0;font-size:11px;font-family:monospace;cursor:pointer;">' +
|
|
1793
|
+
s + '</span>';
|
|
1794
|
+
}).join("");
|
|
1795
|
+
const detail = syms.length
|
|
1796
|
+
? '<div style="margin:4px 0 0 0;">' + symBadges + '</div>'
|
|
1797
|
+
: '<div style="margin:4px 0 0 0;font-size:11px;opacity:.5;">(no top-level symbols detected)</div>';
|
|
1798
|
+
const searchKey = (n + " " + syms.join(" ")).toLowerCase();
|
|
1799
|
+
|
|
1800
|
+
html +=
|
|
1801
|
+
'<div class="fc-util-item" data-name="' + n + '" data-search="' + searchKey + '" ' +
|
|
1802
|
+
'style="border-bottom:1px solid rgba(128,128,128,.1);">' +
|
|
1803
|
+
'<div style="display:flex;align-items:center;padding:4px 6px;cursor:pointer;" class="fc-util-row">' +
|
|
1804
|
+
'<i class="fa fa-caret-right fc-util-arrow" style="width:14px;opacity:.4;font-size:12px;transition:transform .15s;"></i>' +
|
|
1805
|
+
'<i class="fa fa-wrench" style="width:14px;color:#fbbf24;font-size:11px;"></i>' +
|
|
1806
|
+
'<span style="font-weight:600;font-size:12px;flex:1;margin-left:4px;">' + n + '</span>' +
|
|
1807
|
+
errBadge +
|
|
1808
|
+
'</div>' +
|
|
1809
|
+
'<div class="fc-util-detail" style="display:none;padding:0 6px 6px 32px;">' + detail + '</div>' +
|
|
1810
|
+
'</div>';
|
|
1811
|
+
});
|
|
1812
|
+
html += '</div></div>';
|
|
1813
|
+
|
|
1814
|
+
const $dlg = $("<div></div>").html(html).dialog({
|
|
1815
|
+
title: "Utilities",
|
|
1816
|
+
modal: true,
|
|
1817
|
+
width: 420,
|
|
1818
|
+
buttons: [
|
|
1819
|
+
{ text: "Close", click: function () { $(this).dialog("close"); } },
|
|
1820
|
+
],
|
|
1821
|
+
close: function () { $(this).remove(); },
|
|
1822
|
+
});
|
|
1823
|
+
|
|
1824
|
+
$dlg.find("#fc-util-search").on("input", function () {
|
|
1825
|
+
const q = $(this).val().toLowerCase();
|
|
1826
|
+
$dlg.find(".fc-util-item").each(function () {
|
|
1827
|
+
$(this).toggle($(this).data("search").indexOf(q) !== -1);
|
|
915
1828
|
});
|
|
916
|
-
|
|
917
|
-
|
|
1829
|
+
}).focus();
|
|
1830
|
+
|
|
1831
|
+
$dlg.on("mouseenter", ".fc-util-row", function () {
|
|
1832
|
+
$(this).css("background", "rgba(251,191,36,.08)");
|
|
1833
|
+
}).on("mouseleave", ".fc-util-row", function () {
|
|
1834
|
+
$(this).css("background", "");
|
|
1835
|
+
});
|
|
1836
|
+
|
|
1837
|
+
$dlg.on("click", ".fc-util-row", function (e) {
|
|
1838
|
+
if ($(e.target).closest(".fc-util-sym").length) return;
|
|
1839
|
+
const $item = $(this).closest(".fc-util-item");
|
|
1840
|
+
const $detail = $item.find(".fc-util-detail");
|
|
1841
|
+
const $arrow = $item.find(".fc-util-arrow");
|
|
1842
|
+
const open = $detail.is(":visible");
|
|
1843
|
+
$detail.slideToggle(100);
|
|
1844
|
+
$arrow.css("transform", open ? "" : "rotate(90deg)");
|
|
1845
|
+
});
|
|
1846
|
+
|
|
1847
|
+
// Click symbol → insert bare identifier at cursor
|
|
1848
|
+
$dlg.on("click", ".fc-util-sym", function (e) {
|
|
1849
|
+
e.stopPropagation();
|
|
1850
|
+
const sym = $(this).data("sym");
|
|
1851
|
+
$dlg.dialog("close");
|
|
918
1852
|
if (editorInstance) {
|
|
919
|
-
editorInstance.
|
|
920
|
-
|
|
1853
|
+
const sel = editorInstance.getSelection();
|
|
1854
|
+
const startLine = sel.startLineNumber;
|
|
1855
|
+
const startCol = sel.startColumn;
|
|
1856
|
+
editorInstance.executeEdits("fc-utilities", [{
|
|
1857
|
+
range: sel,
|
|
1858
|
+
text: sym,
|
|
1859
|
+
}]);
|
|
1860
|
+
setTimeout(function () {
|
|
1861
|
+
editorInstance.setPosition({ lineNumber: startLine, column: startCol + sym.length });
|
|
1862
|
+
editorInstance.focus();
|
|
1863
|
+
}, 50);
|
|
921
1864
|
} else {
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1865
|
+
const ta = $("#fc-fallback")[0];
|
|
1866
|
+
const s = ta.selectionStart, en = ta.selectionEnd, v = ta.value;
|
|
1867
|
+
ta.value = v.slice(0, s) + sym + v.slice(en);
|
|
1868
|
+
setTimeout(function () {
|
|
1869
|
+
ta.selectionStart = ta.selectionEnd = s + sym.length;
|
|
1870
|
+
ta.focus();
|
|
1871
|
+
}, 50);
|
|
926
1872
|
}
|
|
927
1873
|
});
|
|
928
1874
|
});
|
|
929
1875
|
});
|
|
930
1876
|
},
|
|
931
1877
|
|
|
1878
|
+
/**
|
|
1879
|
+
* Editor → flow. Normalises `subPath` (trim), clears the legacy
|
|
1880
|
+
* `endpoint` field, collects `libs` from the editableList widget,
|
|
1881
|
+
* writes both Monaco editors back to hidden inputs + node props,
|
|
1882
|
+
* then disposes both editors.
|
|
1883
|
+
* @returns {void}
|
|
1884
|
+
*/
|
|
932
1885
|
oneditsave: function () {
|
|
933
1886
|
console.log("[FC-Monaco] PORTAL oneditsave");
|
|
934
|
-
|
|
1887
|
+
|
|
1888
|
+
// Clear legacy endpoint field + normalize subPath
|
|
1889
|
+
$("#node-input-endpoint").val("");
|
|
1890
|
+
this.endpoint = "";
|
|
1891
|
+
this.subPath = ($("#node-input-subPath").val() || "").trim();
|
|
1892
|
+
|
|
1893
|
+
// Collect libs from editableList
|
|
1894
|
+
const libs = [];
|
|
1895
|
+
const items = $("#node-input-libs-container").editableList("items");
|
|
1896
|
+
items.each(function() {
|
|
1897
|
+
const mod = $(this).data("mod").val().trim();
|
|
1898
|
+
const v = $(this).data("var").val().trim();
|
|
1899
|
+
if (mod) {
|
|
1900
|
+
libs.push({ module: mod, var: v });
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
this.libs = libs;
|
|
1904
|
+
|
|
1905
|
+
const code = editorInstance
|
|
935
1906
|
? editorInstance.getValue()
|
|
936
1907
|
: $("#fc-fallback").val();
|
|
937
1908
|
$("#node-input-componentCode").val(code);
|
|
938
1909
|
this.componentCode = code;
|
|
939
1910
|
|
|
940
|
-
|
|
1911
|
+
const head = headEditorInstance
|
|
941
1912
|
? headEditorInstance.getValue()
|
|
942
1913
|
: $("#fc-head-fallback").val();
|
|
943
1914
|
$("#node-input-customHead").val(head);
|
|
@@ -957,6 +1928,12 @@
|
|
|
957
1928
|
}
|
|
958
1929
|
},
|
|
959
1930
|
|
|
1931
|
+
/**
|
|
1932
|
+
* Discard both Monaco editors without writing back to the node.
|
|
1933
|
+
* Identical dispose pattern to oneditsave so we never leak Monaco
|
|
1934
|
+
* graphs even if the user spams Edit / Cancel rapidly.
|
|
1935
|
+
* @returns {void}
|
|
1936
|
+
*/
|
|
960
1937
|
oneditcancel: function () {
|
|
961
1938
|
console.log("[FC-Monaco] PORTAL oneditcancel");
|
|
962
1939
|
if (editorInstance) {
|
|
@@ -973,12 +1950,19 @@
|
|
|
973
1950
|
}
|
|
974
1951
|
},
|
|
975
1952
|
|
|
1953
|
+
/**
|
|
1954
|
+
* Dialog resize → compute available editor height by subtracting
|
|
1955
|
+
* the heights of non-tab form rows + tab strip + 30 px padding,
|
|
1956
|
+
* then re-layout both Monaco editors. Falls back to 200 px minimum.
|
|
1957
|
+
* @param {{height: number, width: number}} size
|
|
1958
|
+
* @returns {void}
|
|
1959
|
+
*/
|
|
976
1960
|
oneditresize: function (size) {
|
|
977
|
-
|
|
978
|
-
|
|
1961
|
+
const tabsH = $("#fc-tabs").outerHeight(true) || 0;
|
|
1962
|
+
const rows = $(
|
|
979
1963
|
"#dialog-form>div:not(#fc-tabs-content):not(:has(#fc-tabs))",
|
|
980
1964
|
);
|
|
981
|
-
|
|
1965
|
+
let h = size.height;
|
|
982
1966
|
rows.each(function () {
|
|
983
1967
|
h -= $(this).outerHeight(true);
|
|
984
1968
|
});
|
|
@@ -997,37 +1981,59 @@
|
|
|
997
1981
|
Help
|
|
998
1982
|
============================================================ -->
|
|
999
1983
|
<script type="text/html" data-help-name="portal-react">
|
|
1984
|
+
<h3>Quick Reference</h3>
|
|
1985
|
+
<ul>
|
|
1986
|
+
<li><code>useNodeRed()</code> → <code>{ data, send, user, portalClient }</code></li>
|
|
1987
|
+
<li>Components from <code>fc-portal-component</code> nodes are auto-imported</li>
|
|
1988
|
+
<li>Must export <code><App /></code></li>
|
|
1989
|
+
<li>JSX is transpiled server-side at deploy</li>
|
|
1990
|
+
</ul>
|
|
1991
|
+
|
|
1000
1992
|
<p>
|
|
1001
|
-
Renders a React application on a configurable HTTP endpoint with live
|
|
1002
|
-
WebSocket data binding.
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
JSX is transpiled <strong>server-side at deploy time</strong> using Sucrase.
|
|
1007
|
-
The browser receives plain JS — no Babel, no runtime compilation. Page
|
|
1008
|
-
weight: ~45 KB (React production).
|
|
1993
|
+
Renders a React 19 application on a configurable HTTP endpoint with live
|
|
1994
|
+
WebSocket data binding. JSX is transpiled <strong>server-side at deploy
|
|
1995
|
+
time</strong> using esbuild — browsers receive pre-compiled JS with zero
|
|
1996
|
+
runtime compilation. Tailwind CSS 4 utility classes are generated
|
|
1997
|
+
server-side and served as static CSS.
|
|
1009
1998
|
</p>
|
|
1010
1999
|
|
|
1011
2000
|
<h3>Inputs</h3>
|
|
1012
2001
|
<p>
|
|
1013
|
-
<code>msg.payload</code> is pushed
|
|
2002
|
+
<code>msg.payload</code> is pushed via WebSocket. Set <code>msg._client</code>
|
|
2003
|
+
to target specific clients, or omit it to broadcast to all.
|
|
1014
2004
|
</p>
|
|
1015
2005
|
<pre>
|
|
1016
|
-
const { data, send } = useNodeRed();
|
|
1017
|
-
// data
|
|
2006
|
+
const { data, send, user, portalClient } = useNodeRed();
|
|
2007
|
+
// data = last msg.payload (reactive)
|
|
2008
|
+
// send(payload, topic?) — emit msg on output wire
|
|
2009
|
+
// user = portal auth data or null
|
|
2010
|
+
// portalClient = unique session/tab ID (assigned by server)</pre
|
|
1018
2011
|
>
|
|
1019
2012
|
|
|
1020
2013
|
<h3>Outputs</h3>
|
|
1021
2014
|
<p>
|
|
1022
2015
|
<code>send(payload, topic?)</code> emits a <code>msg</code> on the node's
|
|
1023
|
-
output wire.
|
|
2016
|
+
output wire via WebSocket.
|
|
2017
|
+
</p>
|
|
2018
|
+
|
|
2019
|
+
<h3>npm Packages</h3>
|
|
2020
|
+
<p>
|
|
2021
|
+
Add npm packages in the <strong>Modules</strong> list (e.g.
|
|
2022
|
+
<code>chart.js/auto@^4.4.0</code>, <code>d3</code>, <code>three</code>).
|
|
2023
|
+
Node-RED auto-installs them at deploy time. All packages are bundled with
|
|
2024
|
+
React into a single vendor IIFE via esbuild, cached by hash of installed
|
|
2025
|
+
versions. Use them in JSX via standard imports.
|
|
1024
2026
|
</p>
|
|
1025
2027
|
|
|
1026
2028
|
<h3>Deploy behavior</h3>
|
|
1027
2029
|
<ul>
|
|
1028
2030
|
<li>
|
|
1029
|
-
Each deploy re-transpiles JSX
|
|
1030
|
-
instant)
|
|
2031
|
+
Each deploy re-transpiles JSX via esbuild and rebuilds Tailwind CSS
|
|
2032
|
+
(both cached by content hash — unchanged code is instant)
|
|
2033
|
+
</li>
|
|
2034
|
+
<li>
|
|
2035
|
+
Vendor bundle (React + npm packages) rebuilt only when package versions
|
|
2036
|
+
change
|
|
1031
2037
|
</li>
|
|
1032
2038
|
<li>
|
|
1033
2039
|
Active WebSocket clients receive close code 1001 and auto-reconnect
|
|
@@ -1045,15 +2051,584 @@ const { data, send } = useNodeRed();
|
|
|
1045
2051
|
<p>
|
|
1046
2052
|
Components defined in <strong>fc-portal-component</strong> nodes on the
|
|
1047
2053
|
canvas are auto-injected into every portal-react page. Use them as JSX tags
|
|
1048
|
-
by their component name.
|
|
2054
|
+
by their component name — no imports needed.
|
|
1049
2055
|
</p>
|
|
1050
2056
|
|
|
2057
|
+
<h3>Portal Auth</h3>
|
|
2058
|
+
<p>
|
|
2059
|
+
When enabled in the Auth tab, reads <code>X-Portal-*</code> headers set by
|
|
2060
|
+
a reverse proxy (e.g. Nginx) and exposes user data:
|
|
2061
|
+
</p>
|
|
2062
|
+
<ul>
|
|
2063
|
+
<li><code>useNodeRed()</code> returns <code>{ data, send, user, portalClient }</code></li>
|
|
2064
|
+
<li>
|
|
2065
|
+
All WebSocket messages include <code>msg._client</code> with
|
|
2066
|
+
<code>{ portalClient, ...userFields }</code>
|
|
2067
|
+
</li>
|
|
2068
|
+
<li>
|
|
2069
|
+
To target a specific tab: keep <code>msg._client</code> (or set
|
|
2070
|
+
<code>msg._client = { portalClient: "..." }</code>)
|
|
2071
|
+
</li>
|
|
2072
|
+
<li>
|
|
2073
|
+
To target all tabs of a user: set
|
|
2074
|
+
<code>msg._client = { userId: "..." }</code> (no portalClient)
|
|
2075
|
+
</li>
|
|
2076
|
+
<li>
|
|
2077
|
+
To broadcast to all: remove <code>msg._client</code>
|
|
2078
|
+
</li>
|
|
2079
|
+
</ul>
|
|
2080
|
+
|
|
1051
2081
|
<h3>Custom Head HTML</h3>
|
|
1052
2082
|
<p>
|
|
1053
2083
|
Inject CDN links, fonts, or extra stylesheets into
|
|
1054
|
-
<code><head></code>.
|
|
2084
|
+
<code><head></code>. This is raw trusted-author HTML; scripts and
|
|
2085
|
+
event handlers run in the public portal page. Example:
|
|
1055
2086
|
</p>
|
|
1056
2087
|
<pre>
|
|
1057
|
-
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet"></pre
|
|
2088
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet"></pre
|
|
1058
2089
|
>
|
|
2090
|
+
|
|
2091
|
+
<h3>WebSocket Status</h3>
|
|
2092
|
+
<p>
|
|
2093
|
+
Optional <em>fromcubes</em> badge in the bottom-right corner showing
|
|
2094
|
+
connection state. Enable via the <strong>Show WebSocket status
|
|
2095
|
+
indicator</strong> checkbox (off by default).
|
|
2096
|
+
</p>
|
|
2097
|
+
</script>
|
|
2098
|
+
|
|
2099
|
+
<!-- ── Assets sidebar tab ──────────────────────────────────────── -->
|
|
2100
|
+
<script type="text/javascript">
|
|
2101
|
+
(function () {
|
|
2102
|
+
// Resolve httpNodeRoot for public URL prefix
|
|
2103
|
+
const nodeRoot = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
|
|
2104
|
+
const publicBase = nodeRoot + "/fromcubes/public/";
|
|
2105
|
+
|
|
2106
|
+
/**
|
|
2107
|
+
* Human-readable byte formatter for the assets sidebar.
|
|
2108
|
+
* @param {number} bytes
|
|
2109
|
+
* @returns {string} e.g. `"42 B"`, `"3.2 KB"`, `"1.4 MB"`.
|
|
2110
|
+
*/
|
|
2111
|
+
function formatSize(bytes) {
|
|
2112
|
+
if (bytes < 1024) return bytes + " B";
|
|
2113
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
|
2114
|
+
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
let allEntries = [];
|
|
2118
|
+
const collapsed = {};
|
|
2119
|
+
|
|
2120
|
+
const content = $('<div class="red-ui-sidebar-info" style="height:100%;overflow:auto;padding:0;"></div>');
|
|
2121
|
+
const fileList = $('<div style="padding:0;"></div>').appendTo(content);
|
|
2122
|
+
|
|
2123
|
+
// ── Toolbar ──
|
|
2124
|
+
const toolbar = $('<div style="display:flex;align-items:center;gap:6px;margin:0 6px;padding:2px 0 0;"></div>');
|
|
2125
|
+
const fileInput = $('<input type="file" multiple style="display:none;">').appendTo(toolbar);
|
|
2126
|
+
$('<button class="red-ui-button red-ui-button-small" style="flex-shrink:0;"><i class="fa fa-upload"></i> Upload</button>')
|
|
2127
|
+
.on("click", function (e) { e.preventDefault(); fileInput.trigger("click"); })
|
|
2128
|
+
.appendTo(toolbar);
|
|
2129
|
+
$('<button class="red-ui-button red-ui-button-small" style="flex-shrink:0;"><i class="fa fa-folder-open"></i> New folder</button>')
|
|
2130
|
+
.on("click", function (e) { e.preventDefault(); showNewFolderInput(""); })
|
|
2131
|
+
.appendTo(toolbar);
|
|
2132
|
+
|
|
2133
|
+
// ── Helpers ──
|
|
2134
|
+
/**
|
|
2135
|
+
* Linear scan over the most-recently-fetched asset list.
|
|
2136
|
+
* @param {string} p Relative path inside the assets root.
|
|
2137
|
+
* @returns {boolean}
|
|
2138
|
+
*/
|
|
2139
|
+
function pathExists(p) {
|
|
2140
|
+
for (let i = 0; i < allEntries.length; i++) {
|
|
2141
|
+
if (allEntries[i].name === p) return true;
|
|
2142
|
+
}
|
|
2143
|
+
return false;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
/**
|
|
2147
|
+
* POST one or more files to the upload endpoint. Prompts before
|
|
2148
|
+
* overwriting existing paths. Each request is sent independently so
|
|
2149
|
+
* an upstream failure of one file does not block the rest; the sidebar
|
|
2150
|
+
* is refreshed once all responses have arrived.
|
|
2151
|
+
* @param {FileList|Array<File>} files
|
|
2152
|
+
* @param {string} targetDir Relative directory inside the assets root
|
|
2153
|
+
* (empty string for root).
|
|
2154
|
+
* @returns {void}
|
|
2155
|
+
*/
|
|
2156
|
+
function uploadFiles(files, targetDir) {
|
|
2157
|
+
if (!files || files.length === 0) return;
|
|
2158
|
+
let toUpload = [];
|
|
2159
|
+
const duplicates = [];
|
|
2160
|
+
for (let i = 0; i < files.length; i++) {
|
|
2161
|
+
const uploadPath = targetDir ? targetDir + "/" + files[i].name : files[i].name;
|
|
2162
|
+
if (pathExists(uploadPath)) {
|
|
2163
|
+
duplicates.push({ file: files[i], path: uploadPath });
|
|
2164
|
+
} else {
|
|
2165
|
+
toUpload.push({ file: files[i], path: uploadPath });
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
if (duplicates.length > 0) {
|
|
2169
|
+
const names = duplicates.map(function (d) { return d.file.name; }).join(", ");
|
|
2170
|
+
if (confirm("These files already exist: " + names + "\nOverwrite?")) {
|
|
2171
|
+
toUpload = toUpload.concat(duplicates);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
if (toUpload.length === 0) return;
|
|
2175
|
+
let pending = toUpload.length;
|
|
2176
|
+
toUpload.forEach(function (item) {
|
|
2177
|
+
const reader = new FileReader();
|
|
2178
|
+
reader.onload = function () {
|
|
2179
|
+
$.ajax({
|
|
2180
|
+
type: "POST",
|
|
2181
|
+
url: "portal-react/assets/upload/" + item.path.split("/").map(encodeURIComponent).join("/"),
|
|
2182
|
+
data: new Uint8Array(reader.result),
|
|
2183
|
+
contentType: "application/octet-stream",
|
|
2184
|
+
processData: false,
|
|
2185
|
+
success: function () { if (--pending === 0) refreshList(); },
|
|
2186
|
+
error: function () {
|
|
2187
|
+
RED.notify("Upload failed: " + item.file.name, "error");
|
|
2188
|
+
if (--pending === 0) refreshList();
|
|
2189
|
+
},
|
|
2190
|
+
});
|
|
2191
|
+
};
|
|
2192
|
+
reader.readAsArrayBuffer(item.file);
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
fileInput.on("change", function () {
|
|
2197
|
+
uploadFiles(this.files, "");
|
|
2198
|
+
fileInput.val("");
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
// External file drag & drop on content area (root upload)
|
|
2202
|
+
content.on("dragover", function (e) {
|
|
2203
|
+
if (e.originalEvent.dataTransfer.types.indexOf("Files") >= 0) {
|
|
2204
|
+
e.preventDefault();
|
|
2205
|
+
e.stopPropagation();
|
|
2206
|
+
content.css("background", "rgba(34,211,238,0.05)");
|
|
2207
|
+
}
|
|
2208
|
+
});
|
|
2209
|
+
content.on("dragleave", function (e) {
|
|
2210
|
+
e.preventDefault();
|
|
2211
|
+
content.css("background", "");
|
|
2212
|
+
});
|
|
2213
|
+
content.on("drop", function (e) {
|
|
2214
|
+
e.preventDefault();
|
|
2215
|
+
e.stopPropagation();
|
|
2216
|
+
content.css("background", "");
|
|
2217
|
+
const dt = e.originalEvent.dataTransfer;
|
|
2218
|
+
if (dt.files && dt.files.length > 0) {
|
|
2219
|
+
uploadFiles(dt.files, "");
|
|
2220
|
+
}
|
|
2221
|
+
});
|
|
2222
|
+
|
|
2223
|
+
/**
|
|
2224
|
+
* Move or rename an asset. Computes the new path from `fromPath`'s basename
|
|
2225
|
+
* + `toDir`. No-ops when source and destination are equal. Prompts before
|
|
2226
|
+
* overwriting an existing destination. Posts to the `move` admin endpoint
|
|
2227
|
+
* (auth- and CSRF-gated server-side); the sidebar refreshes on success.
|
|
2228
|
+
* @param {string} fromPath Current relative path of the item.
|
|
2229
|
+
* @param {string} toDir Destination directory (empty string for root).
|
|
2230
|
+
* @returns {void}
|
|
2231
|
+
*/
|
|
2232
|
+
function moveItem(fromPath, toDir) {
|
|
2233
|
+
const filename = fromPath.split("/").pop();
|
|
2234
|
+
const newPath = toDir ? toDir + "/" + filename : filename;
|
|
2235
|
+
if (fromPath === newPath) return;
|
|
2236
|
+
if (pathExists(newPath)) {
|
|
2237
|
+
if (!confirm("'" + filename + "' already exists in this folder. Overwrite?")) return;
|
|
2238
|
+
}
|
|
2239
|
+
$.ajax({
|
|
2240
|
+
type: "POST", url: "portal-react/assets/move",
|
|
2241
|
+
contentType: "application/json",
|
|
2242
|
+
data: JSON.stringify({ from: fromPath, to: newPath }),
|
|
2243
|
+
success: function () { refreshList(); },
|
|
2244
|
+
error: function (xhr) {
|
|
2245
|
+
const msg = xhr.responseJSON ? xhr.responseJSON.error : "move failed";
|
|
2246
|
+
RED.notify("Move failed: " + msg, "error");
|
|
2247
|
+
},
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
// ── Inline new-folder input ──
|
|
2252
|
+
function showNewFolderInput(parentDir) {
|
|
2253
|
+
const existingInput = fileList.find(".fc-new-folder-row");
|
|
2254
|
+
if (existingInput.length) existingInput.remove();
|
|
2255
|
+
|
|
2256
|
+
const depth = parentDir ? parentDir.split("/").length : 0;
|
|
2257
|
+
const indent = 8 + depth * 18;
|
|
2258
|
+
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>');
|
|
2259
|
+
row.css("padding-left", indent + "px");
|
|
2260
|
+
$('<i class="fa fa-folder" style="color:#fbbf24;font-size:13px;width:16px;text-align:center;"></i>').appendTo(row);
|
|
2261
|
+
const inp = $('<input type="text" placeholder="folder name" class="red-ui-searchBox-input" style="flex:1;padding:3px 6px;font-size:12px;">');
|
|
2262
|
+
inp.appendTo(row);
|
|
2263
|
+
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>');
|
|
2264
|
+
const cancelBtn = $('<button class="red-ui-button red-ui-button-small" title="Cancel"><i class="fa fa-times"></i></button>');
|
|
2265
|
+
function submit() {
|
|
2266
|
+
const name = inp.val().trim();
|
|
2267
|
+
if (!name) { row.remove(); return; }
|
|
2268
|
+
const p = parentDir ? parentDir + "/" + name : name;
|
|
2269
|
+
if (pathExists(p)) {
|
|
2270
|
+
RED.notify("Folder '" + name + "' already exists", "warning");
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
$.ajax({ type: "POST", url: "portal-react/assets/mkdir", contentType: "application/json", data: JSON.stringify({ path: p }),
|
|
2274
|
+
success: function () { row.remove(); refreshList(); },
|
|
2275
|
+
error: function () { RED.notify("Failed to create folder", "error"); },
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
okBtn.on("click", function (e) { e.preventDefault(); submit(); });
|
|
2279
|
+
cancelBtn.on("click", function (e) { e.preventDefault(); row.remove(); });
|
|
2280
|
+
inp.on("keydown", function (e) {
|
|
2281
|
+
if (e.key === "Enter") submit();
|
|
2282
|
+
if (e.key === "Escape") row.remove();
|
|
2283
|
+
});
|
|
2284
|
+
okBtn.appendTo(row);
|
|
2285
|
+
cancelBtn.appendTo(row);
|
|
2286
|
+
|
|
2287
|
+
// Insert after the parent folder row, or at top
|
|
2288
|
+
if (parentDir) {
|
|
2289
|
+
const parentRow = fileList.find('[data-path="' + parentDir + '"]');
|
|
2290
|
+
if (parentRow.length) { row.insertAfter(parentRow); } else { fileList.prepend(row); }
|
|
2291
|
+
} else {
|
|
2292
|
+
fileList.prepend(row);
|
|
2293
|
+
}
|
|
2294
|
+
inp.focus();
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// ── Context menu (native Node-RED style) ──
|
|
2298
|
+
let activeMenu = null;
|
|
2299
|
+
function closeMenu() {
|
|
2300
|
+
if (activeMenu) { activeMenu.remove(); activeMenu = null; }
|
|
2301
|
+
}
|
|
2302
|
+
$(document).on("click", closeMenu);
|
|
2303
|
+
|
|
2304
|
+
function showMenu(anchor, items) {
|
|
2305
|
+
closeMenu();
|
|
2306
|
+
const menu = $('<ul class="red-ui-menu red-ui-menu-dropdown" style="position:absolute;z-index:10000;display:block;"></ul>');
|
|
2307
|
+
items.forEach(function (item) {
|
|
2308
|
+
if (item.divider) {
|
|
2309
|
+
menu.append('<li class="red-ui-menu-divider"></li>');
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
const li = $('<li></li>');
|
|
2313
|
+
const a = $('<a href="#"></a>');
|
|
2314
|
+
if (item.danger) a.css("color", "var(--red-ui-text-color-error)");
|
|
2315
|
+
a.append('<i class="fa ' + item.icon + '" style="width:18px;text-align:center;"></i> ');
|
|
2316
|
+
a.append($('<span class="red-ui-menu-label"></span>').text(item.label));
|
|
2317
|
+
a.on("click", function (e) {
|
|
2318
|
+
e.preventDefault();
|
|
2319
|
+
e.stopPropagation();
|
|
2320
|
+
closeMenu();
|
|
2321
|
+
item.action();
|
|
2322
|
+
});
|
|
2323
|
+
li.append(a);
|
|
2324
|
+
menu.append(li);
|
|
2325
|
+
});
|
|
2326
|
+
|
|
2327
|
+
$("body").append(menu);
|
|
2328
|
+
activeMenu = menu;
|
|
2329
|
+
|
|
2330
|
+
// Position near the anchor
|
|
2331
|
+
const off = anchor.offset();
|
|
2332
|
+
let top = off.top + anchor.outerHeight() + 2;
|
|
2333
|
+
let left = off.left - menu.outerWidth() + anchor.outerWidth();
|
|
2334
|
+
if (left < 0) left = off.left;
|
|
2335
|
+
if (top + menu.outerHeight() > $(window).height()) top = off.top - menu.outerHeight() - 2;
|
|
2336
|
+
menu.css({ top: top, left: left });
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
const adminRoot = (RED.settings.httpAdminRoot || "/").replace(/\/$/, "");
|
|
2340
|
+
|
|
2341
|
+
function showRenameInput(rowEl, fullPath, currentName) {
|
|
2342
|
+
const nameSpan = rowEl.find("span").filter(function () { return $(this).text() === currentName; }).first();
|
|
2343
|
+
if (!nameSpan.length) return;
|
|
2344
|
+
const origText = nameSpan.text();
|
|
2345
|
+
const inp = $('<input type="text" class="red-ui-searchBox-input" style="flex:1;padding:3px 6px;font-size:12px;">').val(origText);
|
|
2346
|
+
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>');
|
|
2347
|
+
const cancelBtn = $('<button class="red-ui-button red-ui-button-small" title="Cancel"><i class="fa fa-times"></i></button>');
|
|
2348
|
+
const dotIdx = origText.lastIndexOf(".");
|
|
2349
|
+
const hasExt = dotIdx > 0; // has extension (not hidden file)
|
|
2350
|
+
const origExt = hasExt ? origText.slice(dotIdx) : "";
|
|
2351
|
+
|
|
2352
|
+
nameSpan.replaceWith(inp);
|
|
2353
|
+
inp.after(cancelBtn).after(okBtn);
|
|
2354
|
+
inp.focus();
|
|
2355
|
+
// Select only the name part before extension
|
|
2356
|
+
const el = inp[0];
|
|
2357
|
+
if (hasExt && el.setSelectionRange) {
|
|
2358
|
+
el.setSelectionRange(0, dotIdx);
|
|
2359
|
+
} else {
|
|
2360
|
+
inp.select();
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
function restore() {
|
|
2364
|
+
okBtn.remove();
|
|
2365
|
+
cancelBtn.remove();
|
|
2366
|
+
inp.replaceWith($('<span style="flex:1;font-size:12px;word-break:break-all;"></span>').text(origText));
|
|
2367
|
+
}
|
|
2368
|
+
function submit() {
|
|
2369
|
+
const newName = inp.val().trim();
|
|
2370
|
+
if (!newName) {
|
|
2371
|
+
RED.notify("Name cannot be empty", "warning");
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
// Warn if extension changed or removed
|
|
2375
|
+
if (hasExt) {
|
|
2376
|
+
const newDot = newName.lastIndexOf(".");
|
|
2377
|
+
const newExt = newDot > 0 ? newName.slice(newDot) : "";
|
|
2378
|
+
if (newExt.toLowerCase() !== origExt.toLowerCase()) {
|
|
2379
|
+
if (!confirm("Extension changed from '" + origExt + "' to '" + (newExt || "none") + "'. Continue?")) return;
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
if (newName === origText) { restore(); return; }
|
|
2383
|
+
const parentDir = fullPath.indexOf("/") >= 0 ? fullPath.slice(0, fullPath.lastIndexOf("/")) : "";
|
|
2384
|
+
const newPath = parentDir ? parentDir + "/" + newName : newName;
|
|
2385
|
+
if (pathExists(newPath)) {
|
|
2386
|
+
RED.notify("'" + newName + "' already exists", "warning");
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
$.ajax({
|
|
2390
|
+
type: "POST", url: "portal-react/assets/move",
|
|
2391
|
+
contentType: "application/json",
|
|
2392
|
+
data: JSON.stringify({ from: fullPath, to: newPath }),
|
|
2393
|
+
success: function () { refreshList(); },
|
|
2394
|
+
error: function (xhr) {
|
|
2395
|
+
const msg = xhr.responseJSON ? xhr.responseJSON.error : "rename failed";
|
|
2396
|
+
RED.notify("Rename failed: " + msg, "error");
|
|
2397
|
+
restore();
|
|
2398
|
+
},
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
okBtn.on("click", function (e) { e.preventDefault(); e.stopPropagation(); submit(); });
|
|
2402
|
+
cancelBtn.on("click", function (e) { e.preventDefault(); e.stopPropagation(); restore(); });
|
|
2403
|
+
inp.on("keydown", function (e) {
|
|
2404
|
+
if (e.key === "Enter") submit();
|
|
2405
|
+
if (e.key === "Escape") restore();
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
// ── Tree rendering ──
|
|
2410
|
+
function buildTree(entries) {
|
|
2411
|
+
// Build nested structure: { children: { name: { type, children, entry } } }
|
|
2412
|
+
const root = { children: {} };
|
|
2413
|
+
entries.forEach(function (e) {
|
|
2414
|
+
const parts = e.name.split("/");
|
|
2415
|
+
let node = root;
|
|
2416
|
+
for (let i = 0; i < parts.length; i++) {
|
|
2417
|
+
if (!node.children[parts[i]]) {
|
|
2418
|
+
node.children[parts[i]] = { children: {} };
|
|
2419
|
+
}
|
|
2420
|
+
node = node.children[parts[i]];
|
|
2421
|
+
}
|
|
2422
|
+
node.entry = e;
|
|
2423
|
+
});
|
|
2424
|
+
return root;
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
const ROOT_KEY = "__root__";
|
|
2428
|
+
|
|
2429
|
+
function renderTree() {
|
|
2430
|
+
fileList.empty();
|
|
2431
|
+
const isOpen = !collapsed[ROOT_KEY];
|
|
2432
|
+
|
|
2433
|
+
// Root folder row — always visible
|
|
2434
|
+
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>');
|
|
2435
|
+
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>');
|
|
2436
|
+
rootArrow.on("click", function () { collapsed[ROOT_KEY] = isOpen; renderTree(); });
|
|
2437
|
+
rootRow.append(rootArrow);
|
|
2438
|
+
$('<i class="fa ' + (isOpen ? 'fa-folder-open' : 'fa-folder') + '" style="color:#fbbf24;font-size:12px;width:16px;text-align:center;"></i>').appendTo(rootRow);
|
|
2439
|
+
$('<span style="flex:1;font-size:12px;cursor:pointer;opacity:0.8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>').text(publicBase.replace(/\/$/, ""))
|
|
2440
|
+
.on("click", function () { collapsed[ROOT_KEY] = isOpen; renderTree(); })
|
|
2441
|
+
.appendTo(rootRow);
|
|
2442
|
+
fileList.append(rootRow);
|
|
2443
|
+
|
|
2444
|
+
if (!isOpen) return;
|
|
2445
|
+
|
|
2446
|
+
if (allEntries.length === 0) {
|
|
2447
|
+
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>'));
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
const tree = buildTree(allEntries);
|
|
2451
|
+
renderNode(tree, "", 1);
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
function renderNode(node, parentPath, depth) {
|
|
2455
|
+
// Collect and sort: dirs first, then files
|
|
2456
|
+
const names = Object.keys(node.children).sort(function (a, b) {
|
|
2457
|
+
const aIsDir = node.children[a].entry && node.children[a].entry.type === "dir";
|
|
2458
|
+
const bIsDir = node.children[b].entry && node.children[b].entry.type === "dir";
|
|
2459
|
+
if (aIsDir && !bIsDir) return -1;
|
|
2460
|
+
if (!aIsDir && bIsDir) return 1;
|
|
2461
|
+
return a.localeCompare(b);
|
|
2462
|
+
});
|
|
2463
|
+
|
|
2464
|
+
names.forEach(function (name) {
|
|
2465
|
+
const child = node.children[name];
|
|
2466
|
+
const e = child.entry;
|
|
2467
|
+
if (!e) return;
|
|
2468
|
+
const fullPath = e.name;
|
|
2469
|
+
const indent = 8 + depth * 18;
|
|
2470
|
+
const isDir = e.type === "dir";
|
|
2471
|
+
const isOpen = !collapsed[fullPath];
|
|
2472
|
+
|
|
2473
|
+
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>');
|
|
2474
|
+
row.css("padding-left", indent + "px");
|
|
2475
|
+
|
|
2476
|
+
if (isDir) {
|
|
2477
|
+
// Expand/collapse arrow
|
|
2478
|
+
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>');
|
|
2479
|
+
arrow.on("click", function (e) {
|
|
2480
|
+
e.stopPropagation();
|
|
2481
|
+
collapsed[fullPath] = isOpen;
|
|
2482
|
+
renderTree();
|
|
2483
|
+
});
|
|
2484
|
+
row.append(arrow);
|
|
2485
|
+
$('<i class="fa ' + (isOpen ? 'fa-folder-open' : 'fa-folder') + '" style="color:#fbbf24;font-size:12px;width:16px;text-align:center;"></i>').appendTo(row);
|
|
2486
|
+
$('<span style="flex:1;font-size:12px;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + fullPath + '"></span>').text(name)
|
|
2487
|
+
.on("click", function () { collapsed[fullPath] = isOpen; renderTree(); })
|
|
2488
|
+
.appendTo(row);
|
|
2489
|
+
|
|
2490
|
+
// Context menu trigger
|
|
2491
|
+
const dirMenuBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;"><i class="fa fa-ellipsis-v"></i></button>');
|
|
2492
|
+
(function (fp, nm) {
|
|
2493
|
+
dirMenuBtn.on("click", function (ev) {
|
|
2494
|
+
ev.preventDefault();
|
|
2495
|
+
ev.stopPropagation();
|
|
2496
|
+
showMenu(dirMenuBtn, [
|
|
2497
|
+
{ icon: "fa-pencil", label: "Rename", action: function () {
|
|
2498
|
+
showRenameInput(row, fp, nm);
|
|
2499
|
+
}},
|
|
2500
|
+
{ icon: "fa-plus", label: "New subfolder", action: function () {
|
|
2501
|
+
if (collapsed[fp]) { collapsed[fp] = false; renderTree(); }
|
|
2502
|
+
setTimeout(function () { showNewFolderInput(fp); }, 50);
|
|
2503
|
+
}},
|
|
2504
|
+
{ divider: true },
|
|
2505
|
+
{ icon: "fa-trash", label: "Delete folder", danger: true, action: function () {
|
|
2506
|
+
if (!confirm("Delete folder '" + nm + "' and all contents?")) return;
|
|
2507
|
+
$.ajax({
|
|
2508
|
+
type: "DELETE",
|
|
2509
|
+
url: "portal-react/assets/" + fp.split("/").map(encodeURIComponent).join("/"),
|
|
2510
|
+
success: function () { refreshList(); },
|
|
2511
|
+
error: function () { RED.notify("Delete failed", "error"); },
|
|
2512
|
+
});
|
|
2513
|
+
}},
|
|
2514
|
+
]);
|
|
2515
|
+
});
|
|
2516
|
+
})(fullPath, name);
|
|
2517
|
+
dirMenuBtn.appendTo(row);
|
|
2518
|
+
|
|
2519
|
+
// Drop target
|
|
2520
|
+
row.on("dragover", function (ev) {
|
|
2521
|
+
ev.preventDefault();
|
|
2522
|
+
ev.stopPropagation();
|
|
2523
|
+
row.css("background", "rgba(251,191,36,0.08)");
|
|
2524
|
+
});
|
|
2525
|
+
row.on("dragleave", function () { row.css("background", ""); });
|
|
2526
|
+
row.on("drop", function (ev) {
|
|
2527
|
+
ev.preventDefault();
|
|
2528
|
+
ev.stopPropagation();
|
|
2529
|
+
row.css("background", "");
|
|
2530
|
+
const srcPath = ev.originalEvent.dataTransfer.getData("text/x-asset-path");
|
|
2531
|
+
if (srcPath) {
|
|
2532
|
+
moveItem(srcPath, fullPath);
|
|
2533
|
+
} else if (ev.originalEvent.dataTransfer.files && ev.originalEvent.dataTransfer.files.length > 0) {
|
|
2534
|
+
uploadFiles(ev.originalEvent.dataTransfer.files, fullPath);
|
|
2535
|
+
}
|
|
2536
|
+
});
|
|
2537
|
+
fileList.append(row);
|
|
2538
|
+
|
|
2539
|
+
// Render children if open
|
|
2540
|
+
if (isOpen) {
|
|
2541
|
+
renderNode(child, fullPath, depth + 1);
|
|
2542
|
+
}
|
|
2543
|
+
} else {
|
|
2544
|
+
// File
|
|
2545
|
+
row.attr("draggable", "true").css("cursor", "grab");
|
|
2546
|
+
$('<span style="width:10px;"></span>').appendTo(row); // spacer for arrow alignment
|
|
2547
|
+
$('<i class="fa fa-file-o" style="color:var(--red-ui-secondary-text-color);font-size:11px;width:16px;text-align:center;"></i>').appendTo(row);
|
|
2548
|
+
$('<span style="flex:1;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + fullPath + '"></span>').text(name).appendTo(row);
|
|
2549
|
+
$('<span style="font-size:10px;color:var(--red-ui-tertiary-text-color);white-space:nowrap;"></span>').text(formatSize(e.size)).appendTo(row);
|
|
2550
|
+
|
|
2551
|
+
row.on("dragstart", function (ev) {
|
|
2552
|
+
ev.originalEvent.dataTransfer.setData("text/x-asset-path", fullPath);
|
|
2553
|
+
ev.originalEvent.dataTransfer.effectAllowed = "move";
|
|
2554
|
+
row.css("opacity", "0.4");
|
|
2555
|
+
});
|
|
2556
|
+
row.on("dragend", function () { row.css("opacity", "1"); });
|
|
2557
|
+
|
|
2558
|
+
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>');
|
|
2559
|
+
const fileMenuBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;"><i class="fa fa-ellipsis-v"></i></button>');
|
|
2560
|
+
(function (fp, nm) {
|
|
2561
|
+
copyBtn.on("click", function (ev) {
|
|
2562
|
+
ev.preventDefault();
|
|
2563
|
+
ev.stopPropagation();
|
|
2564
|
+
const url = publicBase + fp;
|
|
2565
|
+
navigator.clipboard.writeText(url).then(function () {
|
|
2566
|
+
RED.notify("Copied: " + url, { type: "success", timeout: 2000 });
|
|
2567
|
+
});
|
|
2568
|
+
});
|
|
2569
|
+
fileMenuBtn.on("click", function (ev) {
|
|
2570
|
+
ev.preventDefault();
|
|
2571
|
+
ev.stopPropagation();
|
|
2572
|
+
showMenu(fileMenuBtn, [
|
|
2573
|
+
{ icon: "fa-pencil", label: "Rename", action: function () {
|
|
2574
|
+
showRenameInput(row, fp, nm);
|
|
2575
|
+
}},
|
|
2576
|
+
{ icon: "fa-download", label: "Download", action: function () {
|
|
2577
|
+
const a = document.createElement("a");
|
|
2578
|
+
a.href = adminRoot + "/portal-react/assets/download/" + fp.split("/").map(encodeURIComponent).join("/");
|
|
2579
|
+
a.download = nm;
|
|
2580
|
+
document.body.appendChild(a);
|
|
2581
|
+
a.click();
|
|
2582
|
+
document.body.removeChild(a);
|
|
2583
|
+
}},
|
|
2584
|
+
{ divider: true },
|
|
2585
|
+
{ icon: "fa-trash", label: "Delete", danger: true, action: function () {
|
|
2586
|
+
$.ajax({
|
|
2587
|
+
type: "DELETE",
|
|
2588
|
+
url: "portal-react/assets/" + fp.split("/").map(encodeURIComponent).join("/"),
|
|
2589
|
+
success: function () { refreshList(); },
|
|
2590
|
+
error: function () { RED.notify("Delete failed: " + nm, "error"); },
|
|
2591
|
+
});
|
|
2592
|
+
}},
|
|
2593
|
+
]);
|
|
2594
|
+
});
|
|
2595
|
+
})(fullPath, name);
|
|
2596
|
+
copyBtn.appendTo(row);
|
|
2597
|
+
fileMenuBtn.appendTo(row);
|
|
2598
|
+
fileList.append(row);
|
|
2599
|
+
}
|
|
2600
|
+
});
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
/**
|
|
2604
|
+
* Re-fetch the assets listing from the admin API and re-render the tree.
|
|
2605
|
+
* Called after every mutation (upload/move/delete/mkdir) and on the
|
|
2606
|
+
* Node-RED `sidebar:resize` event.
|
|
2607
|
+
* @returns {void}
|
|
2608
|
+
*/
|
|
2609
|
+
function refreshList() {
|
|
2610
|
+
$.getJSON("portal-react/assets", function (entries) {
|
|
2611
|
+
allEntries = entries || [];
|
|
2612
|
+
renderTree();
|
|
2613
|
+
});
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
RED.sidebar.addTab({
|
|
2617
|
+
id: "fromcubes-public",
|
|
2618
|
+
label: "Portal Assets",
|
|
2619
|
+
name: "fromcubes portal assets",
|
|
2620
|
+
iconClass: "fa fa-desktop",
|
|
2621
|
+
content: content[0],
|
|
2622
|
+
toolbar: toolbar[0],
|
|
2623
|
+
pinned: false,
|
|
2624
|
+
enableOnEdit: true,
|
|
2625
|
+
});
|
|
2626
|
+
|
|
2627
|
+
RED.actions.add("fromcubes:show-assets", function () {
|
|
2628
|
+
RED.sidebar.show("fromcubes-public");
|
|
2629
|
+
});
|
|
2630
|
+
|
|
2631
|
+
RED.events.on("sidebar:resize", function () { refreshList(); });
|
|
2632
|
+
setTimeout(refreshList, 1000);
|
|
2633
|
+
})();
|
|
1059
2634
|
</script>
|