@pascal-app/editor 0.5.1 → 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 (150) hide show
  1. package/package.json +12 -7
  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 +29 -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 +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -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/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -219,7 +219,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
219
219
  const views = usePaletteViewRegistry((s) => s.views)
220
220
 
221
221
  const activeLevelId = useViewer((s) => s.selection.levelId)
222
- const activeLevelNode = useScene((s) => (activeLevelId ? s.nodes[activeLevelId] : null))
223
222
 
224
223
  const wallMode = useViewer((s) => s.wallMode)
225
224
  const setWallMode = useViewer((s) => s.setWallMode)
@@ -1,191 +1,211 @@
1
1
  'use client'
2
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
- }
3
+ import {
4
+ getCatalogMaterialById,
5
+ getLibraryMaterialIdFromRef,
6
+ getMaterialsForCategory,
7
+ MATERIAL_CATEGORIES,
8
+ toLibraryMaterialRef,
9
+ type MaterialSchema,
10
+ } from '@pascal-app/core'
11
+ import { useEffect, useRef, useState } from 'react'
12
+ import useEditor from '../../../store/use-editor'
31
13
 
32
14
  type MaterialPickerProps = {
33
15
  value?: MaterialSchema
34
- onChange: (material: MaterialSchema) => void
16
+ selectedMaterialPreset?: string
17
+ onChange?: (material: MaterialSchema) => void
18
+ onSelectMaterialPreset?: (materialPreset: string) => void
19
+ disabled?: boolean
35
20
  }
36
21
 
