import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'; import type { TicketModel } from '@/types/models/ticket-model'; /** Pointer capture keeps mouse, pen and touch drags on the same code path. */ export function useBoardDrag(options: { canStart: (ticket: TicketModel) => boolean; drop: (ticket: TicketModel, columnId: number) => void; }) { const ticket = shallowRef(null); const active = ref(false); const x = ref(0); const y = ref(0); const columnId = ref(null); let pointerId: number | null = null; let handle: HTMLElement | null = null; let startX = 0; let startY = 0; let frame = 0; const draggedTicket = computed(() => (active.value ? ticket.value : null)); function locateTarget(): void { const target = document .elementFromPoint(x.value, y.value) ?.closest('[data-board-drop-column]'); columnId.value = target ? Number(target.dataset.boardDropColumn) : null; } function scroll(): void { if (!active.value) { return; } const element = document.elementFromPoint(x.value, y.value); const region = element?.closest( '[data-board-drag-scroll]', ); if (region) { const bounds = region.getBoundingClientRect(); const left = Math.max(0, bounds.left); const right = Math.min(window.innerWidth, bounds.right); const delta = x.value < left + 44 ? -10 : x.value > right - 44 ? 10 : 0; region.scrollLeft += delta; } if (!element?.closest('[data-board-mobile-targets]')) { const delta = y.value < 64 ? -10 : y.value > window.innerHeight - 64 ? 10 : 0; window.scrollBy(0, delta); } locateTarget(); frame = requestAnimationFrame(scroll); } function cancel(): void { const previousPointer = pointerId; pointerId = null; active.value = false; ticket.value = null; columnId.value = null; cancelAnimationFrame(frame); if ( previousPointer !== null && handle?.hasPointerCapture(previousPointer) ) { handle.releasePointerCapture(previousPointer); } handle = null; } function start(event: PointerEvent, source: TicketModel): void { if ( pointerId !== null || !event.isPrimary || event.button !== 0 || !options.canStart(source) ) { return; } handle = event.currentTarget as HTMLElement; handle.focus({ preventScroll: true }); handle.setPointerCapture(event.pointerId); pointerId = event.pointerId; ticket.value = source; startX = x.value = event.clientX; startY = y.value = event.clientY; } function move(event: PointerEvent): void { if (event.pointerId !== pointerId) { return; } x.value = event.clientX; y.value = event.clientY; if ( !active.value && Math.hypot(x.value - startX, y.value - startY) >= 6 ) { active.value = true; frame = requestAnimationFrame(scroll); } if (active.value) { event.preventDefault(); locateTarget(); } } function finish(event: PointerEvent): void { if (event.pointerId !== pointerId) { return; } x.value = event.clientX; y.value = event.clientY; locateTarget(); const source = draggedTicket.value; const target = columnId.value; cancel(); if (source && target !== null) { options.drop(source, target); } } function cancelPointer(event: PointerEvent): void { if (event.pointerId === pointerId) { cancel(); } } function keydown(event: KeyboardEvent): void { if (event.key === 'Escape' && pointerId !== null) { event.preventDefault(); cancel(); } } onMounted(() => { document.addEventListener('pointermove', move, { passive: false }); document.addEventListener('pointerup', finish); document.addEventListener('pointercancel', cancelPointer); document.addEventListener('lostpointercapture', cancelPointer); document.addEventListener('keydown', keydown); window.addEventListener('blur', cancel); }); onUnmounted(() => { cancel(); document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', finish); document.removeEventListener('pointercancel', cancelPointer); document.removeEventListener('lostpointercapture', cancelPointer); document.removeEventListener('keydown', keydown); window.removeEventListener('blur', cancel); }); return { draggedTicket, columnId, x, y, start, cancel }; }