@pascal-app/editor 0.6.0 → 0.7.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 (122) hide show
  1. package/package.json +9 -5
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +20 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9855 -3298
  12. package/src/components/editor/index.tsx +269 -21
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/thumbnail-generator.tsx +38 -7
  15. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  16. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  17. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  18. package/src/components/editor/wall-measurement-label.tsx +267 -36
  19. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  20. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  21. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  22. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  23. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  24. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  25. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  26. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  27. package/src/components/editor-2d/svg-paths.ts +119 -0
  28. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  29. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  30. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  31. package/src/components/tools/column/column-tool.tsx +97 -0
  32. package/src/components/tools/column/move-column-tool.tsx +105 -0
  33. package/src/components/tools/door/door-tool.tsx +7 -0
  34. package/src/components/tools/door/move-door-tool.tsx +28 -8
  35. package/src/components/tools/fence/fence-drafting.ts +10 -3
  36. package/src/components/tools/fence/fence-tool.tsx +159 -3
  37. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
  38. package/src/components/tools/fence/move-fence-tool.tsx +101 -34
  39. package/src/components/tools/item/move-tool.tsx +10 -1
  40. package/src/components/tools/item/placement-math.ts +30 -1
  41. package/src/components/tools/item/placement-strategies.ts +109 -31
  42. package/src/components/tools/item/placement-types.ts +7 -0
  43. package/src/components/tools/item/use-draft-node.ts +2 -0
  44. package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
  45. package/src/components/tools/roof/move-roof-tool.tsx +22 -15
  46. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  47. package/src/components/tools/shared/segment-angle.ts +156 -0
  48. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  49. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  50. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  51. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  52. package/src/components/tools/tool-manager.tsx +18 -3
  53. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
  54. package/src/components/tools/wall/wall-drafting.ts +18 -9
  55. package/src/components/tools/wall/wall-tool.tsx +134 -2
  56. package/src/components/tools/window/move-window-tool.tsx +18 -0
  57. package/src/components/tools/window/window-tool.tsx +5 -0
  58. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  59. package/src/components/ui/action-menu/control-modes.tsx +28 -1
  60. package/src/components/ui/action-menu/index.tsx +91 -1
  61. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  63. package/src/components/ui/command-palette/editor-commands.tsx +18 -1
  64. package/src/components/ui/controls/material-picker.tsx +152 -165
  65. package/src/components/ui/controls/slider-control.tsx +66 -18
  66. package/src/components/ui/floating-level-selector.tsx +286 -55
  67. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  68. package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
  69. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  70. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  71. package/src/components/ui/panels/ceiling-panel.tsx +1 -25
  72. package/src/components/ui/panels/column-panel.tsx +715 -0
  73. package/src/components/ui/panels/door-panel.tsx +981 -289
  74. package/src/components/ui/panels/fence-panel.tsx +3 -45
  75. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  76. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  77. package/src/components/ui/panels/node-display.ts +39 -0
  78. package/src/components/ui/panels/paint-panel.tsx +138 -0
  79. package/src/components/ui/panels/panel-manager.tsx +210 -1
  80. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  81. package/src/components/ui/panels/reference-panel.tsx +238 -5
  82. package/src/components/ui/panels/roof-panel.tsx +4 -105
  83. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  84. package/src/components/ui/panels/slab-panel.tsx +4 -30
  85. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  86. package/src/components/ui/panels/stair-panel.tsx +11 -117
  87. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  88. package/src/components/ui/panels/wall-panel.tsx +1 -95
  89. package/src/components/ui/panels/window-panel.tsx +660 -139
  90. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  91. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  92. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  93. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  94. package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
  95. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  96. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  97. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
  98. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
  99. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  100. package/src/components/ui/viewer-toolbar.tsx +42 -1
  101. package/src/hooks/use-auto-frame.ts +45 -0
  102. package/src/hooks/use-keyboard.ts +64 -7
  103. package/src/hooks/use-mobile.ts +12 -12
  104. package/src/lib/door-interaction.ts +88 -0
  105. package/src/lib/floorplan/geometry.ts +263 -0
  106. package/src/lib/floorplan/index.ts +38 -0
  107. package/src/lib/floorplan/items.ts +179 -0
  108. package/src/lib/floorplan/selection-tool.ts +231 -0
  109. package/src/lib/floorplan/stairs.ts +478 -0
  110. package/src/lib/floorplan/types.ts +57 -0
  111. package/src/lib/floorplan/walls.ts +23 -0
  112. package/src/lib/guide-events.ts +10 -0
  113. package/src/lib/level-duplication.test.ts +72 -0
  114. package/src/lib/level-duplication.ts +153 -0
  115. package/src/lib/local-guide-image.ts +42 -0
  116. package/src/lib/material-paint.ts +284 -0
  117. package/src/lib/roof-duplication.ts +214 -0
  118. package/src/lib/scene-bounds.test.ts +183 -0
  119. package/src/lib/scene-bounds.ts +169 -0
  120. package/src/lib/stair-duplication.ts +126 -0
  121. package/src/lib/window-interaction.ts +86 -0
  122. package/src/store/use-editor.tsx +164 -8
