Update Vite configuration to deduplicate React and React-DOM; enhance DeviceContext with syncPeqCatalog method for improved PEQ state management; refactor LuxsinAPI to streamline PEQ filter updates; replace range input with ThumbOnlyRangeSlider in Home component for better user experience.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRef, type CSSProperties } from "react";
|
||||
|
||||
/** Matches .cyan-slider thumb (~17.6px); generous hit area for touch. */
|
||||
const THUMB_HIT_RADIUS_PX = 24;
|
||||
|
||||
function valueFromClientX(input: HTMLInputElement, clientX: number): number {
|
||||
const rect = input.getBoundingClientRect();
|
||||
const min = Number(input.min);
|
||||
const max = Number(input.max);
|
||||
if (rect.width <= 0) return Number(input.value);
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
return Math.round(min + ratio * (max - min));
|
||||
}
|
||||
|
||||
function isPointerOnThumb(input: HTMLInputElement, clientX: number): boolean {
|
||||
const rect = input.getBoundingClientRect();
|
||||
const min = Number(input.min);
|
||||
const max = Number(input.max);
|
||||
const value = Number(input.value);
|
||||
const span = max - min;
|
||||
const ratio = span > 0 ? (value - min) / span : 0;
|
||||
const thumbCenterX = rect.left + ratio * rect.width;
|
||||
return Math.abs(clientX - thumbCenterX) <= THUMB_HIT_RADIUS_PX;
|
||||
}
|
||||
|
||||
const RELEASE_KEYS = [
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
] as const;
|
||||
|
||||
export interface ThumbOnlyRangeSliderProps {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
onChange: (value: number) => void;
|
||||
/** Fires when the user releases the thumb after a drag or keyboard adjust. */
|
||||
onRelease?: (value: number) => void;
|
||||
onDragStart?: () => void;
|
||||
onDragEnd?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Range slider that ignores track clicks/taps — only the thumb can be dragged.
|
||||
* Prevents accidental jumps to high volume when missing the thumb.
|
||||
*/
|
||||
export default function ThumbOnlyRangeSlider({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
disabled = false,
|
||||
className,
|
||||
style,
|
||||
onChange,
|
||||
onRelease,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}: ThumbOnlyRangeSliderProps) {
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
const emitRelease = (el: EventTarget | null) => {
|
||||
if (disabled) return;
|
||||
if (!(el instanceof HTMLInputElement)) return;
|
||||
onRelease?.(Number(el.value));
|
||||
onDragEnd?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
className={cn("cyan-slider relative z-10", className)}
|
||||
style={{ touchAction: "none", ...style }}
|
||||
onChange={(e) => {
|
||||
if (disabled || draggingRef.current) return;
|
||||
onChange(Number(e.target.value));
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (disabled) return;
|
||||
const input = e.currentTarget;
|
||||
if (!isPointerOnThumb(input, e.clientX)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
draggingRef.current = true;
|
||||
onDragStart?.();
|
||||
input.setPointerCapture(e.pointerId);
|
||||
onChange(valueFromClientX(input, e.clientX));
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (disabled || !draggingRef.current) return;
|
||||
onChange(valueFromClientX(e.currentTarget, e.clientX));
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
emitRelease(e.currentTarget);
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
emitRelease(e.currentTarget);
|
||||
}}
|
||||
onLostPointerCapture={(e) => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
emitRelease(e.currentTarget);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (draggingRef.current) {
|
||||
draggingRef.current = false;
|
||||
emitRelease(e.currentTarget);
|
||||
return;
|
||||
}
|
||||
if (!disabled) {
|
||||
onRelease?.(Number(e.currentTarget.value));
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (disabled) return;
|
||||
if (RELEASE_KEYS.includes(e.key as (typeof RELEASE_KEYS)[number])) {
|
||||
emitRelease(e.currentTarget);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user