146 lines
4.1 KiB
TypeScript
146 lines
4.1 KiB
TypeScript
import { useRef, useState, useEffect, useCallback } from 'react'
|
|
import { useStore } from '@nanostores/react'
|
|
import { mappingMode } from '../stores/mappingMode'
|
|
|
|
interface KnobProps {
|
|
label: string
|
|
value: number
|
|
min: number
|
|
max: number
|
|
step: number
|
|
unit?: string
|
|
onChange: (value: number) => void
|
|
formatValue?: (id: string, value: number) => string
|
|
valueId?: string
|
|
size?: number
|
|
paramId?: string
|
|
onMapClick?: (paramId: string, activeLFO: number) => void
|
|
mappedLFOs?: number[]
|
|
}
|
|
|
|
export function Knob({
|
|
label,
|
|
value,
|
|
min,
|
|
max,
|
|
step,
|
|
unit,
|
|
onChange,
|
|
formatValue,
|
|
valueId,
|
|
size = 48,
|
|
paramId,
|
|
onMapClick,
|
|
mappedLFOs = []
|
|
}: KnobProps) {
|
|
const [isDragging, setIsDragging] = useState(false)
|
|
const startYRef = useRef<number>(0)
|
|
const startValueRef = useRef<number>(0)
|
|
const mappingModeState = useStore(mappingMode)
|
|
|
|
const displayValue = formatValue && valueId ? formatValue(valueId, value) : `${value}${unit || ''}`
|
|
const isInMappingMode = mappingModeState.isActive && paramId
|
|
const hasMappings = mappedLFOs.length > 0
|
|
|
|
const normalizedValue = (value - min) / (max - min)
|
|
const angle = -225 + normalizedValue * 270
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
if (isInMappingMode && paramId && mappingModeState.activeLFO !== null && onMapClick) {
|
|
onMapClick(paramId, mappingModeState.activeLFO)
|
|
e.preventDefault()
|
|
return
|
|
}
|
|
|
|
setIsDragging(true)
|
|
startYRef.current = e.clientY
|
|
startValueRef.current = value
|
|
e.preventDefault()
|
|
}
|
|
|
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
|
if (!isDragging) return
|
|
|
|
const deltaY = startYRef.current - e.clientY
|
|
const range = max - min
|
|
const sensitivity = range / 200
|
|
const newValue = Math.max(min, Math.min(max, startValueRef.current + deltaY * sensitivity))
|
|
|
|
const steppedValue = Math.round(newValue / step) * step
|
|
onChange(steppedValue)
|
|
}, [isDragging, max, min, step, onChange])
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
setIsDragging(false)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (isDragging) {
|
|
window.addEventListener('mousemove', handleMouseMove)
|
|
window.addEventListener('mouseup', handleMouseUp)
|
|
return () => {
|
|
window.removeEventListener('mousemove', handleMouseMove)
|
|
window.removeEventListener('mouseup', handleMouseUp)
|
|
}
|
|
}
|
|
}, [isDragging, handleMouseMove, handleMouseUp])
|
|
|
|
return (
|
|
<div className="relative flex flex-col items-center">
|
|
<div
|
|
className={`relative select-none ${isInMappingMode ? 'cursor-pointer' : 'cursor-ns-resize'}`}
|
|
onMouseDown={handleMouseDown}
|
|
style={{ width: size, height: size }}
|
|
>
|
|
<svg
|
|
width={size}
|
|
height={size}
|
|
viewBox={`0 0 ${size} ${size}`}
|
|
>
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={(size - 4) / 2}
|
|
fill="none"
|
|
stroke="white"
|
|
strokeWidth="2"
|
|
className={isInMappingMode ? 'animate-pulse' : ''}
|
|
/>
|
|
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={(size - 8) / 2}
|
|
fill="black"
|
|
/>
|
|
|
|
<line
|
|
x1={size / 2 + Math.cos((angle * Math.PI) / 180) * ((size - 16) / 2)}
|
|
y1={size / 2 + Math.sin((angle * Math.PI) / 180) * ((size - 16) / 2)}
|
|
x2={size / 2 + Math.cos((angle * Math.PI) / 180) * ((size - 4) / 2)}
|
|
y2={size / 2 + Math.sin((angle * Math.PI) / 180) * ((size - 4) / 2)}
|
|
stroke="white"
|
|
strokeWidth="2"
|
|
strokeLinecap="square"
|
|
/>
|
|
|
|
{hasMappings && (
|
|
<circle
|
|
cx={size / 2}
|
|
cy={8}
|
|
r={2}
|
|
fill="white"
|
|
/>
|
|
)}
|
|
</svg>
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
<span className={`font-mono text-[9px] tracking-[0.15em] text-white ${isInMappingMode ? 'animate-pulse' : ''}`}>
|
|
{isDragging ? displayValue : label.toUpperCase()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|