@@ -1,59 +1,117 @@
1
1
  'use client'
2
2
 
3
3
  import {
4
- getMaterialsForTarget,
4
+ getCatalogMaterialById,
5
+ getLibraryMaterialIdFromRef,
6
+ getMaterialsForCategory,
7
+ MATERIAL_CATEGORIES,
5
8
  toLibraryMaterialRef,
6
9
  type MaterialSchema,
7
- type MaterialTarget,
8
10
  } from '@pascal-app/core'
9
- import { useEffect, useState } from 'react'
11
+ import { useEffect, useRef, useState } from 'react'
12
+ import useEditor from '../../../store/use-editor'
10
13
 
11
14
  type MaterialPickerProps = {
12
- nodeType?: MaterialTarget
13
15
  value?: MaterialSchema
14
16
  selectedMaterialPreset?: string
15
17
  onChange?: (material: MaterialSchema) => void
16
18
  onSelectMaterialPreset?: (materialPreset: string) => void
17
- hideSideControl?: boolean
18
19
  disabled?: boolean
19
20
  }
20
21
 
21
22
  export function MaterialPicker({
22
- nodeType,
23
23
  value,
24
24
  selectedMaterialPreset,
25
25
  onChange,
26
26
  onSelectMaterialPreset,
27
- hideSideControl = false,
28
27
  disabled = false,
29
28
  }: MaterialPickerProps) {
29
+ const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
30
30
  const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
31
- const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
31
+ const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
32
+ MATERIAL_CATEGORIES[0],
33
+ )
34
+ const catalogScrollRef = useRef<HTMLDivElement>(null)
35
+ const categoryScrollRef = useRef<HTMLDivElement>(null)
36
+ const catalogItems =
37
+ selectedCategory === 'other'
38
+ ? getMaterialsForCategory('other')
39
+ : getMaterialsForCategory(selectedCategory)
32
40
 
33
41
  useEffect(() => {
34
42
  setShowCustom(!!value?.properties && !selectedMaterialPreset)
35
43
  }, [selectedMaterialPreset, value?.properties])
36
44
 
37
- const currentProps = value?.properties || {
38
- color: '#ffffff',
39
- roughness: 0.5,
40
- metalness: 0,
41
- opacity: 1,
42
- transparent: false,
43
- side: 'front' as const,
44
- }
45
+ useEffect(() => {
46
+ if (!selectedMaterialPreset && value?.properties) {
47
+ setSelectedCategory('other')
48
+ return
49
+ }
50
+
51
+ const catalogId =
52
+ getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
53
+ const selectedCatalogEntry = getCatalogMaterialById(catalogId)
54
+ if (selectedCatalogEntry?.category) {
55
+ setSelectedCategory(selectedCatalogEntry.category)
56
+ }
57
+ }, [selectedMaterialPreset, value?.id])
58
+
45
59
  const selectedCatalogId =
46
60
  selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
47
61
 
48
62
  const handleCatalogSelect = (materialId: string) => {
49
63
  if (disabled) return
50
64
  setShowCustom(false)
65
+ setPaintPanelOpen(false)
51
66
  onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
52
67
  }
53
68
 
