@pascal-app/editor 0.4.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 (165) hide show
  1. package/package.json +62 -0
  2. package/src/components/editor/custom-camera-controls.tsx +387 -0
  3. package/src/components/editor/editor-layout-v2.tsx +220 -0
  4. package/src/components/editor/export-manager.tsx +78 -0
  5. package/src/components/editor/first-person-controls.tsx +249 -0
  6. package/src/components/editor/floating-action-menu.tsx +231 -0
  7. package/src/components/editor/floorplan-panel.tsx +9609 -0
  8. package/src/components/editor/grid.tsx +161 -0
  9. package/src/components/editor/index.tsx +928 -0
  10. package/src/components/editor/node-action-menu.tsx +66 -0
  11. package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
  12. package/src/components/editor/selection-manager.tsx +897 -0
  13. package/src/components/editor/site-edge-labels.tsx +90 -0
  14. package/src/components/editor/thumbnail-generator.tsx +166 -0
  15. package/src/components/editor/wall-measurement-label.tsx +258 -0
  16. package/src/components/feedback-dialog.tsx +265 -0
  17. package/src/components/pascal-radio.tsx +280 -0
  18. package/src/components/preview-button.tsx +16 -0
  19. package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
  20. package/src/components/systems/roof/roof-edit-system.tsx +69 -0
  21. package/src/components/systems/stair/stair-edit-system.tsx +69 -0
  22. package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
  23. package/src/components/systems/zone/zone-system.tsx +87 -0
  24. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
  25. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
  26. package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
  27. package/src/components/tools/door/door-math.ts +110 -0
  28. package/src/components/tools/door/door-tool.tsx +293 -0
  29. package/src/components/tools/door/move-door-tool.tsx +373 -0
  30. package/src/components/tools/item/item-tool.tsx +26 -0
  31. package/src/components/tools/item/move-tool.tsx +90 -0
  32. package/src/components/tools/item/placement-math.ts +85 -0
  33. package/src/components/tools/item/placement-strategies.ts +556 -0
  34. package/src/components/tools/item/placement-types.ts +117 -0
  35. package/src/components/tools/item/use-draft-node.ts +227 -0
  36. package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
  37. package/src/components/tools/roof/move-roof-tool.tsx +288 -0
  38. package/src/components/tools/roof/roof-tool.tsx +318 -0
  39. package/src/components/tools/select/box-select-tool.tsx +626 -0
  40. package/src/components/tools/shared/cursor-sphere.tsx +119 -0
  41. package/src/components/tools/shared/polygon-editor.tsx +361 -0
  42. package/src/components/tools/site/site-boundary-editor.tsx +42 -0
  43. package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
  44. package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
  45. package/src/components/tools/slab/slab-tool.tsx +322 -0
  46. package/src/components/tools/stair/stair-defaults.ts +7 -0
  47. package/src/components/tools/stair/stair-tool.tsx +194 -0
  48. package/src/components/tools/tool-manager.tsx +120 -0
  49. package/src/components/tools/wall/wall-drafting.ts +140 -0
  50. package/src/components/tools/wall/wall-tool.tsx +210 -0
  51. package/src/components/tools/window/move-window-tool.tsx +410 -0
  52. package/src/components/tools/window/window-math.ts +117 -0
  53. package/src/components/tools/window/window-tool.tsx +303 -0
  54. package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
  55. package/src/components/tools/zone/zone-tool.tsx +364 -0
  56. package/src/components/ui/action-menu/action-button.tsx +59 -0
  57. package/src/components/ui/action-menu/camera-actions.tsx +74 -0
  58. package/src/components/ui/action-menu/control-modes.tsx +240 -0
  59. package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
  60. package/src/components/ui/action-menu/index.tsx +152 -0
  61. package/src/components/ui/action-menu/structure-tools.tsx +100 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +397 -0
  63. package/src/components/ui/command-palette/editor-commands.tsx +396 -0
  64. package/src/components/ui/command-palette/index.tsx +730 -0
  65. package/src/components/ui/controls/action-button.tsx +33 -0
  66. package/src/components/ui/controls/material-picker.tsx +194 -0
  67. package/src/components/ui/controls/metric-control.tsx +262 -0
  68. package/src/components/ui/controls/panel-section.tsx +65 -0
  69. package/src/components/ui/controls/segmented-control.tsx +45 -0
  70. package/src/components/ui/controls/slider-control.tsx +245 -0
  71. package/src/components/ui/controls/toggle-control.tsx +38 -0
  72. package/src/components/ui/floating-level-selector.tsx +355 -0
  73. package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
  74. package/src/components/ui/helpers/helper-manager.tsx +33 -0
  75. package/src/components/ui/helpers/item-helper.tsx +40 -0
  76. package/src/components/ui/helpers/roof-helper.tsx +16 -0
  77. package/src/components/ui/helpers/slab-helper.tsx +20 -0
  78. package/src/components/ui/helpers/wall-helper.tsx +20 -0
  79. package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
  80. package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
  81. package/src/components/ui/panels/ceiling-panel.tsx +230 -0
  82. package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
  83. package/src/components/ui/panels/door-panel.tsx +600 -0
  84. package/src/components/ui/panels/item-panel.tsx +306 -0
  85. package/src/components/ui/panels/panel-manager.tsx +59 -0
  86. package/src/components/ui/panels/panel-wrapper.tsx +80 -0
  87. package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
  88. package/src/components/ui/panels/reference-panel.tsx +177 -0
  89. package/src/components/ui/panels/roof-panel.tsx +262 -0
  90. package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
  91. package/src/components/ui/panels/slab-panel.tsx +228 -0
  92. package/src/components/ui/panels/stair-panel.tsx +304 -0
  93. package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
  94. package/src/components/ui/panels/wall-panel.tsx +123 -0
  95. package/src/components/ui/panels/window-panel.tsx +441 -0
  96. package/src/components/ui/primitives/button.tsx +69 -0
  97. package/src/components/ui/primitives/card.tsx +75 -0
  98. package/src/components/ui/primitives/color-dot.tsx +61 -0
  99. package/src/components/ui/primitives/context-menu.tsx +227 -0
  100. package/src/components/ui/primitives/dialog.tsx +129 -0
  101. package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
  102. package/src/components/ui/primitives/error-boundary.tsx +52 -0
  103. package/src/components/ui/primitives/input.tsx +21 -0
  104. package/src/components/ui/primitives/number-input.tsx +187 -0
  105. package/src/components/ui/primitives/opacity-control.tsx +79 -0
  106. package/src/components/ui/primitives/popover.tsx +42 -0
  107. package/src/components/ui/primitives/separator.tsx +28 -0
  108. package/src/components/ui/primitives/sheet.tsx +130 -0
  109. package/src/components/ui/primitives/shortcut-token.tsx +64 -0
  110. package/src/components/ui/primitives/sidebar.tsx +855 -0
  111. package/src/components/ui/primitives/skeleton.tsx +13 -0
  112. package/src/components/ui/primitives/slider.tsx +58 -0
  113. package/src/components/ui/primitives/switch.tsx +29 -0
  114. package/src/components/ui/primitives/tooltip.tsx +57 -0
  115. package/src/components/ui/scene-loader.tsx +40 -0
  116. package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
  117. package/src/components/ui/sidebar/icon-rail.tsx +147 -0
  118. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
  119. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
  120. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
  121. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
  122. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
  123. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
  125. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
  126. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
  127. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
  128. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
  130. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
  131. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
  134. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
  135. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
  136. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
  137. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
  138. package/src/components/ui/sidebar/tab-bar.tsx +39 -0
  139. package/src/components/ui/slider-demo.tsx +36 -0
  140. package/src/components/ui/slider.tsx +81 -0
  141. package/src/components/ui/viewer-toolbar.tsx +342 -0
  142. package/src/components/viewer-overlay.tsx +499 -0
  143. package/src/components/viewer-zone-system.tsx +48 -0
  144. package/src/contexts/presets-context.tsx +121 -0
  145. package/src/hooks/use-auto-save.ts +194 -0
  146. package/src/hooks/use-contextual-tools.ts +52 -0
  147. package/src/hooks/use-grid-events.ts +106 -0
  148. package/src/hooks/use-keyboard.ts +214 -0
  149. package/src/hooks/use-mobile.ts +19 -0
  150. package/src/hooks/use-reduced-motion.ts +20 -0
  151. package/src/index.tsx +33 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/level-selection.ts +31 -0
  154. package/src/lib/scene.ts +394 -0
  155. package/src/lib/sfx/index.ts +2 -0
  156. package/src/lib/sfx-bus.ts +49 -0
  157. package/src/lib/sfx-player.ts +60 -0
  158. package/src/lib/utils.ts +43 -0
  159. package/src/store/use-audio.tsx +45 -0
  160. package/src/store/use-command-registry.ts +36 -0
  161. package/src/store/use-editor.tsx +522 -0
  162. package/src/store/use-palette-view-registry.ts +45 -0
  163. package/src/store/use-upload.ts +90 -0
  164. package/src/three-types.ts +3 -0
  165. package/tsconfig.json +9 -0
