From a692b6a9e284a2e51f8699a245f9d6ee2ee00324 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 13:06:57 -0400 Subject: [PATCH] Band Lineup 1.12.0: dragging photos into spots works with a finger and stops dropping out The editor used the browser's built-in drag-and-drop, which never fires for a touchscreen, flickered its drop highlight over a card's inner parts, and lost the drag whenever a board update arrived mid-drag (the editor was rebuilt under the pointer). Dragging is now driven by pointer events, one path for mouse and touch: a mouse drags as soon as it moves, a finger holds still for a moment first so a swipe still scrolls. A floating copy of the photo follows the pointer, the spot under it lights up, the editor auto-scrolls near its edges, and board updates wait until the drop. Tap a photo then tap a spot still works. Co-Authored-By: Claude Opus 5.5 --- modules/band-lineup/client.js | 158 ++++++++++++++++++++++---------- modules/band-lineup/module.json | 2 +- modules/band-lineup/style.css | 17 ++++ 3 files changed, 129 insertions(+), 48 deletions(-) diff --git a/modules/band-lineup/client.js b/modules/band-lineup/client.js index ab86bc8..1e0b2d8 100644 --- a/modules/band-lineup/client.js +++ b/modules/band-lineup/client.js @@ -1,5 +1,5 @@ // Band Lineup — client. Renders the shared stage board from /state + /stream. -// The Edit button opens an editor: an uploaded-photo library you drag (or click) +// The Edit button opens an editor: an uploaded-photo library you drag (or tap) // into slots, plus rename / clear / upload / delete. Edits go to the server and // come back over SSE, so every Band Lineup screen stays in sync. Birthday week // (Wed→Tue) rains confetti. @@ -14,26 +14,107 @@ export default function create({ root, moduleApi }) { let selectedLibId = ''; // click-to-place fallback for touch const canvases = new Set(); let raf = null; - // Auto-scroll the editor while dragging a photo (native DnD won't do it reliably). - let dragScrolling = false; - let dragY = 0; - let dragBody = null; - let dragRaf = null; - function startDragAutoScroll() { - if (dragRaf) return; - const tick = () => { - if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; } - const r = dragBody.getBoundingClientRect(); - const zone = 90; // generous edge band so you don't have to be pixel-accurate - let dy = 0; - if (dragY < r.top + zone) dy = -Math.ceil((r.top + zone - dragY) / 4) - 3; - else if (dragY > r.bottom - zone) dy = Math.ceil((dragY - (r.bottom - zone)) / 4) + 3; - if (dy) dragBody.scrollTop += dy; - dragRaf = requestAnimationFrame(tick); - }; - dragRaf = requestAnimationFrame(tick); + /* ---------- drag (pointer events: one path for mouse, pen and touch) ---------- + * Native HTML drag-and-drop never fires for a finger, drops onto a card's child elements + * flicker, and a board update arriving mid-drag rebuilt the editor out from under it. This + * drives the drag by hand instead: a mouse drags as soon as it moves; a finger holds still for + * a moment first (so a quick swipe still scrolls the editor). A floating copy of the photo + * follows the pointer, the spot under it lights up, and the editor auto-scrolls near its + * edges. Board updates that arrive during a drag wait until it ends. */ + const DRAG_MOVE_PX = 6; + const TOUCH_HOLD_MS = 220; + let drag = null; // { kind: 'lib'|'slot', id, body, ghost, over, x, y, raf } + let pending = null; // a pointerdown that may become a drag + let renderHeld = false; // a state update arrived mid-drag; render when it ends + + function slotUnder(x, y) { + const hit = document.elementFromPoint(x, y); + const cell = hit && hit.closest ? hit.closest('.bl-edit-slot') : null; + return cell && root.contains(cell) ? cell : null; } - function stopDragAutoScroll() { dragScrolling = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; } } + function beginDrag(p) { + const ghost = el('div', 'bl-drag-ghost'); + ghost.dataset.module = 'band-lineup'; // lives on (above every tile), so it carries its own scope + const src = p.source.querySelector('img'); + if (src) { const img = document.createElement('img'); img.src = src.src; img.alt = ''; ghost.appendChild(img); } + else ghost.textContent = p.label || ''; + document.body.appendChild(ghost); + drag = { kind: p.kind, id: p.id, body: p.body, ghost, over: null, x: p.x, y: p.y, raf: null, source: p.source }; + p.source.classList.add('bl-dragging'); + root.classList.add('bl-is-dragging'); + moveDrag(p.x, p.y); + const tick = () => { + if (!drag) return; + const b = drag.body; + if (b && b.isConnected) { + const r = b.getBoundingClientRect(); const zone = 70; let dy = 0; + if (drag.y < r.top + zone) dy = -Math.ceil((r.top + zone - drag.y) / 4) - 2; + else if (drag.y > r.bottom - zone) dy = Math.ceil((drag.y - (r.bottom - zone)) / 4) + 2; + if (dy) { b.scrollTop += dy; moveDrag(drag.x, drag.y); } + } + drag.raf = requestAnimationFrame(tick); + }; + drag.raf = requestAnimationFrame(tick); + } + function moveDrag(x, y) { + drag.x = x; drag.y = y; + drag.ghost.style.transform = `translate(${x}px, ${y}px)`; + const cell = slotUnder(x, y); + if (cell !== drag.over) { + if (drag.over) drag.over.classList.remove('bl-drop-over'); + drag.over = cell; + if (cell) cell.classList.add('bl-drop-over'); + } + } + function endDrag(drop) { + const d = drag; drag = null; + if (!d) return; + if (d.raf) cancelAnimationFrame(d.raf); + d.ghost.remove(); + d.source.classList.remove('bl-dragging'); + root.classList.remove('bl-is-dragging'); + if (d.over) d.over.classList.remove('bl-drop-over'); + if (drop && d.over) { + const to = d.over.dataset.index; + if (d.kind === 'lib') assign(to, d.id); + else if (d.id !== to) api(`/move?from=${d.id}&to=${to}`, { method: 'POST' }); + } + if (renderHeld) { renderHeld = false; render(); } + } + function cancelPending() { if (pending && pending.timer) clearTimeout(pending.timer); pending = null; } + // Make `node` draggable. `kind`/`id` say what is being dragged. + function makeDraggable(node, kind, id, body, label) { + node.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || drag) return; + if (e.target.closest('button')) return; // name / clear / delete buttons keep working + cancelPending(); + pending = { kind, id, body, label, source: node, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pointerId: e.pointerId, touch: e.pointerType !== 'mouse', armed: e.pointerType === 'mouse', timer: null }; + if (pending.touch) { + const p = pending; + p.timer = setTimeout(() => { if (pending === p) { p.armed = true; beginDrag(p); try { node.setPointerCapture(p.pointerId); } catch { /* gone */ } } }, TOUCH_HOLD_MS); + } + }); + } + // Document-level move/up so the drag survives leaving the card it started on. + function onPointerMove(e) { + if (drag) { e.preventDefault(); moveDrag(e.clientX, e.clientY); return; } + if (!pending || e.pointerId !== pending.pointerId) return; + const dist = Math.hypot(e.clientX - pending.sx, e.clientY - pending.sy); + if (pending.touch) { if (dist > 10) cancelPending(); return; } // moved before the hold: it's a scroll + if (dist >= DRAG_MOVE_PX) { const p = pending; pending = null; p.x = e.clientX; p.y = e.clientY; beginDrag(p); } + } + function onPointerUp(e) { + if (drag) { e.preventDefault(); suppressClick = true; setTimeout(() => { suppressClick = false; }, 0); endDrag(e.type === 'pointerup'); } + cancelPending(); + } + let suppressClick = false; // the click that follows a drop must not also select / place + // While a finger drag is live, stop the page from scrolling under it. + function onTouchMove(e) { if (drag) e.preventDefault(); } + document.addEventListener('pointermove', onPointerMove, { passive: false }); + document.addEventListener('pointerup', onPointerUp); + document.addEventListener('pointercancel', onPointerUp); + document.addEventListener('touchmove', onTouchMove, { passive: false }); + function stopDragAutoScroll() { cancelPending(); endDrag(false); } /* ---------- confetti (always animates) ---------- */ function makeParticle(w, h, intensity, seedY) { @@ -79,6 +160,7 @@ export default function create({ root, moduleApi }) { function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } } function render() { + if (drag) { renderHeld = true; return; } stopConfetti(); stopParallax(); root.innerHTML = ''; @@ -163,8 +245,6 @@ export default function create({ root, moduleApi }) { // board on top and the picture picker (library) below it. const wrap = el('div', 'bl-editor bl-editor-nobar'); const body = el('div', 'bl-editor-body'); - // Feed the drag auto-scroller the pointer position anywhere over the editor. - body.addEventListener('dragover', (e) => { dragY = e.clientY; }); // Slots (drop targets) const boardEl = el('div', 'bl-edit-board'); @@ -176,14 +256,7 @@ export default function create({ root, moduleApi }) { photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+')); cell.appendChild(photo); // An occupied card is a drag SOURCE too — move/swap it to another slot. - if (slot.person) { - cell.draggable = true; - cell.addEventListener('dragstart', (e) => { - e.dataTransfer.setData('text/plain', 'slot:' + slot.index); e.dataTransfer.effectAllowed = 'move'; - dragBody = body; dragScrolling = true; startDragAutoScroll(); - }); - cell.addEventListener('dragend', stopDragAutoScroll); - } + if (slot.person) makeDraggable(cell, 'slot', String(slot.index), body, slot.person.name); const nameRow = el('div', 'bl-edit-namerow'); const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…'); nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : '')); @@ -191,16 +264,7 @@ export default function create({ root, moduleApi }) { if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); } cell.appendChild(nameRow); cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`)); - // drop - cell.addEventListener('dragover', (e) => { e.preventDefault(); cell.classList.add('bl-drop-over'); }); - cell.addEventListener('dragleave', () => cell.classList.remove('bl-drop-over')); - cell.addEventListener('drop', (e) => { - e.preventDefault(); stopDragAutoScroll(); cell.classList.remove('bl-drop-over'); - const data = e.dataTransfer.getData('text/plain') || ''; - if (data.startsWith('lib:')) assign(slot.index, data.slice(4)); - else if (data.startsWith('slot:')) { const from = data.slice(5); if (from !== String(slot.index)) api(`/move?from=${from}&to=${slot.index}`, { method: 'POST' }); } - }); - cell.addEventListener('click', (e) => { if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); }); + cell.addEventListener('click', (e) => { if (suppressClick) return; if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); }); boardEl.appendChild(cell); } body.appendChild(boardEl); @@ -210,7 +274,7 @@ export default function create({ root, moduleApi }) { const list = state?.library || []; if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.')); for (const person of list) { - const item = el('div', 'bl-lib-item'); item.draggable = true; item.dataset.id = person.id; + const item = el('div', 'bl-lib-item'); item.dataset.id = person.id; if (person.id === selectedLibId) item.classList.add('bl-lib-selected'); const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph); const nm = el('button', 'bl-lib-name', person.name || 'Name…'); @@ -219,12 +283,8 @@ export default function create({ root, moduleApi }) { const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo'; del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); }); item.appendChild(del); - item.addEventListener('dragstart', (e) => { - e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy'; - dragBody = body; dragScrolling = true; startDragAutoScroll(); - }); - item.addEventListener('dragend', stopDragAutoScroll); - item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); }); + makeDraggable(item, 'lib', person.id, body, person.name); + item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); }); lib.appendChild(item); } body.appendChild(lib); @@ -293,6 +353,10 @@ export default function create({ root, moduleApi }) { stopConfetti(); stopParallax(); stopDragAutoScroll(); + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); + document.removeEventListener('pointercancel', onPointerUp); + document.removeEventListener('touchmove', onTouchMove); root.innerHTML = ''; }, }; diff --git a/modules/band-lineup/module.json b/modules/band-lineup/module.json index df2cc60..56e431f 100644 --- a/modules/band-lineup/module.json +++ b/modules/band-lineup/module.json @@ -1,7 +1,7 @@ { "id": "band-lineup", "name": "Band Lineup", - "version": "1.11.1", + "version": "1.12.0", "proddash": ">=1.4.0", "description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill", "client": "client.js", diff --git a/modules/band-lineup/style.css b/modules/band-lineup/style.css index 68fe5bf..55214f1 100644 --- a/modules/band-lineup/style.css +++ b/modules/band-lineup/style.css @@ -230,3 +230,20 @@ background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1; } [data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; } + +/* Drag: the floating photo that follows the pointer (appended to , so it scopes itself). */ +[data-module="band-lineup"].bl-drag-ghost { + position: fixed; left: 0; top: 0; z-index: 10000; pointer-events: none; + width: 72px; aspect-ratio: 3 / 4; margin: -48px 0 0 -36px; border-radius: 8px; overflow: hidden; + border: 2px solid var(--accent); background: var(--panel, #222); color: var(--text); font-size: 11px; + display: flex; align-items: center; justify-content: center; text-align: center; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); opacity: 0.92; +} +[data-module="band-lineup"].bl-drag-ghost img { width: 100%; height: 100%; object-fit: cover; object-position: center top; } +[data-module="band-lineup"] .bl-lib-item, +[data-module="band-lineup"] .bl-edit-slot { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; } +[data-module="band-lineup"] .bl-edit-slot img, +[data-module="band-lineup"] .bl-lib-item img { -webkit-user-drag: none; pointer-events: none; } +[data-module="band-lineup"] .bl-dragging { opacity: 0.35; } +[data-module="band-lineup"].bl-is-dragging, [data-module="band-lineup"] .bl-is-dragging { cursor: grabbing; } +[data-module="band-lineup"] .bl-is-dragging .bl-edit-slot { transition: box-shadow 0.08s; }