69
+ useEffect(() => {
70
+ const container = catalogScrollRef.current
71
+ if (!container) return
72
+
73
+ const handleWheel = (event: WheelEvent) => {
74
+ const deltaX = event.deltaX
75
+ const deltaY = event.deltaY
76
+ const nextScrollLeft = container.scrollLeft + deltaX + deltaY
77
+
78
+ if (nextScrollLeft === container.scrollLeft) return
79
+
80
+ event.preventDefault()
81
+ container.scrollLeft = nextScrollLeft
82
+ }
83
+
84
+ container.addEventListener('wheel', handleWheel, { passive: false })
85
+ return () => {
86
+ container.removeEventListener('wheel', handleWheel)
87
+ }
88
+ }, [catalogItems.length, onChange, showCustom])
89
+
90
+ useEffect(() => {
91
+ const container = categoryScrollRef.current
92
+ if (!container) return
93
+
94
+ const handleWheel = (event: WheelEvent) => {
95
+ const deltaX = event.deltaX
96
+ const deltaY = event.deltaY
97
+ const nextScrollLeft = container.scrollLeft + deltaX + deltaY
98
+
99
+ if (nextScrollLeft === container.scrollLeft) return
100
+
101
+ event.preventDefault()
102
+ container.scrollLeft = nextScrollLeft
103
+ }
104
+
105
+ container.addEventListener('wheel', handleWheel, { passive: false })
106
+ return () => {
107
+ container.removeEventListener('wheel', handleWheel)
108
+ }
109
+ }, [])
110
+
54
111
  const handleCustomOpen = () => {
55
112
  if (disabled) return
56
113
  setShowCustom(true)
114
+ setPaintPanelOpen(true)
57
115
  onChange?.({
58
116
  preset: 'custom',
59
117
  properties: {
@@ -67,159 +125,88 @@ export function MaterialPicker({
67
125
  })
68
126
  }
69
127
 
70
- const handlePropertyChange = (
71
- prop: keyof typeof currentProps,
72
- val: (typeof currentProps)[keyof typeof currentProps],
73
- ) => {
74
- if (disabled) return
75
- onChange?.({
76
- preset: 'custom',
77
- properties: {
78
- ...currentProps,
79
- [prop]: val,
80
- },
81
- })
82
- }
83
-
84
128
  return (
85
- <div className={`space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
129
+ <div className={`min-w-0 space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
86
130
  {(catalogItems.length > 0 || onChange) && (
87
- <div className="space-y-2">
88
- {catalogItems.length > 0 ? (
89
- <div className="text-gray-500 text-xs uppercase tracking-[0.16em]">Library</div>
90
- ) : null}
91
- <div className="flex flex-wrap gap-1.5">
92
- {catalogItems.map((item) => (
93
- <button
94
- className={`h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
95
- selectedCatalogId === toLibraryMaterialRef(item.id)
96
- ? 'border-blue-500 ring-2 ring-blue-500/30'
97
- : 'border-gray-300 hover:border-gray-400'
98
- }`}
99
- key={item.id}
100
- onClick={() => handleCatalogSelect(item.id)}
101
- title={item.label}
102
- type="button"
103
- >
104
- {item.previewThumbnailUrl ? (
105
- <img
106
- alt={item.label}
107
- className="h-full w-full object-cover"
108
- src={item.previewThumbnailUrl}
109
- />
110
- ) : item.previewColor ? (
111
- <div className="h-full w-full" style={{ backgroundColor: item.previewColor }} />
112
- ) : (
113
- <div className="h-full w-full bg-gray-100" />
114
- )}
115
- </button>
116
- ))}
117
- {onChange ? (
118
- <button
119
- className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
120
- showCustom
121
- ? 'border-blue-500 bg-blue-50 text-blue-700 ring-2 ring-blue-500/30'
122
- : 'border-gray-300 bg-white text-gray-500 hover:border-gray-400'
123
- }`}
124
- onClick={handleCustomOpen}
125
- title="Custom"
126
- type="button"
127
- >
128
- Custom
129
- </button>
130
- ) : null}
131
- </div>
132
- </div>
133
- )}
134
-
135
- {showCustom && onChange && (
136
- <div className="space-y-2 pt-2">
137
- <div className="flex items-center gap-2">
138
- <label className="w-16 text-gray-500 text-xs">Color</label>
139
- <input
140
- className="h-7 w-12 cursor-pointer rounded border border-gray-300"
141
- onChange={(e) => handlePropertyChange('color', e.target.value)}
142
- type="color"
143
- value={currentProps.color}
144
- />
145
- <input
146
- className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
147
- onChange={(e) => handlePropertyChange('color', e.target.value)}
148
- type="text"
149
- value={currentProps.color}
150
- />
151
- </div>
152
-
153
- <div className="flex items-center gap-2">
154
- <label className="w-16 text-gray-500 text-xs">Roughness</label>
155
- <input
156
- className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
157
- max={1}
158
- min={0}
159
- onChange={(e) => handlePropertyChange('roughness', Number.parseFloat(e.target.value))}
160
- step={0.01}
161
- type="range"
162
- value={currentProps.roughness}
163
- />
164
- <span className="w-8 text-right text-gray-400 text-xs">
165
- {currentProps.roughness.toFixed(2)}
166
- </span>
167
- </div>
168
-
169
- <div className="flex items-center gap-2">
170
- <label className="w-16 text-gray-500 text-xs">Metalness</label>
171
- <input
172
- className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
173
- max={1}
174
- min={0}
175
- onChange={(e) => handlePropertyChange('metalness', Number.parseFloat(e.target.value))}
176
- step={0.01}
177
- type="range"
178
- value={currentProps.metalness}
179
- />
180
- <span className="w-8 text-right text-gray-400 text-xs">
181
- {currentProps.metalness.toFixed(2)}
182
- </span>
183
- </div>
184
-
185
- <div className="flex items-center gap-2">
186
- <label className="w-16 text-gray-500 text-xs">Opacity</label>
187
- <input
188
- className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
189
- max={1}
190
- min={0}
191
- onChange={(e) => {
192
- const opacity = Number.parseFloat(e.target.value)
193
- handlePropertyChange('opacity', opacity)
194
- if (opacity < 1 && !currentProps.transparent) {
195
- handlePropertyChange('transparent', true)
196
- }
197
- }}
198
- step={0.01}
199
- type="range"
200
- value={currentProps.opacity}
201
- />
202
- <span className="w-8 text-right text-gray-400 text-xs">
203
- {currentProps.opacity.toFixed(2)}
204
- </span>
131
+ <div className="min-w-0 space-y-1">
132
+ <div
133
+ className="w-full max-w-full overflow-x-auto overflow-y-hidden"
134
+ ref={categoryScrollRef}
135
+ style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
136
+ >
137
+ <div className="flex min-w-max gap-1 pb-1">
138
+ {MATERIAL_CATEGORIES.map((category) => (
139
+ <button
140
+ className={`shrink-0 px-2 font-medium text-[11px] uppercase tracking-[0.12em] transition-all ${
141
+ selectedCategory === category
142
+ ? 'bg-transparent text-foreground'
143
+ : 'bg-transparent text-muted-foreground opacity-70 hover:text-foreground hover:opacity-100'
144
+ }`}
145
+ key={category}
146
+ onClick={() => {
147
+ setSelectedCategory(category)
148
+ if (showCustom) {
149
+ setShowCustom(false)
150
+ }
151
+ if (category !== 'other') {
152
+ setPaintPanelOpen(false)
153
+ }
154
+ }}
155
+ type="button"
156
+ >
157
+ {category.charAt(0).toUpperCase() + category.slice(1)}
158
+ </button>
159
+ ))}
160
+ </div>
205
161
  </div>
206
-
207
- {!hideSideControl && (
208
- <div className="flex items-center gap-2">
209
- <label className="w-16 text-gray-500 text-xs">Side</label>
210
- <select
211
- className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
212
- onChange={(e) =>
213
- handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
214
- }
215
- value={currentProps.side}
216
- >
217
- <option value="front">Front</option>
218
- <option value="back">Back</option>
219
- <option value="double">Double</option>
220
- </select>
162
+ <div
163
+ className="w-full max-w-full overflow-x-auto overflow-y-hidden"
164
+ ref={catalogScrollRef}
165
+ style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
166
+ >
167
+ <div className="flex min-w-max gap-1.5 pb-1">
168
+ {catalogItems.map((item) => (
169
+ <button
170
+ className={`relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
171
+ selectedCatalogId === toLibraryMaterialRef(item.id)
172
+ ? 'border-blue-500 ring-2 ring-blue-500/30'
173
+ : 'border-gray-300 hover:border-gray-400'
174
+ }`}
175
+ key={item.id}
176
+ onClick={() => handleCatalogSelect(item.id)}
177
+ title={item.label}
178
+ type="button"
179
+ >
180
+ <div className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-inset ring-white/12" />
181
+ {item.previewThumbnailUrl ? (
182
+ <img
183
+ alt={item.label}
184
+ className="h-full w-full object-cover"
185
+ src={item.previewThumbnailUrl}
186
+ />
187
+ ) : item.previewColor ? (
188
+ <div className="h-full w-full" style={{ backgroundColor: item.previewColor }} />
189
+ ) : (
190
+ <div className="h-full w-full bg-gray-100" />
191
+ )}
192
+ </button>
193
+ ))}
194
+ {selectedCategory === 'other' && onChange ? (
195
+ <button
196
+ className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
197
+ showCustom
198
+ ? 'border-blue-500 ring-2 ring-blue-500/30'
199
+ : 'border-gray-300 hover:border-gray-400'
200
+ }`}
201
+ onClick={handleCustomOpen}
202
+ title="Custom"
203
+ type="button"
204
+ >
205
+ Custom
206
+ </button>
207
+ ) : null}
221
208
  </div>
222
- )}
209
+ </div>
223
210
  </div>
224
211
  )}
