From aef8a10029df0f3c2f665a0040e724a3c76a8855 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Sun, 19 Jul 2026 12:59:53 -0400 Subject: [PATCH] Make the upcoming-queue drag reorder work on touch devices The native HTML5 Drag-and-Drop API (draggable/dragstart/dragover/drop) is mouse-only by spec and never fires on phones/tablets, so reordering was unusable on mobile. Replaced it with the Pointer Events API (pointerdown/pointermove/pointerup), which unifies mouse, touch, and pen into one code path, plus touch-action: none on the cards so touching one to drag it doesn't get hijacked by the browser's default scroll gesture. Same visual behavior as before (dim the dragged card, outline the drop target). --- server/app/templates/index.html | 86 +++++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 19 deletions(-) diff --git a/server/app/templates/index.html b/server/app/templates/index.html index e31d6af..ef6a415 100644 --- a/server/app/templates/index.html +++ b/server/app/templates/index.html @@ -27,7 +27,14 @@ .thumb { width: 160px; max-width: 100%; border-radius: 4px; display: block; } .photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; margin-top: 8px; } - .photo-card { position: relative; cursor: grab; border-radius: 6px; overflow: hidden; aspect-ratio: 1; background: #f3f4f6; border: 1px solid #e5e7eb; } + .photo-card { + position: relative; cursor: grab; border-radius: 6px; overflow: hidden; aspect-ratio: 1; + background: #f3f4f6; border: 1px solid #e5e7eb; + /* Touching a card is what starts a drag -- without this the browser + treats that touch as the start of a page scroll instead, and + pointermove events for the drag never arrive on mobile. */ + touch-action: none; + } .photo-card:active { cursor: grabbing; } .photo-card.dragging { opacity: 0.35; } .photo-card.drag-over { outline: 3px solid #2563eb; outline-offset: -3px; } @@ -158,7 +165,25 @@ }); let upcomingItems = []; - let dragFromIndex = null; + + // Pointer Events (not the native HTML5 Drag-and-Drop API) so the same + // code drives mouse, touch, and pen -- native drag-and-drop is + // mouse-only by spec and never fires at all on phones/tablets. + let dragState = null; // { pointerId, fromIndex, toIndex, moved } + const DRAG_START_THRESHOLD_PX = 6; // ignore tiny jitter from an imprecise tap + + function clearDragOverStyling() { + document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over')); + } + + function endDrag(card) { + card.classList.remove('dragging'); + clearDragOverStyling(); + if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) { + moveItem(dragState.fromIndex, dragState.toIndex); + } + dragState = null; + } function renderUpcoming(items) { upcomingItems = items; @@ -168,7 +193,7 @@ items.forEach((item, i) => { const card = document.createElement('div'); card.className = 'photo-card'; - card.draggable = true; + card.dataset.index = String(i); const img = document.createElement('img'); img.src = item.thumbnail_url; @@ -190,25 +215,48 @@ }); card.appendChild(nextBtn); - card.addEventListener('dragstart', (e) => { - dragFromIndex = i; - card.classList.add('dragging'); - e.dataTransfer.effectAllowed = 'move'; + card.addEventListener('pointerdown', (e) => { + if (e.target.closest('.show-next')) { + return; // let the button's own click handler run, don't start a drag + } + if (e.pointerType === 'mouse' && e.button !== 0) { + return; // left button only + } + dragState = { pointerId: e.pointerId, fromIndex: i, toIndex: undefined, moved: false, startX: e.clientX, startY: e.clientY }; + card.setPointerCapture(e.pointerId); }); - card.addEventListener('dragend', () => { - card.classList.remove('dragging'); - dragFromIndex = null; + + card.addEventListener('pointermove', (e) => { + if (!dragState || dragState.pointerId !== e.pointerId) { + return; + } + if (!dragState.moved) { + const dx = e.clientX - dragState.startX; + const dy = e.clientY - dragState.startY; + if (Math.hypot(dx, dy) < DRAG_START_THRESHOLD_PX) { + return; + } + dragState.moved = true; + card.classList.add('dragging'); + } + const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card'); + clearDragOverStyling(); + if (overCard && overCard !== card) { + overCard.classList.add('drag-over'); + dragState.toIndex = Number(overCard.dataset.index); + } else { + dragState.toIndex = undefined; + } }); - card.addEventListener('dragover', (e) => { - e.preventDefault(); - card.classList.add('drag-over'); + + card.addEventListener('pointerup', (e) => { + if (dragState && dragState.pointerId === e.pointerId) { + endDrag(card); + } }); - card.addEventListener('dragleave', () => card.classList.remove('drag-over')); - card.addEventListener('drop', (e) => { - e.preventDefault(); - card.classList.remove('drag-over'); - if (dragFromIndex !== null && dragFromIndex !== i) { - moveItem(dragFromIndex, i); + card.addEventListener('pointercancel', (e) => { + if (dragState && dragState.pointerId === e.pointerId) { + endDrag(card); } });