@aaqu/fromcubes-react 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +405 -2
  3. package/THIRD-PARTY-NOTICES.md +164 -0
  4. package/examples/D3 Poland Map.json +90 -0
  5. package/examples/Live Chart.json +101 -0
  6. package/examples/PixiJS Sprites.json +114 -0
  7. package/examples/Sensor Portal.json +93 -0
  8. package/examples/Shared Components.json +68 -0
  9. package/examples/Three.js Scene.json +118 -0
  10. package/examples/Utility Hooks.json +94 -0
  11. package/examples/WebGPU Shader.json +110 -0
  12. package/nodes/editor/ark-pixel.LICENSE.txt +96 -0
  13. package/nodes/editor/ark-pixel.woff2 +0 -0
  14. package/nodes/editor/file-tree.js +403 -0
  15. package/nodes/editor/format.js +182 -0
  16. package/nodes/editor/icons.svg +46 -0
  17. package/nodes/editor/mark.svg +5 -0
  18. package/nodes/editor/provider-settings.js +192 -0
  19. package/nodes/editor/workspace.css +653 -0
  20. package/nodes/editor/workspace.js +1565 -0
  21. package/nodes/icons/fromcubes-component.svg +27 -0
  22. package/nodes/icons/fromcubes-react.svg +25 -0
  23. package/nodes/icons/fromcubes-utility.svg +21 -0
  24. package/nodes/lib/admin-api.js +238 -0
  25. package/nodes/lib/admin-security.js +161 -0
  26. package/nodes/lib/api.js +29 -0
  27. package/nodes/lib/helpers.js +990 -0
  28. package/nodes/lib/hooks.js +145 -0
  29. package/nodes/lib/page-builder.js +499 -0
  30. package/nodes/lib/portal-page-route.js +131 -0
  31. package/nodes/lib/providers.js +536 -0
  32. package/nodes/lib/registry-nodes.js +331 -0
  33. package/nodes/lib/router.js +103 -0
  34. package/nodes/lib/ws-heartbeat.js +58 -0
  35. package/nodes/portal-react.html +2539 -0
  36. package/nodes/portal-react.js +1652 -0
  37. package/package.json +76 -4
