fix(V0.2): 三轮反馈 5 项 —— 回滚键盘方案 / 列表卡片拖动 / 看板防误触 / 横滑整屏 / 详情页恢复
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:
jif2.zhang
2026-10-24 23:24:31 +08:00
parent 9fea1a5a3a
commit d6ca7573f3
16 changed files with 285 additions and 148 deletions
+4 -1
View File
@@ -38,7 +38,10 @@ export default function CopyTaskPopup({ task, visible, onClose, onDone }) {
<Popup
visible={visible}
onMaskClick={onClose}
className="kb-safe"
bodyStyle={{
borderTopLeftRadius: 12, borderTopRightRadius: 12,
padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)',
}}
>
<div className="sheet-title">跨项目拷贝</div>
<div className="sheet-sub">「{task.title}」→ 选择目标项目;源任务保持不变</div>
+3
View File
@@ -25,6 +25,7 @@ export default function TaskCard({
<span
className="bstat"
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
onPointerDown={(e) => e.stopPropagation()}
title="点按切换完成"
>
<StatusDot status={task.status} />
@@ -42,6 +43,7 @@ export default function TaskCard({
<span
className="bmore-btn"
onClick={(e) => { e.stopPropagation(); onMore(task) }}
onPointerDown={(e) => e.stopPropagation()}
>⋯</span>
)}
</div>
@@ -51,6 +53,7 @@ export default function TaskCard({
key={c.id}
className={`bsub${c.status === 'done' ? ' done' : ''}`}
onClick={(e) => { e.stopPropagation(); onToggleSub?.(c) }}
onPointerDown={(e) => e.stopPropagation()}
>
<span className="sbox" />
<span className="stitle">{c.title}</span>
+18 -33
View File
@@ -1,49 +1,25 @@
import { useEffect, useRef } from 'react'
import StatusDot from './StatusDot.jsx'
// 列表视图的一行 (Todoist 风格, T-UI-01)
// 长按 500ms → onLongPress (移动到列菜单); 点击其他区域 → onClick (编辑)
const LONG_PRESS = 500
// 点击行体 → 打开详情; 长按 400ms 后左右拖动换列 (bindDrag 来自 useRowDrag)
// 复选框 / ⋯ 是独立控件: pointerdown 不冒泡, 不发起拖动,
// 避免长按行后抬手时合成的 click 落在控件上误触状态切换
export default function TaskRow({
task, onToggle, onClick, onMore, onLongPress, indent = false, children,
task, onToggle, onClick, onMore, bindDrag, dragTarget, indent = false, children,
}) {
const timer = useRef(null)
const moved = useRef(false)
const clear = () => { if (timer.current) { clearTimeout(timer.current); timer.current = null } }
useEffect(() => () => clear(), [])
const startPress = () => {
moved.current = false
clear()
if (!onLongPress) return
timer.current = setTimeout(() => {
if (!moved.current) {
if (navigator.vibrate) navigator.vibrate(15)
onLongPress(task)
}
}, LONG_PRESS)
}
const cancelPress = () => clear()
const markMoved = () => { moved.current = true; clear() }
const done = task.status === 'done'
return (
<>
<div
className={`${indent ? 'sub-row' : 'task-row'}${done ? ' done' : ''}`}
className={`${indent ? 'sub-row' : 'task-row'}${done ? ' done' : ''}${dragTarget ? ' dragging' : ''}`}
data-task-id={indent ? undefined : task.id}
onClick={() => { if (!moved.current) onClick?.(task) }}
onTouchStart={startPress}
onTouchMove={markMoved}
onTouchEnd={cancelPress}
onMouseDown={startPress}
onMouseUp={cancelPress}
onClick={onClick}
{...(!indent && bindDrag ? bindDrag(task) : {})}
>
<div
className="box"
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
onPointerDown={(e) => e.stopPropagation()}
>
<svg viewBox="0 0 14 14" width={indent ? 10 : 11} height={indent ? 10 : 11}>
<path d="M2.5 7.2l3 3 6-6.4" stroke="#fff" strokeWidth="2" fill="none" strokeLinecap="round" />
@@ -72,7 +48,16 @@ export default function TaskRow({
</div>
</div>
<div className="task-right">
<span className="more" onClick={(e) => { e.stopPropagation(); onMore?.(task) }}>⋯</span>
{dragTarget && (
<span className={`drag-pill${dragTarget.id === task.columnId ? ' cur' : ''}`}>
→ {dragTarget.name}
</span>
)}
<span
className="more"
onClick={(e) => { e.stopPropagation(); onMore?.(task) }}
onPointerDown={(e) => e.stopPropagation()}
>⋯</span>
</div>
</>
)}
+1 -1
View File
@@ -34,7 +34,7 @@ export default function UserPicker({
}
return (
<Popup visible={visible} onMaskClick={onClose} position="bottom" className="kb-safe">
<Popup visible={visible} onMaskClick={onClose} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)' }}>
<div className="sheet-title">{title}</div>
<div className="sheet-sub">仅可选择当前项目的成员</div>
+35 -26
View File
@@ -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 }
}
+98
View File
@@ -0,0 +1,98 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
// 列表视图卡片拖动换列 (2026-10-24, 替代「长按 → ActionSheet」:
// 旧方案长按与复选框/行点击互相误触, 且菜单弹出时手指还按在屏上, 体验割裂)
// 手势与看板一致: 长按 400ms 行原位抬起(震动), 之后左右拖动;
// 水平位移按 80px/列 步进切换目标列 (顺序同列表分组), 行尾实时显示
// 目标列名徽标, 松手落到目标列 (≠当前列才提交)。
// touch-action: pan-y —— 横向位移归拖动, 纵向仍归列表滚动;
// 激活后浏览器若接管纵向滚动 (pointercancel) 只放弃不提交。
// 注: 列表只展示有任务的列, 空列拖不进去 (用看板视图可拖空列)。
const STEP = 80
export default function useRowDrag({ columns, onDrop }) {
// { task, fromIdx, toIdx }: 拖动中的行 + 当前目标列下标 (徽标渲染)
const [drag, setDrag] = useState(null)
const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, fromIdx: 0, toIdx: 0 })
const lastEnd = useRef(0) // 拖动结束时刻
// 目标列顺序 = 列表分组顺序 (sortOrder)
const ordered = useMemo(
() => [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder),
[columns],
)
const cleanup = useCallback(() => {
if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null }
st.current.active = false
setDrag(null)
}, [])
useEffect(() => () => cleanup(), [cleanup])
/** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */
const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, [])
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 to = Math.max(0, Math.min(ordered.length - 1, s.fromIdx + Math.round((ev.clientX - s.startX) / STEP)))
if (to !== s.toIdx) {
s.toIdx = to
setDrag({ task: s.task, fromIdx: s.fromIdx, toIdx: to })
}
}
const onUp = () => {
const s = st.current
if (s.active && s.task) {
lastEnd.current = Date.now()
const target = ordered[s.toIdx]
if (target && target.id !== s.task.columnId) onDrop?.(s.task, target.id)
}
cleanup()
}
/** 绑定到列表行: 行元素需设置 touch-action: pan-y */
const bind = useCallback((task) => ({
onPointerDown: (ev) => {
if (ev.button !== undefined && ev.button !== 0) return
const fromIdx = ordered.findIndex((c) => c.id === task.columnId)
if (fromIdx < 0) return // 不在任何列 (如「其他」组) 的任务不支持拖动
st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, task, fromIdx, toIdx: fromIdx, active: false }
st.current.timer = setTimeout(() => {
st.current.active = true
if (navigator.vibrate) navigator.vibrate(15)
setDrag({ task, fromIdx, toIdx: fromIdx })
}, 400)
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
const el = ev.currentTarget
const move = (e2) => onMove(e2)
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.addEventListener('pointermove', move)
el.addEventListener('pointerup', up)
el.addEventListener('pointercancel', cancel)
},
// 长按不弹系统菜单 (配合 CSS user-select:none, 防长按变文字选择)
onContextMenu: (ev) => ev.preventDefault(),
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [ordered, onDrop])
return { drag, bind, cancel: cleanup, justEnded }
}