From eb00a417f7fc12df30c42d5ab03a6ff163082c3b Mon Sep 17 00:00:00 2001 From: eafonyang Date: Tue, 15 Sep 2026 17:09:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(slider):=20=E6=B7=BB=E5=8A=A0=E4=BB=85?= =?UTF-8?q?=E5=8F=AF=E6=8B=96=E5=8A=A8=E6=BB=91=E5=9D=97=E7=9A=84=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E9=9F=B3=E9=87=8F=E6=BB=91=E5=9D=97=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ThumbOnlyRangeSlider 组件,禁用轨道点击,仅支持滑块拖动 - 避免触摸时点击轨道导致音量跳变问题 - 在 Home 页面替换旧的音量输入滑块为新组件 - 移除旧音量滑块相关多余的引用和函数 - 支持拖动开始、结束和释放回调以优化操作逻辑 - 优化了 BrandDrawer 组件的样式以支持不同单位的视口高度显示 --- .../src/components/ThumbOnlyRangeSlider.tsx | 162 ++++++++++++++++++ client/src/pages/Home.tsx | 76 ++------ .../src/pages/eq/components/BrandDrawer.tsx | 2 +- 3 files changed, 175 insertions(+), 65 deletions(-) create mode 100644 client/src/components/ThumbOnlyRangeSlider.tsx diff --git a/client/src/components/ThumbOnlyRangeSlider.tsx b/client/src/components/ThumbOnlyRangeSlider.tsx new file mode 100644 index 0000000..6297251 --- /dev/null +++ b/client/src/components/ThumbOnlyRangeSlider.tsx @@ -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 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(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 ( +
{ + 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)} + > + { + // 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?.(); + } + }} + /> +
+ ); +} diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 0ed2e9c..8d39fe6 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -12,6 +12,7 @@ import { useDevice } from "@/contexts/DeviceContext"; import ConnectionPlaceholder from "@/components/ConnectionPlaceholder"; import BottomNav from "@/components/BottomNav"; +import ThumbOnlyRangeSlider from "@/components/ThumbOnlyRangeSlider"; import { AlertDialog, AlertDialogAction, @@ -231,8 +232,6 @@ export default function Home() { const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100); const isDragging = useRef(false); - const volumeSliderRef = useRef(null); - const volumeSliderDragAllowedRef = useRef(false); const knobDraggingRef = useRef(false); const knobActivePointerIdRef = useRef(null); const knobStartAngleRef = useRef(0); @@ -249,16 +248,6 @@ export default function Home() { knobLastVolRef.current = 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 () => { if (powerActionRef.current) return; powerActionRef.current = true; @@ -677,67 +666,26 @@ export default function Home() { background: "#00FFF6", boxShadow: "0 0 8px rgba(0,255,246,0.5)" }} /> - { + className={volumePassthroughActive ? "opacity-50" : undefined} + onChange={(v) => { if (volumePassthroughActive) return; - if (!volumeSliderDragAllowedRef.current) { - e.target.value = String(localVol); - return; - } - const v = Number(e.target.value); setLocalVol(v); }} - onPointerDown={(e) => { + onDragStart={() => { if (volumePassthroughActive) return; - const onThumb = isPointerOnVolumeSliderThumb(e.clientX); - volumeSliderDragAllowedRef.current = onThumb; - if (!onThumb) { - e.preventDefault(); - return; - } isDragging.current = true; }} - onPointerUp={(e) => { - const allowed = volumeSliderDragAllowedRef.current; - volumeSliderDragAllowedRef.current = false; + onDragEnd={() => { isDragging.current = false; - if (!allowed || volumePassthroughActive) return; - applyVolume(Number(e.currentTarget.value)); }} - onPointerCancel={(e) => { - const allowed = volumeSliderDragAllowedRef.current; - volumeSliderDragAllowedRef.current = false; - 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)); - } + onRelease={(v) => { + if (volumePassthroughActive) return; + applyVolume(v); }} /> diff --git a/client/src/pages/eq/components/BrandDrawer.tsx b/client/src/pages/eq/components/BrandDrawer.tsx index f5eadcd..14bdd44 100644 --- a/client/src/pages/eq/components/BrandDrawer.tsx +++ b/client/src/pages/eq/components/BrandDrawer.tsx @@ -164,7 +164,7 @@ export function BrandDrawer({ onClick={onClose} />
e.stopPropagation()} >