fix(V0.2): 三轮反馈 5 项 —— 回滚键盘方案 / 列表卡片拖动 / 看板防误触 / 横滑整屏 / 详情页恢复
Docker Build and Push / build-image (push) Successful in 4m9s
Docker Build and Push / build-image (push) Successful in 4m9s
1. 回滚键盘遮挡统一方案 (真机测试不通过, 需求搁置): 恢复 TaskDetail/ UserPicker/CopyTaskPopup/ProjectManage 与 useKeyboardInset 到 9fea1a5^, 删除 useKeyboardInsetWatcher / .kb-safe / interactive-widget=resizes-content 2. 任务详情页样式随 1 一并恢复提交前状态 (含底部拷贝/删除按钮) 3. 列表视图换列改为卡片拖动: 新 useRowDrag (长按 400ms 行抬起, 左右 80px/列步进, 行尾徽标显示目标列, 松手落列), 移除长按 ActionSheet —— 旧长按与复选框/行点击互相误触, 抬手合成 click 落在控件上触发状态切换 4. 看板拖动防误触: 任务区禁长按文字选择与系统菜单 (输入框除外); pointercancel 只放弃不提交; 拖动结束 350ms 内抑制 click 误开详情; 状态点/⋯/子任务行 pointerdown 不冒泡, 不发起拖动 5. 看板横滑扩展到整屏: .page-transition 补 height:100% 打通 app-main → page-with-navbar 百分比链, board-wrap 撑满剩余高度 已验证: kb-safe/Watcher/onLongPress 残留引用清零, 逐文件 diff 审阅通过; 本机无 Node 运行时, 未跑 vite build
This commit is contained in:
@@ -1,18 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
// 看板卡片列拖动 (重点设计 §7.1, 2026-10-24 重做)
|
||||
// 方案要点 (替代旧版"手指跟随浮层 + 落点即子任务"的不可靠实现):
|
||||
// 看板卡片列拖动 (重点设计 §7.1)
|
||||
// 方案要点:
|
||||
// 1. Pointer Events + setPointerCapture —— 触摸/鼠标统一, 捕获后不丢事件
|
||||
// 2. 长按 400ms 激活 (先给滚动让路: 激活前移动 >10px 视为滚动并取消)
|
||||
// 3. 激活后卡片原位抬起 (不跟随手指, 无浮层/无任务名), touch-action 由捕获保证
|
||||
// 2. 长按 400ms 激活 (激活前移动 >10px 视为滚动, 取消本次拖动)
|
||||
// 3. 激活后卡片原位抬起 (不跟随手指, 无浮层/无任务名)
|
||||
// 4. 拖动中持续 elementFromPoint 命中 [data-column-id] 高亮目标列;
|
||||
// 指针靠近容器左右边缘时自动横向滚动, 可拖到屏幕外的列
|
||||
// 5. 松手/取消(pointercancel 也要处理) → 落到目标列(≠原列)才提交;
|
||||
// 拖到卡片上不再转子任务 —— 统一走详情页「转为子任务」
|
||||
// 指针靠近容器左右边缘时 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 })
|
||||
const lastEnd = useRef(0) // 拖动结束时刻
|
||||
|
||||
const stopAutoScroll = () => {
|
||||
st.current.autoScroll = 0
|
||||
@@ -29,6 +32,9 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
||||
|
||||
useEffect(() => () => cleanup(), [cleanup])
|
||||
|
||||
/** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */
|
||||
const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, [])
|
||||
|
||||
const hitColumn = (x, y) => {
|
||||
const el = document.elementFromPoint(x, y)
|
||||
const colEl = el?.closest?.('[data-column-id]')
|
||||
@@ -54,17 +60,6 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
||||
st.current.raf = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
const onPointerDown = (task) => (ev) => {
|
||||
if (ev.button !== undefined && ev.button !== 0) return
|
||||
st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, task, active: false }
|
||||
// 长按计时: 期间移动超容差(用户在滚动)则取消
|
||||
st.current.timer = setTimeout(() => {
|
||||
st.current.active = true
|
||||
setDragTask(task)
|
||||
if (navigator.vibrate) navigator.vibrate(15)
|
||||
}, 400)
|
||||
}
|
||||
|
||||
const onPointerMove = (ev) => {
|
||||
const s = st.current
|
||||
if (!s.active) {
|
||||
@@ -82,6 +77,7 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
||||
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 && col !== s.task.columnId) onDrop?.(s.task, col)
|
||||
}
|
||||
@@ -91,24 +87,37 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
||||
/** 绑定到卡片: 需要卡片元素设置 touch-action: pan-y */
|
||||
const bind = useCallback((task) => ({
|
||||
onPointerDown: (ev) => {
|
||||
// 捕获前先让事件正常冒泡(点击等); 只做长按计时
|
||||
onPointerDown(task)(ev)
|
||||
// 捕获: 后续 move/up 都派发给该元素, 不丢
|
||||
if (ev.button !== undefined && ev.button !== 0) return
|
||||
st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, task, active: false }
|
||||
// 长按计时: 期间移动超容差(用户在滚动)则取消
|
||||
st.current.timer = setTimeout(() => {
|
||||
st.current.active = true
|
||||
setDragTask(task)
|
||||
if (navigator.vibrate) navigator.vibrate(15)
|
||||
}, 400)
|
||||
// 捕获: 后续 move/up/cancel 都派发给该元素, 不丢
|
||||
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
|
||||
const el = ev.currentTarget
|
||||
const move = (e2) => onPointerMove(e2)
|
||||
const up = (e2) => {
|
||||
onPointerUp(e2)
|
||||
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', up)
|
||||
el.removeEventListener('pointercancel', cancel)
|
||||
}
|
||||
el.addEventListener('pointermove', move)
|
||||
el.addEventListener('pointerup', up)
|
||||
el.addEventListener('pointercancel', up)
|
||||
el.addEventListener('pointercancel', cancel)
|
||||
},
|
||||
// 长按不弹系统菜单 (配合 CSS user-select:none, 防长按变文字选择)
|
||||
onContextMenu: (ev) => ev.preventDefault(),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [])
|
||||
|
||||
return { dragTask, overCol, bind, cancel: cleanup }
|
||||
return { dragTask, overCol, bind, cancel: cleanup, justEnded }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user