@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/LICENSE +21 -0
- package/dist/index.cjs +693 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.mjs +657 -0
- package/dist/react.cjs +259 -0
- package/dist/react.d.ts +155 -0
- package/dist/react.mjs +251 -0
- package/package.json +65 -0
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;
|
package/dist/react.d.ts
ADDED
|
@@ -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 };
|