create-visualbuild-app 0.1.0 → 1.0.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.
Files changed (44) hide show
  1. package/README.md +306 -123
  2. package/docs/user-guide.md +759 -0
  3. package/package.json +92 -85
  4. package/scripts/create-builder-app.mjs +513 -501
  5. package/scripts/vb-dev.mjs +41 -32
  6. package/scripts/vb-generate.mjs +332 -224
  7. package/templates/default/app/.vercelignore +6 -0
  8. package/templates/default/app/README.md +56 -21
  9. package/templates/default/app/visualbuild/components.json +3 -0
  10. package/templates/default/app/visualbuild/config.d.ts +48 -0
  11. package/templates/default/app/visualbuild.config.ts +38 -0
  12. package/templates/default/builder/README.md +37 -21
  13. package/visual-app-builder/server/parseReactPage.ts +776 -571
  14. package/visual-app-builder/shared/createReactComponentSource.d.mts +2 -0
  15. package/visual-app-builder/shared/createReactComponentSource.mjs +45 -0
  16. package/visual-app-builder/shared/generateReactSource.d.mts +57 -37
  17. package/visual-app-builder/shared/generateReactSource.mjs +608 -443
  18. package/visual-app-builder/shared/npmBuildCommand.d.mts +17 -0
  19. package/visual-app-builder/shared/npmBuildCommand.mjs +26 -0
  20. package/visual-app-builder/shared/syncCustomComponentSource.d.mts +13 -0
  21. package/visual-app-builder/shared/syncCustomComponentSource.mjs +345 -0
  22. package/visual-app-builder/shared/vercelDeployment.d.mts +31 -0
  23. package/visual-app-builder/shared/vercelDeployment.mjs +266 -0
  24. package/visual-app-builder/shared/visualbuildConfig.d.mts +144 -0
  25. package/visual-app-builder/shared/visualbuildConfig.mjs +578 -0
  26. package/visual-app-builder/src/App.tsx +1090 -874
  27. package/visual-app-builder/src/components/Canvas.tsx +1116 -1059
  28. package/visual-app-builder/src/components/CodePanel.tsx +1147 -812
  29. package/visual-app-builder/src/components/ComponentSidebar.tsx +365 -302
  30. package/visual-app-builder/src/components/DeploymentModal.tsx +295 -0
  31. package/visual-app-builder/src/components/PageSwitcher.tsx +133 -133
  32. package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -1054
  33. package/visual-app-builder/src/components/PropertiesPanel.tsx +792 -692
  34. package/visual-app-builder/src/components/Topbar.tsx +257 -128
  35. package/visual-app-builder/src/data/componentRegistry.tsx +613 -292
  36. package/visual-app-builder/src/data/tailwindClassCatalog.ts +95 -0
  37. package/visual-app-builder/src/index.css +383 -111
  38. package/visual-app-builder/src/stores/useAppStore.ts +2385 -1265
  39. package/visual-app-builder/src/theme.ts +71 -0
  40. package/visual-app-builder/src/types/index.ts +350 -261
  41. package/visual-app-builder/src/utils/codegen.ts +90 -66
  42. package/visual-app-builder/src/utils/deployment.ts +54 -0
  43. package/visual-app-builder/src/utils/projectPersistence.ts +218 -177
  44. package/visual-app-builder/vite.config.ts +1946 -1479
