@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,33 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../../../lib/utils'
4
+
5
+ interface ActionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
6
+ icon?: React.ReactNode
7
+ label: string
8
+ }
9
+
10
+ export function ActionButton({ icon, label, className, ...props }: ActionButtonProps) {
11
+ return (
12
+ <button
13
+ {...props}
14
+ className={cn(
15
+ 'flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 font-medium text-foreground text-xs transition-colors hover:bg-[#3e3e3e] active:bg-[#3e3e3e]',
16
+ className,
17
+ )}
18
+ >
19
+ {icon}
20
+ <span>{label}</span>
21
+ </button>
22
+ )
23
+ }
24
+
25
+ export function ActionGroup({
26
+ children,
27
+ className,
28
+ }: {
29
+ children: React.ReactNode
30
+ className?: string
31
+ }) {
32
+ return <div className={cn('flex gap-1.5', className)}>{children}</div>
33
+ }
@@ -0,0 +1,194 @@
1
+ 'use client'
2
+
3
+ import { DEFAULT_MATERIALS, type MaterialPreset, type MaterialSchema } from '@pascal-app/core'
4
+ import { useState } from 'react'
5
+
6
+ const PRESET_COLORS: Record<MaterialPreset, string> = {
7
+ white: '#ffffff',
8
+ brick: '#8b4513',
9
+ concrete: '#808080',
10
+ wood: '#deb887',
11
+ glass: '#87ceeb',
12
+ metal: '#c0c0c0',
13
+ plaster: '#f5f5dc',
14
+ tile: '#d3d3d3',
15
+ marble: '#fafafa',
16
+ custom: '#ffffff',
17
+ }
18
+
19
+ const PRESET_LABELS: Record<MaterialPreset, string> = {
20
+ white: 'White',
21
+ brick: 'Brick',
22
+ concrete: 'Concrete',
23
+ wood: 'Wood',
24
+ glass: 'Glass',
25
+ metal: 'Metal',
26
+ plaster: 'Plaster',
27
+ tile: 'Tile',
28
+ marble: 'Marble',
29
+ custom: 'Custom',
30
+ }
31
+
32
+ type MaterialPickerProps = {
33
+ value?: MaterialSchema
34
+ onChange: (material: MaterialSchema) => void
35
+ }
36
+
37
+ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
38
+ const [showCustom, setShowCustom] = useState<boolean>(
39
+ value?.preset === 'custom' || !!value?.properties,
40
+ )
41
+
42
+ const currentPreset = value?.preset || 'white'
43
+ const currentProps = value?.properties || DEFAULT_MATERIALS[currentPreset]
44
+
45
+ const handlePresetChange = (preset: MaterialPreset) => {
46
+ if (preset === 'custom') {
47
+ setShowCustom(true)
48
+ onChange({
49
+ preset: 'custom',
50
+ properties: {
51
+ color: value?.properties?.color || '#ffffff',
52
+ roughness: value?.properties?.roughness ?? 0.5,
53
+ metalness: value?.properties?.metalness ?? 0,
54
+ opacity: value?.properties?.opacity ?? 1,
55
+ transparent: value?.properties?.transparent ?? false,
56
+ side: value?.properties?.side ?? 'front',
57
+ },
58
+ })
59
+ } else {
60
+ setShowCustom(false)
61
+ onChange({ preset })
62
+ }
63
+ }
64
+
65
+ const handlePropertyChange = (
66
+ prop: keyof typeof currentProps,
67
+ val: (typeof currentProps)[keyof typeof currentProps],
68
+ ) => {
69
+ onChange({
70
+ preset: showCustom ? 'custom' : currentPreset,
71
+ properties: {
72
+ ...currentProps,
73
+ [prop]: val,
74
+ },
75
+ })
76
+ }
77
+
78
+ return (
79
+ <div className="space-y-3">
80
+ <div className="grid grid-cols-5 gap-1.5">
81
+ {(Object.keys(PRESET_COLORS) as MaterialPreset[]).map((preset) => (
82
+ <button
83
+ className={`h-8 w-8 rounded border-2 transition-all ${
84
+ currentPreset === preset
85
+ ? 'border-blue-500 ring-2 ring-blue-500/30'
86
+ : 'border-gray-300 hover:border-gray-400'
87
+ }`}
88
+ key={preset}
89
+ onClick={() => handlePresetChange(preset)}
90
+ style={{
91
+ backgroundColor: PRESET_COLORS[preset],
92
+ backgroundImage:
93
+ preset === 'glass'
94
+ ? 'linear-gradient(135deg, rgba(255,255,255,0.3) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.3) 75%, transparent 75%, transparent)'
95
+ : undefined,
96
+ backgroundSize: preset === 'glass' ? '8px 8px' : undefined,
97
+ }}
98
+ title={PRESET_LABELS[preset]}
99
+ type="button"
100
+ />
101
+ ))}
102
+ </div>
103
+
104
+ {showCustom && (
105
+ <div className="space-y-2 pt-2">
106
+ <div className="flex items-center gap-2">
107
+ <label className="w-16 text-gray-500 text-xs">Color</label>
108
+ <input
109
+ className="h-7 w-12 cursor-pointer rounded border border-gray-300"
110
+ onChange={(e) => handlePropertyChange('color', e.target.value)}
111
+ type="color"
112
+ value={currentProps.color}
113
+ />
114
+ <input
115
+ className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
116
+ onChange={(e) => handlePropertyChange('color', e.target.value)}
117
+ type="text"
118
+ value={currentProps.color}
119
+ />
120
+ </div>
121
+
122
+ <div className="flex items-center gap-2">
123
+ <label className="w-16 text-gray-500 text-xs">Roughness</label>
124
+ <input
125
+ className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
126
+ max={1}
127
+ min={0}
128
+ onChange={(e) => handlePropertyChange('roughness', Number.parseFloat(e.target.value))}
129
+ step={0.01}
130
+ type="range"
131
+ value={currentProps.roughness}
132
+ />
133
+ <span className="w-8 text-right text-gray-400 text-xs">
134
+ {currentProps.roughness.toFixed(2)}
135
+ </span>
136
+ </div>
137
+
138
+ <div className="flex items-center gap-2">
139
+ <label className="w-16 text-gray-500 text-xs">Metalness</label>
140
+ <input
141
+ className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
142
+ max={1}
143
+ min={0}
144
+ onChange={(e) => handlePropertyChange('metalness', Number.parseFloat(e.target.value))}
145
+ step={0.01}
146
+ type="range"
147
+ value={currentProps.metalness}
148
+ />
149
+ <span className="w-8 text-right text-gray-400 text-xs">
150
+ {currentProps.metalness.toFixed(2)}
151
+ </span>
152
+ </div>
153
+
154
+ <div className="flex items-center gap-2">
155
+ <label className="w-16 text-gray-500 text-xs">Opacity</label>
156
+ <input
157
+ className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
158
+ max={1}
159
+ min={0}
160
+ onChange={(e) => {
161
+ const opacity = Number.parseFloat(e.target.value)
162
+ handlePropertyChange('opacity', opacity)
163
+ if (opacity < 1 && !currentProps.transparent) {
164
+ handlePropertyChange('transparent', true)
165
+ }
166
+ }}
167
+ step={0.01}
168
+ type="range"
169
+ value={currentProps.opacity}
170
+ />
171
+ <span className="w-8 text-right text-gray-400 text-xs">
172
+ {currentProps.opacity.toFixed(2)}
173
+ </span>
174
+ </div>
175
+
176
+ <div className="flex items-center gap-2">
177
+ <label className="w-16 text-gray-500 text-xs">Side</label>
178
+ <select
179
+ className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
180
+ onChange={(e) =>
181
+ handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
182
+ }
183
+ value={currentProps.side}
184
+ >
185
+ <option value="front">Front</option>
186
+ <option value="back">Back</option>
187
+ <option value="double">Double</option>
188
+ </select>
189
+ </div>
190
+ </div>
191
+ )}
192
+ </div>
193
+ )
194
+ }
@@ -0,0 +1,262 @@
1
+ 'use client'
2
+
3
+ import { useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { useCallback, useEffect, useRef, useState } from 'react'
6
+ import { cn } from '../../../lib/utils'
7
+
8
+ interface MetricControlProps {
9
+ label: React.ReactNode
10
+ value: number
11
+ onChange: (value: number) => void
12
+ min?: number
13
+ max?: number
14
+ precision?: number
15
+ step?: number
16
+ className?: string
17
+ unit?: string
18
+ }
19
+
20
+ export function MetricControl({
21
+ label,
22
+ value,
23
+ onChange,
24
+ min = Number.NEGATIVE_INFINITY,
25
+ max = Number.POSITIVE_INFINITY,
26
+ precision = 2,
27
+ step = 1,
28
+ className,
29
+ unit = '',
30
+ }: MetricControlProps) {
31
+ const viewerUnit = useViewer((state) => state.unit)
32
+ const isImperial = viewerUnit === 'imperial' && unit === 'm'
33
+ const multiplier = isImperial ? 3.280_84 : 1
34
+ const displayUnit = isImperial ? 'ft' : unit
35
+
36
+ const displayValue = value * multiplier
37
+
38
+ const [isEditing, setIsEditing] = useState(false)
39
+ const [isDragging, setIsDragging] = useState(false)
40
+ const [isHovered, setIsHovered] = useState(false)
41
+ const [inputValue, setInputValue] = useState(displayValue.toFixed(precision))
42
+ const startXRef = useRef(0)
43
+ const startValueRef = useRef(0)
44
+ const containerRef = useRef<HTMLDivElement>(null)
45
+
46
+ const valueRef = useRef(value)
47
+ valueRef.current = value
48
+
49
+ const clamp = useCallback(
50
+ (val: number) => {
51
+ return Math.min(Math.max(val, min), max)
52
+ },
53
+ [min, max],
54
+ )
55
+
56
+ useEffect(() => {
57
+ if (!isEditing) {
58
+ setInputValue(displayValue.toFixed(precision))
59
+ }
60
+ }, [displayValue, precision, isEditing])
61
+
62
+ useEffect(() => {
63
+ const container = containerRef.current
64
+ if (!container) return
65
+
66
+ const handleWheel = (e: WheelEvent) => {
67
+ if (isEditing) return
68
+
69
+ e.preventDefault()
70
+
71
+ const direction = e.deltaY < 0 ? 1 : -1
72
+ let scrollStep = step / multiplier
73
+ if (e.shiftKey) scrollStep = (step * 10) / multiplier
74
+ else if (e.altKey) scrollStep = (step * 0.1) / multiplier
75
+
76
+ const newValue = clamp(valueRef.current + direction * scrollStep)
77
+ const finalValue = Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
78
+
79
+ if (Math.abs(finalValue - valueRef.current) > 1e-6) {
80
+ onChange(finalValue)
81
+ }
82
+ }
83
+
84
+ container.addEventListener('wheel', handleWheel, { passive: false })
85
+ return () => container.removeEventListener('wheel', handleWheel)
86
+ }, [isEditing, step, clamp, onChange, precision, multiplier])
87
+
88
+ useEffect(() => {
89
+ if (!isHovered || isEditing) return
90
+
91
+ const handleKeyDown = (e: KeyboardEvent) => {
92
+ let direction = 0
93
+ if (e.key === 'ArrowUp') direction = 1
94
+ else if (e.key === 'ArrowDown') direction = -1
95
+
96
+ if (direction !== 0) {
97
+ e.preventDefault()
98
+ let scrollStep = step / multiplier
99
+ if (e.shiftKey) scrollStep = (step * 10) / multiplier
100
+ else if (e.altKey) scrollStep = (step * 0.1) / multiplier
101
+
102
+ const newValue = clamp(valueRef.current + direction * scrollStep)
103
+ const finalValue =
104
+ Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
105
+
106
+ if (Math.abs(finalValue - valueRef.current) > 1e-6) {
107
+ onChange(finalValue)
108
+ }
109
+ }
110
+ }
111
+
112
+ window.addEventListener('keydown', handleKeyDown)
113
+ return () => window.removeEventListener('keydown', handleKeyDown)
114
+ }, [isHovered, isEditing, step, clamp, onChange, precision, multiplier])
115
+
116
+ const handlePointerDown = useCallback(
117
+ (e: React.PointerEvent) => {
118
+ if (isEditing) return
119
+ e.preventDefault()
120
+
121
+ setIsDragging(true)
122
+ startXRef.current = e.clientX
123
+ startValueRef.current = value
124
+ useScene.temporal.getState().pause()
125
+
126
+ let finalValue = value
127
+
128
+ const handlePointerMove = (moveEvent: PointerEvent) => {
129
+ const deltaX = moveEvent.clientX - startXRef.current
130
+
131
+ let dragStep = step / multiplier
132
+ if (moveEvent.shiftKey) dragStep = (step * 10) / multiplier
133
+ else if (moveEvent.altKey) dragStep = (step * 0.1) / multiplier
134
+
135
+ const deltaValue = deltaX * dragStep
136
+ const newValue = clamp(startValueRef.current + deltaValue)
137
+ const newFinalValue =
138
+ Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
139
+
140
+ if (Math.abs(newFinalValue - finalValue) > 1e-6) {
141
+ finalValue = newFinalValue
142
+ onChange(finalValue)
143
+ }
144
+ }
145
+
146
+ const handlePointerUp = () => {
147
+ setIsDragging(false)
148
+ document.removeEventListener('pointermove', handlePointerMove)
149
+ document.removeEventListener('pointerup', handlePointerUp)
150
+
151
+ if (Math.abs(finalValue - startValueRef.current) > 1e-6) {
152
+ onChange(startValueRef.current)
153
+ useScene.temporal.getState().resume()
154
+ onChange(finalValue)
155
+ } else {
156
+ useScene.temporal.getState().resume()
157
+ }
158
+ }
159
+
160
+ document.addEventListener('pointermove', handlePointerMove)
161
+ document.addEventListener('pointerup', handlePointerUp)
162
+ },
163
+ [isEditing, value, onChange, clamp, precision, step, multiplier],
164
+ )
165
+
166
+ const handleValueClick = useCallback(() => {
167
+ setIsEditing(true)
168
+ setInputValue((value * multiplier).toFixed(precision))
169
+ }, [value, multiplier, precision])
170
+
171
+ const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
172
+ setInputValue(e.target.value)
173
+ }, [])
174
+
175
+ const submitValue = useCallback(() => {
176
+ const numValue = Number.parseFloat(inputValue)
177
+ if (Number.isNaN(numValue)) {
178
+ setInputValue((value * multiplier).toFixed(precision))
179
+ } else {
180
+ onChange(clamp(numValue / multiplier))
181
+ }
182
+ setIsEditing(false)
183
+ }, [inputValue, onChange, clamp, multiplier, value, precision])
184
+
185
+ const handleInputBlur = useCallback(() => {
186
+ submitValue()
187
+ }, [submitValue])
188
+
189
+ const handleInputKeyDown = useCallback(
190
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
191
+ if (e.key === 'Enter') {
192
+ submitValue()
193
+ } else if (e.key === 'Escape') {
194
+ setInputValue((value * multiplier).toFixed(precision))
195
+ setIsEditing(false)
196
+ } else if (e.key === 'ArrowUp') {
197
+ e.preventDefault()
198
+ const newV = clamp(value + step / multiplier)
199
+ onChange(newV)
200
+ setInputValue((newV * multiplier).toFixed(precision))
201
+ } else if (e.key === 'ArrowDown') {
202
+ e.preventDefault()
203
+ const newV = clamp(value - step / multiplier)
204
+ onChange(newV)
205
+ setInputValue((newV * multiplier).toFixed(precision))
206
+ }
207
+ },
208
+ [submitValue, value, multiplier, precision, step, clamp, onChange],
209
+ )
210
+
211
+ return (
212
+ <div
213
+ className={cn(
214
+ 'group flex h-10 w-full items-center justify-between rounded-lg border border-border/50 px-3 text-sm transition-colors',
215
+ isDragging ? 'bg-[#3e3e3e]' : 'bg-[#2C2C2E] hover:bg-[#3e3e3e]',
216
+ className,
217
+ )}
218
+ onMouseEnter={() => setIsHovered(true)}
219
+ onMouseLeave={() => setIsHovered(false)}
220
+ ref={containerRef}
221
+ >
222
+ <div
223
+ className={cn(
224
+ 'select-none truncate text-muted-foreground transition-colors',
225
+ isDragging
226
+ ? 'cursor-ew-resize text-foreground'
227
+ : 'hover:cursor-ew-resize hover:text-foreground',
228
+ )}
229
+ onPointerDown={handlePointerDown}
230
+ >
231
+ {label}
232
+ </div>
233
+
234
+ <div className="flex shrink-0 justify-end">
235
+ {isEditing ? (
236
+ <div className="flex items-center">
237
+ <input
238
+ autoFocus
239
+ className="w-full bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
240
+ onBlur={handleInputBlur}
241
+ onChange={handleInputChange}
242
+ onKeyDown={handleInputKeyDown}
243
+ type="text"
244
+ value={inputValue}
245
+ />
246
+ {displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
247
+ </div>
248
+ ) : (
249
+ <div
250
+ className="flex w-full cursor-text items-center justify-end text-foreground transition-colors hover:text-primary"
251
+ onClick={handleValueClick}
252
+ >
253
+ <span className="font-mono tabular-nums tracking-tight">
254
+ {Number(displayValue.toFixed(precision)).toFixed(precision)}
255
+ </span>
256
+ {displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
257
+ </div>
258
+ )}
259
+ </div>
260
+ </div>
261
+ )
262
+ }
@@ -0,0 +1,65 @@
1
+ 'use client'
2
+
3
+ import { ChevronDown } from 'lucide-react'
4
+ import { AnimatePresence, motion } from 'motion/react'
5
+ import { useState } from 'react'
6
+ import { cn } from '../../../lib/utils'
7
+
8
+ interface PanelSectionProps {
9
+ title: string
10
+ children: React.ReactNode
11
+ defaultExpanded?: boolean
12
+ className?: string
13
+ }
14
+
15
+ export function PanelSection({
16
+ title,
17
+ children,
18
+ defaultExpanded = true,
19
+ className,
20
+ }: PanelSectionProps) {
21
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded)
22
+
23
+ return (
24
+ <motion.div
25
+ className={cn('flex shrink-0 flex-col overflow-hidden border-border/50 border-b', className)}
26
+ layout
27
+ transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
28
+ >
29
+ <motion.button
30
+ className={cn(
31
+ 'group/section flex h-10 shrink-0 items-center justify-between px-3 transition-all duration-200',
32
+ isExpanded
33
+ ? 'bg-accent/50 text-foreground'
34
+ : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
35
+ )}
36
+ layout="position"
37
+ onClick={() => setIsExpanded(!isExpanded)}
38
+ type="button"
39
+ >
40
+ <span className="truncate font-medium text-sm">{title}</span>
41
+ <ChevronDown
42
+ className={cn(
43
+ 'h-4 w-4 transition-transform duration-200',
44
+ isExpanded ? 'rotate-180' : 'rotate-0',
45
+ isExpanded ? 'text-foreground' : 'opacity-0 group-hover/section:opacity-100',
46
+ )}
47
+ />
48
+ </motion.button>
49
+
50
+ <AnimatePresence initial={false}>
51
+ {isExpanded && (
52
+ <motion.div
53
+ animate={{ height: 'auto', opacity: 1 }}
54
+ className="overflow-hidden"
55
+ exit={{ height: 0, opacity: 0 }}
56
+ initial={{ height: 0, opacity: 0 }}
57
+ transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
58
+ >
59
+ <div className="flex flex-col gap-1.5 p-3 pt-2">{children}</div>
60
+ </motion.div>
61
+ )}
62
+ </AnimatePresence>
63
+ </motion.div>
64
+ )
65
+ }
@@ -0,0 +1,45 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../../../lib/utils'
4
+
5
+ interface SegmentedControlProps<T extends string> {
6
+ value: T
7
+ onChange: (value: T) => void
8
+ options: { label: React.ReactNode; value: T }[]
9
+ className?: string
10
+ }
11
+
12
+ export function SegmentedControl<T extends string>({
13
+ value,
14
+ onChange,
15
+ options,
16
+ className,
17
+ }: SegmentedControlProps<T>) {
18
+ return (
19
+ <div
20
+ className={cn(
21
+ 'flex h-9 w-full items-center rounded-lg border border-border/50 bg-[#2C2C2E] p-[3px]',
22
+ className,
23
+ )}
24
+ >
25
+ {options.map((option) => {
26
+ const isSelected = value === option.value
27
+ return (
28
+ <button
29
+ className={cn(
30
+ 'relative flex h-full flex-1 items-center justify-center rounded-md font-medium text-xs transition-all duration-200',
31
+ isSelected
32
+ ? 'bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50'
33
+ : 'text-muted-foreground hover:bg-white/5 hover:text-foreground',
34
+ )}
35
+ key={option.value}
36
+ onClick={() => onChange(option.value)}
37
+ type="button"
38
+ >
39
+ <span className="relative z-10 flex items-center gap-1.5">{option.label}</span>
40
+ </button>
41
+ )
42
+ })}
43
+ </div>
44
+ )
45
+ }