feat(V0.2): 拖动跟手浮层 —— 半透明副本随指针, 原位虚线占位
Docker Build and Push / build-image (push) Successful in 1m5s

- useRowDrag/useHabitDrag 暴露 ghostRef, 拖动激活与移动时直接改
  transform (绕过 React 重渲染, 保证跟手流畅)
- 打卡浮层带颜色点 + 名称; 任务列表浮层带任务名 + 实时目标列名
- 原位卡片降为低透明度虚线占位, 落点指示不变
This commit is contained in:
jif2.zhang
2026-10-25 01:42:29 +08:00
parent 215263bf2f
commit 83cc218c5e
5 changed files with 54 additions and 5 deletions
+11 -1
View File
@@ -24,6 +24,13 @@ export default function useHabitDrag({ onDrop }) {
const onDropRef = useRef(onDrop)
onDropRef.current = onDrop
// 跟手浮层: 消费方渲染 <div ref={ghostRef}>, 拖动时 hook 直接改
// transform (绕过 React 重渲染, 保证跟手流畅)
const ghostRef = useRef(null)
const moveGhost = (x, y) => {
if (ghostRef.current) ghostRef.current.style.transform = `translate(${x}px, ${y}px) translate(-50%, -110%)`
}
const cleanup = useCallback(() => {
if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null }
st.current.active = false
@@ -58,6 +65,7 @@ export default function useHabitDrag({ onDrop }) {
return
}
ev.preventDefault()
moveGhost(ev.clientX, ev.clientY)
const hit = hitRow(ev.clientX, ev.clientY)
if (hit) { s.over = hit; setOver(hit) }
else if (s.over) { s.over = null; setOver(null) }
@@ -104,11 +112,13 @@ export default function useHabitDrag({ onDrop }) {
if (instant) {
st.current.active = true
setDragItem(item)
moveGhost(st.current.startX, st.current.startY)
if (navigator.vibrate) navigator.vibrate(15)
} else {
st.current.timer = setTimeout(() => {
st.current.active = true
setDragItem(item)
moveGhost(st.current.startX, st.current.startY)
if (navigator.vibrate) navigator.vibrate(15)
}, 400)
}
@@ -137,5 +147,5 @@ export default function useHabitDrag({ onDrop }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [])
return { dragItem, over, bind, bindHandle, cancel: cleanup, justEnded }
return { dragItem, over, bind, bindHandle, ghostRef, cancel: cleanup, justEnded }
}
+10 -1
View File
@@ -33,6 +33,12 @@ export default function useRowDrag({ columns, onDrop }) {
/** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */
const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, [])
// 跟手浮层: 消费方渲染 <div ref={ghostRef}>, 拖动时直接改 transform
const ghostRef = useRef(null)
const moveGhost = (x, y) => {
if (ghostRef.current) ghostRef.current.style.transform = `translate(${x}px, ${y}px) translate(-50%, -110%)`
}
const onMove = (ev) => {
const s = st.current
if (!s.active) {
@@ -42,6 +48,7 @@ export default function useRowDrag({ columns, onDrop }) {
return
}
ev.preventDefault()
moveGhost(ev.clientX, ev.clientY)
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
@@ -99,6 +106,7 @@ export default function useRowDrag({ columns, onDrop }) {
st.current.active = true
if (navigator.vibrate) navigator.vibrate(15)
setDrag({ task, fromIdx, toIdx: fromIdx })
moveGhost(st.current.startX, st.current.startY)
}, 400)
armListeners(ev)
},
@@ -120,11 +128,12 @@ export default function useRowDrag({ columns, onDrop }) {
st.current.active = true
if (navigator.vibrate) navigator.vibrate(15)
setDrag({ task, fromIdx, toIdx: fromIdx })
moveGhost(st.current.startX, st.current.startY)
armListeners(ev, true)
},
onContextMenu: (ev) => ev.preventDefault(),
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [ordered, onDrop])
return { drag, bind, bindHandle, cancel: cleanup, justEnded }
return { drag, bind, bindHandle, ghostRef, cancel: cleanup, justEnded }
}
+8
View File
@@ -363,6 +363,14 @@ export default function MergedView({ date, onDateChange }) {
))}
</div>
{/* 跟手浮层: 半透明副本随手指移动, 原位保留虚线占位 */}
{drag.dragItem && (
<div ref={drag.ghostRef} className="drag-ghost">
<i style={{ background: drag.dragItem.color }} />
{drag.dragItem.name}
</div>
)}
{/* 「⋯」操作面板: 撤销 / 编辑 / 归档 */}
<Popup
visible={!!sheetItem}
+11 -2
View File
@@ -34,8 +34,7 @@ export default function ListView({
return (
<div className="list-body">
{groups.map((g) => (
<div key={g.key}>
{groups.map((g) => ( <div key={g.key}>
<div className="group-title">
{g.name}
<span className="cnt">{g.list.length}</span>
@@ -74,6 +73,16 @@ export default function ListView({
})}
</div>
))}
{/* 跟手浮层: 任务名 + 目标列, 原位保留半透明占位 */}
{drag.drag && (
<div ref={drag.ghostRef} className="drag-ghost">
{drag.drag.task.title}
{orderedCols[drag.drag.toIdx] && (
<b>→ {orderedCols[drag.drag.toIdx].name}</b>
)}
</div>
)}
</div>
)
}
+14 -1
View File
@@ -694,11 +694,24 @@ body.me-safe-top .app-safe-top { height: 0; }
.mv-view { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
.mv-view input, .mv-view textarea { -webkit-user-select: text; user-select: text; }
.mv-card { touch-action: pan-y; }
.mv-card.dragging { opacity: .55; outline: 2px dashed var(--color-primary); outline-offset: 1px; }
.mv-card.dragging { opacity: .3; outline: 2px dashed var(--color-primary); outline-offset: 1px; }
.mv-card.dragover { outline: 2px solid var(--color-primary); outline-offset: 1px; }
.mv-card.dragover.after { border-bottom: 3px solid var(--color-primary); }
.mv-card.dragover.before { border-top: 3px solid var(--color-primary); }
/* 拖动跟手浮层: 半透明副本随指针, 原位保留占位 */
.drag-ghost {
position: fixed; left: 0; top: 0; z-index: 1000;
display: flex; align-items: center; gap: 6px;
background: rgba(255,255,255,.94); color: var(--color-text);
font-size: 13px; font-weight: 600; white-space: nowrap;
padding: 8px 12px; border-radius: 8px;
box-shadow: 0 10px 24px rgba(15,23,42,.28);
pointer-events: none; will-change: transform;
}
.drag-ghost i { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.drag-ghost b { color: var(--color-primary); font-weight: 600; margin-left: 4px; }
/* ----- 日历视图 ----- */
.calendar-view { padding: 0 12px 20px; }
.cal-month-bar { display: flex; align-items: center; gap: 10px; padding: 8px 2px; }