@nuforge/editor 0.3.0 → 0.3.2
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/dist/index.cjs +5 -114
- package/dist/index.d.ts +0 -151
- package/dist/index.mjs +5 -114
- package/dist/react.cjs +14 -137
- package/dist/react.d.ts +0 -164
- package/dist/react.mjs +14 -137
- package/package.json +9 -9
- package/dist/index.cjs.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/react.cjs.map +0 -1
- package/dist/react.mjs.map +0 -1
package/dist/react.cjs
CHANGED
|
@@ -6,27 +6,11 @@ var t = require('@nuforge/types');
|
|
|
6
6
|
var react = require('react');
|
|
7
7
|
var reactDom = require('react-dom');
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* Wire protocol for `RemoteCanvasHost` <-> `createCanvasReceiver`: a genuinely
|
|
11
|
-
* navigated `/canvas/[id]`-style iframe has its own JS realm, so state and
|
|
12
|
-
* interaction can't cross as direct object references the way `IframeCanvas`'s
|
|
13
|
-
* portal allows — everything here is a structured-clone-safe plain message.
|
|
14
|
-
*
|
|
15
|
-
* Deliberately generic: only the shapes the editor primitive itself needs
|
|
16
|
-
* (state sync, ready handshake, the core interaction events) are typed. Host
|
|
17
|
-
* concerns like theme/preview-mode/overrides ride through `CanvasUiMessage`'s
|
|
18
|
-
* opaque `payload` — this package relays it, never interprets it.
|
|
19
|
-
*/
|
|
20
9
|
const CANVAS_CHANNEL = 'nuforge-canvas';
|
|
21
10
|
const CANVAS_PROTOCOL_VERSION = 1;
|
|
22
|
-
/** Namespace the `BroadcastChannel` per canvas instance (derive from the
|
|
23
|
-
* iframe's own `src`, so a host never has to invent/track a separate id). */
|
|
24
11
|
function canvasChannelName(src) {
|
|
25
12
|
return `${CANVAS_CHANNEL}:${src}`;
|
|
26
13
|
}
|
|
27
|
-
/** Narrow an arbitrary `BroadcastChannel` payload to a message from this
|
|
28
|
-
* protocol — guards against unrelated traffic on the same channel name and
|
|
29
|
-
* against a version mismatch across a stale cached bundle. */
|
|
30
14
|
function isCanvasMessage(data) {
|
|
31
15
|
return (!!data &&
|
|
32
16
|
typeof data === 'object' &&
|
|
@@ -34,13 +18,6 @@ function isCanvasMessage(data) {
|
|
|
34
18
|
data.v === CANVAS_PROTOCOL_VERSION);
|
|
35
19
|
}
|
|
36
20
|
|
|
37
|
-
/**
|
|
38
|
-
* Compute a drop position from the pointer's Y against a target's bounding rect.
|
|
39
|
-
* When `allowInside` is set, the middle band is `inside` (drop as a child) and
|
|
40
|
-
* the outer bands are `before`/`after`; otherwise it's a simple before/after
|
|
41
|
-
* split at the midpoint. Pure — use it from an `onDragOver`/`onPointerMove`
|
|
42
|
-
* handler to drive a drop indicator and the eventual `moveNode`/`pasteNode`.
|
|
43
|
-
*/
|
|
44
21
|
function dropPositionFromPointer(clientY, rect, opts = {}) {
|
|
45
22
|
const ratio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0;
|
|
46
23
|
if (opts.allowInside) {
|
|
@@ -53,9 +30,6 @@ function dropPositionFromPointer(clientY, rect, opts = {}) {
|
|
|
53
30
|
return ratio < 0.5 ? 'before' : 'after';
|
|
54
31
|
}
|
|
55
32
|
|
|
56
|
-
// Tag every rendered element with its template id so a canvas can map a clicked
|
|
57
|
-
// DOM node back to its source node. Covers external components too, so they're
|
|
58
|
-
// independently selectable/highlightable in the editor canvas.
|
|
59
33
|
const decorate = (view) => ({
|
|
60
34
|
'data-nu-id': view.template?.id,
|
|
61
35
|
});
|
|
@@ -64,18 +38,6 @@ const AMBIENT_CANVAS_WINDOW = {
|
|
|
64
38
|
window: typeof window === 'undefined' ? null : window,
|
|
65
39
|
};
|
|
66
40
|
const CanvasWindowContext = react.createContext(AMBIENT_CANVAS_WINDOW);
|
|
67
|
-
/**
|
|
68
|
-
* The real `window`/`document` a canvas-rendered element visually lives in.
|
|
69
|
-
* `IframeCanvas` portals its content into an `<iframe>`, but a portal only
|
|
70
|
-
* relocates DOM nodes — the JS building them still runs in the host page's
|
|
71
|
-
* realm. Any library that reads a bare `window`/`document` global (scroll
|
|
72
|
-
* listeners, `matchMedia`, `ResizeObserver`/`IntersectionObserver` defaults)
|
|
73
|
-
* silently ends up watching the HOST page, not the iframe the user actually
|
|
74
|
-
* sees and scrolls. Build refs/options from `useCanvasWindow()` instead of the
|
|
75
|
-
* ambient global so they resolve to the right realm — this works identically
|
|
76
|
-
* outside `IframeCanvas` too (e.g. `NuFrame`'s direct, non-iframe render),
|
|
77
|
-
* where it's just the ambient `window`/`document`.
|
|
78
|
-
*/
|
|
79
41
|
function useCanvasWindow() {
|
|
80
42
|
return react.useContext(CanvasWindowContext);
|
|
81
43
|
}
|
|
@@ -83,17 +45,14 @@ const EditorView = react.memo(function EditorView({ view, }) {
|
|
|
83
45
|
const renderChildren = (views) => views.map((child) => react.createElement(EditorView, { key: child.key, view: child }));
|
|
84
46
|
return react$1.createViewElement(view, renderChildren, decorate);
|
|
85
47
|
});
|
|
86
|
-
/** Reactive renderer for a frame, decorating each element with `data-nu-id`. */
|
|
87
48
|
const EditorFrame = react$1.observer(function EditorFrame({ frame }) {
|
|
88
49
|
return react.createElement(react.Fragment, null, frame.view.children.map((view) => react.createElement(EditorView, { key: view.key, view })));
|
|
89
50
|
});
|
|
90
|
-
/** Map a DOM node (or event target) back to its template id. */
|
|
91
51
|
function templateIdFromElement(target) {
|
|
92
52
|
return (target
|
|
93
53
|
?.closest('[data-nu-id]')
|
|
94
54
|
?.getAttribute('data-nu-id') ?? null);
|
|
95
55
|
}
|
|
96
|
-
/** Sensible default styling for the isolated canvas document. */
|
|
97
56
|
const DEFAULT_CANVAS_CSS = `
|
|
98
57
|
*, *::before, *::after { box-sizing: border-box; }
|
|
99
58
|
html, body { margin: 0; }
|
|
@@ -112,13 +71,6 @@ const DEFAULT_CANVAS_CSS = `
|
|
|
112
71
|
img { max-width: 100%; }
|
|
113
72
|
[data-nu-id] { cursor: default; }
|
|
114
73
|
`;
|
|
115
|
-
/**
|
|
116
|
-
* A style-isolated editor canvas: the frame is portaled into an `<iframe>` so
|
|
117
|
-
* the host's CSS can't leak into the preview (and vice-versa). Selection/hover
|
|
118
|
-
* use native capture listeners on the iframe document, so element handlers stay
|
|
119
|
-
* inert while editing. Selection/hover rects are computed (in a layout effect,
|
|
120
|
-
* after the iframe commits) and handed to `renderBox` for the overlay.
|
|
121
|
-
*/
|
|
122
74
|
function IframeCanvas({ frame, selectedId = null, hoveredId = null, revision = 0, previewMode = false, onSelect, onHover, onDropOnNode, baseCss = DEFAULT_CANVAS_CSS, srcDoc, className, containerClassName, renderBox, }) {
|
|
123
75
|
const iframeRef = react.useRef(null);
|
|
124
76
|
const [doc, setDoc] = react.useState(null);
|
|
@@ -153,9 +105,6 @@ function IframeCanvas({ frame, selectedId = null, hoveredId = null, revision = 0
|
|
|
153
105
|
if (!doc) {
|
|
154
106
|
return;
|
|
155
107
|
}
|
|
156
|
-
// In preview mode, only keep the scroll listener alive (so an overlay, if any
|
|
157
|
-
// lingers during a toggle, stays aligned). Skip the capturing click handler
|
|
158
|
-
// so element `onClick`s actually fire, and skip hover highlighting.
|
|
159
108
|
const onScroll = () => setTick((x) => x + 1);
|
|
160
109
|
doc.addEventListener('scroll', onScroll, true);
|
|
161
110
|
if (previewMode) {
|
|
@@ -178,7 +127,6 @@ function IframeCanvas({ frame, selectedId = null, hoveredId = null, revision = 0
|
|
|
178
127
|
doc.removeEventListener('scroll', onScroll, true);
|
|
179
128
|
};
|
|
180
129
|
}, [doc, previewMode, onSelect, onHover]);
|
|
181
|
-
// Drop-on-canvas: compute the drop target node + position from the pointer.
|
|
182
130
|
react.useEffect(() => {
|
|
183
131
|
if (!doc || !onDropOnNode || previewMode) {
|
|
184
132
|
return;
|
|
@@ -195,7 +143,7 @@ function IframeCanvas({ frame, selectedId = null, hoveredId = null, revision = 0
|
|
|
195
143
|
setDrop(null);
|
|
196
144
|
return;
|
|
197
145
|
}
|
|
198
|
-
e.preventDefault();
|
|
146
|
+
e.preventDefault();
|
|
199
147
|
setDrop({
|
|
200
148
|
rect: { top: r.top, left: r.left, width: r.width, height: r.height },
|
|
201
149
|
position: dropPositionFromPointer(e.clientY, r, { allowInside: true }),
|
|
@@ -221,8 +169,6 @@ function IframeCanvas({ frame, selectedId = null, hoveredId = null, revision = 0
|
|
|
221
169
|
doc.removeEventListener('dragleave', onDragLeave);
|
|
222
170
|
};
|
|
223
171
|
}, [doc, onDropOnNode]);
|
|
224
|
-
// Recompute overlay rects when the iframe is resized (panel/device resize) or
|
|
225
|
-
// its content reflows — otherwise the selection box keeps a stale size.
|
|
226
172
|
react.useEffect(() => {
|
|
227
173
|
const iframe = iframeRef.current;
|
|
228
174
|
if (!iframe) {
|
|
@@ -235,30 +181,19 @@ function IframeCanvas({ frame, selectedId = null, hoveredId = null, revision = 0
|
|
|
235
181
|
}
|
|
236
182
|
return () => observer.disconnect();
|
|
237
183
|
}, [doc]);
|
|
238
|
-
// Drop-on-canvas indicator has no meaning while previewing.
|
|
239
184
|
react.useEffect(() => {
|
|
240
185
|
if (previewMode) {
|
|
241
186
|
setDrop(null);
|
|
242
187
|
}
|
|
243
188
|
}, [previewMode]);
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
return null;
|
|
247
|
-
}
|
|
248
|
-
const el = doc.querySelector(`[data-nu-id="${id}"]`);
|
|
189
|
+
const elementFor = (id) => doc && id ? doc.querySelector(`[data-nu-id="${id}"]`) : null;
|
|
190
|
+
const rectOfEl = (el) => {
|
|
249
191
|
if (!el) {
|
|
250
192
|
return null;
|
|
251
193
|
}
|
|
252
194
|
const r = el.getBoundingClientRect();
|
|
253
195
|
return { top: r.top, left: r.left, width: r.width, height: r.height };
|
|
254
196
|
};
|
|
255
|
-
const labelOf = (id) => {
|
|
256
|
-
if (!doc || !id) {
|
|
257
|
-
return null;
|
|
258
|
-
}
|
|
259
|
-
const el = doc.querySelector(`[data-nu-id="${id}"]`);
|
|
260
|
-
return el ? el.tagName.toLowerCase() : null;
|
|
261
|
-
};
|
|
262
197
|
react.useLayoutEffect(() => {
|
|
263
198
|
if (previewMode) {
|
|
264
199
|
setSelected(null);
|
|
@@ -266,18 +201,14 @@ function IframeCanvas({ frame, selectedId = null, hoveredId = null, revision = 0
|
|
|
266
201
|
setSelectedLabel(null);
|
|
267
202
|
return;
|
|
268
203
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
204
|
+
const selectedEl = elementFor(selectedId);
|
|
205
|
+
setSelected(rectOfEl(selectedEl));
|
|
206
|
+
setHovered(hoveredId && hoveredId !== selectedId ? rectOfEl(elementFor(hoveredId)) : null);
|
|
207
|
+
setSelectedLabel(selectedEl ? selectedEl.tagName.toLowerCase() : null);
|
|
272
208
|
}, [previewMode, selectedId, hoveredId, revision, tick, doc]);
|
|
273
209
|
return react.createElement('div', { className: containerClassName, style: { position: 'relative' } }, react.createElement('iframe', { ref: iframeRef, title: 'nuforge preview', className, srcDoc }, doc?.body
|
|
274
210
|
? reactDom.createPortal(react.createElement(CanvasWindowContext.Provider, { value: { doc, window: doc.defaultView } }, react.createElement(EditorFrame, { frame })), doc.body)
|
|
275
|
-
: null),
|
|
276
|
-
// The overlay is fully yours: omit `renderBox` for none, or build borders,
|
|
277
|
-
// labels and action toolbars from the computed rects + selectedLabel. The
|
|
278
|
-
// container is pointer-events:none; set pointerEvents:'auto' on interactive
|
|
279
|
-
// bits (e.g. an action toolbar) so clicks land.
|
|
280
|
-
renderBox && !previewMode
|
|
211
|
+
: null), renderBox && !previewMode
|
|
281
212
|
? react.createElement('div', {
|
|
282
213
|
style: {
|
|
283
214
|
position: 'absolute',
|
|
@@ -288,22 +219,6 @@ function IframeCanvas({ frame, selectedId = null, hoveredId = null, revision = 0
|
|
|
288
219
|
}, renderBox({ selected, hovered, selectedLabel, drop }))
|
|
289
220
|
: null);
|
|
290
221
|
}
|
|
291
|
-
/**
|
|
292
|
-
* The `IframeCanvas` alternative for when a canvas needs its OWN genuine JS
|
|
293
|
-
* realm — e.g. so a third-party library reading bare `window`/`document` at
|
|
294
|
-
* module scope (an animation library's scroll-linked internals, a gesture
|
|
295
|
-
* library, ...) resolves against the canvas's real globals instead of
|
|
296
|
-
* silently binding to the host page's. A portal can't fix this (see
|
|
297
|
-
* `useCanvasWindow`'s docs for that half of the problem) because it only
|
|
298
|
-
* relocates DOM nodes, not the JS module graph building them.
|
|
299
|
-
*
|
|
300
|
-
* Renders a genuinely-`src`-navigated `<iframe>` — no `createPortal`, so
|
|
301
|
-
* nothing here shares object identity with the canvas's own React tree. State
|
|
302
|
-
* flows one-way, host -> canvas, over a `BroadcastChannel` as `t.flatten`'d
|
|
303
|
-
* JSON (see `./canvas-protocol`); interaction (select/hover/drop/keydown/
|
|
304
|
-
* link-click) relays back the other way as small typed messages. Pair with
|
|
305
|
-
* `createCanvasReceiver` (`./canvas-client`) on the canvas route's own end.
|
|
306
|
-
*/
|
|
307
222
|
function RemoteCanvasHost({ nu, activeComponent, src, selectedId = null, hoveredId = null, revision = 0, previewMode = false, uiPayload, onSelect, onHover, onDropOnNode, onKeydownRelay, onLinkClick, className, containerClassName, renderBox, }) {
|
|
308
223
|
const iframeRef = react.useRef(null);
|
|
309
224
|
const [doc, setDoc] = react.useState(null);
|
|
@@ -332,10 +247,6 @@ function RemoteCanvasHost({ nu, activeComponent, src, selectedId = null, hovered
|
|
|
332
247
|
onLinkClick,
|
|
333
248
|
};
|
|
334
249
|
const connectionRef = react.useRef(null);
|
|
335
|
-
// A same-origin `src` navigation still allows the host to read the iframe's
|
|
336
|
-
// own document for layout geometry — that's a plain DOM read, not blocked
|
|
337
|
-
// by realm separation (only shared JS object identity is). Used purely to
|
|
338
|
-
// size/position the overlay; no sync-protocol concern.
|
|
339
250
|
react.useEffect(() => {
|
|
340
251
|
const iframe = iframeRef.current;
|
|
341
252
|
if (!iframe) {
|
|
@@ -419,14 +330,6 @@ function RemoteCanvasHost({ nu, activeComponent, src, selectedId = null, hovered
|
|
|
419
330
|
mountGen = message.mountGen;
|
|
420
331
|
ready = true;
|
|
421
332
|
sendInit();
|
|
422
|
-
// The `uiPayload` effect below only fires again when the payload
|
|
423
|
-
// itself changes — on a fresh mount (or a remount after an error
|
|
424
|
-
// boundary), that push happens before the canvas has attached its
|
|
425
|
-
// listener (it hasn't navigated/mounted yet) and is silently lost,
|
|
426
|
-
// since BroadcastChannel doesn't buffer for late listeners. Resend
|
|
427
|
-
// the CURRENT payload here too, gated on the same readiness signal
|
|
428
|
-
// as the state bootstrap, so theme/overrides never depend on some
|
|
429
|
-
// unrelated prop changing later to "unstick" them.
|
|
430
333
|
postUi(props.current.uiPayload);
|
|
431
334
|
break;
|
|
432
335
|
case 'select':
|
|
@@ -462,12 +365,7 @@ function RemoteCanvasHost({ nu, activeComponent, src, selectedId = null, hovered
|
|
|
462
365
|
}
|
|
463
366
|
connectionRef.current = null;
|
|
464
367
|
};
|
|
465
|
-
// `nu` is stable for the app's lifetime; `src` identifies the channel.
|
|
466
|
-
// Everything else is read fresh off `props.current` inside the handlers.
|
|
467
368
|
}, [src, nu]);
|
|
468
|
-
// A page switch or preview-mode transition needs a fresh `init`, not just an
|
|
469
|
-
// incremental `patch` — see `previewMode`'s doc above for why preview mode
|
|
470
|
-
// specifically must never let a local-only mutation linger into edit mode.
|
|
471
369
|
const resyncKey = `${activeComponent}:${previewMode}`;
|
|
472
370
|
const lastResyncKey = react.useRef(null);
|
|
473
371
|
react.useEffect(() => {
|
|
@@ -480,24 +378,14 @@ function RemoteCanvasHost({ nu, activeComponent, src, selectedId = null, hovered
|
|
|
480
378
|
react.useEffect(() => {
|
|
481
379
|
connectionRef.current?.postUi(uiPayload);
|
|
482
380
|
}, [uiPayload]);
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
return null;
|
|
486
|
-
}
|
|
487
|
-
const el = doc.querySelector(`[data-nu-id="${id}"]`);
|
|
381
|
+
const elementFor = (id) => doc && id ? doc.querySelector(`[data-nu-id="${id}"]`) : null;
|
|
382
|
+
const rectOfEl = (el) => {
|
|
488
383
|
if (!el) {
|
|
489
384
|
return null;
|
|
490
385
|
}
|
|
491
386
|
const r = el.getBoundingClientRect();
|
|
492
387
|
return { top: r.top, left: r.left, width: r.width, height: r.height };
|
|
493
388
|
};
|
|
494
|
-
const labelOf = (id) => {
|
|
495
|
-
if (!doc || !id) {
|
|
496
|
-
return null;
|
|
497
|
-
}
|
|
498
|
-
const el = doc.querySelector(`[data-nu-id="${id}"]`);
|
|
499
|
-
return el ? el.tagName.toLowerCase() : null;
|
|
500
|
-
};
|
|
501
389
|
react.useLayoutEffect(() => {
|
|
502
390
|
if (previewMode) {
|
|
503
391
|
setSelected(null);
|
|
@@ -505,9 +393,10 @@ function RemoteCanvasHost({ nu, activeComponent, src, selectedId = null, hovered
|
|
|
505
393
|
setSelectedLabel(null);
|
|
506
394
|
return;
|
|
507
395
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
396
|
+
const selectedEl = elementFor(selectedId);
|
|
397
|
+
setSelected(rectOfEl(selectedEl));
|
|
398
|
+
setHovered(hoveredId && hoveredId !== selectedId ? rectOfEl(elementFor(hoveredId)) : null);
|
|
399
|
+
setSelectedLabel(selectedEl ? selectedEl.tagName.toLowerCase() : null);
|
|
511
400
|
}, [previewMode, selectedId, hoveredId, revision, tick, doc]);
|
|
512
401
|
return react.createElement('div', { className: containerClassName, style: { position: 'relative' } }, react.createElement('iframe', {
|
|
513
402
|
ref: iframeRef,
|
|
@@ -525,26 +414,16 @@ function RemoteCanvasHost({ nu, activeComponent, src, selectedId = null, hovered
|
|
|
525
414
|
}, renderBox({ selected, hovered, selectedLabel }))
|
|
526
415
|
: null);
|
|
527
416
|
}
|
|
528
|
-
// Structural param types (not the nominal `Editor` class), so these hooks accept
|
|
529
|
-
// an Editor imported from the package's main entry without a dual-bundle clash.
|
|
530
|
-
/** Re-render only when the tree shape changes (add/remove/move). */
|
|
531
417
|
function useStructureRevision(editor) {
|
|
532
418
|
const [rev, setRev] = react.useState(0);
|
|
533
419
|
react.useEffect(() => editor.onStructureChange(() => setRev((r) => r + 1)), [editor]);
|
|
534
420
|
return rev;
|
|
535
421
|
}
|
|
536
|
-
/** Re-render on every committed change (for undo state, inspectors, …). */
|
|
537
422
|
function useEditorRevision(editor) {
|
|
538
423
|
const [rev, setRev] = react.useState(0);
|
|
539
424
|
react.useEffect(() => editor.onChange(() => setRev((r) => r + 1)), [editor]);
|
|
540
425
|
return rev;
|
|
541
426
|
}
|
|
542
|
-
/**
|
|
543
|
-
* A headless inspector renderer: builds a control per {@link PropSchemaField}
|
|
544
|
-
* (text / number / checkbox / select / expression) so you don't hand-write a
|
|
545
|
-
* form per block. Style via `className` and your own CSS; wire `onChange` to the
|
|
546
|
-
* editor's mutation methods.
|
|
547
|
-
*/
|
|
548
427
|
function PropFields({ fields, values, onChange, className, }) {
|
|
549
428
|
return react.createElement('div', { className, 'data-nu-propfields': '' }, fields.map((field) => react.createElement(PropFieldControl, {
|
|
550
429
|
key: field.name,
|
|
@@ -582,7 +461,6 @@ function PropFieldControl({ field, value, onChange, }) {
|
|
|
582
461
|
}, (field.options ?? []).map((opt) => react.createElement('option', { key: opt, value: opt }, opt)));
|
|
583
462
|
break;
|
|
584
463
|
default:
|
|
585
|
-
// 'string' | 'expression'
|
|
586
464
|
control = react.createElement('input', {
|
|
587
465
|
id,
|
|
588
466
|
type: 'text',
|
|
@@ -603,4 +481,3 @@ exports.templateIdFromElement = templateIdFromElement;
|
|
|
603
481
|
exports.useCanvasWindow = useCanvasWindow;
|
|
604
482
|
exports.useEditorRevision = useEditorRevision;
|
|
605
483
|
exports.useStructureRevision = useStructureRevision;
|
|
606
|
-
//# sourceMappingURL=react.cjs.map
|
package/dist/react.d.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { ReactNode } from 'react';
|
|
|
3
3
|
import * as t from '@nuforge/types';
|
|
4
4
|
import { MutationEntry } from '@nuforge/reactivity';
|
|
5
5
|
|
|
6
|
-
/** A control hint for auto-generating inspector fields from a block's props. */
|
|
7
6
|
interface PropSchemaField {
|
|
8
7
|
name: string;
|
|
9
8
|
type: 'string' | 'number' | 'boolean' | 'expression' | 'enum';
|
|
@@ -11,11 +10,6 @@ interface PropSchemaField {
|
|
|
11
10
|
default?: string | number | boolean;
|
|
12
11
|
options?: string[];
|
|
13
12
|
}
|
|
14
|
-
/**
|
|
15
|
-
* A reusable element/block in the palette. `create()` returns a fresh template
|
|
16
|
-
* subtree to insert. `icon` is a name (not a component) so the registry stays
|
|
17
|
-
* framework-agnostic; the UI maps the name to its own icon set.
|
|
18
|
-
*/
|
|
19
13
|
interface Block {
|
|
20
14
|
id: string;
|
|
21
15
|
label: string;
|
|
@@ -33,20 +27,8 @@ declare class BlockRegistry {
|
|
|
33
27
|
byCategory(): Record<string, Block[]>;
|
|
34
28
|
}
|
|
35
29
|
|
|
36
|
-
/** Where a dragged node is dropped relative to a target. */
|
|
37
30
|
type DropPosition = 'before' | 'after' | 'inside';
|
|
38
31
|
|
|
39
|
-
/**
|
|
40
|
-
* Wire protocol for `RemoteCanvasHost` <-> `createCanvasReceiver`: a genuinely
|
|
41
|
-
* navigated `/canvas/[id]`-style iframe has its own JS realm, so state and
|
|
42
|
-
* interaction can't cross as direct object references the way `IframeCanvas`'s
|
|
43
|
-
* portal allows — everything here is a structured-clone-safe plain message.
|
|
44
|
-
*
|
|
45
|
-
* Deliberately generic: only the shapes the editor primitive itself needs
|
|
46
|
-
* (state sync, ready handshake, the core interaction events) are typed. Host
|
|
47
|
-
* concerns like theme/preview-mode/overrides ride through `CanvasUiMessage`'s
|
|
48
|
-
* opaque `payload` — this package relays it, never interprets it.
|
|
49
|
-
*/
|
|
50
32
|
declare const CANVAS_CHANNEL = "nuforge-canvas";
|
|
51
33
|
declare const CANVAS_PROTOCOL_VERSION = 1;
|
|
52
34
|
interface CanvasKeydownMessage {
|
|
@@ -61,7 +43,6 @@ interface CanvasKeydownMessage {
|
|
|
61
43
|
altKey: boolean;
|
|
62
44
|
}
|
|
63
45
|
|
|
64
|
-
/** A named action a UI can discover and bind to (toolbar buttons, shortcuts). */
|
|
65
46
|
interface Command {
|
|
66
47
|
id: string;
|
|
67
48
|
label: string;
|
|
@@ -79,67 +60,37 @@ declare class CommandRegistry {
|
|
|
79
60
|
}
|
|
80
61
|
|
|
81
62
|
interface CreateEditorOptions {
|
|
82
|
-
/** Extra blocks to add to the palette. */
|
|
83
63
|
blocks?: Block[];
|
|
84
|
-
/** Include the built-in DEFAULT_BLOCKS (default true). */
|
|
85
64
|
defaultBlocks?: boolean;
|
|
86
65
|
}
|
|
87
|
-
/**
|
|
88
|
-
* A high-level facade over a {@link Nu} runtime for building visual page
|
|
89
|
-
* editors. Every mutation goes through `nu.change`, so it is transactional and
|
|
90
|
-
* undoable. Reach for these instead of hand-splicing the AST.
|
|
91
|
-
*/
|
|
92
66
|
declare class Editor {
|
|
93
67
|
readonly nu: Nu;
|
|
94
68
|
readonly blocks: BlockRegistry;
|
|
95
69
|
readonly commands: CommandRegistry;
|
|
96
70
|
constructor(nu: Nu, opts?: CreateEditorOptions);
|
|
97
|
-
/** Append (or insert at `index`) `child` into `parent`'s children. */
|
|
98
71
|
insertNode(parent: t.Template, child: t.Template, index?: number): void;
|
|
99
|
-
/** Remove a node from its parent. Returns false if it has no parent. */
|
|
100
72
|
removeNode(node: t.Template): boolean;
|
|
101
|
-
/** Move `dragged` before/after/inside `target`. Refuses to nest into self. */
|
|
102
73
|
moveNode(dragged: t.Template, target: t.Template, position: DropPosition): boolean;
|
|
103
74
|
private clipboardNode;
|
|
104
|
-
/** Put a node on the editor clipboard (a clone is made on paste). */
|
|
105
75
|
copyNode(node: t.Template): void;
|
|
106
|
-
/** Copy a node, then remove it. */
|
|
107
76
|
cutNode(node: t.Template): boolean;
|
|
108
|
-
/** Whether there is something on the clipboard to paste. */
|
|
109
77
|
canPaste(): boolean;
|
|
110
|
-
/**
|
|
111
|
-
* Paste a fresh copy (new ids) of the clipboard node before/after/inside
|
|
112
|
-
* `target`. Returns the inserted node, or null if nothing was pasted.
|
|
113
|
-
*/
|
|
114
78
|
pasteNode(target: t.Template, position?: DropPosition): t.Template | null;
|
|
115
|
-
/** Duplicate a node in place (inserted right after it). Returns the copy. */
|
|
116
79
|
duplicateNode(node: t.Template): t.Template | null;
|
|
117
|
-
/**
|
|
118
|
-
* Insert a detached node before/after/inside a target (undoable). The core of
|
|
119
|
-
* canvas drag-and-drop: a drop computes a target + position, then calls this.
|
|
120
|
-
* Returns the inserted node.
|
|
121
|
-
*/
|
|
122
80
|
insertNodeAt(node: t.Template, target: t.Template, position: DropPosition): t.Template | null;
|
|
123
|
-
/** Create a block and insert it before/after/inside a target. */
|
|
124
81
|
insertBlockAt(blockId: string, target: t.Template, position: DropPosition): t.Template | null;
|
|
125
82
|
private insertCloneRelativeTo;
|
|
126
|
-
/** Set several props at once. */
|
|
127
83
|
updateProps(node: t.Template, props: Record<string, t.Expression>): void;
|
|
128
84
|
setProp(node: t.Template, key: string, expr: t.Expression): void;
|
|
129
85
|
removeProp(node: t.Template, key: string): void;
|
|
130
|
-
/** Parse `source` as a nuforge expression and assign it to `key`. */
|
|
131
86
|
setExpression(node: t.Template, key: string, source: string): boolean;
|
|
132
|
-
/** Update a text node's literal value. */
|
|
133
87
|
setText(node: t.Template, value: string): boolean;
|
|
134
88
|
setTag(node: t.TagTemplate, tag: string): void;
|
|
135
89
|
addClass(node: t.Template, name: string): void;
|
|
136
|
-
/** Parse `source` and set the condition under which class `name` applies. */
|
|
137
90
|
setClassCondition(node: t.Template, name: string, source: string): boolean;
|
|
138
91
|
removeClass(node: t.Template, name: string): void;
|
|
139
|
-
/** Set the `@if` condition from source. */
|
|
140
92
|
setCondition(node: t.Template, source: string): boolean;
|
|
141
93
|
clearCondition(node: t.Template): void;
|
|
142
|
-
/** Repeat a node over `iterator` (a list expression), binding each `alias`. */
|
|
143
94
|
setEach(node: t.Template, iterator: string, alias?: string, indexName?: string): boolean;
|
|
144
95
|
clearEach(node: t.Template): void;
|
|
145
96
|
addState(component: t.NuComponent, name?: string, init?: string): t.Val;
|
|
@@ -148,12 +99,9 @@ declare class Editor {
|
|
|
148
99
|
addComponentProp(component: t.NuComponent, name?: string, init?: string): t.ComponentProp;
|
|
149
100
|
removeComponentProp(component: t.NuComponent, prop: t.ComponentProp): void;
|
|
150
101
|
setComponentPropInit(prop: t.ComponentProp, source: string): boolean;
|
|
151
|
-
/** Create a block's template and insert it into `parent`. */
|
|
152
102
|
insertBlock(blockId: string, parent: t.Template, index?: number): t.Template | null;
|
|
153
103
|
private uniqueName;
|
|
154
|
-
/** Add a new component (a "page"). */
|
|
155
104
|
addComponent(name?: string): t.NuComponent;
|
|
156
|
-
/** Rename a component, updating `<Name/>` references across the program. */
|
|
157
105
|
renameComponent(component: t.NuComponent, name: string): boolean;
|
|
158
106
|
removeComponent(component: t.NuComponent): boolean;
|
|
159
107
|
duplicateComponent(component: t.NuComponent): t.NuComponent;
|
|
@@ -164,15 +112,11 @@ declare class Editor {
|
|
|
164
112
|
getChildren(node: t.Template): t.Template[];
|
|
165
113
|
getSiblings(node: t.Template): t.Template[];
|
|
166
114
|
getNodeIndex(node: t.Template): number;
|
|
167
|
-
/** Full path from the State root to this node (for selections/serialization). */
|
|
168
115
|
getNodePath(node: t.Type): Array<string | number>;
|
|
169
|
-
/** Fires with the raw field-level writes of every change (incl. undo/redo). */
|
|
170
116
|
onChange(cb: (entries: MutationEntry[]) => void): () => void;
|
|
171
|
-
/** Fires only when the tree shape changes (a child array was mutated). */
|
|
172
117
|
onStructureChange(cb: () => void): () => void;
|
|
173
118
|
private spliceFromParent;
|
|
174
119
|
private applyMove;
|
|
175
|
-
/** Insert an already-detached node relative to `target` (no removal). */
|
|
176
120
|
private insertRelativeTo;
|
|
177
121
|
}
|
|
178
122
|
|
|
@@ -183,76 +127,31 @@ interface Rect {
|
|
|
183
127
|
height: number;
|
|
184
128
|
}
|
|
185
129
|
interface CanvasWindow {
|
|
186
|
-
/** The canvas's own document — the iframe's, inside `IframeCanvas`. */
|
|
187
130
|
doc: Document | null;
|
|
188
|
-
/** The canvas's own window — the iframe's, inside `IframeCanvas`. */
|
|
189
131
|
window: Window | null;
|
|
190
132
|
}
|
|
191
|
-
/**
|
|
192
|
-
* The real `window`/`document` a canvas-rendered element visually lives in.
|
|
193
|
-
* `IframeCanvas` portals its content into an `<iframe>`, but a portal only
|
|
194
|
-
* relocates DOM nodes — the JS building them still runs in the host page's
|
|
195
|
-
* realm. Any library that reads a bare `window`/`document` global (scroll
|
|
196
|
-
* listeners, `matchMedia`, `ResizeObserver`/`IntersectionObserver` defaults)
|
|
197
|
-
* silently ends up watching the HOST page, not the iframe the user actually
|
|
198
|
-
* sees and scrolls. Build refs/options from `useCanvasWindow()` instead of the
|
|
199
|
-
* ambient global so they resolve to the right realm — this works identically
|
|
200
|
-
* outside `IframeCanvas` too (e.g. `NuFrame`'s direct, non-iframe render),
|
|
201
|
-
* where it's just the ambient `window`/`document`.
|
|
202
|
-
*/
|
|
203
133
|
declare function useCanvasWindow(): CanvasWindow;
|
|
204
|
-
/** Reactive renderer for a frame, decorating each element with `data-nu-id`. */
|
|
205
134
|
declare const EditorFrame: (props: {
|
|
206
135
|
frame: Frame;
|
|
207
136
|
}) => ReactNode;
|
|
208
|
-
/** Map a DOM node (or event target) back to its template id. */
|
|
209
137
|
declare function templateIdFromElement(target: EventTarget | null): string | null;
|
|
210
|
-
/** Sensible default styling for the isolated canvas document. */
|
|
211
138
|
declare const DEFAULT_CANVAS_CSS = "\n *, *::before, *::after { box-sizing: border-box; }\n html, body { margin: 0; }\n body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; color: #111; line-height: 1.5; padding: 28px; }\n h1 { font-size: 2rem; font-weight: 700; margin: .5em 0; }\n h2 { font-size: 1.5rem; font-weight: 600; margin: .6em 0; }\n h3 { font-size: 1.2rem; font-weight: 600; margin: .7em 0; }\n p { margin: .8em 0; }\n ul { list-style: disc; padding-left: 1.5em; margin: .8em 0; }\n ol { list-style: decimal; padding-left: 1.5em; margin: .8em 0; }\n a { color: #2563eb; text-decoration: underline; }\n button { border: 1px solid #d4d4d8; border-radius: 6px; padding: 4px 12px; background: #fafafa; cursor: pointer; font: inherit; }\n button:hover { background: #f4f4f5; }\n input, textarea, select { border: 1px solid #d4d4d8; border-radius: 6px; padding: 4px 8px; font: inherit; }\n section { display: flex; align-items: center; gap: .75rem; margin: .8em 0; }\n img { max-width: 100%; }\n [data-nu-id] { cursor: default; }\n";
|
|
212
139
|
interface IframeCanvasProps {
|
|
213
140
|
frame: Frame;
|
|
214
141
|
selectedId?: string | null;
|
|
215
142
|
hoveredId?: string | null;
|
|
216
|
-
/** Bump to force the overlay to recompute (e.g. on every committed change). */
|
|
217
143
|
revision?: number;
|
|
218
|
-
/**
|
|
219
|
-
* Turn the canvas into a live, interactive preview. When `true`, the editing
|
|
220
|
-
* affordances are switched off: clicks and hovers pass through to the real
|
|
221
|
-
* elements (buttons fire, links work, inputs focus), drag-and-drop editing is
|
|
222
|
-
* disabled, and no overlay is drawn. When `false` (the default) the canvas is
|
|
223
|
-
* in edit mode — clicks select instead of activating, hover highlights, and
|
|
224
|
-
* `onDropOnNode` / `renderBox` are live. The same reactive `frame` is shown in
|
|
225
|
-
* both modes, so toggling is instant and state is preserved.
|
|
226
|
-
*/
|
|
227
144
|
previewMode?: boolean;
|
|
228
145
|
onSelect?: (id: string | null) => void;
|
|
229
146
|
onHover?: (id: string | null) => void;
|
|
230
|
-
/**
|
|
231
|
-
* Enable drop-on-canvas: fired when something is dropped onto a node. Compute
|
|
232
|
-
* a block to insert and call `editor.insertBlockAt(blockId, target, position)`.
|
|
233
|
-
*/
|
|
234
147
|
onDropOnNode?: (targetId: string, position: DropPosition, event: DragEvent) => void;
|
|
235
|
-
/** CSS injected into the isolated document (defaults to DEFAULT_CANVAS_CSS). */
|
|
236
148
|
baseCss?: string;
|
|
237
|
-
/**
|
|
238
|
-
* Initial HTML for the iframe document — put anything in the `<head>` (CDN
|
|
239
|
-
* stylesheets, web fonts, `<script>` tags, a `<base>`, meta). The frame is
|
|
240
|
-
* portaled into its `<body>`. `baseCss` is still appended on top; pass
|
|
241
|
-
* `baseCss=""` to fully control styling from here.
|
|
242
|
-
*/
|
|
243
149
|
srcDoc?: string;
|
|
244
150
|
className?: string;
|
|
245
151
|
containerClassName?: string;
|
|
246
|
-
/**
|
|
247
|
-
* Build the overlay from the computed selection / hover / drop rects and the
|
|
248
|
-
* selected element's tag. Omit it for no overlay. The overlay container is
|
|
249
|
-
* pointer-events:none — set `pointerEvents: 'auto'` on interactive bits (e.g.
|
|
250
|
-
* an action toolbar) so they receive clicks.
|
|
251
|
-
*/
|
|
252
152
|
renderBox?: (rects: {
|
|
253
153
|
selected: Rect | null;
|
|
254
154
|
hovered: Rect | null;
|
|
255
|
-
/** Tag name of the selected element, e.g. `"div"`. */
|
|
256
155
|
selectedLabel: string | null;
|
|
257
156
|
drop: {
|
|
258
157
|
rect: Rect;
|
|
@@ -260,41 +159,15 @@ interface IframeCanvasProps {
|
|
|
260
159
|
} | null;
|
|
261
160
|
}) => ReactNode;
|
|
262
161
|
}
|
|
263
|
-
/**
|
|
264
|
-
* A style-isolated editor canvas: the frame is portaled into an `<iframe>` so
|
|
265
|
-
* the host's CSS can't leak into the preview (and vice-versa). Selection/hover
|
|
266
|
-
* use native capture listeners on the iframe document, so element handlers stay
|
|
267
|
-
* inert while editing. Selection/hover rects are computed (in a layout effect,
|
|
268
|
-
* after the iframe commits) and handed to `renderBox` for the overlay.
|
|
269
|
-
*/
|
|
270
162
|
declare function IframeCanvas({ frame, selectedId, hoveredId, revision, previewMode, onSelect, onHover, onDropOnNode, baseCss, srcDoc, className, containerClassName, renderBox, }: IframeCanvasProps): ReactNode;
|
|
271
163
|
interface RemoteCanvasHostProps {
|
|
272
|
-
/** The host's `Nu` instance — flattened and pushed to the canvas realm on
|
|
273
|
-
* every committed change. Never referenced directly by the canvas realm: a
|
|
274
|
-
* real `src` navigation puts it in a separate JS realm with no shared
|
|
275
|
-
* object identity, unlike `IframeCanvas`'s portal. */
|
|
276
164
|
nu: Nu;
|
|
277
|
-
/** Which component the canvas should render (`Nu.createFrame`'s `component.name`). */
|
|
278
165
|
activeComponent: string;
|
|
279
|
-
/** The canvas route's URL, e.g. `/canvas/my-project`. The `BroadcastChannel`
|
|
280
|
-
* name is derived from it, so `createCanvasReceiver` on the other end just
|
|
281
|
-
* needs the SAME `src` — no separate id to keep in sync. */
|
|
282
166
|
src: string;
|
|
283
167
|
selectedId?: string | null;
|
|
284
168
|
hoveredId?: string | null;
|
|
285
|
-
/** Bump to force the overlay to recompute (e.g. on every committed change). */
|
|
286
169
|
revision?: number;
|
|
287
|
-
/**
|
|
288
|
-
* Mirrors `IframeCanvas`'s `previewMode`: when `true`, the canvas is a live,
|
|
289
|
-
* interactive preview (its own DSL `onClick` handlers run against the
|
|
290
|
-
* canvas's local `Nu`, since that instance is genuinely loaded and mutable —
|
|
291
|
-
* see `createCanvasReceiver`'s docs). Toggling this (either direction) forces
|
|
292
|
-
* a fresh `init` instead of an incremental `patch`, so any preview-only
|
|
293
|
-
* local mutation never lingers once you're back in edit mode.
|
|
294
|
-
*/
|
|
295
170
|
previewMode?: boolean;
|
|
296
|
-
/** Opaque host-defined payload (theme CSS, hide/lock overrides, ...) pushed
|
|
297
|
-
* to the canvas via a `ui` message whenever this reference changes. */
|
|
298
171
|
uiPayload?: unknown;
|
|
299
172
|
onSelect?: (id: string | null) => void;
|
|
300
173
|
onHover?: (id: string | null) => void;
|
|
@@ -303,59 +176,22 @@ interface RemoteCanvasHostProps {
|
|
|
303
176
|
onLinkClick?: (href: string) => void;
|
|
304
177
|
className?: string;
|
|
305
178
|
containerClassName?: string;
|
|
306
|
-
/**
|
|
307
|
-
* Build the overlay from the computed selection / hover rects and the
|
|
308
|
-
* selected element's tag — same contract as `IframeCanvas`'s `renderBox`
|
|
309
|
-
* (no `drop` rect: the canvas realm renders its own drop indicator locally,
|
|
310
|
-
* since it already has the DOM geometry and relaying it every `dragover`
|
|
311
|
-
* tick would add a message-per-frame the local realm doesn't need). Reading
|
|
312
|
-
* these rects is a same-origin DOM read (`iframe.contentDocument`), which a
|
|
313
|
-
* real `src` navigation doesn't block — only shared JS object identity is
|
|
314
|
-
* blocked, not DOM geometry reads.
|
|
315
|
-
*/
|
|
316
179
|
renderBox?: (rects: {
|
|
317
180
|
selected: Rect | null;
|
|
318
181
|
hovered: Rect | null;
|
|
319
182
|
selectedLabel: string | null;
|
|
320
183
|
}) => ReactNode;
|
|
321
184
|
}
|
|
322
|
-
/**
|
|
323
|
-
* The `IframeCanvas` alternative for when a canvas needs its OWN genuine JS
|
|
324
|
-
* realm — e.g. so a third-party library reading bare `window`/`document` at
|
|
325
|
-
* module scope (an animation library's scroll-linked internals, a gesture
|
|
326
|
-
* library, ...) resolves against the canvas's real globals instead of
|
|
327
|
-
* silently binding to the host page's. A portal can't fix this (see
|
|
328
|
-
* `useCanvasWindow`'s docs for that half of the problem) because it only
|
|
329
|
-
* relocates DOM nodes, not the JS module graph building them.
|
|
330
|
-
*
|
|
331
|
-
* Renders a genuinely-`src`-navigated `<iframe>` — no `createPortal`, so
|
|
332
|
-
* nothing here shares object identity with the canvas's own React tree. State
|
|
333
|
-
* flows one-way, host -> canvas, over a `BroadcastChannel` as `t.flatten`'d
|
|
334
|
-
* JSON (see `./canvas-protocol`); interaction (select/hover/drop/keydown/
|
|
335
|
-
* link-click) relays back the other way as small typed messages. Pair with
|
|
336
|
-
* `createCanvasReceiver` (`./canvas-client`) on the canvas route's own end.
|
|
337
|
-
*/
|
|
338
185
|
declare function RemoteCanvasHost({ nu, activeComponent, src, selectedId, hoveredId, revision, previewMode, uiPayload, onSelect, onHover, onDropOnNode, onKeydownRelay, onLinkClick, className, containerClassName, renderBox, }: RemoteCanvasHostProps): ReactNode;
|
|
339
|
-
/** Re-render only when the tree shape changes (add/remove/move). */
|
|
340
186
|
declare function useStructureRevision(editor: Pick<Editor, 'onStructureChange'>): number;
|
|
341
|
-
/** Re-render on every committed change (for undo state, inspectors, …). */
|
|
342
187
|
declare function useEditorRevision(editor: Pick<Editor, 'onChange'>): number;
|
|
343
188
|
type PropFieldValue = string | number | boolean | undefined;
|
|
344
189
|
interface PropFieldsProps {
|
|
345
|
-
/** The control schema (e.g. a block's `props`). */
|
|
346
190
|
fields: PropSchemaField[];
|
|
347
|
-
/** Current value per field name. */
|
|
348
191
|
values: Record<string, PropFieldValue>;
|
|
349
|
-
/** Called when a control changes. Wire to `editor.setProp`/`setExpression`. */
|
|
350
192
|
onChange: (name: string, value: string | number | boolean) => void;
|
|
351
193
|
className?: string;
|
|
352
194
|
}
|
|
353
|
-
/**
|
|
354
|
-
* A headless inspector renderer: builds a control per {@link PropSchemaField}
|
|
355
|
-
* (text / number / checkbox / select / expression) so you don't hand-write a
|
|
356
|
-
* form per block. Style via `className` and your own CSS; wire `onChange` to the
|
|
357
|
-
* editor's mutation methods.
|
|
358
|
-
*/
|
|
359
195
|
declare function PropFields({ fields, values, onChange, className, }: PropFieldsProps): ReactNode;
|
|
360
196
|
|
|
361
197
|
export { DEFAULT_CANVAS_CSS, EditorFrame, IframeCanvas, PropFields, RemoteCanvasHost, templateIdFromElement, useCanvasWindow, useEditorRevision, useStructureRevision };
|