@@ -1,1059 +1,1116 @@
1
- import {
2
- useDndContext,
3
- useDraggable,
4
- useDroppable,
5
- type DraggableAttributes,
6
- type DraggableSyntheticListeners,
7
- } from '@dnd-kit/core'
8
- import { CSS } from '@dnd-kit/utilities'
9
- import {
10
- createElement,
11
- createContext,
12
- useCallback,
13
- useContext,
14
- useEffect,
15
- useLayoutEffect,
16
- useRef,
17
- useState,
18
- type CSSProperties,
19
- type MouseEvent as ReactMouseEvent,
20
- type MutableRefObject,
21
- type PointerEvent as ReactPointerEvent,
22
- type ReactNode,
23
- } from 'react'
24
- import { createPortal } from 'react-dom'
25
- import { primitiveRegistry } from '../data/primitiveRegistry'
26
- import { useAppStore } from '../stores/useAppStore'
27
- import type { Viewport, VisualNode } from '../types'
28
-
29
- type DropOrientation = 'vertical' | 'horizontal'
30
-
31
- interface FloatingRect {
32
- top: number
33
- right: number
34
- bottom: number
35
- left: number
36
- width: number
37
- height: number
38
- }
39
-
40
- const CanvasOverlayContext = createContext<HTMLElement | null>(null)
41
-
42
- export default function Canvas() {
43
- const [overlayRoot, setOverlayRoot] = useState<HTMLDivElement | null>(null)
44
- const {
45
- pages,
46
- appShell,
47
- activePageId,
48
- selectedComponentId,
49
- viewport,
50
- previewMode,
51
- setActivePage,
52
- setSelectedComponent,
53
- setViewport,
54
- removeNode,
55
- removeAppShellComponent,
56
- } = useAppStore()
57
- const activePage = pages.find((page) => page.id === activePageId)
58
- const pageRoot = activePage?.root
59
- const pageRootRef = useRef<HTMLElement | null>(null)
60
- const rootOrientation = pageRoot
61
- ? getChildOrientation(pageRoot)
62
- : 'vertical'
63
- const isPageRootSelected =
64
- !previewMode && pageRoot?.id !== undefined && selectedComponentId === pageRoot.id
65
- const { isOver, setNodeRef } = useDroppable({
66
- id: 'canvas:root',
67
- disabled: previewMode || !activePage,
68
- data: {
69
- kind: 'canvas-root',
70
- },
71
- })
72
-
73
- useEffect(() => {
74
- if (!previewMode) {
75
- return
76
- }
77
-
78
- const syncPageToLocation = () => {
79
- const matchingPage = pages.find(
80
- (page) => normalizeRoute(page.route) === normalizeRoute(window.location.pathname)
81
- )
82
-
83
- if (matchingPage && matchingPage.id !== activePageId) {
84
- setActivePage(matchingPage.id)
85
- }
86
- }
87
-
88
- window.addEventListener('popstate', syncPageToLocation)
89
- return () => window.removeEventListener('popstate', syncPageToLocation)
90
- }, [activePageId, pages, previewMode, setActivePage])
91
-
92
- const handlePreviewNavigate = (
93
- event: ReactMouseEvent<HTMLAnchorElement>,
94
- href: string,
95
- target: string
96
- ) => {
97
- if (!previewMode || target === '_blank' || href.startsWith('#')) {
98
- return
99
- }
100
-
101
- const destination = new URL(href, window.location.href)
102
-
103
- if (destination.origin !== window.location.origin) {
104
- return
105
- }
106
-
107
- const matchingPage = pages.find(
108
- (page) => normalizeRoute(page.route) === normalizeRoute(destination.pathname)
109
- )
110
-
111
- event.preventDefault()
112
- event.stopPropagation()
113
-
114
- if (!matchingPage) {
115
- return
116
- }
117
-
118
- setActivePage(matchingPage.id)
119
- window.history.pushState({}, '', destination.pathname)
120
- }
121
-
122
- const rootElementProps = pageRoot
123
- ? {
124
- 'data-vb-page-root': 'true',
125
- 'data-canvas-node-id': pageRoot.id,
126
- 'data-canvas-node-tag': pageRoot.type,
127
- ...(previewMode
128
- ? {}
129
- : {
130
- tabIndex: 0,
131
- 'data-vb-editor-node': 'true',
132
- 'data-vb-selected': isPageRootSelected ? 'true' : undefined,
133
- 'data-vb-drop-target': isOver ? 'true' : undefined,
134
- onClick: (event: ReactMouseEvent<HTMLElement>) => {
135
- event.preventDefault()
136
- event.stopPropagation()
137
- setSelectedComponent(pageRoot.id)
138
- },
139
- onKeyDown: (event: KeyboardEvent) => {
140
- if (event.key === 'Enter' || event.key === ' ') {
141
- event.preventDefault()
142
- setSelectedComponent(pageRoot.id)
143
- }
144
- },
145
- }),
146
- }
147
- : undefined
148
-
149
- return (
150
- <CanvasOverlayContext.Provider value={overlayRoot}>
151
- <main
152
- className="min-w-0 flex-1 overflow-auto bg-[#eef1f4]"
153
- onClick={() => setSelectedComponent(null)}
154
- >
155
- <div className="sticky top-0 z-10 flex items-center justify-center gap-1 border-b border-gray-200 bg-white py-2">
156
- {(['mobile', 'tablet', 'desktop'] as const).map((size) => (
157
- <button
158
- key={size}
159
- type="button"
160
- onClick={(event) => {
161
- event.stopPropagation()
162
- setViewport(size)
163
- }}
164
- className={`cursor-pointer select-none rounded px-3 py-1 text-xs transition-colors ${
165
- viewport === size
166
- ? 'bg-gray-900 text-white'
167
- : 'text-gray-500 hover:bg-gray-100 hover:text-gray-900'
168
- }`}
169
- >
170
- {size.charAt(0).toUpperCase() + size.slice(1)}
171
- </button>
172
- ))}
173
- </div>
174
-
175
- <div
176
- className={`flex ${
177
- viewport === 'desktop'
178
- ? 'w-full min-w-0 justify-start'
179
- : 'min-w-full justify-center'
180
- } ${previewMode ? '' : 'px-4 py-6'}`}
181
- >
182
- <div
183
- ref={setNodeRef}
184
- className={`vb-canvas-surface min-h-[calc(100vh-10rem)] bg-white transition-[width,border-color,box-shadow] ${
185
- viewport === 'desktop' ? 'min-w-0 w-full' : 'shrink-0'
186
- } ${
187
- previewMode
188
- ? ''
189
- : 'vb-canvas-editor-frame shadow-sm'
190
- }`}
191
- data-vb-canvas-over={!previewMode && isOver ? 'true' : undefined}
192
- style={
193
- viewport === 'desktop'
194
- ? undefined
195
- : { width: getViewportWidth(viewport) }
196
- }
197
- onClick={(event) => {
198
- if (!pageRoot || previewMode) return
199
- event.stopPropagation()
200
- setSelectedComponent(pageRoot.id)
201
- }}
202
- >
203
- {!activePage || !pageRoot ? null : (
204
- <>
205
- {appShell.header && (
206
- <CanvasNode
207
- node={appShell.header}
208
- parentSelectionId={null}
209
- selectedNodeId={selectedComponentId}
210
- previewMode={previewMode}
211
- onSelect={setSelectedComponent}
212
- onDelete={() => removeAppShellComponent('header')}
213
- onPreviewNavigate={handlePreviewNavigate}
214
- />
215
- )}
216
-
217
- <PrimitiveElement
218
- node={pageRoot}
219
- previewMode={previewMode}
220
- elementRef={(element) => {
221
- pageRootRef.current = element
222
- }}
223
- elementProps={rootElementProps}
224
- onPreviewNavigate={handlePreviewNavigate}
225
- >
226
- {activePage.components.length === 0 ? (
227
- previewMode ? null : (
228
- <EmptyCanvas isOver={isOver} />
229
- )
230
- ) : (
231
- activePage.components.map((node, index) => (
232
- <DraggableCanvasNode
233
- key={node.id}
234
- node={node}
235
- parentNodeId={null}
236
- parentSelectionId={pageRoot.id}
237
- index={index}
238
- isLast={index === activePage.components.length - 1}
239
- orientation={rootOrientation}
240
- selectedNodeId={selectedComponentId}
241
- previewMode={previewMode}
242
- onSelect={setSelectedComponent}
243
- onDelete={removeNode}
244
- onPreviewNavigate={handlePreviewNavigate}
245
- />
246
- ))
247
- )}
248
- </PrimitiveElement>
249
-
250
- {appShell.footer && (
251
- <CanvasNode
252
- node={appShell.footer}
253
- parentSelectionId={null}
254
- selectedNodeId={selectedComponentId}
255
- previewMode={previewMode}
256
- onSelect={setSelectedComponent}
257
- onDelete={() => removeAppShellComponent('footer')}
258
- onPreviewNavigate={handlePreviewNavigate}
259
- />
260
- )}
261
- </>
262
- )}
263
- </div>
264
- </div>
265
-
266
- {!previewMode && pageRoot && (
267
- <FloatingNodeToolbar
268
- targetRef={pageRootRef}
269
- tag={`page: ${pageRoot.type}`}
270
- visible={isPageRootSelected}
271
- selected={isPageRootSelected}
272
- onSelect={() => setSelectedComponent(pageRoot.id)}
273
- />
274
- )}
275
- {!previewMode && (
276
- <FloatingElementOutline
277
- targetRef={pageRootRef}
278
- visible={isOver}
279
- label="Drop on page"
280
- />
281
- )}
282
- <div
283
- ref={setOverlayRoot}
284
- className="pointer-events-none fixed inset-0 z-[70]"
285
- data-vb-overlay-root
286
- />
287
- </main>
288
- </CanvasOverlayContext.Provider>
289
- )
290
- }
291
-
292
- function DraggableCanvasNode({
293
- node,
294
- parentNodeId,
295
- parentSelectionId,
296
- index,
297
- isLast,
298
- orientation,
299
- selectedNodeId,
300
- previewMode,
301
- onSelect,
302
- onDelete,
303
- onPreviewNavigate,
304
- }: {
305
- node: VisualNode
306
- parentNodeId: string | null
307
- parentSelectionId: string | null
308
- index: number
309
- isLast: boolean
310
- orientation: DropOrientation
311
- selectedNodeId: string | null
312
- previewMode: boolean
313
- onSelect: (id: string | null) => void
314
- onDelete: (id: string) => void
315
- onPreviewNavigate: PreviewNavigateHandler
316
- }) {
317
- const targetRef = useRef<HTMLElement | null>(null)
318
- const {
319
- attributes,
320
- listeners,
321
- setNodeRef,
322
- transform,
323
- isDragging,
324
- } = useDraggable({
325
- id: `canvas-node:${node.id}`,
326
- disabled: previewMode,
327
- data: {
328
- kind: 'canvas-node',
329
- nodeId: node.id,
330
- },
331
- })
332
- const dragStyle: CSSProperties | undefined = transform
333
- ? {
334
- transform: CSS.Translate.toString(transform),
335
- opacity: isDragging ? 0.55 : 1,
336
- position: 'relative',
337
- zIndex: isDragging ? 30 : undefined,
338
- }
339
- : undefined
340
-
341
- return (
342
- <>
343
- {!previewMode && (
344
- <InsertionDropZone
345
- targetRef={targetRef}
346
- parentNodeId={parentNodeId}
347
- index={index}
348
- edge="before"
349
- orientation={orientation}
350
- />
351
- )}
352
- <CanvasNode
353
- node={node}
354
- parentSelectionId={parentSelectionId}
355
- selectedNodeId={selectedNodeId}
356
- previewMode={previewMode}
357
- onSelect={onSelect}
358
- onDelete={onDelete}
359
- onPreviewNavigate={onPreviewNavigate}
360
- dragAttributes={attributes}
361
- dragListeners={listeners}
362
- dragNodeRef={setNodeRef}
363
- dragStyle={dragStyle}
364
- externalElementRef={targetRef}
365
- isDragging={isDragging}
366
- />
367
- {!previewMode && isLast && (
368
- <InsertionDropZone
369
- targetRef={targetRef}
370
- parentNodeId={parentNodeId}
371
- index={index + 1}
372
- edge="after"
373
- orientation={orientation}
374
- />
375
- )}
376
- </>
377
- )
378
- }
379
-
380
- type PreviewNavigateHandler = (
381
- event: ReactMouseEvent<HTMLAnchorElement>,
382
- href: string,
383
- target: string
384
- ) => void
385
-
386
- function CanvasNode({
387
- node,
388
- parentSelectionId,
389
- selectedNodeId,
390
- previewMode,
391
- onSelect,
392
- onDelete,
393
- onPreviewNavigate,
394
- dragAttributes,
395
- dragListeners,
396
- dragNodeRef,
397
- dragStyle,
398
- externalElementRef,
399
- isDragging = false,
400
- }: {
401
- node: VisualNode
402
- parentSelectionId: string | null
403
- selectedNodeId: string | null
404
- previewMode: boolean
405
- onSelect: (id: string | null) => void
406
- onDelete: (id: string) => void
407
- onPreviewNavigate: PreviewNavigateHandler
408
- dragAttributes?: DraggableAttributes
409
- dragListeners?: DraggableSyntheticListeners
410
- dragNodeRef?: (element: HTMLElement | null) => void
411
- dragStyle?: CSSProperties
412
- externalElementRef?: MutableRefObject<HTMLElement | null>
413
- isDragging?: boolean
414
- }) {
415
- const definition = primitiveRegistry[node.type]
416
- const acceptsChildren = definition.acceptsChildren
417
- const isSelected = !previewMode && selectedNodeId === node.id
418
- const elementRef = useRef<HTMLElement | null>(null)
419
- const [isHovered, setIsHovered] = useState(false)
420
- const hideToolbarTimeout = useRef<number | null>(null)
421
- const { isOver, setNodeRef: setDropNodeRef } = useDroppable({
422
- id: `node:${node.id}`,
423
- disabled: previewMode || !acceptsChildren,
424
- data: {
425
- kind: 'node-dropzone',
426
- nodeId: node.id,
427
- },
428
- })
429
-
430
- const setElementRef = useCallback(
431
- (element: HTMLElement | null) => {
432
- elementRef.current = element
433
- setDropNodeRef(element)
434
- dragNodeRef?.(element)
435
-
436
- if (externalElementRef) {
437
- externalElementRef.current = element
438
- }
439
- },
440
- [dragNodeRef, externalElementRef, setDropNodeRef]
441
- )
442
-
443
- useEffect(
444
- () => () => {
445
- if (hideToolbarTimeout.current !== null) {
446
- window.clearTimeout(hideToolbarTimeout.current)
447
- }
448
- },
449
- []
450
- )
451
-
452
- const showToolbar = () => {
453
- if (hideToolbarTimeout.current !== null) {
454
- window.clearTimeout(hideToolbarTimeout.current)
455
- }
456
- setIsHovered(true)
457
- }
458
-
459
- const scheduleToolbarHide = () => {
460
- if (isSelected) {
461
- return
462
- }
463
-
464
- hideToolbarTimeout.current = window.setTimeout(() => {
465
- setIsHovered(false)
466
- }, 120)
467
- }
468
-
469
- const dragElementListeners = dragListeners
470
- ? Object.fromEntries(
471
- Object.entries(dragListeners as Record<string, unknown>).filter(
472
- ([key]) => key !== 'onKeyDown'
473
- )
474
- )
475
- : {}
476
-
477
- const elementProps = previewMode
478
- ? undefined
479
- : {
480
- ...dragElementListeners,
481
- tabIndex: 0,
482
- style: dragStyle,
483
- 'data-canvas-node-id': node.id,
484
- 'data-canvas-node-tag': node.type,
485
- 'data-draggable-node-id': dragListeners ? node.id : undefined,
486
- 'data-draggable-node-tag': dragListeners ? node.type : undefined,
487
- 'data-vb-editor-node': 'true',
488
- 'data-vb-selected': isSelected ? 'true' : undefined,
489
- 'data-vb-hovered': isHovered ? 'true' : undefined,
490
- 'data-vb-drop-target': isOver ? 'true' : undefined,
491
- 'data-vb-dragging': isDragging ? 'true' : undefined,
492
- onClick: (event: ReactMouseEvent<HTMLElement>) => {
493
- event.preventDefault()
494
- event.stopPropagation()
495
- onSelect(node.id)
496
- },
497
- onPointerEnter: (event: ReactPointerEvent<HTMLElement>) => {
498
- event.stopPropagation()
499
- showToolbar()
500
- },
501
- onPointerLeave: (event: ReactPointerEvent<HTMLElement>) => {
502
- event.stopPropagation()
503
- scheduleToolbarHide()
504
- },
505
- onKeyDown: (event: KeyboardEvent) => {
506
- if (event.key === 'Delete' || event.key === 'Backspace') {
507
- event.preventDefault()
508
- event.stopPropagation()
509
- onDelete(node.id)
510
- }
511
- },
512
- }
513
-
514
- return (
515
- <>
516
- <PrimitiveElement
517
- node={node}
518
- previewMode={previewMode}
519
- elementRef={previewMode ? undefined : setElementRef}
520
- elementProps={elementProps}
521
- onPreviewNavigate={onPreviewNavigate}
522
- >
523
- {node.children.length > 0
524
- ? node.children.map((child, index) => (
525
- <DraggableCanvasNode
526
- key={child.id}
527
- node={child}
528
- parentNodeId={node.id}
529
- parentSelectionId={node.id}
530
- index={index}
531
- isLast={index === node.children.length - 1}
532
- orientation={getChildOrientation(node)}
533
- selectedNodeId={selectedNodeId}
534
- previewMode={previewMode}
535
- onSelect={onSelect}
536
- onDelete={onDelete}
537
- onPreviewNavigate={onPreviewNavigate}
538
- />
539
- ))
540
- : renderEditableNodeContent(node, previewMode)}
541
- </PrimitiveElement>
542
-
543
- {!previewMode && (
544
- <FloatingNodeToolbar
545
- targetRef={elementRef}
546
- tag={node.type}
547
- visible={isSelected}
548
- selected={isSelected}
549
- parentSelectionId={parentSelectionId}
550
- onSelect={() => onSelect(node.id)}
551
- onSelectParent={
552
- parentSelectionId ? () => onSelect(parentSelectionId) : undefined
553
- }
554
- onDelete={() => onDelete(node.id)}
555
- dragAttributes={dragAttributes}
556
- dragListeners={dragListeners}
557
- />
558
- )}
559
- {!previewMode && (
560
- <FloatingElementOutline
561
- targetRef={elementRef}
562
- visible={isOver}
563
- label="Drop inside"
564
- />
565
- )}
566
- </>
567
- )
568
- }
569
-
570
- function FloatingNodeToolbar({
571
- targetRef,
572
- tag,
573
- visible,
574
- selected,
575
- parentSelectionId,
576
- onSelect,
577
- onSelectParent,
578
- onDelete,
579
- onPointerEnter,
580
- onPointerLeave,
581
- dragAttributes,
582
- dragListeners,
583
- }: {
584
- targetRef: MutableRefObject<HTMLElement | null>
585
- tag: string
586
- visible: boolean
587
- selected: boolean
588
- parentSelectionId?: string | null
589
- onSelect: () => void
590
- onSelectParent?: () => void
591
- onDelete?: () => void
592
- onPointerEnter?: () => void
593
- onPointerLeave?: () => void
594
- dragAttributes?: DraggableAttributes
595
- dragListeners?: DraggableSyntheticListeners
596
- }) {
597
- const overlayRoot = useContext(CanvasOverlayContext)
598
- const rect = useElementRect(targetRef, visible)
599
-
600
- if (!visible || !rect || !overlayRoot) {
601
- return null
602
- }
603
-
604
- const top = rect.top >= 32 ? rect.top - 28 : rect.top + 4
605
- const left = Math.max(4, Math.min(rect.left, window.innerWidth - 112))
606
-
607
- return createPortal(
608
- <div
609
- className={`pointer-events-auto fixed z-[90] flex h-7 items-center overflow-hidden rounded-md border text-[10px] shadow-lg ${
610
- selected
611
- ? 'border-blue-300 bg-blue-600 text-white'
612
- : 'border-gray-600 bg-gray-900 text-gray-100'
613
- }`}
614
- style={{ top, left }}
615
- data-vb-floating-toolbar={tag}
616
- onPointerEnter={onPointerEnter}
617
- onPointerLeave={onPointerLeave}
618
- onClick={(event) => event.stopPropagation()}
619
- >
620
- <button
621
- type="button"
622
- {...dragAttributes}
623
- {...dragListeners}
624
- aria-label={dragListeners ? `Drag ${tag} to reorder` : `Select ${tag}`}
625
- onClick={(event) => {
626
- event.stopPropagation()
627
- onSelect()
628
- }}
629
- className={`flex h-full w-8 items-center justify-center font-mono text-xs font-semibold ${
630
- dragListeners
631
- ? 'touch-none cursor-grab active:cursor-grabbing'
632
- : 'cursor-pointer'
633
- }`}
634
- >
635
- ::
636
- </button>
637
- {parentSelectionId && onSelectParent && (
638
- <button
639
- type="button"
640
- aria-label="Select parent element"
641
- onPointerDown={(event) => event.stopPropagation()}
642
- onClick={(event) => {
643
- event.stopPropagation()
644
- onSelectParent()
645
- }}
646
- className="h-full w-8 cursor-pointer border-l border-white/15 text-xs text-gray-200 transition-colors hover:bg-white/10 hover:text-white"
647
- >
648
- Up
649
- </button>
650
- )}
651
- {onDelete && (
652
- <button
653
- type="button"
654
- aria-label={`Delete ${tag}`}
655
- onPointerDown={(event) => event.stopPropagation()}
656
- onClick={(event) => {
657
- event.stopPropagation()
658
- onDelete()
659
- }}
660
- className="flex h-full w-8 cursor-pointer items-center justify-center border-l border-white/15 text-sm text-red-200 transition-colors hover:bg-red-500 hover:text-white"
661
- >
662
- <span aria-hidden="true">x</span>
663
- </button>
664
- )}
665
- </div>,
666
- overlayRoot
667
- )
668
- }
669
-
670
- function FloatingElementOutline({
671
- targetRef,
672
- visible,
673
- label,
674
- }: {
675
- targetRef: MutableRefObject<HTMLElement | null>
676
- visible: boolean
677
- label: string
678
- }) {
679
- const overlayRoot = useContext(CanvasOverlayContext)
680
- const rect = useElementRect(targetRef, visible)
681
-
682
- if (!visible || !rect || !overlayRoot) {
683
- return null
684
- }
685
-
686
- return createPortal(
687
- <div
688
- className="pointer-events-none fixed z-[80] flex items-center justify-center border-2 border-blue-500 bg-blue-100/20"
689
- style={{
690
- top: rect.top,
691
- left: rect.left,
692
- width: rect.width,
693
- height: Math.max(rect.height, 12),
694
- }}
695
- >
696
- <span className="rounded bg-blue-600 px-2 py-1 text-[10px] font-medium text-white shadow">
697
- {label}
698
- </span>
699
- </div>,
700
- overlayRoot
701
- )
702
- }
703
-
704
- function InsertionDropZone({
705
- targetRef,
706
- parentNodeId,
707
- index,
708
- edge,
709
- orientation,
710
- }: {
711
- targetRef: MutableRefObject<HTMLElement | null>
712
- parentNodeId: string | null
713
- index: number
714
- edge: 'before' | 'after'
715
- orientation: DropOrientation
716
- }) {
717
- const overlayRoot = useContext(CanvasOverlayContext)
718
- const { active } = useDndContext()
719
- const rect = useElementRect(targetRef, active !== null)
720
- const { isOver, setNodeRef } = useDroppable({
721
- id: `insert:${parentNodeId ?? 'page-root'}:${index}:${edge}`,
722
- data: {
723
- kind: 'insertion-zone',
724
- parentNodeId,
725
- index,
726
- },
727
- })
728
-
729
- if (!rect || !overlayRoot) {
730
- return null
731
- }
732
-
733
- const isHorizontal = orientation === 'horizontal'
734
- const style: CSSProperties = isHorizontal
735
- ? {
736
- top: rect.top,
737
- left: edge === 'before' ? rect.left - 7 : rect.right - 7,
738
- width: 14,
739
- height: Math.max(rect.height, 12),
740
- }
741
- : {
742
- top: edge === 'before' ? rect.top - 7 : rect.bottom - 7,
743
- left: rect.left,
744
- width: Math.max(rect.width, 12),
745
- height: 14,
746
- }
747
-
748
- return createPortal(
749
- <div
750
- ref={setNodeRef}
751
- aria-hidden="true"
752
- data-drop-index={index}
753
- data-drop-parent={parentNodeId ?? 'page-root'}
754
- className={`fixed z-[85] flex items-center justify-center ${
755
- active ? 'pointer-events-auto' : 'pointer-events-none'
756
- }`}
757
- style={style}
758
- >
759
- <span
760
- className={`block rounded-full transition-all ${
761
- isHorizontal ? 'h-full w-0.5' : 'h-0.5 w-full'
762
- } ${
763
- isOver
764
- ? 'bg-blue-500 shadow-[0_0_0_3px_rgba(96,165,250,0.3)]'
765
- : 'bg-transparent'
766
- }`}
767
- />
768
- </div>,
769
- overlayRoot
770
- )
771
- }
772
-
773
- function useElementRect(
774
- targetRef: MutableRefObject<HTMLElement | null>,
775
- trackChanges: boolean
776
- ) {
777
- const [rect, setRect] = useState<FloatingRect | null>(null)
778
- const updateRect = useCallback(() => {
779
- const element = targetRef.current
780
-
781
- if (!element) {
782
- setRect(null)
783
- return
784
- }
785
-
786
- const nextRect = element.getBoundingClientRect()
787
- setRect({
788
- top: nextRect.top,
789
- right: nextRect.right,
790
- bottom: nextRect.bottom,
791
- left: nextRect.left,
792
- width: nextRect.width,
793
- height: nextRect.height,
794
- })
795
- }, [targetRef])
796
-
797
- useLayoutEffect(() => {
798
- updateRect()
799
- const animationFrame = window.requestAnimationFrame(updateRect)
800
-
801
- const element = targetRef.current
802
- if (!element) {
803
- return () => window.cancelAnimationFrame(animationFrame)
804
- }
805
-
806
- const resizeObserver = new ResizeObserver(updateRect)
807
- resizeObserver.observe(element)
808
-
809
- if (trackChanges) {
810
- window.addEventListener('resize', updateRect)
811
- document.addEventListener('scroll', updateRect, true)
812
- }
813
-
814
- return () => {
815
- window.cancelAnimationFrame(animationFrame)
816
- resizeObserver.disconnect()
817
- window.removeEventListener('resize', updateRect)
818
- document.removeEventListener('scroll', updateRect, true)
819
- }
820
- }, [targetRef, trackChanges, updateRect])
821
-
822
- return rect
823
- }
824
-
825
- function PrimitiveElement({
826
- node,
827
- children,
828
- previewMode,
829
- elementRef,
830
- elementProps,
831
- onPreviewNavigate,
832
- }: {
833
- node: VisualNode
834
- children: ReactNode
835
- previewMode: boolean
836
- elementRef?: (element: HTMLElement | null) => void
837
- elementProps?: Record<string, unknown>
838
- onPreviewNavigate: PreviewNavigateHandler
839
- }) {
840
- const className = String(node.props.className ?? '')
841
- const id = String(node.props.id ?? '')
842
- const baseProps: Record<string, unknown> = {
843
- id: id || undefined,
844
- className,
845
- ref: elementRef,
846
- ...elementProps,
847
- }
848
-
849
- if (voidElements.has(node.type)) {
850
- if (node.type === 'input') {
851
- return createElement('input', {
852
- ...baseProps,
853
- type: String(node.props.type ?? 'text'),
854
- name: String(node.props.name ?? '') || undefined,
855
- placeholder: String(node.props.placeholder ?? '') || undefined,
856
- })
857
- }
858
-
859
- if (node.type === 'img') {
860
- return createElement('img', {
861
- ...baseProps,
862
- src: String(node.props.src ?? ''),
863
- alt: String(node.props.alt ?? ''),
864
- })
865
- }
866
-
867
- if (node.type === 'source') {
868
- return createElement('source', {
869
- ...baseProps,
870
- src: String(node.props.src ?? ''),
871
- type: String(node.props.type ?? '') || undefined,
872
- })
873
- }
874
-
875
- return createElement(node.type, baseProps)
876
- }
877
-
878
- if (node.type === 'a') {
879
- const href = String(node.props.href ?? '#')
880
- const target = String(node.props.target ?? '_self')
881
- const editorOnClick = elementProps?.onClick as
882
- | ((event: ReactMouseEvent<HTMLAnchorElement>) => void)
883
- | undefined
884
-
885
- return createElement(
886
- 'a',
887
- {
888
- ...baseProps,
889
- href,
890
- target,
891
- onClick: (event: ReactMouseEvent<HTMLAnchorElement>) => {
892
- if (previewMode) {
893
- onPreviewNavigate(event, href, target)
894
- return
895
- }
896
-
897
- event.preventDefault()
898
- editorOnClick?.(event)
899
- },
900
- },
901
- children
902
- )
903
- }
904
-
905
- if (node.type === 'button') {
906
- return createElement(
907
- 'button',
908
- {
909
- ...baseProps,
910
- type: String(node.props.type ?? 'button'),
911
- },
912
- children
913
- )
914
- }
915
-
916
- if (node.type === 'label') {
917
- return createElement(
918
- 'label',
919
- {
920
- ...baseProps,
921
- htmlFor: String(node.props.htmlFor ?? '') || undefined,
922
- },
923
- children
924
- )
925
- }
926
-
927
- if (node.type === 'textarea') {
928
- return createElement('textarea', {
929
- ...baseProps,
930
- name: String(node.props.name ?? '') || undefined,
931
- placeholder: String(node.props.placeholder ?? '') || undefined,
932
- ...(previewMode
933
- ? { defaultValue: String(node.props.text ?? '') }
934
- : { value: String(node.props.text ?? ''), readOnly: true }),
935
- })
936
- }
937
-
938
- if (node.type === 'option') {
939
- return createElement(
940
- 'option',
941
- {
942
- ...baseProps,
943
- value: String(node.props.value ?? '') || undefined,
944
- },
945
- children
946
- )
947
- }
948
-
949
- if (node.type === 'optgroup') {
950
- return createElement(
951
- 'optgroup',
952
- {
953
- ...baseProps,
954
- label: String(node.props.label ?? 'Group'),
955
- },
956
- children
957
- )
958
- }
959
-
960
- if (node.type === 'video' || node.type === 'audio') {
961
- return createElement(node.type, {
962
- ...baseProps,
963
- src: String(node.props.src ?? '') || undefined,
964
- controls: node.props.controls !== false,
965
- })
966
- }
967
-
968
- if (node.type === 'iframe') {
969
- return createElement('iframe', {
970
- ...baseProps,
971
- src: String(node.props.src ?? ''),
972
- title: String(node.props.title ?? 'Embedded content'),
973
- })
974
- }
975
-
976
- return createElement(node.type, baseProps, children)
977
- }
978
-
979
- function renderEditableNodeContent(node: VisualNode, previewMode: boolean) {
980
- if (voidElements.has(node.type)) {
981
- return null
982
- }
983
-
984
- const text = String(node.props.text ?? '')
985
-
986
- if (text.trim() !== '') {
987
- return text
988
- }
989
-
990
- if (previewMode) {
991
- return ''
992
- }
993
-
994
- return (
995
- <span className="vb-empty-node-placeholder" aria-hidden="true">
996
- Drop an element
997
- </span>
998
- )
999
- }
1000
-
1001
- const voidElements = new Set<VisualNode['type']>([
1002
- 'br',
1003
- 'hr',
1004
- 'img',
1005
- 'input',
1006
- 'source',
1007
- 'col',
1008
- ])
1009
-
1010
- function EmptyCanvas({ isOver }: { isOver: boolean }) {
1011
- return (
1012
- <div
1013
- className={`flex min-h-[calc(100vh-10rem)] flex-col items-center justify-center border-2 border-dashed text-center transition-colors ${
1014
- isOver
1015
- ? 'border-blue-500 bg-blue-50 ring-2 ring-inset ring-blue-300'
1016
- : 'border-gray-300'
1017
- }`}
1018
- >
1019
- <p className="mb-3 text-3xl">+</p>
1020
- <p className="text-sm font-medium text-gray-500">
1021
- Drag any component or HTML element here
1022
- </p>
1023
- <p className="mt-1 text-xs text-gray-400">Your canvas is empty</p>
1024
- </div>
1025
- )
1026
- }
1027
-
1028
- function getViewportWidth(viewport: Viewport) {
1029
- if (viewport === 'mobile') return '390px'
1030
- if (viewport === 'tablet') return '768px'
1031
- return '100%'
1032
- }
1033
-
1034
- function getChildOrientation(node: VisualNode): DropOrientation {
1035
- const classNames = String(node.props.className ?? '')
1036
- .split(/\s+/)
1037
- .filter(Boolean)
1038
- const hasGrid = classNames.some(
1039
- (className) => className === 'grid' || className.endsWith(':grid')
1040
- )
1041
- const hasFlex = classNames.some(
1042
- (className) =>
1043
- className === 'flex' ||
1044
- className === 'inline-flex' ||
1045
- className.endsWith(':flex') ||
1046
- className.endsWith(':inline-flex')
1047
- )
1048
- const hasFlexColumn = classNames.some(
1049
- (className) =>
1050
- className === 'flex-col' || className.endsWith(':flex-col')
1051
- )
1052
-
1053
- return hasGrid || (hasFlex && !hasFlexColumn) ? 'horizontal' : 'vertical'
1054
- }
1055
-
1056
- function normalizeRoute(route: string) {
1057
- if (!route || route === '/') return '/'
1058
- return `/${route.replace(/^\/+|\/+$/g, '')}`
1059
- }
1
+ import {
2
+ useDndContext,
3
+ useDraggable,
4
+ useDroppable,
5
+ type DraggableAttributes,
6
+ type DraggableSyntheticListeners,
7
+ } from '@dnd-kit/core'
8
+ import { CSS } from '@dnd-kit/utilities'
9
+ import {
10
+ createElement,
11
+ createContext,
12
+ useCallback,
13
+ useContext,
14
+ useEffect,
15
+ useLayoutEffect,
16
+ useRef,
17
+ useState,
18
+ type CSSProperties,
19
+ type MouseEvent as ReactMouseEvent,
20
+ type MutableRefObject,
21
+ type PointerEvent as ReactPointerEvent,
22
+ type ReactNode,
23
+ } from 'react'
24
+ import { createPortal } from 'react-dom'
25
+ import { primitiveRegistry } from '../data/primitiveRegistry'
26
+ import { useAppStore } from '../stores/useAppStore'
27
+ import type { PrimitiveType, VisualNode } from '../types'
28
+
29
+ type DropOrientation = 'vertical' | 'horizontal'
30
+
31
+ interface FloatingRect {
32
+ top: number
33
+ right: number
34
+ bottom: number
35
+ left: number
36
+ width: number
37
+ height: number
38
+ }
39
+
40
+ const CanvasOverlayContext = createContext<HTMLElement | null>(null)
41
+
42
+ export default function Canvas() {
43
+ const [overlayRoot, setOverlayRoot] = useState<HTMLDivElement | null>(null)
44
+ const {
45
+ pages,
46
+ appShell,
47
+ activePageId,
48
+ selectedComponentId,
49
+ viewport,
50
+ previewMode,
51
+ projectConfig,
52
+ setActivePage,
53
+ setSelectedComponent,
54
+ setViewport,
55
+ removeNode,
56
+ removeAppShellComponent,
57
+ } = useAppStore()
58
+ const activePage = pages.find((page) => page.id === activePageId)
59
+ const pageRoot = activePage?.root
60
+ const pageRootRef = useRef<HTMLElement | null>(null)
61
+ const rootOrientation = pageRoot
62
+ ? getChildOrientation(pageRoot)
63
+ : 'vertical'
64
+ const isPageRootSelected =
65
+ !previewMode && pageRoot?.id !== undefined && selectedComponentId === pageRoot.id
66
+ const { isOver, setNodeRef } = useDroppable({
67
+ id: 'canvas:root',
68
+ disabled: previewMode || !activePage,
69
+ data: {
70
+ kind: 'canvas-root',
71
+ },
72
+ })
73
+
74
+ useEffect(() => {
75
+ if (!previewMode) {
76
+ return
77
+ }
78
+
79
+ const syncPageToLocation = () => {
80
+ const matchingPage = pages.find(
81
+ (page) => normalizeRoute(page.route) === normalizeRoute(window.location.pathname)
82
+ )
83
+
84
+ if (matchingPage && matchingPage.id !== activePageId) {
85
+ setActivePage(matchingPage.id)
86
+ }
87
+ }
88
+
89
+ window.addEventListener('popstate', syncPageToLocation)
90
+ return () => window.removeEventListener('popstate', syncPageToLocation)
91
+ }, [activePageId, pages, previewMode, setActivePage])
92
+
93
+ const handlePreviewNavigate = (
94
+ event: ReactMouseEvent<HTMLAnchorElement>,
95
+ href: string,
96
+ target: string
97
+ ) => {
98
+ if (!previewMode || target === '_blank' || href.startsWith('#')) {
99
+ return
100
+ }
101
+
102
+ const destination = new URL(href, window.location.href)
103
+
104
+ if (destination.origin !== window.location.origin) {
105
+ return
106
+ }
107
+
108
+ const matchingPage = pages.find(
109
+ (page) => normalizeRoute(page.route) === normalizeRoute(destination.pathname)
110
+ )
111
+
112
+ event.preventDefault()
113
+ event.stopPropagation()
114
+
115
+ if (!matchingPage) {
116
+ return
117
+ }
118
+
119
+ setActivePage(matchingPage.id)
120
+ window.history.pushState({}, '', destination.pathname)
121
+ }
122
+
123
+ const rootElementProps = pageRoot
124
+ ? {
125
+ 'data-vb-page-root': 'true',
126
+ 'data-canvas-node-id': pageRoot.id,
127
+ 'data-canvas-node-tag': pageRoot.type,
128
+ ...(previewMode
129
+ ? {}
130
+ : {
131
+ tabIndex: 0,
132
+ 'data-vb-editor-node': 'true',
133
+ 'data-vb-selected': isPageRootSelected ? 'true' : undefined,
134
+ 'data-vb-drop-target': isOver ? 'true' : undefined,
135
+ onClick: (event: ReactMouseEvent<HTMLElement>) => {
136
+ event.preventDefault()
137
+ event.stopPropagation()
138
+ setSelectedComponent(pageRoot.id)
139
+ },
140
+ onKeyDown: (event: KeyboardEvent) => {
141
+ if (event.key === 'Enter' || event.key === ' ') {
142
+ event.preventDefault()
143
+ setSelectedComponent(pageRoot.id)
144
+ }
145
+ },
146
+ }),
147
+ }
148
+ : undefined
149
+
150
+ return (
151
+ <CanvasOverlayContext.Provider value={overlayRoot}>
152
+ <main
153
+ className="vb-canvas-workspace min-w-0 flex-1 overflow-auto bg-[#eef1f4]"
154
+ onClick={() => setSelectedComponent(null)}
155
+ >
156
+ <div data-vb-editor-chrome className="vb-canvas-toolbar sticky top-0 z-10 flex items-center justify-center gap-1 border-b border-gray-200 bg-white py-2">
157
+ {(['mobile', 'tablet', 'desktop'] as const).map((size) => (
158
+ <button
159
+ key={size}
160
+ type="button"
161
+ onClick={(event) => {
162
+ event.stopPropagation()
163
+ setViewport(size)
164
+ }}
165
+ className={`cursor-pointer select-none rounded px-3 py-1 text-xs transition-colors ${
166
+ viewport === size
167
+ ? 'bg-gray-900 text-white'
168
+ : 'text-gray-500 hover:bg-gray-100 hover:text-gray-900'
169
+ }`}
170
+ data-vb-active={viewport === size ? 'true' : undefined}
171
+ >
172
+ {size.charAt(0).toUpperCase() + size.slice(1)}
173
+ </button>
174
+ ))}
175
+ </div>
176
+
177
+ <div
178
+ className={`flex ${
179
+ viewport === 'desktop'
180
+ ? 'w-full min-w-0 justify-start'
181
+ : 'min-w-full justify-center'
182
+ } ${previewMode ? '' : 'px-4 py-6'}`}
183
+ >
184
+ <div
185
+ ref={setNodeRef}
186
+ className={`vb-canvas-surface min-h-[calc(100vh-10rem)] bg-white transition-[width,border-color,box-shadow] ${
187
+ viewport === 'desktop' ? 'min-w-0 w-full' : 'shrink-0'
188
+ } ${
189
+ previewMode
190
+ ? ''
191
+ : 'vb-canvas-editor-frame shadow-sm'
192
+ }`}
193
+ data-vb-canvas-over={!previewMode && isOver ? 'true' : undefined}
194
+ style={
195
+ projectConfig.editor.responsiveWidths[viewport] === 'fluid'
196
+ ? undefined
197
+ : {
198
+ width: `${projectConfig.editor.responsiveWidths[viewport]}px`,
199
+ }
200
+ }
201
+ onClick={(event) => {
202
+ if (!pageRoot || previewMode) return
203
+ event.stopPropagation()
204
+ setSelectedComponent(pageRoot.id)
205
+ }}
206
+ >
207
+ {!activePage || !pageRoot ? null : (
208
+ <>
209
+ {appShell.header && (
210
+ <CanvasNode
211
+ node={appShell.header}
212
+ parentSelectionId={null}
213
+ selectedNodeId={selectedComponentId}
214
+ previewMode={previewMode}
215
+ onSelect={setSelectedComponent}
216
+ onDelete={() => removeAppShellComponent('header')}
217
+ onPreviewNavigate={handlePreviewNavigate}
218
+ />
219
+ )}
220
+
221
+ <PrimitiveElement
222
+ node={pageRoot}
223
+ previewMode={previewMode}
224
+ elementRef={(element) => {
225
+ pageRootRef.current = element
226
+ }}
227
+ elementProps={rootElementProps}
228
+ onPreviewNavigate={handlePreviewNavigate}
229
+ >
230
+ {activePage.components.length === 0 ? (
231
+ previewMode ? null : (
232
+ <EmptyCanvas isOver={isOver} />
233
+ )
234
+ ) : (
235
+ activePage.components.map((node, index) => (
236
+ <DraggableCanvasNode
237
+ key={node.id}
238
+ node={node}
239
+ parentNodeId={null}
240
+ parentSelectionId={pageRoot.id}
241
+ index={index}
242
+ isLast={index === activePage.components.length - 1}
243
+ orientation={rootOrientation}
244
+ selectedNodeId={selectedComponentId}
245
+ previewMode={previewMode}
246
+ onSelect={setSelectedComponent}
247
+ onDelete={removeNode}
248
+ onPreviewNavigate={handlePreviewNavigate}
249
+ />
250
+ ))
251
+ )}
252
+ </PrimitiveElement>
253
+
254
+ {appShell.footer && (
255
+ <CanvasNode
256
+ node={appShell.footer}
257
+ parentSelectionId={null}
258
+ selectedNodeId={selectedComponentId}
259
+ previewMode={previewMode}
260
+ onSelect={setSelectedComponent}
261
+ onDelete={() => removeAppShellComponent('footer')}
262
+ onPreviewNavigate={handlePreviewNavigate}
263
+ />
264
+ )}
265
+ </>
266
+ )}
267
+ </div>
268
+ </div>
269
+
270
+ {!previewMode && pageRoot && (
271
+ <FloatingNodeToolbar
272
+ targetRef={pageRootRef}
273
+ tag={`page: ${pageRoot.type}`}
274
+ visible={isPageRootSelected}
275
+ selected={isPageRootSelected}
276
+ onSelect={() => setSelectedComponent(pageRoot.id)}
277
+ />
278
+ )}
279
+ {!previewMode && (
280
+ <FloatingElementOutline
281
+ targetRef={pageRootRef}
282
+ visible={isOver}
283
+ label="Drop on page"
284
+ />
285
+ )}
286
+ <div
287
+ ref={setOverlayRoot}
288
+ className="pointer-events-none fixed inset-0 z-[70]"
289
+ data-vb-overlay-root
290
+ />
291
+ </main>
292
+ </CanvasOverlayContext.Provider>
293
+ )
294
+ }
295
+
296
+ function DraggableCanvasNode({
297
+ node,
298
+ parentNodeId,
299
+ parentSelectionId,
300
+ index,
301
+ isLast,
302
+ orientation,
303
+ selectedNodeId,
304
+ previewMode,
305
+ onSelect,
306
+ onDelete,
307
+ onPreviewNavigate,
308
+ }: {
309
+ node: VisualNode
310
+ parentNodeId: string | null
311
+ parentSelectionId: string | null
312
+ index: number
313
+ isLast: boolean
314
+ orientation: DropOrientation
315
+ selectedNodeId: string | null
316
+ previewMode: boolean
317
+ onSelect: (id: string | null) => void
318
+ onDelete: (id: string) => void
319
+ onPreviewNavigate: PreviewNavigateHandler
320
+ }) {
321
+ const targetRef = useRef<HTMLElement | null>(null)
322
+ const {
323
+ attributes,
324
+ listeners,
325
+ setNodeRef,
326
+ transform,
327
+ isDragging,
328
+ } = useDraggable({
329
+ id: `canvas-node:${node.id}`,
330
+ disabled: previewMode,
331
+ data: {
332
+ kind: 'canvas-node',
333
+ nodeId: node.id,
334
+ },
335
+ })
336
+ const dragStyle: CSSProperties | undefined = transform
337
+ ? {
338
+ transform: CSS.Translate.toString(transform),
339
+ opacity: isDragging ? 0.55 : 1,
340
+ position: 'relative',
341
+ zIndex: isDragging ? 30 : undefined,
342
+ }
343
+ : undefined
344
+
345
+ return (
346
+ <>
347
+ {!previewMode && (
348
+ <InsertionDropZone
349
+ targetRef={targetRef}
350
+ parentNodeId={parentNodeId}
351
+ index={index}
352
+ edge="before"
353
+ orientation={orientation}
354
+ />
355
+ )}
356
+ <CanvasNode
357
+ node={node}
358
+ parentSelectionId={parentSelectionId}
359
+ selectedNodeId={selectedNodeId}
360
+ previewMode={previewMode}
361
+ onSelect={onSelect}
362
+ onDelete={onDelete}
363
+ onPreviewNavigate={onPreviewNavigate}
364
+ dragAttributes={attributes}
365
+ dragListeners={listeners}
366
+ dragNodeRef={setNodeRef}
367
+ dragStyle={dragStyle}
368
+ externalElementRef={targetRef}
369
+ isDragging={isDragging}
370
+ />
371
+ {!previewMode && isLast && (
372
+ <InsertionDropZone
373
+ targetRef={targetRef}
374
+ parentNodeId={parentNodeId}
375
+ index={index + 1}
376
+ edge="after"
377
+ orientation={orientation}
378
+ />
379
+ )}
380
+ </>
381
+ )
382
+ }
383
+
384
+ type PreviewNavigateHandler = (
385
+ event: ReactMouseEvent<HTMLAnchorElement>,
386
+ href: string,
387
+ target: string
388
+ ) => void
389
+
390
+ function CanvasNode({
391
+ node,
392
+ parentSelectionId,
393
+ selectedNodeId,
394
+ previewMode,
395
+ onSelect,
396
+ onDelete,
397
+ onPreviewNavigate,
398
+ dragAttributes,
399
+ dragListeners,
400
+ dragNodeRef,
401
+ dragStyle,
402
+ externalElementRef,
403
+ isDragging = false,
404
+ }: {
405
+ node: VisualNode
406
+ parentSelectionId: string | null
407
+ selectedNodeId: string | null
408
+ previewMode: boolean
409
+ onSelect: (id: string | null) => void
410
+ onDelete: (id: string) => void
411
+ onPreviewNavigate: PreviewNavigateHandler
412
+ dragAttributes?: DraggableAttributes
413
+ dragListeners?: DraggableSyntheticListeners
414
+ dragNodeRef?: (element: HTMLElement | null) => void
415
+ dragStyle?: CSSProperties
416
+ externalElementRef?: MutableRefObject<HTMLElement | null>
417
+ isDragging?: boolean
418
+ }) {
419
+ const customComponents = useAppStore((state) => state.customComponents)
420
+ const customDefinition = customComponents.find(
421
+ (component) => component.name === node.type
422
+ )
423
+ const definition = primitiveRegistry[node.type as PrimitiveType]
424
+ const acceptsChildren =
425
+ definition?.acceptsChildren ?? Boolean(customDefinition)
426
+ const canvasChildren = customDefinition
427
+ ? getCustomComponentTreeChildren(node)
428
+ : node.children
429
+ const isSelected = !previewMode && selectedNodeId === node.id
430
+ const elementRef = useRef<HTMLElement | null>(null)
431
+ const [isHovered, setIsHovered] = useState(false)
432
+ const hideToolbarTimeout = useRef<number | null>(null)
433
+ const { isOver, setNodeRef: setDropNodeRef } = useDroppable({
434
+ id: `node:${node.id}`,
435
+ disabled: previewMode || !acceptsChildren,
436
+ data: {
437
+ kind: 'node-dropzone',
438
+ nodeId: node.id,
439
+ },
440
+ })
441
+
442
+ const setElementRef = useCallback(
443
+ (element: HTMLElement | null) => {
444
+ elementRef.current = element
445
+ setDropNodeRef(element)
446
+ dragNodeRef?.(element)
447
+
448
+ if (externalElementRef) {
449
+ externalElementRef.current = element
450
+ }
451
+ },
452
+ [dragNodeRef, externalElementRef, setDropNodeRef]
453
+ )
454
+
455
+ useEffect(
456
+ () => () => {
457
+ if (hideToolbarTimeout.current !== null) {
458
+ window.clearTimeout(hideToolbarTimeout.current)
459
+ }
460
+ },
461
+ []
462
+ )
463
+
464
+ const showToolbar = () => {
465
+ if (hideToolbarTimeout.current !== null) {
466
+ window.clearTimeout(hideToolbarTimeout.current)
467
+ }
468
+ setIsHovered(true)
469
+ }
470
+
471
+ const scheduleToolbarHide = () => {
472
+ if (isSelected) {
473
+ return
474
+ }
475
+
476
+ hideToolbarTimeout.current = window.setTimeout(() => {
477
+ setIsHovered(false)
478
+ }, 120)
479
+ }
480
+
481
+ const dragElementListeners = dragListeners
482
+ ? Object.fromEntries(
483
+ Object.entries(dragListeners as Record<string, unknown>).filter(
484
+ ([key]) => key !== 'onKeyDown'
485
+ )
486
+ )
487
+ : {}
488
+
489
+ const elementProps = previewMode
490
+ ? undefined
491
+ : {
492
+ ...dragElementListeners,
493
+ tabIndex: 0,
494
+ style: dragStyle,
495
+ 'data-canvas-node-id': node.id,
496
+ 'data-canvas-node-tag': node.type,
497
+ 'data-draggable-node-id': dragListeners ? node.id : undefined,
498
+ 'data-draggable-node-tag': dragListeners ? node.type : undefined,
499
+ 'data-vb-editor-node': 'true',
500
+ 'data-vb-selected': isSelected ? 'true' : undefined,
501
+ 'data-vb-hovered': isHovered ? 'true' : undefined,
502
+ 'data-vb-drop-target': isOver ? 'true' : undefined,
503
+ 'data-vb-dragging': isDragging ? 'true' : undefined,
504
+ onClick: (event: ReactMouseEvent<HTMLElement>) => {
505
+ event.preventDefault()
506
+ event.stopPropagation()
507
+ onSelect(node.id)
508
+ },
509
+ onPointerEnter: (event: ReactPointerEvent<HTMLElement>) => {
510
+ event.stopPropagation()
511
+ showToolbar()
512
+ },
513
+ onPointerLeave: (event: ReactPointerEvent<HTMLElement>) => {
514
+ event.stopPropagation()
515
+ scheduleToolbarHide()
516
+ },
517
+ onKeyDown: (event: KeyboardEvent) => {
518
+ if (event.key === 'Delete' || event.key === 'Backspace') {
519
+ event.preventDefault()
520
+ event.stopPropagation()
521
+ onDelete(node.id)
522
+ }
523
+ },
524
+ }
525
+
526
+ return (
527
+ <>
528
+ <PrimitiveElement
529
+ node={node}
530
+ customDefinition={customDefinition}
531
+ previewMode={previewMode}
532
+ elementRef={previewMode ? undefined : setElementRef}
533
+ elementProps={elementProps}
534
+ onPreviewNavigate={onPreviewNavigate}
535
+ >
536
+ {canvasChildren.length > 0
537
+ ? canvasChildren.map((child, index) => (
538
+ <DraggableCanvasNode
539
+ key={child.id}
540
+ node={child}
541
+ parentNodeId={node.id}
542
+ parentSelectionId={node.id}
543
+ index={index}
544
+ isLast={index === canvasChildren.length - 1}
545
+ orientation={getChildOrientation(node)}
546
+ selectedNodeId={selectedNodeId}
547
+ previewMode={previewMode}
548
+ onSelect={onSelect}
549
+ onDelete={onDelete}
550
+ onPreviewNavigate={onPreviewNavigate}
551
+ />
552
+ ))
553
+ : renderEditableNodeContent(node, previewMode)}
554
+ </PrimitiveElement>
555
+
556
+ {!previewMode && (
557
+ <FloatingNodeToolbar
558
+ targetRef={elementRef}
559
+ tag={node.type}
560
+ visible={isSelected}
561
+ selected={isSelected}
562
+ parentSelectionId={parentSelectionId}
563
+ onSelect={() => onSelect(node.id)}
564
+ onSelectParent={
565
+ parentSelectionId ? () => onSelect(parentSelectionId) : undefined
566
+ }
567
+ onDelete={() => onDelete(node.id)}
568
+ dragAttributes={dragAttributes}
569
+ dragListeners={dragListeners}
570
+ />
571
+ )}
572
+ {!previewMode && (
573
+ <FloatingElementOutline
574
+ targetRef={elementRef}
575
+ visible={isOver}
576
+ label="Drop inside"
577
+ />
578
+ )}
579
+ </>
580
+ )
581
+ }
582
+
583
+ function FloatingNodeToolbar({
584
+ targetRef,
585
+ tag,
586
+ visible,
587
+ selected,
588
+ parentSelectionId,
589
+ onSelect,
590
+ onSelectParent,
591
+ onDelete,
592
+ onPointerEnter,
593
+ onPointerLeave,
594
+ dragAttributes,
595
+ dragListeners,
596
+ }: {
597
+ targetRef: MutableRefObject<HTMLElement | null>
598
+ tag: string
599
+ visible: boolean
600
+ selected: boolean
601
+ parentSelectionId?: string | null
602
+ onSelect: () => void
603
+ onSelectParent?: () => void
604
+ onDelete?: () => void
605
+ onPointerEnter?: () => void
606
+ onPointerLeave?: () => void
607
+ dragAttributes?: DraggableAttributes
608
+ dragListeners?: DraggableSyntheticListeners
609
+ }) {
610
+ const overlayRoot = useContext(CanvasOverlayContext)
611
+ const rect = useElementRect(targetRef, visible)
612
+
613
+ if (!visible || !rect || !overlayRoot) {
614
+ return null
615
+ }
616
+
617
+ const top = rect.top >= 32 ? rect.top - 28 : rect.top + 4
618
+ const left = Math.max(4, Math.min(rect.left, window.innerWidth - 112))
619
+
620
+ return createPortal(
621
+ <div
622
+ className={`pointer-events-auto fixed z-[90] flex h-7 items-center overflow-hidden rounded-md border text-[10px] shadow-lg ${
623
+ selected
624
+ ? 'border-blue-300 bg-blue-600 text-white'
625
+ : 'border-gray-600 bg-gray-900 text-gray-100'
626
+ }`}
627
+ style={{ top, left }}
628
+ data-vb-floating-toolbar={tag}
629
+ onPointerEnter={onPointerEnter}
630
+ onPointerLeave={onPointerLeave}
631
+ onClick={(event) => event.stopPropagation()}
632
+ >
633
+ <button
634
+ type="button"
635
+ {...dragAttributes}
636
+ {...dragListeners}
637
+ aria-label={dragListeners ? `Drag ${tag} to reorder` : `Select ${tag}`}
638
+ onClick={(event) => {
639
+ event.stopPropagation()
640
+ onSelect()
641
+ }}
642
+ className={`flex h-full w-8 items-center justify-center font-mono text-xs font-semibold ${
643
+ dragListeners
644
+ ? 'touch-none cursor-grab active:cursor-grabbing'
645
+ : 'cursor-pointer'
646
+ }`}
647
+ >
648
+ ::
649
+ </button>
650
+ {parentSelectionId && onSelectParent && (
651
+ <button
652
+ type="button"
653
+ aria-label="Select parent element"
654
+ onPointerDown={(event) => event.stopPropagation()}
655
+ onClick={(event) => {
656
+ event.stopPropagation()
657
+ onSelectParent()
658
+ }}
659
+ className="h-full w-8 cursor-pointer border-l border-white/15 text-xs text-gray-200 transition-colors hover:bg-white/10 hover:text-white"
660
+ >
661
+ Up
662
+ </button>
663
+ )}
664
+ {onDelete && (
665
+ <button
666
+ type="button"
667
+ aria-label={`Delete ${tag}`}
668
+ onPointerDown={(event) => event.stopPropagation()}
669
+ onClick={(event) => {
670
+ event.stopPropagation()
671
+ onDelete()
672
+ }}
673
+ className="flex h-full w-8 cursor-pointer items-center justify-center border-l border-white/15 text-sm text-red-200 transition-colors hover:bg-red-500 hover:text-white"
674
+ >
675
+ <span aria-hidden="true">x</span>
676
+ </button>
677
+ )}
678
+ </div>,
679
+ overlayRoot
680
+ )
681
+ }
682
+
683
+ function FloatingElementOutline({
684
+ targetRef,
685
+ visible,
686
+ label,
687
+ }: {
688
+ targetRef: MutableRefObject<HTMLElement | null>
689
+ visible: boolean
690
+ label: string
691
+ }) {
692
+ const overlayRoot = useContext(CanvasOverlayContext)
693
+ const rect = useElementRect(targetRef, visible)
694
+
695
+ if (!visible || !rect || !overlayRoot) {
696
+ return null
697
+ }
698
+
699
+ return createPortal(
700
+ <div
701
+ className="pointer-events-none fixed z-[80] flex items-center justify-center border-2 border-blue-500 bg-blue-100/20"
702
+ style={{
703
+ top: rect.top,
704
+ left: rect.left,
705
+ width: rect.width,
706
+ height: Math.max(rect.height, 12),
707
+ }}
708
+ >
709
+ <span className="rounded bg-blue-600 px-2 py-1 text-[10px] font-medium text-white shadow">
710
+ {label}
711
+ </span>
712
+ </div>,
713
+ overlayRoot
714
+ )
715
+ }
716
+
717
+ function InsertionDropZone({
718
+ targetRef,
719
+ parentNodeId,
720
+ index,
721
+ edge,
722
+ orientation,
723
+ }: {
724
+ targetRef: MutableRefObject<HTMLElement | null>
725
+ parentNodeId: string | null
726
+ index: number
727
+ edge: 'before' | 'after'
728
+ orientation: DropOrientation
729
+ }) {
730
+ const overlayRoot = useContext(CanvasOverlayContext)
731
+ const { active } = useDndContext()
732
+ const rect = useElementRect(targetRef, active !== null)
733
+ const { isOver, setNodeRef } = useDroppable({
734
+ id: `insert:${parentNodeId ?? 'page-root'}:${index}:${edge}`,
735
+ data: {
736
+ kind: 'insertion-zone',
737
+ parentNodeId,
738
+ index,
739
+ },
740
+ })
741
+
742
+ if (!rect || !overlayRoot) {
743
+ return null
744
+ }
745
+
746
+ const isHorizontal = orientation === 'horizontal'
747
+ const style: CSSProperties = isHorizontal
748
+ ? {
749
+ top: rect.top,
750
+ left: edge === 'before' ? rect.left - 7 : rect.right - 7,
751
+ width: 14,
752
+ height: Math.max(rect.height, 12),
753
+ }
754
+ : {
755
+ top: edge === 'before' ? rect.top - 7 : rect.bottom - 7,
756
+ left: rect.left,
757
+ width: Math.max(rect.width, 12),
758
+ height: 14,
759
+ }
760
+
761
+ return createPortal(
762
+ <div
763
+ ref={setNodeRef}
764
+ aria-hidden="true"
765
+ data-drop-index={index}
766
+ data-drop-parent={parentNodeId ?? 'page-root'}
767
+ className={`fixed z-[85] flex items-center justify-center ${
768
+ active ? 'pointer-events-auto' : 'pointer-events-none'
769
+ }`}
770
+ style={style}
771
+ >
772
+ <span
773
+ className={`block rounded-full transition-all ${
774
+ isHorizontal ? 'h-full w-0.5' : 'h-0.5 w-full'
775
+ } ${
776
+ isOver
777
+ ? 'bg-blue-500 shadow-[0_0_0_3px_rgba(96,165,250,0.3)]'
778
+ : 'bg-transparent'
779
+ }`}
780
+ />
781
+ </div>,
782
+ overlayRoot
783
+ )
784
+ }
785
+
786
+ function useElementRect(
787
+ targetRef: MutableRefObject<HTMLElement | null>,
788
+ trackChanges: boolean
789
+ ) {
790
+ const [rect, setRect] = useState<FloatingRect | null>(null)
791
+ const updateRect = useCallback(() => {
792
+ const element = targetRef.current
793
+
794
+ if (!element) {
795
+ setRect(null)
796
+ return
797
+ }
798
+
799
+ const nextRect = element.getBoundingClientRect()
800
+ setRect({
801
+ top: nextRect.top,
802
+ right: nextRect.right,
803
+ bottom: nextRect.bottom,
804
+ left: nextRect.left,
805
+ width: nextRect.width,
806
+ height: nextRect.height,
807
+ })
808
+ }, [targetRef])
809
+
810
+ useLayoutEffect(() => {
811
+ updateRect()
812
+ const animationFrame = window.requestAnimationFrame(updateRect)
813
+
814
+ const element = targetRef.current
815
+ if (!element) {
816
+ return () => window.cancelAnimationFrame(animationFrame)
817
+ }
818
+
819
+ const resizeObserver = new ResizeObserver(updateRect)
820
+ resizeObserver.observe(element)
821
+
822
+ if (trackChanges) {
823
+ window.addEventListener('resize', updateRect)
824
+ document.addEventListener('scroll', updateRect, true)
825
+ }
826
+
827
+ return () => {
828
+ window.cancelAnimationFrame(animationFrame)
829
+ resizeObserver.disconnect()
830
+ window.removeEventListener('resize', updateRect)
831
+ document.removeEventListener('scroll', updateRect, true)
832
+ }
833
+ }, [targetRef, trackChanges, updateRect])
834
+
835
+ return rect
836
+ }
837
+
838
+ function PrimitiveElement({
839
+ node,
840
+ customDefinition,
841
+ children,
842
+ previewMode,
843
+ elementRef,
844
+ elementProps,
845
+ onPreviewNavigate,
846
+ }: {
847
+ node: VisualNode
848
+ customDefinition?: import('../types').CustomComponentDefinition
849
+ children: ReactNode
850
+ previewMode: boolean
851
+ elementRef?: (element: HTMLElement | null) => void
852
+ elementProps?: Record<string, unknown>
853
+ onPreviewNavigate: PreviewNavigateHandler
854
+ }) {
855
+ const className = String(node.props.className ?? '')
856
+ const id = String(node.props.id ?? '')
857
+ const baseProps: Record<string, unknown> = {
858
+ id: id || undefined,
859
+ className,
860
+ ref: elementRef,
861
+ ...elementProps,
862
+ }
863
+
864
+ if (customDefinition) {
865
+ const preview = customDefinition.preview
866
+ const previewClassName = String(
867
+ preview?.rootProps.className ?? ''
868
+ )
869
+ const customClassName = Array.from(
870
+ new Set(
871
+ `${previewClassName} ${className}`
872
+ .split(/\s+/)
873
+ .filter(Boolean)
874
+ )
875
+ ).join(' ')
876
+ return createElement(
877
+ String(node.props.wrapperTag ?? preview?.rootTag ?? 'div'),
878
+ {
879
+ ...baseProps,
880
+ id:
881
+ id ||
882
+ String(preview?.rootProps.id ?? '') ||
883
+ undefined,
884
+ className: `rounded-lg border border-dashed border-violet-400/60 bg-violet-500/10 p-4 ${customClassName}`,
885
+ },
886
+ children ||
887
+ (!previewMode ? (
888
+ <span
889
+ className="vb-empty-node-placeholder"
890
+ aria-hidden="true"
891
+ >
892
+ Drop an element
893
+ </span>
894
+ ) : null)
895
+ )
896
+ }
897
+
898
+ if (voidElements.has(node.type)) {
899
+ if (node.type === 'input') {
900
+ return createElement('input', {
901
+ ...baseProps,
902
+ type: String(node.props.type ?? 'text'),
903
+ name: String(node.props.name ?? '') || undefined,
904
+ placeholder: String(node.props.placeholder ?? '') || undefined,
905
+ })
906
+ }
907
+
908
+ if (node.type === 'img') {
909
+ return createElement('img', {
910
+ ...baseProps,
911
+ src: String(node.props.src ?? ''),
912
+ alt: String(node.props.alt ?? ''),
913
+ })
914
+ }
915
+
916
+ if (node.type === 'source') {
917
+ return createElement('source', {
918
+ ...baseProps,
919
+ src: String(node.props.src ?? ''),
920
+ type: String(node.props.type ?? '') || undefined,
921
+ })
922
+ }
923
+
924
+ return createElement(node.type, baseProps)
925
+ }
926
+
927
+ if (node.type === 'a') {
928
+ const href = String(node.props.href ?? '#')
929
+ const target = String(node.props.target ?? '_self')
930
+ const editorOnClick = elementProps?.onClick as
931
+ | ((event: ReactMouseEvent<HTMLAnchorElement>) => void)
932
+ | undefined
933
+
934
+ return createElement(
935
+ 'a',
936
+ {
937
+ ...baseProps,
938
+ href,
939
+ target,
940
+ onClick: (event: ReactMouseEvent<HTMLAnchorElement>) => {
941
+ if (previewMode) {
942
+ onPreviewNavigate(event, href, target)
943
+ return
944
+ }
945
+
946
+ event.preventDefault()
947
+ editorOnClick?.(event)
948
+ },
949
+ },
950
+ children
951
+ )
952
+ }
953
+
954
+ if (node.type === 'button') {
955
+ return createElement(
956
+ 'button',
957
+ {
958
+ ...baseProps,
959
+ type: String(node.props.type ?? 'button'),
960
+ },
961
+ children
962
+ )
963
+ }
964
+
965
+ if (node.type === 'label') {
966
+ return createElement(
967
+ 'label',
968
+ {
969
+ ...baseProps,
970
+ htmlFor: String(node.props.htmlFor ?? '') || undefined,
971
+ },
972
+ children
973
+ )
974
+ }
975
+
976
+ if (node.type === 'textarea') {
977
+ return createElement('textarea', {
978
+ ...baseProps,
979
+ name: String(node.props.name ?? '') || undefined,
980
+ placeholder: String(node.props.placeholder ?? '') || undefined,
981
+ ...(previewMode
982
+ ? { defaultValue: String(node.props.text ?? '') }
983
+ : { value: String(node.props.text ?? ''), readOnly: true }),
984
+ })
985
+ }
986
+
987
+ if (node.type === 'option') {
988
+ return createElement(
989
+ 'option',
990
+ {
991
+ ...baseProps,
992
+ value: String(node.props.value ?? '') || undefined,
993
+ },
994
+ children
995
+ )
996
+ }
997
+
998
+ if (node.type === 'optgroup') {
999
+ return createElement(
1000
+ 'optgroup',
1001
+ {
1002
+ ...baseProps,
1003
+ label: String(node.props.label ?? 'Group'),
1004
+ },
1005
+ children
1006
+ )
1007
+ }
1008
+
1009
+ if (node.type === 'video' || node.type === 'audio') {
1010
+ return createElement(node.type, {
1011
+ ...baseProps,
1012
+ src: String(node.props.src ?? '') || undefined,
1013
+ controls: node.props.controls !== false,
1014
+ })
1015
+ }
1016
+
1017
+ if (node.type === 'iframe') {
1018
+ return createElement('iframe', {
1019
+ ...baseProps,
1020
+ src: String(node.props.src ?? ''),
1021
+ title: String(node.props.title ?? 'Embedded content'),
1022
+ })
1023
+ }
1024
+
1025
+ return createElement(node.type, baseProps, children)
1026
+ }
1027
+
1028
+ function renderEditableNodeContent(node: VisualNode, previewMode: boolean) {
1029
+ if (voidElements.has(node.type)) {
1030
+ return null
1031
+ }
1032
+
1033
+ const text = String(node.props.text ?? '')
1034
+
1035
+ if (text.trim() !== '') {
1036
+ return text
1037
+ }
1038
+
1039
+ if (previewMode) {
1040
+ return ''
1041
+ }
1042
+
1043
+ return (
1044
+ <span className="vb-empty-node-placeholder" aria-hidden="true">
1045
+ Drop an element
1046
+ </span>
1047
+ )
1048
+ }
1049
+
1050
+ function getCustomComponentTreeChildren(node: VisualNode) {
1051
+ const tree = node.props.componentTree
1052
+
1053
+ if (
1054
+ typeof tree !== 'object' ||
1055
+ tree === null ||
1056
+ !Array.isArray((tree as { children?: unknown }).children)
1057
+ ) {
1058
+ return []
1059
+ }
1060
+
1061
+ return (tree as { children: VisualNode[] }).children
1062
+ }
1063
+
1064
+ const voidElements = new Set<VisualNode['type']>([
1065
+ 'br',
1066
+ 'hr',
1067
+ 'img',
1068
+ 'input',
1069
+ 'source',
1070
+ 'col',
1071
+ ])
1072
+
1073
+ function EmptyCanvas({ isOver }: { isOver: boolean }) {
1074
+ return (
1075
+ <div
1076
+ className={`flex min-h-[calc(100vh-10rem)] flex-col items-center justify-center border-2 border-dashed text-center transition-colors ${
1077
+ isOver
1078
+ ? 'border-blue-500 bg-blue-50 ring-2 ring-inset ring-blue-300'
1079
+ : 'border-gray-300'
1080
+ }`}
1081
+ >
1082
+ <p className="mb-3 text-3xl">+</p>
1083
+ <p className="text-sm font-medium text-gray-500">
1084
+ Drag any component or HTML element here
1085
+ </p>
1086
+ <p className="mt-1 text-xs text-gray-400">Your canvas is empty</p>
1087
+ </div>
1088
+ )
1089
+ }
1090
+
1091
+ function getChildOrientation(node: VisualNode): DropOrientation {
1092
+ const classNames = String(node.props.className ?? '')
1093
+ .split(/\s+/)
1094
+ .filter(Boolean)
1095
+ const hasGrid = classNames.some(
1096
+ (className) => className === 'grid' || className.endsWith(':grid')
1097
+ )
1098
+ const hasFlex = classNames.some(
1099
+ (className) =>
1100
+ className === 'flex' ||
1101
+ className === 'inline-flex' ||
1102
+ className.endsWith(':flex') ||
1103
+ className.endsWith(':inline-flex')
1104
+ )
1105
+ const hasFlexColumn = classNames.some(
1106
+ (className) =>
1107
+ className === 'flex-col' || className.endsWith(':flex-col')
1108
+ )
1109
+
1110
+ return hasGrid || (hasFlex && !hasFlexColumn) ? 'horizontal' : 'vertical'
1111
+ }
1112
+
1113
+ function normalizeRoute(route: string) {
1114
+ if (!route || route === '/') return '/'
1115
+ return `/${route.replace(/^\/+|\/+$/g, '')}`
1116
+ }