{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-drag-scroll",
  "title": "useDragScroll",
  "description": "Grab-to-scroll any overflow container with pointer drags — momentum glide, grabbing cursor, and click suppression so dragging over a card never fires its onClick.",
  "files": [
    {
      "path": "src/registry/hooks/use-drag-scroll.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nexport type DragScrollAxis = \"x\" | \"y\" | \"both\"\n\nexport interface UseDragScrollOptions {\n  /** 哪些轴可以被拖动。`\"x\"` 时纵向滚动完全交还给页面/容器自身。默认 `\"both\"`。 */\n  axis?: DragScrollAxis\n  /** 关掉抓手交互(容器仍是普通的原生滚动区:滚轮 / 触控板 / 滚动条照常)。默认 false。 */\n  disabled?: boolean\n  /** 松手后按最近几帧的速度惯性滑行。`prefers-reduced-motion` 下自动关掉。默认 true。 */\n  momentum?: boolean\n  /** 由 hook 通过 `bind.style` 给出 `grab` / `grabbing` 光标。默认 true。 */\n  cursor?: boolean\n  /** 一次滚动活动开始(指针真正拖动起来)时触发一次。 */\n  onScrollStart?: () => void\n  /** 该次活动彻底停下(松手 + 惯性滑行结束)时触发一次,与 `onScrollStart` 一一配对。 */\n  onScrollEnd?: () => void\n}\n\nexport interface DragScrollBind {\n  /** 光标(以及消费者自己的 style 合并点)。 */\n  style: React.CSSProperties\n  /** 拖动中为 `\"true\"`,可直接用 `data-[dragging=true]:` 变体上样式。 */\n  \"data-dragging\": \"true\" | undefined\n  /** 拖动中吃掉原生 HTML5 拖拽(否则按住图片/链接拖会变成拖影而不是滚动)。 */\n  onDragStart: (event: React.DragEvent) => void\n}\n\nexport interface UseDragScrollResult<T extends HTMLElement> {\n  /** 挂到滚动容器(那个 `overflow-auto` 元素)上。 */\n  ref: React.RefObject<T | null>\n  /** 指针正按住并已越过拖动阈值。惯性滑行期间为 false —— 那时手已经松了。 */\n  isDragging: boolean\n  /** 摊到同一个容器元素上的 props。放在你自己的 `style` 之前,或手动合并 `bind.style`。 */\n  bind: DragScrollBind\n}\n\n/** 越过多少像素才算「在拖」而不是「在点」——同时也是抑制 click 的阈值。 */\nconst DRAG_SLOP = 5\n/** 拖动结束后多久内到达的 click 视为这次拖动的余波,予以抑制。 */\nconst CLICK_SUPPRESS_MS = 400\n/** 只用最近这段时间内的采样算甩出速度,避免把「拖到一半停住再松手」算成有速度。 */\nconst VELOCITY_WINDOW_MS = 100\nconst MAX_SAMPLES = 6\n/** 低于这个速度(px/ms)不启动惯性——手指停住时松手不该再飘。 */\nconst MIN_FLING_VELOCITY = 0.1\n/** 滑行期间低于这个速度就收尾(≈0.8px/帧,再往下人眼看不出还在动,只会拖长尾巴)。 */\nconst MIN_GLIDE_VELOCITY = 0.05\n/** 速度上限,挡住个别浏览器给出的异常采样(px/ms)。 */\nconst MAX_VELOCITY = 4\n/**\n * 每 16.7ms 保留的速度比例,dt 换算后做指数衰减,和帧率无关。\n * 时间常数 ≈ 16.7 / -ln(0.92) ≈ 200ms,所以滑行距离 ≈ 松手速度 × 200px,\n * 最猛的一甩(被 MAX_VELOCITY 夹到 4px/ms)也在 ~0.9s 内停住。\n */\nconst FRICTION_PER_FRAME = 0.92\nconst FRAME_MS = 1000 / 60\n/** 卡顿/切标签页回来时的单帧时间上限,防止一帧跳出去几百像素。 */\nconst MAX_FRAME_MS = 32\n/** 亚像素布局常让 scrollWidth 比 clientWidth 大一丁点,不算溢出。 */\nconst OVERFLOW_EPSILON = 1\n\n/** 这些子元素上的按压不该被当成拖动:文本录入要能选字,原生下拉要能展开。 */\nconst IGNORE_SELECTOR =\n  \"input, textarea, select, [contenteditable]:not([contenteditable='false']), [data-drag-scroll-ignore]\"\n\n/**\n * 同一根指针同时只允许一个容器拖。嵌套滚动区(看板:外层横滚 + 每列纵滚)里\n * 两个容器都会收到同一串 pointermove,谁先越过**自己那条轴**的阈值谁拿走这根\n * 指针——横向甩动归外层、纵向甩动归内层,而不是靠 DOM 深度硬分。\n */\nconst claimedPointers = new Set<number>()\n\n/**\n * 拖动期间的全局样式(grabbing 光标 + 禁选)加在 `document.body` 上,这样指针\n * 拖出容器时光标不会变回箭头、也不会在页面别处刷出一条蓝色选区。多个实例可能\n * 同时持有(触屏 + 鼠标),所以用**模块级引用计数**:先存原值,计数归零才还原,\n * 避免第二个实例把第一个实例设的 `grabbing` 当成「原值」永久留在页面上。\n */\nlet bodyLockCount = 0\nlet savedBodyCursor = \"\"\nlet savedBodyUserSelect = \"\"\nlet savedBodyWebkitUserSelect = \"\"\n\nfunction lockBodyStyles() {\n  if (typeof document === \"undefined\") return\n  if (bodyLockCount++ > 0) return\n  const { style } = document.body\n  savedBodyCursor = style.cursor\n  savedBodyUserSelect = style.userSelect\n  savedBodyWebkitUserSelect = style.getPropertyValue(\"-webkit-user-select\")\n  style.cursor = \"grabbing\"\n  style.userSelect = \"none\"\n  // Safari 17 之前只认前缀版,少这一行拖动时仍会选中文字。\n  style.setProperty(\"-webkit-user-select\", \"none\")\n}\n\nfunction releaseBodyStyles() {\n  if (typeof document === \"undefined\" || bodyLockCount === 0) return\n  if (--bodyLockCount > 0) return\n  const { style } = document.body\n  style.cursor = savedBodyCursor\n  style.userSelect = savedBodyUserSelect\n  if (savedBodyWebkitUserSelect) style.setProperty(\"-webkit-user-select\", savedBodyWebkitUserSelect)\n  else style.removeProperty(\"-webkit-user-select\")\n}\n\nfunction prefersReducedMotion() {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return false\n  return window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n}\n\nfunction clampVelocity(value: number) {\n  if (!Number.isFinite(value)) return 0\n  return Math.min(MAX_VELOCITY, Math.max(-MAX_VELOCITY, value))\n}\n\n/**\n * 按住拖动来滚动一个容器——地图、看板、宽表格上的「抓手」交互。\n *\n * **不是拿 pointerdown 就开拖**:按下时 hook 什么都不做(不捕获指针、不改样式、\n * 不 preventDefault),所以容器里的按钮照常聚焦、照常有 `:active`。只有指针在\n * **可滚动的那条轴**上移动超过 {@link DRAG_SLOP}px 才算真拖动,这时才\n * `setPointerCapture`。这个顺序是刻意的:在 pointerdown 就捕获指针会让后续的\n * 兼容鼠标事件(含 `click`)被重定向到容器上,容器里的卡片将**再也收不到点击**。\n *\n * **点击穿透**是这类交互最容易翻车的地方:拖完松手,浏览器仍会补一个 `click`,\n * 卡片于是被「顺手点开」。hook 在容器上常驻一个**捕获阶段**的 click 监听,只要\n * 上一次手势越过了阈值,就在事件到达任何子元素之前 `stopPropagation()` +\n * `preventDefault()` 吃掉它(React 的事件委托挂在应用根节点上,捕获阶段拦在容器\n * 这一层,子元素的 `onClick` 因此不会触发)。判定用 `event.timeStamp` 比较窗口,\n * 不用定时器——没有「timer 和 click 谁先到」的竞态。\n *\n * **惯性**:拖动时按 `event.timeStamp` 采样最近 6 个点,松手时用最后\n * {@link VELOCITY_WINDOW_MS}ms 的位移算速度,再用 rAF 做指数衰减滑行(按每帧\n * 实际 dt 换算,帧率无关);再次按下立即停住。`prefers-reduced-motion` 下惯性\n * 整个关掉,但拖动本身照常——功能不因为关动效而失效。\n *\n * **触摸指针直接放行**:`pointerType === \"touch\"` 一律不接管,原生触摸滚动的\n * 惯性/回弹比任何 JS 模拟都好。同理不设 `touch-action`,`axis: \"x\"` 时纵向滚动\n * 完整交还给页面。\n *\n * 清理:rAF、window 上的 pointermove/up/cancel、pointer capture、body 上的\n * grabbing/禁选、被临时改掉的 `scroll-behavior`,在手势结束和卸载时全部还原。\n */\nexport function useDragScroll<T extends HTMLElement = HTMLElement>(\n  options: UseDragScrollOptions = {},\n): UseDragScrollResult<T> {\n  const { axis = \"both\", disabled = false, momentum = true, cursor = true } = options\n\n  const ref = React.useRef<T | null>(null)\n  const [isDragging, setIsDragging] = React.useState(false)\n  /** 只驱动光标:内容没溢出时不该摆出一只抓手。 */\n  const [scrollable, setScrollable] = React.useState(false)\n\n  const draggingRef = React.useRef(false)\n  // latest-ref:消费者几乎一定是内联箭头函数,进依赖数组会让监听器每次渲染重绑。\n  const callbacksRef = React.useRef(options)\n  React.useEffect(() => {\n    callbacksRef.current = options\n  })\n\n  React.useEffect(() => {\n    const element = ref.current\n    if (!element || disabled) return\n\n    const allowX = axis === \"x\" || axis === \"both\"\n    const allowY = axis === \"y\" || axis === \"both\"\n\n    let pointerId: number | null = null\n    let canX = false\n    let canY = false\n    /** 当前 scroll 位置锚定在哪个指针坐标上(碰到边界会重锚,保证反向立刻跟手)。 */\n    let anchorClientX = 0\n    let anchorClientY = 0\n    let anchorScrollX = 0\n    let anchorScrollY = 0\n    let dragging = false\n    /** 一次「滚动活动」是否已开场(onScrollStart 已发),松手 + 滑行停下才收场。 */\n    let active = false\n    let bodyLocked = false\n    let savedScrollBehavior: string | null = null\n    let samples: { x: number; y: number; t: number }[] = []\n    let velocityX = 0\n    let velocityY = 0\n    let glideX = 0\n    let glideY = 0\n    let lastFrameTime = 0\n    let frame = 0\n    /** click 抑制窗口的截止时间戳(与 event.timeStamp 同一时基),0 表示不抑制。 */\n    let suppressClickUntil = 0\n\n    const maxScrollX = () => Math.max(0, element.scrollWidth - element.clientWidth)\n    const maxScrollY = () => Math.max(0, element.scrollHeight - element.clientHeight)\n\n    const cancelFrame = () => {\n      if (!frame) return\n      cancelAnimationFrame(frame)\n      frame = 0\n    }\n\n    /** 活动收场:先还原被借走的 scroll-behavior,再发一次 onScrollEnd。 */\n    const finishActivity = () => {\n      if (savedScrollBehavior !== null) {\n        element.style.scrollBehavior = savedScrollBehavior\n        savedScrollBehavior = null\n      }\n      if (!active) return\n      active = false\n      callbacksRef.current.onScrollEnd?.()\n    }\n\n    const beginActivity = () => {\n      if (active) return\n      active = true\n      // 消费者可能给容器上了 `scroll-smooth`,那样每次写 scrollLeft 都会变成一段\n      // 补间动画,拖动会黏手。整个活动期间临时按 auto 写,结束还原。\n      savedScrollBehavior = element.style.scrollBehavior\n      element.style.scrollBehavior = \"auto\"\n      callbacksRef.current.onScrollStart?.()\n    }\n\n    const applyScroll = (clientX: number, clientY: number) => {\n      if (canX) {\n        const max = maxScrollX()\n        let next = anchorScrollX - (clientX - anchorClientX)\n        if (next <= 0) {\n          next = 0\n          anchorClientX = clientX\n          anchorScrollX = 0\n        } else if (next >= max) {\n          next = max\n          anchorClientX = clientX\n          anchorScrollX = max\n        }\n        element.scrollLeft = next\n      }\n      if (canY) {\n        const max = maxScrollY()\n        let next = anchorScrollY - (clientY - anchorClientY)\n        if (next <= 0) {\n          next = 0\n          anchorClientY = clientY\n          anchorScrollY = 0\n        } else if (next >= max) {\n          next = max\n          anchorClientY = clientY\n          anchorScrollY = max\n        }\n        element.scrollTop = next\n      }\n    }\n\n    const step = (time: number) => {\n      frame = 0\n      const dt = Math.min(MAX_FRAME_MS, Math.max(1, time - lastFrameTime))\n      lastFrameTime = time\n      const decay = FRICTION_PER_FRAME ** (dt / FRAME_MS)\n      velocityX *= decay\n      velocityY *= decay\n      if (Math.abs(velocityX) < MIN_GLIDE_VELOCITY) velocityX = 0\n      if (Math.abs(velocityY) < MIN_GLIDE_VELOCITY) velocityY = 0\n\n      if (velocityX) {\n        const max = maxScrollX()\n        glideX += velocityX * dt\n        if (glideX <= 0) {\n          glideX = 0\n          velocityX = 0\n        } else if (glideX >= max) {\n          glideX = max\n          velocityX = 0\n        }\n        element.scrollLeft = glideX\n      }\n      if (velocityY) {\n        const max = maxScrollY()\n        glideY += velocityY * dt\n        if (glideY <= 0) {\n          glideY = 0\n          velocityY = 0\n        } else if (glideY >= max) {\n          glideY = max\n          velocityY = 0\n        }\n        element.scrollTop = glideY\n      }\n\n      if (velocityX || velocityY) frame = requestAnimationFrame(step)\n      else finishActivity()\n    }\n\n    const startGlide = (timeStamp: number) => {\n      const last = samples[samples.length - 1]\n      const first = samples.find(s => last.t - s.t <= VELOCITY_WINDOW_MS) ?? last\n      const span = last.t - first.t\n      if (span <= 0) return false\n\n      // 指针往右 = 内容往右 = scrollLeft 变小,所以速度取反号。\n      velocityX = canX ? clampVelocity(-(last.x - first.x) / span) : 0\n      velocityY = canY ? clampVelocity(-(last.y - first.y) / span) : 0\n      if (Math.hypot(velocityX, velocityY) < MIN_FLING_VELOCITY) return false\n\n      glideX = element.scrollLeft\n      glideY = element.scrollTop\n      lastFrameTime = timeStamp\n      frame = requestAnimationFrame(step)\n      return true\n    }\n\n    /** 结束一次按压。`flick` 为 true 才考虑惯性与 click 抑制(pointercancel 两者都不做)。 */\n    const endPress = (event: PointerEvent | null, flick: boolean) => {\n      window.removeEventListener(\"pointermove\", handlePointerMove)\n      window.removeEventListener(\"pointerup\", handlePointerUp)\n      window.removeEventListener(\"pointercancel\", handlePointerCancel)\n\n      const wasDragging = dragging\n      dragging = false\n      draggingRef.current = false\n\n      if (pointerId !== null) {\n        // 只有真正拿到这根指针的实例才能撤销登记——否则嵌套场景里放弃的那个实例\n        // 会把赢家的登记删掉,两个容器就同时动起来了。\n        if (wasDragging) claimedPointers.delete(pointerId)\n        if (element.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId)\n        pointerId = null\n      }\n      if (bodyLocked) {\n        releaseBodyStyles()\n        bodyLocked = false\n      }\n      setIsDragging(false)\n\n      if (!wasDragging) {\n        // 纯点击:什么都没发生过,click 原样放行。\n        finishActivity()\n        return\n      }\n      if (flick && event) {\n        suppressClickUntil = event.timeStamp + CLICK_SUPPRESS_MS\n        if (momentum && !prefersReducedMotion() && startGlide(event.timeStamp)) return\n      }\n      finishActivity()\n    }\n\n    const handlePointerMove = (event: PointerEvent) => {\n      if (pointerId === null || event.pointerId !== pointerId) return\n\n      samples.push({ x: event.clientX, y: event.clientY, t: event.timeStamp })\n      if (samples.length > MAX_SAMPLES) samples.shift()\n\n      if (!dragging) {\n        // 只统计**能真的滚**的轴上的位移:axis=\"x\" 时纯纵向抖手不该被当成拖动,\n        // 更不该因此吃掉这次 click。\n        const dx = canX ? event.clientX - anchorClientX : 0\n        const dy = canY ? event.clientY - anchorClientY : 0\n        if (Math.hypot(dx, dy) < DRAG_SLOP) return\n        if (claimedPointers.has(event.pointerId)) {\n          // 嵌套场景:另一个容器已经在这根指针上开拖了,本实例整轮放弃。\n          endPress(null, false)\n          return\n        }\n        claimedPointers.add(event.pointerId)\n        dragging = true\n        draggingRef.current = true\n        setIsDragging(true)\n        element.setPointerCapture?.(event.pointerId)\n        lockBodyStyles()\n        bodyLocked = true\n        // 阈值之前是原生行为,可能已经拉出了一段选区;禁选只能阻止新的选择。\n        const selection = document.getSelection()\n        if (selection && !selection.isCollapsed) selection.removeAllRanges()\n        beginActivity()\n      }\n\n      applyScroll(event.clientX, event.clientY)\n    }\n\n    const handlePointerUp = (event: PointerEvent) => {\n      if (pointerId === null || event.pointerId !== pointerId) return\n      endPress(event, true)\n    }\n\n    const handlePointerCancel = (event: PointerEvent) => {\n      if (pointerId === null || event.pointerId !== pointerId) return\n      endPress(event, false)\n    }\n\n    const handlePointerDown = (event: PointerEvent) => {\n      // 触摸交给平台自己的滚动(它的惯性/回弹比模拟的好),右键/中键不参与。\n      if (event.pointerType === \"touch\" || event.button !== 0 || !event.isPrimary) return\n      if (pointerId !== null) return\n      const target = event.target as Element | null\n      if (target?.closest?.(IGNORE_SELECTOR)) return\n\n      canX = allowX && maxScrollX() > OVERFLOW_EPSILON\n      canY = allowY && maxScrollY() > OVERFLOW_EPSILON\n      // 这条轴上根本没有可滚的内容:不接管,滚轮/选中/点击全按原生来。\n      if (!canX && !canY) return\n\n      cancelFrame() // 再次按下立刻停住滑行(活动不收场,接着这次手势继续)。\n      pointerId = event.pointerId\n      anchorClientX = event.clientX\n      anchorClientY = event.clientY\n      anchorScrollX = element.scrollLeft\n      anchorScrollY = element.scrollTop\n      samples = [{ x: event.clientX, y: event.clientY, t: event.timeStamp }]\n\n      // 监听挂在 window 上:指针可能在容器外松开(捕获前),那时元素自己收不到 up。\n      window.addEventListener(\"pointermove\", handlePointerMove)\n      window.addEventListener(\"pointerup\", handlePointerUp)\n      window.addEventListener(\"pointercancel\", handlePointerCancel)\n    }\n\n    const handleClickCapture = (event: MouseEvent) => {\n      const until = suppressClickUntil\n      if (!until) return\n      // 一次拖动最多吃掉一个 click;窗口外迟到的点击是新的真实意图,必须放行。\n      suppressClickUntil = 0\n      if (event.timeStamp > until) return\n      event.stopPropagation()\n      event.preventDefault()\n    }\n\n    element.addEventListener(\"pointerdown\", handlePointerDown)\n    element.addEventListener(\"click\", handleClickCapture, true)\n\n    return () => {\n      element.removeEventListener(\"pointerdown\", handlePointerDown)\n      element.removeEventListener(\"click\", handleClickCapture, true)\n      cancelFrame()\n      endPress(null, false)\n      if (savedScrollBehavior !== null) {\n        element.style.scrollBehavior = savedScrollBehavior\n        savedScrollBehavior = null\n      }\n    }\n  }, [axis, disabled, momentum])\n\n  // 抓手光标只在内容真的溢出时才给。observe() 会立刻回调一次,正好当首测,\n  // 不需要在 effect 体里同步 setState。\n  React.useEffect(() => {\n    const element = ref.current\n    if (!element || !cursor || disabled || typeof ResizeObserver === \"undefined\") return\n\n    const measure = () => {\n      const overflowX = element.scrollWidth - element.clientWidth > OVERFLOW_EPSILON\n      const overflowY = element.scrollHeight - element.clientHeight > OVERFLOW_EPSILON\n      const next = (axis !== \"y\" && overflowX) || (axis !== \"x\" && overflowY)\n      setScrollable(previous => (previous === next ? previous : next))\n    }\n\n    const observer = new ResizeObserver(measure)\n    observer.observe(element)\n    // 内容整体被包在一个 wrapper 里时(横向条最常见),它的尺寸变化才是溢出与否的来源。\n    if (element.firstElementChild) observer.observe(element.firstElementChild)\n    return () => observer.disconnect()\n  }, [axis, cursor, disabled])\n\n  const bind = React.useMemo<DragScrollBind>(\n    () => ({\n      style: {\n        cursor:\n          cursor && !disabled && scrollable ? (isDragging ? \"grabbing\" : \"grab\") : undefined,\n      },\n      \"data-dragging\": isDragging ? \"true\" : undefined,\n      onDragStart: (event: React.DragEvent) => {\n        if (draggingRef.current) event.preventDefault()\n      },\n    }),\n    [cursor, disabled, isDragging, scrollable],\n  )\n\n  return { ref, isDragging, bind }\n}\n\nexport default useDragScroll\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}