@pascal-app/editor 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/package.json +62 -0
  2. package/src/components/editor/custom-camera-controls.tsx +387 -0
  3. package/src/components/editor/editor-layout-v2.tsx +220 -0
  4. package/src/components/editor/export-manager.tsx +78 -0
  5. package/src/components/editor/first-person-controls.tsx +249 -0
  6. package/src/components/editor/floating-action-menu.tsx +231 -0
  7. package/src/components/editor/floorplan-panel.tsx +9609 -0
  8. package/src/components/editor/grid.tsx +161 -0
  9. package/src/components/editor/index.tsx +928 -0
  10. package/src/components/editor/node-action-menu.tsx +66 -0
  11. package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
  12. package/src/components/editor/selection-manager.tsx +897 -0
  13. package/src/components/editor/site-edge-labels.tsx +90 -0
  14. package/src/components/editor/thumbnail-generator.tsx +166 -0
  15. package/src/components/editor/wall-measurement-label.tsx +258 -0
  16. package/src/components/feedback-dialog.tsx +265 -0
  17. package/src/components/pascal-radio.tsx +280 -0
  18. package/src/components/preview-button.tsx +16 -0
  19. package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
  20. package/src/components/systems/roof/roof-edit-system.tsx +69 -0
  21. package/src/components/systems/stair/stair-edit-system.tsx +69 -0
  22. package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
  23. package/src/components/systems/zone/zone-system.tsx +87 -0
  24. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
  25. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
  26. package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
  27. package/src/components/tools/door/door-math.ts +110 -0
  28. package/src/components/tools/door/door-tool.tsx +293 -0
  29. package/src/components/tools/door/move-door-tool.tsx +373 -0
  30. package/src/components/tools/item/item-tool.tsx +26 -0
  31. package/src/components/tools/item/move-tool.tsx +90 -0
  32. package/src/components/tools/item/placement-math.ts +85 -0
  33. package/src/components/tools/item/placement-strategies.ts +556 -0
  34. package/src/components/tools/item/placement-types.ts +117 -0
  35. package/src/components/tools/item/use-draft-node.ts +227 -0
  36. package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
  37. package/src/components/tools/roof/move-roof-tool.tsx +288 -0
  38. package/src/components/tools/roof/roof-tool.tsx +318 -0
  39. package/src/components/tools/select/box-select-tool.tsx +626 -0
  40. package/src/components/tools/shared/cursor-sphere.tsx +119 -0
  41. package/src/components/tools/shared/polygon-editor.tsx +361 -0
  42. package/src/components/tools/site/site-boundary-editor.tsx +42 -0
  43. package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
  44. package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
  45. package/src/components/tools/slab/slab-tool.tsx +322 -0
  46. package/src/components/tools/stair/stair-defaults.ts +7 -0
  47. package/src/components/tools/stair/stair-tool.tsx +194 -0
  48. package/src/components/tools/tool-manager.tsx +120 -0
  49. package/src/components/tools/wall/wall-drafting.ts +140 -0
  50. package/src/components/tools/wall/wall-tool.tsx +210 -0
  51. package/src/components/tools/window/move-window-tool.tsx +410 -0
  52. package/src/components/tools/window/window-math.ts +117 -0
  53. package/src/components/tools/window/window-tool.tsx +303 -0
  54. package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
  55. package/src/components/tools/zone/zone-tool.tsx +364 -0
  56. package/src/components/ui/action-menu/action-button.tsx +59 -0
  57. package/src/components/ui/action-menu/camera-actions.tsx +74 -0
  58. package/src/components/ui/action-menu/control-modes.tsx +240 -0
  59. package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
  60. package/src/components/ui/action-menu/index.tsx +152 -0
  61. package/src/components/ui/action-menu/structure-tools.tsx +100 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +397 -0
  63. package/src/components/ui/command-palette/editor-commands.tsx +396 -0
  64. package/src/components/ui/command-palette/index.tsx +730 -0
  65. package/src/components/ui/controls/action-button.tsx +33 -0
  66. package/src/components/ui/controls/material-picker.tsx +194 -0
  67. package/src/components/ui/controls/metric-control.tsx +262 -0
  68. package/src/components/ui/controls/panel-section.tsx +65 -0
  69. package/src/components/ui/controls/segmented-control.tsx +45 -0
  70. package/src/components/ui/controls/slider-control.tsx +245 -0
  71. package/src/components/ui/controls/toggle-control.tsx +38 -0
  72. package/src/components/ui/floating-level-selector.tsx +355 -0
  73. package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
  74. package/src/components/ui/helpers/helper-manager.tsx +33 -0
  75. package/src/components/ui/helpers/item-helper.tsx +40 -0
  76. package/src/components/ui/helpers/roof-helper.tsx +16 -0
  77. package/src/components/ui/helpers/slab-helper.tsx +20 -0
  78. package/src/components/ui/helpers/wall-helper.tsx +20 -0
  79. package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
  80. package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
  81. package/src/components/ui/panels/ceiling-panel.tsx +230 -0
  82. package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
  83. package/src/components/ui/panels/door-panel.tsx +600 -0
  84. package/src/components/ui/panels/item-panel.tsx +306 -0
  85. package/src/components/ui/panels/panel-manager.tsx +59 -0
  86. package/src/components/ui/panels/panel-wrapper.tsx +80 -0
  87. package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
  88. package/src/components/ui/panels/reference-panel.tsx +177 -0
  89. package/src/components/ui/panels/roof-panel.tsx +262 -0
  90. package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
  91. package/src/components/ui/panels/slab-panel.tsx +228 -0
  92. package/src/components/ui/panels/stair-panel.tsx +304 -0
  93. package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
  94. package/src/components/ui/panels/wall-panel.tsx +123 -0
  95. package/src/components/ui/panels/window-panel.tsx +441 -0
  96. package/src/components/ui/primitives/button.tsx +69 -0
  97. package/src/components/ui/primitives/card.tsx +75 -0
  98. package/src/components/ui/primitives/color-dot.tsx +61 -0
  99. package/src/components/ui/primitives/context-menu.tsx +227 -0
  100. package/src/components/ui/primitives/dialog.tsx +129 -0
  101. package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
  102. package/src/components/ui/primitives/error-boundary.tsx +52 -0
  103. package/src/components/ui/primitives/input.tsx +21 -0
  104. package/src/components/ui/primitives/number-input.tsx +187 -0
  105. package/src/components/ui/primitives/opacity-control.tsx +79 -0
  106. package/src/components/ui/primitives/popover.tsx +42 -0
  107. package/src/components/ui/primitives/separator.tsx +28 -0
  108. package/src/components/ui/primitives/sheet.tsx +130 -0
  109. package/src/components/ui/primitives/shortcut-token.tsx +64 -0
  110. package/src/components/ui/primitives/sidebar.tsx +855 -0
  111. package/src/components/ui/primitives/skeleton.tsx +13 -0
  112. package/src/components/ui/primitives/slider.tsx +58 -0
  113. package/src/components/ui/primitives/switch.tsx +29 -0
  114. package/src/components/ui/primitives/tooltip.tsx +57 -0
  115. package/src/components/ui/scene-loader.tsx +40 -0
  116. package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
  117. package/src/components/ui/sidebar/icon-rail.tsx +147 -0
  118. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
  119. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
  120. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
  121. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
  122. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
  123. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
  125. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
  126. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
  127. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
  128. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
  130. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
  131. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
  134. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
  135. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
  136. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
  137. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
  138. package/src/components/ui/sidebar/tab-bar.tsx +39 -0
  139. package/src/components/ui/slider-demo.tsx +36 -0
  140. package/src/components/ui/slider.tsx +81 -0
  141. package/src/components/ui/viewer-toolbar.tsx +342 -0
  142. package/src/components/viewer-overlay.tsx +499 -0
  143. package/src/components/viewer-zone-system.tsx +48 -0
  144. package/src/contexts/presets-context.tsx +121 -0
  145. package/src/hooks/use-auto-save.ts +194 -0
  146. package/src/hooks/use-contextual-tools.ts +52 -0
  147. package/src/hooks/use-grid-events.ts +106 -0
  148. package/src/hooks/use-keyboard.ts +214 -0
  149. package/src/hooks/use-mobile.ts +19 -0
  150. package/src/hooks/use-reduced-motion.ts +20 -0
  151. package/src/index.tsx +33 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/level-selection.ts +31 -0
  154. package/src/lib/scene.ts +394 -0
  155. package/src/lib/sfx/index.ts +2 -0
  156. package/src/lib/sfx-bus.ts +49 -0
  157. package/src/lib/sfx-player.ts +60 -0
  158. package/src/lib/utils.ts +43 -0
  159. package/src/store/use-audio.tsx +45 -0
  160. package/src/store/use-command-registry.ts +36 -0
  161. package/src/store/use-editor.tsx +522 -0
  162. package/src/store/use-palette-view-registry.ts +45 -0
  163. package/src/store/use-upload.ts +90 -0
  164. package/src/three-types.ts +3 -0
  165. package/tsconfig.json +9 -0
@@ -0,0 +1,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,3 @@
1
+ /** Three.js layer used for editor-only objects (helpers, grid, polygon editors).
2
+ * The thumbnail camera renders only layer 0, so these are excluded from thumbnails. */
3
+ export const EDITOR_LAYER = 1
@@ -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
+ }