feat(V0.2): 列表拖动修复 + 打卡项组内拖动排序

- 任务列表/看板拖动: 激活后用非 passive touchmove preventDefault 拦截浏览器滚动接管, 修复列表长按抬起后 pointercancel 导致无法真正拖动
- 打卡: 列表按周期分组展示(每天/每周/每月/每年), 长按拖动组内排序(上下落点指示), 新增 POST /checkin/habits/reorder 重写 sortOrder; 新建项排到末尾
- 打卡卡片加 touch-action/user-select 防长按误触; 拖动结束 350ms 内抑制点击余波
This commit is contained in:
jif2.zhang
2026-10-25 00:06:44 +08:00
parent d6ca7573f3
commit 9467d4c242
7 changed files with 847 additions and 651 deletions
+97
View File
@@ -0,0 +1,97 @@
import { useCallback, useEffect, useRef, useState } from 'react'
// 打卡项组内拖动排序
// 手势与任务列表/看板一致: 长按 400ms 原位抬起(震动), 之后拖动;
// elementFromPoint 命中同一周期组内的 [data-habit-row] 作为落点,
// 按指针在目标卡上半/下半决定插到其前/后, 松手提交。
// 只允许组内 (每天/每周/每月/每年) 排序, 命中其他组视为无效落点。
// 激活后用非 passive touchmove preventDefault 拦截浏览器滚动接管,
// 保证纵向拖动期间指针事件持续派发 (同 useRowDrag 的教训)。
export default function useHabitDrag({ onDrop }) {
const [dragItem, setDragItem] = useState(null) // 拖动中的打卡项 (视觉态)
const [over, setOver] = useState(null) // { id, after } 落点
const st = useRef({ timer: null, active: false, startX: 0, startY: 0, item: null, over: null })
const lastEnd = useRef(0)
const cleanup = useCallback(() => {
if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null }
st.current.active = false
st.current.over = null
setDragItem(null)
setOver(null)
}, [])
useEffect(() => () => cleanup(), [cleanup])
/** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误触打卡 */
const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, [])
const hitRow = (x, y) => {
const el = document.elementFromPoint(x, y)
const rowEl = el?.closest?.('[data-habit-row]')
if (!rowEl) return null
const r = rowEl.getBoundingClientRect()
return { id: Number(rowEl.dataset.habitId), after: y > r.top + r.height / 2 }
}
const onMove = (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()
const hit = hitRow(ev.clientX, ev.clientY)
// 只认同一周期组的落点
if (hit && hit.id !== s.item.id) { s.over = hit; setOver(hit) }
else { s.over = null; setOver(null) }
}
const onUp = () => {
const s = st.current
if (s.active && s.item) {
lastEnd.current = Date.now()
if (s.over && s.over.id !== s.item.id) onDrop?.(s.item, s.over.id, s.over.after)
}
cleanup()
}
/** 绑定到卡片: 卡片元素需设置 touch-action: pan-y */
const bind = useCallback((item) => ({
onPointerDown: (ev) => {
if (ev.button !== undefined && ev.button !== 0) return
st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, item, active: false }
st.current.timer = setTimeout(() => {
st.current.active = true
setDragItem(item)
if (navigator.vibrate) navigator.vibrate(15)
}, 400)
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
const el = ev.currentTarget
const move = (e2) => onMove(e2)
const tm = (e2) => { if (st.current.active) e2.preventDefault() }
const up = () => { onUp(); 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.addEventListener('pointermove', move)
el.addEventListener('pointerup', up)
el.addEventListener('pointercancel', cancel)
el.addEventListener('touchmove', tm, { passive: false })
},
onContextMenu: (ev) => ev.preventDefault(),
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [])
return { dragItem, over, bind, cancel: cleanup, justEnded }
}