@pascal-app/editor 0.6.0 → 0.8.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 (157) hide show
  1. package/package.json +13 -9
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +74 -5
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +24 -3
  6. package/src/components/editor/first-person/build-collider-world.ts +363 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -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 +9861 -3297
  12. package/src/components/editor/index.tsx +295 -32
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  15. package/src/components/editor/thumbnail-generator.tsx +56 -68
  16. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  17. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  18. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  19. package/src/components/editor/wall-measurement-label.tsx +267 -36
  20. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  21. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  22. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  23. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
  24. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  25. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
  26. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  27. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  28. package/src/components/editor-2d/svg-paths.ts +119 -0
  29. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  30. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  31. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  32. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  33. package/src/components/systems/zone/zone-system.tsx +0 -0
  34. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  35. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  36. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  37. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  38. package/src/components/tools/column/column-tool.tsx +97 -0
  39. package/src/components/tools/column/move-column-tool.tsx +105 -0
  40. package/src/components/tools/door/door-tool.tsx +7 -0
  41. package/src/components/tools/door/move-door-tool.tsx +28 -8
  42. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  43. package/src/components/tools/fence/fence-drafting.ts +10 -3
  44. package/src/components/tools/fence/fence-tool.tsx +160 -4
  45. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
  46. package/src/components/tools/fence/move-fence-tool.tsx +111 -40
  47. package/src/components/tools/item/move-tool.tsx +7 -1
  48. package/src/components/tools/item/placement-math.ts +32 -5
  49. package/src/components/tools/item/placement-strategies.ts +110 -31
  50. package/src/components/tools/item/placement-types.ts +7 -0
  51. package/src/components/tools/item/use-draft-node.ts +1 -0
  52. package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
  53. package/src/components/tools/roof/move-roof-tool.tsx +29 -17
  54. package/src/components/tools/select/box-select-tool.tsx +12 -17
  55. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  56. package/src/components/tools/shared/segment-angle.ts +156 -0
  57. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  58. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  59. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  60. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  61. package/src/components/tools/tool-manager.tsx +20 -5
  62. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
  64. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  65. package/src/components/tools/wall/wall-drafting.ts +18 -9
  66. package/src/components/tools/wall/wall-tool.tsx +136 -4
  67. package/src/components/tools/window/move-window-tool.tsx +18 -0
  68. package/src/components/tools/window/window-tool.tsx +5 -0
  69. package/src/components/tools/zone/zone-tool.tsx +20 -5
  70. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  71. package/src/components/ui/action-menu/control-modes.tsx +34 -1
  72. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  73. package/src/components/ui/action-menu/index.tsx +98 -59
  74. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  75. package/src/components/ui/action-menu/view-toggles.tsx +418 -41
  76. package/src/components/ui/command-palette/editor-commands.tsx +24 -5
  77. package/src/components/ui/command-palette/index.tsx +4 -255
  78. package/src/components/ui/controls/material-picker.tsx +154 -164
  79. package/src/components/ui/controls/slider-control.tsx +66 -18
  80. package/src/components/ui/floating-level-selector.tsx +286 -55
  81. package/src/components/ui/helpers/helper-manager.tsx +10 -0
  82. package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
  83. package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
  84. package/src/components/ui/level-duplicate-dialog.tsx +113 -0
  85. package/src/components/ui/panels/ceiling-panel.tsx +3 -28
  86. package/src/components/ui/panels/column-panel.tsx +759 -0
  87. package/src/components/ui/panels/door-panel.tsx +989 -290
  88. package/src/components/ui/panels/fence-panel.tsx +2 -49
  89. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  90. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  91. package/src/components/ui/panels/node-display.ts +39 -0
  92. package/src/components/ui/panels/paint-panel.tsx +163 -0
  93. package/src/components/ui/panels/panel-manager.tsx +208 -28
  94. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  95. package/src/components/ui/panels/reference-panel.tsx +253 -5
  96. package/src/components/ui/panels/roof-panel.tsx +13 -64
  97. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  98. package/src/components/ui/panels/slab-panel.tsx +4 -30
  99. package/src/components/ui/panels/spawn-panel.tsx +161 -0
  100. package/src/components/ui/panels/stair-panel.tsx +20 -74
  101. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  102. package/src/components/ui/panels/wall-panel.tsx +10 -8
  103. package/src/components/ui/panels/window-panel.tsx +668 -139
  104. package/src/components/ui/primitives/number-input.tsx +1 -1
  105. package/src/components/ui/primitives/sidebar.tsx +0 -0
  106. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  107. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  108. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  109. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  110. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  112. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  114. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  115. package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
  116. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  117. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  118. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
  119. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
  120. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
  121. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/slider.tsx +1 -1
  124. package/src/components/viewer-overlay.tsx +0 -0
  125. package/src/components/viewer-zone-system.tsx +0 -0
  126. package/src/hooks/use-auto-frame.ts +45 -0
  127. package/src/hooks/use-auto-save.ts +14 -0
  128. package/src/hooks/use-keyboard.ts +74 -7
  129. package/src/hooks/use-mobile.ts +12 -12
  130. package/src/index.tsx +8 -1
  131. package/src/lib/door-interaction.ts +88 -0
  132. package/src/lib/floorplan/geometry.ts +263 -0
  133. package/src/lib/floorplan/index.ts +38 -0
  134. package/src/lib/floorplan/items.ts +179 -0
  135. package/src/lib/floorplan/selection-tool.ts +231 -0
  136. package/src/lib/floorplan/stairs.ts +478 -0
  137. package/src/lib/floorplan/types.ts +57 -0
  138. package/src/lib/floorplan/walls.ts +23 -0
  139. package/src/lib/guide-events.ts +10 -0
  140. package/src/lib/level-duplication.test.ts +70 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/scene.ts +0 -0
  148. package/src/lib/sfx-bus.ts +2 -0
  149. package/src/lib/sfx-player.ts +5 -5
  150. package/src/lib/stair-duplication.ts +126 -0
  151. package/src/lib/window-interaction.ts +86 -0
  152. package/src/store/use-editor.tsx +186 -62
  153. package/tsconfig.json +2 -1
  154. package/src/components/feedback-dialog.tsx +0 -265
  155. package/src/components/pascal-radio.tsx +0 -280
  156. package/src/components/preview-button.tsx +0 -16
  157. package/src/components/ui/viewer-toolbar.tsx +0 -395
