feat(slider): implement infinity dragging using PointerLock API (#206)

Slider controls now use the Pointer Lock API for drag interactions. When dragging a slider label, the cursor locks and movement continues infinitely past the screen edge, matching Unity3D and other professional 3D editors.

- requestPointerLock on drag start, movementX accumulation, exitPointerLock on release
- Falls back to setPointerCapture if PointerLock unavailable
- ESC during drag handled via pointerlockchange listener
- Shift/Ctrl step multipliers preserved
- Undo/redo temporal state correctly managed on all exit paths

Closes #204
This commit is contained in:
Clay
2026-04-02 11:26:23 -04:00
committed by GitHub
parent 50eca057ca
commit 08f93f527a
@@ -32,7 +32,7 @@ export function SliderControl({
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
const [inputValue, setInputValue] = useState(value.toFixed(precision)) const [inputValue, setInputValue] = useState(value.toFixed(precision))
const dragRef = useRef<{ startX: number; startValue: number } | null>(null) const dragRef = useRef<{ accumulatedDx: number; startValue: number } | null>(null)
const labelRef = useRef<HTMLDivElement>(null) const labelRef = useRef<HTMLDivElement>(null)
const valueRef = useRef(value) const valueRef = useRef(value)
valueRef.current = value valueRef.current = value
@@ -89,8 +89,15 @@ export function SliderControl({
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
if (isEditing) return if (isEditing) return
e.preventDefault() e.preventDefault()
e.currentTarget.setPointerCapture(e.pointerId) // Use PointerLock for infinite dragging (Unity3D-style).
dragRef.current = { startX: e.clientX, startValue: valueRef.current } // Falls back to pointer capture if lock is denied.
const el = e.currentTarget
if (el.requestPointerLock) {
el.requestPointerLock()
} else {
el.setPointerCapture(e.pointerId)
}
dragRef.current = { accumulatedDx: 0, startValue: valueRef.current }
setIsDragging(true) setIsDragging(true)
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
}, },
@@ -100,13 +107,15 @@ export function SliderControl({
const handleLabelPointerMove = useCallback( const handleLabelPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return if (!dragRef.current) return
const { startX, startValue } = dragRef.current // Accumulate movementX for infinite dragging. movementX gives the
const dx = e.clientX - startX // delta since the last event, independent of screen bounds.
dragRef.current.accumulatedDx += e.movementX
const { accumulatedDx, startValue } = dragRef.current
let s = step let s = step
if (e.shiftKey) s = step * 10 if (e.shiftKey) s = step * 10
else if (e.metaKey || e.ctrlKey) s = step * 0.1 else if (e.metaKey || e.ctrlKey) s = step * 0.1
// 4 px per step at default sensitivity // 4 px per step at default sensitivity
const newValue = clamp(Number.parseFloat((startValue + (dx / 4) * s).toFixed(precision))) const newValue = clamp(Number.parseFloat((startValue + (accumulatedDx / 4) * s).toFixed(precision)))
onChange(newValue) onChange(newValue)
}, },
[step, precision, clamp, onChange], [step, precision, clamp, onChange],
@@ -119,7 +128,12 @@ export function SliderControl({
const finalVal = valueRef.current const finalVal = valueRef.current
dragRef.current = null dragRef.current = null
setIsDragging(false) setIsDragging(false)
e.currentTarget.releasePointerCapture(e.pointerId)
if (document.pointerLockElement) {
document.exitPointerLock()
} else {
e.currentTarget.releasePointerCapture(e.pointerId)
}
if (startValue !== finalVal) { if (startValue !== finalVal) {
onChange(startValue) onChange(startValue)
@@ -132,6 +146,28 @@ export function SliderControl({
[onChange], [onChange],
) )
// Clean up drag state if pointer lock is lost unexpectedly (e.g. Escape key)
useEffect(() => {
const handlePointerLockChange = () => {
if (!document.pointerLockElement && dragRef.current) {
const { startValue } = dragRef.current
const finalVal = valueRef.current
dragRef.current = null
setIsDragging(false)
if (startValue !== finalVal) {
onChange(startValue)
useScene.temporal.getState().resume()
onChange(finalVal)
} else {
useScene.temporal.getState().resume()
}
}
}
document.addEventListener('pointerlockchange', handlePointerLockChange)
return () => document.removeEventListener('pointerlockchange', handlePointerLockChange)
}, [onChange])
const handleValueClick = useCallback(() => { const handleValueClick = useCallback(() => {
setIsEditing(true) setIsEditing(true)
setInputValue(value.toFixed(precision)) setInputValue(value.toFixed(precision))