import { useCallback, useEffect, useRef, useState } from 'react' import { datasetId, sameId } from '../../pages/task/ids.js' // 看板卡片列拖动 (重点设计 §7.1) // 方案要点: // 1. Pointer Events + setPointerCapture —— 触摸/鼠标统一, 捕获后不丢事件 // 2. 长按 400ms 激活 (激活前移动 >10px 视为滚动, 取消本次拖动) // 3. 激活后卡片原位抬起 (不跟随手指, 无浮层/无任务名) // 4. 拖动中持续 elementFromPoint 命中 [data-column-id] 高亮目标列; // 指针靠近容器左右边缘时 rAF 自动横滚, 可拖到屏幕外的列 // 5. 松手落到目标列 (≠原列) 才提交; pointercancel (浏览器接管手势, // 如触发纵向滚动) 只放弃不提交 // 6. 防误触: 卡片禁长按系统菜单(contextmenu); 拖动结束后 350ms 内的 // click 视为拖动余波, 不打开详情 (justEnded) export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } = {}) { const [dragTask, setDragTask] = useState(null) // 拖动中的任务 (视觉态) const [overCol, setOverCol] = useState(null) // 高亮的目标列 id const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, autoScroll: 0, raf: 0, srcEl: null, ghost: null }) const lastEnd = useRef(0) // 拖动结束时刻 // onDrop 经 ref 桥接实时闭包 (bind 空依赖 useCallback, 防陈旧) const onDropRef = useRef(onDrop) onDropRef.current = onDrop // 跟手浮层: 激活时克隆原卡片挂到 body, 移动时直接改 transform const moveGhost = (x, y) => { const g = st.current.ghost if (g) g.style.transform = `translate(${x}px, ${y}px) translate(-50%, -60%)` } const mountGhost = (x, y) => { const src = st.current.srcEl if (!src) return const clone = src.cloneNode(true) clone.classList.remove('dragging', 'hl') clone.style.width = `${src.offsetWidth}px` const g = document.createElement('div') g.className = 'drag-ghost' g.appendChild(clone) document.body.appendChild(g) st.current.ghost = g moveGhost(x, y) } const stopAutoScroll = () => { st.current.autoScroll = 0 if (st.current.raf) { cancelAnimationFrame(st.current.raf); st.current.raf = 0 } } const cleanup = useCallback(() => { if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null } st.current.active = false st.current.srcEl = null if (st.current.ghost) { st.current.ghost.remove(); st.current.ghost = null } stopAutoScroll() setDragTask(null) setOverCol(null) }, []) useEffect(() => () => cleanup(), [cleanup]) /** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */ const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, []) // 命中列 id: 本地库列 id 是 uuid 字符串, 不能 Number() (会变 NaN -> 落不下去) const hitColumn = (x, y) => { const el = document.elementFromPoint(x, y) const colEl = el?.closest?.('[data-column-id]') return colEl ? datasetId(colEl.dataset.columnId) : null } // 边缘自动滚动 (±36px 触发, 每帧 10px) const autoScroll = (container, clientX) => { if (!container) { stopAutoScroll(); return } const r = container.getBoundingClientRect() const edge = 36 let dir = 0 if (clientX < r.left + edge) dir = -1 else if (clientX > r.right - edge) dir = 1 st.current.autoScroll = dir if (!dir) { stopAutoScroll(); return } if (st.current.raf) return const step = () => { if (!st.current.autoScroll) return container.scrollLeft += st.current.autoScroll * 10 st.current.raf = requestAnimationFrame(step) } st.current.raf = requestAnimationFrame(step) } const onPointerMove = (ev) => { const s = st.current if (!s.active) { if (s.timer && (Math.abs(ev.clientX - s.startX) > 10 || Math.abs(ev.clientY - s.startY) > 10)) { clearTimeout(s.timer); s.timer = null } return } ev.preventDefault() moveGhost(ev.clientX, ev.clientY) const col = hitColumn(ev.clientX, ev.clientY) setOverCol(col) autoScroll(document.querySelector(scrollSelector), ev.clientX) } const onPointerUp = (ev) => { const s = st.current if (s.active) { lastEnd.current = Date.now() const col = hitColumn(ev.clientX, ev.clientY) if (col && s.task && !sameId(col, s.task.columnId)) onDropRef.current?.(s.task, col) } cleanup() } // 指针监听装配 (capture + 非 passive touchmove; instant=true 把手路径 // 再挂 touchstart preventDefault, 浏览器完全不接管该触摸) const armListeners = (ev, instant) => { try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ } const el = ev.currentTarget const move = (e2) => onPointerMove(e2) const tm = (e2) => { if (st.current.active) e2.preventDefault() } const ts = (e2) => e2.preventDefault() const up = (e2) => { onPointerUp(e2); detach() } const cancel = () => { if (st.current.active) lastEnd.current = Date.now() cleanup() detach() } const detach = () => { el.removeEventListener('pointermove', move) el.removeEventListener('pointerup', up) el.removeEventListener('pointercancel', cancel) el.removeEventListener('touchmove', tm) el.removeEventListener('touchstart', ts) } el.addEventListener('pointermove', move) el.addEventListener('pointerup', up) el.addEventListener('pointercancel', cancel) el.addEventListener('touchmove', tm, { passive: false }) if (instant) el.addEventListener('touchstart', ts, { passive: false }) } const startDrag = (ev, task, instant) => { st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, task, active: false, srcEl: ev.currentTarget.closest('.bcard'), } const activate = () => { st.current.active = true setDragTask(task) if (navigator.vibrate) navigator.vibrate(15) mountGhost(st.current.startX, st.current.startY) } if (instant) activate() else st.current.timer = setTimeout(activate, 400) armListeners(ev, instant) } /** 绑定到卡片: 需要卡片元素设置 touch-action: pan-y */ const bind = useCallback((task) => ({ onPointerDown: (ev) => { if (ev.button !== undefined && ev.button !== 0) return startDrag(ev, task, false) }, // 长按不弹系统菜单 (配合 CSS user-select:none, 防长按变文字选择) onContextMenu: (ev) => ev.preventDefault(), // eslint-disable-next-line react-hooks/exhaustive-deps }), []) /** 绑定到卡片左侧拖动把手: 把手需 touch-action: none, 按下立即拖动 */ const bindHandle = useCallback((task) => ({ onPointerDown: (ev) => { if (ev.button !== undefined && ev.button !== 0) return ev.stopPropagation() // 不触发卡片自身的长按路径 startDrag(ev, task, true) }, onContextMenu: (ev) => ev.preventDefault(), // eslint-disable-next-line react-hooks/exhaustive-deps }), []) return { dragTask, overCol, bind, bindHandle, cancel: cleanup, justEnded } }