225
212
  </div>
@@ -8,12 +8,14 @@ interface SliderControlProps {
8
8
  label: React.ReactNode
9
9
  value: number
10
10
  onChange: (value: number) => void
11
+ onCommit?: (value: number) => void
11
12
  min?: number
12
13
  max?: number
13
14
  precision?: number
14
15
  step?: number
15
16
  className?: string
16
17
  unit?: string
18
+ restoreOnCommit?: boolean
17
19
  }
18
20
 
19
21
  function stepPrecision(s: number): number {
@@ -21,6 +23,17 @@ function stepPrecision(s: number): number {
21
23
  return Math.max(0, Math.ceil(-Math.log10(s)))
22
24
  }
23
25
 
26
+ function getStepMultiplier(modifiers: {
27
+ shiftKey?: boolean
28
+ metaKey?: boolean
29
+ ctrlKey?: boolean
30
+ altKey?: boolean
31
+ }): number {
32
+ if (modifiers.shiftKey) return 10
33
+ if (modifiers.metaKey || modifiers.ctrlKey || modifiers.altKey) return 0.1
34
+ return 1
35
+ }
36
+
24
37
  function getAdjustedStep(
25
38
  baseStep: number,
26
39
  modifiers: {
@@ -30,28 +43,38 @@ function getAdjustedStep(
30
43
  altKey?: boolean
31
44
  },
32
45
  ): number {
33
- if (modifiers.shiftKey) return baseStep * 10
34
- if (modifiers.metaKey || modifiers.ctrlKey || modifiers.altKey) return baseStep * 0.1
35
- return baseStep
46
+ return baseStep * getStepMultiplier(modifiers)
36
47
  }
37
48
 
38
49
  export function SliderControl({
39
50
  label,
40
51
  value,
41
52
  onChange,
53
+ onCommit,
42
54
  min = Number.NEGATIVE_INFINITY,
43
55
  max = Number.POSITIVE_INFINITY,
44
56
  precision = 0,
45
57
  step = 1,
46
58
  className,
47
59
  unit = '',
60
+ restoreOnCommit = true,
48
61
  }: SliderControlProps) {
49
62
  const [isEditing, setIsEditing] = useState(false)
50
63
  const [isDragging, setIsDragging] = useState(false)
51
64
  const [isHovered, setIsHovered] = useState(false)
52
65
  const [inputValue, setInputValue] = useState(value.toFixed(precision))
53
66
 
54
- const dragRef = useRef<{ startX: number; startValue: number } | null>(null)
67
+ const dragRef = useRef<{
68
+ // Original value at drag start — preserved across modifier re-anchors so
69
+ // undo/redo rolls back to the pre-drag state, not to a mid-drag anchor.
70
+ originValue: number
71
+ // Anchor pointer position and value — updated whenever modifier keys
72
+ // change so the delta calculation continues smoothly from the current
73
+ // position at the new step size.
74
+ anchorX: number
75
+ anchorValue: number
76
+ stepMultiplier: number
77
+ } | null>(null)
55
78
  const labelRef = useRef<HTMLDivElement>(null)
56
79
  const valueRef = useRef(value)
57
80
  valueRef.current = value
@@ -76,10 +99,11 @@ export function SliderControl({
76
99
  const newValue = clamp(valueRef.current + direction * s)
77
100
  const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
78
101
  if (final !== valueRef.current) onChange(final)
102
+ onCommit?.(final)
79
103
  }
80
104
  el.addEventListener('wheel', handleWheel, { passive: false })
81
105
  return () => el.removeEventListener('wheel', handleWheel)
82
- }, [isEditing, step, clamp, onChange])
106
+ }, [isEditing, step, clamp, onChange, onCommit])
83
107
 
