@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,245 @@
1
+ 'use client'
2
+
3
+ import { useScene } from '@pascal-app/core'
4
+ import { useCallback, useEffect, useRef, useState } from 'react'
5
+ import { cn } from '../../../lib/utils'
6
+
7
+ interface SliderControlProps {
8
+ label: React.ReactNode
9
+ value: number
10
+ onChange: (value: number) => void
11
+ min?: number
12
+ max?: number
13
+ precision?: number
14
+ step?: number
15
+ className?: string
16
+ unit?: string
17
+ }
18
+
19
+ function stepPrecision(s: number): number {
20
+ if (s <= 0) return 0
21
+ return Math.max(0, Math.ceil(-Math.log10(s)))
22
+ }
23
+
24
+ export function SliderControl({
25
+ label,
26
+ value,
27
+ onChange,
28
+ min = Number.NEGATIVE_INFINITY,
29
+ max = Number.POSITIVE_INFINITY,
30
+ precision = 0,
31
+ step = 1,
32
+ className,
33
+ unit = '',
34
+ }: SliderControlProps) {
35
+ const [isEditing, setIsEditing] = useState(false)
36
+ const [isDragging, setIsDragging] = useState(false)
37
+ const [isHovered, setIsHovered] = useState(false)
38
+ const [inputValue, setInputValue] = useState(value.toFixed(precision))
39
+
40
+ const dragRef = useRef<{ startX: number; startValue: number } | null>(null)
41
+ const labelRef = useRef<HTMLDivElement>(null)
42
+ const valueRef = useRef(value)
43
+ valueRef.current = value
44
+
45
+ const clamp = useCallback((val: number) => Math.min(Math.max(val, min), max), [min, max])
46
+
47
+ useEffect(() => {
48
+ if (!isEditing) {
49
+ setInputValue(value.toFixed(precision))
50
+ }
51
+ }, [value, precision, isEditing])
52
+
53
+ // Wheel support on the label
54
+ useEffect(() => {
55
+ const el = labelRef.current
56
+ if (!el) return
57
+ const handleWheel = (e: WheelEvent) => {
58
+ if (isEditing) return
59
+ e.preventDefault()
60
+ const direction = e.deltaY < 0 ? 1 : -1
61
+ let s = step
62
+ if (e.shiftKey) s = step * 10
63
+ else if (e.altKey) s = step * 0.1
64
+ const newValue = clamp(valueRef.current + direction * s)
65
+ const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
66
+ if (final !== valueRef.current) onChange(final)
67
+ }
68
+ el.addEventListener('wheel', handleWheel, { passive: false })
69
+ return () => el.removeEventListener('wheel', handleWheel)
70
+ }, [isEditing, step, clamp, onChange, precision])
71
+
72
+ // Arrow key support while hovered
73
+ useEffect(() => {
74
+ if (!isHovered || isEditing) return
75
+ const handleKeyDown = (e: KeyboardEvent) => {
76
+ let direction = 0
77
+ if (e.key === 'ArrowUp' || e.key === 'ArrowRight') direction = 1
78
+ else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1
79
+ if (direction !== 0) {
80
+ e.preventDefault()
81
+ let s = step
82
+ if (e.shiftKey) s = step * 10
83
+ else if (e.metaKey || e.ctrlKey) s = step * 0.1
84
+ const newValue = clamp(valueRef.current + direction * s)
85
+ const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
86
+ if (final !== valueRef.current) onChange(final)
87
+ }
88
+ }
89
+ window.addEventListener('keydown', handleKeyDown)
90
+ return () => window.removeEventListener('keydown', handleKeyDown)
91
+ }, [isHovered, isEditing, step, clamp, onChange, precision])
92
+
93
+ const handleLabelPointerDown = useCallback(
94
+ (e: React.PointerEvent<HTMLDivElement>) => {
95
+ if (isEditing) return
96
+ e.preventDefault()
97
+ e.currentTarget.setPointerCapture(e.pointerId)
98
+ dragRef.current = { startX: e.clientX, startValue: valueRef.current }
99
+ setIsDragging(true)
100
+ useScene.temporal.getState().pause()
101
+ },
102
+ [isEditing],
103
+ )
104
+
105
+ const handleLabelPointerMove = useCallback(
106
+ (e: React.PointerEvent<HTMLDivElement>) => {
107
+ if (!dragRef.current) return
108
+ const { startX, startValue } = dragRef.current
109
+ const dx = e.clientX - startX
110
+ let s = step
111
+ if (e.shiftKey) s = step * 10
112
+ else if (e.metaKey || e.ctrlKey) s = step * 0.1
113
+ // 4 px per step at default sensitivity
114
+ const newValue = clamp(
115
+ Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
116
+ )
117
+ onChange(newValue)
118
+ },
119
+ [step, precision, clamp, onChange],
120
+ )
121
+
122
+ const handleLabelPointerUp = useCallback(
123
+ (e: React.PointerEvent<HTMLDivElement>) => {
124
+ if (!dragRef.current) return
125
+ const { startValue } = dragRef.current
126
+ const finalVal = valueRef.current
127
+ dragRef.current = null
128
+ setIsDragging(false)
129
+ e.currentTarget.releasePointerCapture(e.pointerId)
130
+
131
+ if (startValue !== finalVal) {
132
+ onChange(startValue)
133
+ useScene.temporal.getState().resume()
134
+ onChange(finalVal)
135
+ } else {
136
+ useScene.temporal.getState().resume()
137
+ }
138
+ },
139
+ [onChange],
140
+ )
141
+
142
+ const handleValueClick = useCallback(() => {
143
+ setIsEditing(true)
144
+ setInputValue(value.toFixed(precision))
145
+ }, [value, precision])
146
+
147
+ const submitValue = useCallback(() => {
148
+ const numValue = Number.parseFloat(inputValue)
149
+ if (Number.isNaN(numValue)) {
150
+ setInputValue(value.toFixed(precision))
151
+ } else {
152
+ onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
153
+ }
154
+ setIsEditing(false)
155
+ }, [inputValue, onChange, clamp, precision, value])
156
+
157
+ const handleInputKeyDown = useCallback(
158
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
159
+ if (e.key === 'Enter') {
160
+ submitValue()
161
+ } else if (e.key === 'Escape') {
162
+ setInputValue(value.toFixed(precision))
163
+ setIsEditing(false)
164
+ } else if (e.key === 'ArrowUp') {
165
+ e.preventDefault()
166
+ const newV = clamp(value + step)
167
+ onChange(newV)
168
+ setInputValue(newV.toFixed(precision))
169
+ } else if (e.key === 'ArrowDown') {
170
+ e.preventDefault()
171
+ const newV = clamp(value - step)
172
+ onChange(newV)
173
+ setInputValue(newV.toFixed(precision))
174
+ }
175
+ },
176
+ [submitValue, value, precision, step, clamp, onChange],
177
+ )
178
+
179
+ return (
180
+ <div
181
+ className={cn(
182
+ 'group flex h-7 w-full select-none items-center rounded-lg px-2 transition-colors',
183
+ isDragging ? 'bg-white/5' : 'hover:bg-white/5',
184
+ className,
185
+ )}
186
+ onMouseEnter={() => setIsHovered(true)}
187
+ onMouseLeave={() => setIsHovered(false)}
188
+ >
189
+ {/* Label — drag handle */}
190
+ <div
191
+ className={cn(
192
+ 'flex shrink-0 cursor-ew-resize items-center gap-1.5 text-xs transition-colors',
193
+ isDragging ? 'text-foreground' : 'text-muted-foreground hover:text-foreground/80',
194
+ )}
195
+ onPointerDown={handleLabelPointerDown}
196
+ onPointerMove={handleLabelPointerMove}
197
+ onPointerUp={handleLabelPointerUp}
198
+ ref={labelRef}
199
+ >
200
+ {/* Grip dots — 2×3 grid */}
201
+ <div
202
+ className={cn(
203
+ 'grid grid-cols-2 gap-[2.5px] transition-opacity',
204
+ isDragging ? 'opacity-70' : 'opacity-25 group-hover:opacity-50',
205
+ )}
206
+ >
207
+ {[...Array(6)].map((_, i) => (
208
+ <div className="h-[2px] w-[2px] rounded-full bg-current" key={i} />
209
+ ))}
210
+ </div>
211
+ <span className="font-medium">{label}</span>
212
+ </div>
213
+
214
+ <div className="flex-1" />
215
+
216
+ {/* Value — click to edit */}
217
+ <div className="flex items-center text-xs">
218
+ {isEditing ? (
219
+ <>
220
+ <input
221
+ autoFocus
222
+ className="w-14 bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
223
+ onBlur={submitValue}
224
+ onChange={(e) => setInputValue(e.target.value)}
225
+ onKeyDown={handleInputKeyDown}
226
+ type="text"
227
+ value={inputValue}
228
+ />
229
+ {unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
230
+ </>
231
+ ) : (
232
+ <div
233
+ className="flex cursor-text items-center text-foreground/60 transition-colors hover:text-foreground"
234
+ onClick={handleValueClick}
235
+ >
236
+ <span className="font-mono tabular-nums tracking-tight" suppressHydrationWarning>
237
+ {Number(value.toFixed(precision)).toFixed(precision)}
238
+ </span>
239
+ {unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
240
+ </div>
241
+ )}
242
+ </div>
243
+ </div>
244
+ )
245
+ }
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import { Check } from 'lucide-react'
4
+ import { cn } from '../../../lib/utils'
5
+
6
+ interface ToggleControlProps {
7
+ label: string
8
+ checked: boolean
9
+ onChange: (checked: boolean) => void
10
+ className?: string
11
+ }
12
+
13
+ export function ToggleControl({ label, checked, onChange, className }: ToggleControlProps) {
14
+ return (
15
+ <div
16
+ className={cn(
17
+ 'group flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm transition-colors hover:bg-[#3e3e3e]',
18
+ className,
19
+ )}
20
+ onClick={() => onChange(!checked)}
21
+ >
22
+ <div className="select-none text-muted-foreground transition-colors group-hover:text-foreground">
23
+ {label}
24
+ </div>
25
+
26
+ <div
27
+ className={cn(
28
+ 'flex h-5 w-5 items-center justify-center rounded-[4px] border transition-all duration-200',
29
+ checked
30
+ ? 'border-primary bg-primary text-primary-foreground'
31
+ : 'border-border bg-black/20 text-transparent group-hover:border-muted-foreground',
32
+ )}
33
+ >
34
+ <Check className="h-3.5 w-3.5" strokeWidth={3} />
35
+ </div>
36
+ </div>
37
+ )
38
+ }
@@ -0,0 +1,355 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type BuildingNode,
7
+ LevelNode,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { MoreVertical, Plus, Trash2 } from 'lucide-react'
12
+ import { useCallback, useEffect, useRef, useState } from 'react'
13
+ import { useShallow } from 'zustand/react/shallow'
14
+ import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
15
+ import { cn } from '../../lib/utils'
16
+ import {
17
+ Dialog,
18
+ DialogContent,
19
+ DialogDescription,
20
+ DialogFooter,
21
+ DialogHeader,
22
+ DialogTitle,
23
+ } from './primitives/dialog'
24
+ import { Popover, PopoverContent, PopoverTrigger } from './primitives/popover'
25
+
26
+ function getLevelDisplayLabel(level: LevelNode) {
27
+ return level.name || `Level ${level.level}`
28
+ }
29
+
30
+ // ── Inline rename input for a level row ─────────────────────────────────────
31
+
32
+ function LevelInlineRename({
33
+ level,
34
+ isEditing,
35
+ onStopEditing,
36
+ }: {
37
+ level: LevelNode
38
+ isEditing: boolean
39
+ onStopEditing: () => void
40
+ }) {
41
+ const updateNode = useScene((s) => s.updateNode)
42
+ const defaultName = `Level ${level.level}`
43
+ const [value, setValue] = useState(level.name || '')
44
+ const inputRef = useRef<HTMLInputElement>(null)
45
+
46
+ useEffect(() => {
47
+ if (isEditing) {
48
+ setValue(level.name || '')
49
+ setTimeout(() => {
50
+ inputRef.current?.focus()
51
+ inputRef.current?.select()
52
+ }, 0)
53
+ }
54
+ }, [isEditing, level.name])
55
+
56
+ const handleSave = useCallback(() => {
57
+ const trimmed = value.trim()
58
+ if (trimmed !== level.name) {
59
+ updateNode(level.id, { name: trimmed || undefined })
60
+ }
61
+ onStopEditing()
62
+ }, [value, level.id, level.name, updateNode, onStopEditing])
63
+
64
+ if (!isEditing) return null
65
+
66
+ return (
67
+ <input
68
+ className="m-0 h-full w-full min-w-0 rounded-lg bg-transparent px-2.5 py-1.5 font-medium text-foreground text-xs outline-none ring-1 ring-primary/50"
69
+ onBlur={handleSave}
70
+ onChange={(e) => setValue(e.target.value)}
71
+ onClick={(e) => e.stopPropagation()}
72
+ onKeyDown={(e) => {
73
+ if (e.key === 'Enter') {
74
+ e.preventDefault()
75
+ handleSave()
76
+ } else if (e.key === 'Escape') {
77
+ e.preventDefault()
78
+ onStopEditing()
79
+ }
80
+ }}
81
+ placeholder={defaultName}
82
+ ref={inputRef}
83
+ type="text"
84
+ value={value}
85
+ />
86
+ )
87
+ }
88
+
89
+ // ── Level row with three-dot menu ───────────────────────────────────────────
90
+
91
+ function LevelRow({
92
+ level,
93
+ isSelected,
94
+ onSelect,
95
+ onRequestDelete,
96
+ }: {
97
+ level: LevelNode
98
+ isSelected: boolean
99
+ onSelect: () => void
100
+ onRequestDelete: () => void
101
+ }) {
102
+ const [isEditing, setIsEditing] = useState(false)
103
+
104
+ return (
105
+ <div className="group/level">
106
+ {isEditing ? (
107
+ <LevelInlineRename
108
+ isEditing={isEditing}
109
+ level={level}
110
+ onStopEditing={() => setIsEditing(false)}
111
+ />
112
+ ) : (
113
+ <div
114
+ className={cn(
115
+ 'flex items-center rounded-lg transition-colors',
116
+ isSelected
117
+ ? 'bg-white/10 text-foreground'
118
+ : 'text-muted-foreground/70 hover:bg-white/5 hover:text-muted-foreground',
119
+ )}
120
+ >
121
+ <button
122
+ className="flex min-w-0 flex-1 items-center justify-start px-2.5 py-1.5 font-medium text-xs"
123
+ onClick={onSelect}
124
+ onDoubleClick={(e) => {
125
+ e.stopPropagation()
126
+ setIsEditing(true)
127
+ }}
128
+ title={getLevelDisplayLabel(level)}
129
+ type="button"
130
+ >
131
+ <span className="truncate">{getLevelDisplayLabel(level)}</span>
132
+ </button>
133
+
134
+ {/* Vertical three-dot menu — inside the pill */}
135
+ <Popover>
136
+ <PopoverTrigger asChild>
137
+ <button
138
+ className="flex h-5 w-4 shrink-0 items-center justify-center text-muted-foreground/40 opacity-0 transition-all hover:text-foreground group-hover/level:opacity-100"
139
+ onClick={(e) => e.stopPropagation()}
140
+ type="button"
141
+ >
142
+ <MoreVertical className="h-3 w-3" />
143
+ </button>
144
+ </PopoverTrigger>
145
+ <PopoverContent align="start" className="w-36 p-1" side="right" sideOffset={8}>
146
+ <button
147
+ className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-red-400"
148
+ onClick={(e) => {
149
+ e.stopPropagation()
150
+ onRequestDelete()
151
+ }}
152
+ type="button"
153
+ >
154
+ <Trash2 className="h-3 w-3" />
155
+ Delete level
156
+ </button>
157
+ </PopoverContent>
158
+ </Popover>
159
+ </div>
160
+ )}
161
+ </div>
162
+ )
163
+ }
164
+
165
+ // ── Main component ──────────────────────────────────────────────────────────
166
+
167
+ export function FloatingLevelSelector() {
168
+ const selectedBuildingId = useViewer((s) => s.selection.buildingId)
169
+ const levelId = useViewer((s) => s.selection.levelId)
170
+ const setSelection = useViewer((s) => s.setSelection)
171
+ const createNode = useScene((s) => s.createNode)
172
+ const updateNodes = useScene((s) => s.updateNodes)
173
+
174
+ const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
175
+
176
+ const resolvedBuildingId = useScene((state) => {
177
+ if (selectedBuildingId) return selectedBuildingId
178
+ const first = Object.values(state.nodes).find((n) => n?.type === 'building') as
179
+ | BuildingNode
180
+ | undefined
181
+ return first?.id ?? null
182
+ })
183
+
184
+ const levels = useScene(
185
+ useShallow((state) => {
186
+ if (!resolvedBuildingId) return [] as LevelNode[]
187
+ const building = state.nodes[resolvedBuildingId]
188
+ if (!building || building.type !== 'building') return [] as LevelNode[]
189
+ return (building as BuildingNode).children
190
+ .map((id) => state.nodes[id])
191
+ .filter((node): node is LevelNode => node?.type === 'level')
192
+ .sort((a, b) => a.level - b.level)
193
+ }),
194
+ )
195
+
196
+ const handleAddAbove = useCallback(() => {
197
+ if (!resolvedBuildingId) return
198
+ const maxLevel = levels.length > 0 ? Math.max(...levels.map((l) => l.level)) : -1
199
+ const newLevel = LevelNode.parse({
200
+ level: maxLevel + 1,
201
+ children: [],
202
+ parentId: resolvedBuildingId,
203
+ })
204
+ createNode(newLevel, resolvedBuildingId)
205
+ setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id })
206
+ }, [resolvedBuildingId, levels, createNode, setSelection])
207
+
208
+ const handleAddBelow = useCallback(() => {
209
+ if (!resolvedBuildingId) return
210
+ const minLevel = levels.length > 0 ? Math.min(...levels.map((l) => l.level)) : 1
211
+ const newLevel = LevelNode.parse({
212
+ level: minLevel - 1,
213
+ children: [],
214
+ parentId: resolvedBuildingId,
215
+ })
216
+ createNode(newLevel, resolvedBuildingId)
217
+ setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id })
218
+ }, [resolvedBuildingId, levels, createNode, setSelection])
219
+
220
+ const handleInsertBetween = useCallback(
221
+ (lowerIndex: number) => {
222
+ if (!resolvedBuildingId) return
223
+ const lower = levels[lowerIndex]
224
+ if (!lower) return
225
+
226
+ const newLevelNumber = lower.level + 1
227
+ const toShift = levels.filter((l) => l.level >= newLevelNumber)
228
+ if (toShift.length > 0) {
229
+ updateNodes(
230
+ toShift.map((l) => ({
231
+ id: l.id as AnyNodeId,
232
+ data: { level: l.level + 1 } as Partial<AnyNode>,
233
+ })),
234
+ )
235
+ }
236
+
237
+ const newLevel = LevelNode.parse({
238
+ level: newLevelNumber,
239
+ children: [],
240
+ parentId: resolvedBuildingId,
241
+ })
242
+ createNode(newLevel, resolvedBuildingId)
243
+ setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id })
244
+ },
245
+ [resolvedBuildingId, levels, createNode, updateNodes, setSelection],
246
+ )
247
+
248
+ const handleConfirmDelete = useCallback(() => {
249
+ if (!deletingLevel) return
250
+ deleteLevelWithFallbackSelection(deletingLevel.id)
251
+ setDeletingLevel(null)
252
+ }, [deletingLevel])
253
+
254
+ if (levels.length === 0) return null
255
+
256
+ const reversedLevels = [...levels].reverse()
257
+
258
+ const addButtonClass =
259
+ 'absolute left-1/2 z-10 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-full border border-border/80 bg-neutral-800 text-muted-foreground/60 shadow-md transition-colors hover:bg-neutral-700 hover:text-foreground'
260
+
261
+ return (
262
+ <>
263
+ <div className="pointer-events-auto absolute top-14 left-3 z-20">
264
+ <div className="relative">
265
+ {/* Floating + at top edge */}
266
+ <button
267
+ className={cn(addButtonClass, 'top-0 -translate-y-1/2')}
268
+ onClick={handleAddAbove}
269
+ title="Add level above"
270
+ type="button"
271
+ >
272
+ <Plus className="h-2.5 w-2.5" />
273
+ </button>
274
+
275
+ {/* Floating + at bottom edge */}
276
+ <button
277
+ className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
278
+ onClick={handleAddBelow}
279
+ title="Add level below"
280
+ type="button"
281
+ >
282
+ <Plus className="h-2.5 w-2.5" />
283
+ </button>
284
+
285
+ {/* Level list */}
286
+ <div className="flex flex-col gap-0.5 rounded-xl border border-border bg-background/90 p-1 shadow-2xl backdrop-blur-md">
287
+ {reversedLevels.map((level, i) => {
288
+ const isSelected = level.id === levelId
289
+ const sortedIndex = levels.indexOf(level)
290
+ const showGapBelow = i < reversedLevels.length - 1
291
+
292
+ return (
293
+ <div className="relative" key={level.id}>
294
+ <LevelRow
295
+ isSelected={isSelected}
296
+ level={level}
297
+ onRequestDelete={() => setDeletingLevel(level)}
298
+ onSelect={() =>
299
+ setSelection(
300
+ resolvedBuildingId
301
+ ? { buildingId: resolvedBuildingId, levelId: level.id }
302
+ : { levelId: level.id },
303
+ )
304
+ }
305
+ />
306
+
307
+ {showGapBelow && (
308
+ <button
309
+ className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
310
+ onClick={() => handleInsertBetween(sortedIndex - 1)}
311
+ title="Insert level here"
312
+ type="button"
313
+ >
314
+ <Plus className="h-2.5 w-2.5" />
315
+ </button>
316
+ )}
317
+ </div>
318
+ )
319
+ })}
320
+ </div>
321
+ </div>
322
+ </div>
323
+
324
+ {/* Delete confirmation dialog */}
325
+ <Dialog onOpenChange={(open) => !open && setDeletingLevel(null)} open={!!deletingLevel}>
326
+ <DialogContent showCloseButton={false}>
327
+ <DialogHeader>
328
+ <DialogTitle>Delete level</DialogTitle>
329
+ <DialogDescription>
330
+ Are you sure you want to delete{' '}
331
+ <strong>{deletingLevel ? getLevelDisplayLabel(deletingLevel) : ''}</strong>? All
332
+ walls, floors, and objects on this level will be permanently removed.
333
+ </DialogDescription>
334
+ </DialogHeader>
335
+ <DialogFooter>
336
+ <button
337
+ className="rounded-full border border-border px-4 py-2 text-sm transition-colors hover:bg-accent"
338
+ onClick={() => setDeletingLevel(null)}
339
+ type="button"
340
+ >
341
+ Cancel
342
+ </button>
343
+ <button
344
+ className="rounded-full bg-red-600 px-4 py-2 text-sm text-white transition-colors hover:bg-red-700"
345
+ onClick={handleConfirmDelete}
346
+ type="button"
347
+ >
348
+ Delete
349
+ </button>
350
+ </DialogFooter>
351
+ </DialogContent>
352
+ </Dialog>
353
+ </>
354
+ )
355
+ }
@@ -0,0 +1,20 @@
1
+ import { ShortcutToken } from '../primitives/shortcut-token'
2
+
3
+ export function CeilingHelper() {
4
+ return (
5
+ <div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
6
+ <div className="flex items-center gap-2 text-sm">
7
+ <ShortcutToken value="Left click" />
8
+ <span className="text-muted-foreground">Add point</span>
9
+ </div>
10
+ <div className="flex items-center gap-2 text-sm">
11
+ <ShortcutToken value="Shift" />
12
+ <span className="text-muted-foreground">Allow non-45° angles</span>
13
+ </div>
14
+ <div className="flex items-center gap-2 text-sm">
15
+ <ShortcutToken value="Esc" />
16
+ <span className="text-muted-foreground">Cancel</span>
17
+ </div>
18
+ </div>
19
+ )
20
+ }
@@ -0,0 +1,33 @@
1
+ 'use client'
2
+
3
+ import useEditor from '../../../store/use-editor'
4
+ import { CeilingHelper } from './ceiling-helper'
5
+ import { ItemHelper } from './item-helper'
6
+ import { RoofHelper } from './roof-helper'
7
+ import { SlabHelper } from './slab-helper'
8
+ import { WallHelper } from './wall-helper'
9
+
10
+ export function HelperManager() {
11
+ const tool = useEditor((s) => s.tool)
12
+ const movingNode = useEditor((state) => state.movingNode)
13
+
14
+ if (movingNode) {
15
+ return <ItemHelper showEsc />
16
+ }
17
+
18
+ // Show appropriate helper based on current tool
19
+ switch (tool) {
20
+ case 'wall':
21
+ return <WallHelper />
22
+ case 'item':
23
+ return <ItemHelper />
24
+ case 'slab':
25
+ return <SlabHelper />
26
+ case 'ceiling':
27
+ return <CeilingHelper />
28
+ case 'roof':
29
+ return <RoofHelper />
30
+ default:
31
+ return null
32
+ }
33
+ }