@nuforge/editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.cjs ADDED
@@ -0,0 +1,259 @@
1
+ 'use client';
2
+ 'use strict';
3
+
4
+ var react$1 = require('@nuforge/react');
5
+ var react = require('react');
6
+ var reactDom = require('react-dom');
7
+ require('@nuforge/types');
8
+
9
+ function dropPositionFromPointer(clientY, rect, opts = {}) {
10
+ const ratio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0;
11
+ if (opts.allowInside) {
12
+ if (ratio < 0.25)
13
+ return 'before';
14
+ if (ratio > 0.75)
15
+ return 'after';
16
+ return 'inside';
17
+ }
18
+ return ratio < 0.5 ? 'before' : 'after';
19
+ }
20
+
21
+ const decorate = (view) => ({ 'data-nu-id': view.template?.id });
22
+ const EditorView = react.memo(function EditorView({ view, }) {
23
+ const renderChildren = (views) => views.map((child) => react.createElement(EditorView, { key: child.key, view: child }));
24
+ return react$1.createViewElement(view, renderChildren, decorate);
25
+ });
26
+ const EditorFrame = react$1.observer(function EditorFrame({ frame }) {
27
+ return react.createElement(react.Fragment, null, frame.view.children.map((view) => react.createElement(EditorView, { key: view.key, view })));
28
+ });
29
+ function templateIdFromElement(target) {
30
+ return (target
31
+ ?.closest('[data-nu-id]')
32
+ ?.getAttribute('data-nu-id') ?? null);
33
+ }
34
+ const DEFAULT_CANVAS_CSS = `
35
+ *, *::before, *::after { box-sizing: border-box; }
36
+ html, body { margin: 0; }
37
+ body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; color: #111; line-height: 1.5; padding: 28px; }
38
+ h1 { font-size: 2rem; font-weight: 700; margin: .5em 0; }
39
+ h2 { font-size: 1.5rem; font-weight: 600; margin: .6em 0; }
40
+ h3 { font-size: 1.2rem; font-weight: 600; margin: .7em 0; }
41
+ p { margin: .8em 0; }
42
+ ul { list-style: disc; padding-left: 1.5em; margin: .8em 0; }
43
+ ol { list-style: decimal; padding-left: 1.5em; margin: .8em 0; }
44
+ a { color: #2563eb; text-decoration: underline; }
45
+ button { border: 1px solid #d4d4d8; border-radius: 6px; padding: 4px 12px; background: #fafafa; cursor: pointer; font: inherit; }
46
+ button:hover { background: #f4f4f5; }
47
+ input, textarea, select { border: 1px solid #d4d4d8; border-radius: 6px; padding: 4px 8px; font: inherit; }
48
+ section { display: flex; align-items: center; gap: .75rem; margin: .8em 0; }
49
+ img { max-width: 100%; }
50
+ [data-nu-id] { cursor: default; }
51
+ `;
52
+ function IframeCanvas({ frame, selectedId = null, hoveredId = null, revision = 0, onSelect, onHover, onDropOnNode, baseCss = DEFAULT_CANVAS_CSS, srcDoc, className, containerClassName, renderBox, }) {
53
+ const iframeRef = react.useRef(null);
54
+ const [doc, setDoc] = react.useState(null);
55
+ const [tick, setTick] = react.useState(0);
56
+ const [selected, setSelected] = react.useState(null);
57
+ const [hovered, setHovered] = react.useState(null);
58
+ const [selectedLabel, setSelectedLabel] = react.useState(null);
59
+ const [drop, setDrop] = react.useState(null);
60
+ react.useEffect(() => {
61
+ const iframe = iframeRef.current;
62
+ if (!iframe) {
63
+ return;
64
+ }
65
+ const setup = () => {
66
+ const d = iframe.contentDocument;
67
+ if (!d?.body) {
68
+ return;
69
+ }
70
+ if (!d.getElementById('nu-base-style')) {
71
+ const style = d.createElement('style');
72
+ style.id = 'nu-base-style';
73
+ style.textContent = baseCss;
74
+ d.head.appendChild(style);
75
+ }
76
+ setDoc(d);
77
+ };
78
+ setup();
79
+ iframe.addEventListener('load', setup);
80
+ return () => iframe.removeEventListener('load', setup);
81
+ }, [baseCss]);
82
+ react.useEffect(() => {
83
+ if (!doc) {
84
+ return;
85
+ }
86
+ const onClick = (e) => {
87
+ e.preventDefault();
88
+ e.stopPropagation();
89
+ onSelect?.(templateIdFromElement(e.target));
90
+ };
91
+ const onMove = (e) => onHover?.(templateIdFromElement(e.target));
92
+ const onLeave = () => onHover?.(null);
93
+ const onScroll = () => setTick((x) => x + 1);
94
+ doc.addEventListener('click', onClick, true);
95
+ doc.addEventListener('mousemove', onMove);
96
+ doc.addEventListener('mouseleave', onLeave);
97
+ doc.addEventListener('scroll', onScroll, true);
98
+ return () => {
99
+ doc.removeEventListener('click', onClick, true);
100
+ doc.removeEventListener('mousemove', onMove);
101
+ doc.removeEventListener('mouseleave', onLeave);
102
+ doc.removeEventListener('scroll', onScroll, true);
103
+ };
104
+ }, [doc, onSelect, onHover]);
105
+ react.useEffect(() => {
106
+ if (!doc || !onDropOnNode) {
107
+ return;
108
+ }
109
+ const rectFor = (id) => {
110
+ const el = doc.querySelector(`[data-nu-id="${id}"]`);
111
+ return el?.getBoundingClientRect() ?? null;
112
+ };
113
+ const onDragOver = (ev) => {
114
+ const e = ev;
115
+ const id = templateIdFromElement(e.target);
116
+ const r = id ? rectFor(id) : null;
117
+ if (!id || !r) {
118
+ setDrop(null);
119
+ return;
120
+ }
121
+ e.preventDefault();
122
+ setDrop({
123
+ rect: { top: r.top, left: r.left, width: r.width, height: r.height },
124
+ position: dropPositionFromPointer(e.clientY, r, { allowInside: true }),
125
+ });
126
+ };
127
+ const onDropEvent = (ev) => {
128
+ const e = ev;
129
+ e.preventDefault();
130
+ const id = templateIdFromElement(e.target);
131
+ const r = id ? rectFor(id) : null;
132
+ if (id && r) {
133
+ onDropOnNode(id, dropPositionFromPointer(e.clientY, r, { allowInside: true }), e);
134
+ }
135
+ setDrop(null);
136
+ };
137
+ const onDragLeave = () => setDrop(null);
138
+ doc.addEventListener('dragover', onDragOver);
139
+ doc.addEventListener('drop', onDropEvent);
140
+ doc.addEventListener('dragleave', onDragLeave);
141
+ return () => {
142
+ doc.removeEventListener('dragover', onDragOver);
143
+ doc.removeEventListener('drop', onDropEvent);
144
+ doc.removeEventListener('dragleave', onDragLeave);
145
+ };
146
+ }, [doc, onDropOnNode]);
147
+ react.useEffect(() => {
148
+ const iframe = iframeRef.current;
149
+ if (!iframe) {
150
+ return;
151
+ }
152
+ const observer = new ResizeObserver(() => setTick((x) => x + 1));
153
+ observer.observe(iframe);
154
+ if (doc?.documentElement) {
155
+ observer.observe(doc.documentElement);
156
+ }
157
+ return () => observer.disconnect();
158
+ }, [doc]);
159
+ const rectOf = (id) => {
160
+ if (!doc || !id) {
161
+ return null;
162
+ }
163
+ const el = doc.querySelector(`[data-nu-id="${id}"]`);
164
+ if (!el) {
165
+ return null;
166
+ }
167
+ const r = el.getBoundingClientRect();
168
+ return { top: r.top, left: r.left, width: r.width, height: r.height };
169
+ };
170
+ const labelOf = (id) => {
171
+ if (!doc || !id) {
172
+ return null;
173
+ }
174
+ const el = doc.querySelector(`[data-nu-id="${id}"]`);
175
+ return el ? el.tagName.toLowerCase() : null;
176
+ };
177
+ react.useLayoutEffect(() => {
178
+ setSelected(rectOf(selectedId));
179
+ setHovered(hoveredId && hoveredId !== selectedId ? rectOf(hoveredId) : null);
180
+ setSelectedLabel(labelOf(selectedId));
181
+ }, [selectedId, hoveredId, revision, tick, doc]);
182
+ return react.createElement('div', { className: containerClassName, style: { position: 'relative' } }, react.createElement('iframe', { ref: iframeRef, title: 'nuforge preview', className, srcDoc }, doc?.body
183
+ ? reactDom.createPortal(react.createElement(EditorFrame, { frame }), doc.body)
184
+ : null), renderBox
185
+ ? react.createElement('div', {
186
+ style: {
187
+ position: 'absolute',
188
+ inset: 0,
189
+ pointerEvents: 'none',
190
+ overflow: 'hidden',
191
+ },
192
+ }, renderBox({ selected, hovered, selectedLabel, drop }))
193
+ : null);
194
+ }
195
+ function useStructureRevision(editor) {
196
+ const [rev, setRev] = react.useState(0);
197
+ react.useEffect(() => editor.onStructureChange(() => setRev((r) => r + 1)), [editor]);
198
+ return rev;
199
+ }
200
+ function useEditorRevision(editor) {
201
+ const [rev, setRev] = react.useState(0);
202
+ react.useEffect(() => editor.onChange(() => setRev((r) => r + 1)), [editor]);
203
+ return rev;
204
+ }
205
+ function PropFields({ fields, values, onChange, className, }) {
206
+ return react.createElement('div', { className, 'data-nu-propfields': '' }, fields.map((field) => react.createElement(PropFieldControl, {
207
+ key: field.name,
208
+ field,
209
+ value: values[field.name],
210
+ onChange,
211
+ })));
212
+ }
213
+ function PropFieldControl({ field, value, onChange, }) {
214
+ const id = `nu-field-${field.name}`;
215
+ const label = field.label ?? field.name;
216
+ let control;
217
+ switch (field.type) {
218
+ case 'boolean':
219
+ control = react.createElement('input', {
220
+ id,
221
+ type: 'checkbox',
222
+ checked: Boolean(value),
223
+ onChange: (e) => onChange(field.name, e.target.checked),
224
+ });
225
+ break;
226
+ case 'number':
227
+ control = react.createElement('input', {
228
+ id,
229
+ type: 'number',
230
+ value: value === undefined ? '' : Number(value),
231
+ onChange: (e) => onChange(field.name, e.target.valueAsNumber),
232
+ });
233
+ break;
234
+ case 'enum':
235
+ control = react.createElement('select', {
236
+ id,
237
+ value: value === undefined ? '' : String(value),
238
+ onChange: (e) => onChange(field.name, e.target.value),
239
+ }, (field.options ?? []).map((opt) => react.createElement('option', { key: opt, value: opt }, opt)));
240
+ break;
241
+ default:
242
+ control = react.createElement('input', {
243
+ id,
244
+ type: 'text',
245
+ value: value === undefined ? '' : String(value),
246
+ placeholder: field.type === 'expression' ? 'expression' : undefined,
247
+ onChange: (e) => onChange(field.name, e.target.value),
248
+ });
249
+ }
250
+ return react.createElement('label', { htmlFor: id, 'data-nu-field': field.name }, react.createElement('span', null, label), control);
251
+ }
252
+
253
+ exports.DEFAULT_CANVAS_CSS = DEFAULT_CANVAS_CSS;
254
+ exports.EditorFrame = EditorFrame;
255
+ exports.IframeCanvas = IframeCanvas;
256
+ exports.PropFields = PropFields;
257
+ exports.templateIdFromElement = templateIdFromElement;
258
+ exports.useEditorRevision = useEditorRevision;
259
+ exports.useStructureRevision = useStructureRevision;
@@ -0,0 +1,155 @@
1
+ import { Nu, Frame } from '@nuforge/core';
2
+ import { ReactNode } from 'react';
3
+ import * as t from '@nuforge/types';
4
+ import { MutationEntry } from '@nuforge/reactivity';
5
+
6
+ interface PropSchemaField {
7
+ name: string;
8
+ type: 'string' | 'number' | 'boolean' | 'expression' | 'enum';
9
+ label?: string;
10
+ default?: string | number | boolean;
11
+ options?: string[];
12
+ }
13
+ interface Block {
14
+ id: string;
15
+ label: string;
16
+ icon?: string;
17
+ category?: string;
18
+ create: () => t.Template;
19
+ props?: PropSchemaField[];
20
+ }
21
+ declare class BlockRegistry {
22
+ private readonly blocks;
23
+ constructor(initial?: Block[]);
24
+ register(block: Block): void;
25
+ get(id: string): Block | undefined;
26
+ all(): Block[];
27
+ byCategory(): Record<string, Block[]>;
28
+ }
29
+
30
+ interface Command {
31
+ id: string;
32
+ label: string;
33
+ shortcut?: string;
34
+ run: (editor: Editor) => void;
35
+ }
36
+ declare class CommandRegistry {
37
+ private readonly editor;
38
+ private readonly commands;
39
+ constructor(editor: Editor);
40
+ register(command: Command): void;
41
+ get(id: string): Command | undefined;
42
+ all(): Command[];
43
+ run(id: string): void;
44
+ }
45
+
46
+ type DropPosition = 'before' | 'after' | 'inside';
47
+
48
+ interface CreateEditorOptions {
49
+ blocks?: Block[];
50
+ defaultBlocks?: boolean;
51
+ }
52
+ declare class Editor {
53
+ readonly nu: Nu;
54
+ readonly blocks: BlockRegistry;
55
+ readonly commands: CommandRegistry;
56
+ constructor(nu: Nu, opts?: CreateEditorOptions);
57
+ insertNode(parent: t.Template, child: t.Template, index?: number): void;
58
+ removeNode(node: t.Template): boolean;
59
+ moveNode(dragged: t.Template, target: t.Template, position: DropPosition): boolean;
60
+ private clipboardNode;
61
+ copyNode(node: t.Template): void;
62
+ cutNode(node: t.Template): boolean;
63
+ canPaste(): boolean;
64
+ pasteNode(target: t.Template, position?: DropPosition): t.Template | null;
65
+ duplicateNode(node: t.Template): t.Template | null;
66
+ insertNodeAt(node: t.Template, target: t.Template, position: DropPosition): t.Template | null;
67
+ insertBlockAt(blockId: string, target: t.Template, position: DropPosition): t.Template | null;
68
+ private insertCloneRelativeTo;
69
+ updateProps(node: t.Template, props: Record<string, t.Expression>): void;
70
+ setProp(node: t.Template, key: string, expr: t.Expression): void;
71
+ removeProp(node: t.Template, key: string): void;
72
+ setExpression(node: t.Template, key: string, source: string): boolean;
73
+ setText(node: t.Template, value: string): boolean;
74
+ setTag(node: t.TagTemplate, tag: string): void;
75
+ addClass(node: t.Template, name: string): void;
76
+ setClassCondition(node: t.Template, name: string, source: string): boolean;
77
+ removeClass(node: t.Template, name: string): void;
78
+ setCondition(node: t.Template, source: string): boolean;
79
+ clearCondition(node: t.Template): void;
80
+ setEach(node: t.Template, iterator: string, alias?: string, indexName?: string): boolean;
81
+ clearEach(node: t.Template): void;
82
+ addState(component: t.NuComponent, name?: string, init?: string): t.Val;
83
+ removeState(component: t.NuComponent, val: t.Val): void;
84
+ setStateInit(val: t.Val, source: string): boolean;
85
+ addComponentProp(component: t.NuComponent, name?: string, init?: string): t.ComponentProp;
86
+ removeComponentProp(component: t.NuComponent, prop: t.ComponentProp): void;
87
+ setComponentPropInit(prop: t.ComponentProp, source: string): boolean;
88
+ insertBlock(blockId: string, parent: t.Template, index?: number): t.Template | null;
89
+ private uniqueName;
90
+ addComponent(name?: string): t.NuComponent;
91
+ renameComponent(component: t.NuComponent, name: string): boolean;
92
+ removeComponent(component: t.NuComponent): boolean;
93
+ duplicateComponent(component: t.NuComponent): t.NuComponent;
94
+ private walkTemplates;
95
+ getComponents(): t.NuComponent[];
96
+ getComponent(name: string): t.NuComponent | undefined;
97
+ getRootTemplate(name: string): t.Template | null;
98
+ getChildren(node: t.Template): t.Template[];
99
+ getSiblings(node: t.Template): t.Template[];
100
+ getNodeIndex(node: t.Template): number;
101
+ getNodePath(node: t.Type): Array<string | number>;
102
+ onChange(cb: (entries: MutationEntry[]) => void): () => void;
103
+ onStructureChange(cb: () => void): () => void;
104
+ private spliceFromParent;
105
+ private applyMove;
106
+ private insertRelativeTo;
107
+ }
108
+
109
+ interface Rect {
110
+ top: number;
111
+ left: number;
112
+ width: number;
113
+ height: number;
114
+ }
115
+ declare const EditorFrame: (props: {
116
+ frame: Frame;
117
+ }) => ReactNode;
118
+ declare function templateIdFromElement(target: EventTarget | null): string | null;
119
+ 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";
120
+ interface IframeCanvasProps {
121
+ frame: Frame;
122
+ selectedId?: string | null;
123
+ hoveredId?: string | null;
124
+ revision?: number;
125
+ onSelect?: (id: string | null) => void;
126
+ onHover?: (id: string | null) => void;
127
+ onDropOnNode?: (targetId: string, position: DropPosition, event: DragEvent) => void;
128
+ baseCss?: string;
129
+ srcDoc?: string;
130
+ className?: string;
131
+ containerClassName?: string;
132
+ renderBox?: (rects: {
133
+ selected: Rect | null;
134
+ hovered: Rect | null;
135
+ selectedLabel: string | null;
136
+ drop: {
137
+ rect: Rect;
138
+ position: DropPosition;
139
+ } | null;
140
+ }) => ReactNode;
141
+ }
142
+ declare function IframeCanvas({ frame, selectedId, hoveredId, revision, onSelect, onHover, onDropOnNode, baseCss, srcDoc, className, containerClassName, renderBox, }: IframeCanvasProps): ReactNode;
143
+ declare function useStructureRevision(editor: Pick<Editor, 'onStructureChange'>): number;
144
+ declare function useEditorRevision(editor: Pick<Editor, 'onChange'>): number;
145
+ type PropFieldValue = string | number | boolean | undefined;
146
+ interface PropFieldsProps {
147
+ fields: PropSchemaField[];
148
+ values: Record<string, PropFieldValue>;
149
+ onChange: (name: string, value: string | number | boolean) => void;
150
+ className?: string;
151
+ }
152
+ declare function PropFields({ fields, values, onChange, className, }: PropFieldsProps): ReactNode;
153
+
154
+ export { DEFAULT_CANVAS_CSS, EditorFrame, IframeCanvas, PropFields, templateIdFromElement, useEditorRevision, useStructureRevision };
155
+ export type { IframeCanvasProps, PropFieldValue, PropFieldsProps, Rect };
package/dist/react.mjs ADDED
@@ -0,0 +1,251 @@
1
+ 'use client';
2
+ import { createViewElement, observer } from '@nuforge/react';
3
+ import { memo, createElement, Fragment, useRef, useState, useEffect, useLayoutEffect } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import '@nuforge/types';
6
+
7
+ function dropPositionFromPointer(clientY, rect, opts = {}) {
8
+ const ratio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0;
9
+ if (opts.allowInside) {
10
+ if (ratio < 0.25)
11
+ return 'before';
12
+ if (ratio > 0.75)
13
+ return 'after';
14
+ return 'inside';
15
+ }
16
+ return ratio < 0.5 ? 'before' : 'after';
17
+ }
18
+
19
+ const decorate = (view) => ({ 'data-nu-id': view.template?.id });
20
+ const EditorView = memo(function EditorView({ view, }) {
21
+ const renderChildren = (views) => views.map((child) => createElement(EditorView, { key: child.key, view: child }));
22
+ return createViewElement(view, renderChildren, decorate);
23
+ });
24
+ const EditorFrame = observer(function EditorFrame({ frame }) {
25
+ return createElement(Fragment, null, frame.view.children.map((view) => createElement(EditorView, { key: view.key, view })));
26
+ });
27
+ function templateIdFromElement(target) {
28
+ return (target
29
+ ?.closest('[data-nu-id]')
30
+ ?.getAttribute('data-nu-id') ?? null);
31
+ }
32
+ const DEFAULT_CANVAS_CSS = `
33
+ *, *::before, *::after { box-sizing: border-box; }
34
+ html, body { margin: 0; }
35
+ body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; color: #111; line-height: 1.5; padding: 28px; }
36
+ h1 { font-size: 2rem; font-weight: 700; margin: .5em 0; }
37
+ h2 { font-size: 1.5rem; font-weight: 600; margin: .6em 0; }
38
+ h3 { font-size: 1.2rem; font-weight: 600; margin: .7em 0; }
39
+ p { margin: .8em 0; }
40
+ ul { list-style: disc; padding-left: 1.5em; margin: .8em 0; }
41
+ ol { list-style: decimal; padding-left: 1.5em; margin: .8em 0; }
42
+ a { color: #2563eb; text-decoration: underline; }
43
+ button { border: 1px solid #d4d4d8; border-radius: 6px; padding: 4px 12px; background: #fafafa; cursor: pointer; font: inherit; }
44
+ button:hover { background: #f4f4f5; }
45
+ input, textarea, select { border: 1px solid #d4d4d8; border-radius: 6px; padding: 4px 8px; font: inherit; }
46
+ section { display: flex; align-items: center; gap: .75rem; margin: .8em 0; }
47
+ img { max-width: 100%; }
48
+ [data-nu-id] { cursor: default; }
49
+ `;
50
+ function IframeCanvas({ frame, selectedId = null, hoveredId = null, revision = 0, onSelect, onHover, onDropOnNode, baseCss = DEFAULT_CANVAS_CSS, srcDoc, className, containerClassName, renderBox, }) {
51
+ const iframeRef = useRef(null);
52
+ const [doc, setDoc] = useState(null);
53
+ const [tick, setTick] = useState(0);
54
+ const [selected, setSelected] = useState(null);
55
+ const [hovered, setHovered] = useState(null);
56
+ const [selectedLabel, setSelectedLabel] = useState(null);
57
+ const [drop, setDrop] = useState(null);
58
+ useEffect(() => {
59
+ const iframe = iframeRef.current;
60
+ if (!iframe) {
61
+ return;
62
+ }
63
+ const setup = () => {
64
+ const d = iframe.contentDocument;
65
+ if (!d?.body) {
66
+ return;
67
+ }
68
+ if (!d.getElementById('nu-base-style')) {
69
+ const style = d.createElement('style');
70
+ style.id = 'nu-base-style';
71
+ style.textContent = baseCss;
72
+ d.head.appendChild(style);
73
+ }
74
+ setDoc(d);
75
+ };
76
+ setup();
77
+ iframe.addEventListener('load', setup);
78
+ return () => iframe.removeEventListener('load', setup);
79
+ }, [baseCss]);
80
+ useEffect(() => {
81
+ if (!doc) {
82
+ return;
83
+ }
84
+ const onClick = (e) => {
85
+ e.preventDefault();
86
+ e.stopPropagation();
87
+ onSelect?.(templateIdFromElement(e.target));
88
+ };
89
+ const onMove = (e) => onHover?.(templateIdFromElement(e.target));
90
+ const onLeave = () => onHover?.(null);
91
+ const onScroll = () => setTick((x) => x + 1);
92
+ doc.addEventListener('click', onClick, true);
93
+ doc.addEventListener('mousemove', onMove);
94
+ doc.addEventListener('mouseleave', onLeave);
95
+ doc.addEventListener('scroll', onScroll, true);
96
+ return () => {
97
+ doc.removeEventListener('click', onClick, true);
98
+ doc.removeEventListener('mousemove', onMove);
99
+ doc.removeEventListener('mouseleave', onLeave);
100
+ doc.removeEventListener('scroll', onScroll, true);
101
+ };
102
+ }, [doc, onSelect, onHover]);
103
+ useEffect(() => {
104
+ if (!doc || !onDropOnNode) {
105
+ return;
106
+ }
107
+ const rectFor = (id) => {
108
+ const el = doc.querySelector(`[data-nu-id="${id}"]`);
109
+ return el?.getBoundingClientRect() ?? null;
110
+ };
111
+ const onDragOver = (ev) => {
112
+ const e = ev;
113
+ const id = templateIdFromElement(e.target);
114
+ const r = id ? rectFor(id) : null;
115
+ if (!id || !r) {
116
+ setDrop(null);
117
+ return;
118
+ }
119
+ e.preventDefault();
120
+ setDrop({
121
+ rect: { top: r.top, left: r.left, width: r.width, height: r.height },
122
+ position: dropPositionFromPointer(e.clientY, r, { allowInside: true }),
123
+ });
124
+ };
125
+ const onDropEvent = (ev) => {
126
+ const e = ev;
127
+ e.preventDefault();
128
+ const id = templateIdFromElement(e.target);
129
+ const r = id ? rectFor(id) : null;
130
+ if (id && r) {
131
+ onDropOnNode(id, dropPositionFromPointer(e.clientY, r, { allowInside: true }), e);
132
+ }
133
+ setDrop(null);
134
+ };
135
+ const onDragLeave = () => setDrop(null);
136
+ doc.addEventListener('dragover', onDragOver);
137
+ doc.addEventListener('drop', onDropEvent);
138
+ doc.addEventListener('dragleave', onDragLeave);
139
+ return () => {
140
+ doc.removeEventListener('dragover', onDragOver);
141
+ doc.removeEventListener('drop', onDropEvent);
142
+ doc.removeEventListener('dragleave', onDragLeave);
143
+ };
144
+ }, [doc, onDropOnNode]);
145
+ useEffect(() => {
146
+ const iframe = iframeRef.current;
147
+ if (!iframe) {
148
+ return;
149
+ }
150
+ const observer = new ResizeObserver(() => setTick((x) => x + 1));
151
+ observer.observe(iframe);
152
+ if (doc?.documentElement) {
153
+ observer.observe(doc.documentElement);
154
+ }
155
+ return () => observer.disconnect();
156
+ }, [doc]);
157
+ const rectOf = (id) => {
158
+ if (!doc || !id) {
159
+ return null;
160
+ }
161
+ const el = doc.querySelector(`[data-nu-id="${id}"]`);
162
+ if (!el) {
163
+ return null;
164
+ }
165
+ const r = el.getBoundingClientRect();
166
+ return { top: r.top, left: r.left, width: r.width, height: r.height };
167
+ };
168
+ const labelOf = (id) => {
169
+ if (!doc || !id) {
170
+ return null;
171
+ }
172
+ const el = doc.querySelector(`[data-nu-id="${id}"]`);
173
+ return el ? el.tagName.toLowerCase() : null;
174
+ };
175
+ useLayoutEffect(() => {
176
+ setSelected(rectOf(selectedId));
177
+ setHovered(hoveredId && hoveredId !== selectedId ? rectOf(hoveredId) : null);
178
+ setSelectedLabel(labelOf(selectedId));
179
+ }, [selectedId, hoveredId, revision, tick, doc]);
180
+ return createElement('div', { className: containerClassName, style: { position: 'relative' } }, createElement('iframe', { ref: iframeRef, title: 'nuforge preview', className, srcDoc }, doc?.body
181
+ ? createPortal(createElement(EditorFrame, { frame }), doc.body)
182
+ : null), renderBox
183
+ ? createElement('div', {
184
+ style: {
185
+ position: 'absolute',
186
+ inset: 0,
187
+ pointerEvents: 'none',
188
+ overflow: 'hidden',
189
+ },
190
+ }, renderBox({ selected, hovered, selectedLabel, drop }))
191
+ : null);
192
+ }
193
+ function useStructureRevision(editor) {
194
+ const [rev, setRev] = useState(0);
195
+ useEffect(() => editor.onStructureChange(() => setRev((r) => r + 1)), [editor]);
196
+ return rev;
197
+ }
198
+ function useEditorRevision(editor) {
199
+ const [rev, setRev] = useState(0);
200
+ useEffect(() => editor.onChange(() => setRev((r) => r + 1)), [editor]);
201
+ return rev;
202
+ }
203
+ function PropFields({ fields, values, onChange, className, }) {
204
+ return createElement('div', { className, 'data-nu-propfields': '' }, fields.map((field) => createElement(PropFieldControl, {
205
+ key: field.name,
206
+ field,
207
+ value: values[field.name],
208
+ onChange,
209
+ })));
210
+ }
211
+ function PropFieldControl({ field, value, onChange, }) {
212
+ const id = `nu-field-${field.name}`;
213
+ const label = field.label ?? field.name;
214
+ let control;
215
+ switch (field.type) {
216
+ case 'boolean':
217
+ control = createElement('input', {
218
+ id,
219
+ type: 'checkbox',
220
+ checked: Boolean(value),
221
+ onChange: (e) => onChange(field.name, e.target.checked),
222
+ });
223
+ break;
224
+ case 'number':
225
+ control = createElement('input', {
226
+ id,
227
+ type: 'number',
228
+ value: value === undefined ? '' : Number(value),
229
+ onChange: (e) => onChange(field.name, e.target.valueAsNumber),
230
+ });
231
+ break;
232
+ case 'enum':
233
+ control = createElement('select', {
234
+ id,
235
+ value: value === undefined ? '' : String(value),
236
+ onChange: (e) => onChange(field.name, e.target.value),
237
+ }, (field.options ?? []).map((opt) => createElement('option', { key: opt, value: opt }, opt)));
238
+ break;
239
+ default:
240
+ control = createElement('input', {
241
+ id,
242
+ type: 'text',
243
+ value: value === undefined ? '' : String(value),
244
+ placeholder: field.type === 'expression' ? 'expression' : undefined,
245
+ onChange: (e) => onChange(field.name, e.target.value),
246
+ });
247
+ }
248
+ return createElement('label', { htmlFor: id, 'data-nu-field': field.name }, createElement('span', null, label), control);
249
+ }
250
+
251
+ export { DEFAULT_CANVAS_CSS, EditorFrame, IframeCanvas, PropFields, templateIdFromElement, useEditorRevision, useStructureRevision };