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>
244 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|