create-visualbuild-app 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +123 -0
  2. package/package.json +85 -0
  3. package/scripts/create-builder-app.mjs +501 -0
  4. package/scripts/vb-dev.mjs +32 -0
  5. package/scripts/vb-generate.mjs +224 -0
  6. package/templates/default/app/README.md +21 -0
  7. package/templates/default/app/eslint.config.js +25 -0
  8. package/templates/default/app/index.html +15 -0
  9. package/templates/default/app/src/index.css +23 -0
  10. package/templates/default/app/src/main.tsx +10 -0
  11. package/templates/default/app/tsconfig.app.json +21 -0
  12. package/templates/default/app/tsconfig.json +7 -0
  13. package/templates/default/app/tsconfig.node.json +19 -0
  14. package/templates/default/app/visualbuild/generated-files.json +3 -0
  15. package/templates/default/app/visualbuild/pages.json +12 -0
  16. package/templates/default/app/vite.config.ts +7 -0
  17. package/templates/default/builder/README.md +21 -0
  18. package/templates/default/builder/eslint.config.js +25 -0
  19. package/templates/default/builder/tsconfig.app.json +21 -0
  20. package/templates/default/builder/tsconfig.json +7 -0
  21. package/templates/default/builder/tsconfig.node.json +22 -0
  22. package/visual-app-builder/index.html +15 -0
  23. package/visual-app-builder/server/parseReactPage.ts +571 -0
  24. package/visual-app-builder/shared/generateReactSource.d.mts +37 -0
  25. package/visual-app-builder/shared/generateReactSource.mjs +443 -0
  26. package/visual-app-builder/src/App.tsx +874 -0
  27. package/visual-app-builder/src/components/Canvas.tsx +1059 -0
  28. package/visual-app-builder/src/components/CodePanel.tsx +812 -0
  29. package/visual-app-builder/src/components/ComponentSidebar.tsx +302 -0
  30. package/visual-app-builder/src/components/PageSwitcher.tsx +161 -0
  31. package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -0
  32. package/visual-app-builder/src/components/PropertiesPanel.tsx +692 -0
  33. package/visual-app-builder/src/components/Topbar.tsx +128 -0
  34. package/visual-app-builder/src/data/componentRegistry.tsx +292 -0
  35. package/visual-app-builder/src/data/primitiveRegistry.ts +368 -0
  36. package/visual-app-builder/src/index.css +111 -0
  37. package/visual-app-builder/src/main.tsx +10 -0
  38. package/visual-app-builder/src/stores/useAppStore.ts +1265 -0
  39. package/visual-app-builder/src/tailwindGeneratedSafelist.ts +36 -0
  40. package/visual-app-builder/src/types/index.ts +261 -0
  41. package/visual-app-builder/src/utils/codegen.ts +66 -0
  42. package/visual-app-builder/src/utils/projectFiles.ts +146 -0
  43. package/visual-app-builder/src/utils/projectPersistence.ts +177 -0
  44. package/visual-app-builder/vite.config.ts +1479 -0
@@ -0,0 +1,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 { 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
+ }