@pascal-app/editor 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,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
|
+
}
|