ui tweaks and performance tweaks
This commit is contained in:
parent
42197bfbc9
commit
3aeff0a7e7
@ -1,9 +1,5 @@
|
|||||||
.panel {
|
.panel {
|
||||||
position: fixed;
|
width: 340px;
|
||||||
right: 16px;
|
|
||||||
top: 60px;
|
|
||||||
z-index: 600;
|
|
||||||
width: 280px;
|
|
||||||
background: rgba(8, 8, 12, 0.84);
|
background: rgba(8, 8, 12, 0.84);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(10px);
|
||||||
@ -57,7 +53,7 @@
|
|||||||
.stripRow {
|
.stripRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 5px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@ -66,37 +62,17 @@
|
|||||||
color: rgba(245, 158, 11, 0.9);
|
color: rgba(245, 158, 11, 0.9);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-width: 44px;
|
min-width: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stripRange {
|
.rangeSep {
|
||||||
color: rgba(255, 255, 255, 0.38);
|
|
||||||
font-size: 11px;
|
|
||||||
min-width: 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accelInput {
|
|
||||||
width: 50px;
|
|
||||||
background: rgba(255, 255, 255, 0.07);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 2px 5px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.accelInput:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: rgba(245, 158, 11, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stripUnit {
|
|
||||||
color: rgba(255, 255, 255, 0.28);
|
color: rgba(255, 255, 255, 0.28);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stripDelete {
|
.stripDelete {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import type { AccelerationStrip } from '../types/api'
|
import type { AccelerationStrip } from '../types/api'
|
||||||
|
import { BlenderInput } from '../ui/BlenderInput'
|
||||||
import styles from './AccelerationStripsPanel.module.css'
|
import styles from './AccelerationStripsPanel.module.css'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
strips: AccelerationStrip[]
|
strips: AccelerationStrip[]
|
||||||
onRemoveStrip: (id: string) => void
|
onRemoveStrip: (id: string) => void
|
||||||
onUpdateStrip: (id: string, accel_ms2: number) => void
|
onUpdateStrip: (id: string, accel_ms2: number) => void
|
||||||
|
onUpdateStripFrac: (id: string, startFrac: number, endFrac: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function pct(v: number) { return `${(v * 100).toFixed(0)}%` }
|
export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip, onUpdateStripFrac }: Props) {
|
||||||
|
|
||||||
export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip }: Props) {
|
|
||||||
const [open, setOpen] = useState(true)
|
const [open, setOpen] = useState(true)
|
||||||
|
|
||||||
// Auto-open when a strip is added
|
// Auto-open when a strip is added
|
||||||
@ -31,19 +31,45 @@ export function AccelerationStripsPanel({ strips, onRemoveStrip, onUpdateStrip }
|
|||||||
: strips.map((s, i) => (
|
: strips.map((s, i) => (
|
||||||
<div key={s.id} className={styles.stripRow}>
|
<div key={s.id} className={styles.stripRow}>
|
||||||
<span className={styles.stripIndex}>Strip {i + 1}</span>
|
<span className={styles.stripIndex}>Strip {i + 1}</span>
|
||||||
<span className={styles.stripRange}>
|
|
||||||
{pct(s.startFrac)}–{pct(s.endFrac)}
|
<BlenderInput
|
||||||
</span>
|
value={s.startFrac * 100}
|
||||||
<input
|
onChange={v => {
|
||||||
type="number"
|
const sf = Math.max(0, Math.min(v / 100, s.endFrac - 0.001))
|
||||||
step={0.5}
|
onUpdateStripFrac(s.id, sf, s.endFrac)
|
||||||
|
}}
|
||||||
|
min={0}
|
||||||
|
max={s.endFrac * 100 - 0.1}
|
||||||
|
step={0.2}
|
||||||
|
decimals={1}
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className={styles.rangeSep}>–</span>
|
||||||
|
|
||||||
|
<BlenderInput
|
||||||
|
value={s.endFrac * 100}
|
||||||
|
onChange={v => {
|
||||||
|
const ef = Math.min(100, Math.max(v / 100, s.startFrac + 0.001))
|
||||||
|
onUpdateStripFrac(s.id, s.startFrac, ef)
|
||||||
|
}}
|
||||||
|
min={s.startFrac * 100 + 0.1}
|
||||||
|
max={100}
|
||||||
|
step={0.2}
|
||||||
|
decimals={1}
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BlenderInput
|
||||||
|
value={s.accel_ms2}
|
||||||
|
onChange={v => onUpdateStrip(s.id, v)}
|
||||||
min={-50}
|
min={-50}
|
||||||
max={50}
|
max={50}
|
||||||
value={s.accel_ms2}
|
step={0.1}
|
||||||
onChange={e => onUpdateStrip(s.id, Number(e.target.value))}
|
decimals={1}
|
||||||
className={styles.accelInput}
|
suffix="m/s²"
|
||||||
/>
|
/>
|
||||||
<span className={styles.stripUnit}>m/s²</span>
|
|
||||||
<button
|
<button
|
||||||
className={styles.stripDelete}
|
className={styles.stripDelete}
|
||||||
onClick={() => onRemoveStrip(s.id)}
|
onClick={() => onRemoveStrip(s.id)}
|
||||||
|
|||||||
@ -516,6 +516,20 @@
|
|||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Right panel column ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.rightColumn {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
top: 60px;
|
||||||
|
z-index: 600;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: calc(100vh - 76px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Loading overlay ─────────────────────────────────────────────────────────── */
|
/* ── Loading overlay ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
|
|||||||
@ -118,10 +118,12 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
|||||||
// Exit ride mode whenever the sim result changes; clear cursor on exit
|
// Exit ride mode whenever the sim result changes; clear cursor on exit
|
||||||
useEffect(() => { setIsRideMode(false); setRideCursor(null) }, [simResult])
|
useEffect(() => { setIsRideMode(false); setRideCursor(null) }, [simResult])
|
||||||
|
|
||||||
// Suspend Cesium's render loop while Three.js ride view is active so both
|
// Suspend Cesium while Three.js ride view is active: stop the render loop AND
|
||||||
// renderers don't compete for the GPU simultaneously.
|
// disable the globe so Cesium's terrain web-workers stop posting decode results
|
||||||
|
// back to the main thread (those async completions caused periodic ~5 s spikes).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
viewer.useDefaultRenderLoop = !isRideMode
|
viewer.useDefaultRenderLoop = !isRideMode
|
||||||
|
viewer.scene.globe.enabled = !isRideMode
|
||||||
}, [isRideMode, viewer])
|
}, [isRideMode, viewer])
|
||||||
|
|
||||||
// Refs for simulation result entities (cleared on each new run / unmount)
|
// Refs for simulation result entities (cleared on each new run / unmount)
|
||||||
@ -135,10 +137,12 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
|||||||
const coords = challenge.region.coordinates[0]
|
const coords = challenge.region.coordinates[0]
|
||||||
const positions = coords.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat))
|
const positions = coords.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat))
|
||||||
const sphere = Cesium.BoundingSphere.fromPoints(positions, new Cesium.BoundingSphere())
|
const sphere = Cesium.BoundingSphere.fromPoints(positions, new Cesium.BoundingSphere())
|
||||||
sphere.radius = Math.max(sphere.radius + 20, 50)
|
// Add 25 % padding so the polygon doesn't butt right up against the frame
|
||||||
|
// edges, then let Cesium compute the exact range (range = 0 = auto-fit).
|
||||||
|
const padded = new Cesium.BoundingSphere(sphere.center, sphere.radius * 1.25)
|
||||||
|
|
||||||
viewer.camera.flyToBoundingSphere(sphere, {
|
viewer.camera.flyToBoundingSphere(padded, {
|
||||||
offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-78), sphere.radius * 3),
|
offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-60), 0),
|
||||||
duration: 1.2,
|
duration: 1.2,
|
||||||
})
|
})
|
||||||
}, [challenge, viewer])
|
}, [challenge, viewer])
|
||||||
@ -324,7 +328,6 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
|||||||
simResult={simResult}
|
simResult={simResult}
|
||||||
captureData={terrain.captureData}
|
captureData={terrain.captureData}
|
||||||
onStop={() => { setIsRideMode(false); setRideCursor(null) }}
|
onStop={() => { setIsRideMode(false); setRideCursor(null) }}
|
||||||
onRideProgress={setRideCursor}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -496,7 +499,7 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Simulation profile charts (left panel) ──────────────────────── */}
|
{/* ── Simulation profile charts (left panel) ──────────────────────── */}
|
||||||
{simResult?.profile && (
|
{!isRideMode && simResult?.profile && (
|
||||||
<SimulationPlots
|
<SimulationPlots
|
||||||
profile={simResult.profile}
|
profile={simResult.profile}
|
||||||
strips={accel.strips}
|
strips={accel.strips}
|
||||||
@ -504,16 +507,17 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Acceleration strips (right panel) ───────────────────────────── */}
|
{/* ── Right panel column (acceleration strips + coaster list) ────── */}
|
||||||
|
{!isRideMode && (
|
||||||
|
<div className={styles.rightColumn}>
|
||||||
{accel.strips.length > 0 && (
|
{accel.strips.length > 0 && (
|
||||||
<AccelerationStripsPanel
|
<AccelerationStripsPanel
|
||||||
strips={accel.strips}
|
strips={accel.strips}
|
||||||
onRemoveStrip={accel.removeStrip}
|
onRemoveStrip={accel.removeStrip}
|
||||||
onUpdateStrip={accel.updateStrip}
|
onUpdateStrip={accel.updateStrip}
|
||||||
|
onUpdateStripFrac={accel.updateStripFrac}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Coaster list panel (right) ───────────────────────────────────── */}
|
|
||||||
{challengeId && (
|
{challengeId && (
|
||||||
<CoasterListPanel
|
<CoasterListPanel
|
||||||
challengeId={challengeId}
|
challengeId={challengeId}
|
||||||
@ -522,6 +526,8 @@ function CoasterEditorScene({ challengeId, challenge, readonly = false, preloadC
|
|||||||
refreshKey={coasterListKey}
|
refreshKey={coasterListKey}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Loading overlay ──────────────────────────────────────────────── */}
|
{/* ── Loading overlay ──────────────────────────────────────────────── */}
|
||||||
{!challenge && <div className={styles.loading}>Loading challenge…</div>}
|
{!challenge && <div className={styles.loading}>Loading challenge…</div>}
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
.panel {
|
.panel {
|
||||||
position: fixed;
|
|
||||||
right: 16px;
|
|
||||||
top: 112px; /* 52px header + ~44px topBar + 16px gap */
|
|
||||||
z-index: 200;
|
|
||||||
width: 240px;
|
width: 240px;
|
||||||
background: rgba(8, 8, 12, 0.84);
|
background: rgba(8, 8, 12, 0.84);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
|
|||||||
@ -249,9 +249,20 @@ function buildScene(captureData: TerrainCaptureData[], rideData: RideData, rende
|
|||||||
addRail(rideData.rail1)
|
addRail(rideData.rail1)
|
||||||
addRail(rideData.rail2)
|
addRail(rideData.rail2)
|
||||||
|
|
||||||
|
// Expose a warm-up helper: makes every terrain patch visible so the caller can
|
||||||
|
// do one render that forces the GPU driver to flush all queued texImage2D /
|
||||||
|
// generateMipmap commands for every patch at once. Without this, those
|
||||||
|
// commands are deferred and cause a GPU pipeline stall the first time each
|
||||||
|
// patch becomes the active (visible) one during the ride — which at high
|
||||||
|
// coaster speeds can be every few seconds.
|
||||||
|
function warmUpPatches() {
|
||||||
|
terrainMeshes.forEach(m => { m.visible = true })
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scene,
|
scene,
|
||||||
setActivePatch,
|
setActivePatch,
|
||||||
|
warmUpPatches,
|
||||||
disposeAll: () => {
|
disposeAll: () => {
|
||||||
geos.forEach(g => g.dispose())
|
geos.forEach(g => g.dispose())
|
||||||
mats.forEach(m => m.dispose())
|
mats.forEach(m => m.dispose())
|
||||||
@ -322,7 +333,7 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
|
|||||||
rideDataRef.current = rideData
|
rideDataRef.current = rideData
|
||||||
setTotalDuration(rideData.totalDuration)
|
setTotalDuration(rideData.totalDuration)
|
||||||
|
|
||||||
const { scene, setActivePatch, disposeAll } = buildScene(captureData, rideData, renderer)
|
const { scene, setActivePatch, warmUpPatches, disposeAll } = buildScene(captureData, rideData, renderer)
|
||||||
sceneRef.current = scene
|
sceneRef.current = scene
|
||||||
setActivePatchRef.current = setActivePatch
|
setActivePatchRef.current = setActivePatch
|
||||||
activePatchIdxRef.current = 0
|
activePatchIdxRef.current = 0
|
||||||
@ -345,7 +356,13 @@ export function RideRenderer({ simResult, captureData, onStop, onRideProgress }:
|
|||||||
cameraRef.current.updateMatrixWorld()
|
cameraRef.current.updateMatrixWorld()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warm-up: make every terrain patch visible and render once. This forces the
|
||||||
|
// GPU driver to flush all pending texImage2D / generateMipmap commands for
|
||||||
|
// every patch right now, so patch-visibility switches during the ride have
|
||||||
|
// zero GPU stall cost. Then reset to only the first patch.
|
||||||
|
warmUpPatches()
|
||||||
renderer.render(scene, cameraRef.current)
|
renderer.render(scene, cameraRef.current)
|
||||||
|
setActivePatch(0)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', onResize)
|
window.removeEventListener('resize', onResize)
|
||||||
|
|||||||
@ -43,6 +43,7 @@ export interface AccelerationStripsHandle {
|
|||||||
strips: AccelerationStrip[]
|
strips: AccelerationStrip[]
|
||||||
removeStrip: (id: string) => void
|
removeStrip: (id: string) => void
|
||||||
updateStrip: (id: string, accel_ms2: number) => void
|
updateStrip: (id: string, accel_ms2: number) => void
|
||||||
|
updateStripFrac: (id: string, startFrac: number, endFrac: number) => void
|
||||||
clearStrips: () => void
|
clearStrips: () => void
|
||||||
loadStrips: (strips: AccelerationStrip[]) => void
|
loadStrips: (strips: AccelerationStrip[]) => void
|
||||||
}
|
}
|
||||||
@ -75,6 +76,10 @@ export function useAccelerationStrips(
|
|||||||
setStrips(prev => prev.map(s => s.id === id ? { ...s, accel_ms2 } : s))
|
setStrips(prev => prev.map(s => s.id === id ? { ...s, accel_ms2 } : s))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const updateStripFrac = useCallback((id: string, startFrac: number, endFrac: number) => {
|
||||||
|
setStrips(prev => prev.map(s => s.id === id ? { ...s, startFrac, endFrac } : s))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const clearStrips = useCallback(() => {
|
const clearStrips = useCallback(() => {
|
||||||
setStrips([])
|
setStrips([])
|
||||||
}, [])
|
}, [])
|
||||||
@ -236,5 +241,5 @@ export function useAccelerationStrips(
|
|||||||
}
|
}
|
||||||
}, [viewer])
|
}, [viewer])
|
||||||
|
|
||||||
return { strips, removeStrip, updateStrip, clearStrips, loadStrips }
|
return { strips, removeStrip, updateStrip, updateStripFrac, clearStrips, loadStrips }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -207,7 +207,7 @@ export function useTerrainCapture(
|
|||||||
|
|
||||||
if (!simResult) {
|
if (!simResult) {
|
||||||
setStatus('idle')
|
setStatus('idle')
|
||||||
return
|
return () => { abortRef.current = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
abortRef.current = false
|
abortRef.current = false
|
||||||
@ -224,6 +224,10 @@ export function useTerrainCapture(
|
|||||||
console.error('Terrain capture failed:', err)
|
console.error('Terrain capture failed:', err)
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortRef.current = true
|
||||||
|
}
|
||||||
}, [simResult, viewer])
|
}, [simResult, viewer])
|
||||||
|
|
||||||
return { status, captureData }
|
return { status, captureData }
|
||||||
|
|||||||
50
web/src/ui/BlenderInput.module.css
Normal file
50
web/src/ui/BlenderInput.module.css
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
.root {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: ew-resize;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 6px;
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: right;
|
||||||
|
justify-content: flex-end;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: border-color 0.12s, background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root:hover .display {
|
||||||
|
background: rgba(255, 255, 255, 0.11);
|
||||||
|
border-color: rgba(245, 158, 11, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suffix {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 64px;
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.6);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 6px;
|
||||||
|
outline: none;
|
||||||
|
cursor: text;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
112
web/src/ui/BlenderInput.tsx
Normal file
112
web/src/ui/BlenderInput.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { useRef, useState, useCallback, useEffect } from 'react'
|
||||||
|
import styles from './BlenderInput.module.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: number
|
||||||
|
onChange: (v: number) => void
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
/** How much the value changes per pixel dragged. Default 0.1 */
|
||||||
|
step?: number
|
||||||
|
/** Decimal places shown. Default 1 */
|
||||||
|
decimals?: number
|
||||||
|
/** Optional suffix shown after the number, e.g. "%" or "m/s²" */
|
||||||
|
suffix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD = 4 // px before we treat motion as a drag
|
||||||
|
|
||||||
|
function clamp(v: number, min: number | undefined, max: number | undefined) {
|
||||||
|
if (min !== undefined && v < min) return min
|
||||||
|
if (max !== undefined && v > max) return max
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlenderInput({ value, onChange, min, max, step = 0.1, decimals = 1, suffix }: Props) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const dragState = useRef<{
|
||||||
|
startX: number
|
||||||
|
startValue: number
|
||||||
|
moved: boolean
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
// ── Focus the real input when we enter edit mode ────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) {
|
||||||
|
inputRef.current?.select()
|
||||||
|
}
|
||||||
|
}, [editing])
|
||||||
|
|
||||||
|
// ── Mouse-down: begin drag tracking ────────────────────────────────────────
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (editing) return
|
||||||
|
e.preventDefault()
|
||||||
|
dragState.current = { startX: e.clientX, startValue: value, moved: false }
|
||||||
|
|
||||||
|
function onMove(me: MouseEvent) {
|
||||||
|
if (!dragState.current) return
|
||||||
|
const dx = me.clientX - dragState.current.startX
|
||||||
|
if (Math.abs(dx) >= DRAG_THRESHOLD) {
|
||||||
|
dragState.current.moved = true
|
||||||
|
const next = clamp(
|
||||||
|
dragState.current.startValue + dx * step,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
)
|
||||||
|
onChange(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp() {
|
||||||
|
if (!dragState.current) return
|
||||||
|
if (!dragState.current.moved) {
|
||||||
|
// treat as a click → enter edit mode
|
||||||
|
setText(value.toFixed(decimals))
|
||||||
|
setEditing(true)
|
||||||
|
}
|
||||||
|
dragState.current = null
|
||||||
|
window.removeEventListener('mousemove', onMove)
|
||||||
|
window.removeEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', onMove)
|
||||||
|
window.addEventListener('mouseup', onUp)
|
||||||
|
}, [editing, value, step, min, max, decimals, onChange])
|
||||||
|
|
||||||
|
// ── Commit text input ───────────────────────────────────────────────────────
|
||||||
|
const commit = useCallback(() => {
|
||||||
|
const parsed = parseFloat(text)
|
||||||
|
if (!isNaN(parsed)) {
|
||||||
|
onChange(clamp(parsed, min, max))
|
||||||
|
}
|
||||||
|
setEditing(false)
|
||||||
|
}, [text, min, max, onChange])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') commit()
|
||||||
|
if (e.key === 'Escape') setEditing(false)
|
||||||
|
}, [commit])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={styles.root} onMouseDown={handleMouseDown}>
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className={styles.input}
|
||||||
|
value={text}
|
||||||
|
onChange={e => setText(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className={styles.display}>
|
||||||
|
{value.toFixed(decimals)}
|
||||||
|
{suffix && <span className={styles.suffix}>{suffix}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user