import React, { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; // Drag & drop on the Kanban board with a finger (browsers offer HTML5 drag & drop only for mouse and // pen): hold a card for a moment, it lifts, drag it onto another card or column, let go. A short tap // still opens the task and a swipe still scrolls, because the drag starts only after the hold. // Cards carry data-kanban-task="", columns data-kanban-col="". const HOLD_MS = 350; const MOVE_TOLERANCE = 8; // px a finger may wander during the hold before it counts as scrolling const EDGE = 90; // px near the top/bottom edge that scroll the page while dragging export type KanbanDropTarget = { taskId: string; position: 'before' | 'after' } | null; interface Options { enabled: boolean; onStart: (taskId: string) => void; onHover: (colId: string | null, target: KanbanDropTarget) => void; onDrop: (taskId: string, colId: string | null, target: KanbanDropTarget) => void; onCancel: () => void; } interface Drag { taskId: string; label: string; x: number; y: number; offsetX: number; offsetY: number; width: number; } export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel }: Options) { const [drag, setDrag] = useState(null); const holdRef = useRef<{ timer: number; x: number; y: number } | null>(null); const dragRef = useRef(null); const hoverRef = useRef<{ col: string | null; target: KanbanDropTarget }>({ col: null, target: null }); const suppressClickRef = useRef(false); const callbacks = useRef({ onStart, onHover, onDrop, onCancel }); callbacks.current = { onStart, onHover, onDrop, onCancel }; const cancelHold = () => { if (holdRef.current) window.clearTimeout(holdRef.current.timer); holdRef.current = null; }; const hoverAt = (x: number, y: number) => { const el = document.elementFromPoint(x, y) as HTMLElement | null; const card = el?.closest('[data-kanban-task]') as HTMLElement | null; const col = (el?.closest('[data-kanban-col]') as HTMLElement | null)?.dataset.kanbanCol ?? null; let target: KanbanDropTarget = null; const cardId = card?.dataset.kanbanTask; if (card && cardId && cardId !== dragRef.current?.taskId) { const r = card.getBoundingClientRect(); target = { taskId: cardId, position: y - r.top < r.height / 2 ? 'before' : 'after' }; } const prev = hoverRef.current; if (prev.col !== col || prev.target?.taskId !== target?.taskId || prev.target?.position !== target?.position) { hoverRef.current = { col, target }; callbacks.current.onHover(col, target); } }; // While dragging: the page must not scroll under the finger (non-passive listener), it scrolls // only near the edges useEffect(() => { if (!drag) return; let frame = 0; const scroll = () => { const d = dragRef.current; if (d) { if (d.y < EDGE) window.scrollBy(0, -Math.ceil((EDGE - d.y) / 6)); else if (d.y > window.innerHeight - EDGE) window.scrollBy(0, Math.ceil((d.y - (window.innerHeight - EDGE)) / 6)); hoverAt(d.x, d.y); } frame = requestAnimationFrame(scroll); }; frame = requestAnimationFrame(scroll); const move = (e: TouchEvent) => { e.preventDefault(); const t = e.touches[0]; if (!t || !dragRef.current) return; dragRef.current = { ...dragRef.current, x: t.clientX, y: t.clientY }; setDrag(dragRef.current); }; const end = (e: TouchEvent) => { const d = dragRef.current; dragRef.current = null; setDrag(null); suppressClickRef.current = true; window.setTimeout(() => (suppressClickRef.current = false), 400); if (!d) return; if (e.type === 'touchcancel') callbacks.current.onCancel(); else callbacks.current.onDrop(d.taskId, hoverRef.current.col, hoverRef.current.target); hoverRef.current = { col: null, target: null }; }; document.addEventListener('touchmove', move, { passive: false }); document.addEventListener('touchend', end); document.addEventListener('touchcancel', end); return () => { cancelAnimationFrame(frame); document.removeEventListener('touchmove', move); document.removeEventListener('touchend', end); document.removeEventListener('touchcancel', end); }; // Listeners live as long as one drag; positions are read from refs // eslint-disable-next-line react-hooks/exhaustive-deps }, [Boolean(drag)]); /** Props for a card; label is what the lifted card shows */ const cardProps = useCallback( (taskId: string, label: string, draggable: boolean) => { if (!enabled || !draggable) return {}; return { onTouchStart: (e: React.TouchEvent) => { if (e.touches.length !== 1) return; const t = e.touches[0]; const el = e.currentTarget; cancelHold(); holdRef.current = { x: t.clientX, y: t.clientY, timer: window.setTimeout(() => { const hold = holdRef.current; holdRef.current = null; if (!hold) return; const r = el.getBoundingClientRect(); navigator.vibrate?.(15); const d: Drag = { taskId, label, x: hold.x, y: hold.y, offsetX: hold.x - r.left, offsetY: hold.y - r.top, width: r.width }; dragRef.current = d; hoverRef.current = { col: null, target: null }; setDrag(d); callbacks.current.onStart(taskId); }, HOLD_MS), }; }, onTouchMove: (e: React.TouchEvent) => { const h = holdRef.current; const t = e.touches[0]; if (h && t && Math.hypot(t.clientX - h.x, t.clientY - h.y) > MOVE_TOLERANCE) cancelHold(); // scrolling }, onTouchEnd: cancelHold, onTouchCancel: cancelHold, // The long press must not open the browser's context menu or select text onContextMenu: (e: React.MouseEvent) => e.preventDefault(), style: { WebkitTouchCallout: 'none' } as React.CSSProperties, }; }, [enabled] ); /** A tap right after a drag must not open the task */ const consumeClick = () => suppressClickRef.current; const ghost = drag ? createPortal(
{drag.label}
, document.body ) : null; return { cardProps, ghost, consumeClick, dragging: Boolean(drag) }; }