From cc0e9a3334c13637cc1229b87b2bc01a79804cac Mon Sep 17 00:00:00 2001 From: eafonyang Date: Tue, 15 Sep 2026 17:09:13 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ThumbOnlyRangeSlider):=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=BB=91=E5=9D=97=E6=8B=96=E6=8B=BD=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E5=92=8C=E4=BA=8B=E4=BB=B6=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除原有基于客户端X坐标计算值的函数,改用更精确的偏移计算 - 实现滑块中心位置和偏移值转换函数,确保滑块不会超出轨道边界 - 将原生输入禁用指针事件,由外层容器处理所有指针事件以提升触控体验 - 修复触摸设备上滑块跳跃问题,防止点击轨道时误触导致值突变 - 优化指针事件绑定和释放逻辑,支持多种交互状态切换 - 保持键盘无障碍可用,输入框仍可通过键盘改变值并触发事件 style(BrandDrawer): 修正抽屉高度适配并增加对动态视窗单位的支持 - 将固定高度单位58dvh替换为58vh,并增加max-h-full以限制最大高度 - 利用CSS supports媒体查询,针对支持1dvh单位的浏览器恢复58dvh高度 - 修复在不同设备和浏览器中抽屉显示高度不一致的问题 --- .../src/components/ThumbOnlyRangeSlider.tsx | 165 ++++++++++-------- .../src/pages/eq/components/BrandDrawer.tsx | 2 +- 2 files changed, 92 insertions(+), 75 deletions(-) diff --git a/client/src/components/ThumbOnlyRangeSlider.tsx b/client/src/components/ThumbOnlyRangeSlider.tsx index d6beabe..6297251 100644 --- a/client/src/components/ThumbOnlyRangeSlider.tsx +++ b/client/src/components/ThumbOnlyRangeSlider.tsx @@ -1,27 +1,22 @@ import { cn } from "@/lib/utils"; import { useRef, type CSSProperties } from "react"; -/** Matches .cyan-slider thumb (~17.6px); generous hit area for touch. */ +/** 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 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); +function thumbCenterXPx(trackWidth: number, min: number, max: number, value: number): number { 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 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 = [ @@ -52,6 +47,13 @@ export interface ThumbOnlyRangeSliderProps { /** * 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, @@ -65,81 +67,96 @@ export default function ThumbOnlyRangeSlider({ onDragStart, onDragEnd, }: ThumbOnlyRangeSliderProps) { - const draggingRef = useRef(false); + const inputRef = useRef(null); + const dragRef = useRef<{ pointerId: number; grabOffsetPx: number } | null>(null); + const valueRef = useRef(value); + valueRef.current = value; - const emitRelease = (el: EventTarget | null) => { - if (disabled) return; - if (!(el instanceof HTMLInputElement)) return; - onRelease?.(Number(el.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 || 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; - } + 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(); - draggingRef.current = true; + dragRef.current = { pointerId: e.pointerId, grabOffsetPx: pressX - centerX }; onDragStart?.(); - input.setPointerCapture(e.pointerId); - onChange(valueFromClientX(input, e.clientX)); + e.currentTarget.setPointerCapture(e.pointerId); }} onPointerMove={(e) => { - if (disabled || !draggingRef.current) return; - onChange(valueFromClientX(e.currentTarget, e.clientX)); + 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) => { - if (!draggingRef.current) return; - draggingRef.current = false; + const drag = dragRef.current; + if (!drag || drag.pointerId !== e.pointerId) return; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { // ignore } - emitRelease(e.currentTarget); + finishDrag(e.pointerId); }} - 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); - } - }} - /> + 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/eq/components/BrandDrawer.tsx b/client/src/pages/eq/components/BrandDrawer.tsx index aa04295..ba9b3c0 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()} >