@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 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
101
101
  function __renderErrorOverlay(title, message, hint) {
102
- var root = document.getElementById('root');
102
+ const root = document.getElementById('root');
103
103
  if (root) root.style.display = 'none';
104
- var bo = document.getElementById('__building_overlay');
104
+ const bo = document.getElementById('__building_overlay');
105
105
  if (bo) bo.remove();
106
- var bn = document.getElementById('__error_banner');
106
+ const bn = document.getElementById('__error_banner');
107
107
  if (bn) bn.remove();
108
- var ov = document.getElementById('__error_overlay');
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
- var ov = document.getElementById('__error_overlay');
116
+ const ov = document.getElementById('__error_overlay');
117
117
  if (ov) ov.remove();
118
- var bo = document.getElementById('__building_overlay');
118
+ const bo = document.getElementById('__building_overlay');
119
119
  if (bo) bo.remove();
120
- var root = document.getElementById('root');
120
+ const root = document.getElementById('root');
121
121
  if (root) root.style.display = '';
122
- var bn = document.getElementById('__error_banner');
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
- var ov = document.getElementById('__error_overlay');
138
+ const ov = document.getElementById('__error_overlay');
139
139
  if (ov) ov.remove();
140
- var bn = document.getElementById('__error_banner');
140
+ const bn = document.getElementById('__error_banner');
141
141
  if (bn) bn.remove();
142
- var root = document.getElementById('root');
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
- var es = document.getElementById('__err_status');
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
- var eo = document.getElementById('__error_overlay');
204
+ const eo = document.getElementById('__error_overlay');
205
205
  if (eo) eo.remove();
206
206
  if (!document.getElementById('__building_overlay')) {
207
- var ov = document.createElement('div');
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
- var es2 = document.getElementById('__err_status');
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
- var es = document.getElementById('__err_status');
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
- var __m = (__e && (__e.stack || __e.message)) || String(__e);
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
- var st = document.getElementById('__err_status');
306
- var pre = document.querySelector('#__error_overlay pre');
307
- var retries = 0;
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
- var p = location.protocol === 'https:' ? 'wss:' : 'ws:';
315
- var ws = new WebSocket(p + '//' + location.host + '${wsPath}');
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
- var m = JSON.parse(e.data);
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
- var delay = Math.min(500 * Math.pow(2, retries), 8000);
329
+ const delay = Math.min(500 * Math.pow(2, retries), 8000);
330
330
  retries++;
331
331
  setTimeout(connect, delay);
332
332
  };