Files
audio-ui/components/ui/CircularKnob.tsx
Sebastian Krüger 418c79d961 feat: add touch event support to knobs and faders
Added comprehensive touch event handling for mobile/tablet support:

CircularKnob.tsx:
- Added handleTouchStart, handleTouchMove, handleTouchEnd handlers
- Touch events use same drag logic as mouse events
- Prevents default to avoid scrolling while adjusting

TrackFader.tsx:
- Added touch event handlers for vertical fader control
- Integrated with existing onTouchStart/onTouchEnd callbacks
- Supports touch-based automation recording

MasterFader.tsx:
- Added touch event handlers matching TrackFader
- Complete touch support for master volume control

All components now work seamlessly on touch-enabled devices.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 01:19:03 +01:00

244 lines
6.6 KiB
TypeScript

'use client';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface CircularKnobProps {
value: number; // -1.0 to 1.0 for pan
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
size?: number;
className?: string;
label?: string;
formatValue?: (value: number) => string;
onTouchStart?: () => void;
onTouchEnd?: () => void;
}
export function CircularKnob({
value,
onChange,
min = -1,
max = 1,
step = 0.01,
size = 48,
className,
label,
formatValue,
onTouchStart,
onTouchEnd,
}: CircularKnobProps) {
const knobRef = React.useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = React.useState(false);
const dragStartRef = React.useRef({ x: 0, y: 0, value: 0 });
const updateValue = React.useCallback(
(clientX: number, clientY: number) => {
if (!knobRef.current) return;
const rect = knobRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// Calculate vertical drag distance from start
const deltaY = dragStartRef.current.y - clientY;
const sensitivity = 200; // pixels for full range
const range = max - min;
const delta = (deltaY / sensitivity) * range;
let newValue = dragStartRef.current.value + delta;
// Snap to step
if (step) {
newValue = Math.round(newValue / step) * step;
}
// Clamp to range
newValue = Math.max(min, Math.min(max, newValue));
onChange(newValue);
},
[min, max, step, onChange]
);
const handleMouseDown = React.useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
value,
};
onTouchStart?.();
},
[value, onTouchStart]
);
const handleMouseMove = React.useCallback(
(e: MouseEvent) => {
if (isDragging) {
updateValue(e.clientX, e.clientY);
}
},
[isDragging, updateValue]
);
const handleMouseUp = React.useCallback(() => {
setIsDragging(false);
onTouchEnd?.();
}, [onTouchEnd]);
const handleTouchStart = React.useCallback(
(e: React.TouchEvent) => {
e.preventDefault();
const touch = e.touches[0];
setIsDragging(true);
dragStartRef.current = {
x: touch.clientX,
y: touch.clientY,
value,
};
onTouchStart?.();
},
[value, onTouchStart]
);
const handleTouchMove = React.useCallback(
(e: TouchEvent) => {
if (isDragging && e.touches.length > 0) {
const touch = e.touches[0];
updateValue(touch.clientX, touch.clientY);
}
},
[isDragging, updateValue]
);
const handleTouchEnd = React.useCallback(() => {
setIsDragging(false);
onTouchEnd?.();
}, [onTouchEnd]);
React.useEffect(() => {
if (isDragging) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('touchend', handleTouchEnd);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
};
}
}, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
// Calculate rotation angle (-135deg to 135deg, 270deg range)
const percentage = (value - min) / (max - min);
const angle = -135 + percentage * 270;
const displayValue = formatValue
? formatValue(value)
: value === 0
? 'C'
: value < 0
? `L${Math.abs(Math.round(value * 100))}`
: `R${Math.round(value * 100)}`;
// Calculate arc parameters for center-based rendering
const isNearCenter = Math.abs(value) < 0.01;
const centerPercentage = 0.5; // Center position (50%)
// Arc goes from center to current value
let arcStartPercentage: number;
let arcLength: number;
if (value < -0.01) {
// Left side: arc from value to center
arcStartPercentage = percentage;
arcLength = centerPercentage - percentage;
} else if (value > 0.01) {
// Right side: arc from center to value
arcStartPercentage = centerPercentage;
arcLength = percentage - centerPercentage;
} else {
// Center: no arc
arcStartPercentage = centerPercentage;
arcLength = 0;
}
return (
<div className={cn('flex flex-col items-center gap-1', className)}>
{label && (
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">
{label}
</div>
)}
<div
ref={knobRef}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
className="relative cursor-pointer select-none"
style={{ width: size, height: size }}
>
{/* Outer ring */}
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="absolute inset-0"
>
{/* Background arc */}
<circle
cx={size / 2}
cy={size / 2}
r={size / 2 - 4}
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-muted/30"
/>
{/* Value arc - only show when not centered */}
{!isNearCenter && (
<circle
cx={size / 2}
cy={size / 2}
r={size / 2 - 4}
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
className="text-primary"
strokeDasharray={`${(arcLength * 270 * Math.PI * (size / 2 - 4)) / 180} ${(Math.PI * 2 * (size / 2 - 4))}`}
transform={`rotate(${-225 + arcStartPercentage * 270} ${size / 2} ${size / 2})`}
/>
)}
</svg>
{/* Knob body */}
<div
className="absolute inset-0 rounded-full bg-card border-2 border-border shadow-sm flex items-center justify-center transition-transform hover:scale-105 active:scale-95"
style={{
transform: `rotate(${angle}deg)`,
margin: '4px',
}}
>
{/* Indicator line */}
<div className="absolute top-1 left-1/2 w-0.5 h-2 bg-primary rounded-full -translate-x-1/2" />
</div>
</div>
{/* Value Display */}
<div className="text-[10px] font-medium text-foreground min-w-[32px] text-center">
{displayValue}
</div>
</div>
);
}