@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.
- package/LICENSE +190 -0
- package/README.md +405 -2
- package/THIRD-PARTY-NOTICES.md +164 -0
- 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/editor/ark-pixel.LICENSE.txt +96 -0
- package/nodes/editor/ark-pixel.woff2 +0 -0
- package/nodes/editor/file-tree.js +403 -0
- package/nodes/editor/format.js +182 -0
- package/nodes/editor/icons.svg +46 -0
- package/nodes/editor/mark.svg +5 -0
- package/nodes/editor/provider-settings.js +192 -0
- package/nodes/editor/workspace.css +653 -0
- package/nodes/editor/workspace.js +1565 -0
- package/nodes/icons/fromcubes-component.svg +27 -0
- package/nodes/icons/fromcubes-react.svg +25 -0
- package/nodes/icons/fromcubes-utility.svg +21 -0
- package/nodes/lib/admin-api.js +238 -0
- package/nodes/lib/admin-security.js +161 -0
- package/nodes/lib/api.js +29 -0
- package/nodes/lib/helpers.js +990 -0
- package/nodes/lib/hooks.js +145 -0
- package/nodes/lib/page-builder.js +499 -0
- package/nodes/lib/portal-page-route.js +131 -0
- package/nodes/lib/providers.js +536 -0
- package/nodes/lib/registry-nodes.js +331 -0
- package/nodes/lib/router.js +103 -0
- package/nodes/lib/ws-heartbeat.js +58 -0
- package/nodes/portal-react.html +2539 -0
- package/nodes/portal-react.js +1652 -0
- 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("×")
|
|
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
|
+
})();
|