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.
- package/README.md +123 -0
- package/package.json +85 -0
- package/scripts/create-builder-app.mjs +501 -0
- package/scripts/vb-dev.mjs +32 -0
- package/scripts/vb-generate.mjs +224 -0
- package/templates/default/app/README.md +21 -0
- package/templates/default/app/eslint.config.js +25 -0
- package/templates/default/app/index.html +15 -0
- package/templates/default/app/src/index.css +23 -0
- package/templates/default/app/src/main.tsx +10 -0
- package/templates/default/app/tsconfig.app.json +21 -0
- package/templates/default/app/tsconfig.json +7 -0
- package/templates/default/app/tsconfig.node.json +19 -0
- package/templates/default/app/visualbuild/generated-files.json +3 -0
- package/templates/default/app/visualbuild/pages.json +12 -0
- package/templates/default/app/vite.config.ts +7 -0
- package/templates/default/builder/README.md +21 -0
- package/templates/default/builder/eslint.config.js +25 -0
- package/templates/default/builder/tsconfig.app.json +21 -0
- package/templates/default/builder/tsconfig.json +7 -0
- package/templates/default/builder/tsconfig.node.json +22 -0
- package/visual-app-builder/index.html +15 -0
- package/visual-app-builder/server/parseReactPage.ts +571 -0
- package/visual-app-builder/shared/generateReactSource.d.mts +37 -0
- package/visual-app-builder/shared/generateReactSource.mjs +443 -0
- package/visual-app-builder/src/App.tsx +874 -0
- package/visual-app-builder/src/components/Canvas.tsx +1059 -0
- package/visual-app-builder/src/components/CodePanel.tsx +812 -0
- package/visual-app-builder/src/components/ComponentSidebar.tsx +302 -0
- package/visual-app-builder/src/components/PageSwitcher.tsx +161 -0
- package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -0
- package/visual-app-builder/src/components/PropertiesPanel.tsx +692 -0
- package/visual-app-builder/src/components/Topbar.tsx +128 -0
- package/visual-app-builder/src/data/componentRegistry.tsx +292 -0
- package/visual-app-builder/src/data/primitiveRegistry.ts +368 -0
- package/visual-app-builder/src/index.css +111 -0
- package/visual-app-builder/src/main.tsx +10 -0
- package/visual-app-builder/src/stores/useAppStore.ts +1265 -0
- package/visual-app-builder/src/tailwindGeneratedSafelist.ts +36 -0
- package/visual-app-builder/src/types/index.ts +261 -0
- package/visual-app-builder/src/utils/codegen.ts +66 -0
- package/visual-app-builder/src/utils/projectFiles.ts +146 -0
- package/visual-app-builder/src/utils/projectPersistence.ts +177 -0
- package/visual-app-builder/vite.config.ts +1479 -0
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DndContext,
|
|
3
|
+
KeyboardSensor,
|
|
4
|
+
PointerSensor,
|
|
5
|
+
pointerWithin,
|
|
6
|
+
rectIntersection,
|
|
7
|
+
useSensor,
|
|
8
|
+
useSensors,
|
|
9
|
+
type CollisionDetection,
|
|
10
|
+
type DragEndEvent,
|
|
11
|
+
} from '@dnd-kit/core'
|
|
12
|
+
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
|
|
13
|
+
import { useEffect, useRef, useState } from 'react'
|
|
14
|
+
import Canvas from './components/Canvas'
|
|
15
|
+
import CodePanel, { type CodeView } from './components/CodePanel'
|
|
16
|
+
import ComponentSidebar from './components/ComponentSidebar'
|
|
17
|
+
import PageSwitcher from './components/PageSwitcher'
|
|
18
|
+
import PropertiesPanel from './components/PropertiesPanel'
|
|
19
|
+
import Topbar, { type SaveState } from './components/Topbar'
|
|
20
|
+
import { useAppStore } from './stores/useAppStore'
|
|
21
|
+
import type { ComponentType, Page, PrimitiveType } from './types'
|
|
22
|
+
import { getPageSourcePath } from './utils/codegen'
|
|
23
|
+
import { getGeneratorDiagnostic } from './utils/projectPersistence'
|
|
24
|
+
|
|
25
|
+
type DragData =
|
|
26
|
+
| { kind: 'sidebar-component'; type: ComponentType }
|
|
27
|
+
| { kind: 'primitive'; type: PrimitiveType }
|
|
28
|
+
| { kind: 'canvas-node'; nodeId: string }
|
|
29
|
+
| { kind: 'canvas-root' }
|
|
30
|
+
| { kind: 'node-dropzone'; nodeId: string }
|
|
31
|
+
| {
|
|
32
|
+
kind: 'insertion-zone'
|
|
33
|
+
parentNodeId: string | null
|
|
34
|
+
index: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ReverseSyncPayload {
|
|
38
|
+
type: 'reverse-sync-success' | 'reverse-sync-error'
|
|
39
|
+
path: string
|
|
40
|
+
pageId?: string
|
|
41
|
+
message: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface GeneratorDiagnosticPayload {
|
|
45
|
+
type: 'generator-diagnostic'
|
|
46
|
+
severity: 'error'
|
|
47
|
+
phase: 'generate'
|
|
48
|
+
code: string
|
|
49
|
+
title: string
|
|
50
|
+
message: string
|
|
51
|
+
path: string
|
|
52
|
+
suggestion?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface FileSystemPayload {
|
|
56
|
+
type: 'filesystem-change'
|
|
57
|
+
path: string
|
|
58
|
+
action: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ManagedPagesDeletedPayload {
|
|
62
|
+
type: 'managed-pages-deleted'
|
|
63
|
+
path: string
|
|
64
|
+
pageIds: string[]
|
|
65
|
+
message: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ManagedPagesMovedPayload {
|
|
69
|
+
type: 'managed-pages-moved'
|
|
70
|
+
path: string
|
|
71
|
+
targetPath: string
|
|
72
|
+
pages: Array<{
|
|
73
|
+
pageId: string
|
|
74
|
+
previousPath: string
|
|
75
|
+
sourcePath: string
|
|
76
|
+
}>
|
|
77
|
+
message: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type VisualBuildPayload =
|
|
81
|
+
| ReverseSyncPayload
|
|
82
|
+
| GeneratorDiagnosticPayload
|
|
83
|
+
| FileSystemPayload
|
|
84
|
+
| ManagedPagesDeletedPayload
|
|
85
|
+
| ManagedPagesMovedPayload
|
|
86
|
+
|
|
87
|
+
type ReverseSyncNoticeState =
|
|
88
|
+
| (ReverseSyncPayload & {
|
|
89
|
+
status: 'success' | 'error' | 'conflict'
|
|
90
|
+
})
|
|
91
|
+
| (GeneratorDiagnosticPayload & {
|
|
92
|
+
status: 'error'
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
export default function App() {
|
|
96
|
+
const [leftPanelOpen, setLeftPanelOpen] = useState(true)
|
|
97
|
+
const [leftPanelTab, setLeftPanelTab] =
|
|
98
|
+
useState<'files' | 'elements'>('elements')
|
|
99
|
+
const [rightPanelOpen, setRightPanelOpen] = useState(true)
|
|
100
|
+
const [rightPanelTab, setRightPanelTab] = useState<'code' | 'properties'>('code')
|
|
101
|
+
const [codeView, setCodeView] = useState<CodeView>('page')
|
|
102
|
+
const [selectedProjectFile, setSelectedProjectFile] = useState<string | null>(
|
|
103
|
+
null
|
|
104
|
+
)
|
|
105
|
+
const [saveState, setSaveState] = useState<SaveState>('idle')
|
|
106
|
+
const [reverseSyncNotice, setReverseSyncNotice] =
|
|
107
|
+
useState<ReverseSyncNoticeState | null>(null)
|
|
108
|
+
const saveResetTimeout = useRef<number | null>(null)
|
|
109
|
+
const {
|
|
110
|
+
pages,
|
|
111
|
+
activePageId,
|
|
112
|
+
loadProject,
|
|
113
|
+
refreshProjectFromDisk,
|
|
114
|
+
isProjectLoaded,
|
|
115
|
+
previewMode,
|
|
116
|
+
togglePreviewMode,
|
|
117
|
+
saveProject,
|
|
118
|
+
addComponentAt,
|
|
119
|
+
addComponentToNode,
|
|
120
|
+
addComponentToNodeAt,
|
|
121
|
+
addPrimitiveAt,
|
|
122
|
+
addPrimitiveToNode,
|
|
123
|
+
addPrimitiveToNodeAt,
|
|
124
|
+
moveNode,
|
|
125
|
+
setActivePage,
|
|
126
|
+
setAppShellComponent,
|
|
127
|
+
} = useAppStore()
|
|
128
|
+
const sensors = useSensors(
|
|
129
|
+
useSensor(PointerSensor, {
|
|
130
|
+
activationConstraint: {
|
|
131
|
+
distance: 6,
|
|
132
|
+
},
|
|
133
|
+
}),
|
|
134
|
+
useSensor(KeyboardSensor, {
|
|
135
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
136
|
+
})
|
|
137
|
+
)
|
|
138
|
+
const activePage = pages.find((page) => page.id === activePageId)
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
void loadProject()
|
|
142
|
+
}, [loadProject])
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const eventSource = new EventSource('/__visualbuild/events')
|
|
146
|
+
const handleReverseSync = (event: MessageEvent<string>) => {
|
|
147
|
+
let payload: VisualBuildPayload
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
payload = JSON.parse(event.data) as VisualBuildPayload
|
|
151
|
+
} catch {
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (payload.type === 'filesystem-change') {
|
|
156
|
+
window.dispatchEvent(
|
|
157
|
+
new CustomEvent('visualbuild:filesystem-changed', {
|
|
158
|
+
detail: payload,
|
|
159
|
+
})
|
|
160
|
+
)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (payload.type === 'managed-pages-deleted') {
|
|
165
|
+
setSelectedProjectFile((currentPath) => {
|
|
166
|
+
if (
|
|
167
|
+
currentPath === payload.path ||
|
|
168
|
+
currentPath?.startsWith(`${payload.path}/`)
|
|
169
|
+
) {
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return currentPath
|
|
174
|
+
})
|
|
175
|
+
setCodeView('page')
|
|
176
|
+
setReverseSyncNotice(null)
|
|
177
|
+
void refreshProjectFromDisk(true).then((result) => {
|
|
178
|
+
window.dispatchEvent(new Event('visualbuild:filesystem-changed'))
|
|
179
|
+
|
|
180
|
+
if (result !== 'updated') {
|
|
181
|
+
setReverseSyncNotice({
|
|
182
|
+
type: 'reverse-sync-error',
|
|
183
|
+
path: payload.path,
|
|
184
|
+
message: 'The page was deleted, but the editor could not refresh.',
|
|
185
|
+
status: 'error',
|
|
186
|
+
})
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const nextActivePage = useAppStore
|
|
191
|
+
.getState()
|
|
192
|
+
.pages.find(
|
|
193
|
+
(page) => page.id === useAppStore.getState().activePageId
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if (nextActivePage) {
|
|
197
|
+
window.history.replaceState({}, '', nextActivePage.route)
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (payload.type === 'managed-pages-moved') {
|
|
204
|
+
setSelectedProjectFile((currentPath) =>
|
|
205
|
+
currentPath
|
|
206
|
+
? replacePathPrefix(
|
|
207
|
+
currentPath,
|
|
208
|
+
payload.path,
|
|
209
|
+
payload.targetPath
|
|
210
|
+
)
|
|
211
|
+
: null
|
|
212
|
+
)
|
|
213
|
+
setReverseSyncNotice(null)
|
|
214
|
+
void refreshProjectFromDisk(true).then((result) => {
|
|
215
|
+
window.dispatchEvent(new Event('visualbuild:filesystem-changed'))
|
|
216
|
+
|
|
217
|
+
if (result !== 'updated') {
|
|
218
|
+
setReverseSyncNotice({
|
|
219
|
+
type: 'reverse-sync-error',
|
|
220
|
+
path: payload.targetPath,
|
|
221
|
+
message:
|
|
222
|
+
'The page source moved, but the editor could not refresh.',
|
|
223
|
+
status: 'error',
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (payload.type === 'generator-diagnostic') {
|
|
231
|
+
setReverseSyncNotice({ ...payload, status: 'error' })
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (payload.type === 'reverse-sync-error') {
|
|
236
|
+
setReverseSyncNotice({ ...payload, status: 'error' })
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
void refreshProjectFromDisk().then((result) => {
|
|
241
|
+
if (result === 'updated') {
|
|
242
|
+
window.dispatchEvent(
|
|
243
|
+
new Event('visualbuild:filesystem-changed')
|
|
244
|
+
)
|
|
245
|
+
setReverseSyncNotice({ ...payload, status: 'success' })
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
setReverseSyncNotice({
|
|
250
|
+
...payload,
|
|
251
|
+
status: result === 'skipped-dirty' ? 'conflict' : 'error',
|
|
252
|
+
message:
|
|
253
|
+
result === 'skipped-dirty'
|
|
254
|
+
? 'Source changed while the canvas has unsaved edits.'
|
|
255
|
+
: 'The source changed, but the canvas could not refresh.',
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
eventSource.addEventListener(
|
|
261
|
+
'visualbuild',
|
|
262
|
+
handleReverseSync as EventListener
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
return () => eventSource.close()
|
|
266
|
+
}, [refreshProjectFromDisk])
|
|
267
|
+
|
|
268
|
+
useEffect(
|
|
269
|
+
() => () => {
|
|
270
|
+
if (saveResetTimeout.current !== null) {
|
|
271
|
+
window.clearTimeout(saveResetTimeout.current)
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
[]
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
const handleToggleCode = () => {
|
|
278
|
+
if (previewMode) {
|
|
279
|
+
togglePreviewMode()
|
|
280
|
+
setRightPanelTab('code')
|
|
281
|
+
setRightPanelOpen(true)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (rightPanelOpen && rightPanelTab === 'code') {
|
|
286
|
+
setRightPanelOpen(false)
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
setRightPanelTab('code')
|
|
291
|
+
setRightPanelOpen(true)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const handleTogglePreview = () => {
|
|
295
|
+
if (!previewMode && activePage) {
|
|
296
|
+
window.history.replaceState({}, '', activePage.route)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
togglePreviewMode()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const handleSelectProjectFile = (path: string | null) => {
|
|
303
|
+
setSelectedProjectFile(path)
|
|
304
|
+
|
|
305
|
+
if (path) {
|
|
306
|
+
const normalizedPath = path.replace(/\\/g, '/')
|
|
307
|
+
const managedPage = pages.find(
|
|
308
|
+
(page) => getPageSourcePath(page) === normalizedPath
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if (managedPage) {
|
|
312
|
+
setActivePage(managedPage.id)
|
|
313
|
+
setCodeView('page')
|
|
314
|
+
} else {
|
|
315
|
+
setCodeView('file')
|
|
316
|
+
}
|
|
317
|
+
setRightPanelTab('code')
|
|
318
|
+
setRightPanelOpen(true)
|
|
319
|
+
} else if (codeView === 'file') {
|
|
320
|
+
setCodeView('page')
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const handleSelectCodeWorkspaceFile = (path: string) => {
|
|
325
|
+
const normalizedPath = path.replace(/\\/g, '/')
|
|
326
|
+
const managedPage = pages.find(
|
|
327
|
+
(page) => getPageSourcePath(page) === normalizedPath
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
setSelectedProjectFile(path)
|
|
331
|
+
if (managedPage) {
|
|
332
|
+
setActivePage(managedPage.id)
|
|
333
|
+
}
|
|
334
|
+
setCodeView('file')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const handleSelectPage = (page: Page) => {
|
|
338
|
+
setSelectedProjectFile(getPageSourcePath(page))
|
|
339
|
+
setCodeView('page')
|
|
340
|
+
setRightPanelTab('code')
|
|
341
|
+
setRightPanelOpen(true)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const handleDeletePage = (page: Page) => {
|
|
345
|
+
if (selectedProjectFile === getPageSourcePath(page)) {
|
|
346
|
+
setSelectedProjectFile(null)
|
|
347
|
+
setCodeView('page')
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const handleSave = async () => {
|
|
352
|
+
if (saveState === 'saving') {
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (saveResetTimeout.current !== null) {
|
|
357
|
+
window.clearTimeout(saveResetTimeout.current)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
setSaveState('saving')
|
|
361
|
+
const minimumOverlayTime = delay(450)
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
await saveProject()
|
|
365
|
+
await minimumOverlayTime
|
|
366
|
+
setSaveState('saved')
|
|
367
|
+
saveResetTimeout.current = window.setTimeout(
|
|
368
|
+
() => setSaveState('idle'),
|
|
369
|
+
1200
|
|
370
|
+
)
|
|
371
|
+
} catch (error) {
|
|
372
|
+
await minimumOverlayTime
|
|
373
|
+
console.error('Failed to save VisualBuild project', error)
|
|
374
|
+
const diagnostic = getGeneratorDiagnostic(error)
|
|
375
|
+
|
|
376
|
+
if (diagnostic) {
|
|
377
|
+
setReverseSyncNotice({
|
|
378
|
+
type: 'generator-diagnostic',
|
|
379
|
+
...diagnostic,
|
|
380
|
+
status: 'error',
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
setSaveState('error')
|
|
385
|
+
saveResetTimeout.current = window.setTimeout(
|
|
386
|
+
() => setSaveState('idle'),
|
|
387
|
+
2400
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const handleUseSourceChanges = async () => {
|
|
393
|
+
const result = await refreshProjectFromDisk(true)
|
|
394
|
+
|
|
395
|
+
if (result === 'updated') {
|
|
396
|
+
window.dispatchEvent(new Event('visualbuild:filesystem-changed'))
|
|
397
|
+
setReverseSyncNotice((notice) =>
|
|
398
|
+
notice && notice.type !== 'generator-diagnostic'
|
|
399
|
+
? {
|
|
400
|
+
...notice,
|
|
401
|
+
status: 'success',
|
|
402
|
+
message: 'Loaded the source version into the canvas.',
|
|
403
|
+
}
|
|
404
|
+
: null
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const handleDragEnd = (event: DragEndEvent) => {
|
|
410
|
+
const activeData = getDragData(event.active.data.current)
|
|
411
|
+
const overData = getDragData(event.over?.data.current)
|
|
412
|
+
|
|
413
|
+
if (!activeData || !overData || !activePage) {
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (overData.kind === 'insertion-zone') {
|
|
418
|
+
if (activeData.kind === 'canvas-node') {
|
|
419
|
+
moveNode(
|
|
420
|
+
activePageId,
|
|
421
|
+
activeData.nodeId,
|
|
422
|
+
overData.parentNodeId,
|
|
423
|
+
overData.index
|
|
424
|
+
)
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (activeData.kind === 'primitive') {
|
|
429
|
+
if (overData.parentNodeId === null) {
|
|
430
|
+
addPrimitiveAt(activePageId, activeData.type, overData.index)
|
|
431
|
+
} else {
|
|
432
|
+
addPrimitiveToNodeAt(
|
|
433
|
+
overData.parentNodeId,
|
|
434
|
+
activeData.type,
|
|
435
|
+
overData.index
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (activeData.kind === 'sidebar-component') {
|
|
442
|
+
if (isGlobalComponent(activeData.type)) {
|
|
443
|
+
setGlobalComponent(activeData.type, setAppShellComponent)
|
|
444
|
+
} else if (overData.parentNodeId === null) {
|
|
445
|
+
addComponentAt(activePageId, activeData.type, overData.index)
|
|
446
|
+
} else {
|
|
447
|
+
addComponentToNodeAt(
|
|
448
|
+
overData.parentNodeId,
|
|
449
|
+
activeData.type,
|
|
450
|
+
overData.index
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (overData.kind === 'node-dropzone') {
|
|
459
|
+
if (activeData.kind === 'canvas-node') {
|
|
460
|
+
moveNode(
|
|
461
|
+
activePageId,
|
|
462
|
+
activeData.nodeId,
|
|
463
|
+
overData.nodeId,
|
|
464
|
+
Number.POSITIVE_INFINITY
|
|
465
|
+
)
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (activeData.kind === 'primitive') {
|
|
470
|
+
addPrimitiveToNode(overData.nodeId, activeData.type)
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (activeData.kind === 'sidebar-component') {
|
|
475
|
+
if (isGlobalComponent(activeData.type)) {
|
|
476
|
+
setGlobalComponent(activeData.type, setAppShellComponent)
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
addComponentToNode(overData.nodeId, activeData.type)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (overData.kind !== 'canvas-root') {
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (activeData.kind === 'canvas-node') {
|
|
491
|
+
moveNode(
|
|
492
|
+
activePageId,
|
|
493
|
+
activeData.nodeId,
|
|
494
|
+
null,
|
|
495
|
+
Number.POSITIVE_INFINITY
|
|
496
|
+
)
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (activeData.kind === 'primitive') {
|
|
501
|
+
addPrimitiveAt(activePageId, activeData.type, Number.POSITIVE_INFINITY)
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (activeData.kind === 'sidebar-component') {
|
|
506
|
+
if (isGlobalComponent(activeData.type)) {
|
|
507
|
+
setGlobalComponent(activeData.type, setAppShellComponent)
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
addComponentAt(activePageId, activeData.type, Number.POSITIVE_INFINITY)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return (
|
|
516
|
+
<div className="flex flex-col h-full bg-gray-950 text-gray-100">
|
|
517
|
+
<Topbar
|
|
518
|
+
showCode={rightPanelOpen && rightPanelTab === 'code'}
|
|
519
|
+
previewMode={previewMode}
|
|
520
|
+
saveState={saveState}
|
|
521
|
+
onToggleCode={handleToggleCode}
|
|
522
|
+
onTogglePreview={handleTogglePreview}
|
|
523
|
+
onSave={() => {
|
|
524
|
+
void handleSave()
|
|
525
|
+
}}
|
|
526
|
+
/>
|
|
527
|
+
{reverseSyncNotice && (
|
|
528
|
+
<ReverseSyncNotice
|
|
529
|
+
notice={reverseSyncNotice}
|
|
530
|
+
onDismiss={() => setReverseSyncNotice(null)}
|
|
531
|
+
onUseSource={() => {
|
|
532
|
+
void handleUseSourceChanges()
|
|
533
|
+
}}
|
|
534
|
+
/>
|
|
535
|
+
)}
|
|
536
|
+
<DndContext
|
|
537
|
+
sensors={sensors}
|
|
538
|
+
collisionDetection={canvasCollisionDetection}
|
|
539
|
+
onDragEnd={handleDragEnd}
|
|
540
|
+
>
|
|
541
|
+
<div className="flex flex-1 overflow-hidden">
|
|
542
|
+
{!isProjectLoaded ? (
|
|
543
|
+
<div className="flex flex-1 items-center justify-center bg-[#eef1f4] text-sm text-gray-500">
|
|
544
|
+
Loading project...
|
|
545
|
+
</div>
|
|
546
|
+
) : (
|
|
547
|
+
<>
|
|
548
|
+
{!previewMode && (
|
|
549
|
+
<WorkspacePanel
|
|
550
|
+
side="left"
|
|
551
|
+
open={leftPanelOpen}
|
|
552
|
+
activeTab={leftPanelTab}
|
|
553
|
+
tabs={[
|
|
554
|
+
{ id: 'files', label: 'Files' },
|
|
555
|
+
{ id: 'elements', label: 'Elements' },
|
|
556
|
+
]}
|
|
557
|
+
onToggle={() => setLeftPanelOpen((value) => !value)}
|
|
558
|
+
onTabChange={(tab) =>
|
|
559
|
+
setLeftPanelTab(tab as 'files' | 'elements')
|
|
560
|
+
}
|
|
561
|
+
>
|
|
562
|
+
<ComponentSidebar
|
|
563
|
+
view={leftPanelTab}
|
|
564
|
+
selectedProjectFile={selectedProjectFile}
|
|
565
|
+
onSelectProjectFile={handleSelectProjectFile}
|
|
566
|
+
/>
|
|
567
|
+
</WorkspacePanel>
|
|
568
|
+
)}
|
|
569
|
+
<div className="flex flex-col flex-1 overflow-hidden">
|
|
570
|
+
<div className="flex flex-1 overflow-hidden">
|
|
571
|
+
<Canvas />
|
|
572
|
+
</div>
|
|
573
|
+
{!previewMode && (
|
|
574
|
+
<PageSwitcher
|
|
575
|
+
onSelectPage={handleSelectPage}
|
|
576
|
+
onDeletePage={handleDeletePage}
|
|
577
|
+
/>
|
|
578
|
+
)}
|
|
579
|
+
</div>
|
|
580
|
+
{!previewMode && (
|
|
581
|
+
<WorkspacePanel
|
|
582
|
+
side="right"
|
|
583
|
+
open={rightPanelOpen}
|
|
584
|
+
activeTab={rightPanelTab}
|
|
585
|
+
tabs={[
|
|
586
|
+
{ id: 'code', label: 'Code' },
|
|
587
|
+
{ id: 'properties', label: 'Properties' },
|
|
588
|
+
]}
|
|
589
|
+
onToggle={() => setRightPanelOpen((value) => !value)}
|
|
590
|
+
onTabChange={(tab) => {
|
|
591
|
+
setRightPanelTab(tab as 'code' | 'properties')
|
|
592
|
+
setRightPanelOpen(true)
|
|
593
|
+
}}
|
|
594
|
+
>
|
|
595
|
+
{rightPanelTab === 'code' ? (
|
|
596
|
+
<CodePanel
|
|
597
|
+
embedded
|
|
598
|
+
view={codeView}
|
|
599
|
+
filePath={selectedProjectFile}
|
|
600
|
+
onViewChange={setCodeView}
|
|
601
|
+
onPageRegistered={handleSelectPage}
|
|
602
|
+
onWorkspaceSelectFile={handleSelectCodeWorkspaceFile}
|
|
603
|
+
/>
|
|
604
|
+
) : (
|
|
605
|
+
<PropertiesPanel embedded />
|
|
606
|
+
)}
|
|
607
|
+
</WorkspacePanel>
|
|
608
|
+
)}
|
|
609
|
+
</>
|
|
610
|
+
)}
|
|
611
|
+
</div>
|
|
612
|
+
</DndContext>
|
|
613
|
+
{saveState === 'saving' && <SaveOverlay />}
|
|
614
|
+
</div>
|
|
615
|
+
)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function replacePathPrefix(
|
|
619
|
+
value: string,
|
|
620
|
+
previousPath: string,
|
|
621
|
+
targetPath: string
|
|
622
|
+
) {
|
|
623
|
+
if (
|
|
624
|
+
value !== previousPath &&
|
|
625
|
+
!value.startsWith(`${previousPath}/`)
|
|
626
|
+
) {
|
|
627
|
+
return value
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return `${targetPath}${value.slice(previousPath.length)}`
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function ReverseSyncNotice({
|
|
634
|
+
notice,
|
|
635
|
+
onDismiss,
|
|
636
|
+
onUseSource,
|
|
637
|
+
}: {
|
|
638
|
+
notice: ReverseSyncNoticeState
|
|
639
|
+
onDismiss: () => void
|
|
640
|
+
onUseSource: () => void
|
|
641
|
+
}) {
|
|
642
|
+
const tone =
|
|
643
|
+
notice.status === 'success'
|
|
644
|
+
? 'border-emerald-400/30 bg-emerald-950/95'
|
|
645
|
+
: notice.status === 'conflict'
|
|
646
|
+
? 'border-amber-400/30 bg-amber-950/95'
|
|
647
|
+
: 'border-red-400/30 bg-red-950/95'
|
|
648
|
+
|
|
649
|
+
return (
|
|
650
|
+
<div
|
|
651
|
+
className={`fixed right-4 top-16 z-[90] w-[390px] max-w-[calc(100vw-2rem)] rounded-md border px-4 py-3 text-white shadow-2xl ${tone}`}
|
|
652
|
+
role={notice.status === 'success' ? 'status' : 'alert'}
|
|
653
|
+
aria-live="polite"
|
|
654
|
+
>
|
|
655
|
+
<div className="flex items-start gap-3">
|
|
656
|
+
<div className="min-w-0 flex-1">
|
|
657
|
+
<p className="text-sm font-semibold">
|
|
658
|
+
{notice.type === 'generator-diagnostic'
|
|
659
|
+
? notice.title
|
|
660
|
+
: notice.status === 'success'
|
|
661
|
+
? 'Source synced'
|
|
662
|
+
: notice.status === 'conflict'
|
|
663
|
+
? 'Choose the version to keep'
|
|
664
|
+
: 'Reverse sync paused'}
|
|
665
|
+
</p>
|
|
666
|
+
<p className="mt-1 text-xs leading-5 text-white/75">
|
|
667
|
+
{notice.message}
|
|
668
|
+
</p>
|
|
669
|
+
<p className="mt-1 truncate font-mono text-[10px] text-white/50">
|
|
670
|
+
{notice.path}
|
|
671
|
+
</p>
|
|
672
|
+
{notice.type === 'generator-diagnostic' &&
|
|
673
|
+
notice.suggestion && (
|
|
674
|
+
<p className="mt-2 text-xs leading-5 text-white/70">
|
|
675
|
+
{notice.suggestion}
|
|
676
|
+
</p>
|
|
677
|
+
)}
|
|
678
|
+
{notice.status === 'conflict' && (
|
|
679
|
+
<button
|
|
680
|
+
type="button"
|
|
681
|
+
onClick={onUseSource}
|
|
682
|
+
className="mt-3 cursor-pointer rounded bg-white px-3 py-1.5 text-xs font-semibold text-gray-950 hover:bg-gray-100"
|
|
683
|
+
>
|
|
684
|
+
Load source changes
|
|
685
|
+
</button>
|
|
686
|
+
)}
|
|
687
|
+
</div>
|
|
688
|
+
<button
|
|
689
|
+
type="button"
|
|
690
|
+
onClick={onDismiss}
|
|
691
|
+
aria-label="Dismiss reverse sync notice"
|
|
692
|
+
title="Dismiss"
|
|
693
|
+
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded text-base text-white/60 hover:bg-white/10 hover:text-white"
|
|
694
|
+
>
|
|
695
|
+
x
|
|
696
|
+
</button>
|
|
697
|
+
</div>
|
|
698
|
+
</div>
|
|
699
|
+
)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function WorkspacePanel({
|
|
703
|
+
side,
|
|
704
|
+
open,
|
|
705
|
+
activeTab,
|
|
706
|
+
tabs,
|
|
707
|
+
onToggle,
|
|
708
|
+
onTabChange,
|
|
709
|
+
children,
|
|
710
|
+
}: {
|
|
711
|
+
side: 'left' | 'right'
|
|
712
|
+
open: boolean
|
|
713
|
+
activeTab: string
|
|
714
|
+
tabs: { id: string; label: string }[]
|
|
715
|
+
onToggle: () => void
|
|
716
|
+
onTabChange: (tab: string) => void
|
|
717
|
+
children: React.ReactNode
|
|
718
|
+
}) {
|
|
719
|
+
const borderClass = side === 'left' ? 'border-r' : 'border-l'
|
|
720
|
+
const collapseLabel = open
|
|
721
|
+
? `Collapse ${side} panel`
|
|
722
|
+
: `Expand ${side} panel`
|
|
723
|
+
const arrow = side === 'left' ? (open ? '<' : '>') : open ? '>' : '<'
|
|
724
|
+
|
|
725
|
+
if (!open) {
|
|
726
|
+
return (
|
|
727
|
+
<div
|
|
728
|
+
className={`flex w-8 shrink-0 items-start justify-center border-white/[0.06] bg-panel pt-2 ${borderClass}`}
|
|
729
|
+
>
|
|
730
|
+
<button
|
|
731
|
+
type="button"
|
|
732
|
+
onClick={onToggle}
|
|
733
|
+
aria-label={collapseLabel}
|
|
734
|
+
title={collapseLabel}
|
|
735
|
+
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-sm text-gray-400 transition-colors hover:bg-white/[0.08] hover:text-white"
|
|
736
|
+
>
|
|
737
|
+
{arrow}
|
|
738
|
+
</button>
|
|
739
|
+
</div>
|
|
740
|
+
)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return (
|
|
744
|
+
<aside
|
|
745
|
+
className={`${side === 'left' ? 'w-64' : 'w-[460px]'} ${borderClass} flex shrink-0 flex-col overflow-hidden border-white/[0.06] bg-panel`}
|
|
746
|
+
>
|
|
747
|
+
<div className="flex h-10 shrink-0 items-center border-b border-white/[0.06] px-1.5">
|
|
748
|
+
<div className="flex min-w-0 flex-1 items-center gap-1">
|
|
749
|
+
{tabs.map((tab) => (
|
|
750
|
+
<button
|
|
751
|
+
key={tab.id}
|
|
752
|
+
type="button"
|
|
753
|
+
onClick={() => onTabChange(tab.id)}
|
|
754
|
+
className={`h-7 cursor-pointer rounded px-2.5 text-[11px] font-medium transition-colors ${
|
|
755
|
+
activeTab === tab.id
|
|
756
|
+
? 'bg-white/[0.1] text-white'
|
|
757
|
+
: 'text-gray-500 hover:bg-white/[0.05] hover:text-gray-200'
|
|
758
|
+
}`}
|
|
759
|
+
>
|
|
760
|
+
{tab.label}
|
|
761
|
+
</button>
|
|
762
|
+
))}
|
|
763
|
+
</div>
|
|
764
|
+
<button
|
|
765
|
+
type="button"
|
|
766
|
+
onClick={onToggle}
|
|
767
|
+
aria-label={collapseLabel}
|
|
768
|
+
title={collapseLabel}
|
|
769
|
+
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-sm text-gray-400 transition-colors hover:bg-white/[0.08] hover:text-white"
|
|
770
|
+
>
|
|
771
|
+
{arrow}
|
|
772
|
+
</button>
|
|
773
|
+
</div>
|
|
774
|
+
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
|
|
775
|
+
</aside>
|
|
776
|
+
)
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function SaveOverlay() {
|
|
780
|
+
return (
|
|
781
|
+
<div
|
|
782
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-gray-950/25 px-4 backdrop-blur-sm"
|
|
783
|
+
role="status"
|
|
784
|
+
aria-live="polite"
|
|
785
|
+
aria-label="Saving project"
|
|
786
|
+
>
|
|
787
|
+
<div className="flex w-[320px] max-w-full items-center gap-3 rounded-lg border border-white/10 bg-[#171a20] px-5 py-4 text-white shadow-2xl">
|
|
788
|
+
<span
|
|
789
|
+
className="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-white/20 border-t-blue-400"
|
|
790
|
+
aria-hidden="true"
|
|
791
|
+
/>
|
|
792
|
+
<div className="min-w-0">
|
|
793
|
+
<p className="text-sm font-medium">Saving project...</p>
|
|
794
|
+
<p className="mt-0.5 text-xs text-gray-400">
|
|
795
|
+
Updating your React files
|
|
796
|
+
</p>
|
|
797
|
+
</div>
|
|
798
|
+
</div>
|
|
799
|
+
</div>
|
|
800
|
+
)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function delay(milliseconds: number) {
|
|
804
|
+
return new Promise<void>((resolve) => window.setTimeout(resolve, milliseconds))
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function isGlobalComponent(type: ComponentType) {
|
|
808
|
+
return type === 'Navbar' || type === 'Footer'
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function setGlobalComponent(
|
|
812
|
+
type: ComponentType,
|
|
813
|
+
setAppShellComponent: ReturnType<typeof useAppStore.getState>['setAppShellComponent']
|
|
814
|
+
) {
|
|
815
|
+
setAppShellComponent(type === 'Footer' ? 'footer' : 'header', type)
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function getDragData(data: unknown): DragData | null {
|
|
819
|
+
if (!data || typeof data !== 'object' || !('kind' in data)) {
|
|
820
|
+
return null
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return data as DragData
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const canvasCollisionDetection: CollisionDetection = (args) => {
|
|
827
|
+
const activeData = getDragData(args.active.data.current)
|
|
828
|
+
const isInvalidSelfTarget = (data: DragData | null) =>
|
|
829
|
+
activeData?.kind === 'canvas-node' &&
|
|
830
|
+
((data?.kind === 'node-dropzone' &&
|
|
831
|
+
data.nodeId === activeData.nodeId) ||
|
|
832
|
+
(data?.kind === 'insertion-zone' &&
|
|
833
|
+
data.parentNodeId === activeData.nodeId))
|
|
834
|
+
const insertionContainers = args.droppableContainers.filter((container) => {
|
|
835
|
+
const data = getDragData(container.data.current)
|
|
836
|
+
return data?.kind === 'insertion-zone' && !isInvalidSelfTarget(data)
|
|
837
|
+
})
|
|
838
|
+
const insertionCollisions = pointerWithin({
|
|
839
|
+
...args,
|
|
840
|
+
droppableContainers: insertionContainers,
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
if (insertionCollisions.length > 0) {
|
|
844
|
+
return insertionCollisions
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const nodeContainers = args.droppableContainers.filter((container) => {
|
|
848
|
+
const data = getDragData(container.data.current)
|
|
849
|
+
return data?.kind === 'node-dropzone' && !isInvalidSelfTarget(data)
|
|
850
|
+
})
|
|
851
|
+
const nodeCollisions = pointerWithin({
|
|
852
|
+
...args,
|
|
853
|
+
droppableContainers: nodeContainers,
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
if (nodeCollisions.length > 0) {
|
|
857
|
+
return [...nodeCollisions].sort((first, second) => {
|
|
858
|
+
const firstRect = args.droppableRects.get(first.id)
|
|
859
|
+
const secondRect = args.droppableRects.get(second.id)
|
|
860
|
+
const firstArea = firstRect ? firstRect.width * firstRect.height : Infinity
|
|
861
|
+
const secondArea = secondRect
|
|
862
|
+
? secondRect.width * secondRect.height
|
|
863
|
+
: Infinity
|
|
864
|
+
|
|
865
|
+
return firstArea - secondArea
|
|
866
|
+
})
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const pointerCollisions = pointerWithin(args)
|
|
870
|
+
|
|
871
|
+
return pointerCollisions.length > 0
|
|
872
|
+
? pointerCollisions
|
|
873
|
+
: rectIntersection(args)
|
|
874
|
+
}
|