From b95d03f56a436102552b84e268c72870fed461e1 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Tue, 21 Jul 2026 00:22:38 -0400 Subject: [PATCH] Server web UI: make photo-card dragging actually feel like dragging The card being dragged never moved -- it just faded in place while a static outline highlighted whatever was underneath the finger. Now the card tracks the pointer 1:1 (translate + a slight scale-up "lift"), gets a stronger shadow while airborne, and leaves its grid slot looking like an empty gap until dropped (transform doesn't remove it from layout flow, so the reserved space stays put -- no reflow needed until drop). pointer-events:none while dragging so elementFromPoint's drop-target hit-test sees through to the card underneath instead of hitting the translated one. Also: a short vibration tick when the touch hold-to-arm fires and another on a successful drop (Chrome/Android only, iOS Safari has no Vibration API -- harmless no-op there), and trimmed the arm delay from 350ms to 250ms now that there's actual feedback confirming the hold registered. --- server/app/templates/base.html | 16 ++++++++++++++-- server/app/templates/index.html | 23 ++++++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/server/app/templates/base.html b/server/app/templates/base.html index b47369e..24f3638 100644 --- a/server/app/templates/base.html +++ b/server/app/templates/base.html @@ -261,8 +261,20 @@ } .photo-card:hover { box-shadow: var(--shadow-hover); transform: translateY(-1px); } .photo-card:active { cursor: grabbing; } - .photo-card.dragging { opacity: 0.35; } - .photo-card.drag-armed { box-shadow: 0 0 0 3px var(--focus-ring) inset; } + .photo-card.drag-armed { + box-shadow: 0 0 0 3px var(--focus-ring) inset; + transform: scale(0.97); + } + /* No transform transition here -- the JS drives transform on every + pointermove to track the finger 1:1, and the .15s base transition + would otherwise make it visibly lag behind a fast swipe. */ + .photo-card.dragging { + z-index: 20; + box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35); + transition: box-shadow .15s ease; + pointer-events: none; /* so elementFromPoint hits the card underneath, not this one */ + cursor: grabbing; + } .photo-card.drag-over { outline: 3px solid var(--accent); outline-offset: -3px; } .photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; } .photo-card .badge { position: absolute; top: 6px; left: 6px; background: var(--overlay); color: white; font-size: 10px; padding: 2px 6px; border-radius: 4px; } diff --git a/server/app/templates/index.html b/server/app/templates/index.html index c8bead1..ac4a9cc 100644 --- a/server/app/templates/index.html +++ b/server/app/templates/index.html @@ -207,9 +207,16 @@ // hold entirely (no scroll-vs-drag ambiguity with a mouse). let dragState = null; // { pointerId, fromIndex, toIndex, moved, armed, holdTimer } const DRAG_START_THRESHOLD_PX = 6; // ignore tiny jitter once armed, before committing to a drag - const DRAG_HOLD_MS = 350; + const DRAG_HOLD_MS = 250; const HOLD_CANCEL_THRESHOLD_PX = 10; // movement before the hold timer fires -> treat as a scroll, not a drag + function vibrate(ms) { + // Chrome/Android only -- iOS Safari has no Vibration API. Wrapped so + // it's a harmless no-op everywhere else rather than needing a + // feature check at every call site. + try { navigator.vibrate?.(ms); } catch (e) { /* ignore */ } + } + function clearDragOverStyling() { document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over')); } @@ -219,9 +226,11 @@ clearTimeout(dragState.holdTimer); } card.style.touchAction = ''; + card.style.transform = ''; card.classList.remove('dragging', 'drag-armed'); clearDragOverStyling(); if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) { + vibrate(15); moveItem(dragState.fromIndex, dragState.toIndex); } dragState = null; @@ -287,6 +296,7 @@ dragState.armed = true; card.classList.add('drag-armed'); card.style.touchAction = 'none'; + vibrate(10); try { card.setPointerCapture(e.pointerId); } catch (err) { /* pointer may have already left */ } } }, DRAG_HOLD_MS); @@ -313,9 +323,10 @@ return; } + const dx = e.clientX - dragState.startX; + const dy = e.clientY - dragState.startY; + 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; } @@ -323,6 +334,12 @@ card.classList.remove('drag-armed'); card.classList.add('dragging'); } + + // Follows the finger 1:1 -- the actual "pick it up and carry it" + // feedback that was missing before (the card used to just fade + // in place while a static outline highlighted the drop target). + card.style.transform = `translate(${dx}px, ${dy}px) scale(1.06)`; + const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card'); clearDragOverStyling(); if (overCard && overCard !== card) {