From 4325eae4d4a15e9481efcd9f44e30503413444e5 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 24 Feb 2026 14:37:33 +0100 Subject: [PATCH] fix(tasks): show drag handle icon on mobile devices (#2286) Resolves https://github.com/go-vikunja/vikunja/issues/2228 --- .../project/views/ProjectKanban.vue | 55 +++++++++++++++++++ .../components/project/views/ProjectList.vue | 32 ++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/project/views/ProjectKanban.vue b/frontend/src/components/project/views/ProjectKanban.vue index ae1d65182..651a90484 100644 --- a/frontend/src/components/project/views/ProjectKanban.vue +++ b/frontend/src/components/project/views/ProjectKanban.vue @@ -148,6 +148,8 @@ + import {computed, nextTick, ref, watch, toRef} from 'vue' +import {useRouter} from 'vue-router' import {useRouteQuery} from '@vueuse/router' import {useI18n} from 'vue-i18n' import draggable from 'zhyswan-vuedraggable' @@ -436,6 +446,40 @@ const project = computed(() => projectId.value ? projectStore.projects[projectId const view = computed(() => project.value?.views.find(v => v.id === props.viewId) as IProjectView || null) const canWrite = computed(() => baseStore.currentProject?.maxPermission > Permissions.READ && view.value.bucketConfigurationMode === 'manual') const canCreateTasks = computed(() => canWrite.value && projectId.value > 0) + +const isTouchDevice = ref(false) +if (typeof window !== 'undefined') { + isTouchDevice.value = !window.matchMedia('(hover: hover) and (pointer: fine)').matches +} +const taskDragHandle = computed(() => isTouchDevice.value ? '.handle' : undefined) + +const router = useRouter() +const touchStartY = ref(0) + +function openTask(task: ITask) { + router.push({ + name: 'task.detail', + params: {id: task.id}, + state: {backdropView: router.currentRoute.value.fullPath}, + }) +} + +function onHandleTouchStart(e: TouchEvent) { + touchStartY.value = e.touches[0].clientY +} + +function onHandleTouchMove(e: TouchEvent) { + if (drag.value) return + + const currentY = e.touches[0].clientY + const deltaY = touchStartY.value - currentY + const scrollContainer = (e.target as HTMLElement).closest('.tasks') as HTMLElement | null + if (scrollContainer) { + scrollContainer.scrollTop += deltaY + touchStartY.value = currentY + } +} + const buckets = computed(() => kanbanStore.buckets) const loading = computed(() => kanbanStore.isLoading) const projectIdWithFallback = computed(() => project.value?.id || projectId.value) @@ -954,6 +998,7 @@ $filter-container-height: '1rem - #{$switch-view-height}'; .task-item { background-color: var(--grey-100); padding: .25rem .5rem; + position: relative; &:first-of-type { padding-block-start: .5rem; @@ -962,6 +1007,16 @@ $filter-container-height: '1rem - #{$switch-view-height}'; &:last-of-type { padding-block-end: .5rem; } + + .handle { + position: absolute; + inset: 0; + z-index: 1; + opacity: 0; + touch-action: none; + -webkit-touch-callout: none; + user-select: none; + } } .no-move { diff --git a/frontend/src/components/project/views/ProjectList.vue b/frontend/src/components/project/views/ProjectList.vue index 71460bce4..270f99d14 100644 --- a/frontend/src/components/project/views/ProjectList.vue +++ b/frontend/src/components/project/views/ProjectList.vue @@ -60,8 +60,9 @@ type: 'transition-group' }" :animation="100" - :delay-on-touch-only="true" - :delay="1000" + :handle="dragHandle" + :delay-on-touch-only="!isTouchDevice" + :delay="isTouchDevice ? 0 : 1000" ghost-class="task-ghost" @start="handleDragStart" @end="saveTaskPosition" @@ -75,7 +76,14 @@ :the-task="t" :all-tasks="allTasks" @taskUpdated="updateTasks" - /> + > + + + + @@ -193,6 +201,12 @@ onMounted(async () => { const canDragTasks = computed(() => canWrite.value || isSavedFilter(project.value)) +const isTouchDevice = ref(false) +if (typeof window !== 'undefined') { + isTouchDevice.value = !window.matchMedia('(hover: hover) and (pointer: fine)').matches +} +const dragHandle = computed(() => isTouchDevice.value ? '.handle' : undefined) + const addTaskRef = ref(null) function focusNewTaskInput() { @@ -373,6 +387,18 @@ onBeforeUnmount(() => { box-shadow: none; } +:deep(.single-task .handle) { + cursor: grab; + margin-inline-end: .25rem; + color: var(--grey-400); +} + +@media (hover: hover) and (pointer: fine) { + :deep(.single-task .handle) { + display: none; + } +} + :deep(.tasks:not(.dragging-disabled) .single-task) { cursor: grab; -webkit-touch-callout: none;