@@ -0,0 +1,1565 @@
1
+ /**
2
+ * React Editor — Node-RED editor plugin.
3
+ *
4
+ * Adds a button to Node-RED's bottom status bar that opens a full-screen
5
+ * overlay presenting every portal-react source field as a file: tree on the
6
+ * left, one Monaco instance on the right, tabs across the top. Each fromcubes
7
+ * node's edit dialog hands off to the same overlay.
8
+ *
9
+ * Source of truth is the editor's own node objects (`RED.nodes`), exactly like
10
+ * a node's `oneditsave` — edits are written straight onto the node, which arms
11
+ * the Deploy button. Nothing here talks to the server for writes: the registry
12
+ * REST endpoints are deprecated (410) and `RED.settings.portalReact*` is
13
+ * rebuilt from node config on every deploy anyway.
14
+ */
15
+ (function () {
16
+ "use strict";
17
+
18
+ const PREFIX = "[FC-Workspace]";
19
+ const EDITOR_NAME = "React Editor";
20
+ const FLUSH_DEBOUNCE_MS = 400;
21
+ const MIN_TREE_WIDTH = 150;
22
+
23
+ // ── Module state ────────────────────────────────────────────
24
+
25
+ /** @type {Object} pure tree helpers from file-tree.js */
26
+ let FT = null;
27
+
28
+ let overlay = null;
29
+ let treeEl = null;
30
+ let tabsEl = null;
31
+ let monacoHost = null;
32
+ let fallbackEl = null;
33
+ let placeholderEl = null;
34
+ let statusEl = null;
35
+ let openPortalBtn = null;
36
+ let deployBtn = null;
37
+ let filterEl = null;
38
+ let settingsPanel = null;
39
+ let gearEl = null;
40
+
41
+ let editor = null;
42
+ let monacoReady = false;
43
+ let monacoFailed = false;
44
+
45
+ /** key → { file, model, viewState, lastSynced } */
46
+ const entries = {};
47
+ /** ordered list of open file keys */
48
+ const openKeys = [];
49
+ let activeKey = null;
50
+
51
+ /** dir path → true when collapsed */
52
+ const collapsed = {};
53
+ /** current flat file listing */
54
+ let files = [];
55
+ /** registryName → error string, from the read-only admin API */
56
+ let compErrors = {};
57
+ let utilErrors = {};
58
+
59
+ /**
60
+ * Everything the settings panel owns, persisted as one object so adding a
61
+ * setting later doesn't mean another localStorage key to migrate.
62
+ * `groupBy`: "flow" mirrors the canvas (each tab holds its own three kinds,
63
+ * alphabetical); "kind" hoists Portals/Components/Utilities to the top.
64
+ */
65
+ const DEFAULT_SETTINGS = { groupBy: "flow", showHead: true, showJs: true };
66
+ const SETTINGS_KEY = "fcWorkspaceSettings";
67
+ let settings = Object.assign({}, DEFAULT_SETTINGS);
68
+ let filterText = "";
69
+ let flushTimers = {};
70
+ /** guards our own RED.events emissions from bouncing back in */
71
+ let selfEmitting = false;
72
+ let treeWidth = 260;
73
+ let resyncTimer = null;
74
+ /** true once the Lucide sprite is in the DOM; false means Font Awesome */
75
+ let spriteReady = false;
76
+ /** flow-tab label → how many tabs carry it */
77
+ let labelCounts = {};
78
+
79
+ // ── Data collection ─────────────────────────────────────────
80
+
81
+ /**
82
+ * Human label for a flow tab (or subflow) id.
83
+ * @param {string} id
84
+ * @returns {string}
85
+ */
86
+ function flowLabel(id) {
87
+ const ws = RED.nodes.workspace(id);
88
+ let label;
89
+ if (ws) {
90
+ label = ws.label || id;
91
+ } else {
92
+ const sf = RED.nodes.subflow ? RED.nodes.subflow(id) : null;
93
+ label = sf ? "Subflow: " + (sf.name || id) : id;
94
+ }
95
+ // Two tabs may carry the same label — without a suffix their folders would
96
+ // merge into one and look like duplicated files.
97
+ if (labelCounts[label] > 1) label += " #" + String(id).slice(0, 6);
98
+ return label;
99
+ }
100
+
101
+ /**
102
+ * Count flow-tab labels so {@link flowLabel} can disambiguate collisions.
103
+ * @returns {void}
104
+ */
105
+ function countFlowLabels() {
106
+ labelCounts = {};
107
+ RED.nodes.eachWorkspace(function (ws) {
108
+ const label = ws.label || ws.id;
109
+ labelCounts[label] = (labelCounts[label] || 0) + 1;
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Rebuild the flat file listing from the current flows.
115
+ * @returns {void}
116
+ */
117
+ function collect() {
118
+ countFlowLabels();
119
+ const nodes = [];
120
+ RED.nodes.eachNode(function (n) {
121
+ if (FT.isPortalNodeType(n.type)) nodes.push(n);
122
+ });
123
+ files = FT.collectPortalFiles(nodes, {
124
+ flowLabel: flowLabel,
125
+ showHead: settings.showHead,
126
+ groupBy: settings.groupBy,
127
+ flowDisabled: function (id) {
128
+ const ws = RED.nodes.workspace(id);
129
+ return !!(ws && ws.disabled);
130
+ },
131
+ });
132
+ if (!settings.showJs) {
133
+ files = files.filter(function (f) {
134
+ return !/\.js$/.test(f.name);
135
+ });
136
+ }
137
+ // Open tabs track renames/subPath changes; drop tabs whose node is gone.
138
+ const byKey = {};
139
+ files.forEach(function (f) {
140
+ byKey[f.key] = f;
141
+ });
142
+ openKeys.slice().forEach(function (key) {
143
+ if (byKey[key]) {
144
+ if (entries[key]) entries[key].file = byKey[key];
145
+ } else if (!RED.nodes.node(key.split(":")[0])) {
146
+ closeTab(key, true);
147
+ }
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Refresh syntax-error markers from the read-only admin endpoints.
153
+ * @param {Function} [done]
154
+ * @returns {void}
155
+ */
156
+ function refreshErrors(done) {
157
+ let pending = 2;
158
+ const settle = function () {
159
+ if (--pending === 0 && done) done();
160
+ };
161
+ $.getJSON("fromcubes-react/registry", function (reg) {
162
+ compErrors = {};
163
+ Object.keys(reg || {}).forEach(function (n) {
164
+ if (reg[n] && reg[n].error) compErrors[n] = reg[n].error;
165
+ });
166
+ })
167
+ .fail(function () {
168
+ compErrors = {};
169
+ })
170
+ .always(settle);
171
+ $.getJSON("fromcubes-react/utilities", function (reg) {
172
+ utilErrors = {};
173
+ Object.keys(reg || {}).forEach(function (n) {
174
+ if (reg[n] && reg[n].error) utilErrors[n] = reg[n].error;
175
+ });
176
+ })
177
+ .fail(function () {
178
+ utilErrors = {};
179
+ })
180
+ .always(settle);
181
+ }
182
+
183
+ /**
184
+ * Error text for a file, or "" when the file's node built cleanly.
185
+ * @param {Object} file
186
+ * @returns {string}
187
+ */
188
+ function errorFor(file) {
189
+ if (!file.registryName) return "";
190
+ if (file.type === "fc-portal-component") return compErrors[file.registryName] || "";
191
+ if (file.type === "fc-portal-utility") return utilErrors[file.registryName] || "";
192
+ return "";
193
+ }
194
+
195
+ /**
196
+ * True when the file has edits that are not deployed yet — either written to
197
+ * the node but not deployed (`node.changed`), or still sitting in the model.
198
+ * @param {Object} file
199
+ * @returns {boolean}
200
+ */
201
+ function isDirty(file) {
202
+ const node = RED.nodes.node(file.nodeId);
203
+ if (!node) return false;
204
+ if (node.changed) return true;
205
+ const entry = entries[file.key];
206
+ return !!(entry && currentValue(entry) !== (node[file.field] || ""));
207
+ }
208
+
209
+ /**
210
+ * Current editor-side text for an open file (Monaco model or textarea).
211
+ * @param {Object} entry
212
+ * @returns {string}
213
+ */
214
+ function currentValue(entry) {
215
+ if (entry.model && !entry.model.isDisposed()) return entry.model.getValue();
216
+ if (monacoFailed && activeKey === entry.file.key && fallbackEl) {
217
+ return fallbackEl.value;
218
+ }
219
+ return entry.lastSynced;
220
+ }
221
+
222
+ // ── Write-back into the flow ────────────────────────────────
223
+
224
+ /**
225
+ * Push the editor content of one open file onto its node, arming Deploy.
226
+ * Mirrors what a node dialog's `oneditsave` does, plus a history entry so
227
+ * Node-RED's Ctrl+Z undoes workspace edits too.
228
+ *
229
+ * @param {string} key
230
+ * @returns {boolean} true when the node was actually modified
231
+ */
232
+ function flush(key) {
233
+ const entry = entries[key];
234
+ if (!entry) return false;
235
+ clearTimeout(flushTimers[key]);
236
+ delete flushTimers[key];
237
+
238
+ const file = entry.file;
239
+ const node = RED.nodes.node(file.nodeId);
240
+ if (!node) return false;
241
+
242
+ const value = currentValue(entry);
243
+ const oldValue = node[file.field] || "";
244
+ entry.lastSynced = value;
245
+ if (oldValue === value) return false;
246
+
247
+ const wasDirty = RED.nodes.dirty();
248
+ const wasChanged = node.changed;
249
+ node[file.field] = value;
250
+ node.changed = true;
251
+ node.dirty = true;
252
+ RED.nodes.dirty(true);
253
+
254
+ try {
255
+ RED.history.push({
256
+ t: "edit",
257
+ node: node,
258
+ changes: (function () {
259
+ const c = {};
260
+ c[file.field] = oldValue;
261
+ return c;
262
+ })(),
263
+ dirty: wasDirty,
264
+ changed: wasChanged,
265
+ });
266
+ } catch (err) {
267
+ console.warn(PREFIX, "history push failed", err);
268
+ }
269
+
270
+ selfEmitting = true;
271
+ try {
272
+ RED.events.emit("nodes:change", node);
273
+ if (RED.view && RED.view.redraw) RED.view.redraw();
274
+ } finally {
275
+ selfEmitting = false;
276
+ }
277
+ return true;
278
+ }
279
+
280
+ /**
281
+ * Flush every open file.
282
+ * @returns {void}
283
+ */
284
+ function flushAll() {
285
+ openKeys.slice().forEach(flush);
286
+ renderAll();
287
+ }
288
+
289
+ /**
290
+ * Debounced flush triggered by model edits.
291
+ * @param {string} key
292
+ * @returns {void}
293
+ */
294
+ function scheduleFlush(key) {
295
+ clearTimeout(flushTimers[key]);
296
+ flushTimers[key] = setTimeout(function () {
297
+ flush(key);
298
+ renderAll();
299
+ }, FLUSH_DEBOUNCE_MS);
300
+ markTabDirty(key);
301
+ }
302
+
303
+ // ── Monaco models ───────────────────────────────────────────
304
+
305
+ /**
306
+ * File extension → Monaco URI suffix.
307
+ * @param {Object} file
308
+ * @returns {string}
309
+ */
310
+ function extOf(file) {
311
+ const dot = file.name.lastIndexOf(".");
312
+ return dot === -1 ? "txt" : file.name.slice(dot + 1);
313
+ }
314
+
315
+ /**
316
+ * True when a model is gone — either never created or disposed under us.
317
+ * Node-RED disposes every leftover Monaco model on its `editor:close` event
318
+ * ("Cleaning up monaco models left behind"), so closing any node dialog kills
319
+ * the workspace models too. They are rebuilt on demand from the node.
320
+ *
321
+ * @param {Object} entry
322
+ * @returns {boolean}
323
+ */
324
+ function modelGone(entry) {
325
+ return !entry.model || entry.model.isDisposed();
326
+ }
327
+
328
+ /**
329
+ * Create the Monaco model backing one file. Workspace models live in their
330
+ * own `fc-ws-` URI namespace so they never collide with — or dispose — the
331
+ * models a node's edit dialog creates.
332
+ *
333
+ * @param {Object} file
334
+ * @param {string} value
335
+ * @returns {Object} Monaco model
336
+ */
337
+ function createModel(file, value) {
338
+ const uri = monaco.Uri.parse(
339
+ "file:///fc-ws-" + file.nodeId + "-" + file.field + "." + extOf(file),
340
+ );
341
+ const stale = monaco.editor.getModel(uri);
342
+ if (stale) stale.dispose();
343
+ const model = monaco.editor.createModel(value, file.lang, uri);
344
+ model.onDidChangeContent(function () {
345
+ scheduleFlush(file.key);
346
+ });
347
+ return model;
348
+ }
349
+
350
+ /**
351
+ * Get (or lazily create) the model entry for a file, re-creating a model that
352
+ * was disposed while the overlay was closed.
353
+ *
354
+ * @param {Object} file
355
+ * @returns {Object} entry
356
+ */
357
+ function ensureEntry(file) {
358
+ let entry = entries[file.key];
359
+ const node = RED.nodes.node(file.nodeId);
360
+ const value = node ? node[file.field] || "" : "";
361
+
362
+ if (!entry) {
363
+ entry = { file: file, model: null, viewState: null, lastSynced: value };
364
+ entries[file.key] = entry;
365
+ }
366
+ entry.file = file;
367
+
368
+ if (monacoReady && modelGone(entry)) {
369
+ // The node is authoritative here: edits are flushed before the overlay
370
+ // closes, so a disposed model can never hold newer text.
371
+ entry.lastSynced = value;
372
+ entry.viewState = null;
373
+ entry.model = createModel(file, value);
374
+ }
375
+ return entry;
376
+ }
377
+
378
+ // ── Go to definition ────────────────────────────────────────
379
+
380
+ /**
381
+ * Where a top-level `name` is declared in `code`.
382
+ *
383
+ * Deliberately a scan, not a parse: the editor has to answer while the user
384
+ * is still typing, against code that may not even be valid yet. A missed
385
+ * declaration costs one failed jump; a parser would cost every jump in a
386
+ * half-written file.
387
+ *
388
+ * @param {string} code
389
+ * @param {string} name
390
+ * @returns {{lineNumber: number, column: number}|null}
391
+ */
392
+ function findDeclaration(code, name) {
393
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
394
+ const patterns = [
395
+ // `function Foo(`, `const Foo =`, `class Foo`, with export/async prefixes
396
+ new RegExp(
397
+ "(?:^|\\n)\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?" +
398
+ "(?:function\\*?|const|let|var|class)\\s+" +
399
+ escaped +
400
+ "(?![\\w$])",
401
+ ),
402
+ // second and later declarators: `const a = 1, Foo = 2`
403
+ new RegExp("(?:^|[,({\\s])" + escaped + "\\s*[=:]"),
404
+ // last resort: the first mention at all
405
+ new RegExp("(?<![\\w$])" + escaped + "(?![\\w$])"),
406
+ ];
407
+ for (const re of patterns) {
408
+ const m = re.exec(code);
409
+ if (!m) continue;
410
+ const at = m.index + m[0].indexOf(name);
411
+ const before = code.slice(0, at);
412
+ const line = before.split("\n").length;
413
+ return { lineNumber: line, column: at - before.lastIndexOf("\n") };
414
+ }
415
+ return null;
416
+ }
417
+
418
+ /**
419
+ * Resolve an identifier to the fromcubes node that declares it: a component
420
+ * by its JSX tag, or a utility by any symbol declared at its top level.
421
+ *
422
+ * Resolution runs off `RED.nodes`, not the server registry, so a component
423
+ * renamed or a helper added since the last deploy is already navigable.
424
+ *
425
+ * @param {string} name
426
+ * @returns {{key: string, position: Object}|null}
427
+ */
428
+ function resolveDefinition(name) {
429
+ if (!name) return null;
430
+ let hit = null;
431
+ RED.nodes.eachNode(function (n) {
432
+ if (hit) return;
433
+ if (n.type === "fc-portal-component") {
434
+ if ((n.compName || "").trim() !== name) return;
435
+ hit = {
436
+ key: n.id + ":compCode",
437
+ position: findDeclaration(n.compCode || "", name) || { lineNumber: 1, column: 1 },
438
+ };
439
+ } else if (n.type === "fc-portal-utility") {
440
+ const code = n.utilCode || "";
441
+ if (!FT.declaresSymbol(code, name)) return;
442
+ hit = {
443
+ key: n.id + ":utilCode",
444
+ position: findDeclaration(code, name) || { lineNumber: 1, column: 1 },
445
+ };
446
+ }
447
+ });
448
+ return hit;
449
+ }
450
+
451
+ /**
452
+ * Jump to the definition of the identifier at `position`, falling back to
453
+ * Monaco's own in-file navigation when the name belongs to neither a
454
+ * component nor a utility.
455
+ *
456
+ * @param {Object} position Monaco position
457
+ * @returns {boolean} true when a fromcubes definition was opened
458
+ */
459
+ function gotoDefinition(position) {
460
+ if (!editor || !position) return false;
461
+ const model = editor.getModel();
462
+ if (!model) return false;
463
+ const word = model.getWordAtPosition(position);
464
+ const target = word && resolveDefinition(word.word);
465
+ if (!target) {
466
+ const action = editor.getAction("editor.action.revealDefinition");
467
+ if (action) action.run();
468
+ return false;
469
+ }
470
+ if (target.key === activeKey) {
471
+ editor.setPosition(target.position);
472
+ editor.revealPositionInCenter(target.position);
473
+ return true;
474
+ }
475
+ openFile(target.key, target.position);
476
+ return true;
477
+ }
478
+
479
+ // ── Tabs ────────────────────────────────────────────────────
480
+
481
+ /**
482
+ * Open a file in the editor pane, creating its tab if needed.
483
+ * @param {string} key
484
+ * @returns {void}
485
+ */
486
+ function openFile(key, position) {
487
+ const file = files.filter(function (f) {
488
+ return f.key === key;
489
+ })[0];
490
+ if (!file) return;
491
+ ensureEntry(file);
492
+ if (openKeys.indexOf(key) === -1) openKeys.push(key);
493
+ setActive(key);
494
+ if (position && editor) {
495
+ editor.setPosition(position);
496
+ editor.revealPositionInCenter(position);
497
+ editor.focus();
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Make one open file the visible one, preserving per-file cursor/scroll.
503
+ * @param {string} key
504
+ * @returns {void}
505
+ */
506
+ function setActive(key) {
507
+ if (activeKey && activeKey !== key) {
508
+ const prev = entries[activeKey];
509
+ if (prev) {
510
+ if (editor && !modelGone(prev)) prev.viewState = editor.saveViewState();
511
+ flush(activeKey);
512
+ }
513
+ }
514
+ activeKey = key;
515
+ let entry = entries[key];
516
+ if (!entry) return;
517
+ // Re-create the model if a node dialog's close disposed it.
518
+ entry = ensureEntry(entry.file);
519
+
520
+ if (editor && !modelGone(entry)) {
521
+ editor.setModel(entry.model);
522
+ if (entry.viewState) editor.restoreViewState(entry.viewState);
523
+ editor.focus();
524
+ } else if (monacoFailed && fallbackEl) {
525
+ fallbackEl.value = entry.lastSynced;
526
+ }
527
+ updatePaneVisibility();
528
+ renderAll();
529
+ revealActiveInTree();
530
+ }
531
+
532
+ /**
533
+ * Close a tab (flushing first unless the node disappeared).
534
+ * @param {string} key
535
+ * @param {boolean} [gone] node no longer exists — skip the flush
536
+ * @returns {void}
537
+ */
538
+ function closeTab(key, gone) {
539
+ if (!gone) flush(key);
540
+ const i = openKeys.indexOf(key);
541
+ if (i !== -1) openKeys.splice(i, 1);
542
+ const entry = entries[key];
543
+ if (entry && !modelGone(entry)) entry.model.dispose();
544
+ delete entries[key];
545
+ if (activeKey === key) {
546
+ activeKey = null;
547
+ if (editor) editor.setModel(null);
548
+ const next = openKeys[Math.max(0, i - 1)];
549
+ if (next) setActive(next);
550
+ }
551
+ updatePaneVisibility();
552
+ renderTabs();
553
+ renderTree();
554
+ }
555
+
556
+ /**
557
+ * Toggle the dirty dot on a tab without a full re-render (fires per keystroke).
558
+ * @param {string} key
559
+ * @returns {void}
560
+ */
561
+ function markTabDirty(key) {
562
+ if (!tabsEl) return;
563
+ tabsEl.find('[data-key="' + cssEscape(key) + '"]').addClass("fc-ws-tab-dirty");
564
+ updateStatus();
565
+ }
566
+
567
+ // ── Rendering ───────────────────────────────────────────────
568
+
569
+ /**
570
+ * Escape a key for use inside an attribute selector.
571
+ * @param {string} s
572
+ * @returns {string}
573
+ */
574
+ function cssEscape(s) {
575
+ return String(s).replace(/["\\]/g, "\\$&");
576
+ }
577
+
578
+ /** @see FT.isParentFile */
579
+ function isParentFile(n) {
580
+ return FT.isParentFile(n);
581
+ }
582
+
583
+ /**
584
+ * Is this node showing its children? Which way round the default runs is
585
+ * `startsCollapsed`'s call; the stored state is always the exception to it.
586
+ *
587
+ * @param {Object} n
588
+ * @returns {boolean}
589
+ */
590
+ function isExpanded(n) {
591
+ const state = collapsed[n.path];
592
+ return FT.startsCollapsed(n) ? state === false : state !== true;
593
+ }
594
+
595
+ /**
596
+ * Flip one node's expansion, recording it explicitly so the stored state
597
+ * reads the same for folders and for files holding files.
598
+ * @param {Object} n
599
+ * @returns {void}
600
+ */
601
+ function toggleExpanded(n) {
602
+ collapsed[n.path] = isExpanded(n);
603
+ }
604
+
605
+ /** Sprite symbol per file kind — the same shapes the node badges wear. */
606
+ const KIND_ICON = {
607
+ portal: "atom",
608
+ component: "component",
609
+ utility: "wrench",
610
+ head: "code-xml",
611
+ file: "code-xml",
612
+ };
613
+
614
+ /**
615
+ * What each sprite symbol degrades to when the sprite could not be loaded.
616
+ * Font Awesome 4 is always there — Node-RED loads it for its own editor.
617
+ */
618
+ const FA_FALLBACK = {
619
+ atom: "fa fa-file-code-o",
620
+ component: "fa fa-file-code-o",
621
+ wrench: "fa fa-file-code-o",
622
+ "code-xml": "fa fa-file-text-o",
623
+ folder: "fa fa-folder-o",
624
+ "folder-open": "fa fa-folder-open-o",
625
+ "chevron-right": "fa fa-caret-right",
626
+ "chevron-down": "fa fa-caret-down",
627
+ ban: "fa fa-ban",
628
+ };
629
+
630
+ /**
631
+ * Fetch the Lucide sprite once and park it in the document, so rows can
632
+ * reference its symbols locally. Local `<use>` is what makes `currentColor`
633
+ * and a CSS `stroke-width` override reliable — through an external file both
634
+ * are inconsistent across engines.
635
+ *
636
+ * @returns {void}
637
+ */
638
+ function injectIconSprite() {
639
+ $.ajax({ url: "fromcubes-react/editor/icons.svg", dataType: "text" })
640
+ .done(function (markup) {
641
+ $("<div>")
642
+ .attr("id", "fc-ws-icon-sprite")
643
+ .css("display", "none")
644
+ .html(markup)
645
+ .appendTo(document.body);
646
+ spriteReady = true;
647
+ if (overlayVisible()) renderTree();
648
+ })
649
+ .fail(function () {
650
+ console.warn(PREFIX, "icon sprite unavailable — falling back to Font Awesome");
651
+ });
652
+ }
653
+
654
+ /**
655
+ * One icon element: a sprite reference, or the Font Awesome equivalent when
656
+ * the sprite never arrived.
657
+ *
658
+ * @param {string} name Sprite symbol name (Lucide's own).
659
+ * @param {string} [kind] File kind, for the colour class.
660
+ * @returns {Object} jQuery element
661
+ */
662
+ function iconEl(name, kind) {
663
+ if (!spriteReady) {
664
+ return $("<i>").addClass(FA_FALLBACK[name] || "fa fa-file-o");
665
+ }
666
+ const NS = "http://www.w3.org/2000/svg";
667
+ const svg = document.createElementNS(NS, "svg");
668
+ svg.setAttribute("class", "fc-ws-icon" + (kind ? " fc-ws-icon--" + kind : ""));
669
+ const use = document.createElementNS(NS, "use");
670
+ use.setAttribute("href", "#fc-icon-" + name);
671
+ svg.appendChild(use);
672
+ return $(svg);
673
+ }
674
+
675
+ /**
676
+ * Icon for a tree entry — folder state for directories, node kind for files.
677
+ * @param {Object} n
678
+ * @returns {Object} jQuery element
679
+ */
680
+ function iconElFor(n) {
681
+ if (n.type === "dir") {
682
+ // A flow tab is not a folder — it is the thing the canvas shows as a tab.
683
+ if (n.kind === "flow") return iconEl("workflow");
684
+ return iconEl(isExpanded(n) ? "folder-open" : "folder");
685
+ }
686
+ const kind = FT.iconKindFor(n.file);
687
+ return iconEl(KIND_ICON[kind] || KIND_ICON.file, kind);
688
+ }
689
+
690
+ /**
691
+ * Read persisted settings, falling back to the defaults for anything absent
692
+ * or malformed — a stale key must never leave the tree empty.
693
+ * @returns {void}
694
+ */
695
+ function loadSettings() {
696
+ try {
697
+ const raw = window.localStorage.getItem(SETTINGS_KEY);
698
+ const saved = raw ? JSON.parse(raw) : null;
699
+ if (saved && typeof saved === "object") {
700
+ if (saved.groupBy === "kind" || saved.groupBy === "flow") {
701
+ settings.groupBy = saved.groupBy;
702
+ }
703
+ if (typeof saved.showHead === "boolean") settings.showHead = saved.showHead;
704
+ if (typeof saved.showJs === "boolean") settings.showJs = saved.showJs;
705
+ }
706
+ } catch (err) {
707
+ console.warn(PREFIX, "settings unreadable, using defaults", err);
708
+ }
709
+ }
710
+
711
+ /**
712
+ * Persist settings and re-render. Storage can throw (private mode, blocked
713
+ * site data) — the setting still applies for this session.
714
+ * @returns {void}
715
+ */
716
+ function saveSettings() {
717
+ try {
718
+ window.localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
719
+ } catch (err) {
720
+ console.warn(PREFIX, "settings not persisted", err);
721
+ }
722
+ collect();
723
+ renderAll();
724
+ }
725
+
726
+ /**
727
+ * Dim a row whose code is not running, and say why. Node-RED can switch off
728
+ * a single node or a whole flow tab; the editor would otherwise show both
729
+ * exactly like live code.
730
+ *
731
+ * @param {Object} row jQuery row element
732
+ * @param {"node"|"flow"} reason
733
+ * @returns {void}
734
+ */
735
+ function markDisabled(row, reason) {
736
+ row.addClass("fc-ws-off");
737
+ iconEl("ban").addClass("fc-ws-off-mark").appendTo(row);
738
+ row.attr(
739
+ "title",
740
+ (row.attr("title") ? row.attr("title") + " — " : "") +
741
+ (reason === "flow" ? "flow disabled in Node-RED" : "node disabled in Node-RED"),
742
+ );
743
+ }
744
+
745
+ /**
746
+ * Render the overlay tree from the current listing + filter.
747
+ * @returns {void}
748
+ */
749
+ function renderTree() {
750
+ if (!treeEl) return;
751
+ treeEl.empty();
752
+ const visible = FT.filterFiles(files, filterText);
753
+ if (!visible.length) {
754
+ $("<div>")
755
+ .addClass("fc-ws-empty")
756
+ .text(
757
+ files.length
758
+ ? "No file matches the filter."
759
+ : "No portal-react, component or utility nodes in this flow yet.",
760
+ )
761
+ .appendTo(treeEl);
762
+ return;
763
+ }
764
+ const tree = FT.buildTree(visible);
765
+ // A filter query implies "show me everything that matched".
766
+ renderChildren(tree, treeEl, 0, !!filterText);
767
+ }
768
+
769
+ /**
770
+ * Recursively render tree rows.
771
+ * @param {Object} dir
772
+ * @param {Object} target jQuery container
773
+ * @param {number} depth
774
+ * @param {boolean} forceOpen ignore collapse state (filter active)
775
+ * @returns {void}
776
+ */
777
+ function renderChildren(dir, target, depth, forceOpen) {
778
+ dir.children.forEach(function (child) {
779
+ const row = $("<div>")
780
+ .addClass("fc-ws-row")
781
+ .css("padding-left", 8 + depth * 14 + "px");
782
+ const expandable = child.type === "dir" || isParentFile(child);
783
+ const open = forceOpen || isExpanded(child);
784
+
785
+ if (isParentFile(child)) {
786
+ // Its own twisty, so the row can be clicked to open the file without
787
+ // that click also folding the thing away.
788
+ iconEl(open ? "chevron-down" : "chevron-right")
789
+ .addClass("fc-ws-twisty")
790
+ .on("click", function (ev) {
791
+ ev.stopPropagation();
792
+ toggleExpanded(child);
793
+ renderTree();
794
+ })
795
+ .appendTo(row);
796
+ }
797
+ iconElFor(child).appendTo(row);
798
+ $("<span>").addClass("fc-ws-name").text(child.name).appendTo(row);
799
+
800
+ if (child.disabled) markDisabled(row, child.disabled);
801
+
802
+ if (child.type === "dir") {
803
+ row.addClass("fc-ws-dir");
804
+ row.on("click", function () {
805
+ toggleExpanded(child);
806
+ renderTree();
807
+ });
808
+ row.appendTo(target);
809
+ if (open) renderChildren(child, target, depth + 1, forceOpen);
810
+ return;
811
+ }
812
+
813
+ const file = child.file;
814
+ if (file.disabled) markDisabled(row, file.disabled);
815
+ const err = errorFor(file);
816
+ if (err) {
817
+ $("<span>")
818
+ .addClass("fc-ws-mark fc-ws-error")
819
+ .attr("title", err)
820
+ .text("\u25CF")
821
+ .appendTo(row);
822
+ } else if (isDirty(file)) {
823
+ $("<span>")
824
+ .addClass("fc-ws-mark fc-ws-dirty")
825
+ .attr("title", "Changed since last deploy")
826
+ .text("\u25CF")
827
+ .appendTo(row);
828
+ }
829
+ if (file.key === activeKey) row.addClass("fc-ws-active");
830
+ row.attr("title", file.path);
831
+ row.on("click", function () {
832
+ openFile(file.key);
833
+ });
834
+ row.on("contextmenu", function (ev) {
835
+ ev.preventDefault();
836
+ revealOnCanvas(file.nodeId);
837
+ });
838
+ row.appendTo(target);
839
+ if (expandable && open) renderChildren(child, target, depth + 1, forceOpen);
840
+ });
841
+ }
842
+
843
+ /**
844
+ * Scroll the tree to the file being edited, expanding any collapsed folder
845
+ * on the way — switching tabs should never leave you hunting for where the
846
+ * open file lives.
847
+ *
848
+ * Only scrolls when the row is actually out of view, so it never yanks the
849
+ * tree away from where the user just scrolled it.
850
+ *
851
+ * @returns {void}
852
+ */
853
+ function revealActiveInTree() {
854
+ if (!treeEl || !activeKey) return;
855
+ const entry = entries[activeKey];
856
+ if (!entry) return;
857
+
858
+ // Expand ancestors first — a collapsed folder means no row to scroll to.
859
+ const dirs = entry.file.segments.slice(0, -1);
860
+ let expanded = false;
861
+ dirs.forEach(function (_seg, i) {
862
+ const path = entry.file.segments.slice(0, i + 1).join("/");
863
+ if (collapsed[path] !== false) {
864
+ collapsed[path] = false;
865
+ expanded = true;
866
+ }
867
+ });
868
+ if (expanded) renderTree();
869
+
870
+ const row = treeEl.find(".fc-ws-row.fc-ws-active")[0];
871
+ if (!row) return;
872
+ const view = treeEl[0];
873
+ const r = row.getBoundingClientRect();
874
+ const c = view.getBoundingClientRect();
875
+ const margin = 8;
876
+ if (r.top < c.top) {
877
+ view.scrollTop -= c.top - r.top + margin;
878
+ } else if (r.bottom > c.bottom) {
879
+ view.scrollTop += r.bottom - c.bottom + margin;
880
+ }
881
+ }
882
+
883
+ /**
884
+ * Render the open-file tab strip.
885
+ * @returns {void}
886
+ */
887
+ function renderTabs() {
888
+ if (!tabsEl) return;
889
+ tabsEl.empty();
890
+ openKeys.forEach(function (key) {
891
+ const entry = entries[key];
892
+ if (!entry) return;
893
+ const file = entry.file;
894
+ const tab = $("<div>").addClass("fc-ws-tab").attr("data-key", key).attr("title", file.path);
895
+ if (key === activeKey) tab.addClass("fc-ws-active");
896
+ if (isDirty(file)) tab.addClass("fc-ws-tab-dirty");
897
+ $("<span>").text(file.name).appendTo(tab);
898
+ $("<span>")
899
+ .addClass("fc-ws-close")
900
+ .html("&times;")
901
+ .on("click", function (ev) {
902
+ ev.stopPropagation();
903
+ closeTab(key);
904
+ })
905
+ .appendTo(tab);
906
+ tab.on("click", function () {
907
+ setActive(key);
908
+ });
909
+ tab.on("mousedown", function (ev) {
910
+ if (ev.which === 2) {
911
+ ev.preventDefault();
912
+ closeTab(key);
913
+ }
914
+ });
915
+ tab.appendTo(tabsEl);
916
+ });
917
+ }
918
+
919
+ /**
920
+ * Update the header status line + Deploy button state.
921
+ * @returns {void}
922
+ */
923
+ function updateStatus() {
924
+ if (!statusEl) return;
925
+ const dirtyCount = files.filter(isDirty).length;
926
+ statusEl.text(
927
+ dirtyCount ? dirtyCount + " file(s) changed — not deployed" : "All changes deployed",
928
+ );
929
+ statusEl.toggleClass("fc-ws-has-changes", dirtyCount > 0);
930
+ if (deployBtn) deployBtn.toggleClass("disabled", !RED.nodes.dirty());
931
+ }
932
+
933
+ /**
934
+ * The served URL of the portal an open file belongs to, or "" for files that
935
+ * are not part of one. `/fromcubes/` is hardcoded server-side; only the
936
+ * sub-path is per node, and `httpNodeRoot` may prefix the lot.
937
+ *
938
+ * @param {string} key
939
+ * @returns {string}
940
+ */
941
+ function portalUrlFor(key) {
942
+ const entry = key && entries[key];
943
+ if (!entry || entry.file.type !== "portal-react") return "";
944
+ const node = RED.nodes.node(entry.file.nodeId);
945
+ const subPath = node && (node.subPath || "").trim();
946
+ if (!subPath) return "";
947
+ const root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
948
+ return root + "/fromcubes/" + subPath;
949
+ }
950
+
951
+ /**
952
+ * Offer the "open in browser" button only while a portal file is on screen —
953
+ * a component or a utility has no address of its own.
954
+ * @returns {void}
955
+ */
956
+ function updateOpenPortalButton() {
957
+ if (!openPortalBtn) return;
958
+ const url = portalUrlFor(activeKey);
959
+ openPortalBtn.toggle(!!url);
960
+ if (url) {
961
+ openPortalBtn.attr("title", "Open " + url + " in a new tab");
962
+ }
963
+ }
964
+
965
+ /**
966
+ * Show the editor, the textarea fallback, or the empty-state placeholder.
967
+ * @returns {void}
968
+ */
969
+ function updatePaneVisibility() {
970
+ if (!placeholderEl) return;
971
+ const hasFile = !!activeKey;
972
+ placeholderEl.toggle(!hasFile);
973
+ if (monacoFailed) {
974
+ monacoHost.hide();
975
+ fallbackEl.toggle(hasFile);
976
+ } else {
977
+ fallbackEl.hide();
978
+ monacoHost.toggle(hasFile);
979
+ if (hasFile && editor) editor.layout();
980
+ }
981
+ }
982
+
983
+ /** True while the full-screen overlay is on screen. @returns {boolean} */
984
+ function overlayVisible() {
985
+ return !!overlay && !overlay.attr("hidden");
986
+ }
987
+
988
+ /** Re-render tree, tabs and status together. @returns {void} */
989
+ function renderAll() {
990
+ if (!overlayVisible()) return;
991
+ renderTree();
992
+ renderTabs();
993
+ updateStatus();
994
+ updateOpenPortalButton();
995
+ }
996
+
997
+ // ── Actions ─────────────────────────────────────────────────
998
+
999
+ /**
1000
+ * Jump to a node on the canvas and close the overlay.
1001
+ * @param {string} nodeId
1002
+ * @returns {void}
1003
+ */
1004
+ function revealOnCanvas(nodeId) {
1005
+ close();
1006
+ try {
1007
+ RED.view.reveal(nodeId);
1008
+ } catch (err) {
1009
+ console.warn(PREFIX, "reveal failed", err);
1010
+ }
1011
+ }
1012
+
1013
+ /**
1014
+ * Flush pending edits, then trigger Node-RED's own deploy.
1015
+ * @returns {void}
1016
+ */
1017
+ function deploy() {
1018
+ flushAll();
1019
+ if (!RED.nodes.dirty()) {
1020
+ RED.notify("Nothing to deploy", "info");
1021
+ return;
1022
+ }
1023
+ try {
1024
+ RED.actions.invoke("core:deploy-flows");
1025
+ } catch (err) {
1026
+ $("#red-ui-header-button-deploy").trigger("click");
1027
+ }
1028
+ }
1029
+
1030
+ /**
1031
+ * True while a node edit tray is open — editing the same node from two
1032
+ * places would let the tray's `oneditsave` clobber workspace edits.
1033
+ * @returns {boolean}
1034
+ */
1035
+ function trayOpen() {
1036
+ return $("#red-ui-tray").length > 0 || $(".red-ui-tray").length > 0;
1037
+ }
1038
+
1039
+ // ── Overlay lifecycle ───────────────────────────────────────
1040
+
1041
+ /**
1042
+ * The settings panel that hangs off the gear beside the filter: how the tree
1043
+ * is grouped, and which generated files are worth showing.
1044
+ * @returns {Object} jQuery element
1045
+ */
1046
+ function buildSettingsPanel() {
1047
+ const panel = $("<div>").addClass("fc-ws-settings").hide();
1048
+
1049
+ $("<div>").addClass("fc-ws-settings-title").text("Group by").appendTo(panel);
1050
+ [
1051
+ ["flow", "Flow tab", "Each flow holds its own Components / Portals / Utilities"],
1052
+ ["kind", "Kind", "Portals / Components / Utilities at the top level"],
1053
+ ].forEach(function (opt) {
1054
+ const row = $("<label>").addClass("fc-ws-settings-row").attr("title", opt[2]);
1055
+ $("<input>")
1056
+ .attr({ type: "radio", name: "fc-ws-groupby" })
1057
+ .prop("checked", settings.groupBy === opt[0])
1058
+ .on("change", function () {
1059
+ settings.groupBy = opt[0];
1060
+ saveSettings();
1061
+ })
1062
+ .appendTo(row);
1063
+ $("<span>").text(opt[1]).appendTo(row);
1064
+ row.appendTo(panel);
1065
+ });
1066
+
1067
+ $("<div>").addClass("fc-ws-settings-title").text("Show").appendTo(panel);
1068
+ [
1069
+ ["showHead", "head.html files", "Custom <head> markup of each portal, empty ones included"],
1070
+ ["showJs", "Utility .js files", "Code from fc-portal-utility nodes"],
1071
+ ].forEach(function (opt) {
1072
+ const row = $("<label>").addClass("fc-ws-settings-row").attr("title", opt[2]);
1073
+ $("<input>")
1074
+ .attr("type", "checkbox")
1075
+ .prop("checked", !!settings[opt[0]])
1076
+ .on("change", function () {
1077
+ settings[opt[0]] = this.checked;
1078
+ saveSettings();
1079
+ })
1080
+ .appendTo(row);
1081
+ $("<span>").text(opt[1]).appendTo(row);
1082
+ row.appendTo(panel);
1083
+ });
1084
+ return panel;
1085
+ }
1086
+
1087
+ /**
1088
+ * Show or hide the settings panel.
1089
+ * @returns {void}
1090
+ */
1091
+ function toggleSettingsPanel() {
1092
+ if (!settingsPanel) return;
1093
+ const showing = !settingsPanel.is(":visible");
1094
+ settingsPanel.toggle(showing);
1095
+ if (gearEl) gearEl.toggleClass("fc-ws-on", showing);
1096
+ }
1097
+
1098
+ /**
1099
+ * Build the overlay DOM once.
1100
+ * @returns {void}
1101
+ */
1102
+ function buildOverlay() {
1103
+ if (overlay) return;
1104
+ overlay = $("<div>").attr("id", "fc-ws-overlay").attr("hidden", true);
1105
+
1106
+ const header = $("<div>").addClass("fc-ws-header").appendTo(overlay);
1107
+ const brand = $("<div>").addClass("fc-ws-brand").appendTo(header);
1108
+ $("<img>")
1109
+ .addClass("fc-ws-mark-logo")
1110
+ .attr("src", "fromcubes-react/editor/mark.svg")
1111
+ .attr("alt", "fromcubes")
1112
+ .appendTo(brand);
1113
+ // One word split by colour — wrapped so the lockup's gap sits between the
1114
+ // mark and the name, not inside the name.
1115
+ const brandText = $("<span>").addClass("fc-ws-brand-text").appendTo(brand);
1116
+ $("<span>").addClass("fc-ws-brand-name").text("fromcubes-").appendTo(brandText);
1117
+ $("<span>").addClass("fc-ws-brand-accent").text("react").appendTo(brandText);
1118
+ statusEl = $("<div>").addClass("fc-ws-status").appendTo(header);
1119
+ $("<div>").addClass("fc-ws-spacer").appendTo(header);
1120
+ openPortalBtn = $(
1121
+ '<button type="button" class="red-ui-button red-ui-button-small fc-ws-open-portal">' +
1122
+ '<i class="fa fa-external-link"></i>' +
1123
+ "</button>",
1124
+ )
1125
+ .hide()
1126
+ .on("click", function () {
1127
+ const url = portalUrlFor(activeKey);
1128
+ if (url) window.open(url, "_blank", "noopener");
1129
+ })
1130
+ .appendTo(header);
1131
+ // Node-RED's own deploy button markup, so it matches the header toolbar.
1132
+ deployBtn = $(
1133
+ '<a class="red-ui-deploy-button" href="#">' +
1134
+ '<span class="red-ui-deploy-button-content">' +
1135
+ '<img src="red/images/deploy-full-o.svg" alt="" /> <span>Deploy</span>' +
1136
+ "</span>" +
1137
+ "</a>",
1138
+ )
1139
+ .on("click", function (ev) {
1140
+ ev.preventDefault();
1141
+ deploy();
1142
+ })
1143
+ .appendTo(header);
1144
+ $(
1145
+ '<button type="button" class="red-ui-button red-ui-button-small fc-ws-close">' +
1146
+ '<i class="fa fa-times"></i> Close <span class="fc-ws-kbd">Esc</span>' +
1147
+ "</button>",
1148
+ )
1149
+ .on("click", function () {
1150
+ close();
1151
+ })
1152
+ .appendTo(header);
1153
+
1154
+ const body = $("<div>").addClass("fc-ws-body").appendTo(overlay);
1155
+
1156
+ const treePane = $("<div>").addClass("fc-ws-tree-pane").appendTo(body);
1157
+ const filterRow = $("<div>").addClass("fc-ws-filter-row").appendTo(treePane);
1158
+ filterEl = $("<input>")
1159
+ .addClass("fc-ws-filter")
1160
+ .attr("type", "text")
1161
+ .attr("placeholder", "Filter files…")
1162
+ .on("input", function () {
1163
+ filterText = this.value;
1164
+ renderTree();
1165
+ })
1166
+ .appendTo(filterRow);
1167
+ const gear = $(
1168
+ '<span class="fc-ws-toggle fc-ws-gear" title="Tree settings"><i class="fa fa-cog"></i></span>',
1169
+ )
1170
+ .on("click", function (ev) {
1171
+ ev.stopPropagation();
1172
+ toggleSettingsPanel();
1173
+ })
1174
+ .appendTo(filterRow);
1175
+ settingsPanel = buildSettingsPanel().appendTo(treePane);
1176
+ // Any click outside closes it — the panel is a menu, not a mode.
1177
+ overlay.on("mousedown.fc-ws-settings", function (ev) {
1178
+ if (!settingsPanel.is(":visible")) return;
1179
+ if ($(ev.target).closest(".fc-ws-settings, .fc-ws-gear").length) return;
1180
+ settingsPanel.hide();
1181
+ gear.removeClass("fc-ws-on");
1182
+ });
1183
+ gearEl = gear;
1184
+
1185
+ treeEl = $("<div>").addClass("fc-ws-tree").appendTo(treePane);
1186
+
1187
+ const splitter = $("<div>").addClass("fc-ws-splitter").appendTo(body);
1188
+ splitter.on("mousedown", function (ev) {
1189
+ ev.preventDefault();
1190
+ const startX = ev.pageX;
1191
+ const startW = treePane.width();
1192
+ const onMove = function (e) {
1193
+ treeWidth = Math.max(MIN_TREE_WIDTH, startW + (e.pageX - startX));
1194
+ treePane.css("width", treeWidth + "px");
1195
+ if (editor) editor.layout();
1196
+ };
1197
+ const onUp = function () {
1198
+ $(document).off("mousemove", onMove).off("mouseup", onUp);
1199
+ };
1200
+ $(document).on("mousemove", onMove).on("mouseup", onUp);
1201
+ });
1202
+
1203
+ const editorPane = $("<div>").addClass("fc-ws-editor-pane").appendTo(body);
1204
+ tabsEl = $("<div>").addClass("fc-ws-tabs").appendTo(editorPane);
1205
+ placeholderEl = $("<div>")
1206
+ .addClass("fc-ws-placeholder")
1207
+ .html("Pick a file on the left.<br /><kbd>Esc</kbd> closes · <kbd>Ctrl/Cmd+S</kbd> deploys")
1208
+ .appendTo(editorPane);
1209
+ monacoHost = $("<div>").addClass("fc-ws-monaco").hide().appendTo(editorPane);
1210
+ fallbackEl = $("<textarea>")
1211
+ .addClass("fc-ws-fallback")
1212
+ .attr("spellcheck", "false")
1213
+ .hide()
1214
+ .on("input", function () {
1215
+ if (activeKey) scheduleFlush(activeKey);
1216
+ })
1217
+ .appendTo(editorPane);
1218
+
1219
+ $(document.body).append(overlay);
1220
+ }
1221
+
1222
+ /**
1223
+ * Create the single Monaco instance, reusing the loader, JSX defaults,
1224
+ * keybindings and completion providers already set up by portal-react.html.
1225
+ * @param {Function} [done]
1226
+ * @returns {void}
1227
+ */
1228
+ function ensureMonaco(done) {
1229
+ if (monacoFailed) {
1230
+ if (done) done();
1231
+ return;
1232
+ }
1233
+ if (monacoReady && editor && editor.getDomNode()) {
1234
+ if (done) done();
1235
+ return;
1236
+ }
1237
+ // Node-RED's `editor:close` cleanup disposes editors it doesn't know about
1238
+ // — if ours went with it, build a fresh one.
1239
+ monacoReady = false;
1240
+ editor = null;
1241
+ window.__fcLoadMonaco(function (failed) {
1242
+ if (failed) {
1243
+ monacoFailed = true;
1244
+ console.warn(PREFIX, "Monaco unavailable — falling back to textarea");
1245
+ if (done) done();
1246
+ return;
1247
+ }
1248
+ window.__fcApplyJsxDefaults();
1249
+ monacoHost.empty();
1250
+ editor = monaco.editor.create(
1251
+ monacoHost[0],
1252
+ Object.assign({ model: null }, window.__fcEditorOpts),
1253
+ );
1254
+ // Same JSX affordances as the node dialogs. Self-close collapse is JSX
1255
+ // shaped; head.html files are the rare exception where it can fire.
1256
+ if (window.__fcAttachSelfClose) window.__fcAttachSelfClose(editor);
1257
+ if (window.__fcAttachKeybindings) window.__fcAttachKeybindings(editor);
1258
+ editor.addAction({
1259
+ id: "fc-ws-save",
1260
+ label: "Save & Deploy",
1261
+ keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
1262
+ run: function () {
1263
+ deploy();
1264
+ },
1265
+ });
1266
+ editor.addAction({
1267
+ id: "fc-ws-goto-definition",
1268
+ label: "Go to Definition (fromcubes)",
1269
+ keybindings: [monaco.KeyCode.F12],
1270
+ contextMenuGroupId: "navigation",
1271
+ contextMenuOrder: 1,
1272
+ run: function (ed) {
1273
+ gotoDefinition(ed.getPosition());
1274
+ },
1275
+ });
1276
+ // Ctrl/Cmd+click, the way every other editor does it. Monaco's own
1277
+ // go-to-definition can't cross models in the standalone build, so the
1278
+ // jump is ours; anything we can't resolve falls through to Monaco's.
1279
+ editor.onMouseDown(function (e) {
1280
+ const mouse = e.event;
1281
+ if (!mouse || !(mouse.ctrlKey || mouse.metaKey) || mouse.rightButton) return;
1282
+ if (!e.target || !e.target.position) return;
1283
+ if (gotoDefinition(e.target.position)) {
1284
+ mouse.preventDefault();
1285
+ mouse.stopPropagation();
1286
+ }
1287
+ });
1288
+ editor.addAction({
1289
+ id: "fc-ws-filter",
1290
+ label: "Filter files",
1291
+ keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyP],
1292
+ run: function () {
1293
+ if (filterEl) filterEl.trigger("focus").trigger("select");
1294
+ },
1295
+ });
1296
+ monacoReady = true;
1297
+ if (done) done();
1298
+ });
1299
+ }
1300
+
1301
+ /**
1302
+ * Load CSS class candidates + component/utility names so completion inside
1303
+ * the overlay is as rich as inside a node dialog.
1304
+ * @returns {void}
1305
+ */
1306
+ function warmCompletionCaches() {
1307
+ if (window.__fcRefreshCssClasses) window.__fcRefreshCssClasses();
1308
+ if (window.__fcRefreshComponentNames) window.__fcRefreshComponentNames();
1309
+ if (window.__fcRefreshUtilities) window.__fcRefreshUtilities();
1310
+ }
1311
+
1312
+ /**
1313
+ * Open the overlay, optionally focused on one file.
1314
+ * @param {string} [key]
1315
+ * @returns {void}
1316
+ */
1317
+ function open(key) {
1318
+ if (trayOpen()) {
1319
+ RED.notify("Close the node edit dialog first", "warning");
1320
+ return;
1321
+ }
1322
+ buildOverlay();
1323
+ warmCompletionCaches();
1324
+ collect();
1325
+ overlay.removeAttr("hidden");
1326
+ // Node-RED's toasts live at z-index 100 and would sit under this overlay —
1327
+ // including the ones this editor raises. workspace.css lifts them while the
1328
+ // class is on, and only while it is on.
1329
+ $(document.body).addClass("fc-ws-open");
1330
+ $(document).on("keydown.fc-ws", onKeyDown);
1331
+
1332
+ ensureMonaco(function () {
1333
+ // Models can only be created once Monaco is up — build them for tabs
1334
+ // that were opened while it was still loading.
1335
+ openKeys.forEach(function (k) {
1336
+ if (entries[k]) ensureEntry(entries[k].file);
1337
+ });
1338
+ if (key) openFile(key);
1339
+ else openDefaultFile();
1340
+ updatePaneVisibility();
1341
+ refreshErrors(renderAll);
1342
+ renderAll();
1343
+ if (editor) editor.layout();
1344
+ });
1345
+ }
1346
+
1347
+ /**
1348
+ * Open one node's file straight from that node's edit dialog. The dialog is
1349
+ * saved and closed first (its `oneditsave` owns the same field), then the
1350
+ * overlay opens once the tray is gone.
1351
+ *
1352
+ * @param {string} nodeId
1353
+ * @param {string} field config property holding the code
1354
+ * @returns {void}
1355
+ */
1356
+ function openNodeFile(nodeId, field) {
1357
+ const key = nodeId + ":" + field;
1358
+ if (trayOpen()) $("#node-dialog-ok").trigger("click");
1359
+ let waited = 0;
1360
+ const tick = function () {
1361
+ if (!trayOpen()) {
1362
+ open(key);
1363
+ return;
1364
+ }
1365
+ waited += 100;
1366
+ if (waited > 3000) {
1367
+ RED.notify("Close the node edit dialog first", "warning");
1368
+ return;
1369
+ }
1370
+ setTimeout(tick, 100);
1371
+ };
1372
+ setTimeout(tick, 100);
1373
+ }
1374
+
1375
+ /**
1376
+ * Which file to show when the editor is opened with nothing in mind.
1377
+ *
1378
+ * The canvas tab you are looking at is the best guess at what you came to
1379
+ * edit, and inside it the portal outranks the parts it is built from. Only
1380
+ * one file opens — the rest of the flow is a click away in the tree.
1381
+ *
1382
+ * Order: keep your place if it is already in this flow → this flow's portal,
1383
+ * then its components and utilities → anything that is actually running →
1384
+ * whatever is first.
1385
+ *
1386
+ * @returns {void}
1387
+ */
1388
+ function openDefaultFile() {
1389
+ if (!files.length) return;
1390
+ const flowId = RED.workspaces.active ? RED.workspaces.active() : null;
1391
+
1392
+ if (activeKey && entries[activeKey] && entries[activeKey].file.flowId === flowId) {
1393
+ setActive(activeKey);
1394
+ return;
1395
+ }
1396
+ // `files` is already ordered portal → component → utility within a flow,
1397
+ // so the first match here is the portal when there is one.
1398
+ const inFlow = files.filter(function (f) {
1399
+ return flowId && f.flowId === flowId;
1400
+ })[0];
1401
+ if (inFlow) {
1402
+ openFile(inFlow.key);
1403
+ return;
1404
+ }
1405
+ if (activeKey) {
1406
+ setActive(activeKey);
1407
+ return;
1408
+ }
1409
+ // Opening a file expands its flow, and a disabled flow was collapsed for
1410
+ // a reason — so prefer one that is running.
1411
+ const live = files.filter(function (f) {
1412
+ return !f.disabled;
1413
+ })[0];
1414
+ openFile((live || files[0]).key);
1415
+ }
1416
+
1417
+ /**
1418
+ * Flush edits and hide the overlay. Models are kept so reopening is instant.
1419
+ * @returns {void}
1420
+ */
1421
+ function close() {
1422
+ if (!overlay) return;
1423
+ if (editor && activeKey && entries[activeKey] && !modelGone(entries[activeKey])) {
1424
+ entries[activeKey].viewState = editor.saveViewState();
1425
+ }
1426
+ flushAll();
1427
+ overlay.attr("hidden", true);
1428
+ $(document.body).removeClass("fc-ws-open");
1429
+ $(document).off("keydown.fc-ws");
1430
+ }
1431
+
1432
+ /**
1433
+ * Overlay-level keyboard handling.
1434
+ * @param {Object} ev
1435
+ * @returns {void}
1436
+ */
1437
+ function onKeyDown(ev) {
1438
+ if (ev.key === "Escape") {
1439
+ ev.preventDefault();
1440
+ ev.stopPropagation();
1441
+ close();
1442
+ return;
1443
+ }
1444
+ if ((ev.ctrlKey || ev.metaKey) && (ev.key === "s" || ev.key === "S")) {
1445
+ ev.preventDefault();
1446
+ // Monaco owns Ctrl+S while the editor has focus (fc-ws-save action) —
1447
+ // handling it here as well would fire deploy twice.
1448
+ if (monacoHost && $.contains(monacoHost[0], ev.target)) return;
1449
+ deploy();
1450
+ }
1451
+ }
1452
+
1453
+ // ── Status bar ──────────────────────────────────────────────
1454
+
1455
+ /**
1456
+ * The editor's only entry point outside a node dialog: a button in Node-RED's
1457
+ * bottom status bar, styled with Node-RED's own `red-ui-footer-button` so it
1458
+ * sits level with its neighbours, carrying the node icon and the editor name.
1459
+ * @returns {void}
1460
+ */
1461
+ function registerStatusBar() {
1462
+ if (!RED.statusBar || !RED.statusBar.add) {
1463
+ console.warn(PREFIX, "no RED.statusBar — status bar button skipped");
1464
+ return;
1465
+ }
1466
+ const el = $('<button type="button" class="red-ui-footer-button fc-ws-statusbar-btn"></button>')
1467
+ .attr("title", "fromcubes-react — open " + EDITOR_NAME)
1468
+ .on("click", function (ev) {
1469
+ ev.preventDefault();
1470
+ open();
1471
+ });
1472
+ $("<img>")
1473
+ .addClass("fc-ws-statusbar-icon")
1474
+ .attr("src", "fromcubes-react/icons/fromcubes-react.svg")
1475
+ .attr("alt", "")
1476
+ .appendTo(el);
1477
+ $("<span>").text(EDITOR_NAME).appendTo(el);
1478
+ RED.statusBar.add({ id: "fromcubes-react-editor", align: "right", element: el });
1479
+ RED.actions.add("fromcubes:open-react-editor", function () {
1480
+ open();
1481
+ });
1482
+ }
1483
+
1484
+ // ── Flow events ─────────────────────────────────────────────
1485
+
1486
+ /**
1487
+ * Re-sync after the flows changed underneath us (deploy, undo, node edited
1488
+ * through its own dialog, node deleted).
1489
+ * @returns {void}
1490
+ */
1491
+ function onFlowsChanged() {
1492
+ if (selfEmitting) return;
1493
+ // Flow-wide operations (import, deploy, undo) fire one event per node —
1494
+ // coalesce them into a single re-sync.
1495
+ clearTimeout(resyncTimer);
1496
+ resyncTimer = setTimeout(resync, 100);
1497
+ }
1498
+
1499
+ /**
1500
+ * Reconcile open models with the node objects after an external change.
1501
+ * @returns {void}
1502
+ */
1503
+ function resync() {
1504
+ collect();
1505
+ openKeys.forEach(function (key) {
1506
+ const entry = entries[key];
1507
+ if (!entry || modelGone(entry)) return;
1508
+ const node = RED.nodes.node(entry.file.nodeId);
1509
+ if (!node) return;
1510
+ const nodeValue = node[entry.file.field] || "";
1511
+ const editorValue = entry.model.getValue();
1512
+ if (nodeValue === editorValue) return;
1513
+ if (editorValue === entry.lastSynced) {
1514
+ // No unflushed edits here — the node is newer, take it.
1515
+ entry.lastSynced = nodeValue;
1516
+ entry.model.setValue(nodeValue);
1517
+ } else {
1518
+ RED.notify(
1519
+ entry.file.path + " changed outside the editor — keeping your version.",
1520
+ { type: "warning", timeout: 6000 },
1521
+ );
1522
+ }
1523
+ });
1524
+ renderAll();
1525
+ }
1526
+
1527
+ // ── Bootstrap ───────────────────────────────────────────────
1528
+
1529
+ /**
1530
+ * Wire everything up once the editor and file-tree helper are available.
1531
+ * @returns {void}
1532
+ */
1533
+ function init() {
1534
+ if (typeof RED === "undefined" || !RED.sidebar || !window.__fcFileTree) {
1535
+ setTimeout(init, 200);
1536
+ return;
1537
+ }
1538
+ FT = window.__fcFileTree;
1539
+ injectIconSprite();
1540
+ loadSettings();
1541
+ registerStatusBar();
1542
+ collect();
1543
+ refreshErrors(renderAll);
1544
+
1545
+ ["nodes:add", "nodes:remove", "nodes:change", "flows:change", "workspace:change"].forEach(
1546
+ function (evt) {
1547
+ RED.events.on(evt, onFlowsChanged);
1548
+ },
1549
+ );
1550
+ RED.events.on("deploy", function () {
1551
+ refreshErrors(renderAll);
1552
+ renderAll();
1553
+ });
1554
+
1555
+ window.__fcPortalWorkspace = {
1556
+ open: open,
1557
+ openNodeFile: openNodeFile,
1558
+ close: close,
1559
+ refresh: resync,
1560
+ };
1561
+ console.log(PREFIX, "ready");
1562
+ }
1563
+
1564
+ init();
1565
+ })();