@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.
- package/package.json +13 -9
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +74 -5
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +24 -3
- package/src/components/editor/first-person/build-collider-world.ts +363 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +32 -55
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +9861 -3297
- package/src/components/editor/index.tsx +295 -32
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
- package/src/components/editor/thumbnail-generator.tsx +56 -68
- 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 +267 -36
- 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 +124 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -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 +10 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1 -1
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
- 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 +7 -0
- package/src/components/tools/door/move-door-tool.tsx +28 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
- package/src/components/tools/fence/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +160 -4
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
- package/src/components/tools/fence/move-fence-tool.tsx +111 -40
- package/src/components/tools/item/move-tool.tsx +7 -1
- package/src/components/tools/item/placement-math.ts +32 -5
- package/src/components/tools/item/placement-strategies.ts +110 -31
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +1 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
- package/src/components/tools/roof/move-roof-tool.tsx +29 -17
- package/src/components/tools/select/box-select-tool.tsx +12 -17
- package/src/components/tools/shared/polygon-editor.tsx +153 -28
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -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/tool-manager.tsx +20 -5
- package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
- package/src/components/tools/wall/move-wall-tool.tsx +6 -4
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +136 -4
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/tools/zone/zone-tool.tsx +20 -5
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +34 -1
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +98 -59
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +418 -41
- package/src/components/ui/command-palette/editor-commands.tsx +24 -5
- package/src/components/ui/command-palette/index.tsx +4 -255
- package/src/components/ui/controls/material-picker.tsx +154 -164
- package/src/components/ui/controls/slider-control.tsx +66 -18
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +10 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
- package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
- package/src/components/ui/level-duplicate-dialog.tsx +113 -0
- package/src/components/ui/panels/ceiling-panel.tsx +3 -28
- package/src/components/ui/panels/column-panel.tsx +759 -0
- package/src/components/ui/panels/door-panel.tsx +989 -290
- package/src/components/ui/panels/fence-panel.tsx +2 -49
- 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 +163 -0
- package/src/components/ui/panels/panel-manager.tsx +208 -28
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +253 -5
- package/src/components/ui/panels/roof-panel.tsx +13 -64
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
- package/src/components/ui/panels/slab-panel.tsx +4 -30
- package/src/components/ui/panels/spawn-panel.tsx +161 -0
- package/src/components/ui/panels/stair-panel.tsx +20 -74
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +10 -8
- package/src/components/ui/panels/window-panel.tsx +668 -139
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -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 +2 -2
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +0 -0
- package/src/components/viewer-zone-system.tsx +0 -0
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-auto-save.ts +14 -0
- package/src/hooks/use-keyboard.ts +74 -7
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +8 -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/level-duplication.test.ts +70 -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/scene.ts +0 -0
- package/src/lib/sfx-bus.ts +2 -0
- package/src/lib/sfx-player.ts +5 -5
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +186 -62
- package/tsconfig.json +2 -1
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- 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
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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<{
|
|
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 = {
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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((
|
|
164
|
+
Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))),
|
|
124
165
|
)
|
|
125
|
-
|
|
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 {
|
|
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 (
|
|
140
|
-
onChange(
|
|
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
|
-
|
|
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>) => {
|