@nuforge/editor 0.2.0 → 0.3.1

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 CHANGED
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  var t = require('@nuforge/types');
4
+ var core = require('@nuforge/core');
5
+ var reactivity = require('@nuforge/reactivity');
4
6
  var parser = require('@nuforge/parser');
5
7
 
6
8
  function _interopNamespaceDefault(e) {
@@ -93,6 +95,78 @@ const DEFAULT_BLOCKS = [
93
95
  },
94
96
  ];
95
97
 
98
+ const CANVAS_CHANNEL = 'nuforge-canvas';
99
+ const CANVAS_PROTOCOL_VERSION = 1;
100
+ function canvasChannelName(src) {
101
+ return `${CANVAS_CHANNEL}:${src}`;
102
+ }
103
+ function isCanvasMessage(data) {
104
+ return (!!data &&
105
+ typeof data === 'object' &&
106
+ data.channel === CANVAS_CHANNEL &&
107
+ data.v === CANVAS_PROTOCOL_VERSION);
108
+ }
109
+
110
+ function createCanvasReceiver(opts) {
111
+ const nu = core.Nu.create({ externals: opts.externals });
112
+ const volatile = reactivity.reactive({ activeComponent: null, loadGeneration: 0 });
113
+ const channel = new BroadcastChannel(canvasChannelName(opts.src));
114
+ const uiListeners = new Set();
115
+ const applyState = (message) => {
116
+ if (message.type === 'init') {
117
+ nu.load(t__namespace.unflatten(message.flat));
118
+ volatile.loadGeneration++;
119
+ }
120
+ else {
121
+ nu.change(() => t__namespace.merge(nu.state, t__namespace.unflatten(message.flat), { arrayReconcile: 'id' }));
122
+ }
123
+ volatile.activeComponent = message.activeComponent;
124
+ };
125
+ const onMessage = (event) => {
126
+ if (!isCanvasMessage(event.data)) {
127
+ return;
128
+ }
129
+ const message = event.data;
130
+ if (message.type === 'init' || message.type === 'patch') {
131
+ applyState(message);
132
+ }
133
+ else if (message.type === 'ui') {
134
+ for (const cb of uiListeners) {
135
+ cb(message.payload);
136
+ }
137
+ }
138
+ };
139
+ channel.addEventListener('message', onMessage);
140
+ return {
141
+ nu,
142
+ volatile,
143
+ postReady() {
144
+ channel.postMessage({
145
+ channel: CANVAS_CHANNEL,
146
+ v: CANVAS_PROTOCOL_VERSION,
147
+ type: 'ready',
148
+ mountGen: Math.floor(Math.random() * 1e9),
149
+ });
150
+ },
151
+ onUi(cb) {
152
+ uiListeners.add(cb);
153
+ return () => uiListeners.delete(cb);
154
+ },
155
+ postEvent(message) {
156
+ channel.postMessage({
157
+ channel: CANVAS_CHANNEL,
158
+ v: CANVAS_PROTOCOL_VERSION,
159
+ ...message,
160
+ });
161
+ },
162
+ dispose() {
163
+ channel.removeEventListener('message', onMessage);
164
+ channel.close();
165
+ uiListeners.clear();
166
+ },
167
+ };
168
+ }
169
+
96
170
  class CommandRegistry {
97
171
  editor;
98
172
  commands = new Map();
@@ -676,15 +750,20 @@ function createEditor(nu, opts) {
676
750
  }
677
751
 
678
752
  exports.BlockRegistry = BlockRegistry;
753
+ exports.CANVAS_CHANNEL = CANVAS_CHANNEL;
754
+ exports.CANVAS_PROTOCOL_VERSION = CANVAS_PROTOCOL_VERSION;
679
755
  exports.CommandRegistry = CommandRegistry;
680
756
  exports.DEFAULT_BLOCKS = DEFAULT_BLOCKS;
681
757
  exports.Editor = Editor;
682
758
  exports.canHaveChildren = canHaveChildren;
759
+ exports.canvasChannelName = canvasChannelName;
760
+ exports.createCanvasReceiver = createCanvasReceiver;
683
761
  exports.createEditor = createEditor;
684
762
  exports.defineBlock = defineBlock;
685
763
  exports.dropPositionFromPointer = dropPositionFromPointer;
686
764
  exports.el = el;
687
765
  exports.isAncestorOf = isAncestorOf;
766
+ exports.isCanvasMessage = isCanvasMessage;
688
767
  exports.isTextTemplate = isTextTemplate;
689
768
  exports.lit = lit;
690
769
  exports.templateChildren = templateChildren;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as t from '@nuforge/types';
2
- import { Nu } from '@nuforge/core';
2
+ import { Nu, NuExternalsFactory } from '@nuforge/core';
3
3
  import { MutationEntry } from '@nuforge/reactivity';
4
4
 
5
5
  interface PropSchemaField {
@@ -46,6 +46,104 @@ declare function textPreview(node: t.Template): string | null;
46
46
  declare function canHaveChildren(node: t.Template | null): boolean;
47
47
  declare function isAncestorOf(nu: Nu, ancestor: t.Template, node: t.Template): boolean;
48
48
 
49
+ declare const CANVAS_CHANNEL = "nuforge-canvas";
50
+ declare const CANVAS_PROTOCOL_VERSION = 1;
51
+ declare function canvasChannelName(src: string): string;
52
+ interface FlattenedState {
53
+ root: {
54
+ $$typeId: string;
55
+ };
56
+ types: Record<string, {
57
+ type: string;
58
+ [key: string]: unknown;
59
+ }>;
60
+ }
61
+ interface CanvasInitMessage {
62
+ channel: typeof CANVAS_CHANNEL;
63
+ v: typeof CANVAS_PROTOCOL_VERSION;
64
+ type: 'init';
65
+ activeComponent: string;
66
+ flat: FlattenedState;
67
+ }
68
+ interface CanvasPatchMessage {
69
+ channel: typeof CANVAS_CHANNEL;
70
+ v: typeof CANVAS_PROTOCOL_VERSION;
71
+ type: 'patch';
72
+ activeComponent: string;
73
+ flat: FlattenedState;
74
+ rev: number;
75
+ }
76
+ interface CanvasUiMessage {
77
+ channel: typeof CANVAS_CHANNEL;
78
+ v: typeof CANVAS_PROTOCOL_VERSION;
79
+ type: 'ui';
80
+ payload: unknown;
81
+ }
82
+ type CanvasHostMessage = CanvasInitMessage | CanvasPatchMessage | CanvasUiMessage;
83
+ interface CanvasReadyMessage {
84
+ channel: typeof CANVAS_CHANNEL;
85
+ v: typeof CANVAS_PROTOCOL_VERSION;
86
+ type: 'ready';
87
+ mountGen: number;
88
+ }
89
+ interface CanvasSelectMessage {
90
+ channel: typeof CANVAS_CHANNEL;
91
+ v: typeof CANVAS_PROTOCOL_VERSION;
92
+ type: 'select';
93
+ nodeId: string | null;
94
+ }
95
+ interface CanvasHoverMessage {
96
+ channel: typeof CANVAS_CHANNEL;
97
+ v: typeof CANVAS_PROTOCOL_VERSION;
98
+ type: 'hover';
99
+ nodeId: string | null;
100
+ }
101
+ interface CanvasDropMessage {
102
+ channel: typeof CANVAS_CHANNEL;
103
+ v: typeof CANVAS_PROTOCOL_VERSION;
104
+ type: 'drop';
105
+ targetId: string;
106
+ position: DropPosition;
107
+ }
108
+ interface CanvasKeydownMessage {
109
+ channel: typeof CANVAS_CHANNEL;
110
+ v: typeof CANVAS_PROTOCOL_VERSION;
111
+ type: 'keydown';
112
+ key: string;
113
+ code: string;
114
+ ctrlKey: boolean;
115
+ metaKey: boolean;
116
+ shiftKey: boolean;
117
+ altKey: boolean;
118
+ }
119
+ interface CanvasLinkClickMessage {
120
+ channel: typeof CANVAS_CHANNEL;
121
+ v: typeof CANVAS_PROTOCOL_VERSION;
122
+ type: 'link-click';
123
+ href: string;
124
+ }
125
+ type CanvasClientMessage = CanvasReadyMessage | CanvasSelectMessage | CanvasHoverMessage | CanvasDropMessage | CanvasKeydownMessage | CanvasLinkClickMessage;
126
+ type CanvasMessage = CanvasHostMessage | CanvasClientMessage;
127
+ type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
128
+ declare function isCanvasMessage(data: unknown): data is CanvasMessage;
129
+
130
+ interface CanvasReceiverOpts {
131
+ src: string;
132
+ externals?: NuExternalsFactory;
133
+ }
134
+ interface CanvasReceiver {
135
+ nu: Nu;
136
+ volatile: {
137
+ activeComponent: string | null;
138
+ loadGeneration: number;
139
+ };
140
+ postReady(): void;
141
+ onUi(cb: (payload: unknown) => void): () => void;
142
+ postEvent(message: DistributiveOmit<CanvasClientMessage, 'channel' | 'v'>): void;
143
+ dispose(): void;
144
+ }
145
+ declare function createCanvasReceiver(opts: CanvasReceiverOpts): CanvasReceiver;
146
+
49
147
  interface CreateEditorOptions {
50
148
  blocks?: Block[];
51
149
  defaultBlocks?: boolean;
@@ -124,5 +222,5 @@ declare class CommandRegistry {
124
222
  run(id: string): void;
125
223
  }
126
224
 
127
- export { BlockRegistry, CommandRegistry, DEFAULT_BLOCKS, Editor, canHaveChildren, createEditor, defineBlock, dropPositionFromPointer, el, isAncestorOf, isTextTemplate, lit, templateChildren, templateLabel, text, textPreview };
128
- export type { Block, Command, CreateEditorOptions, DropPosition, PropSchemaField };
225
+ export { BlockRegistry, CANVAS_CHANNEL, CANVAS_PROTOCOL_VERSION, CommandRegistry, DEFAULT_BLOCKS, Editor, canHaveChildren, canvasChannelName, createCanvasReceiver, createEditor, defineBlock, dropPositionFromPointer, el, isAncestorOf, isCanvasMessage, isTextTemplate, lit, templateChildren, templateLabel, text, textPreview };
226
+ export type { Block, CanvasClientMessage, CanvasDropMessage, CanvasHostMessage, CanvasHoverMessage, CanvasInitMessage, CanvasKeydownMessage, CanvasLinkClickMessage, CanvasMessage, CanvasPatchMessage, CanvasReadyMessage, CanvasReceiver, CanvasReceiverOpts, CanvasSelectMessage, CanvasUiMessage, Command, CreateEditorOptions, DistributiveOmit, DropPosition, FlattenedState, PropSchemaField };
package/dist/index.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import * as t from '@nuforge/types';
2
+ import { Nu } from '@nuforge/core';
3
+ import { reactive } from '@nuforge/reactivity';
2
4
  import { Parser } from '@nuforge/parser';
3
5
 
4
6
  function lit(value) {
@@ -72,6 +74,78 @@ const DEFAULT_BLOCKS = [
72
74
  },
73
75
  ];
74
76
 
77
+ const CANVAS_CHANNEL = 'nuforge-canvas';
78
+ const CANVAS_PROTOCOL_VERSION = 1;
79
+ function canvasChannelName(src) {
80
+ return `${CANVAS_CHANNEL}:${src}`;
81
+ }
82
+ function isCanvasMessage(data) {
83
+ return (!!data &&
84
+ typeof data === 'object' &&
85
+ data.channel === CANVAS_CHANNEL &&
86
+ data.v === CANVAS_PROTOCOL_VERSION);
87
+ }
88
+
89
+ function createCanvasReceiver(opts) {
90
+ const nu = Nu.create({ externals: opts.externals });
91
+ const volatile = reactive({ activeComponent: null, loadGeneration: 0 });
92
+ const channel = new BroadcastChannel(canvasChannelName(opts.src));
93
+ const uiListeners = new Set();
94
+ const applyState = (message) => {
95
+ if (message.type === 'init') {
96
+ nu.load(t.unflatten(message.flat));
97
+ volatile.loadGeneration++;
98
+ }
99
+ else {
100
+ nu.change(() => t.merge(nu.state, t.unflatten(message.flat), { arrayReconcile: 'id' }));
101
+ }
102
+ volatile.activeComponent = message.activeComponent;
103
+ };
104
+ const onMessage = (event) => {
105
+ if (!isCanvasMessage(event.data)) {
106
+ return;
107
+ }
108
+ const message = event.data;
109
+ if (message.type === 'init' || message.type === 'patch') {
110
+ applyState(message);
111
+ }
112
+ else if (message.type === 'ui') {
113
+ for (const cb of uiListeners) {
114
+ cb(message.payload);
115
+ }
116
+ }
117
+ };
118
+ channel.addEventListener('message', onMessage);
119
+ return {
120
+ nu,
121
+ volatile,
122
+ postReady() {
123
+ channel.postMessage({
124
+ channel: CANVAS_CHANNEL,
125
+ v: CANVAS_PROTOCOL_VERSION,
126
+ type: 'ready',
127
+ mountGen: Math.floor(Math.random() * 1e9),
128
+ });
129
+ },
130
+ onUi(cb) {
131
+ uiListeners.add(cb);
132
+ return () => uiListeners.delete(cb);
133
+ },
134
+ postEvent(message) {
135
+ channel.postMessage({
136
+ channel: CANVAS_CHANNEL,
137
+ v: CANVAS_PROTOCOL_VERSION,
138
+ ...message,
139
+ });
140
+ },
141
+ dispose() {
142
+ channel.removeEventListener('message', onMessage);
143
+ channel.close();
144
+ uiListeners.clear();
145
+ },
146
+ };
147
+ }
148
+
75
149
  class CommandRegistry {
76
150
  editor;
77
151
  commands = new Map();
@@ -654,4 +728,4 @@ function createEditor(nu, opts) {
654
728
  return new Editor(nu, opts);
655
729
  }
656
730
 
657
- export { BlockRegistry, CommandRegistry, DEFAULT_BLOCKS, Editor, canHaveChildren, createEditor, defineBlock, dropPositionFromPointer, el, isAncestorOf, isTextTemplate, lit, templateChildren, templateLabel, text, textPreview };
731
+ export { BlockRegistry, CANVAS_CHANNEL, CANVAS_PROTOCOL_VERSION, CommandRegistry, DEFAULT_BLOCKS, Editor, canHaveChildren, canvasChannelName, createCanvasReceiver, createEditor, defineBlock, dropPositionFromPointer, el, isAncestorOf, isCanvasMessage, isTextTemplate, lit, templateChildren, templateLabel, text, textPreview };
package/dist/react.cjs CHANGED
@@ -2,9 +2,21 @@
2
2
  'use strict';
3
3
 
4
4
  var react$1 = require('@nuforge/react');
5
+ var t = require('@nuforge/types');
5
6
  var react = require('react');
6
7
  var reactDom = require('react-dom');
7
- require('@nuforge/types');
8
+
9
+ const CANVAS_CHANNEL = 'nuforge-canvas';
10
+ const CANVAS_PROTOCOL_VERSION = 1;
11
+ function canvasChannelName(src) {
12
+ return `${CANVAS_CHANNEL}:${src}`;
13
+ }
14
+ function isCanvasMessage(data) {
15
+ return (!!data &&
16
+ typeof data === 'object' &&
17
+ data.channel === CANVAS_CHANNEL &&
18
+ data.v === CANVAS_PROTOCOL_VERSION);
19
+ }
8
20
 
9
21
  function dropPositionFromPointer(clientY, rect, opts = {}) {
10
22
  const ratio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0;
@@ -174,24 +186,14 @@ function IframeCanvas({ frame, selectedId = null, hoveredId = null, revision = 0
174
186
  setDrop(null);
175
187
  }
176
188
  }, [previewMode]);
177
- const rectOf = (id) => {
178
- if (!doc || !id) {
179
- return null;
180
- }
181
- 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) => {
182
191
  if (!el) {
183
192
  return null;
184
193
  }
185
194
  const r = el.getBoundingClientRect();
186
195
  return { top: r.top, left: r.left, width: r.width, height: r.height };
187
196
  };
188
- const labelOf = (id) => {
189
- if (!doc || !id) {
190
- return null;
191
- }
192
- const el = doc.querySelector(`[data-nu-id="${id}"]`);
193
- return el ? el.tagName.toLowerCase() : null;
194
- };
195
197
  react.useLayoutEffect(() => {
196
198
  if (previewMode) {
197
199
  setSelected(null);
@@ -199,9 +201,10 @@ function IframeCanvas({ frame, selectedId = null, hoveredId = null, revision = 0
199
201
  setSelectedLabel(null);
200
202
  return;
201
203
  }
202
- setSelected(rectOf(selectedId));
203
- setHovered(hoveredId && hoveredId !== selectedId ? rectOf(hoveredId) : null);
204
- setSelectedLabel(labelOf(selectedId));
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);
205
208
  }, [previewMode, selectedId, hoveredId, revision, tick, doc]);
206
209
  return react.createElement('div', { className: containerClassName, style: { position: 'relative' } }, react.createElement('iframe', { ref: iframeRef, title: 'nuforge preview', className, srcDoc }, doc?.body
207
210
  ? reactDom.createPortal(react.createElement(CanvasWindowContext.Provider, { value: { doc, window: doc.defaultView } }, react.createElement(EditorFrame, { frame })), doc.body)
@@ -216,6 +219,201 @@ function IframeCanvas({ frame, selectedId = null, hoveredId = null, revision = 0
216
219
  }, renderBox({ selected, hovered, selectedLabel, drop }))
217
220
  : null);
218
221
  }
222
+ function RemoteCanvasHost({ nu, activeComponent, src, selectedId = null, hoveredId = null, revision = 0, previewMode = false, uiPayload, onSelect, onHover, onDropOnNode, onKeydownRelay, onLinkClick, className, containerClassName, renderBox, }) {
223
+ const iframeRef = react.useRef(null);
224
+ const [doc, setDoc] = react.useState(null);
225
+ const [tick, setTick] = react.useState(0);
226
+ const [selected, setSelected] = react.useState(null);
227
+ const [hovered, setHovered] = react.useState(null);
228
+ const [selectedLabel, setSelectedLabel] = react.useState(null);
229
+ const props = react.useRef({
230
+ nu,
231
+ activeComponent,
232
+ uiPayload,
233
+ onSelect,
234
+ onHover,
235
+ onDropOnNode,
236
+ onKeydownRelay,
237
+ onLinkClick,
238
+ });
239
+ props.current = {
240
+ nu,
241
+ activeComponent,
242
+ uiPayload,
243
+ onSelect,
244
+ onHover,
245
+ onDropOnNode,
246
+ onKeydownRelay,
247
+ onLinkClick,
248
+ };
249
+ const connectionRef = react.useRef(null);
250
+ react.useEffect(() => {
251
+ const iframe = iframeRef.current;
252
+ if (!iframe) {
253
+ return;
254
+ }
255
+ const onLoad = () => setDoc(iframe.contentDocument);
256
+ iframe.addEventListener('load', onLoad);
257
+ return () => iframe.removeEventListener('load', onLoad);
258
+ }, [src]);
259
+ react.useEffect(() => {
260
+ if (!doc) {
261
+ return;
262
+ }
263
+ const onScroll = () => setTick((x) => x + 1);
264
+ doc.addEventListener('scroll', onScroll, true);
265
+ return () => doc.removeEventListener('scroll', onScroll, true);
266
+ }, [doc]);
267
+ react.useEffect(() => {
268
+ const iframe = iframeRef.current;
269
+ if (!iframe) {
270
+ return;
271
+ }
272
+ const observer = new ResizeObserver(() => setTick((x) => x + 1));
273
+ observer.observe(iframe);
274
+ if (doc?.documentElement) {
275
+ observer.observe(doc.documentElement);
276
+ }
277
+ return () => observer.disconnect();
278
+ }, [doc]);
279
+ react.useEffect(() => {
280
+ const channel = new BroadcastChannel(canvasChannelName(src));
281
+ let ready = false;
282
+ let mountGen = null;
283
+ let rafId = null;
284
+ let rev = 0;
285
+ const sendInit = () => {
286
+ channel.postMessage({
287
+ channel: CANVAS_CHANNEL,
288
+ v: CANVAS_PROTOCOL_VERSION,
289
+ type: 'init',
290
+ activeComponent: props.current.activeComponent,
291
+ flat: t.flatten(props.current.nu.state),
292
+ });
293
+ };
294
+ const requestPatch = () => {
295
+ if (!ready || rafId !== null) {
296
+ return;
297
+ }
298
+ rafId = requestAnimationFrame(() => {
299
+ rafId = null;
300
+ channel.postMessage({
301
+ channel: CANVAS_CHANNEL,
302
+ v: CANVAS_PROTOCOL_VERSION,
303
+ type: 'patch',
304
+ activeComponent: props.current.activeComponent,
305
+ flat: t.flatten(props.current.nu.state),
306
+ rev: ++rev,
307
+ });
308
+ });
309
+ };
310
+ const postUi = (payload) => {
311
+ channel.postMessage({
312
+ channel: CANVAS_CHANNEL,
313
+ v: CANVAS_PROTOCOL_VERSION,
314
+ type: 'ui',
315
+ payload,
316
+ });
317
+ };
318
+ connectionRef.current = { sendInit, requestPatch, postUi };
319
+ const onMessage = (event) => {
320
+ if (!isCanvasMessage(event.data)) {
321
+ return;
322
+ }
323
+ const message = event.data;
324
+ const p = props.current;
325
+ switch (message.type) {
326
+ case 'ready':
327
+ if (mountGen !== null && message.mountGen <= mountGen) {
328
+ return;
329
+ }
330
+ mountGen = message.mountGen;
331
+ ready = true;
332
+ sendInit();
333
+ postUi(props.current.uiPayload);
334
+ break;
335
+ case 'select':
336
+ p.onSelect?.(message.nodeId);
337
+ setTick((x) => x + 1);
338
+ break;
339
+ case 'hover':
340
+ p.onHover?.(message.nodeId);
341
+ setTick((x) => x + 1);
342
+ break;
343
+ case 'drop':
344
+ p.onDropOnNode?.(message.targetId, message.position);
345
+ break;
346
+ case 'keydown':
347
+ p.onKeydownRelay?.(message);
348
+ break;
349
+ case 'link-click':
350
+ p.onLinkClick?.(message.href);
351
+ break;
352
+ }
353
+ };
354
+ channel.addEventListener('message', onMessage);
355
+ const unlistenChangeset = nu.listenToChangeset(() => {
356
+ requestPatch();
357
+ setTick((x) => x + 1);
358
+ });
359
+ return () => {
360
+ channel.removeEventListener('message', onMessage);
361
+ channel.close();
362
+ unlistenChangeset();
363
+ if (rafId !== null) {
364
+ cancelAnimationFrame(rafId);
365
+ }
366
+ connectionRef.current = null;
367
+ };
368
+ }, [src, nu]);
369
+ const resyncKey = `${activeComponent}:${previewMode}`;
370
+ const lastResyncKey = react.useRef(null);
371
+ react.useEffect(() => {
372
+ if (lastResyncKey.current === resyncKey) {
373
+ return;
374
+ }
375
+ lastResyncKey.current = resyncKey;
376
+ connectionRef.current?.sendInit();
377
+ }, [resyncKey]);
378
+ react.useEffect(() => {
379
+ connectionRef.current?.postUi(uiPayload);
380
+ }, [uiPayload]);
381
+ const elementFor = (id) => doc && id ? doc.querySelector(`[data-nu-id="${id}"]`) : null;
382
+ const rectOfEl = (el) => {
383
+ if (!el) {
384
+ return null;
385
+ }
386
+ const r = el.getBoundingClientRect();
387
+ return { top: r.top, left: r.left, width: r.width, height: r.height };
388
+ };
389
+ react.useLayoutEffect(() => {
390
+ if (previewMode) {
391
+ setSelected(null);
392
+ setHovered(null);
393
+ setSelectedLabel(null);
394
+ return;
395
+ }
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);
400
+ }, [previewMode, selectedId, hoveredId, revision, tick, doc]);
401
+ return react.createElement('div', { className: containerClassName, style: { position: 'relative' } }, react.createElement('iframe', {
402
+ ref: iframeRef,
403
+ title: 'nuforge canvas',
404
+ className,
405
+ src,
406
+ }), renderBox && !previewMode
407
+ ? react.createElement('div', {
408
+ style: {
409
+ position: 'absolute',
410
+ inset: 0,
411
+ pointerEvents: 'none',
412
+ overflow: 'hidden',
413
+ },
414
+ }, renderBox({ selected, hovered, selectedLabel }))
415
+ : null);
416
+ }
219
417
  function useStructureRevision(editor) {
220
418
  const [rev, setRev] = react.useState(0);
221
419
  react.useEffect(() => editor.onStructureChange(() => setRev((r) => r + 1)), [editor]);
@@ -278,6 +476,7 @@ exports.DEFAULT_CANVAS_CSS = DEFAULT_CANVAS_CSS;
278
476
  exports.EditorFrame = EditorFrame;
279
477
  exports.IframeCanvas = IframeCanvas;
280
478
  exports.PropFields = PropFields;
479
+ exports.RemoteCanvasHost = RemoteCanvasHost;
281
480
  exports.templateIdFromElement = templateIdFromElement;
282
481
  exports.useCanvasWindow = useCanvasWindow;
283
482
  exports.useEditorRevision = useEditorRevision;
package/dist/react.d.ts CHANGED
@@ -27,6 +27,22 @@ declare class BlockRegistry {
27
27
  byCategory(): Record<string, Block[]>;
28
28
  }
29
29
 
30
+ type DropPosition = 'before' | 'after' | 'inside';
31
+
32
+ declare const CANVAS_CHANNEL = "nuforge-canvas";
33
+ declare const CANVAS_PROTOCOL_VERSION = 1;
34
+ interface CanvasKeydownMessage {
35
+ channel: typeof CANVAS_CHANNEL;
36
+ v: typeof CANVAS_PROTOCOL_VERSION;
37
+ type: 'keydown';
38
+ key: string;
39
+ code: string;
40
+ ctrlKey: boolean;
41
+ metaKey: boolean;
42
+ shiftKey: boolean;
43
+ altKey: boolean;
44
+ }
45
+
30
46
  interface Command {
31
47
  id: string;
32
48
  label: string;
@@ -43,8 +59,6 @@ declare class CommandRegistry {
43
59
  run(id: string): void;
44
60
  }
45
61
 
46
- type DropPosition = 'before' | 'after' | 'inside';
47
-
48
62
  interface CreateEditorOptions {
49
63
  blocks?: Block[];
50
64
  defaultBlocks?: boolean;
@@ -146,6 +160,29 @@ interface IframeCanvasProps {
146
160
  }) => ReactNode;
147
161
  }
148
162
  declare function IframeCanvas({ frame, selectedId, hoveredId, revision, previewMode, onSelect, onHover, onDropOnNode, baseCss, srcDoc, className, containerClassName, renderBox, }: IframeCanvasProps): ReactNode;
163
+ interface RemoteCanvasHostProps {
164
+ nu: Nu;
165
+ activeComponent: string;
166
+ src: string;
167
+ selectedId?: string | null;
168
+ hoveredId?: string | null;
169
+ revision?: number;
170
+ previewMode?: boolean;
171
+ uiPayload?: unknown;
172
+ onSelect?: (id: string | null) => void;
173
+ onHover?: (id: string | null) => void;
174
+ onDropOnNode?: (targetId: string, position: DropPosition) => void;
175
+ onKeydownRelay?: (e: Omit<CanvasKeydownMessage, 'channel' | 'v' | 'type'>) => void;
176
+ onLinkClick?: (href: string) => void;
177
+ className?: string;
178
+ containerClassName?: string;
179
+ renderBox?: (rects: {
180
+ selected: Rect | null;
181
+ hovered: Rect | null;
182
+ selectedLabel: string | null;
183
+ }) => ReactNode;
184
+ }
185
+ declare function RemoteCanvasHost({ nu, activeComponent, src, selectedId, hoveredId, revision, previewMode, uiPayload, onSelect, onHover, onDropOnNode, onKeydownRelay, onLinkClick, className, containerClassName, renderBox, }: RemoteCanvasHostProps): ReactNode;
149
186
  declare function useStructureRevision(editor: Pick<Editor, 'onStructureChange'>): number;
150
187
  declare function useEditorRevision(editor: Pick<Editor, 'onChange'>): number;
151
188
  type PropFieldValue = string | number | boolean | undefined;
@@ -157,5 +194,5 @@ interface PropFieldsProps {
157
194
  }
158
195
  declare function PropFields({ fields, values, onChange, className, }: PropFieldsProps): ReactNode;
159
196
 
160
- export { DEFAULT_CANVAS_CSS, EditorFrame, IframeCanvas, PropFields, templateIdFromElement, useCanvasWindow, useEditorRevision, useStructureRevision };
161
- export type { CanvasWindow, IframeCanvasProps, PropFieldValue, PropFieldsProps, Rect };
197
+ export { DEFAULT_CANVAS_CSS, EditorFrame, IframeCanvas, PropFields, RemoteCanvasHost, templateIdFromElement, useCanvasWindow, useEditorRevision, useStructureRevision };
198
+ export type { CanvasWindow, IframeCanvasProps, PropFieldValue, PropFieldsProps, Rect, RemoteCanvasHostProps };
package/dist/react.mjs CHANGED
@@ -1,8 +1,20 @@
1
1
  'use client';
2
2
  import { createViewElement, observer } from '@nuforge/react';
3
+ import { flatten } from '@nuforge/types';
3
4
  import { createContext, memo, createElement, Fragment, useRef, useState, useEffect, useLayoutEffect, useContext } from 'react';
4
5
  import { createPortal } from 'react-dom';
5
- import '@nuforge/types';
6
+
7
+ const CANVAS_CHANNEL = 'nuforge-canvas';
8
+ const CANVAS_PROTOCOL_VERSION = 1;
9
+ function canvasChannelName(src) {
10
+ return `${CANVAS_CHANNEL}:${src}`;
11
+ }
12
+ function isCanvasMessage(data) {
13
+ return (!!data &&
14
+ typeof data === 'object' &&
15
+ data.channel === CANVAS_CHANNEL &&
16
+ data.v === CANVAS_PROTOCOL_VERSION);
17
+ }
6
18
 
7
19
  function dropPositionFromPointer(clientY, rect, opts = {}) {
8
20
  const ratio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0;
@@ -172,24 +184,14 @@ function IframeCanvas({ frame, selectedId = null, hoveredId = null, revision = 0
172
184
  setDrop(null);
173
185
  }
174
186
  }, [previewMode]);
175
- const rectOf = (id) => {
176
- if (!doc || !id) {
177
- return null;
178
- }
179
- const el = doc.querySelector(`[data-nu-id="${id}"]`);
187
+ const elementFor = (id) => doc && id ? doc.querySelector(`[data-nu-id="${id}"]`) : null;
188
+ const rectOfEl = (el) => {
180
189
  if (!el) {
181
190
  return null;
182
191
  }
183
192
  const r = el.getBoundingClientRect();
184
193
  return { top: r.top, left: r.left, width: r.width, height: r.height };
185
194
  };
186
- const labelOf = (id) => {
187
- if (!doc || !id) {
188
- return null;
189
- }
190
- const el = doc.querySelector(`[data-nu-id="${id}"]`);
191
- return el ? el.tagName.toLowerCase() : null;
192
- };
193
195
  useLayoutEffect(() => {
194
196
  if (previewMode) {
195
197
  setSelected(null);
@@ -197,9 +199,10 @@ function IframeCanvas({ frame, selectedId = null, hoveredId = null, revision = 0
197
199
  setSelectedLabel(null);
198
200
  return;
199
201
  }
200
- setSelected(rectOf(selectedId));
201
- setHovered(hoveredId && hoveredId !== selectedId ? rectOf(hoveredId) : null);
202
- setSelectedLabel(labelOf(selectedId));
202
+ const selectedEl = elementFor(selectedId);
203
+ setSelected(rectOfEl(selectedEl));
204
+ setHovered(hoveredId && hoveredId !== selectedId ? rectOfEl(elementFor(hoveredId)) : null);
205
+ setSelectedLabel(selectedEl ? selectedEl.tagName.toLowerCase() : null);
203
206
  }, [previewMode, selectedId, hoveredId, revision, tick, doc]);
204
207
  return createElement('div', { className: containerClassName, style: { position: 'relative' } }, createElement('iframe', { ref: iframeRef, title: 'nuforge preview', className, srcDoc }, doc?.body
205
208
  ? createPortal(createElement(CanvasWindowContext.Provider, { value: { doc, window: doc.defaultView } }, createElement(EditorFrame, { frame })), doc.body)
@@ -214,6 +217,201 @@ function IframeCanvas({ frame, selectedId = null, hoveredId = null, revision = 0
214
217
  }, renderBox({ selected, hovered, selectedLabel, drop }))
215
218
  : null);
216
219
  }
220
+ function RemoteCanvasHost({ nu, activeComponent, src, selectedId = null, hoveredId = null, revision = 0, previewMode = false, uiPayload, onSelect, onHover, onDropOnNode, onKeydownRelay, onLinkClick, className, containerClassName, renderBox, }) {
221
+ const iframeRef = useRef(null);
222
+ const [doc, setDoc] = useState(null);
223
+ const [tick, setTick] = useState(0);
224
+ const [selected, setSelected] = useState(null);
225
+ const [hovered, setHovered] = useState(null);
226
+ const [selectedLabel, setSelectedLabel] = useState(null);
227
+ const props = useRef({
228
+ nu,
229
+ activeComponent,
230
+ uiPayload,
231
+ onSelect,
232
+ onHover,
233
+ onDropOnNode,
234
+ onKeydownRelay,
235
+ onLinkClick,
236
+ });
237
+ props.current = {
238
+ nu,
239
+ activeComponent,
240
+ uiPayload,
241
+ onSelect,
242
+ onHover,
243
+ onDropOnNode,
244
+ onKeydownRelay,
245
+ onLinkClick,
246
+ };
247
+ const connectionRef = useRef(null);
248
+ useEffect(() => {
249
+ const iframe = iframeRef.current;
250
+ if (!iframe) {
251
+ return;
252
+ }
253
+ const onLoad = () => setDoc(iframe.contentDocument);
254
+ iframe.addEventListener('load', onLoad);
255
+ return () => iframe.removeEventListener('load', onLoad);
256
+ }, [src]);
257
+ useEffect(() => {
258
+ if (!doc) {
259
+ return;
260
+ }
261
+ const onScroll = () => setTick((x) => x + 1);
262
+ doc.addEventListener('scroll', onScroll, true);
263
+ return () => doc.removeEventListener('scroll', onScroll, true);
264
+ }, [doc]);
265
+ useEffect(() => {
266
+ const iframe = iframeRef.current;
267
+ if (!iframe) {
268
+ return;
269
+ }
270
+ const observer = new ResizeObserver(() => setTick((x) => x + 1));
271
+ observer.observe(iframe);
272
+ if (doc?.documentElement) {
273
+ observer.observe(doc.documentElement);
274
+ }
275
+ return () => observer.disconnect();
276
+ }, [doc]);
277
+ useEffect(() => {
278
+ const channel = new BroadcastChannel(canvasChannelName(src));
279
+ let ready = false;
280
+ let mountGen = null;
281
+ let rafId = null;
282
+ let rev = 0;
283
+ const sendInit = () => {
284
+ channel.postMessage({
285
+ channel: CANVAS_CHANNEL,
286
+ v: CANVAS_PROTOCOL_VERSION,
287
+ type: 'init',
288
+ activeComponent: props.current.activeComponent,
289
+ flat: flatten(props.current.nu.state),
290
+ });
291
+ };
292
+ const requestPatch = () => {
293
+ if (!ready || rafId !== null) {
294
+ return;
295
+ }
296
+ rafId = requestAnimationFrame(() => {
297
+ rafId = null;
298
+ channel.postMessage({
299
+ channel: CANVAS_CHANNEL,
300
+ v: CANVAS_PROTOCOL_VERSION,
301
+ type: 'patch',
302
+ activeComponent: props.current.activeComponent,
303
+ flat: flatten(props.current.nu.state),
304
+ rev: ++rev,
305
+ });
306
+ });
307
+ };
308
+ const postUi = (payload) => {
309
+ channel.postMessage({
310
+ channel: CANVAS_CHANNEL,
311
+ v: CANVAS_PROTOCOL_VERSION,
312
+ type: 'ui',
313
+ payload,
314
+ });
315
+ };
316
+ connectionRef.current = { sendInit, requestPatch, postUi };
317
+ const onMessage = (event) => {
318
+ if (!isCanvasMessage(event.data)) {
319
+ return;
320
+ }
321
+ const message = event.data;
322
+ const p = props.current;
323
+ switch (message.type) {
324
+ case 'ready':
325
+ if (mountGen !== null && message.mountGen <= mountGen) {
326
+ return;
327
+ }
328
+ mountGen = message.mountGen;
329
+ ready = true;
330
+ sendInit();
331
+ postUi(props.current.uiPayload);
332
+ break;
333
+ case 'select':
334
+ p.onSelect?.(message.nodeId);
335
+ setTick((x) => x + 1);
336
+ break;
337
+ case 'hover':
338
+ p.onHover?.(message.nodeId);
339
+ setTick((x) => x + 1);
340
+ break;
341
+ case 'drop':
342
+ p.onDropOnNode?.(message.targetId, message.position);
343
+ break;
344
+ case 'keydown':
345
+ p.onKeydownRelay?.(message);
346
+ break;
347
+ case 'link-click':
348
+ p.onLinkClick?.(message.href);
349
+ break;
350
+ }
351
+ };
352
+ channel.addEventListener('message', onMessage);
353
+ const unlistenChangeset = nu.listenToChangeset(() => {
354
+ requestPatch();
355
+ setTick((x) => x + 1);
356
+ });
357
+ return () => {
358
+ channel.removeEventListener('message', onMessage);
359
+ channel.close();
360
+ unlistenChangeset();
361
+ if (rafId !== null) {
362
+ cancelAnimationFrame(rafId);
363
+ }
364
+ connectionRef.current = null;
365
+ };
366
+ }, [src, nu]);
367
+ const resyncKey = `${activeComponent}:${previewMode}`;
368
+ const lastResyncKey = useRef(null);
369
+ useEffect(() => {
370
+ if (lastResyncKey.current === resyncKey) {
371
+ return;
372
+ }
373
+ lastResyncKey.current = resyncKey;
374
+ connectionRef.current?.sendInit();
375
+ }, [resyncKey]);
376
+ useEffect(() => {
377
+ connectionRef.current?.postUi(uiPayload);
378
+ }, [uiPayload]);
379
+ const elementFor = (id) => doc && id ? doc.querySelector(`[data-nu-id="${id}"]`) : null;
380
+ const rectOfEl = (el) => {
381
+ if (!el) {
382
+ return null;
383
+ }
384
+ const r = el.getBoundingClientRect();
385
+ return { top: r.top, left: r.left, width: r.width, height: r.height };
386
+ };
387
+ useLayoutEffect(() => {
388
+ if (previewMode) {
389
+ setSelected(null);
390
+ setHovered(null);
391
+ setSelectedLabel(null);
392
+ return;
393
+ }
394
+ const selectedEl = elementFor(selectedId);
395
+ setSelected(rectOfEl(selectedEl));
396
+ setHovered(hoveredId && hoveredId !== selectedId ? rectOfEl(elementFor(hoveredId)) : null);
397
+ setSelectedLabel(selectedEl ? selectedEl.tagName.toLowerCase() : null);
398
+ }, [previewMode, selectedId, hoveredId, revision, tick, doc]);
399
+ return createElement('div', { className: containerClassName, style: { position: 'relative' } }, createElement('iframe', {
400
+ ref: iframeRef,
401
+ title: 'nuforge canvas',
402
+ className,
403
+ src,
404
+ }), renderBox && !previewMode
405
+ ? createElement('div', {
406
+ style: {
407
+ position: 'absolute',
408
+ inset: 0,
409
+ pointerEvents: 'none',
410
+ overflow: 'hidden',
411
+ },
412
+ }, renderBox({ selected, hovered, selectedLabel }))
413
+ : null);
414
+ }
217
415
  function useStructureRevision(editor) {
218
416
  const [rev, setRev] = useState(0);
219
417
  useEffect(() => editor.onStructureChange(() => setRev((r) => r + 1)), [editor]);
@@ -272,4 +470,4 @@ function PropFieldControl({ field, value, onChange, }) {
272
470
  return createElement('label', { htmlFor: id, 'data-nu-field': field.name }, createElement('span', null, label), control);
273
471
  }
274
472
 
275
- export { DEFAULT_CANVAS_CSS, EditorFrame, IframeCanvas, PropFields, templateIdFromElement, useCanvasWindow, useEditorRevision, useStructureRevision };
473
+ export { DEFAULT_CANVAS_CSS, EditorFrame, IframeCanvas, PropFields, RemoteCanvasHost, templateIdFromElement, useCanvasWindow, useEditorRevision, useStructureRevision };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuforge/editor",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "High-level SDK for building visual page editors on nuforge (mutations, blocks, commands, selection)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,10 +29,10 @@
29
29
  "peerDependencies": {
30
30
  "react": "^18 || ^19",
31
31
  "react-dom": "^18 || ^19",
32
- "@nuforge/core": "0.1.4",
33
- "@nuforge/parser": "0.1.3",
34
- "@nuforge/react": "0.1.4",
35
- "@nuforge/types": "0.1.3"
32
+ "@nuforge/core": "0.1.5",
33
+ "@nuforge/react": "0.1.5",
34
+ "@nuforge/types": "0.1.4",
35
+ "@nuforge/parser": "0.1.4"
36
36
  },
37
37
  "peerDependenciesMeta": {
38
38
  "@nuforge/react": {
@@ -50,10 +50,10 @@
50
50
  "@types/react-dom": "^19.2.0",
51
51
  "react": "^19.2.0",
52
52
  "react-dom": "^19.2.0",
53
- "@nuforge/core": "0.1.4",
54
- "@nuforge/parser": "0.1.3",
55
- "@nuforge/react": "0.1.4",
56
- "@nuforge/types": "0.1.3"
53
+ "@nuforge/core": "0.1.5",
54
+ "@nuforge/react": "0.1.5",
55
+ "@nuforge/parser": "0.1.4",
56
+ "@nuforge/types": "0.1.4"
57
57
  },
58
58
  "publishConfig": {
59
59
  "access": "public"