37
- export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
38
- const [showCustom, setShowCustom] = useState<boolean>(
39
- value?.preset === 'custom' || !!value?.properties,
22
+ export function MaterialPicker({
23
+ value,
24
+ selectedMaterialPreset,
25
+ onChange,
26
+ onSelectMaterialPreset,
27
+ disabled = false,
28
+ }: MaterialPickerProps) {
29
+ const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
30
+ const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
31
+ const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
32
+ MATERIAL_CATEGORIES[0],
40
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)
40
+
41
+ useEffect(() => {
42
+ setShowCustom(!!value?.properties && !selectedMaterialPreset)
43
+ }, [selectedMaterialPreset, value?.properties])
41
44
 
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 })
45
+ useEffect(() => {
46
+ if (!selectedMaterialPreset && value?.properties) {
47
+ setSelectedCategory('other')
48
+ return
62
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
+
59
+ const selectedCatalogId =
60
+ selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
61
+
62
+ const handleCatalogSelect = (materialId: string) => {
63
+ if (disabled) return
64
+ setShowCustom(false)
65
+ setPaintPanelOpen(false)
66
+ onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
63
67
  }
64
68
 
65
- const handlePropertyChange = (
66
- prop: keyof typeof currentProps,
67
- val: (typeof currentProps)[keyof typeof currentProps],
68
- ) => {
69
- onChange({
70
- preset: showCustom ? 'custom' : currentPreset,
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
+
111
+ const handleCustomOpen = () => {
112
+ if (disabled) return
113
+ setShowCustom(true)
114
+ setPaintPanelOpen(true)
115
+ onChange?.({
116
+ preset: 'custom',
71
117
  properties: {
72
- ...currentProps,
73
- [prop]: val,
118
+ color: value?.properties?.color || '#ffffff',
119
+ roughness: value?.properties?.roughness ?? 0.5,
120
+ metalness: value?.properties?.metalness ?? 0,
121
+ opacity: value?.properties?.opacity ?? 1,
122
+ transparent: value?.properties?.transparent ?? false,
123
+ side: value?.properties?.side ?? 'front',
74
124
  },
75
125
  })
76
126
  }
77
127
 
78
128
  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
- />
129
+ <div className={`min-w-0 space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
130
+ {(catalogItems.length > 0 || onChange) && (
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>
120
161
  </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>
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}
208
+ </div>
189
209
  </div>
190
210
  </div>
191
211
  )}
@@ -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,23 +23,58 @@ 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
+
37
+ function getAdjustedStep(
38
+ baseStep: number,
39
+ modifiers: {
40
+ shiftKey?: boolean
41
+ metaKey?: boolean
42
+ ctrlKey?: boolean
43
+ altKey?: boolean
44
+ },
45
+ ): number {
46
+ return baseStep * getStepMultiplier(modifiers)
47
+ }
48
+
24
49
  export function SliderControl({
25
50
  label,
26
51
  value,
27
52
  onChange,
53
+ onCommit,
28
54
  min = Number.NEGATIVE_INFINITY,
29
55
  max = Number.POSITIVE_INFINITY,
30
56
  precision = 0,
31
57
  step = 1,
32
58
  className,
33
59
  unit = '',
60
+ restoreOnCommit = true,
34
61
  }: SliderControlProps) {
35
62
  const [isEditing, setIsEditing] = useState(false)
36
63
  const [isDragging, setIsDragging] = useState(false)
37
64
  const [isHovered, setIsHovered] = useState(false)
38
65
  const [inputValue, setInputValue] = useState(value.toFixed(precision))
39
66
 
40
- 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)
41
78
  const labelRef = useRef<HTMLDivElement>(null)
42
79
  const valueRef = useRef(value)
43
80
  valueRef.current = value
@@ -58,16 +95,15 @@ export function SliderControl({
58
95
  if (isEditing) return
59
96
  e.preventDefault()
60
97
  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
98
+ const s = getAdjustedStep(step, e)
64
99
  const newValue = clamp(valueRef.current + direction * s)
65
100
  const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
66
101
  if (final !== valueRef.current) onChange(final)
102
+ onCommit?.(final)
67
103
  }
68
104
  el.addEventListener('wheel', handleWheel, { passive: false })
69
105
  return () => el.removeEventListener('wheel', handleWheel)
70
- }, [isEditing, step, clamp, onChange, precision])
106
+ }, [isEditing, step, clamp, onChange, onCommit])
71
107
 
72
108
  // Arrow key support while hovered
73
109
  useEffect(() => {
@@ -78,24 +114,28 @@ export function SliderControl({
78
114
  else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1
79
115
  if (direction !== 0) {
80
116
  e.preventDefault()
81
- let s = step
82
- if (e.shiftKey) s = step * 10
83
- else if (e.metaKey || e.ctrlKey) s = step * 0.1
117
+ const s = getAdjustedStep(step, e)
84
118
  const newValue = clamp(valueRef.current + direction * s)
85
119
  const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
86
120
  if (final !== valueRef.current) onChange(final)
121
+ onCommit?.(final)
87
122
  }
88
123
  }
89
124
  window.addEventListener('keydown', handleKeyDown)
90
125
  return () => window.removeEventListener('keydown', handleKeyDown)
91
- }, [isHovered, isEditing, step, clamp, onChange, precision])
126
+ }, [isHovered, isEditing, step, clamp, onChange, onCommit])
92
127
 
93
128
  const handleLabelPointerDown = useCallback(
94
129
  (e: React.PointerEvent<HTMLDivElement>) => {
95
130
  if (isEditing) return
96
131
  e.preventDefault()
97
132
  e.currentTarget.setPointerCapture(e.pointerId)
98
- 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
+ }
99
139
  setIsDragging(true)
100
140
  useScene.temporal.getState().pause()
101
141
  },
@@ -105,38 +145,52 @@ export function SliderControl({
105
145
  const handleLabelPointerMove = useCallback(
106
146
  (e: React.PointerEvent<HTMLDivElement>) => {
107
147
  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
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
113
162
  // 4 px per step at default sensitivity
114
163
  const newValue = clamp(
115
- Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
164
+ Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))),
116
165
  )
117
- onChange(newValue)
166
+ if (newValue !== valueRef.current) {
167
+ valueRef.current = newValue
168
+ onChange(newValue)
169
+ }
118
170
  },
119
- [step, precision, clamp, onChange],
171
+ [step, clamp, onChange],
120
172
  )
121
173
 
122
174
  const handleLabelPointerUp = useCallback(
123
175
  (e: React.PointerEvent<HTMLDivElement>) => {
124
176
  if (!dragRef.current) return
125
- const { startValue } = dragRef.current
177
+ const { originValue } = dragRef.current
126
178
  const finalVal = valueRef.current
127
179
  dragRef.current = null
128
180
  setIsDragging(false)
129
181
  e.currentTarget.releasePointerCapture(e.pointerId)
130
182
 
131
- if (startValue !== finalVal) {
132
- onChange(startValue)
183
+ if (originValue !== finalVal && restoreOnCommit) {
184
+ onChange(originValue)
133
185
  useScene.temporal.getState().resume()
134
186
  onChange(finalVal)
187
+ onCommit?.(finalVal)
135
188
  } else {
136
189
  useScene.temporal.getState().resume()
190
+ onCommit?.(finalVal)
137
191
  }
138
192
  },
139
- [onChange],
193
+ [onChange, onCommit, restoreOnCommit],
140
194
  )
141
195
 
142
196
  const handleValueClick = useCallback(() => {
@@ -149,10 +203,12 @@ export function SliderControl({
149
203
  if (Number.isNaN(numValue)) {
150
204
  setInputValue(value.toFixed(precision))
151
205
  } else {
152
- onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
206
+ const nextValue = clamp(Number.parseFloat(numValue.toFixed(precision)))
207
+ onChange(nextValue)
208
+ onCommit?.(nextValue)
153
209
  }
154
210
  setIsEditing(false)
155
- }, [inputValue, onChange, clamp, precision, value])
211
+ }, [inputValue, onChange, onCommit, clamp, precision, value])
156
212
 
157
213
  const handleInputKeyDown = useCallback(
158
214
  (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -163,12 +219,18 @@ export function SliderControl({
163
219
  setIsEditing(false)
164
220
  } else if (e.key === 'ArrowUp') {
165
221
  e.preventDefault()
166
- const newV = clamp(value + step)
222
+ const adjustedStep = getAdjustedStep(step, e)
223
+ const newV = clamp(
224
+ Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))),
225
+ )
167
226
  onChange(newV)
168
227
  setInputValue(newV.toFixed(precision))
169
228
  } else if (e.key === 'ArrowDown') {
170
229
  e.preventDefault()
171
- const newV = clamp(value - step)
230
+ const adjustedStep = getAdjustedStep(step, e)
231
+ const newV = clamp(
232
+ Number.parseFloat((value - adjustedStep).toFixed(stepPrecision(adjustedStep))),
233
+ )
172
234
  onChange(newV)
173
235
  setInputValue(newV.toFixed(precision))
174
236
  }