@@ -0,0 +1,928 @@
1
+ 'use client'
2
+
3
+ import { Icon } from '@iconify/react'
4
+ import {
5
+ initSpaceDetectionSync,
6
+ initSpatialGridSync,
7
+ spatialGridManager,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
11
+ import {
12
+ type ReactNode,
13
+ type PointerEvent as ReactPointerEvent,
14
+ useCallback,
15
+ useEffect,
16
+ useRef,
17
+ useState,
18
+ } from 'react'
19
+ import { ViewerOverlay } from '../../components/viewer-overlay'
20
+ import { ViewerZoneSystem } from '../../components/viewer-zone-system'
21
+ import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
22
+ import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
23
+ import { useKeyboard } from '../../hooks/use-keyboard'
24
+ import {
25
+ applySceneGraphToEditor,
26
+ loadSceneFromLocalStorage,
27
+ type SceneGraph,
28
+ writePersistedSelection,
29
+ } from '../../lib/scene'
30
+ import { initSFXBus } from '../../lib/sfx-bus'
31
+ import useEditor from '../../store/use-editor'
32
+ import { CeilingSystem } from '../systems/ceiling/ceiling-system'
33
+ import { RoofEditSystem } from '../systems/roof/roof-edit-system'
34
+ import { StairEditSystem } from '../systems/stair/stair-edit-system'
35
+ import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
36
+ import { ZoneSystem } from '../systems/zone/zone-system'
37
+ import { BoxSelectTool } from '../tools/select/box-select-tool'
38
+ import { ToolManager } from '../tools/tool-manager'
39
+ import { ActionMenu } from '../ui/action-menu'
40
+ import { CommandPalette, type CommandPaletteEmptyAction } from '../ui/command-palette'
41
+ import { EditorCommands } from '../ui/command-palette/editor-commands'
42
+ import { FloatingLevelSelector } from '../ui/floating-level-selector'
43
+ import { HelperManager } from '../ui/helpers/helper-manager'
44
+ import { PanelManager } from '../ui/panels/panel-manager'
45
+ import { ErrorBoundary } from '../ui/primitives/error-boundary'
46
+ import { useSidebarStore } from '../ui/primitives/sidebar'
47
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
48
+ import { SceneLoader } from '../ui/scene-loader'
49
+ import { AppSidebar } from '../ui/sidebar/app-sidebar'
50
+ import type { ExtraPanel } from '../ui/sidebar/icon-rail'
51
+ import { SettingsPanel, type SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'
52
+ import { SitePanel, type SitePanelProps } from '../ui/sidebar/panels/site-panel'
53
+ import type { SidebarTab } from '../ui/sidebar/tab-bar'
54
+ import { CustomCameraControls } from './custom-camera-controls'
55
+ import { EditorLayoutV2 } from './editor-layout-v2'
56
+ import { ExportManager } from './export-manager'
57
+ import { FloatingActionMenu } from './floating-action-menu'
58
+ import { FloorplanPanel } from './floorplan-panel'
59
+ import { Grid } from './grid'
60
+ import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
61
+ import { SelectionManager } from './selection-manager'
62
+ import { SiteEdgeLabels } from './site-edge-labels'
63
+ import { ThumbnailGenerator } from './thumbnail-generator'
64
+ import { WallMeasurementLabel } from './wall-measurement-label'
65
+ import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
66
+
67
+ const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
68
+ const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
69
+ const DELETE_CURSOR_BADGE_OFFSET_X = 14
70
+ const DELETE_CURSOR_BADGE_OFFSET_Y = 14
71
+
72
+ /**
73
+ * Wire up module-level singletons (spatial grid, space detection, SFX) for
74
+ * an Editor mount. Returns a teardown function that detaches the scene-store
75
+ * subscriptions and resets the shared singletons so a subsequent remount —
76
+ * including hot navigation back to the editor in the same tab — starts from
77
+ * a clean slate.
78
+ */
79
+ function initializeEditorRuntime(): () => void {
80
+ const unsubscribeSpatialGrid = initSpatialGridSync()
81
+ const unsubscribeSpaceDetection = initSpaceDetectionSync(useScene, useEditor)
82
+ initSFXBus()
83
+
84
+ return () => {
85
+ unsubscribeSpatialGrid()
86
+ unsubscribeSpaceDetection?.()
87
+
88
+ spatialGridManager.clear()
89
+
90
+ const outliner = useViewer.getState().outliner
91
+ outliner.selectedObjects.length = 0
92
+ outliner.hoveredObjects.length = 0
93
+ }
94
+ }
95
+ export interface EditorProps {
96
+ // Layout version — 'v1' (default) or 'v2' (navbar + two-column)
97
+ layoutVersion?: 'v1' | 'v2'
98
+
99
+ // UI slots (v1)
100
+ appMenuButton?: ReactNode
101
+ sidebarTop?: ReactNode
102
+
103
+ // UI slots (v2)
104
+ navbarSlot?: ReactNode
105
+ sidebarTabs?: (SidebarTab & { component: React.ComponentType })[]
106
+ viewerToolbarLeft?: ReactNode
107
+ viewerToolbarRight?: ReactNode
108
+
109
+ projectId?: string | null
110
+
111
+ // Persistence — defaults to localStorage when omitted
112
+ onLoad?: () => Promise<SceneGraph | null>
113
+ onSave?: (scene: SceneGraph) => Promise<void>
114
+ onDirty?: () => void
115
+ onSaveStatusChange?: (status: SaveStatus) => void
116
+
117
+ // Version preview
118
+ previewScene?: SceneGraph
119
+ isVersionPreviewMode?: boolean
120
+
121
+ // Loading indicator (e.g. project fetching in community mode)
122
+ isLoading?: boolean
123
+
124
+ // Thumbnail
125
+ onThumbnailCapture?: (blob: Blob) => void
126
+
127
+ // Version preview overlays (rendered by host app)
128
+ sidebarOverlay?: ReactNode
129
+ viewerBanner?: ReactNode
130
+
131
+ // Panel config (passed through to sidebar panels — v1 only)
132
+ settingsPanelProps?: SettingsPanelProps
133
+ sitePanelProps?: SitePanelProps
134
+ extraSidebarPanels?: ExtraPanel[]
135
+
136
+ // Presets storage backend (defaults to localStorage)
137
+ presetsAdapter?: PresetsAdapter
138
+
139
+ // Command palette fallback when no commands match
140
+ commandPaletteEmptyAction?: CommandPaletteEmptyAction
141
+ }
142
+
143
+ function EditorSceneCrashFallback() {
144
+ return (
145
+ <div className="fixed inset-0 z-80 flex items-center justify-center bg-background/95 p-4 text-foreground">
146
+ <div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
147
+ <h2 className="font-semibold text-lg">The editor scene failed to render</h2>
148
+ <p className="mt-2 text-muted-foreground text-sm">
149
+ You can retry the scene or return home without reloading the whole app shell.
150
+ </p>
151
+ <div className="mt-4 flex items-center gap-2">
152
+ <button
153
+ className="rounded-md border border-border bg-accent px-3 py-2 font-medium text-sm hover:bg-accent/80"
154
+ onClick={() => window.location.reload()}
155
+ type="button"
156
+ >
157
+ Reload editor
158
+ </button>
159
+ <a
160
+ className="rounded-md border border-border bg-background px-3 py-2 font-medium text-sm hover:bg-accent/40"
161
+ href="/"
162
+ >
163
+ Back to home
164
+ </a>
165
+ </div>
166
+ </div>
167
+ </div>
168
+ )
169
+ }
170
+
171
+ // ── Sidebar slot: in-flow, resizable, collapses to a grab strip ──────────────
172
+
173
+ function SidebarSlot({ children }: { children: ReactNode }) {
174
+ const width = useSidebarStore((s) => s.width)
175
+ const isCollapsed = useSidebarStore((s) => s.isCollapsed)
176
+ const setIsCollapsed = useSidebarStore((s) => s.setIsCollapsed)
177
+ const setWidth = useSidebarStore((s) => s.setWidth)
178
+ const isDragging = useSidebarStore((s) => s.isDragging)
179
+ const setIsDragging = useSidebarStore((s) => s.setIsDragging)
180
+
181
+ const isResizing = useRef(false)
182
+ const isExpanding = useRef(false)
183
+
184
+ const handleResizerDown = useCallback(
185
+ (e: React.PointerEvent) => {
186
+ e.preventDefault()
187
+ isResizing.current = true
188
+ setIsDragging(true)
189
+ document.body.style.cursor = 'col-resize'
190
+ document.body.style.userSelect = 'none'
191
+ },
192
+ [setIsDragging],
193
+ )
194
+
195
+ const handleGrabDown = useCallback(
196
+ (e: React.PointerEvent) => {
197
+ e.preventDefault()
198
+ isExpanding.current = true
199
+ setIsDragging(true)
200
+ document.body.style.cursor = 'col-resize'
201
+ document.body.style.userSelect = 'none'
202
+ },
203
+ [setIsDragging],
204
+ )
205
+
206
+ useEffect(() => {
207
+ const handlePointerMove = (e: PointerEvent) => {
208
+ if (isResizing.current) {
209
+ setWidth(e.clientX)
210
+ } else if (isExpanding.current && e.clientX > 60) {
211
+ setIsCollapsed(false)
212
+ setWidth(Math.max(240, e.clientX))
213
+ }
214
+ }
215
+ const handlePointerUp = () => {
216
+ isResizing.current = false
217
+ isExpanding.current = false
218
+ setIsDragging(false)
219
+ document.body.style.cursor = ''
220
+ document.body.style.userSelect = ''
221
+ }
222
+ window.addEventListener('pointermove', handlePointerMove)
223
+ window.addEventListener('pointerup', handlePointerUp)
224
+ return () => {
225
+ window.removeEventListener('pointermove', handlePointerMove)
226
+ window.removeEventListener('pointerup', handlePointerUp)
227
+ }
228
+ }, [setWidth, setIsCollapsed, setIsDragging])
229
+
230
+ return (
231
+ // Outer: no overflow-hidden so the handle can extend into the gap
232
+ <div
233
+ className="relative h-full flex-shrink-0 rounded-xl"
234
+ style={{
235
+ width: isCollapsed ? 8 : width,
236
+ transition: isDragging ? 'none' : 'width 150ms ease',
237
+ }}
238
+ >
239
+ {/* Inner: overflow-hidden clips content to rounded corners */}
240
+ <div className="h-full w-full overflow-hidden rounded-xl">
241
+ {isCollapsed ? (
242
+ <div
243
+ className="absolute inset-0 z-10 cursor-col-resize transition-colors hover:bg-primary/20"
244
+ onPointerDown={handleGrabDown}
245
+ title="Expand sidebar"
246
+ />
247
+ ) : (
248
+ children
249
+ )}
250
+ </div>
251
+
252
+ {/* Handle: extends into the gap, centered on the gap midpoint */}
253
+ {!isCollapsed && (
254
+ <div
255
+ className="group absolute inset-y-0 -right-3.5 z-10 flex w-4 cursor-col-resize items-stretch justify-center py-4"
256
+ onPointerDown={handleResizerDown}
257
+ >
258
+ <div className="w-px self-stretch rounded-full bg-transparent transition-colors group-hover:bg-neutral-300" />
259
+ </div>
260
+ )}
261
+ </div>
262
+ )
263
+ }
264
+
265
+ // ── UI overlays: fixed, scoped to viewer area via transform containing block ──
266
+
267
+ function ViewerOverlays({ left, children }: { left: number; children: ReactNode }) {
268
+ return (
269
+ <div
270
+ className="pointer-events-none"
271
+ style={{
272
+ position: 'fixed',
273
+ top: 0,
274
+ right: 0,
275
+ bottom: 0,
276
+ left,
277
+ // Creates a containing block so position:fixed children are scoped here
278
+ transform: 'translateZ(0)',
279
+ zIndex: 30,
280
+ }}
281
+ >
282
+ {children}
283
+ </div>
284
+ )
285
+ }
286
+
287
+ // ─────────────────────────────────────────────────────────────────────────────
288
+
289
+ function SelectionPersistenceManager({ enabled }: { enabled: boolean }) {
290
+ const selection = useViewer((state) => state.selection)
291
+
292
+ useEffect(() => {
293
+ if (!enabled) {
294
+ return
295
+ }
296
+
297
+ writePersistedSelection(selection)
298
+ }, [enabled, selection])
299
+
300
+ return null
301
+ }
302
+
303
+ type ShortcutKey = {
304
+ value: string
305
+ }
306
+
307
+ type CameraControlHint = {
308
+ action: string
309
+ keys: ShortcutKey[]
310
+ alternativeKeys?: ShortcutKey[]
311
+ }
312
+
313
+ const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
314
+ {
315
+ action: 'Pan',
316
+ keys: [{ value: 'Space' }, { value: 'Left click' }],
317
+ },
318
+ { action: 'Rotate', keys: [{ value: 'Right click' }] },
319
+ { action: 'Zoom', keys: [{ value: 'Scroll' }] },
320
+ ]
321
+
322
+ const PREVIEW_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
323
+ { action: 'Pan', keys: [{ value: 'Left click' }] },
324
+ { action: 'Rotate', keys: [{ value: 'Right click' }] },
325
+ { action: 'Zoom', keys: [{ value: 'Scroll' }] },
326
+ ]
327
+
328
+ const CAMERA_SHORTCUT_KEY_META: Record<string, { icon?: string; label: string; text?: string }> = {
329
+ 'Left click': {
330
+ icon: 'ph:mouse-left-click-fill',
331
+ label: 'Left click',
332
+ },
333
+ 'Middle click': {
334
+ icon: 'qlementine-icons:mouse-middle-button-16',
335
+ label: 'Middle click',
336
+ },
337
+ 'Right click': {
338
+ icon: 'ph:mouse-right-click-fill',
339
+ label: 'Right click',
340
+ },
341
+ Scroll: {
342
+ icon: 'qlementine-icons:mouse-middle-button-16',
343
+ label: 'Scroll wheel',
344
+ },
345
+ Space: {
346
+ icon: 'lucide:space',
347
+ label: 'Space',
348
+ },
349
+ }
350
+
351
+ function readCameraControlsHintDismissed(): boolean {
352
+ if (typeof window === 'undefined') {
353
+ return false
354
+ }
355
+
356
+ try {
357
+ return window.localStorage.getItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY) === '1'
358
+ } catch {
359
+ return false
360
+ }
361
+ }
362
+
363
+ function writeCameraControlsHintDismissed(dismissed: boolean) {
364
+ if (typeof window === 'undefined') {
365
+ return
366
+ }
367
+
368
+ try {
369
+ if (dismissed) {
370
+ window.localStorage.setItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY, '1')
371
+ return
372
+ }
373
+
374
+ window.localStorage.removeItem(CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY)
375
+ } catch {}
376
+ }
377
+
378
+ function InlineShortcutKey({ shortcutKey }: { shortcutKey: ShortcutKey }) {
379
+ const meta = CAMERA_SHORTCUT_KEY_META[shortcutKey.value]
380
+
381
+ if (meta?.icon) {
382
+ return (
383
+ <span
384
+ aria-label={meta.label}
385
+ className="inline-flex items-center text-foreground/90"
386
+ role="img"
387
+ title={meta.label}
388
+ >
389
+ <Icon aria-hidden="true" color="currentColor" height={16} icon={meta.icon} width={16} />
390
+ <span className="sr-only">{meta.label}</span>
391
+ </span>
392
+ )
393
+ }
394
+
395
+ return (
396
+ <span className="font-medium text-[11px] text-foreground/90">
397
+ {meta?.text ?? shortcutKey.value}
398
+ </span>
399
+ )
400
+ }
401
+
402
+ function ShortcutSequence({ keys }: { keys: ShortcutKey[] }) {
403
+ return (
404
+ <div className="flex flex-wrap items-center gap-1">
405
+ {keys.map((key, index) => (
406
+ <div className="flex items-center gap-1" key={`${key.value}-${index}`}>
407
+ {index > 0 ? <span className="text-[10px] text-muted-foreground/70">+</span> : null}
408
+ <InlineShortcutKey shortcutKey={key} />
409
+ </div>
410
+ ))}
411
+ </div>
412
+ )
413
+ }
414
+
415
+ function CameraControlHintItem({ hint }: { hint: CameraControlHint }) {
416
+ return (
417
+ <div className="flex min-w-0 flex-col items-center gap-1.5 px-4 text-center first:pl-0 last:pr-0">
418
+ <span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
419
+ {hint.action}
420
+ </span>
421
+ <div className="flex flex-wrap items-center justify-center gap-1.5">
422
+ <ShortcutSequence keys={hint.keys} />
423
+ {hint.alternativeKeys ? (
424
+ <>
425
+ <span className="text-[10px] text-muted-foreground/40">/</span>
426
+ <ShortcutSequence keys={hint.alternativeKeys} />
427
+ </>
428
+ ) : null}
429
+ </div>
430
+ </div>
431
+ )
432
+ }
433
+
434
+ function ViewerCanvasControlsHint({
435
+ isPreviewMode,
436
+ onDismiss,
437
+ }: {
438
+ isPreviewMode: boolean
439
+ onDismiss: () => void
440
+ }) {
441
+ const hints = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS
442
+
443
+ return (
444
+ <div className="pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2">
445
+ <section
446
+ aria-label="Camera controls hint"
447
+ className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-[0_22px_40px_-28px_rgba(15,23,42,0.65),0_10px_24px_-20px_rgba(15,23,42,0.55)] backdrop-blur-xl"
448
+ >
449
+ <div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18">
450
+ {hints.map((hint) => (
451
+ <CameraControlHintItem hint={hint} key={hint.action} />
452
+ ))}
453
+ </div>
454
+ <Tooltip>
455
+ <TooltipTrigger asChild>
456
+ <button
457
+ aria-label="Dismiss camera controls hint"
458
+ className="flex h-5 shrink-0 items-center justify-center self-center border-border/18 border-l pl-3 text-muted-foreground/70 transition-colors hover:text-foreground"
459
+ onClick={onDismiss}
460
+ type="button"
461
+ >
462
+ <Icon
463
+ aria-hidden="true"
464
+ color="currentColor"
465
+ height={14}
466
+ icon="lucide:x"
467
+ width={14}
468
+ />
469
+ </button>
470
+ </TooltipTrigger>
471
+ <TooltipContent side="bottom" sideOffset={8}>
472
+ Dismiss
473
+ </TooltipContent>
474
+ </Tooltip>
475
+ </section>
476
+ </div>
477
+ )
478
+ }
479
+
480
+ function DeleteCursorBadge({ position }: { position: { x: number; y: number } }) {
481
+ return (
482
+ <div
483
+ aria-hidden="true"
484
+ className="pointer-events-none absolute z-40"
485
+ style={{
486
+ left: position.x + DELETE_CURSOR_BADGE_OFFSET_X,
487
+ top: position.y + DELETE_CURSOR_BADGE_OFFSET_Y,
488
+ }}
489
+ >
490
+ <div
491
+ className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
492
+ style={{
493
+ boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${DELETE_CURSOR_BADGE_COLOR}22`,
494
+ }}
495
+ >
496
+ <Icon
497
+ aria-hidden="true"
498
+ className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
499
+ color={DELETE_CURSOR_BADGE_COLOR}
500
+ height={18}
501
+ icon="mdi:trash-can-outline"
502
+ width={18}
503
+ />
504
+ </div>
505
+ </div>
506
+ )
507
+ }
508
+
509
+ export default function Editor({
510
+ layoutVersion = 'v1',
511
+ appMenuButton,
512
+ sidebarTop,
513
+ navbarSlot,
514
+ sidebarTabs,
515
+ viewerToolbarLeft,
516
+ viewerToolbarRight,
517
+ projectId,
518
+ onLoad,
519
+ onSave,
520
+ onDirty,
521
+ onSaveStatusChange,
522
+ previewScene,
523
+ isVersionPreviewMode = false,
524
+ isLoading = false,
525
+ onThumbnailCapture,
526
+ sidebarOverlay,
527
+ viewerBanner,
528
+ settingsPanelProps,
529
+ sitePanelProps,
530
+ extraSidebarPanels,
531
+ presetsAdapter,
532
+ commandPaletteEmptyAction,
533
+ }: EditorProps) {
534
+ useKeyboard({ isVersionPreviewMode })
535
+
536
+ const { isLoadingSceneRef } = useAutoSave({
537
+ onSave,
538
+ onDirty,
539
+ onSaveStatusChange,
540
+ isVersionPreviewMode,
541
+ })
542
+
543
+ const [isSceneLoading, setIsSceneLoading] = useState(false)
544
+ const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
545
+ const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(
546
+ null,
547
+ )
548
+ const isPreviewMode = useEditor((s) => s.isPreviewMode)
549
+ const mode = useEditor((s) => s.mode)
550
+ const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
551
+ const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
552
+ const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
553
+ const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
554
+ const [viewerCursorPosition, setViewerCursorPosition] = useState<{ x: number; y: number } | null>(
555
+ null,
556
+ )
557
+
558
+ const sidebarWidth = useSidebarStore((s) => s.width)
559
+ const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
560
+ const viewerAreaRef = useRef<HTMLDivElement>(null)
561
+ const isResizingFloorplan = useRef(false)
562
+
563
+ const handleFloorplanDividerDown = useCallback((e: React.PointerEvent) => {
564
+ e.preventDefault()
565
+ isResizingFloorplan.current = true
566
+ document.body.style.cursor = 'col-resize'
567
+ document.body.style.userSelect = 'none'
568
+ }, [])
569
+
570
+ useEffect(() => {
571
+ const handlePointerMove = (e: PointerEvent) => {
572
+ if (!isResizingFloorplan.current) return
573
+ if (!viewerAreaRef.current) return
574
+ const rect = viewerAreaRef.current.getBoundingClientRect()
575
+ const newRatio = (e.clientX - rect.left) / rect.width
576
+ setFloorplanPaneRatio(Math.max(0.15, Math.min(0.85, newRatio)))
577
+ }
578
+ const handlePointerUp = () => {
579
+ isResizingFloorplan.current = false
580
+ document.body.style.cursor = ''
581
+ document.body.style.userSelect = ''
582
+ }
583
+ window.addEventListener('pointermove', handlePointerMove)
584
+ window.addEventListener('pointerup', handlePointerUp)
585
+ return () => {
586
+ window.removeEventListener('pointermove', handlePointerMove)
587
+ window.removeEventListener('pointerup', handlePointerUp)
588
+ }
589
+ }, [])
590
+
591
+ useEffect(() => {
592
+ const teardown = initializeEditorRuntime()
593
+ return teardown
594
+ }, [])
595
+
596
+ useEffect(() => {
597
+ useViewer.getState().setProjectId(projectId ?? null)
598
+
599
+ return () => {
600
+ useViewer.getState().setProjectId(null)
601
+ }
602
+ }, [projectId])
603
+
604
+ // Load scene on mount (or when onLoad identity changes, e.g. project switch)
605
+ useEffect(() => {
606
+ let cancelled = false
607
+
608
+ async function load() {
609
+ isLoadingSceneRef.current = true
610
+ setHasLoadedInitialScene(false)
611
+ setIsSceneLoading(true)
612
+
613
+ try {
614
+ const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage()
615
+ if (!cancelled) {
616
+ applySceneGraphToEditor(sceneGraph)
617
+ }
618
+ } catch {
619
+ if (!cancelled) applySceneGraphToEditor(null)
620
+ } finally {
621
+ if (!cancelled) {
622
+ setIsSceneLoading(false)
623
+ setHasLoadedInitialScene(true)
624
+ requestAnimationFrame(() => {
625
+ isLoadingSceneRef.current = false
626
+ })
627
+ }
628
+ }
629
+ }
630
+
631
+ load()
632
+
633
+ return () => {
634
+ cancelled = true
635
+ }
636
+ }, [onLoad, isLoadingSceneRef])
637
+
638
+ // Apply preview scene when version preview mode changes
639
+ useEffect(() => {
640
+ if (isVersionPreviewMode && previewScene) {
641
+ applySceneGraphToEditor(previewScene)
642
+ }
643
+ }, [isVersionPreviewMode, previewScene])
644
+
645
+ // Lock scene graph and reset to select mode when entering version preview
646
+ useEffect(() => {
647
+ useScene.getState().setReadOnly(isVersionPreviewMode)
648
+ if (isVersionPreviewMode) {
649
+ useEditor.getState().setMode('select')
650
+ }
651
+ return () => {
652
+ useScene.getState().setReadOnly(false)
653
+ }
654
+ }, [isVersionPreviewMode])
655
+
656
+ useEffect(() => {
657
+ document.body.classList.add('dark')
658
+ return () => {
659
+ document.body.classList.remove('dark')
660
+ }
661
+ }, [])
662
+
663
+ useEffect(() => {
664
+ setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
665
+ }, [])
666
+
667
+ const showLoader = isLoading || isSceneLoading
668
+ const dismissCameraControlsHint = useCallback(() => {
669
+ setIsCameraControlsHintVisible(false)
670
+ writeCameraControlsHintDismissed(true)
671
+ }, [])
672
+
673
+ // ── Shared viewer scene content ──
674
+ const viewerSceneContent = (
675
+ <>
676
+ {!isFirstPersonMode && <SelectionManager />}
677
+ {!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />}
678
+ {!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
679
+ {!isFirstPersonMode && <WallMeasurementLabel />}
680
+ <ExportManager />
681
+ {isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
682
+ <CeilingSystem />
683
+ <RoofEditSystem />
684
+ <StairEditSystem />
685
+ {!isLoading && !isFirstPersonMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
686
+ {!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && <ToolManager />}
687
+ {isFirstPersonMode && <FirstPersonControls />}
688
+ <CustomCameraControls />
689
+ <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
690
+ <PresetThumbnailGenerator />
691
+ {!isFirstPersonMode && <SiteEdgeLabels />}
692
+ {isFirstPersonMode && <InteractiveSystem />}
693
+ </>
694
+ )
695
+
696
+ const previewViewerContent = (
697
+ <Viewer selectionManager="default">
698
+ <ExportManager />
699
+ <ViewerZoneSystem />
700
+ <CeilingSystem />
701
+ <RoofEditSystem />
702
+ <StairEditSystem />
703
+ <CustomCameraControls />
704
+ <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
705
+ <PresetThumbnailGenerator />
706
+ <InteractiveSystem />
707
+ </Viewer>
708
+ )
709
+
710
+ // ── Shared viewer canvas (handles split/2d/3d) ──
711
+ const viewMode = useEditor((s) => s.viewMode)
712
+
713
+ const show2d = viewMode === '2d' || viewMode === 'split'
714
+ const show3d = viewMode === '3d' || viewMode === 'split'
715
+ const showDeleteCursorBadge = mode === 'delete' && !isVersionPreviewMode
716
+
717
+ useEffect(() => {
718
+ if (!(showDeleteCursorBadge && show3d)) {
719
+ setViewerCursorPosition(null)
720
+ }
721
+ }, [show3d, showDeleteCursorBadge])
722
+
723
+ const handleViewerPointerMove = useCallback(
724
+ (event: ReactPointerEvent<HTMLDivElement>) => {
725
+ if (!showDeleteCursorBadge) {
726
+ setViewerCursorPosition(null)
727
+ return
728
+ }
729
+
730
+ const rect = event.currentTarget.getBoundingClientRect()
731
+ setViewerCursorPosition({
732
+ x: event.clientX - rect.left,
733
+ y: event.clientY - rect.top,
734
+ })
735
+ },
736
+ [showDeleteCursorBadge],
737
+ )
738
+
739
+ const handleViewerPointerLeave = useCallback(() => {
740
+ setViewerCursorPosition(null)
741
+ }, [])
742
+
743
+ const viewerCanvas = (
744
+ <ErrorBoundary fallback={<EditorSceneCrashFallback />}>
745
+ <div className="flex h-full" ref={viewerAreaRef}>
746
+ {/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
747
+ <div
748
+ className="relative h-full flex-shrink-0"
749
+ style={{
750
+ width: viewMode === '2d' ? '100%' : `${floorplanPaneRatio * 100}%`,
751
+ display: show2d ? undefined : 'none',
752
+ }}
753
+ >
754
+ <div className="h-full w-full overflow-hidden">
755
+ <FloorplanPanel />
756
+ </div>
757
+ {viewMode === 'split' && (
758
+ <div
759
+ className="absolute inset-y-0 -right-3 z-10 flex w-6 cursor-col-resize items-center justify-center"
760
+ onPointerDown={handleFloorplanDividerDown}
761
+ >
762
+ <div className="h-8 w-1 rounded-full bg-neutral-400" />
763
+ </div>
764
+ )}
765
+ </div>
766
+
767
+ {/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
768
+ <div
769
+ className="relative min-w-0 flex-1 overflow-hidden"
770
+ onPointerEnter={handleViewerPointerMove}
771
+ onPointerLeave={handleViewerPointerLeave}
772
+ onPointerMove={handleViewerPointerMove}
773
+ style={{ display: show3d ? undefined : 'none' }}
774
+ >
775
+ {showDeleteCursorBadge && viewerCursorPosition ? (
776
+ <DeleteCursorBadge position={viewerCursorPosition} />
777
+ ) : null}
778
+ {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
779
+ <ViewerCanvasControlsHint
780
+ isPreviewMode={isPreviewMode}
781
+ onDismiss={dismissCameraControlsHint}
782
+ />
783
+ ) : null}
784
+ <SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
785
+ <Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>{viewerSceneContent}</Viewer>
786
+ </div>
787
+ </div>
788
+ {!(isLoading || isVersionPreviewMode) && <ZoneLabelEditorSystem />}
789
+ </ErrorBoundary>
790
+ )
791
+
792
+ // ── V2 layout ──
793
+ if (layoutVersion === 'v2') {
794
+ const tabMap = new Map(sidebarTabs?.map((t) => [t.id, t]) ?? [])
795
+
796
+ const renderTabContent = (tabId: string) => {
797
+ // Built-in panels
798
+ if (tabId === 'site') {
799
+ return <SitePanel {...sitePanelProps} />
800
+ }
801
+ if (tabId === 'settings') {
802
+ return <SettingsPanel {...settingsPanelProps} />
803
+ }
804
+ // External tabs (AI chat, catalog, etc.)
805
+ const tab = tabMap.get(tabId)
806
+ if (!tab) return null
807
+ const Component = tab.component
808
+ return <Component />
809
+ }
810
+
811
+ const tabBarTabs = sidebarTabs?.map(({ id, label }) => ({ id, label })) ?? []
812
+
813
+ return (
814
+ <PresetsProvider adapter={presetsAdapter}>
815
+ {showLoader && (
816
+ <div className="fixed inset-0 z-60">
817
+ <SceneLoader />
818
+ </div>
819
+ )}
820
+
821
+ {!isLoading && isPreviewMode ? (
822
+ <div className="dark flex h-full w-full flex-col bg-neutral-100 text-foreground">
823
+ <ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
824
+ <div className="h-full w-full">{previewViewerContent}</div>
825
+ </div>
826
+ ) : (
827
+ <>
828
+ <EditorLayoutV2
829
+ navbarSlot={navbarSlot}
830
+ overlays={
831
+ <>
832
+ <FloatingLevelSelector />
833
+ {!isVersionPreviewMode && (
834
+ <div className="pointer-events-auto">
835
+ <ActionMenu />
836
+ </div>
837
+ )}
838
+ {!isVersionPreviewMode && (
839
+ <div className="pointer-events-auto">
840
+ <PanelManager />
841
+ </div>
842
+ )}
843
+ <div className="pointer-events-auto">
844
+ <HelperManager />
845
+ </div>
846
+ {viewerBanner}
847
+ </>
848
+ }
849
+ renderTabContent={renderTabContent}
850
+ sidebarOverlay={sidebarOverlay}
851
+ sidebarTabs={tabBarTabs}
852
+ viewerContent={viewerCanvas}
853
+ viewerToolbarLeft={viewerToolbarLeft}
854
+ viewerToolbarRight={viewerToolbarRight}
855
+ />
856
+ {/* First-person overlay — rendered on top of normal layout */}
857
+ {isFirstPersonMode && (
858
+ <div className="fixed inset-0 z-50 pointer-events-none">
859
+ <FirstPersonOverlay
860
+ onExit={() => useEditor.getState().setFirstPersonMode(false)}
861
+ />
862
+ </div>
863
+ )}
864
+ <EditorCommands />
865
+ <CommandPalette emptyAction={commandPaletteEmptyAction} />
866
+ </>
867
+ )}
868
+ </PresetsProvider>
869
+ )
870
+ }
871
+
872
+ // ── V1 layout (existing) ──
873
+ // p-3 (12px) padding on root + gap-3 (12px) between sidebar and viewer + sidebar width
874
+ const LAYOUT_PADDING = 12
875
+ const LAYOUT_GAP = 12
876
+ const overlayLeft = LAYOUT_PADDING + (isSidebarCollapsed ? 8 : sidebarWidth) + LAYOUT_GAP
877
+
878
+ return (
879
+ <PresetsProvider adapter={presetsAdapter}>
880
+ <div className="dark flex h-full w-full gap-3 bg-neutral-100 p-3 text-foreground">
881
+ {showLoader && (
882
+ <div className="fixed inset-0 z-60">
883
+ <SceneLoader />
884
+ </div>
885
+ )}
886
+
887
+ {!isLoading && isPreviewMode ? (
888
+ <>
889
+ <ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
890
+ <div className="h-full w-full">{previewViewerContent}</div>
891
+ </>
892
+ ) : (
893
+ <>
894
+ {/* Sidebar */}
895
+ <SidebarSlot>
896
+ <AppSidebar
897
+ appMenuButton={appMenuButton}
898
+ commandPaletteEmptyAction={commandPaletteEmptyAction}
899
+ extraPanels={extraSidebarPanels}
900
+ settingsPanelProps={settingsPanelProps}
901
+ sidebarTop={sidebarTop}
902
+ sitePanelProps={sitePanelProps}
903
+ />
904
+ </SidebarSlot>
905
+
906
+ {/* Viewer area */}
907
+ <div className="relative flex-1 overflow-hidden rounded-xl" ref={viewerAreaRef}>
908
+ {viewerCanvas}
909
+ </div>
910
+
911
+ {/* Fixed UI overlays scoped to the viewer area */}
912
+ <ViewerOverlays left={overlayLeft}>
913
+ <div className="pointer-events-auto">
914
+ <ActionMenu />
915
+ </div>
916
+ <div className="pointer-events-auto">
917
+ <PanelManager />
918
+ </div>
919
+ <div className="pointer-events-auto">
920
+ <HelperManager />
921
+ </div>
922
+ </ViewerOverlays>
923
+ </>
924
+ )}
925
+ </div>
926
+ </PresetsProvider>
927
+ )
928
+ }