@aaqu/fromcubes-portal-react 0.1.0-alpha.21 → 0.1.0-alpha.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/examples/008-utility-debounce-flow.json +72 -0
- package/nodes/lib/page-builder.js +24 -24
- package/nodes/portal-react.html +578 -209
- package/nodes/portal-react.js +367 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,6 +81,29 @@ The opt-out is page-wide — the strictest call wins. If any component on the pa
|
|
|
81
81
|
|
|
82
82
|
There is also a config node, **fc-portal-component**, that lets you define reusable React components once and reference them by name from any portal-react node. Referenced components (and their transitive dependencies) are injected at transpile time, so unused ones add nothing to the bundle.
|
|
83
83
|
|
|
84
|
+
For shared **non-component** code (helpers, custom hooks, constants), use **fc-portal-utility** — a sibling config node. Unlike component nodes (which export exactly one symbol via an IIFE wrapper), a utility node is injected raw at top level so it can declare any number of `function` / `const` / `let` / `class` symbols. Selective inclusion: a utility node lands in a portal's bundle only when the portal's JSX or any of its referenced library components mentions at least one of the symbols declared in that utility.
|
|
85
|
+
|
|
86
|
+
```jsx
|
|
87
|
+
// fc-portal-utility, Module name = mathHelpers
|
|
88
|
+
const PI2 = Math.PI * 2;
|
|
89
|
+
function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
|
|
90
|
+
function useDebounce(value, ms = 300) {
|
|
91
|
+
const [v, setV] = React.useState(value);
|
|
92
|
+
React.useEffect(() => {
|
|
93
|
+
const t = setTimeout(() => setV(value), ms);
|
|
94
|
+
return () => clearTimeout(t);
|
|
95
|
+
}, [value, ms]);
|
|
96
|
+
return v;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// portal-react, JSX tab
|
|
100
|
+
function App() {
|
|
101
|
+
const { data } = useNodeRed();
|
|
102
|
+
const slow = useDebounce(data?.value);
|
|
103
|
+
return <div>{clamp(slow ?? 0, 0, 100)}</div>;
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
84
107
|
## Editor features
|
|
85
108
|
|
|
86
109
|
- **Monaco** with full JSX support and `useNodeRed()` type declarations
|
|
@@ -88,6 +111,8 @@ There is also a config node, **fc-portal-component**, that lets you define reusa
|
|
|
88
111
|
- **JSX tag completion** — type tag name, Tab to expand
|
|
89
112
|
- **Self-close collapse** — type `/` inside empty `<tag></tag>` to convert to `<tag />`
|
|
90
113
|
- **Component completion** — registry components + any PascalCase word
|
|
114
|
+
- **Utility-symbol completion** — top-level identifiers from any `fc-portal-utility` node, suggested in JS context
|
|
115
|
+
- **Components / Utilities dialogs** — buttons in the JSX tab; Components inserts `<Tag></Tag>`, Utilities expands to the symbols declared in each node and inserts the bare identifier on click
|
|
91
116
|
- **Portal Assets sidebar** — file manager for static assets (GLB, textures, fonts…)
|
|
92
117
|
|
|
93
118
|
## Multi-user / Multi-tenancy
|
|
@@ -177,6 +202,7 @@ Import `001-shared-components-flow.json` first — it provides shared UI compone
|
|
|
177
202
|
| `005-threejs-portal-flow.json` | `three` | 3D scene with Three.js |
|
|
178
203
|
| `006-pixi-portal-flow.json` | `pixi.js`, `@pixi/react` | Clickable bunny sprites with PixiJS |
|
|
179
204
|
| `007-webgpu-tsl-flow.json` | `three` | WebGPU renderer + TSL animated shaders |
|
|
205
|
+
| `008-utility-debounce-flow.json` | — | `fc-portal-utility` demo: `useDebounce` custom hook + `clamp` helper |
|
|
180
206
|
|
|
181
207
|
## Troubleshooting
|
|
182
208
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "fc-util-flow",
|
|
4
|
+
"type": "tab",
|
|
5
|
+
"label": "Utility: useDebounce",
|
|
6
|
+
"disabled": false
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "fc-util-tick",
|
|
10
|
+
"type": "inject",
|
|
11
|
+
"z": "fc-util-flow",
|
|
12
|
+
"name": "Fast tick (10Hz)",
|
|
13
|
+
"props": [{ "p": "payload" }],
|
|
14
|
+
"repeat": "0.1",
|
|
15
|
+
"payload": "",
|
|
16
|
+
"payloadType": "date",
|
|
17
|
+
"x": 160,
|
|
18
|
+
"y": 160,
|
|
19
|
+
"wires": [["fc-util-noisy"]]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": "fc-util-noisy",
|
|
23
|
+
"type": "function",
|
|
24
|
+
"z": "fc-util-flow",
|
|
25
|
+
"name": "Noisy signal",
|
|
26
|
+
"func": "// Random walk between 0..120 with bursts of noise — exactly the sort of\n// fast-changing input where a debounced display is much more readable\n// than the raw value.\nconst prev = context.get('v') ?? 50;\nconst drift = (Math.random() - 0.5) * 8;\nconst noise = Math.random() < 0.15 ? (Math.random() - 0.5) * 30 : 0;\nconst v = Math.max(0, Math.min(120, prev + drift + noise));\ncontext.set('v', v);\nmsg.payload = { value: +v.toFixed(2), ts: Date.now() };\nreturn msg;",
|
|
27
|
+
"outputs": 1,
|
|
28
|
+
"x": 360,
|
|
29
|
+
"y": 160,
|
|
30
|
+
"wires": [["fc-util-portal"]]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "fc-util-helpers",
|
|
34
|
+
"type": "fc-portal-utility",
|
|
35
|
+
"z": "fc-util-flow",
|
|
36
|
+
"name": "math helpers + useDebounce",
|
|
37
|
+
"utilName": "mathHelpers",
|
|
38
|
+
"utilCode": "// Constants — bundled once, shared across portals that reference them.\nconst CLAMP_MIN = 0;\nconst CLAMP_MAX = 100;\n\n// Pure helper.\nfunction clamp(n, min, max) {\n return Math.max(min, Math.min(max, n));\n}\n\n// Custom React hook — name MUST start with `use` for React's rules of hooks.\n// This node groups a constant + a helper + a hook in one block; portals that\n// reference any of `clamp`, `useDebounce`, `CLAMP_MIN`, `CLAMP_MAX` pull in\n// the whole node.\nfunction useDebounce(value, ms = 300) {\n const [v, setV] = React.useState(value);\n React.useEffect(() => {\n const t = setTimeout(() => setV(value), ms);\n return () => clearTimeout(t);\n }, [value, ms]);\n return v;\n}",
|
|
39
|
+
"x": 160,
|
|
40
|
+
"y": 280,
|
|
41
|
+
"wires": []
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"id": "fc-util-bar",
|
|
45
|
+
"type": "fc-portal-component",
|
|
46
|
+
"z": "fc-util-flow",
|
|
47
|
+
"name": "Bar",
|
|
48
|
+
"compName": "Bar",
|
|
49
|
+
"compCode": "function Bar({ value = 0, label = '' }) {\n // Component uses `clamp` from the utility node — selective inclusion picks\n // up the dependency through the components' code, not just the user JSX.\n const pct = clamp(value, CLAMP_MIN, CLAMP_MAX);\n const color = pct > 80 ? 'bg-red-500' : pct > 50 ? 'bg-amber-500' : 'bg-emerald-500';\n return (\n <div className=\"flex items-center gap-3\">\n <div className=\"w-24 text-xs text-zinc-400 uppercase tracking-wider\">{label}</div>\n <div className=\"flex-1 h-3 bg-zinc-800 rounded-full overflow-hidden\">\n <div className={`h-full ${color} transition-all duration-150`} style={{ width: pct + '%' }} />\n </div>\n <div className=\"w-14 text-right font-mono text-sm tabular-nums text-zinc-200\">\n {value.toFixed(1)}\n </div>\n </div>\n );\n}",
|
|
50
|
+
"compInputs": "value,label",
|
|
51
|
+
"compOutputs": "",
|
|
52
|
+
"x": 360,
|
|
53
|
+
"y": 280,
|
|
54
|
+
"wires": []
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "fc-util-portal",
|
|
58
|
+
"type": "portal-react",
|
|
59
|
+
"z": "fc-util-flow",
|
|
60
|
+
"name": "Debounce demo",
|
|
61
|
+
"subPath": "debounce",
|
|
62
|
+
"pageTitle": "useDebounce demo",
|
|
63
|
+
"customHead": "",
|
|
64
|
+
"componentCode": "function App() {\n const { data } = useNodeRed();\n const raw = data?.value ?? 0;\n\n // Live raw signal vs debounced views at three different windows.\n // `useDebounce` is declared in the fc-portal-utility node — no import.\n const slow100 = useDebounce(raw, 100);\n const slow400 = useDebounce(raw, 400);\n const slow1500 = useDebounce(raw, 1500);\n\n return (\n <div className=\"min-h-screen bg-zinc-950 text-zinc-100 p-8 font-sans\">\n <h1 className=\"text-2xl font-light text-amber-400 mb-2\">useDebounce</h1>\n <p className=\"text-sm text-zinc-500 mb-8\">\n Custom hook from <code className=\"text-amber-300\">fc-portal-utility</code>. Same noisy 10 Hz input fed through different debounce windows.\n </p>\n <div className=\"max-w-xl space-y-4\">\n <Bar value={raw} label=\"raw\" />\n <Bar value={slow100} label=\"100 ms\" />\n <Bar value={slow400} label=\"400 ms\" />\n <Bar value={slow1500} label=\"1500 ms\" />\n </div>\n </div>\n );\n}",
|
|
65
|
+
"libs": [],
|
|
66
|
+
"portalAuth": false,
|
|
67
|
+
"showWsStatus": false,
|
|
68
|
+
"x": 580,
|
|
69
|
+
"y": 160,
|
|
70
|
+
"wires": [[]]
|
|
71
|
+
}
|
|
72
|
+
]
|
|
@@ -99,13 +99,13 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
99
99
|
<script>
|
|
100
100
|
function __safe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
|
101
101
|
function __renderErrorOverlay(title, message, hint) {
|
|
102
|
-
|
|
102
|
+
const root = document.getElementById('root');
|
|
103
103
|
if (root) root.style.display = 'none';
|
|
104
|
-
|
|
104
|
+
const bo = document.getElementById('__building_overlay');
|
|
105
105
|
if (bo) bo.remove();
|
|
106
|
-
|
|
106
|
+
const bn = document.getElementById('__error_banner');
|
|
107
107
|
if (bn) bn.remove();
|
|
108
|
-
|
|
108
|
+
let ov = document.getElementById('__error_overlay');
|
|
109
109
|
if (!ov) { ov = document.createElement('div'); ov.id = '__error_overlay'; document.body.appendChild(ov); }
|
|
110
110
|
ov.innerHTML = '<h1>' + __safe(title) + '</h1>'
|
|
111
111
|
+ (hint ? '<p class="__hint">' + __safe(hint) + '</p>' : '')
|
|
@@ -113,13 +113,13 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
113
113
|
+ '<p class="__status __off" id="__err_status">Waiting for redeploy\\u2026</p>';
|
|
114
114
|
}
|
|
115
115
|
function __renderErrorBanner(message) {
|
|
116
|
-
|
|
116
|
+
const ov = document.getElementById('__error_overlay');
|
|
117
117
|
if (ov) ov.remove();
|
|
118
|
-
|
|
118
|
+
const bo = document.getElementById('__building_overlay');
|
|
119
119
|
if (bo) bo.remove();
|
|
120
|
-
|
|
120
|
+
const root = document.getElementById('root');
|
|
121
121
|
if (root) root.style.display = '';
|
|
122
|
-
|
|
122
|
+
let bn = document.getElementById('__error_banner');
|
|
123
123
|
if (!bn) {
|
|
124
124
|
bn = document.createElement('div');
|
|
125
125
|
bn.id = '__error_banner';
|
|
@@ -135,11 +135,11 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
135
135
|
+ '<pre>' + __safe(message) + '</pre>';
|
|
136
136
|
}
|
|
137
137
|
function __clearErrorOverlay() {
|
|
138
|
-
|
|
138
|
+
const ov = document.getElementById('__error_overlay');
|
|
139
139
|
if (ov) ov.remove();
|
|
140
|
-
|
|
140
|
+
const bn = document.getElementById('__error_banner');
|
|
141
141
|
if (bn) bn.remove();
|
|
142
|
-
|
|
142
|
+
const root = document.getElementById('root');
|
|
143
143
|
if (root) root.style.display = '';
|
|
144
144
|
}
|
|
145
145
|
window.__NR = {
|
|
@@ -170,7 +170,7 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
170
170
|
if (s) { s.textContent = 'fromcubes • connected'; s.className = 'ok'; }
|
|
171
171
|
this._retries = 0;
|
|
172
172
|
this._wasConnected = true;
|
|
173
|
-
|
|
173
|
+
const es = document.getElementById('__err_status');
|
|
174
174
|
if (es) { es.textContent = 'Connected \\u2014 will reload on redeploy'; es.className = '__status'; }
|
|
175
175
|
if (this._pendingRuntimeError) {
|
|
176
176
|
try { ws.send(JSON.stringify({ type: 'runtime_error', message: this._pendingRuntimeError })); } catch(_) {}
|
|
@@ -201,10 +201,10 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
201
201
|
if (m.type === 'building') {
|
|
202
202
|
this._buildErrorActive = true;
|
|
203
203
|
document.getElementById('root').style.display = 'none';
|
|
204
|
-
|
|
204
|
+
const eo = document.getElementById('__error_overlay');
|
|
205
205
|
if (eo) eo.remove();
|
|
206
206
|
if (!document.getElementById('__building_overlay')) {
|
|
207
|
-
|
|
207
|
+
const ov = document.createElement('div');
|
|
208
208
|
ov.id = '__building_overlay';
|
|
209
209
|
ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:#111;color:#888;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:monospace';
|
|
210
210
|
ov.innerHTML = '<div style="font-size:24px;margin-bottom:16px">Building\\u2026</div>'
|
|
@@ -219,7 +219,7 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
219
219
|
__renderErrorBanner(m.message);
|
|
220
220
|
} else {
|
|
221
221
|
__renderErrorOverlay('Build Error', m.message, ${JSON.stringify(DEFAULT_HINT)});
|
|
222
|
-
|
|
222
|
+
const es2 = document.getElementById('__err_status');
|
|
223
223
|
if (es2) { es2.textContent = 'Connected \\u2014 will reload on redeploy'; es2.className = '__status'; }
|
|
224
224
|
}
|
|
225
225
|
}
|
|
@@ -241,7 +241,7 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
241
241
|
ws.onclose = () => {
|
|
242
242
|
if (s) { s.textContent = 'fromcubes • disconnected'; s.className = 'err'; }
|
|
243
243
|
this._ws = null;
|
|
244
|
-
|
|
244
|
+
const es = document.getElementById('__err_status');
|
|
245
245
|
if (es) { es.textContent = 'Disconnected \\u2014 reconnecting\\u2026'; es.className = '__status __off'; }
|
|
246
246
|
const delay = Math.min(500 * Math.pow(2, this._retries), 8000);
|
|
247
247
|
this._retries++;
|
|
@@ -267,7 +267,7 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
267
267
|
<script>
|
|
268
268
|
try { ${escScript(transpiledJs)}
|
|
269
269
|
} catch(__e) {
|
|
270
|
-
|
|
270
|
+
const __m = (__e && (__e.stack || __e.message)) || String(__e);
|
|
271
271
|
__renderErrorOverlay('Runtime Error', __m, ${JSON.stringify(DEFAULT_HINT)});
|
|
272
272
|
// Report back to server so node status goes red. WS may not be open
|
|
273
273
|
// yet (sync throw during initial bundle); queue until onopen.
|
|
@@ -302,31 +302,31 @@ function buildErrorPage(title, error, wsPath) {
|
|
|
302
302
|
})}</div>
|
|
303
303
|
<script>
|
|
304
304
|
(function() {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
305
|
+
const st = document.getElementById('__err_status');
|
|
306
|
+
const pre = document.querySelector('#__error_overlay pre');
|
|
307
|
+
let retries = 0;
|
|
308
308
|
function setStatus(text, ok) {
|
|
309
309
|
if (!st) return;
|
|
310
310
|
st.textContent = text;
|
|
311
311
|
st.className = '__status' + (ok ? '' : ' __off');
|
|
312
312
|
}
|
|
313
313
|
function connect() {
|
|
314
|
-
|
|
315
|
-
|
|
314
|
+
const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
315
|
+
const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
|
|
316
316
|
ws.onopen = function() {
|
|
317
317
|
retries = 0;
|
|
318
318
|
setStatus('Connected \\u2014 will reload on redeploy', true);
|
|
319
319
|
};
|
|
320
320
|
ws.onmessage = function(e) {
|
|
321
321
|
try {
|
|
322
|
-
|
|
322
|
+
const m = JSON.parse(e.data);
|
|
323
323
|
if (m.type === 'version' && m.hash) location.reload();
|
|
324
324
|
if (m.type === 'error' && pre) pre.textContent = m.message;
|
|
325
325
|
} catch(_) {}
|
|
326
326
|
};
|
|
327
327
|
ws.onclose = function() {
|
|
328
328
|
setStatus('Disconnected \\u2014 reconnecting\\u2026', false);
|
|
329
|
-
|
|
329
|
+
const delay = Math.min(500 * Math.pow(2, retries), 8000);
|
|
330
330
|
retries++;
|
|
331
331
|
setTimeout(connect, delay);
|
|
332
332
|
};
|