feat: support desktop task swipe toggles
ci / docker (push) Successful in 3m26s

This commit is contained in:
2026-09-07 11:17:13 +08:00
parent 810d59b377
commit e1137ca861
2 changed files with 44 additions and 2 deletions
+37 -2
View File
@@ -48,6 +48,7 @@ const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageS
const expandedFolders = ref(new Set<string>()) const expandedFolders = ref(new Set<string>())
const navigationLoaded = ref(false) const navigationLoaded = ref(false)
const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null) 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 taskSwipeOffsets = ref<Record<string, number>>({})
const taskComposeOpen = ref(false) const taskComposeOpen = ref(false)
const composeTitle = ref('') const composeTitle = ref('')
@@ -365,6 +366,40 @@ function startTaskSwipe(task: Task, event: TouchEvent) {
taskSwipeOffsets.value[task.id] = 0 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) { function moveTaskSwipe(task: Task, event: TouchEvent) {
const start = taskSwipeStart.value const start = taskSwipeStart.value
const touch = event.touches[0] 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> <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}"> <section class="task-list" :class="{loading}">
<template v-for="node in taskTree" :key="node.task.id"> <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> <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> <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> <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-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> <button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
</article> </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> </template>
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div> <div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
</section> </section>
+7
View File
@@ -13,6 +13,13 @@ describe('mobile navigation styles', () => {
}) })
describe('task and habit row decoration', () => { 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', () => { it('does not draw a left border on task or habit rows', () => {
expect(css).toContain('.task-row{border-left:0;') expect(css).toContain('.task-row{border-left:0;')
expect(css).toContain('.habit-row{border-left:0;') expect(css).toContain('.habit-row{border-left:0;')