@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.
- package/package.json +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- 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
|
+
}
|