@@ -1,59 +1,120 @@
1
1
  'use client'
2
2
 
3
3
  import {
4
+ getCatalogMaterialById,
5
+ getLibraryMaterialIdFromRef,
6
+ getMaterialsForCategory,
4
7
  getMaterialsForTarget,
5
- toLibraryMaterialRef,
8
+ MATERIAL_CATEGORIES,
6
9
  type MaterialSchema,
7
10
  type MaterialTarget,
11
+ toLibraryMaterialRef,
8
12
  } from '@pascal-app/core'
9
- import { useEffect, useState } from 'react'
13
+ import { useEffect, useRef, useState } from 'react'
14
+ import useEditor from '../../../store/use-editor'
10
15
 
11
16
  type MaterialPickerProps = {
12
- nodeType?: MaterialTarget
13
17
  value?: MaterialSchema
14
18
  selectedMaterialPreset?: string
15
19
  onChange?: (material: MaterialSchema) => void
16
20
  onSelectMaterialPreset?: (materialPreset: string) => void
17
- hideSideControl?: boolean
18
21
  disabled?: boolean
22
+ nodeType?: MaterialTarget
23
+ hideSideControl?: boolean
19
24
  }
20
25
 
21
26
  export function MaterialPicker({
22
- nodeType,
23
27
  value,
24
28
  selectedMaterialPreset,
25
29
  onChange,
26
30
  onSelectMaterialPreset,
27
- hideSideControl = false,
28
31
  disabled = false,
29
32
  }: MaterialPickerProps) {
33
+ const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
30
34
  const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
31
- const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
35
+ const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
36
+ MATERIAL_CATEGORIES[0],
37
+ )
38
+ const catalogScrollRef = useRef<HTMLDivElement>(null)
39
+ const categoryScrollRef = useRef<HTMLDivElement>(null)
40
+ const catalogItems =
41
+ selectedCategory === 'other'
42
+ ? getMaterialsForCategory('other')
43
+ : getMaterialsForCategory(selectedCategory)
32
44
 
33
45
  useEffect(() => {
34
46
  setShowCustom(!!value?.properties && !selectedMaterialPreset)
35
47
  }, [selectedMaterialPreset, value?.properties])
36
48
 
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
- }
49
+ useEffect(() => {
50
+ if (!selectedMaterialPreset && value?.properties) {
51
+ setSelectedCategory('other')
52
+ return
53
+ }
54
+
55
+ const catalogId = getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
56
+ const selectedCatalogEntry = getCatalogMaterialById(catalogId)
57
+ if (selectedCatalogEntry?.category) {
58
+ setSelectedCategory(selectedCatalogEntry.category)
59
+ }
60
+ }, [selectedMaterialPreset, value?.id])
61
+
45
62
  const selectedCatalogId =
46
63
  selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
47
64
 
48
65
  const handleCatalogSelect = (materialId: string) => {
49
66
  if (disabled) return
50
67
  setShowCustom(false)
68
+ setPaintPanelOpen(false)
51
69
  onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
52
70
  }
53
71
 
