This commit is contained in:
+37
-2
@@ -48,6 +48,7 @@ const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageS
|
||||
const expandedFolders = ref(new Set<string>())
|
||||
const navigationLoaded = ref(false)
|
||||
const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||||
const taskPointerStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||||
const taskSwipeOffsets = ref<Record<string, number>>({})
|
||||
const taskComposeOpen = ref(false)
|
||||
const composeTitle = ref('')
|
||||
@@ -365,6 +366,40 @@ function startTaskSwipe(task: Task, event: TouchEvent) {
|
||||
taskSwipeOffsets.value[task.id] = 0
|
||||
}
|
||||
}
|
||||
function startTaskPointer(task: Task, event: PointerEvent) {
|
||||
if (activeView.value === 'trash' || loading.value || isInteractiveTarget(event.target)) return
|
||||
if (event.pointerType === 'touch') return
|
||||
taskPointerStart.value = { id: task.id, x: event.clientX, y: event.clientY }
|
||||
}
|
||||
function moveTaskPointer(task: Task, event: PointerEvent) {
|
||||
const start = taskPointerStart.value
|
||||
if (!start || start.id !== task.id) return
|
||||
const deltaX = event.clientX - start.x
|
||||
const deltaY = event.clientY - start.y
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
if ((!task.completed && deltaX > 0) || (task.completed && deltaX < 0)) {
|
||||
taskSwipeOffsets.value[task.id] = Math.max(-92, Math.min(deltaX, 92))
|
||||
}
|
||||
}
|
||||
}
|
||||
function finishTaskPointer(task: Task, event: PointerEvent) {
|
||||
const start = taskPointerStart.value
|
||||
if (!start || start.id !== task.id) return
|
||||
taskPointerStart.value = null
|
||||
const deltaX = event.clientX - start.x
|
||||
const deltaY = event.clientY - start.y
|
||||
taskSwipeOffsets.value[task.id] = 0
|
||||
const expectedDirection = task.completed ? deltaX < 0 : deltaX > 0
|
||||
if (expectedDirection && shouldToggleRowSwipe(Math.abs(deltaX), deltaY)) {
|
||||
suppressTaskClickId = task.id
|
||||
window.setTimeout(() => { if (suppressTaskClickId === task.id) suppressTaskClickId = '' }, 400)
|
||||
void toggle(task)
|
||||
}
|
||||
}
|
||||
function cancelTaskPointer(task?: Task) {
|
||||
taskPointerStart.value = null
|
||||
if (task) taskSwipeOffsets.value[task.id] = 0
|
||||
}
|
||||
function moveTaskSwipe(task: Task, event: TouchEvent) {
|
||||
const start = taskSwipeStart.value
|
||||
const touch = event.touches[0]
|
||||
@@ -538,7 +573,7 @@ onMounted(bootstrap)
|
||||
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
|
||||
<section class="task-list" :class="{loading}">
|
||||
<template v-for="node in taskTree" :key="node.task.id">
|
||||
<article class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--swipe-width': `${Math.abs(taskSwipeOffsets[node.task.id] ?? 0)}px` }" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
|
||||
<article class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--swipe-width': `${Math.abs(taskSwipeOffsets[node.task.id] ?? 0)}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
|
||||
<span class="swipe-bg" :class="{ cancel: node.task.completed }">{{ node.task.completed ? '↩ 未完成' : '✓ 完成' }}</span>
|
||||
<div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div>
|
||||
<button v-if="activeView!=='trash'" class="sr-only" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)">切换任务状态</button>
|
||||
@@ -547,7 +582,7 @@ onMounted(bootstrap)
|
||||
<button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button>
|
||||
<button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
|
||||
</article>
|
||||
<article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--swipe-width': `${Math.abs(taskSwipeOffsets[subtask.id] ?? 0)}px` }" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><span class="swipe-bg" :class="{ cancel: subtask.completed }">{{ subtask.completed ? '↩ 未完成' : '✓ 完成' }}</span><GripVertical/><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div><button class="sr-only" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)">切换任务状态</button></article>
|
||||
<article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--swipe-width': `${Math.abs(taskSwipeOffsets[subtask.id] ?? 0)}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><span class="swipe-bg" :class="{ cancel: subtask.completed }">{{ subtask.completed ? '↩ 未完成' : '✓ 完成' }}</span><GripVertical/><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div><button class="sr-only" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)">切换任务状态</button></article>
|
||||
</template>
|
||||
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
|
||||
</section>
|
||||
|
||||
@@ -13,6 +13,13 @@ describe('mobile navigation styles', () => {
|
||||
})
|
||||
|
||||
describe('task and habit row decoration', () => {
|
||||
it('supports pointer dragging for desktop task and subtask completion', () => {
|
||||
expect(app.match(/@pointerdown="startTaskPointer/g)?.length).toBe(2)
|
||||
expect(app.match(/@pointermove="moveTaskPointer/g)?.length).toBe(2)
|
||||
expect(app.match(/@pointerup="finishTaskPointer/g)?.length).toBe(2)
|
||||
expect(app.match(/@pointercancel="cancelTaskPointer/g)?.length).toBe(2)
|
||||
})
|
||||
|
||||
it('does not draw a left border on task or habit rows', () => {
|
||||
expect(css).toContain('.task-row{border-left:0;')
|
||||
expect(css).toContain('.habit-row{border-left:0;')
|
||||
|
||||
Reference in New Issue
Block a user