@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.
- package/package.json +12 -7
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +29 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +281 -83
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +10442 -3275
- package/src/components/editor/index.tsx +270 -20
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +766 -12
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +350 -157
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +377 -58
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +19 -0
- package/src/components/tools/door/move-door-tool.tsx +38 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +27 -8
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
- package/src/components/tools/fence/move-fence-tool.tsx +102 -27
- package/src/components/tools/item/move-tool.tsx +19 -1
- package/src/components/tools/item/placement-math.ts +44 -7
- package/src/components/tools/item/placement-strategies.ts +111 -33
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
- package/src/components/tools/roof/move-roof-tool.tsx +111 -43
- package/src/components/tools/shared/polygon-editor.tsx +244 -29
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +30 -3
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +348 -17
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +28 -0
- package/src/components/tools/window/window-tool.tsx +17 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +37 -5
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +27 -5
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +189 -169
- package/src/components/ui/controls/slider-control.tsx +88 -26
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +47 -27
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +986 -294
- package/src/components/ui/panels/fence-panel.tsx +55 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +241 -30
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +243 -9
- package/src/components/ui/panels/roof-panel.tsx +30 -62
- package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
- package/src/components/ui/panels/slab-panel.tsx +46 -24
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +117 -69
- package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
- package/src/components/ui/panels/wall-panel.tsx +71 -17
- package/src/components/ui/panels/window-panel.tsx +665 -146
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +96 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +67 -9
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +2 -1
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/history.ts +20 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- 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 {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
+
selectedMaterialPreset?: string
|
|
17
|
+
onChange?: (material: MaterialSchema) => void
|
|
18
|
+
onSelectMaterialPreset?: (materialPreset: string) => void
|
|
19
|
+
disabled?: boolean
|
|
35
20
|
}
|
|
36
21
|
|
|
37
|
-
export function MaterialPicker({
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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=
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
<
|
|
83
|
-
className=
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
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<{
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 = {
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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((
|
|
164
|
+
Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))),
|
|
116
165
|
)
|
|
117
|
-
|
|
166
|
+
if (newValue !== valueRef.current) {
|
|
167
|
+
valueRef.current = newValue
|
|
168
|
+
onChange(newValue)
|
|
169
|
+
}
|
|
118
170
|
},
|
|
119
|
-
[step,
|
|
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 {
|
|
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 (
|
|
132
|
-
onChange(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|