@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.
Files changed (103) hide show
  1. package/package.json +6 -6
  2. package/src/components/editor/custom-camera-controls.tsx +2 -1
  3. package/src/components/editor/editor-layout-v2.tsx +4 -3
  4. package/src/components/editor/first-person/build-collider-world.ts +5 -7
  5. package/src/components/editor/first-person/bvh-ecctrl.tsx +119 -54
  6. package/src/components/editor/first-person-controls.tsx +11 -11
  7. package/src/components/editor/floating-action-menu.tsx +0 -0
  8. package/src/components/editor/floorplan-panel.tsx +44 -37
  9. package/src/components/editor/index.tsx +68 -53
  10. package/src/components/editor/selection-manager.tsx +2 -2
  11. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  12. package/src/components/editor/thumbnail-generator.tsx +18 -61
  13. package/src/components/editor/use-floorplan-background-placement.ts +3 -3
  14. package/src/components/editor/wall-measurement-label.tsx +0 -0
  15. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  16. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +6 -1
  17. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +5 -5
  18. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  19. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  20. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  21. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  22. package/src/components/systems/zone/zone-system.tsx +0 -0
  23. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  24. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  25. package/src/components/tools/fence/fence-tool.tsx +2 -2
  26. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +11 -8
  27. package/src/components/tools/fence/move-fence-tool.tsx +13 -9
  28. package/src/components/tools/item/move-tool.tsx +3 -6
  29. package/src/components/tools/item/placement-math.ts +2 -4
  30. package/src/components/tools/item/placement-strategies.ts +11 -10
  31. package/src/components/tools/item/use-draft-node.ts +0 -1
  32. package/src/components/tools/item/use-placement-coordinator.tsx +9 -111
  33. package/src/components/tools/roof/move-roof-tool.tsx +7 -2
  34. package/src/components/tools/select/box-select-tool.tsx +12 -17
  35. package/src/components/tools/shared/segment-angle.ts +1 -1
  36. package/src/components/tools/tool-manager.tsx +12 -12
  37. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  38. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +11 -8
  39. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  40. package/src/components/tools/wall/wall-drafting.ts +0 -0
  41. package/src/components/tools/wall/wall-tool.tsx +3 -3
  42. package/src/components/tools/zone/zone-tool.tsx +20 -5
  43. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  44. package/src/components/ui/action-menu/control-modes.tsx +7 -1
  45. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  46. package/src/components/ui/action-menu/index.tsx +35 -86
  47. package/src/components/ui/action-menu/view-toggles.tsx +19 -31
  48. package/src/components/ui/command-palette/editor-commands.tsx +6 -4
  49. package/src/components/ui/command-palette/index.tsx +4 -255
  50. package/src/components/ui/controls/material-picker.tsx +8 -5
  51. package/src/components/ui/floating-level-selector.tsx +1 -1
  52. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  53. package/src/components/ui/item-catalog/catalog-items.tsx +1742 -315
  54. package/src/components/ui/item-catalog/item-catalog.tsx +88 -46
  55. package/src/components/ui/level-duplicate-dialog.tsx +3 -5
  56. package/src/components/ui/panels/ceiling-panel.tsx +2 -3
  57. package/src/components/ui/panels/column-panel.tsx +62 -18
  58. package/src/components/ui/panels/door-panel.tsx +272 -265
  59. package/src/components/ui/panels/fence-panel.tsx +0 -5
  60. package/src/components/ui/panels/paint-panel.tsx +66 -41
  61. package/src/components/ui/panels/panel-manager.tsx +3 -32
  62. package/src/components/ui/panels/reference-panel.tsx +28 -13
  63. package/src/components/ui/panels/roof-panel.tsx +52 -2
  64. package/src/components/ui/panels/roof-segment-panel.tsx +0 -0
  65. package/src/components/ui/panels/slab-panel.tsx +0 -0
  66. package/src/components/ui/panels/spawn-panel.tsx +10 -4
  67. package/src/components/ui/panels/stair-panel.tsx +66 -14
  68. package/src/components/ui/panels/wall-panel.tsx +97 -1
  69. package/src/components/ui/panels/window-panel.tsx +13 -5
  70. package/src/components/ui/primitives/number-input.tsx +1 -1
  71. package/src/components/ui/primitives/sidebar.tsx +0 -0
  72. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  73. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  74. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  75. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  76. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  77. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  78. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  79. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -7
  80. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -1
  81. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  82. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  83. package/src/components/ui/slider.tsx +1 -1
  84. package/src/components/viewer-overlay.tsx +0 -0
  85. package/src/components/viewer-zone-system.tsx +0 -0
  86. package/src/hooks/use-auto-save.ts +14 -0
  87. package/src/hooks/use-keyboard.ts +10 -0
  88. package/src/index.tsx +8 -1
  89. package/src/lib/level-duplication.test.ts +0 -2
  90. package/src/lib/level-duplication.ts +1 -1
  91. package/src/lib/material-paint.ts +1 -1
  92. package/src/lib/roof-duplication.ts +1 -1
  93. package/src/lib/scene-bounds.ts +1 -1
  94. package/src/lib/scene.ts +0 -0
  95. package/src/lib/sfx-bus.ts +2 -0
  96. package/src/lib/sfx-player.ts +5 -5
  97. package/src/lib/stair-duplication.ts +2 -2
  98. package/src/store/use-editor.tsx +27 -59
  99. package/tsconfig.json +2 -1
  100. package/src/components/feedback-dialog.tsx +0 -265
  101. package/src/components/pascal-radio.tsx +0 -280
  102. package/src/components/preview-button.tsx +0 -16
  103. 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'
@@ -1,5 +1,3 @@
1
- // @ts-expect-error — bun:test is provided by the Bun runtime; editor does not
2
- // depend on @types/bun so the import type is unresolved at compile time.
3
1
  import { describe, expect, test } from 'bun:test'
4
2
  import {
5
3
  type AnyNode,
@@ -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) || !Array.isArray(node.children)) {
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 || !selectedMaterialTarget || selectedMaterialTarget.nodeId !== 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 || !parentChildIds?.includes(createdRoof.id as AnyNodeId)) {
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
 
@@ -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) || !Number.isFinite(z)) return
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
@@ -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
  /**
@@ -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, enough to kill the
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, max absolute offset (0 = center, 1 = hard
17
- // right). A small value like 0.15 keeps things centered but adds just enough
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, no variation.
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, two plays of the same SFX within minIntervalMs just
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
- sceneRegistry,
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'
@@ -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 = 'site'
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
- const mergedState = {
757
- ...currentState,
758
- ...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorState>),
759
- ...normalizePersistedEditorLayoutState(persistedState as Partial<PersistedEditorState>),
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
@@ -2,7 +2,8 @@
2
2
  "extends": "@pascal/typescript-config/react-library.json",
3
3
  "compilerOptions": {
4
4
  "rootDir": "src",
5
- "noEmit": true
5
+ "noEmit": true,
6
+ "types": ["bun"]
6
7
  },
7
8
  "include": ["src"],
8
9
  "exclude": ["node_modules"]
@@ -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&apos;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
- }