@michaeltangseng/schemaai 0.0.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.
@@ -0,0 +1,1007 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import React, { createContext, useContext, useReducer } from 'react';
3
+ import { v4 as uuidv4 } from 'uuid';
4
+ import { INITIAL_TREE, COMPONENT_REGISTRY } from '../../../constants';
5
+ import { findNodeAndParent, regenerateIds, findNodeByQueryId } from '../../../utils/nodeUtils';
6
+ // NOTE: 该文件为从主工程 `store/EditorStore.tsx` 复制的初始版本,
7
+ // 仍然依赖主工程的 `types/constants/utils`,后续会在 `schemaAI` 包内部重新抽象这些依赖。
8
+ // --- Default Theme ---
9
+ const DEFAULT_THEME = {
10
+ colors: {
11
+ primary: '#3b82f6',
12
+ secondary: '#64748b',
13
+ success: '#22c55e',
14
+ danger: '#ef4444',
15
+ dark: '#1e293b',
16
+ light: '#f8fafc',
17
+ white: '#ffffff'
18
+ },
19
+ borderRadius: {
20
+ none: '0px',
21
+ sm: '4px',
22
+ md: '8px',
23
+ lg: '16px',
24
+ full: '9999px'
25
+ }
26
+ };
27
+ // --- Reducer ---
28
+ const editorReducer = (state, action) => {
29
+ // Helper to push history before mutation (scoped to current page logic usually, but simplified here)
30
+ const pushHistory = (currentState) => ({
31
+ ...currentState,
32
+ history: {
33
+ past: [...currentState.history.past, structuredClone(currentState.tree)],
34
+ future: [] // Clear future on new change
35
+ }
36
+ });
37
+ // Helper to sync the modified 'tree' back into the 'pages' array
38
+ const syncTreeToPages = (state, newTree) => {
39
+ // If we are editing the active page, sync it
40
+ // Note: If we are updating a node in a background page (e.g. via openModal data transfer),
41
+ // the 'tree' reference might be the active one, but we need to update the correct page in 'pages'.
42
+ // Strategy: Since findNodeAndParent/UpdateNode usually works on 'state.tree',
43
+ // For cross-page updates, we handle it explicitly in the action or ensure 'state.tree' is temporarily swapped?
44
+ // Actually, UPDATE_NODE logic below assumes 'state.tree'.
45
+ // To support updating non-active pages, we need to find which page the node belongs to.
46
+ // For now, simpler sync:
47
+ return state.pages.map(p => p.id === state.activePageId ? { ...p, tree: newTree } : p);
48
+ };
49
+ switch (action.type) {
50
+ // --- Page Management ---
51
+ case 'ADD_PAGE': {
52
+ const newPageId = uuidv4();
53
+ // Correctly clone INITIAL_TREE and regenerate child IDs so they are unique
54
+ // We keep root ID as 'root' because the editor relies on top-level being 'root'
55
+ const baseTree = structuredClone(INITIAL_TREE);
56
+ const newTree = {
57
+ ...baseTree,
58
+ id: 'root', // Maintain root ID for editor compatibility
59
+ children: baseTree.children?.map(child => regenerateIds(child)) || [],
60
+ // Sub-pages: default init handler to receive senddata from open('PageName', { senddata })
61
+ ...(action.payload.isSubPage ? {
62
+ eventHandlers: [
63
+ {
64
+ id: uuidv4(),
65
+ name: 'onOpenData',
66
+ eventType: 'onOverlayInit',
67
+ code: '// 接收主页面 open() 传入的 senddata,参数为 data\n// 例如: const ds = data; 或 将 data 赋给子组件',
68
+ input: 'data'
69
+ }
70
+ ]
71
+ } : {})
72
+ };
73
+ const newPage = {
74
+ id: newPageId,
75
+ name: action.payload.name,
76
+ slug: action.payload.slug,
77
+ tree: newTree,
78
+ isSubPage: action.payload.isSubPage || false,
79
+ };
80
+ return {
81
+ ...state,
82
+ pages: [...state.pages, newPage],
83
+ activePageId: newPageId,
84
+ tree: newTree,
85
+ selectedId: null,
86
+ history: { past: [], future: [] }
87
+ };
88
+ }
89
+ case 'DELETE_PAGE': {
90
+ const pageToDelete = state.pages.find(p => p.id === action.payload);
91
+ // Only block deletion when removing the last MAIN page (sub-pages can always be deleted)
92
+ if (!pageToDelete?.isSubPage) {
93
+ const mainPageCount = state.pages.filter(p => !p.isSubPage).length;
94
+ if (mainPageCount <= 1) {
95
+ alert("Cannot delete the last page.");
96
+ return state;
97
+ }
98
+ }
99
+ const newPages = state.pages.filter(p => p.id !== action.payload);
100
+ let newActiveId = state.activePageId;
101
+ let newTree = state.tree;
102
+ // If we deleted the active page, prefer switching to a main page
103
+ if (state.activePageId === action.payload) {
104
+ const nextMainPage = newPages.find(p => !p.isSubPage);
105
+ const nextPage = nextMainPage || newPages[0];
106
+ newActiveId = nextPage.id;
107
+ newTree = nextPage.tree;
108
+ }
109
+ return {
110
+ ...state,
111
+ pages: newPages,
112
+ activePageId: newActiveId,
113
+ tree: newTree,
114
+ selectedId: null,
115
+ history: { past: [], future: [] }
116
+ };
117
+ }
118
+ case 'UPDATE_PAGE_META': {
119
+ return {
120
+ ...state,
121
+ pages: state.pages.map(p => p.id === action.payload.id
122
+ ? { ...p, ...action.payload }
123
+ : p)
124
+ };
125
+ }
126
+ case 'SWITCH_PAGE': {
127
+ const targetPage = state.pages.find(p => p.id === action.payload);
128
+ if (!targetPage)
129
+ return state;
130
+ return {
131
+ ...state,
132
+ activePageId: targetPage.id,
133
+ tree: targetPage.tree,
134
+ selectedId: null,
135
+ history: { past: [], future: [] },
136
+ bindingState: { isActive: false, targetNodeId: null, targetField: null, targetType: null },
137
+ modals: [],
138
+ overlayDesignerId: null, // Close designer when switching pages
139
+ };
140
+ }
141
+ case 'REORDER_PAGE': {
142
+ const { sourceIndex, targetIndex } = action.payload;
143
+ if (sourceIndex === targetIndex)
144
+ return state;
145
+ const newPages = [...state.pages];
146
+ const [movedPage] = newPages.splice(sourceIndex, 1);
147
+ newPages.splice(targetIndex, 0, movedPage);
148
+ return {
149
+ ...state,
150
+ pages: newPages
151
+ };
152
+ }
153
+ case 'SET_FULL_STATE': {
154
+ return {
155
+ ...state,
156
+ pages: action.payload.pages,
157
+ activePageId: action.payload.activePageId,
158
+ tree: action.payload.pages.find(p => p.id === action.payload.activePageId)?.tree || INITIAL_TREE,
159
+ version: state.version + 1
160
+ };
161
+ }
162
+ // --- Modals ---
163
+ case 'OPEN_MODAL': {
164
+ const modal = action.payload;
165
+ let nextPages = state.pages;
166
+ // When overlay is opened with senddata, set the target page's root Page.Rendered_data.datasource so bindings work
167
+ if (modal.senddata !== undefined && modal.senddata !== null) {
168
+ nextPages = state.pages.map(p => {
169
+ if (p.id !== modal.pageId)
170
+ return p;
171
+ const tree = structuredClone(p.tree);
172
+ if (tree.props)
173
+ tree.props = { ...tree.props, datasource: modal.senddata };
174
+ else
175
+ tree.props = { datasource: modal.senddata };
176
+ return { ...p, tree };
177
+ });
178
+ }
179
+ return {
180
+ ...state,
181
+ pages: nextPages,
182
+ modals: [...state.modals, modal]
183
+ };
184
+ }
185
+ case 'CLOSE_MODAL': {
186
+ if (action.payload) {
187
+ return {
188
+ ...state,
189
+ modals: state.modals.filter(m => m.id !== action.payload)
190
+ };
191
+ }
192
+ // Close last one
193
+ return {
194
+ ...state,
195
+ modals: state.modals.slice(0, -1)
196
+ };
197
+ }
198
+ // --- Node Operations ---
199
+ case 'SELECT_NODE':
200
+ return {
201
+ ...state,
202
+ selectedId: action.payload,
203
+ bindingState: { isActive: false, targetNodeId: null, targetField: null, targetType: null }
204
+ };
205
+ case 'HOVER_NODE':
206
+ return { ...state, hoveredId: action.payload };
207
+ case 'SET_DRAGGED_ITEM':
208
+ return { ...state, draggedItem: action.payload };
209
+ case 'SET_VIEWPORT':
210
+ return { ...state, viewport: action.payload };
211
+ case 'SET_PREVIEW_FIT':
212
+ return { ...state, previewFitCanvas: action.payload };
213
+ case 'SET_MODE': {
214
+ let runtimeValues = {};
215
+ if (action.payload === 'preview') {
216
+ state.variables.forEach(v => {
217
+ let val = v.defaultValue;
218
+ if (v.type === 'number')
219
+ val = Number(v.defaultValue);
220
+ if (v.type === 'boolean')
221
+ val = v.defaultValue === 'true';
222
+ if (v.type === 'object') {
223
+ try {
224
+ val = JSON.parse(v.defaultValue);
225
+ }
226
+ catch (e) {
227
+ val = {};
228
+ }
229
+ }
230
+ runtimeValues[v.id] = val;
231
+ });
232
+ }
233
+ return {
234
+ ...state,
235
+ mode: action.payload,
236
+ selectedId: null,
237
+ hoveredId: null,
238
+ runtimeValues,
239
+ bindingState: { isActive: false, targetNodeId: null, targetField: null, targetType: null },
240
+ modals: [],
241
+ overlayDesignerId: null, // Close designer on mode change
242
+ };
243
+ }
244
+ /**
245
+ * Preview Runtime Hydration (for iframe preview)
246
+ * - The host editor sends a snapshot of runtime state to the iframe via postMessage.
247
+ * - We apply it in ONE reducer step to avoid flicker and to avoid `SET_MODE('preview')`
248
+ * overwriting runtimeValues before variables are set.
249
+ */
250
+ case 'HYDRATE_PREVIEW': {
251
+ const nextPages = action.payload.pages || [];
252
+ const nextActivePageId = action.payload.activePageId || nextPages[0]?.id || state.activePageId;
253
+ const nextTree = nextPages.find(p => p.id === nextActivePageId)?.tree || INITIAL_TREE;
254
+ return {
255
+ ...state,
256
+ mode: 'preview',
257
+ viewport: action.payload.viewport || state.viewport,
258
+ pages: nextPages,
259
+ activePageId: nextActivePageId,
260
+ tree: nextTree,
261
+ themeConfig: action.payload.themeConfig || state.themeConfig,
262
+ variables: action.payload.variables || [],
263
+ runtimeValues: action.payload.runtimeValues || {},
264
+ queryResults: action.payload.queryResults || {},
265
+ // Reset editor-only UI state in runtime
266
+ selectedId: null,
267
+ hoveredId: null,
268
+ draggedItem: null,
269
+ clipboard: null,
270
+ contextMenu: null,
271
+ bindingState: { isActive: false, targetNodeId: null, targetField: null, targetType: null },
272
+ history: { past: [], future: [] },
273
+ modals: [],
274
+ overlayDesignerId: null,
275
+ isDataSourceDrawerOpen: false,
276
+ isEventDrawerOpen: false,
277
+ version: state.version + 1
278
+ };
279
+ }
280
+ // One-time migration for preview: fill/patch props.__rb_layout for existing absolute nodes
281
+ // IMPORTANT: no history pollution (does not call pushHistory)
282
+ case 'MIGRATE_RB_LAYOUT': {
283
+ const { updates } = action.payload;
284
+ if (!updates || updates.length === 0)
285
+ return state;
286
+ const newState = structuredClone(state);
287
+ let touched = false;
288
+ for (const u of updates) {
289
+ if (!u?.id || u.id === 'root')
290
+ continue;
291
+ const res = findNodeAndParent(newState.tree, u.id);
292
+ if (!res?.node)
293
+ continue;
294
+ const target = res.node;
295
+ if (target.style?.position !== 'absolute')
296
+ continue;
297
+ if (!target.props)
298
+ target.props = {};
299
+ const prev = target.props.__rb_layout || {};
300
+ target.props.__rb_layout = { ...prev, ...u.layout };
301
+ touched = true;
302
+ }
303
+ if (!touched)
304
+ return state;
305
+ newState.pages = syncTreeToPages(newState, newState.tree); // Sync
306
+ // Do NOT bump version here: EditorLayout keys Canvas by `state.version`, bumping would remount Canvas
307
+ // which would rerun the migration effect and cause an infinite update loop.
308
+ return newState;
309
+ }
310
+ case 'OPEN_CONTEXT_MENU':
311
+ return { ...state, contextMenu: action.payload, selectedId: action.payload.nodeId };
312
+ case 'CLOSE_CONTEXT_MENU':
313
+ return { ...state, contextMenu: null };
314
+ case 'UNDO': {
315
+ const { past, future } = state.history;
316
+ if (past.length === 0)
317
+ return state;
318
+ const previous = past[past.length - 1];
319
+ const newPast = past.slice(0, -1);
320
+ return {
321
+ ...state,
322
+ tree: previous,
323
+ pages: syncTreeToPages(state, previous), // Sync
324
+ history: {
325
+ past: newPast,
326
+ future: [state.tree, ...future]
327
+ },
328
+ version: state.version + 1
329
+ };
330
+ }
331
+ case 'REDO': {
332
+ const { past, future } = state.history;
333
+ if (future.length === 0)
334
+ return state;
335
+ const next = future[0];
336
+ const newFuture = future.slice(1);
337
+ return {
338
+ ...state,
339
+ tree: next,
340
+ pages: syncTreeToPages(state, next), // Sync
341
+ history: {
342
+ past: [...past, state.tree],
343
+ future: newFuture
344
+ },
345
+ version: state.version + 1
346
+ };
347
+ }
348
+ case 'COPY_NODE': {
349
+ if (!state.selectedId || state.selectedId === 'root')
350
+ return state;
351
+ const result = findNodeAndParent(state.tree, state.selectedId);
352
+ if (!result)
353
+ return state;
354
+ return { ...state, clipboard: structuredClone(result.node) };
355
+ }
356
+ case 'PASTE_NODE': {
357
+ if (!state.clipboard)
358
+ return state;
359
+ const stateWithHistory = pushHistory(state);
360
+ const newState = structuredClone(stateWithHistory);
361
+ const newNode = regenerateIds(state.clipboard);
362
+ newNode.name = `${newNode.type.toLowerCase()}_${newNode.id.slice(0, 4)}`;
363
+ let targetParentId = 'root';
364
+ let insertIndex = undefined;
365
+ if (state.selectedId) {
366
+ const selectedResult = findNodeAndParent(newState.tree, state.selectedId);
367
+ if (selectedResult) {
368
+ const meta = COMPONENT_REGISTRY[selectedResult.node.type];
369
+ if (meta && meta.isContainer) {
370
+ targetParentId = state.selectedId;
371
+ }
372
+ else {
373
+ targetParentId = selectedResult.parent?.id || 'root';
374
+ if (selectedResult.parent && selectedResult.parent.children) {
375
+ const idx = selectedResult.parent.children.findIndex(n => n.id === state.selectedId);
376
+ if (idx !== -1)
377
+ insertIndex = idx + 1;
378
+ }
379
+ }
380
+ }
381
+ }
382
+ const targetResult = findNodeAndParent(newState.tree, targetParentId);
383
+ if (targetResult && targetResult.node) {
384
+ if (!targetResult.node.children)
385
+ targetResult.node.children = [];
386
+ if (insertIndex !== undefined) {
387
+ targetResult.node.children.splice(insertIndex, 0, newNode);
388
+ }
389
+ else {
390
+ targetResult.node.children.push(newNode);
391
+ }
392
+ }
393
+ newState.pages = syncTreeToPages(newState, newState.tree); // Sync
394
+ return newState;
395
+ }
396
+ case 'DUPLICATE_NODE': {
397
+ if (!action.payload || action.payload === 'root')
398
+ return state;
399
+ const stateWithHistory = pushHistory(state);
400
+ const newState = structuredClone(stateWithHistory);
401
+ const result = findNodeAndParent(newState.tree, action.payload);
402
+ if (result && result.parent && result.parent.children) {
403
+ const index = result.parent.children.findIndex(n => n.id === action.payload);
404
+ if (index !== -1) {
405
+ const newNode = regenerateIds(result.node);
406
+ newNode.name = `${newNode.type.toLowerCase()}_${newNode.id.slice(0, 4)}`;
407
+ result.parent.children.splice(index + 1, 0, newNode);
408
+ newState.pages = syncTreeToPages(newState, newState.tree); // Sync
409
+ return { ...newState, selectedId: newNode.id };
410
+ }
411
+ }
412
+ return state;
413
+ }
414
+ case 'REORDER_NODE': {
415
+ const { nodeId, direction } = action.payload;
416
+ if (nodeId === 'root')
417
+ return state;
418
+ const stateWithHistory = pushHistory(state);
419
+ const newState = structuredClone(stateWithHistory);
420
+ const result = findNodeAndParent(newState.tree, nodeId);
421
+ if (result && result.parent && result.parent.children) {
422
+ const children = result.parent.children;
423
+ const index = children.findIndex(n => n.id === nodeId);
424
+ if (index === -1)
425
+ return state;
426
+ if (direction === 'up') {
427
+ if (index > 0) {
428
+ [children[index], children[index - 1]] = [children[index - 1], children[index]];
429
+ }
430
+ }
431
+ else {
432
+ if (index < children.length - 1) {
433
+ [children[index], children[index + 1]] = [children[index + 1], children[index]];
434
+ }
435
+ }
436
+ }
437
+ newState.pages = syncTreeToPages(newState, newState.tree); // Sync
438
+ return newState;
439
+ }
440
+ case 'ADD_NODE': {
441
+ const { parentId, node, index, position } = action.payload;
442
+ const stateWithHistory = pushHistory(state);
443
+ const newState = structuredClone(stateWithHistory);
444
+ const result = findNodeAndParent(newState.tree, parentId);
445
+ if (result && result.node) {
446
+ if (!result.node.children)
447
+ result.node.children = [];
448
+ if (position) {
449
+ node.style = {
450
+ ...node.style,
451
+ position: 'absolute',
452
+ left: `${position.x}px`,
453
+ top: `${position.y}px`
454
+ };
455
+ }
456
+ if (!node.name) {
457
+ node.name = `${node.type.toLowerCase()}_${node.id.slice(0, 4)}`;
458
+ }
459
+ // Initialize deleted props cache for new nodes
460
+ if (!node.props)
461
+ node.props = {};
462
+ if (!node.props.__rb_deletedProps) {
463
+ node.props.__rb_deletedProps = [];
464
+ }
465
+ if (typeof index === 'number' && index >= 0) {
466
+ result.node.children.splice(index, 0, node);
467
+ }
468
+ else {
469
+ result.node.children.push(node);
470
+ }
471
+ }
472
+ newState.pages = syncTreeToPages(newState, newState.tree); // Sync
473
+ return newState;
474
+ }
475
+ case 'UPDATE_NODE': {
476
+ const { id, props, data, style, events, eventHandlers, name, replace } = action.payload;
477
+ // We need to handle updates across ANY page, not just the active one
478
+ // But finding the node efficiently requires searching all pages if not active.
479
+ const stateWithHistory = pushHistory(state);
480
+ const newState = structuredClone(stateWithHistory);
481
+ // 1. Try active tree first
482
+ let result = findNodeAndParent(newState.tree, id);
483
+ // 2. If not found, search other pages
484
+ if (!result) {
485
+ for (const page of newState.pages) {
486
+ if (page.id !== newState.activePageId) {
487
+ result = findNodeAndParent(page.tree, id);
488
+ if (result)
489
+ break; // Found in another page
490
+ }
491
+ }
492
+ }
493
+ if (result && result.node) {
494
+ if (props) {
495
+ result.node.props = replace?.props ? props : { ...result.node.props, ...props };
496
+ }
497
+ if (data) {
498
+ const current = result.node.data || {};
499
+ result.node.data = replace?.data ? data : { ...current, ...data };
500
+ }
501
+ if (style) {
502
+ result.node.style = replace?.style ? style : { ...result.node.style, ...style };
503
+ }
504
+ if (events) {
505
+ const current = result.node.events || {};
506
+ result.node.events = replace?.events ? events : { ...current, ...events };
507
+ }
508
+ if (eventHandlers)
509
+ result.node.eventHandlers = eventHandlers;
510
+ if (name !== undefined)
511
+ result.node.name = name;
512
+ }
513
+ // If the node was in the active tree, this sync works.
514
+ // If it was in another page, 'result.node' is a reference to that page's tree node inside 'newState.pages'
515
+ // because we cloned 'newState' which cloned 'pages'. So the mutation above is already applied to 'newState.pages'.
516
+ // We just need to ensure active tree is synced back IF we were editing active tree.
517
+ // Re-sync active tree just in case we mutated it via 'newState.tree'
518
+ newState.pages = syncTreeToPages(newState, newState.tree);
519
+ return newState;
520
+ }
521
+ case 'UPDATE_NODE_IN_PAGE': {
522
+ const { pageId, id, props: propsPatch, data: dataPatch, style: stylePatch } = action.payload;
523
+ const page = state.pages.find(p => p.id === pageId);
524
+ if (!page)
525
+ return state;
526
+ const treeClone = structuredClone(page.tree);
527
+ const result = findNodeAndParent(treeClone, id);
528
+ if (result?.node) {
529
+ if (propsPatch)
530
+ result.node.props = { ...result.node.props, ...propsPatch };
531
+ if (dataPatch)
532
+ result.node.data = { ...(result.node.data || {}), ...dataPatch };
533
+ if (stylePatch)
534
+ result.node.style = { ...result.node.style, ...stylePatch };
535
+ }
536
+ return {
537
+ ...state,
538
+ pages: state.pages.map(p => p.id === pageId ? { ...p, tree: treeClone } : p)
539
+ };
540
+ }
541
+ case 'REPLACE_NODE': {
542
+ const { id, node } = action.payload;
543
+ if (id === 'root') {
544
+ const stateWithHistory = pushHistory(state);
545
+ const newTree = { ...node, id: 'root' };
546
+ return {
547
+ ...stateWithHistory,
548
+ tree: newTree,
549
+ pages: syncTreeToPages(stateWithHistory, newTree), // Sync
550
+ version: state.version + 1
551
+ };
552
+ }
553
+ const stateWithHistory = pushHistory(state);
554
+ const newState = structuredClone(stateWithHistory);
555
+ const result = findNodeAndParent(newState.tree, id);
556
+ if (result && result.parent && result.parent.children) {
557
+ const index = result.parent.children.findIndex(n => n.id === id);
558
+ if (index !== -1) {
559
+ const newNode = { ...node, id };
560
+ result.parent.children[index] = newNode;
561
+ }
562
+ }
563
+ newState.pages = syncTreeToPages(newState, newState.tree); // Sync
564
+ return { ...newState, version: state.version + 1 };
565
+ }
566
+ case 'MOVE_NODE': {
567
+ const { nodeId, targetParentId, index, position, layout } = action.payload;
568
+ if (nodeId === 'root')
569
+ return state;
570
+ const stateWithHistory = pushHistory(state);
571
+ const newState = structuredClone(stateWithHistory);
572
+ const sourceResult = findNodeAndParent(newState.tree, nodeId);
573
+ if (!sourceResult || !sourceResult.parent)
574
+ return state;
575
+ const nodeToMove = sourceResult.node;
576
+ const isDescendant = (parent, childId) => {
577
+ if (parent.children) {
578
+ for (const child of parent.children) {
579
+ if (child.id === childId)
580
+ return true;
581
+ if (isDescendant(child, childId))
582
+ return true;
583
+ }
584
+ }
585
+ return false;
586
+ };
587
+ if (isDescendant(nodeToMove, targetParentId))
588
+ return state;
589
+ sourceResult.parent.children = sourceResult.parent.children.filter(child => child.id !== nodeId);
590
+ if (position) {
591
+ // Drag-based positioning: override with mouse-computed px values.
592
+ // This covers both "drop into new parent" (cross-parent) and "re-drag within same parent"
593
+ // (isSelfDrop), so any previously-configured % position is intentionally overwritten here.
594
+ nodeToMove.style = {
595
+ ...nodeToMove.style,
596
+ position: 'absolute',
597
+ left: `${position.x}px`,
598
+ top: `${position.y}px`,
599
+ marginTop: '0px',
600
+ marginLeft: '0px'
601
+ };
602
+ // Store percent-based layout metadata for preview rendering (optional)
603
+ if (layout) {
604
+ if (!nodeToMove.props)
605
+ nodeToMove.props = {};
606
+ const prev = nodeToMove.props.__rb_layout || {};
607
+ nodeToMove.props.__rb_layout = { ...prev, ...layout };
608
+ }
609
+ }
610
+ else {
611
+ // No explicit position provided.
612
+ // Scenario 2: if the node carries a manually-configured % position (left/top ending with '%'),
613
+ // keep it as-is so it auto-adapts to the new parent container.
614
+ // Otherwise fall back to the original behaviour: reset to relative flow positioning.
615
+ const leftVal = nodeToMove.style?.left;
616
+ const topVal = nodeToMove.style?.top;
617
+ const hasPercentPos = (typeof leftVal === 'string' && leftVal.trim().endsWith('%')) ||
618
+ (typeof topVal === 'string' && topVal.trim().endsWith('%'));
619
+ if (hasPercentPos) {
620
+ // Preserve absolute + % position so CSS auto-resolves against the new parent.
621
+ nodeToMove.style = { ...nodeToMove.style, position: 'absolute' };
622
+ }
623
+ else {
624
+ // Original: strip left/top and switch to relative flow.
625
+ const { position: pos, left, top, ...restStyle } = nodeToMove.style;
626
+ nodeToMove.style = { ...restStyle, position: 'relative' };
627
+ }
628
+ }
629
+ const targetResult = findNodeAndParent(newState.tree, targetParentId);
630
+ if (targetResult && targetResult.node) {
631
+ if (!targetResult.node.children)
632
+ targetResult.node.children = [];
633
+ if (typeof index === 'number' && index >= 0) {
634
+ targetResult.node.children.splice(index, 0, nodeToMove);
635
+ }
636
+ else {
637
+ targetResult.node.children.push(nodeToMove);
638
+ }
639
+ }
640
+ newState.pages = syncTreeToPages(newState, newState.tree); // Sync
641
+ return newState;
642
+ }
643
+ case 'DELETE_NODE': {
644
+ if (action.payload === 'root')
645
+ return state;
646
+ const stateWithHistory = pushHistory(state);
647
+ const newState = structuredClone(stateWithHistory);
648
+ const result = findNodeAndParent(newState.tree, action.payload);
649
+ if (result && result.parent && result.parent.children) {
650
+ result.parent.children = result.parent.children.filter(n => n.id !== action.payload);
651
+ }
652
+ newState.pages = syncTreeToPages(newState, newState.tree); // Sync
653
+ return { ...newState, selectedId: null, contextMenu: null };
654
+ }
655
+ case 'SAVE_PRESET': {
656
+ if (!action.payload || action.payload === 'root')
657
+ return state;
658
+ const result = findNodeAndParent(state.tree, action.payload);
659
+ if (!result)
660
+ return state;
661
+ const preset = regenerateIds(structuredClone(result.node));
662
+ preset.displayName = `${preset.type} Preset`;
663
+ return {
664
+ ...state,
665
+ presets: [...state.presets, preset]
666
+ };
667
+ }
668
+ case 'DELETE_PRESET': {
669
+ return {
670
+ ...state,
671
+ presets: state.presets.filter(p => p.id !== action.payload)
672
+ };
673
+ }
674
+ case 'UPDATE_THEME_TOKEN': {
675
+ const { category, name, value } = action.payload;
676
+ return {
677
+ ...state,
678
+ themeConfig: {
679
+ ...state.themeConfig,
680
+ [category]: {
681
+ ...state.themeConfig[category],
682
+ [name]: value
683
+ }
684
+ }
685
+ };
686
+ }
687
+ case 'ADD_VARIABLE': {
688
+ return {
689
+ ...state,
690
+ variables: [...state.variables, action.payload]
691
+ };
692
+ }
693
+ case 'UPDATE_VARIABLE': {
694
+ return {
695
+ ...state,
696
+ variables: state.variables.map(v => v.id === action.payload.id ? action.payload : v)
697
+ };
698
+ }
699
+ case 'DELETE_VARIABLE': {
700
+ return {
701
+ ...state,
702
+ variables: state.variables.filter(v => v.id !== action.payload)
703
+ };
704
+ }
705
+ case 'SET_RUNTIME_VALUE': {
706
+ return {
707
+ ...state,
708
+ runtimeValues: {
709
+ ...state.runtimeValues,
710
+ [action.payload.id]: action.payload.value
711
+ }
712
+ };
713
+ }
714
+ case 'ADD_QUERY': {
715
+ const stateWithHistory = pushHistory(state);
716
+ const newState = structuredClone(stateWithHistory);
717
+ const result = findNodeAndParent(newState.tree, action.payload.ownerId || '');
718
+ if (result && result.node) {
719
+ if (!result.node.queries)
720
+ result.node.queries = [];
721
+ result.node.queries.push(action.payload);
722
+ }
723
+ newState.pages = syncTreeToPages(newState, newState.tree);
724
+ return newState;
725
+ }
726
+ case 'UPDATE_QUERY': {
727
+ const stateWithHistory = pushHistory(state);
728
+ const newState = structuredClone(stateWithHistory);
729
+ const ownerId = action.payload.ownerId || '';
730
+ let targetNode = null;
731
+ if (ownerId) {
732
+ const result = findNodeAndParent(newState.tree, ownerId);
733
+ if (result)
734
+ targetNode = result.node;
735
+ }
736
+ if (!targetNode) {
737
+ targetNode = findNodeByQueryId(newState.tree, action.payload.id);
738
+ }
739
+ if (targetNode && targetNode.queries) {
740
+ targetNode.queries = targetNode.queries.map(q => q.id === action.payload.id ? action.payload : q);
741
+ }
742
+ newState.pages = syncTreeToPages(newState, newState.tree);
743
+ return newState;
744
+ }
745
+ case 'DELETE_QUERY': {
746
+ const stateWithHistory = pushHistory(state);
747
+ const newState = structuredClone(stateWithHistory);
748
+ const targetNode = findNodeByQueryId(newState.tree, action.payload);
749
+ if (targetNode && targetNode.queries) {
750
+ targetNode.queries = targetNode.queries.filter(q => q.id !== action.payload);
751
+ }
752
+ newState.pages = syncTreeToPages(newState, newState.tree);
753
+ return newState;
754
+ }
755
+ case 'SET_DATA_SOURCE_DRAWER_OPEN': {
756
+ return {
757
+ ...state,
758
+ isDataSourceDrawerOpen: action.payload,
759
+ isEventDrawerOpen: action.payload ? false : state.isEventDrawerOpen
760
+ };
761
+ }
762
+ case 'SET_EVENT_DRAWER_OPEN': {
763
+ return {
764
+ ...state,
765
+ isEventDrawerOpen: action.payload,
766
+ isDataSourceDrawerOpen: action.payload ? false : state.isDataSourceDrawerOpen
767
+ };
768
+ }
769
+ case 'ADD_EVENT_HANDLER': {
770
+ const stateWithHistory = pushHistory(state);
771
+ const newState = structuredClone(stateWithHistory);
772
+ const result = findNodeAndParent(newState.tree, action.payload.nodeId);
773
+ if (result && result.node) {
774
+ if (!result.node.eventHandlers)
775
+ result.node.eventHandlers = [];
776
+ result.node.eventHandlers.push(action.payload.handler);
777
+ }
778
+ newState.pages = syncTreeToPages(newState, newState.tree);
779
+ return newState;
780
+ }
781
+ case 'UPDATE_EVENT_HANDLER': {
782
+ const stateWithHistory = pushHistory(state);
783
+ const newState = structuredClone(stateWithHistory);
784
+ const result = findNodeAndParent(newState.tree, action.payload.nodeId);
785
+ if (result && result.node && result.node.eventHandlers) {
786
+ const index = result.node.eventHandlers.findIndex(h => h.id === action.payload.handler.id);
787
+ if (index !== -1) {
788
+ result.node.eventHandlers[index] = action.payload.handler;
789
+ }
790
+ }
791
+ newState.pages = syncTreeToPages(newState, newState.tree);
792
+ return newState;
793
+ }
794
+ case 'DELETE_EVENT_HANDLER': {
795
+ const stateWithHistory = pushHistory(state);
796
+ const newState = structuredClone(stateWithHistory);
797
+ const result = findNodeAndParent(newState.tree, action.payload.nodeId);
798
+ if (result && result.node && result.node.eventHandlers) {
799
+ result.node.eventHandlers = result.node.eventHandlers.filter(h => h.id !== action.payload.handlerId);
800
+ }
801
+ newState.pages = syncTreeToPages(newState, newState.tree);
802
+ return newState;
803
+ }
804
+ case 'REORDER_EVENT_HANDLER': {
805
+ const stateWithHistory = pushHistory(state);
806
+ const newState = structuredClone(stateWithHistory);
807
+ const result = findNodeAndParent(newState.tree, action.payload.nodeId);
808
+ if (result && result.node && result.node.eventHandlers) {
809
+ const handlers = result.node.eventHandlers;
810
+ const { sourceIndex, targetIndex } = action.payload;
811
+ if (sourceIndex >= 0 && sourceIndex < handlers.length && targetIndex >= 0 && targetIndex < handlers.length) {
812
+ const [removed] = handlers.splice(sourceIndex, 1);
813
+ handlers.splice(targetIndex, 0, removed);
814
+ }
815
+ }
816
+ newState.pages = syncTreeToPages(newState, newState.tree);
817
+ return newState;
818
+ }
819
+ case 'SET_BINDING_STATE': {
820
+ return {
821
+ ...state,
822
+ bindingState: action.payload
823
+ };
824
+ }
825
+ case 'APPLY_BINDING': {
826
+ const { targetNodeId, targetField, targetType } = state.bindingState;
827
+ if (!targetNodeId || !targetField)
828
+ return state;
829
+ const stateWithHistory = pushHistory(state);
830
+ const newState = structuredClone(stateWithHistory);
831
+ const path = action.payload;
832
+ if (targetType === 'query') {
833
+ const nodeWithQuery = findNodeByQueryId(newState.tree, targetNodeId);
834
+ if (nodeWithQuery && nodeWithQuery.queries) {
835
+ const queryIndex = nodeWithQuery.queries.findIndex(q => q.id === targetNodeId);
836
+ if (queryIndex !== -1) {
837
+ const query = nodeWithQuery.queries[queryIndex];
838
+ const fieldParts = targetField.split('.');
839
+ if (fieldParts.length === 1) {
840
+ const key = targetField;
841
+ if (typeof query[key] === 'string') {
842
+ query[key] = (query[key] || '') + path;
843
+ }
844
+ }
845
+ else if (fieldParts.length === 3) {
846
+ const [collection, indexStr, key] = fieldParts;
847
+ const idx = parseInt(indexStr);
848
+ const colKey = collection;
849
+ if (query[colKey] && query[colKey][idx]) {
850
+ const existing = query[colKey][idx][key] || '';
851
+ query[colKey][idx][key] = existing + path;
852
+ }
853
+ }
854
+ }
855
+ }
856
+ }
857
+ else if (targetType === 'prop' || targetType === 'data') {
858
+ const result = findNodeAndParent(newState.tree, targetNodeId);
859
+ if (result && result.node) {
860
+ if (targetType === 'prop') {
861
+ const existingVal = result.node.props[targetField] || '';
862
+ result.node.props[targetField] = `${existingVal}${path}`;
863
+ }
864
+ else if (targetType === 'data') {
865
+ if (!result.node.data)
866
+ result.node.data = {};
867
+ const existingVal = result.node.data[targetField] || '';
868
+ result.node.data[targetField] = `${existingVal}${path}`;
869
+ }
870
+ }
871
+ }
872
+ newState.pages = syncTreeToPages(newState, newState.tree);
873
+ return {
874
+ ...newState,
875
+ bindingState: { isActive: false, targetNodeId: null, targetField: null, targetType: null }
876
+ };
877
+ }
878
+ case 'SET_QUERY_RESULT': {
879
+ return {
880
+ ...state,
881
+ queryResults: {
882
+ ...state.queryResults,
883
+ [action.payload.id]: action.payload.data
884
+ }
885
+ };
886
+ }
887
+ // --- Persistence ---
888
+ case 'SET_TREE': {
889
+ // Legacy support mostly, but adapted to update active page
890
+ const stateWithHistory = pushHistory(state);
891
+ return {
892
+ ...stateWithHistory,
893
+ tree: action.payload,
894
+ pages: syncTreeToPages(stateWithHistory, action.payload),
895
+ selectedId: null,
896
+ bindingState: { isActive: false, targetNodeId: null, targetField: null, targetType: null },
897
+ version: state.version + 1
898
+ };
899
+ }
900
+ case 'BUMP_VERSION': {
901
+ return {
902
+ ...state,
903
+ version: state.version + 1
904
+ };
905
+ }
906
+ case 'SET_OVERLAY_DESIGNER': {
907
+ return { ...state, overlayDesignerId: action.payload };
908
+ }
909
+ default:
910
+ return state;
911
+ }
912
+ };
913
+ const EditorContext = createContext(undefined);
914
+ export const EditorProvider = ({ children }) => {
915
+ const [state, dispatch] = useReducer(editorReducer, {
916
+ pages: [{ id: 'init', name: 'Home', slug: '/', tree: INITIAL_TREE }],
917
+ activePageId: 'init',
918
+ modals: [],
919
+ tree: INITIAL_TREE,
920
+ selectedId: null,
921
+ hoveredId: null,
922
+ draggedItem: null,
923
+ history: {
924
+ past: [],
925
+ future: []
926
+ },
927
+ clipboard: null,
928
+ viewport: 'ratio-16-9',
929
+ previewFitCanvas: true,
930
+ mode: 'edit',
931
+ contextMenu: null,
932
+ presets: [],
933
+ themeConfig: DEFAULT_THEME,
934
+ variables: [],
935
+ isDataSourceDrawerOpen: false,
936
+ isEventDrawerOpen: false,
937
+ runtimeValues: {},
938
+ bindingState: { isActive: false, targetNodeId: null, targetField: null, targetType: null },
939
+ queryResults: {},
940
+ version: 0,
941
+ overlayDesignerId: null
942
+ });
943
+ const actions = {
944
+ addNode: (parentId, node, index, position) => dispatch({ type: 'ADD_NODE', payload: { parentId, node, index, position } }),
945
+ updateNode: (id, updates) => dispatch({ type: 'UPDATE_NODE', payload: { id, ...updates } }),
946
+ updateNodeInPage: (pageId, id, updates) => dispatch({ type: 'UPDATE_NODE_IN_PAGE', payload: { pageId, id, ...updates } }),
947
+ replaceNode: (id, node) => dispatch({ type: 'REPLACE_NODE', payload: { id, node } }),
948
+ moveNode: (nodeId, targetParentId, index, position, layout) => dispatch({ type: 'MOVE_NODE', payload: { nodeId, targetParentId, index, position, layout } }),
949
+ deleteNode: (id) => dispatch({ type: 'DELETE_NODE', payload: id }),
950
+ selectNode: (id) => dispatch({ type: 'SELECT_NODE', payload: id }),
951
+ hoverNode: (id) => dispatch({ type: 'HOVER_NODE', payload: id }),
952
+ undo: () => dispatch({ type: 'UNDO' }),
953
+ redo: () => dispatch({ type: 'REDO' }),
954
+ copyNode: () => dispatch({ type: 'COPY_NODE' }),
955
+ pasteNode: () => dispatch({ type: 'PASTE_NODE' }),
956
+ setViewport: (mode) => dispatch({ type: 'SET_VIEWPORT', payload: mode }),
957
+ setPreviewFitCanvas: (fit) => dispatch({ type: 'SET_PREVIEW_FIT', payload: fit }),
958
+ setMode: (mode) => dispatch({ type: 'SET_MODE', payload: mode }),
959
+ hydratePreview: (payload) => dispatch({ type: 'HYDRATE_PREVIEW', payload }),
960
+ migrateRbLayout: (updates) => dispatch({ type: 'MIGRATE_RB_LAYOUT', payload: { updates } }),
961
+ openContextMenu: (payload) => dispatch({ type: 'OPEN_CONTEXT_MENU', payload }),
962
+ closeContextMenu: () => dispatch({ type: 'CLOSE_CONTEXT_MENU' }),
963
+ duplicateNode: (id) => dispatch({ type: 'DUPLICATE_NODE', payload: id }),
964
+ reorderNode: (nodeId, direction) => dispatch({ type: 'REORDER_NODE', payload: { nodeId, direction } }),
965
+ savePreset: (id) => dispatch({ type: 'SAVE_PRESET', payload: id }),
966
+ deletePreset: (id) => dispatch({ type: 'DELETE_PRESET', payload: id }),
967
+ updateThemeToken: (category, name, value) => dispatch({ type: 'UPDATE_THEME_TOKEN', payload: { category, name, value } }),
968
+ addVariable: (variable) => dispatch({ type: 'ADD_VARIABLE', payload: variable }),
969
+ updateVariable: (variable) => dispatch({ type: 'UPDATE_VARIABLE', payload: variable }),
970
+ deleteVariable: (id) => dispatch({ type: 'DELETE_VARIABLE', payload: id }),
971
+ setRuntimeValue: (id, value) => dispatch({ type: 'SET_RUNTIME_VALUE', payload: { id, value } }),
972
+ addQuery: (query) => dispatch({ type: 'ADD_QUERY', payload: query }),
973
+ updateQuery: (query) => dispatch({ type: 'UPDATE_QUERY', payload: query }),
974
+ deleteQuery: (id) => dispatch({ type: 'DELETE_QUERY', payload: id }),
975
+ setDataSourceDrawerOpen: (isOpen) => dispatch({ type: 'SET_DATA_SOURCE_DRAWER_OPEN', payload: isOpen }),
976
+ setBindingState: (state) => dispatch({ type: 'SET_BINDING_STATE', payload: state }),
977
+ applyBinding: (ref) => dispatch({ type: 'APPLY_BINDING', payload: ref }),
978
+ setQueryResult: (id, data) => dispatch({ type: 'SET_QUERY_RESULT', payload: { id, data } }),
979
+ setTree: (tree) => dispatch({ type: 'SET_TREE', payload: tree }),
980
+ // Events
981
+ setEventDrawerOpen: (isOpen) => dispatch({ type: 'SET_EVENT_DRAWER_OPEN', payload: isOpen }),
982
+ addEventHandler: (nodeId, handler) => dispatch({ type: 'ADD_EVENT_HANDLER', payload: { nodeId, handler } }),
983
+ updateEventHandler: (nodeId, handler) => dispatch({ type: 'UPDATE_EVENT_HANDLER', payload: { nodeId, handler } }),
984
+ deleteEventHandler: (nodeId, handlerId) => dispatch({ type: 'DELETE_EVENT_HANDLER', payload: { nodeId, handlerId } }),
985
+ reorderEventHandler: (nodeId, sourceIndex, targetIndex) => dispatch({ type: 'REORDER_EVENT_HANDLER', payload: { nodeId, sourceIndex, targetIndex } }),
986
+ // Pages
987
+ addPage: (name, slug, isSubPage) => dispatch({ type: 'ADD_PAGE', payload: { name, slug, isSubPage } }),
988
+ deletePage: (id) => dispatch({ type: 'DELETE_PAGE', payload: id }),
989
+ updatePageMeta: (id, meta) => dispatch({ type: 'UPDATE_PAGE_META', payload: { id, ...meta } }),
990
+ setOverlayDesigner: (pageId) => dispatch({ type: 'SET_OVERLAY_DESIGNER', payload: pageId }),
991
+ switchPage: (id) => dispatch({ type: 'SWITCH_PAGE', payload: id }),
992
+ reorderPage: (sourceIndex, targetIndex) => dispatch({ type: 'REORDER_PAGE', payload: { sourceIndex, targetIndex } }),
993
+ setFullState: (pages, activePageId) => dispatch({ type: 'SET_FULL_STATE', payload: { pages, activePageId } }),
994
+ // Modals
995
+ openModal: (modal) => dispatch({ type: 'OPEN_MODAL', payload: modal }),
996
+ closeModal: (id) => dispatch({ type: 'CLOSE_MODAL', payload: id }),
997
+ // UI helpers
998
+ bumpVersion: () => dispatch({ type: 'BUMP_VERSION' })
999
+ };
1000
+ return (_jsx(EditorContext.Provider, { value: { state, dispatch, actions }, children: children }));
1001
+ };
1002
+ export const useEditor = () => {
1003
+ const context = useContext(EditorContext);
1004
+ if (!context)
1005
+ throw new Error('useEditor must be used within an EditorProvider');
1006
+ return context;
1007
+ };