84
108
  // Arrow key support while hovered
85
109
  useEffect(() => {
@@ -94,18 +118,24 @@ export function SliderControl({
94
118
  const newValue = clamp(valueRef.current + direction * s)
95
119
  const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
96
120
  if (final !== valueRef.current) onChange(final)
121
+ onCommit?.(final)
97
122
  }
98
123
  }
99
124
  window.addEventListener('keydown', handleKeyDown)
100
125
  return () => window.removeEventListener('keydown', handleKeyDown)
101
- }, [isHovered, isEditing, step, clamp, onChange])
126
+ }, [isHovered, isEditing, step, clamp, onChange, onCommit])
102
127
 
103
128
  const handleLabelPointerDown = useCallback(
104
129
  (e: React.PointerEvent<HTMLDivElement>) => {
105
130
  if (isEditing) return
106
131
  e.preventDefault()
107
132
  e.currentTarget.setPointerCapture(e.pointerId)
108
- dragRef.current = { startX: e.clientX, startValue: valueRef.current }
133
+ dragRef.current = {
134
+ originValue: valueRef.current,
135
+ anchorX: e.clientX,
136
+ anchorValue: valueRef.current,
137
+ stepMultiplier: getStepMultiplier(e),
138
+ }
109
139
  setIsDragging(true)
110
140
  useScene.temporal.getState().pause()
111
141
  },
@@ -115,14 +145,28 @@ export function SliderControl({
115
145
  const handleLabelPointerMove = useCallback(
116
146
  (e: React.PointerEvent<HTMLDivElement>) => {
117
147
  if (!dragRef.current) return
118
- const { startX, startValue } = dragRef.current
119
- const dx = e.clientX - startX
120
- const s = getAdjustedStep(step, e)
148
+ const multiplier = getStepMultiplier(e)
149
+ // If modifier keys changed mid-drag, re-anchor from the current pointer
150
+ // position and value — otherwise the accumulated dx would be applied
151
+ // with a new step size and jump the value (e.g. pressing Cmd while
152
+ // already far from the starting point would snap back toward it).
153
+ if (multiplier !== dragRef.current.stepMultiplier) {
154
+ dragRef.current.anchorX = e.clientX
155
+ dragRef.current.anchorValue = valueRef.current
156
+ dragRef.current.stepMultiplier = multiplier
157
+ return
158
+ }
159
+ const { anchorX, anchorValue } = dragRef.current
160
+ const dx = e.clientX - anchorX
161
+ const s = step * multiplier
121
162
  // 4 px per step at default sensitivity
122
163
  const newValue = clamp(
123
- Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
164
+ Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))),
124
165
  )
125
- onChange(newValue)
166
+ if (newValue !== valueRef.current) {
167
+ valueRef.current = newValue
168
+ onChange(newValue)
169
+ }
126
170
  },
127
171
  [step, clamp, onChange],
128
172
  )