72
+ useEffect(() => {
73
+ const container = catalogScrollRef.current
74
+ if (!container) return
75
+
76
+ const handleWheel = (event: WheelEvent) => {
77
+ const deltaX = event.deltaX
78
+ const deltaY = event.deltaY
79
+ const nextScrollLeft = container.scrollLeft + deltaX + deltaY
80
+
81
+ if (nextScrollLeft === container.scrollLeft) return
82
+
83
+ event.preventDefault()
84
+ container.scrollLeft = nextScrollLeft
85
+ }
86
+
87
+ container.addEventListener('wheel', handleWheel, { passive: false })
88
+ return () => {
89
+ container.removeEventListener('wheel', handleWheel)
90
+ }
91
+ }, [catalogItems.length, onChange, showCustom])
92
+
93
+ useEffect(() => {
94
+ const container = categoryScrollRef.current
95
+ if (!container) return
96
+
97
+ const handleWheel = (event: WheelEvent) => {
98
+ const deltaX = event.deltaX
99
+ const deltaY = event.deltaY
100
+ const nextScrollLeft = container.scrollLeft + deltaX + deltaY
101
+
102
+ if (nextScrollLeft === container.scrollLeft) return
103
+
104
+ event.preventDefault()
105
+ container.scrollLeft = nextScrollLeft
106
+ }
107
+
108
+ container.addEventListener('wheel', handleWheel, { passive: false })
109
+ return () => {
110
+ container.removeEventListener('wheel', handleWheel)
111
+ }
112
+ }, [])
113
+
54
114
  const handleCustomOpen = () => {
55
115
  if (disabled) return
56
116
  setShowCustom(true)
117
+ setPaintPanelOpen(true)
57
118
  onChange?.({
58
119
  preset: 'custom',
59
120
  properties: {
@@ -67,159 +128,88 @@ export function MaterialPicker({
67
128
  })
68
129
  }
69
130
 
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
131
  return (
85
- <div className={`space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
132
+ <div className={`min-w-0 space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
86
133
  {(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>
134
+ <div className="min-w-0 space-y-1">
135
+ <div
136
+ className="w-full max-w-full overflow-x-auto overflow-y-hidden"
137
+ ref={categoryScrollRef}
138
+ style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
139
+ >
140
+ <div className="flex min-w-max gap-1 pb-1">
141
+ {MATERIAL_CATEGORIES.map((category) => (
142
+ <button
143
+ className={`shrink-0 px-2 font-medium text-[11px] uppercase tracking-[0.12em] transition-all ${
144
+ selectedCategory === category
145
+ ? 'bg-transparent text-foreground'
146
+ : 'bg-transparent text-muted-foreground opacity-70 hover:text-foreground hover:opacity-100'
147
+ }`}
148
+ key={category}
149
+ onClick={() => {
150
+ setSelectedCategory(category)
151
+ if (showCustom) {
152
+ setShowCustom(false)
153
+ }
154
+ if (category !== 'other') {
155
+ setPaintPanelOpen(false)
156
+ }
157
+ }}
158
+ type="button"
159
+ >
160
+ {category.charAt(0).toUpperCase() + category.slice(1)}
161
+ </button>
162
+ ))}
163
+ </div>
205
164
  </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>
165
+ <div
166
+ className="w-full max-w-full overflow-x-auto overflow-y-hidden"
167
+ ref={catalogScrollRef}
168
+ style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
169
+ >
170
+ <div className="flex min-w-max gap-1.5 pb-1">
171
+ {catalogItems.map((item) => (
172
+ <button
173
+ className={`relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
174
+ selectedCatalogId === toLibraryMaterialRef(item.id)
175
+ ? 'border-blue-500 ring-2 ring-blue-500/30'
176
+ : 'border-gray-300 hover:border-gray-400'
177
+ }`}
178
+ key={item.id}
179
+ onClick={() => handleCatalogSelect(item.id)}
180
+ title={item.label}
181
+ type="button"
182
+ >
183
+ <div className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-white/12 ring-inset" />
184
+ {item.previewThumbnailUrl ? (
185
+ <img
186
+ alt={item.label}
187
+ className="h-full w-full object-cover"
188
+ src={item.previewThumbnailUrl}
189
+ />
190
+ ) : item.previewColor ? (
191
+ <div className="h-full w-full" style={{ backgroundColor: item.previewColor }} />
192
+ ) : (
193
+ <div className="h-full w-full bg-gray-100" />
194
+ )}
195
+ </button>
196
+ ))}
197
+ {selectedCategory === 'other' && onChange ? (
198
+ <button
199
+ className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border font-medium text-[10px] transition-all ${
200
+ showCustom
201
+ ? 'border-blue-500 ring-2 ring-blue-500/30'
202
+ : 'border-gray-300 hover:border-gray-400'
203
+ }`}
204
+ onClick={handleCustomOpen}
205
+ title="Custom"
206
+ type="button"
207
+ >
208
+ Custom
209
+ </button>
210
+ ) : null}
221
211
  </div>
222
- )}
212
+ </div>
223
213
  </div>
224
214
  )}
225
215
  </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>) => {