@pascal-app/editor 0.7.0 → 0.8.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 +6 -6
- package/src/components/editor/custom-camera-controls.tsx +2 -1
- package/src/components/editor/editor-layout-v2.tsx +4 -3
- package/src/components/editor/first-person/build-collider-world.ts +5 -7
- package/src/components/editor/first-person/bvh-ecctrl.tsx +119 -54
- package/src/components/editor/first-person-controls.tsx +11 -11
- package/src/components/editor/floating-action-menu.tsx +0 -0
- package/src/components/editor/floorplan-panel.tsx +44 -37
- package/src/components/editor/index.tsx +68 -53
- package/src/components/editor/selection-manager.tsx +2 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
- package/src/components/editor/thumbnail-generator.tsx +18 -61
- package/src/components/editor/use-floorplan-background-placement.ts +3 -3
- package/src/components/editor/wall-measurement-label.tsx +0 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +5 -5
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1 -1
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
- package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
- package/src/components/tools/fence/fence-tool.tsx +2 -2
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +11 -8
- package/src/components/tools/fence/move-fence-tool.tsx +13 -9
- package/src/components/tools/item/move-tool.tsx +3 -6
- package/src/components/tools/item/placement-math.ts +2 -4
- package/src/components/tools/item/placement-strategies.ts +11 -10
- package/src/components/tools/item/use-draft-node.ts +0 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +9 -111
- package/src/components/tools/roof/move-roof-tool.tsx +7 -2
- package/src/components/tools/select/box-select-tool.tsx +12 -17
- package/src/components/tools/shared/segment-angle.ts +1 -1
- package/src/components/tools/tool-manager.tsx +12 -12
- package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +11 -8
- package/src/components/tools/wall/move-wall-tool.tsx +6 -4
- package/src/components/tools/wall/wall-drafting.ts +0 -0
- package/src/components/tools/wall/wall-tool.tsx +3 -3
- package/src/components/tools/zone/zone-tool.tsx +20 -5
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +7 -1
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +35 -86
- package/src/components/ui/action-menu/view-toggles.tsx +19 -31
- package/src/components/ui/command-palette/editor-commands.tsx +6 -4
- package/src/components/ui/command-palette/index.tsx +4 -255
- package/src/components/ui/controls/material-picker.tsx +8 -5
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1742 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +88 -46
- package/src/components/ui/level-duplicate-dialog.tsx +3 -5
- package/src/components/ui/panels/ceiling-panel.tsx +2 -3
- package/src/components/ui/panels/column-panel.tsx +62 -18
- package/src/components/ui/panels/door-panel.tsx +272 -265
- package/src/components/ui/panels/fence-panel.tsx +0 -5
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +3 -32
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/panels/roof-panel.tsx +52 -2
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -0
- package/src/components/ui/panels/slab-panel.tsx +0 -0
- package/src/components/ui/panels/spawn-panel.tsx +10 -4
- package/src/components/ui/panels/stair-panel.tsx +66 -14
- package/src/components/ui/panels/wall-panel.tsx +97 -1
- package/src/components/ui/panels/window-panel.tsx +13 -5
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -7
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +0 -0
- package/src/components/viewer-zone-system.tsx +0 -0
- package/src/hooks/use-auto-save.ts +14 -0
- package/src/hooks/use-keyboard.ts +10 -0
- package/src/index.tsx +8 -1
- package/src/lib/level-duplication.test.ts +0 -2
- package/src/lib/level-duplication.ts +1 -1
- package/src/lib/material-paint.ts +1 -1
- package/src/lib/roof-duplication.ts +1 -1
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene.ts +0 -0
- package/src/lib/sfx-bus.ts +2 -0
- package/src/lib/sfx-player.ts +5 -5
- package/src/lib/stair-duplication.ts +2 -2
- package/src/store/use-editor.tsx +27 -59
- package/tsconfig.json +2 -1
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/ui/viewer-toolbar.tsx +0 -436
|
@@ -60,6 +60,7 @@ export function useAutoSave({
|
|
|
60
60
|
// Stable subscription to scene changes
|
|
61
61
|
useEffect(() => {
|
|
62
62
|
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
|
|
63
|
+
let lastNodeCount = Object.keys(useScene.getState().nodes).length
|
|
63
64
|
|
|
64
65
|
async function executeSave() {
|
|
65
66
|
if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) {
|
|
@@ -71,6 +72,19 @@ export function useAutoSave({
|
|
|
71
72
|
const { nodes, rootNodeIds } = useScene.getState()
|
|
72
73
|
const sceneGraph = { nodes, rootNodeIds } as SceneGraph
|
|
73
74
|
|
|
75
|
+
// Guard: refuse to autosave if the scene went from populated to nearly empty.
|
|
76
|
+
// This catches accidental full deletions before they're persisted.
|
|
77
|
+
const currentNodeCount = Object.keys(nodes).length
|
|
78
|
+
const STRUCTURAL_NODE_COUNT = 4 // site + building + levels (empty scene skeleton)
|
|
79
|
+
if (lastNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT) {
|
|
80
|
+
console.warn(
|
|
81
|
+
`[autosave] Blocked: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
|
|
82
|
+
)
|
|
83
|
+
setSaveStatus('error')
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
lastNodeCount = currentNodeCount
|
|
87
|
+
|
|
74
88
|
isSavingRef.current = true
|
|
75
89
|
pendingSaveRef.current = false
|
|
76
90
|
setSaveStatus('saving')
|
|
@@ -77,6 +77,7 @@ export const useKeyboard = ({
|
|
|
77
77
|
e.preventDefault()
|
|
78
78
|
useEditor.getState().setPhase('furnish')
|
|
79
79
|
useEditor.getState().setMode('build')
|
|
80
|
+
useEditor.getState().setActiveSidebarPanel('items')
|
|
80
81
|
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
|
|
81
82
|
if (isVersionPreviewMode) return
|
|
82
83
|
e.preventDefault()
|
|
@@ -252,6 +253,15 @@ export const useKeyboard = ({
|
|
|
252
253
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
253
254
|
|
|
254
255
|
if (selectedNodeIds.length > 0) {
|
|
256
|
+
// Guard against accidental bulk deletion (e.g. box-select all + Delete)
|
|
257
|
+
const BULK_DELETE_THRESHOLD = 10
|
|
258
|
+
if (selectedNodeIds.length >= BULK_DELETE_THRESHOLD) {
|
|
259
|
+
const confirmed = window.confirm(
|
|
260
|
+
`Delete ${selectedNodeIds.length} selected elements? This cannot be undone if the undo history is exhausted.`,
|
|
261
|
+
)
|
|
262
|
+
if (!confirmed) return
|
|
263
|
+
}
|
|
264
|
+
|
|
255
265
|
// Play appropriate SFX based on what's being deleted
|
|
256
266
|
if (selectedNodeIds.length === 1) {
|
|
257
267
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
package/src/index.tsx
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
export type { EditorProps } from './components/editor'
|
|
2
2
|
export { default as Editor } from './components/editor'
|
|
3
|
+
export {
|
|
4
|
+
type SnapshotCameraData,
|
|
5
|
+
ThumbnailGenerator,
|
|
6
|
+
} from './components/editor/thumbnail-generator'
|
|
7
|
+
export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions'
|
|
8
|
+
export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles'
|
|
3
9
|
export { useCommandPalette } from './components/ui/command-palette'
|
|
4
10
|
export { SliderControl } from './components/ui/controls/slider-control'
|
|
5
11
|
export { FloatingLevelSelector } from './components/ui/floating-level-selector'
|
|
6
12
|
export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items'
|
|
13
|
+
export { PALETTE_COLORS } from './components/ui/primitives/color-dot'
|
|
7
14
|
export { useSidebarStore } from './components/ui/primitives/sidebar'
|
|
8
15
|
export { Slider } from './components/ui/primitives/slider'
|
|
9
16
|
export { SceneLoader } from './components/ui/scene-loader'
|
|
10
17
|
export type { ExtraPanel } from './components/ui/sidebar/icon-rail'
|
|
18
|
+
export { ItemsPanel } from './components/ui/sidebar/panels/items-panel'
|
|
11
19
|
export {
|
|
12
20
|
type ProjectVisibility,
|
|
13
21
|
SettingsPanel,
|
|
@@ -15,7 +23,6 @@ export {
|
|
|
15
23
|
} from './components/ui/sidebar/panels/settings-panel'
|
|
16
24
|
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
|
|
17
25
|
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
|
|
18
|
-
export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'
|
|
19
26
|
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
|
|
20
27
|
export { PresetsProvider } from './contexts/presets-context'
|
|
21
28
|
export type { SaveStatus } from './hooks/use-auto-save'
|
|
@@ -123,7 +123,7 @@ export function buildLevelDuplicateCreateOps({
|
|
|
123
123
|
const keptIds = new Set(filteredNodes.map((node) => node.id))
|
|
124
124
|
|
|
125
125
|
const cleanedNodes = filteredNodes.map((node) => {
|
|
126
|
-
if (!('children' in node
|
|
126
|
+
if (!('children' in node && Array.isArray(node.children))) {
|
|
127
127
|
return node
|
|
128
128
|
}
|
|
129
129
|
|
|
@@ -154,7 +154,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|
|
154
154
|
} | null
|
|
155
155
|
}): ActivePaintMaterial | null {
|
|
156
156
|
const { nodes, selectedId, selectedMaterialTarget } = params
|
|
157
|
-
if (!selectedId
|
|
157
|
+
if (!(selectedId && selectedMaterialTarget) || selectedMaterialTarget.nodeId !== selectedId)
|
|
158
158
|
return null
|
|
159
159
|
|
|
160
160
|
const selectedNode = nodes[selectedId]
|
|
@@ -134,7 +134,7 @@ export function duplicateRoofSubtree(
|
|
|
134
134
|
createdParent && 'children' in createdParent && Array.isArray(createdParent.children)
|
|
135
135
|
? (createdParent.children as AnyNodeId[])
|
|
136
136
|
: null
|
|
137
|
-
if (!createdParent
|
|
137
|
+
if (!(createdParent && parentChildIds?.includes(createdRoof.id as AnyNodeId))) {
|
|
138
138
|
throw new Error(`Duplicated roof "${createdRoof.id}" was not linked to parent "${parentId}"`)
|
|
139
139
|
}
|
|
140
140
|
|
package/src/lib/scene-bounds.ts
CHANGED
|
@@ -33,7 +33,7 @@ function extendPoint(
|
|
|
33
33
|
z: unknown,
|
|
34
34
|
): void {
|
|
35
35
|
if (typeof x !== 'number' || typeof z !== 'number') return
|
|
36
|
-
if (!Number.isFinite(x)
|
|
36
|
+
if (!(Number.isFinite(x) && Number.isFinite(z))) return
|
|
37
37
|
if (x < acc.minX) acc.minX = x
|
|
38
38
|
if (x > acc.maxX) acc.maxX = x
|
|
39
39
|
if (z < acc.minZ) acc.minZ = z
|
package/src/lib/scene.ts
CHANGED
|
File without changes
|
package/src/lib/sfx-bus.ts
CHANGED
|
@@ -12,6 +12,7 @@ type SFXEvents = {
|
|
|
12
12
|
'sfx:item-rotate': undefined
|
|
13
13
|
'sfx:structure-build': undefined
|
|
14
14
|
'sfx:structure-delete': undefined
|
|
15
|
+
'sfx:snapshot-capture': undefined
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -37,6 +38,7 @@ export function initSFXBus() {
|
|
|
37
38
|
sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
|
|
38
39
|
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuild'))
|
|
39
40
|
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
|
|
41
|
+
sfxEmitter.on('sfx:snapshot-capture', () => playSFX('snapshotCapture'))
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/**
|
package/src/lib/sfx-player.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Howl } from 'howler'
|
|
|
2
2
|
import useAudio from '../store/use-audio'
|
|
3
3
|
|
|
4
4
|
// Per-sound variation config. Playback rate also shifts pitch (one semitone ≈ 1.0595×),
|
|
5
|
-
// so a rate range of ~0.88–1.12 reads as a subtle ±2 semitones
|
|
5
|
+
// so a rate range of ~0.88–1.12 reads as a subtle ±2 semitones — enough to kill the
|
|
6
6
|
// machine-gun feeling when the same SFX fires in rapid succession.
|
|
7
7
|
type SFXConfig = {
|
|
8
8
|
src: string
|
|
@@ -13,8 +13,8 @@ type SFXConfig = {
|
|
|
13
13
|
// Minimum gap between two plays of this SFX. Triggers within this window
|
|
14
14
|
// are silently dropped so bursty sequences don't phase-stack into noise.
|
|
15
15
|
minIntervalMs?: number
|
|
16
|
-
// Random stereo pan per play
|
|
17
|
-
// right). A small value like 0.15 keeps things
|
|
16
|
+
// Random stereo pan per play — max absolute offset (0 = center, 1 = hard
|
|
17
|
+
// right). A small value like 0.15 keeps things centred but adds just enough
|
|
18
18
|
// spread to stop repeats from stacking on the same point in the field.
|
|
19
19
|
panJitter?: number
|
|
20
20
|
}
|
|
@@ -66,7 +66,7 @@ export const SFX: Record<string, SFXConfig> = {
|
|
|
66
66
|
panJitter: 0.15,
|
|
67
67
|
},
|
|
68
68
|
snapshotCapture: {
|
|
69
|
-
// Shutter should sound consistent
|
|
69
|
+
// Shutter should sound consistent — no variation.
|
|
70
70
|
src: '/audios/sfx/snapshot_capture.mp3',
|
|
71
71
|
},
|
|
72
72
|
} as const
|
|
@@ -102,7 +102,7 @@ export function playSFX(name: SFXName) {
|
|
|
102
102
|
}
|
|
103
103
|
const config = SFX[name]!
|
|
104
104
|
|
|
105
|
-
// Drop rapid repeats
|
|
105
|
+
// Drop rapid repeats — two plays of the same SFX within minIntervalMs just
|
|
106
106
|
// smear into noise, they don't add useful information.
|
|
107
107
|
const now = performance.now()
|
|
108
108
|
const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
-
generateId,
|
|
3
2
|
type AnyNodeId,
|
|
4
|
-
|
|
3
|
+
generateId,
|
|
5
4
|
type StairNode,
|
|
6
5
|
StairNode as StairNodeSchema,
|
|
7
6
|
type StairSegmentNode,
|
|
8
7
|
StairSegmentNode as StairSegmentNodeSchema,
|
|
8
|
+
sceneRegistry,
|
|
9
9
|
useScene,
|
|
10
10
|
} from '@pascal-app/core'
|
|
11
11
|
import { useViewer } from '@pascal-app/viewer'
|
package/src/store/use-editor.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import type { AssetInput } from '@pascal-app/core'
|
|
3
4
|
import {
|
|
4
5
|
type AnyNodeId,
|
|
5
|
-
type AssetInput,
|
|
6
6
|
type BuildingNode,
|
|
7
7
|
type CeilingNode,
|
|
8
8
|
type ColumnNode,
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
type SingleSurfaceMaterialRole,
|
|
37
37
|
} from '../lib/material-paint'
|
|
38
38
|
|
|
39
|
-
const DEFAULT_ACTIVE_SIDEBAR_PANEL = '
|
|
39
|
+
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'ai'
|
|
40
40
|
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
|
|
41
41
|
const MIN_FLOORPLAN_PANE_RATIO = 0.15
|
|
42
42
|
const MAX_FLOORPLAN_PANE_RATIO = 0.85
|
|
@@ -138,11 +138,11 @@ type EditorState = {
|
|
|
138
138
|
| ItemNode
|
|
139
139
|
| WindowNode
|
|
140
140
|
| DoorNode
|
|
141
|
-
| FenceNode
|
|
142
141
|
| CeilingNode
|
|
143
142
|
| ColumnNode
|
|
144
143
|
| SlabNode
|
|
145
144
|
| WallNode
|
|
145
|
+
| FenceNode
|
|
146
146
|
| RoofNode
|
|
147
147
|
| RoofSegmentNode
|
|
148
148
|
| SpawnNode
|
|
@@ -155,11 +155,11 @@ type EditorState = {
|
|
|
155
155
|
| ItemNode
|
|
156
156
|
| WindowNode
|
|
157
157
|
| DoorNode
|
|
158
|
-
| FenceNode
|
|
159
158
|
| CeilingNode
|
|
160
159
|
| ColumnNode
|
|
161
160
|
| SlabNode
|
|
162
161
|
| WallNode
|
|
162
|
+
| FenceNode
|
|
163
163
|
| RoofNode
|
|
164
164
|
| RoofSegmentNode
|
|
165
165
|
| SpawnNode
|
|
@@ -202,6 +202,9 @@ type EditorState = {
|
|
|
202
202
|
// Preview mode (viewer-like experience inside the editor)
|
|
203
203
|
isPreviewMode: boolean
|
|
204
204
|
setPreviewMode: (preview: boolean) => void
|
|
205
|
+
// Capture mode (snapshot toolbar — hides panels for clean framing)
|
|
206
|
+
isCaptureMode: boolean
|
|
207
|
+
setCaptureMode: (active: boolean) => void
|
|
205
208
|
// View mode (3D only, 2D only, or split 2D+3D)
|
|
206
209
|
viewMode: ViewMode
|
|
207
210
|
setViewMode: (mode: ViewMode) => void
|
|
@@ -224,21 +227,22 @@ type EditorState = {
|
|
|
224
227
|
setReferenceFloorOffset: (offset: number) => void
|
|
225
228
|
referenceFloorOpacity: number
|
|
226
229
|
setReferenceFloorOpacity: (opacity: number) => void
|
|
230
|
+
// Development-only camera debug flag for inspecting underside geometry
|
|
231
|
+
allowUndergroundCamera: boolean
|
|
232
|
+
setAllowUndergroundCamera: (enabled: boolean) => void
|
|
227
233
|
// First-person walkthrough mode (street view)
|
|
228
234
|
isFirstPersonMode: boolean
|
|
229
235
|
_viewModeBeforeFirstPerson: ViewMode | null
|
|
230
236
|
setFirstPersonMode: (enabled: boolean) => void
|
|
231
|
-
// Development-only camera debug flag for inspecting underside geometry
|
|
232
|
-
allowUndergroundCamera: boolean
|
|
233
|
-
setAllowUndergroundCamera: (enabled: boolean) => void
|
|
234
237
|
activeSidebarPanel: string
|
|
235
238
|
setActiveSidebarPanel: (id: string) => void
|
|
236
|
-
mobilePanelSheetHeight: number
|
|
237
|
-
setMobilePanelSheetHeight: (height: number) => void
|
|
238
|
-
isCaptureMode: boolean
|
|
239
239
|
setIsCaptureMode: (enabled: boolean) => void
|
|
240
240
|
floorplanPaneRatio: number
|
|
241
241
|
setFloorplanPaneRatio: (ratio: number) => void
|
|
242
|
+
// Mobile-only: pixel height of the secondary panel sheet while open (0 when closed).
|
|
243
|
+
// Read by the mobile layout so the viewer container can shrink to preview edits.
|
|
244
|
+
mobilePanelSheetHeight: number
|
|
245
|
+
setMobilePanelSheetHeight: (px: number) => void
|
|
242
246
|
}
|
|
243
247
|
|
|
244
248
|
export type PersistedEditorUiState = Pick<
|
|
@@ -456,10 +460,6 @@ export function selectDefaultBuildingAndLevel() {
|
|
|
456
460
|
}
|
|
457
461
|
}
|
|
458
462
|
|
|
459
|
-
function getDefaultSelectedItemForCategory(category: CatalogCategory | null): AssetInput | null {
|
|
460
|
-
return getDefaultCatalogItem(category)
|
|
461
|
-
}
|
|
462
|
-
|
|
463
463
|
const useEditor = create<EditorState>()(
|
|
464
464
|
persist(
|
|
465
465
|
(set, get) => ({
|
|
@@ -481,11 +481,7 @@ const useEditor = create<EditorState>()(
|
|
|
481
481
|
} else if (phase === 'structure') {
|
|
482
482
|
set({ tool: 'wall', catalogCategory: null })
|
|
483
483
|
} else if (phase === 'furnish') {
|
|
484
|
-
set({
|
|
485
|
-
tool: 'item',
|
|
486
|
-
catalogCategory: 'furniture',
|
|
487
|
-
selectedItem: getDefaultSelectedItemForCategory('furniture'),
|
|
488
|
-
})
|
|
484
|
+
set({ tool: 'item', catalogCategory: 'furniture' })
|
|
489
485
|
}
|
|
490
486
|
} else {
|
|
491
487
|
// Reset to select mode and clear tool/catalog when switching phases
|
|
@@ -525,15 +521,8 @@ const useEditor = create<EditorState>()(
|
|
|
525
521
|
} else if (phase === 'structure' && structureLayer === 'elements') {
|
|
526
522
|
set({ tool: 'wall' })
|
|
527
523
|
} else if (phase === 'furnish') {
|
|
528
|
-
set({
|
|
529
|
-
tool: 'item',
|
|
530
|
-
catalogCategory: 'furniture',
|
|
531
|
-
selectedItem: getDefaultSelectedItemForCategory('furniture'),
|
|
532
|
-
})
|
|
524
|
+
set({ tool: 'item', catalogCategory: 'furniture' })
|
|
533
525
|
}
|
|
534
|
-
} else if (phase === 'furnish' && tool === 'item' && !get().selectedItem) {
|
|
535
|
-
const category = get().catalogCategory ?? 'furniture'
|
|
536
|
-
set({ selectedItem: getDefaultSelectedItemForCategory(category) })
|
|
537
526
|
}
|
|
538
527
|
} else if (mode === 'material-paint') {
|
|
539
528
|
get().primeMaterialPaintFromSelection()
|
|
@@ -563,27 +552,17 @@ const useEditor = create<EditorState>()(
|
|
|
563
552
|
})
|
|
564
553
|
},
|
|
565
554
|
catalogCategory: DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory,
|
|
566
|
-
setCatalogCategory: (category) =>
|
|
567
|
-
set((state) => ({
|
|
568
|
-
catalogCategory: category,
|
|
569
|
-
selectedItem:
|
|
570
|
-
category !== null &&
|
|
571
|
-
state.phase === 'furnish' &&
|
|
572
|
-
state.mode === 'build' &&
|
|
573
|
-
state.tool === 'item'
|
|
574
|
-
? getDefaultSelectedItemForCategory(category)
|
|
575
|
-
: state.selectedItem,
|
|
576
|
-
})),
|
|
555
|
+
setCatalogCategory: (category) => set({ catalogCategory: category }),
|
|
577
556
|
selectedItem: null,
|
|
578
557
|
setSelectedItem: (item) => set({ selectedItem: item }),
|
|
579
558
|
movingNode: null as
|
|
580
559
|
| ItemNode
|
|
581
560
|
| WindowNode
|
|
582
561
|
| DoorNode
|
|
583
|
-
| FenceNode
|
|
584
562
|
| CeilingNode
|
|
585
563
|
| SlabNode
|
|
586
564
|
| WallNode
|
|
565
|
+
| FenceNode
|
|
587
566
|
| RoofNode
|
|
588
567
|
| RoofSegmentNode
|
|
589
568
|
| StairNode
|
|
@@ -688,6 +667,8 @@ const useEditor = create<EditorState>()(
|
|
|
688
667
|
set({ isPreviewMode: false })
|
|
689
668
|
}
|
|
690
669
|
},
|
|
670
|
+
isCaptureMode: false,
|
|
671
|
+
setCaptureMode: (active) => set({ isCaptureMode: active }),
|
|
691
672
|
viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode,
|
|
692
673
|
setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }),
|
|
693
674
|
splitOrientation: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.splitOrientation,
|
|
@@ -742,33 +723,20 @@ const useEditor = create<EditorState>()(
|
|
|
742
723
|
},
|
|
743
724
|
activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
|
|
744
725
|
setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),
|
|
745
|
-
mobilePanelSheetHeight: 0,
|
|
746
|
-
setMobilePanelSheetHeight: (height) => set({ mobilePanelSheetHeight: height }),
|
|
747
|
-
isCaptureMode: false,
|
|
748
726
|
setIsCaptureMode: (enabled) => set({ isCaptureMode: enabled }),
|
|
749
727
|
floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,
|
|
750
728
|
setFloorplanPaneRatio: (ratio) =>
|
|
751
729
|
set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }),
|
|
730
|
+
mobilePanelSheetHeight: 0,
|
|
731
|
+
setMobilePanelSheetHeight: (px) => set({ mobilePanelSheetHeight: Math.max(0, px) }),
|
|
752
732
|
}),
|
|
753
733
|
{
|
|
754
734
|
name: 'pascal-editor-ui-preferences',
|
|
755
|
-
merge: (persistedState, currentState) => {
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
return {
|
|
763
|
-
...mergedState,
|
|
764
|
-
selectedItem:
|
|
765
|
-
mergedState.phase === 'furnish' &&
|
|
766
|
-
mergedState.mode === 'build' &&
|
|
767
|
-
mergedState.tool === 'item'
|
|
768
|
-
? getDefaultSelectedItemForCategory(mergedState.catalogCategory ?? 'furniture')
|
|
769
|
-
: currentState.selectedItem,
|
|
770
|
-
}
|
|
771
|
-
},
|
|
735
|
+
merge: (persistedState, currentState) => ({
|
|
736
|
+
...currentState,
|
|
737
|
+
...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorState>),
|
|
738
|
+
...normalizePersistedEditorLayoutState(persistedState as Partial<PersistedEditorState>),
|
|
739
|
+
}),
|
|
772
740
|
partialize: (state) => ({
|
|
773
741
|
phase: state.phase,
|
|
774
742
|
mode: state.mode,
|
package/tsconfig.json
CHANGED
|
@@ -1,265 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import { useScene } from '@pascal-app/core'
|
|
4
|
-
import { ImageIcon, MessageSquare, X } from 'lucide-react'
|
|
5
|
-
import { useCallback, useRef, useState } from 'react'
|
|
6
|
-
import { Button } from './ui/primitives/button'
|
|
7
|
-
import {
|
|
8
|
-
Dialog,
|
|
9
|
-
DialogContent,
|
|
10
|
-
DialogDescription,
|
|
11
|
-
DialogHeader,
|
|
12
|
-
DialogTitle,
|
|
13
|
-
} from './ui/primitives/dialog'
|
|
14
|
-
|
|
15
|
-
const MAX_IMAGES = 5
|
|
16
|
-
const MAX_IMAGE_SIZE = 5 * 1024 * 1024
|
|
17
|
-
|
|
18
|
-
type ImagePreview = { file: File; url: string }
|
|
19
|
-
|
|
20
|
-
export function FeedbackDialog({
|
|
21
|
-
projectId: projectIdProp,
|
|
22
|
-
onSubmit,
|
|
23
|
-
}: {
|
|
24
|
-
projectId?: string
|
|
25
|
-
onSubmit?: (data: {
|
|
26
|
-
message: string
|
|
27
|
-
projectId?: string
|
|
28
|
-
sceneGraph: unknown
|
|
29
|
-
images: File[]
|
|
30
|
-
}) => Promise<{ success: boolean; error?: string }>
|
|
31
|
-
}) {
|
|
32
|
-
const projectId = projectIdProp
|
|
33
|
-
|
|
34
|
-
const [open, setOpen] = useState(false)
|
|
35
|
-
const [message, setMessage] = useState('')
|
|
36
|
-
const [images, setImages] = useState<ImagePreview[]>([])
|
|
37
|
-
const [isDragging, setIsDragging] = useState(false)
|
|
38
|
-
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
39
|
-
const [error, setError] = useState<string | null>(null)
|
|
40
|
-
const [sent, setSent] = useState(false)
|
|
41
|
-
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
42
|
-
const dragCounter = useRef(0)
|
|
43
|
-
|
|
44
|
-
const handleOpen = () => {
|
|
45
|
-
setOpen(true)
|
|
46
|
-
setSent(false)
|
|
47
|
-
setError(null)
|
|
48
|
-
setMessage('')
|
|
49
|
-
setImages([])
|
|
50
|
-
setIsDragging(false)
|
|
51
|
-
dragCounter.current = 0
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const handleClose = () => {
|
|
55
|
-
if (isSubmitting) return
|
|
56
|
-
setOpen(false)
|
|
57
|
-
images.forEach((img) => {
|
|
58
|
-
URL.revokeObjectURL(img.url)
|
|
59
|
-
})
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const addFiles = useCallback((files: FileList | File[]) => {
|
|
63
|
-
const incoming = Array.from(files).filter(
|
|
64
|
-
(f) => f.type.startsWith('image/') && f.size <= MAX_IMAGE_SIZE,
|
|
65
|
-
)
|
|
66
|
-
setImages((prev) => {
|
|
67
|
-
const remaining = MAX_IMAGES - prev.length
|
|
68
|
-
const added = incoming.slice(0, remaining).map((file) => ({
|
|
69
|
-
file,
|
|
70
|
-
url: URL.createObjectURL(file),
|
|
71
|
-
}))
|
|
72
|
-
return [...prev, ...added]
|
|
73
|
-
})
|
|
74
|
-
}, [])
|
|
75
|
-
|
|
76
|
-
const removeImage = (index: number) => {
|
|
77
|
-
setImages((prev) => {
|
|
78
|
-
const img = prev[index]
|
|
79
|
-
if (img) URL.revokeObjectURL(img.url)
|
|
80
|
-
return prev.filter((_, i) => i !== index)
|
|
81
|
-
})
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ── Drag handlers (on the entire dialog content) ──
|
|
85
|
-
const onDragEnter = (e: React.DragEvent) => {
|
|
86
|
-
e.preventDefault()
|
|
87
|
-
e.stopPropagation()
|
|
88
|
-
dragCounter.current++
|
|
89
|
-
if (e.dataTransfer.types.includes('Files')) {
|
|
90
|
-
setIsDragging(true)
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const onDragLeave = (e: React.DragEvent) => {
|
|
95
|
-
e.preventDefault()
|
|
96
|
-
e.stopPropagation()
|
|
97
|
-
dragCounter.current--
|
|
98
|
-
if (dragCounter.current === 0) {
|
|
99
|
-
setIsDragging(false)
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const onDragOver = (e: React.DragEvent) => {
|
|
104
|
-
e.preventDefault()
|
|
105
|
-
e.stopPropagation()
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const onDrop = (e: React.DragEvent) => {
|
|
109
|
-
e.preventDefault()
|
|
110
|
-
e.stopPropagation()
|
|
111
|
-
dragCounter.current = 0
|
|
112
|
-
setIsDragging(false)
|
|
113
|
-
if (e.dataTransfer.files.length > 0) {
|
|
114
|
-
addFiles(e.dataTransfer.files)
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const handleSubmit = async (e: React.FormEvent) => {
|
|
119
|
-
e.preventDefault()
|
|
120
|
-
setError(null)
|
|
121
|
-
setIsSubmitting(true)
|
|
122
|
-
|
|
123
|
-
try {
|
|
124
|
-
if (!onSubmit) return
|
|
125
|
-
const { nodes, rootNodeIds } = useScene.getState()
|
|
126
|
-
const sceneGraph = { nodes, rootNodeIds }
|
|
127
|
-
const result = await onSubmit({
|
|
128
|
-
message,
|
|
129
|
-
projectId,
|
|
130
|
-
sceneGraph,
|
|
131
|
-
images: images.map((img) => img.file),
|
|
132
|
-
})
|
|
133
|
-
if (result.success) {
|
|
134
|
-
setSent(true)
|
|
135
|
-
setTimeout(() => setOpen(false), 1500)
|
|
136
|
-
} else {
|
|
137
|
-
setError(result.error ?? 'Something went wrong')
|
|
138
|
-
}
|
|
139
|
-
} finally {
|
|
140
|
-
setIsSubmitting(false)
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return (
|
|
145
|
-
<>
|
|
146
|
-
<button
|
|
147
|
-
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90"
|
|
148
|
-
onClick={handleOpen}
|
|
149
|
-
>
|
|
150
|
-
<MessageSquare className="h-4 w-4" />
|
|
151
|
-
Feedback
|
|
152
|
-
</button>
|
|
153
|
-
|
|
154
|
-
<Dialog onOpenChange={handleClose} open={open}>
|
|
155
|
-
<DialogContent
|
|
156
|
-
className="sm:max-w-[460px]"
|
|
157
|
-
onDragEnter={onDragEnter}
|
|
158
|
-
onDragLeave={onDragLeave}
|
|
159
|
-
onDragOver={onDragOver}
|
|
160
|
-
onDrop={onDrop}
|
|
161
|
-
>
|
|
162
|
-
{/* Drag overlay — only visible when dragging files over the dialog */}
|
|
163
|
-
{isDragging && (
|
|
164
|
-
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-primary/50 border-dashed bg-primary/5 backdrop-blur-sm transition-all">
|
|
165
|
-
<div className="flex flex-col items-center gap-2 text-primary/70">
|
|
166
|
-
<ImageIcon className="h-8 w-8" />
|
|
167
|
-
<p className="font-medium text-sm">Drop images here</p>
|
|
168
|
-
</div>
|
|
169
|
-
</div>
|
|
170
|
-
)}
|
|
171
|
-
|
|
172
|
-
<DialogHeader>
|
|
173
|
-
<DialogTitle>Send Feedback</DialogTitle>
|
|
174
|
-
<DialogDescription>We'd love to hear your thoughts</DialogDescription>
|
|
175
|
-
</DialogHeader>
|
|
176
|
-
|
|
177
|
-
{sent ? (
|
|
178
|
-
<p className="py-4 text-center text-muted-foreground text-sm">
|
|
179
|
-
Thanks for your feedback!
|
|
180
|
-
</p>
|
|
181
|
-
) : (
|
|
182
|
-
<form className="space-y-4" onSubmit={handleSubmit}>
|
|
183
|
-
<div>
|
|
184
|
-
<label className="font-medium text-sm" htmlFor="feedback-message">
|
|
185
|
-
Your feedback
|
|
186
|
-
</label>
|
|
187
|
-
<textarea
|
|
188
|
-
autoFocus
|
|
189
|
-
className="mt-1 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
|
190
|
-
disabled={isSubmitting}
|
|
191
|
-
id="feedback-message"
|
|
192
|
-
onChange={(e) => setMessage(e.target.value)}
|
|
193
|
-
placeholder="Share your thoughts, suggestions, feature requests, or report issues..."
|
|
194
|
-
rows={5}
|
|
195
|
-
value={message}
|
|
196
|
-
/>
|
|
197
|
-
</div>
|
|
198
|
-
|
|
199
|
-
{/* Image thumbnails */}
|
|
200
|
-
{images.length > 0 && (
|
|
201
|
-
<div className="flex flex-wrap gap-2">
|
|
202
|
-
{images.map((img, i) => (
|
|
203
|
-
<div
|
|
204
|
-
className="group relative h-14 w-14 overflow-hidden rounded-md border border-border"
|
|
205
|
-
key={img.url}
|
|
206
|
-
>
|
|
207
|
-
<img alt="" className="h-full w-full object-cover" src={img.url} />
|
|
208
|
-
<button
|
|
209
|
-
className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
|
|
210
|
-
onClick={() => removeImage(i)}
|
|
211
|
-
type="button"
|
|
212
|
-
>
|
|
213
|
-
<X className="h-4 w-4 text-white" />
|
|
214
|
-
</button>
|
|
215
|
-
</div>
|
|
216
|
-
))}
|
|
217
|
-
</div>
|
|
218
|
-
)}
|
|
219
|
-
|
|
220
|
-
{error && <p className="text-destructive text-sm">{error}</p>}
|
|
221
|
-
|
|
222
|
-
<div className="flex items-center justify-between">
|
|
223
|
-
{/* Subtle attach button */}
|
|
224
|
-
<button
|
|
225
|
-
className="flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground disabled:opacity-40"
|
|
226
|
-
disabled={isSubmitting || images.length >= MAX_IMAGES}
|
|
227
|
-
onClick={() => fileInputRef.current?.click()}
|
|
228
|
-
type="button"
|
|
229
|
-
>
|
|
230
|
-
<ImageIcon className="h-3.5 w-3.5" />
|
|
231
|
-
{images.length > 0 ? `${images.length}/${MAX_IMAGES}` : 'Attach'}
|
|
232
|
-
</button>
|
|
233
|
-
<input
|
|
234
|
-
accept="image/*"
|
|
235
|
-
className="hidden"
|
|
236
|
-
multiple
|
|
237
|
-
onChange={(e) => {
|
|
238
|
-
if (e.target.files) addFiles(e.target.files)
|
|
239
|
-
e.target.value = ''
|
|
240
|
-
}}
|
|
241
|
-
ref={fileInputRef}
|
|
242
|
-
type="file"
|
|
243
|
-
/>
|
|
244
|
-
|
|
245
|
-
<div className="flex gap-2">
|
|
246
|
-
<Button
|
|
247
|
-
disabled={isSubmitting}
|
|
248
|
-
onClick={handleClose}
|
|
249
|
-
type="button"
|
|
250
|
-
variant="outline"
|
|
251
|
-
>
|
|
252
|
-
Cancel
|
|
253
|
-
</Button>
|
|
254
|
-
<Button disabled={isSubmitting || !message.trim() || !onSubmit} type="submit">
|
|
255
|
-
{isSubmitting ? 'Sending...' : 'Send Feedback'}
|
|
256
|
-
</Button>
|
|
257
|
-
</div>
|
|
258
|
-
</div>
|
|
259
|
-
</form>
|
|
260
|
-
)}
|
|
261
|
-
</DialogContent>
|
|
262
|
-
</Dialog>
|
|
263
|
-
</>
|
|
264
|
-
)
|
|
265
|
-
}
|