@@ -130,21 +174,23 @@ export function SliderControl({
130
174
  const handleLabelPointerUp = useCallback(
131
175
  (e: React.PointerEvent<HTMLDivElement>) => {
132
176
  if (!dragRef.current) return
133
- const { startValue } = dragRef.current
177
+ const { originValue } = dragRef.current
134
178
  const finalVal = valueRef.current
135
179
  dragRef.current = null
136
180
  setIsDragging(false)
137
181
  e.currentTarget.releasePointerCapture(e.pointerId)
138
182
 
139
- if (startValue !== finalVal) {
140
- onChange(startValue)
183
+ if (originValue !== finalVal && restoreOnCommit) {
184
+ onChange(originValue)
141
185
  useScene.temporal.getState().resume()
142
186
  onChange(finalVal)
187
+ onCommit?.(finalVal)
143
188
  } else {
144
189
  useScene.temporal.getState().resume()
190
+ onCommit?.(finalVal)
145
191
  }
146
192
  },
147
- [onChange],
193
+ [onChange, onCommit, restoreOnCommit],
148
194
  )
149
195
 
150
196
  const handleValueClick = useCallback(() => {
@@ -157,10 +203,12 @@ export function SliderControl({
157
203
  if (Number.isNaN(numValue)) {
158
204
  setInputValue(value.toFixed(precision))
159
205
  } else {
160
- onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
206
+ const nextValue = clamp(Number.parseFloat(numValue.toFixed(precision)))
207
+ onChange(nextValue)
208
+ onCommit?.(nextValue)
161
209
  }
162
210
  setIsEditing(false)
163
- }, [inputValue, onChange, clamp, precision, value])
211
+ }, [inputValue, onChange, onCommit, clamp, precision, value])
164
212
 
165
213
  const handleInputKeyDown = useCallback(
166
214
  (e: React.KeyboardEvent<HTMLInputElement>) => {