feat(slider): 添加仅可拖动滑块的自定义音量滑块组件
- 新增 ThumbOnlyRangeSlider 组件,禁用轨道点击,仅支持滑块拖动 - 避免触摸时点击轨道导致音量跳变问题 - 在 Home 页面替换旧的音量输入滑块为新组件 - 移除旧音量滑块相关多余的引用和函数 - 支持拖动开始、结束和释放回调以优化操作逻辑 - 优化了 BrandDrawer 组件的样式以支持不同单位的视口高度显示
This commit is contained in:
@@ -0,0 +1,162 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useRef, type CSSProperties } from "react";
|
||||||
|
|
||||||
|
/** Generous hit area around the thumb for touch. */
|
||||||
|
const THUMB_HIT_RADIUS_PX = 24;
|
||||||
|
/** Half of the .cyan-slider thumb (~17.6-18px wide); the thumb center never reaches the track ends. */
|
||||||
|
const THUMB_HALF_WIDTH_PX = 9;
|
||||||
|
|
||||||
|
function thumbCenterXPx(trackWidth: number, min: number, max: number, value: number): number {
|
||||||
|
const span = max - min;
|
||||||
|
const ratio = span > 0 ? (value - min) / span : 0;
|
||||||
|
const travel = Math.max(0, trackWidth - THUMB_HALF_WIDTH_PX * 2);
|
||||||
|
return THUMB_HALF_WIDTH_PX + ratio * travel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueFromOffsetXPx(trackWidth: number, min: number, max: number, offsetX: number): number {
|
||||||
|
const travel = Math.max(1, trackWidth - THUMB_HALF_WIDTH_PX * 2);
|
||||||
|
const ratio = Math.max(0, Math.min(1, (offsetX - THUMB_HALF_WIDTH_PX) / travel));
|
||||||
|
return Math.round(min + ratio * (max - min));
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* The native <input type="range"> is rendered pointer-events:none and driven
|
||||||
|
* entirely by wrapper pointer events. On touch input the native tap-to-position
|
||||||
|
* behavior cannot be reliably canceled via preventDefault on pointerdown (React
|
||||||
|
* listens to touchstart passively at the root, and the touch-driven path differs
|
||||||
|
* from desktop mouse), so the only robust fix is to remove the native pointer
|
||||||
|
* interaction surface altogether. The input stays keyboard-focusable for a11y.
|
||||||
|
*/
|
||||||
|
export default function ThumbOnlyRangeSlider({
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
onChange,
|
||||||
|
onRelease,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
}: ThumbOnlyRangeSliderProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const dragRef = useRef<{ pointerId: number; grabOffsetPx: number } | null>(null);
|
||||||
|
const valueRef = useRef(value);
|
||||||
|
valueRef.current = value;
|
||||||
|
|
||||||
|
const finishDrag = (pointerId: number | null) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag) return;
|
||||||
|
if (pointerId !== null && drag.pointerId !== pointerId) return;
|
||||||
|
dragRef.current = null;
|
||||||
|
onRelease?.(valueRef.current);
|
||||||
|
onDragEnd?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex h-6 w-full items-center",
|
||||||
|
disabled ? "cursor-default" : "cursor-pointer",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{ touchAction: "none", ...style }}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (disabled || dragRef.current) return;
|
||||||
|
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input) return;
|
||||||
|
const rect = input.getBoundingClientRect();
|
||||||
|
const pressX = e.clientX - rect.left;
|
||||||
|
const centerX = thumbCenterXPx(rect.width, min, max, valueRef.current);
|
||||||
|
// Track press: ignore entirely — no value change, no drag.
|
||||||
|
if (Math.abs(pressX - centerX) > THUMB_HIT_RADIUS_PX) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragRef.current = { pointerId: e.pointerId, grabOffsetPx: pressX - centerX };
|
||||||
|
onDragStart?.();
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (disabled || !drag || drag.pointerId !== e.pointerId) return;
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input) return;
|
||||||
|
const rect = input.getBoundingClientRect();
|
||||||
|
const offsetX = e.clientX - rect.left - drag.grabOffsetPx;
|
||||||
|
onChange(valueFromOffsetXPx(rect.width, min, max, offsetX));
|
||||||
|
}}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.pointerId !== e.pointerId) return;
|
||||||
|
try {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
finishDrag(e.pointerId);
|
||||||
|
}}
|
||||||
|
onPointerCancel={() => finishDrag(null)}
|
||||||
|
onLostPointerCapture={() => finishDrag(null)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
className="cyan-slider block w-full"
|
||||||
|
style={{ pointerEvents: "none", touchAction: "none" }}
|
||||||
|
onChange={(e) => {
|
||||||
|
// Only reachable via keyboard — the input no longer receives pointer input.
|
||||||
|
if (disabled || dragRef.current) return;
|
||||||
|
onChange(Number(e.target.value));
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (dragRef.current) {
|
||||||
|
finishDrag(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!disabled) {
|
||||||
|
onRelease?.(Number(e.currentTarget.value));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (RELEASE_KEYS.includes(e.key as (typeof RELEASE_KEYS)[number])) {
|
||||||
|
onRelease?.(Number(e.currentTarget.value));
|
||||||
|
onDragEnd?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+12
-64
@@ -12,6 +12,7 @@
|
|||||||
import { useDevice } from "@/contexts/DeviceContext";
|
import { useDevice } from "@/contexts/DeviceContext";
|
||||||
import ConnectionPlaceholder from "@/components/ConnectionPlaceholder";
|
import ConnectionPlaceholder from "@/components/ConnectionPlaceholder";
|
||||||
import BottomNav from "@/components/BottomNav";
|
import BottomNav from "@/components/BottomNav";
|
||||||
|
import ThumbOnlyRangeSlider from "@/components/ThumbOnlyRangeSlider";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -231,8 +232,6 @@ export default function Home() {
|
|||||||
|
|
||||||
const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100);
|
const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100);
|
||||||
const isDragging = useRef(false);
|
const isDragging = useRef(false);
|
||||||
const volumeSliderRef = useRef<HTMLInputElement>(null);
|
|
||||||
const volumeSliderDragAllowedRef = useRef(false);
|
|
||||||
const knobDraggingRef = useRef(false);
|
const knobDraggingRef = useRef(false);
|
||||||
const knobActivePointerIdRef = useRef<number | null>(null);
|
const knobActivePointerIdRef = useRef<number | null>(null);
|
||||||
const knobStartAngleRef = useRef(0);
|
const knobStartAngleRef = useRef(0);
|
||||||
@@ -249,16 +248,6 @@ export default function Home() {
|
|||||||
knobLastVolRef.current = localVol;
|
knobLastVolRef.current = localVol;
|
||||||
}, [localVol]);
|
}, [localVol]);
|
||||||
|
|
||||||
const isPointerOnVolumeSliderThumb = useCallback((clientX: number) => {
|
|
||||||
const el = volumeSliderRef.current;
|
|
||||||
if (!el) return false;
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
if (rect.width <= 0) return false;
|
|
||||||
const thumbCenterX = rect.left + (localVol / 200) * rect.width;
|
|
||||||
const hitRadius = Math.max(28, rect.width * 0.05);
|
|
||||||
return Math.abs(clientX - thumbCenterX) <= hitRadius;
|
|
||||||
}, [localVol]);
|
|
||||||
|
|
||||||
const handlePowerOff = useCallback(async () => {
|
const handlePowerOff = useCallback(async () => {
|
||||||
if (powerActionRef.current) return;
|
if (powerActionRef.current) return;
|
||||||
powerActionRef.current = true;
|
powerActionRef.current = true;
|
||||||
@@ -677,67 +666,26 @@ export default function Home() {
|
|||||||
background: "#00FFF6",
|
background: "#00FFF6",
|
||||||
boxShadow: "0 0 8px rgba(0,255,246,0.5)"
|
boxShadow: "0 0 8px rgba(0,255,246,0.5)"
|
||||||
}} />
|
}} />
|
||||||
<input
|
<ThumbOnlyRangeSlider
|
||||||
ref={volumeSliderRef}
|
min={0}
|
||||||
type="range" min={0} max={200} value={localVol}
|
max={200}
|
||||||
|
value={localVol}
|
||||||
disabled={volumePassthroughActive}
|
disabled={volumePassthroughActive}
|
||||||
className="cyan-slider cyan-slider-thumb-only relative z-10 disabled:opacity-50"
|
className={volumePassthroughActive ? "opacity-50" : undefined}
|
||||||
onChange={(e) => {
|
onChange={(v) => {
|
||||||
if (volumePassthroughActive) return;
|
if (volumePassthroughActive) return;
|
||||||
if (!volumeSliderDragAllowedRef.current) {
|
|
||||||
e.target.value = String(localVol);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const v = Number(e.target.value);
|
|
||||||
setLocalVol(v);
|
setLocalVol(v);
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => {
|
onDragStart={() => {
|
||||||
if (volumePassthroughActive) return;
|
if (volumePassthroughActive) return;
|
||||||
const onThumb = isPointerOnVolumeSliderThumb(e.clientX);
|
|
||||||
volumeSliderDragAllowedRef.current = onThumb;
|
|
||||||
if (!onThumb) {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isDragging.current = true;
|
isDragging.current = true;
|
||||||
}}
|
}}
|
||||||
onPointerUp={(e) => {
|
onDragEnd={() => {
|
||||||
const allowed = volumeSliderDragAllowedRef.current;
|
|
||||||
volumeSliderDragAllowedRef.current = false;
|
|
||||||
isDragging.current = false;
|
isDragging.current = false;
|
||||||
if (!allowed || volumePassthroughActive) return;
|
|
||||||
applyVolume(Number(e.currentTarget.value));
|
|
||||||
}}
|
}}
|
||||||
onPointerCancel={(e) => {
|
onRelease={(v) => {
|
||||||
const allowed = volumeSliderDragAllowedRef.current;
|
if (volumePassthroughActive) return;
|
||||||
volumeSliderDragAllowedRef.current = false;
|
applyVolume(v);
|
||||||
isDragging.current = false;
|
|
||||||
if (!volumePassthroughActive && allowed) {
|
|
||||||
applyVolume(Number(e.currentTarget.value));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const allowed = volumeSliderDragAllowedRef.current;
|
|
||||||
volumeSliderDragAllowedRef.current = false;
|
|
||||||
isDragging.current = false;
|
|
||||||
if (!volumePassthroughActive && allowed) {
|
|
||||||
applyVolume(Number(e.currentTarget.value));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyUp={(e) => {
|
|
||||||
const k = e.key;
|
|
||||||
if (
|
|
||||||
k === "ArrowLeft" ||
|
|
||||||
k === "ArrowRight" ||
|
|
||||||
k === "ArrowUp" ||
|
|
||||||
k === "ArrowDown" ||
|
|
||||||
k === "Home" ||
|
|
||||||
k === "End" ||
|
|
||||||
k === "PageUp" ||
|
|
||||||
k === "PageDown"
|
|
||||||
) {
|
|
||||||
applyVolume(Number(e.currentTarget.value));
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export function BrandDrawer({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="relative z-10 flex h-[58dvh] flex-shrink-0 flex-col overflow-hidden rounded-t-[18px] bg-white shadow-[0_-8px_32px_rgba(0,0,0,0.35)]"
|
className="relative z-10 flex h-[58vh] max-h-full flex-shrink-0 flex-col overflow-hidden rounded-t-[18px] bg-white shadow-[0_-8px_32px_rgba(0,0,0,0.35)] supports-[height:1dvh]:h-[58dvh]"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex shrink-0 items-center justify-center pt-2 pb-1">
|
<div className="flex shrink-0 items-center justify-center pt-2 pb-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user