@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,194 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useScene } from '@pascal-app/core'
|
|
4
|
+
import { type MutableRefObject, useCallback, useEffect, useRef, useState } from 'react'
|
|
5
|
+
import { type SceneGraph, saveSceneToLocalStorage } from '../lib/scene'
|
|
6
|
+
|
|
7
|
+
const AUTOSAVE_DEBOUNCE_MS = 1000
|
|
8
|
+
|
|
9
|
+
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
|
|
10
|
+
|
|
11
|
+
interface UseAutoSaveOptions {
|
|
12
|
+
onSave?: (scene: SceneGraph) => Promise<void>
|
|
13
|
+
onDirty?: () => void
|
|
14
|
+
onSaveStatusChange?: (status: SaveStatus) => void
|
|
15
|
+
isVersionPreviewMode?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generic autosave hook. Subscribes to the scene store and debounces saves.
|
|
20
|
+
* Falls back to localStorage when no `onSave` is provided.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ Mount in exactly ONE component (the Editor).
|
|
23
|
+
*/
|
|
24
|
+
export function useAutoSave({
|
|
25
|
+
onSave,
|
|
26
|
+
onDirty,
|
|
27
|
+
onSaveStatusChange,
|
|
28
|
+
isVersionPreviewMode = false,
|
|
29
|
+
}: UseAutoSaveOptions): { saveStatus: SaveStatus; isLoadingSceneRef: MutableRefObject<boolean> } {
|
|
30
|
+
const [saveStatus, _setSaveStatus] = useState<SaveStatus>('idle')
|
|
31
|
+
|
|
32
|
+
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
|
33
|
+
const isSavingRef = useRef(false)
|
|
34
|
+
const isLoadingSceneRef = useRef(false)
|
|
35
|
+
const pendingSaveRef = useRef(false)
|
|
36
|
+
const executeSaveRef = useRef<(() => Promise<void>) | null>(null)
|
|
37
|
+
const hasDirtyChangesRef = useRef(false)
|
|
38
|
+
|
|
39
|
+
// Keep latest callback/value refs so the stable subscription always uses current values
|
|
40
|
+
const onSaveRef = useRef(onSave)
|
|
41
|
+
const onDirtyRef = useRef(onDirty)
|
|
42
|
+
const onSaveStatusChangeRef = useRef(onSaveStatusChange)
|
|
43
|
+
const isVersionPreviewModeRef = useRef(isVersionPreviewMode)
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
onSaveRef.current = onSave
|
|
47
|
+
}, [onSave])
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
onDirtyRef.current = onDirty
|
|
50
|
+
}, [onDirty])
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
onSaveStatusChangeRef.current = onSaveStatusChange
|
|
53
|
+
}, [onSaveStatusChange])
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
isVersionPreviewModeRef.current = isVersionPreviewMode
|
|
56
|
+
}, [isVersionPreviewMode])
|
|
57
|
+
|
|
58
|
+
const setSaveStatus = useCallback((status: SaveStatus) => {
|
|
59
|
+
_setSaveStatus(status)
|
|
60
|
+
onSaveStatusChangeRef.current?.(status)
|
|
61
|
+
}, [])
|
|
62
|
+
|
|
63
|
+
// Stable subscription to scene changes
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
|
|
66
|
+
|
|
67
|
+
async function executeSave() {
|
|
68
|
+
if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) {
|
|
69
|
+
pendingSaveRef.current = true
|
|
70
|
+
setSaveStatus('paused')
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { nodes, rootNodeIds } = useScene.getState()
|
|
75
|
+
const sceneGraph = { nodes, rootNodeIds } as SceneGraph
|
|
76
|
+
|
|
77
|
+
isSavingRef.current = true
|
|
78
|
+
pendingSaveRef.current = false
|
|
79
|
+
setSaveStatus('saving')
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
if (onSaveRef.current) {
|
|
83
|
+
await onSaveRef.current(sceneGraph)
|
|
84
|
+
} else {
|
|
85
|
+
saveSceneToLocalStorage(sceneGraph)
|
|
86
|
+
}
|
|
87
|
+
hasDirtyChangesRef.current = false
|
|
88
|
+
setSaveStatus('saved')
|
|
89
|
+
} catch {
|
|
90
|
+
setSaveStatus('error')
|
|
91
|
+
} finally {
|
|
92
|
+
isSavingRef.current = false
|
|
93
|
+
|
|
94
|
+
if (pendingSaveRef.current) {
|
|
95
|
+
pendingSaveRef.current = false
|
|
96
|
+
setSaveStatus('pending')
|
|
97
|
+
saveTimeoutRef.current = setTimeout(() => {
|
|
98
|
+
saveTimeoutRef.current = undefined
|
|
99
|
+
executeSave()
|
|
100
|
+
}, AUTOSAVE_DEBOUNCE_MS)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
executeSaveRef.current = executeSave
|
|
106
|
+
|
|
107
|
+
const unsubscribe = useScene.subscribe((state) => {
|
|
108
|
+
if (isLoadingSceneRef.current) {
|
|
109
|
+
lastNodesSnapshot = JSON.stringify(state.nodes)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (isVersionPreviewModeRef.current) {
|
|
114
|
+
setSaveStatus('paused')
|
|
115
|
+
lastNodesSnapshot = JSON.stringify(state.nodes)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const currentNodesSnapshot = JSON.stringify(state.nodes)
|
|
120
|
+
if (currentNodesSnapshot === lastNodesSnapshot) return
|
|
121
|
+
|
|
122
|
+
lastNodesSnapshot = currentNodesSnapshot
|
|
123
|
+
hasDirtyChangesRef.current = true
|
|
124
|
+
onDirtyRef.current?.()
|
|
125
|
+
setSaveStatus('pending')
|
|
126
|
+
|
|
127
|
+
if (isSavingRef.current) {
|
|
128
|
+
pendingSaveRef.current = true
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
|
133
|
+
|
|
134
|
+
saveTimeoutRef.current = setTimeout(() => {
|
|
135
|
+
saveTimeoutRef.current = undefined
|
|
136
|
+
executeSave()
|
|
137
|
+
}, AUTOSAVE_DEBOUNCE_MS)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
function flushOnExit() {
|
|
141
|
+
if (!hasDirtyChangesRef.current) return
|
|
142
|
+
const { nodes, rootNodeIds } = useScene.getState()
|
|
143
|
+
const sceneGraph = { nodes, rootNodeIds } as SceneGraph
|
|
144
|
+
if (onSaveRef.current) {
|
|
145
|
+
onSaveRef.current(sceneGraph).catch(() => {})
|
|
146
|
+
} else {
|
|
147
|
+
saveSceneToLocalStorage(sceneGraph)
|
|
148
|
+
}
|
|
149
|
+
hasDirtyChangesRef.current = false
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
window.addEventListener('beforeunload', flushOnExit)
|
|
153
|
+
|
|
154
|
+
return () => {
|
|
155
|
+
executeSaveRef.current = null
|
|
156
|
+
window.removeEventListener('beforeunload', flushOnExit)
|
|
157
|
+
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
|
158
|
+
flushOnExit()
|
|
159
|
+
unsubscribe()
|
|
160
|
+
}
|
|
161
|
+
}, [setSaveStatus])
|
|
162
|
+
|
|
163
|
+
// Handle version preview mode transitions
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (isVersionPreviewMode) {
|
|
166
|
+
if (saveTimeoutRef.current) {
|
|
167
|
+
clearTimeout(saveTimeoutRef.current)
|
|
168
|
+
saveTimeoutRef.current = undefined
|
|
169
|
+
}
|
|
170
|
+
if (hasDirtyChangesRef.current) {
|
|
171
|
+
pendingSaveRef.current = true
|
|
172
|
+
}
|
|
173
|
+
setSaveStatus('paused')
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (isSavingRef.current) return
|
|
178
|
+
|
|
179
|
+
if (hasDirtyChangesRef.current) {
|
|
180
|
+
setSaveStatus('pending')
|
|
181
|
+
if (!saveTimeoutRef.current) {
|
|
182
|
+
saveTimeoutRef.current = setTimeout(() => {
|
|
183
|
+
saveTimeoutRef.current = undefined
|
|
184
|
+
executeSaveRef.current?.()
|
|
185
|
+
}, AUTOSAVE_DEBOUNCE_MS)
|
|
186
|
+
}
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
setSaveStatus('saved')
|
|
191
|
+
}, [isVersionPreviewMode, setSaveStatus])
|
|
192
|
+
|
|
193
|
+
return { saveStatus, isLoadingSceneRef }
|
|
194
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { useMemo } from 'react'
|
|
4
|
+
import useEditor, { type StructureTool } from '../store/use-editor'
|
|
5
|
+
|
|
6
|
+
export function useContextualTools() {
|
|
7
|
+
const selection = useViewer((s) => s.selection)
|
|
8
|
+
const nodes = useScene((s) => s.nodes)
|
|
9
|
+
const phase = useEditor((s) => s.phase)
|
|
10
|
+
const structureLayer = useEditor((s) => s.structureLayer)
|
|
11
|
+
|
|
12
|
+
return useMemo(() => {
|
|
13
|
+
// If we are in the zones layer, only zone tool is relevant
|
|
14
|
+
if (structureLayer === 'zones') {
|
|
15
|
+
return ['zone'] as StructureTool[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Default tools when nothing is selected
|
|
19
|
+
const defaultTools: StructureTool[] = ['wall', 'slab', 'ceiling', 'roof', 'door', 'window']
|
|
20
|
+
|
|
21
|
+
if (selection.selectedIds.length === 0) {
|
|
22
|
+
return defaultTools
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Get types of selected nodes
|
|
26
|
+
const selectedTypes = new Set(
|
|
27
|
+
selection.selectedIds.map((id) => nodes[id as AnyNodeId]?.type).filter(Boolean),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// If a wall is selected, prioritize wall-hosted elements
|
|
31
|
+
if (selectedTypes.has('wall')) {
|
|
32
|
+
return ['window', 'door', 'wall'] as StructureTool[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// If a slab is selected, prioritize slab editing
|
|
36
|
+
if (selectedTypes.has('slab')) {
|
|
37
|
+
return ['slab', 'wall'] as StructureTool[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// If a ceiling is selected, prioritize ceiling editing
|
|
41
|
+
if (selectedTypes.has('ceiling')) {
|
|
42
|
+
return ['ceiling'] as StructureTool[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// If a roof is selected, prioritize roof editing
|
|
46
|
+
if (selectedTypes.has('roof')) {
|
|
47
|
+
return ['roof'] as StructureTool[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return defaultTools
|
|
51
|
+
}, [selection.selectedIds, nodes, structureLayer])
|
|
52
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { useThree } from '@react-three/fiber'
|
|
4
|
+
import { useEffect, useRef } from 'react'
|
|
5
|
+
import { Plane, Raycaster, Vector2, Vector3 } from 'three'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Custom grid events hook that uses manual raycasting instead of mesh events.
|
|
9
|
+
* This ensures grid events work even when other meshes block pointer events with stopPropagation.
|
|
10
|
+
*/
|
|
11
|
+
export function useGridEvents(gridY: number) {
|
|
12
|
+
const { camera, gl } = useThree()
|
|
13
|
+
const raycaster = useRef(new Raycaster())
|
|
14
|
+
const pointer = useRef(new Vector2())
|
|
15
|
+
const groundPlane = useRef(new Plane(new Vector3(0, 1, 0), 0))
|
|
16
|
+
const intersectionPoint = useRef(new Vector3())
|
|
17
|
+
|
|
18
|
+
// Update ground plane when grid Y changes
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
groundPlane.current.constant = -gridY
|
|
21
|
+
}, [gridY])
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const canvas = gl.domElement
|
|
25
|
+
|
|
26
|
+
const getIntersection = (nativeEvent: MouseEvent | PointerEvent): Vector3 | null => {
|
|
27
|
+
// Convert mouse position to normalized device coordinates (-1 to +1)
|
|
28
|
+
const rect = canvas.getBoundingClientRect()
|
|
29
|
+
pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1
|
|
30
|
+
pointer.current.y = -((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1
|
|
31
|
+
|
|
32
|
+
// Update raycaster
|
|
33
|
+
raycaster.current.setFromCamera(pointer.current, camera)
|
|
34
|
+
|
|
35
|
+
// Intersect with ground plane
|
|
36
|
+
if (raycaster.current.ray.intersectPlane(groundPlane.current, intersectionPoint.current)) {
|
|
37
|
+
return intersectionPoint.current.clone()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const emit = (suffix: EventSuffix, nativeEvent: MouseEvent | PointerEvent) => {
|
|
44
|
+
const point = getIntersection(nativeEvent)
|
|
45
|
+
if (!point) return
|
|
46
|
+
|
|
47
|
+
const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`
|
|
48
|
+
const payload: GridEvent = {
|
|
49
|
+
position: [point.x, point.y, point.z],
|
|
50
|
+
nativeEvent: nativeEvent as any, // Type compatibility with ThreeEvent
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
emitter.emit(eventKey, payload)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const handlePointerDown = (e: PointerEvent) => {
|
|
57
|
+
if (useViewer.getState().cameraDragging) return
|
|
58
|
+
if (e.button !== 0) return
|
|
59
|
+
emit('pointerdown', e)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const handlePointerUp = (e: PointerEvent) => {
|
|
63
|
+
if (useViewer.getState().cameraDragging) return
|
|
64
|
+
if (e.button !== 0) return
|
|
65
|
+
emit('pointerup', e)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const handleClick = (e: PointerEvent) => {
|
|
69
|
+
if (useViewer.getState().cameraDragging) return
|
|
70
|
+
if (e.button !== 0) return
|
|
71
|
+
emit('click', e)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const handlePointerMove = (e: PointerEvent) => {
|
|
75
|
+
// Emit move even if camera is dragging, so tools like PolygonEditor still work
|
|
76
|
+
emit('move', e)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const handleDoubleClick = (e: MouseEvent) => {
|
|
80
|
+
if (useViewer.getState().cameraDragging) return
|
|
81
|
+
emit('double-click', e)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const handleContextMenu = (e: MouseEvent) => {
|
|
85
|
+
if (useViewer.getState().cameraDragging) return
|
|
86
|
+
emit('context-menu', e)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Attach listeners to canvas
|
|
90
|
+
canvas.addEventListener('pointerdown', handlePointerDown)
|
|
91
|
+
canvas.addEventListener('pointerup', handlePointerUp)
|
|
92
|
+
canvas.addEventListener('click', handleClick)
|
|
93
|
+
canvas.addEventListener('pointermove', handlePointerMove)
|
|
94
|
+
canvas.addEventListener('dblclick', handleDoubleClick)
|
|
95
|
+
canvas.addEventListener('contextmenu', handleContextMenu)
|
|
96
|
+
|
|
97
|
+
return () => {
|
|
98
|
+
canvas.removeEventListener('pointerdown', handlePointerDown)
|
|
99
|
+
canvas.removeEventListener('pointerup', handlePointerUp)
|
|
100
|
+
canvas.removeEventListener('click', handleClick)
|
|
101
|
+
canvas.removeEventListener('pointermove', handlePointerMove)
|
|
102
|
+
canvas.removeEventListener('dblclick', handleDoubleClick)
|
|
103
|
+
canvas.removeEventListener('contextmenu', handleContextMenu)
|
|
104
|
+
}
|
|
105
|
+
}, [camera, gl])
|
|
106
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
import { sfxEmitter } from '../lib/sfx-bus'
|
|
5
|
+
import useEditor from '../store/use-editor'
|
|
6
|
+
|
|
7
|
+
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
|
|
8
|
+
// so that the global Escape handler knows not to also switch to select mode.
|
|
9
|
+
let _toolCancelConsumed = false
|
|
10
|
+
export const markToolCancelConsumed = () => {
|
|
11
|
+
_toolCancelConsumed = true
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
17
|
+
// Don't handle shortcuts if user is typing in an input
|
|
18
|
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (e.key === 'Escape') {
|
|
23
|
+
// If in walkthrough mode, let WalkthroughControls handle ESC
|
|
24
|
+
if (useViewer.getState().walkthroughMode) return
|
|
25
|
+
|
|
26
|
+
e.preventDefault()
|
|
27
|
+
_toolCancelConsumed = false
|
|
28
|
+
emitter.emit('tool:cancel')
|
|
29
|
+
|
|
30
|
+
// Only switch to select mode if no tool had an active mid-action to cancel.
|
|
31
|
+
// (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
|
|
32
|
+
if (!_toolCancelConsumed) {
|
|
33
|
+
const currentPhase = useEditor.getState().phase
|
|
34
|
+
const currentStructureLayer = useEditor.getState().structureLayer
|
|
35
|
+
|
|
36
|
+
useEditor.getState().setEditingHole(null)
|
|
37
|
+
|
|
38
|
+
// From zone mode, return to structure select
|
|
39
|
+
if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
|
|
40
|
+
useEditor.getState().setStructureLayer('elements')
|
|
41
|
+
useEditor.getState().setMode('select')
|
|
42
|
+
} else {
|
|
43
|
+
// Return to the default select tool while keeping the active building/level context.
|
|
44
|
+
useEditor.getState().setMode('select')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
useEditor.getState().setFloorplanSelectionTool('click')
|
|
48
|
+
|
|
49
|
+
// Clear selections to close UI panels, but KEEP the active building and level context.
|
|
50
|
+
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
|
51
|
+
useEditor.getState().setSelectedReferenceId(null)
|
|
52
|
+
}
|
|
53
|
+
} else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
|
|
54
|
+
e.preventDefault()
|
|
55
|
+
useEditor.getState().setPhase('site')
|
|
56
|
+
useEditor.getState().setMode('select')
|
|
57
|
+
} else if (e.key === '2' && !e.metaKey && !e.ctrlKey) {
|
|
58
|
+
e.preventDefault()
|
|
59
|
+
useEditor.getState().setPhase('structure')
|
|
60
|
+
useEditor.getState().setMode('select')
|
|
61
|
+
} else if (e.key === '3' && !e.metaKey && !e.ctrlKey) {
|
|
62
|
+
e.preventDefault()
|
|
63
|
+
useEditor.getState().setPhase('furnish')
|
|
64
|
+
useEditor.getState().setMode('select')
|
|
65
|
+
} else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
|
|
66
|
+
if (isVersionPreviewMode) return
|
|
67
|
+
e.preventDefault()
|
|
68
|
+
useEditor.getState().setPhase('furnish')
|
|
69
|
+
useEditor.getState().setMode('build')
|
|
70
|
+
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
|
|
71
|
+
if (isVersionPreviewMode) return
|
|
72
|
+
e.preventDefault()
|
|
73
|
+
useEditor.getState().setPhase('structure')
|
|
74
|
+
useEditor.getState().setStructureLayer('zones')
|
|
75
|
+
useEditor.getState().setMode('build')
|
|
76
|
+
}
|
|
77
|
+
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
|
78
|
+
e.preventDefault()
|
|
79
|
+
useEditor.getState().setMode('select')
|
|
80
|
+
useEditor.getState().setFloorplanSelectionTool('click')
|
|
81
|
+
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
|
|
82
|
+
if (isVersionPreviewMode) return
|
|
83
|
+
e.preventDefault()
|
|
84
|
+
useEditor.getState().setPhase('structure')
|
|
85
|
+
useEditor.getState().setStructureLayer('elements')
|
|
86
|
+
useEditor.getState().setMode('build')
|
|
87
|
+
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
|
88
|
+
if (isVersionPreviewMode) return
|
|
89
|
+
e.preventDefault()
|
|
90
|
+
useScene.temporal.getState().undo()
|
|
91
|
+
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
|
92
|
+
if (isVersionPreviewMode) return
|
|
93
|
+
e.preventDefault()
|
|
94
|
+
useScene.temporal.getState().redo()
|
|
95
|
+
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
|
|
96
|
+
e.preventDefault()
|
|
97
|
+
const { buildingId, levelId } = useViewer.getState().selection
|
|
98
|
+
if (buildingId) {
|
|
99
|
+
const building = useScene.getState().nodes[buildingId]
|
|
100
|
+
if (building && building.type === 'building' && building.children.length > 0) {
|
|
101
|
+
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
|
|
102
|
+
const nextIdx = currentIdx < building.children.length - 1 ? currentIdx + 1 : currentIdx
|
|
103
|
+
if (nextIdx !== -1 && nextIdx !== currentIdx) {
|
|
104
|
+
useViewer.getState().setSelection({ levelId: building.children[nextIdx] as any })
|
|
105
|
+
} else if (currentIdx === -1) {
|
|
106
|
+
useViewer.getState().setSelection({ levelId: building.children[0] as any })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} else if (e.key === 'ArrowDown' && (e.metaKey || e.ctrlKey)) {
|
|
111
|
+
e.preventDefault()
|
|
112
|
+
const { buildingId, levelId } = useViewer.getState().selection
|
|
113
|
+
if (buildingId) {
|
|
114
|
+
const building = useScene.getState().nodes[buildingId]
|
|
115
|
+
if (building && building.type === 'building' && building.children.length > 0) {
|
|
116
|
+
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
|
|
117
|
+
const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
|
|
118
|
+
if (prevIdx !== -1 && prevIdx !== currentIdx) {
|
|
119
|
+
useViewer.getState().setSelection({ levelId: building.children[prevIdx] as any })
|
|
120
|
+
} else if (currentIdx === -1) {
|
|
121
|
+
useViewer
|
|
122
|
+
.getState()
|
|
123
|
+
.setSelection({ levelId: building.children[building.children.length - 1] as any })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
|
|
128
|
+
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
|
|
129
|
+
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
130
|
+
if (selectedNodeIds.length === 1) {
|
|
131
|
+
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
132
|
+
if (node && 'rotation' in node) {
|
|
133
|
+
e.preventDefault()
|
|
134
|
+
const ROTATION_STEP = Math.PI / 4
|
|
135
|
+
|
|
136
|
+
// Handle different rotation types (number for roof, array for items/windows/doors)
|
|
137
|
+
if (typeof node.rotation === 'number') {
|
|
138
|
+
useScene.getState().updateNode(node.id, { rotation: node.rotation + ROTATION_STEP })
|
|
139
|
+
} else if (Array.isArray(node.rotation)) {
|
|
140
|
+
useScene.getState().updateNode(node.id, {
|
|
141
|
+
rotation: [node.rotation[0], node.rotation[1] + ROTATION_STEP, node.rotation[2]],
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode) {
|
|
148
|
+
// Rotate selected node counter-clockwise
|
|
149
|
+
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
150
|
+
if (selectedNodeIds.length === 1) {
|
|
151
|
+
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
152
|
+
if (node && 'rotation' in node) {
|
|
153
|
+
e.preventDefault()
|
|
154
|
+
const ROTATION_STEP = Math.PI / 4
|
|
155
|
+
|
|
156
|
+
if (typeof node.rotation === 'number') {
|
|
157
|
+
useScene.getState().updateNode(node.id, { rotation: node.rotation - ROTATION_STEP })
|
|
158
|
+
} else if (Array.isArray(node.rotation)) {
|
|
159
|
+
useScene.getState().updateNode(node.id, {
|
|
160
|
+
rotation: [node.rotation[0], node.rotation[1] - ROTATION_STEP, node.rotation[2]],
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} else if ((e.key === 'Delete' || e.key === 'Backspace') && !isVersionPreviewMode) {
|
|
167
|
+
e.preventDefault()
|
|
168
|
+
|
|
169
|
+
// Check for a selected reference (guide/scan) first
|
|
170
|
+
const selectedRefId = useEditor.getState().selectedReferenceId
|
|
171
|
+
if (selectedRefId) {
|
|
172
|
+
const refNode = useScene.getState().nodes[selectedRefId as AnyNodeId]
|
|
173
|
+
if (refNode && (refNode.type === 'guide' || refNode.type === 'scan')) {
|
|
174
|
+
sfxEmitter.emit('sfx:structure-delete')
|
|
175
|
+
useScene.getState().deleteNode(selectedRefId as AnyNodeId)
|
|
176
|
+
useEditor.getState().setSelectedReferenceId(null)
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Delete selected zone
|
|
182
|
+
const selectedZoneId = useViewer.getState().selection.zoneId
|
|
183
|
+
if (selectedZoneId) {
|
|
184
|
+
sfxEmitter.emit('sfx:structure-delete')
|
|
185
|
+
useScene.getState().deleteNode(selectedZoneId as AnyNodeId)
|
|
186
|
+
useViewer.getState().setSelection({ zoneId: null })
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
191
|
+
|
|
192
|
+
if (selectedNodeIds.length > 0) {
|
|
193
|
+
// Play appropriate SFX based on what's being deleted
|
|
194
|
+
if (selectedNodeIds.length === 1) {
|
|
195
|
+
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
196
|
+
if (node?.type === 'item') {
|
|
197
|
+
sfxEmitter.emit('sfx:item-delete')
|
|
198
|
+
} else {
|
|
199
|
+
sfxEmitter.emit('sfx:structure-delete')
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
sfxEmitter.emit('sfx:structure-delete')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
useScene.getState().deleteNodes(selectedNodeIds)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
210
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
211
|
+
}, [isVersionPreviewMode])
|
|
212
|
+
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
const MOBILE_BREAKPOINT = 768
|
|
4
|
+
|
|
5
|
+
export function useIsMobile() {
|
|
6
|
+
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
|
7
|
+
|
|
8
|
+
React.useEffect(() => {
|
|
9
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
|
10
|
+
const onChange = () => {
|
|
11
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
12
|
+
}
|
|
13
|
+
mql.addEventListener('change', onChange)
|
|
14
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
15
|
+
return () => mql.removeEventListener('change', onChange)
|
|
16
|
+
}, [])
|
|
17
|
+
|
|
18
|
+
return !!isMobile
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns true when the user has requested reduced motion via OS settings.
|
|
5
|
+
* Useful for disabling animations (WCAG 2.3.3).
|
|
6
|
+
*/
|
|
7
|
+
export function useReducedMotion(): boolean {
|
|
8
|
+
const [reducedMotion, setReducedMotion] = useState(false)
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const mql = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
12
|
+
setReducedMotion(mql.matches)
|
|
13
|
+
|
|
14
|
+
const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches)
|
|
15
|
+
mql.addEventListener('change', handler)
|
|
16
|
+
return () => mql.removeEventListener('change', handler)
|
|
17
|
+
}, [])
|
|
18
|
+
|
|
19
|
+
return reducedMotion
|
|
20
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type { EditorProps } from './components/editor'
|
|
2
|
+
export { default as Editor } from './components/editor'
|
|
3
|
+
export { useCommandPalette } from './components/ui/command-palette'
|
|
4
|
+
export { SliderControl } from './components/ui/controls/slider-control'
|
|
5
|
+
export { FloatingLevelSelector } from './components/ui/floating-level-selector'
|
|
6
|
+
export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items'
|
|
7
|
+
export { useSidebarStore } from './components/ui/primitives/sidebar'
|
|
8
|
+
export { Slider } from './components/ui/primitives/slider'
|
|
9
|
+
export { SceneLoader } from './components/ui/scene-loader'
|
|
10
|
+
export type { ExtraPanel } from './components/ui/sidebar/icon-rail'
|
|
11
|
+
export {
|
|
12
|
+
type ProjectVisibility,
|
|
13
|
+
SettingsPanel,
|
|
14
|
+
type SettingsPanelProps,
|
|
15
|
+
} from './components/ui/sidebar/panels/settings-panel'
|
|
16
|
+
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
|
|
17
|
+
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
|
|
18
|
+
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
|
|
19
|
+
export { PresetsProvider } from './contexts/presets-context'
|
|
20
|
+
export type { SaveStatus } from './hooks/use-auto-save'
|
|
21
|
+
export type { SceneGraph } from './lib/scene'
|
|
22
|
+
export { applySceneGraphToEditor } from './lib/scene'
|
|
23
|
+
export { default as useAudio } from './store/use-audio'
|
|
24
|
+
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
|
|
25
|
+
export type { FloorplanSelectionTool, SplitOrientation, ViewMode } from './store/use-editor'
|
|
26
|
+
export { default as useEditor } from './store/use-editor'
|
|
27
|
+
export {
|
|
28
|
+
type PaletteView,
|
|
29
|
+
type PaletteViewProps,
|
|
30
|
+
usePaletteViewRegistry,
|
|
31
|
+
} from './store/use-palette-view-registry'
|
|
32
|
+
export { useUploadStore } from './store/use-upload'
|
|
33
|
+
export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AnyNodeId, BuildingNode, LevelNode } from '@pascal-app/core'
|
|
2
|
+
import { useScene } from '@pascal-app/core'
|
|
3
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
4
|
+
|
|
5
|
+
function getAdjacentLevelIdForDeletion(levelId: AnyNodeId): LevelNode['id'] | null {
|
|
6
|
+
const { nodes } = useScene.getState()
|
|
7
|
+
const level = nodes[levelId]
|
|
8
|
+
if (!level || level.type !== 'level' || !level.parentId) return null
|
|
9
|
+
|
|
10
|
+
const building = nodes[level.parentId as AnyNodeId]
|
|
11
|
+
if (!building || building.type !== 'building') return null
|
|
12
|
+
|
|
13
|
+
const siblingLevelIds = (building as BuildingNode).children.filter(
|
|
14
|
+
(childId): childId is LevelNode['id'] => nodes[childId as AnyNodeId]?.type === 'level',
|
|
15
|
+
)
|
|
16
|
+
const currentIndex = siblingLevelIds.indexOf(level.id)
|
|
17
|
+
if (currentIndex === -1) return null
|
|
18
|
+
|
|
19
|
+
return siblingLevelIds[currentIndex - 1] ?? siblingLevelIds[currentIndex + 1] ?? null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function deleteLevelWithFallbackSelection(levelId: AnyNodeId) {
|
|
23
|
+
const isSelectedLevel = useViewer.getState().selection.levelId === levelId
|
|
24
|
+
const nextLevelId = getAdjacentLevelIdForDeletion(levelId)
|
|
25
|
+
|
|
26
|
+
useScene.getState().deleteNode(levelId)
|
|
27
|
+
|
|
28
|
+
if (isSelectedLevel) {
|
|
29
|
+
useViewer.getState().setSelection({ levelId: nextLevelId })
|
|
30
|
+
}
|
|
31
|
+
}
|