diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a5b3856..a436623 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -276,6 +276,18 @@ async function removeItem(item) { items.value = items.value.filter((i) => i.id !== item.id) } +async function clearSublistItems(group) { + if (!group.items.length) return + const name = group.sublist ? group.sublist.name : 'this section' + if (!window.confirm(`Clear all items in "${name}"?`)) return + + const ids = new Set(group.items.map((i) => i.id)) + await Promise.all( + [...ids].map((id) => fetch(`/api/lists/${listId.value}/items/${id}`, { method: 'DELETE' })), + ) + items.value = items.value.filter((i) => !ids.has(i.id)) +} + function startEdit(item) { editingId.value = item.id editName.value = item.name @@ -381,6 +393,55 @@ function findTarget(anchors, pointerY) { return { targetKey, insertAfterItemId } } +// Long lists are taller than the viewport, and .handle has touch-action: +// none (so a real drag doesn't also trigger native scrolling) — without +// this, there'd be no way to drag an item to a part of the list that's +// scrolled out of view. While dragging near the top/bottom edge of the +// screen, we scroll the page ourselves and keep re-running the drag's +// move logic against the same last-known pointer position, since the +// pointer itself isn't moving. +const AUTO_SCROLL_EDGE = 70 +const AUTO_SCROLL_MAX_SPEED = 14 + +function autoScrollSpeed(clientY) { + if (clientY < AUTO_SCROLL_EDGE) { + return -AUTO_SCROLL_MAX_SPEED * (1 - Math.max(0, clientY) / AUTO_SCROLL_EDGE) + } + const fromBottom = window.innerHeight - clientY + if (fromBottom < AUTO_SCROLL_EDGE) { + return AUTO_SCROLL_MAX_SPEED * (1 - Math.max(0, fromBottom) / AUTO_SCROLL_EDGE) + } + return 0 +} + +let autoScrollRAF = null +let autoScrollDrag = null // { getClientY, update } + +function autoScrollTick() { + if (!autoScrollDrag) { + autoScrollRAF = null + return + } + const clientY = autoScrollDrag.getClientY() + const speed = clientY == null ? 0 : autoScrollSpeed(clientY) + if (speed !== 0) { + window.scrollBy(0, speed) + autoScrollDrag.update() + } + autoScrollRAF = requestAnimationFrame(autoScrollTick) +} + +function startAutoScroll(getClientY, update) { + autoScrollDrag = { getClientY, update } + if (!autoScrollRAF) autoScrollRAF = requestAnimationFrame(autoScrollTick) +} + +function stopAutoScroll() { + autoScrollDrag = null + if (autoScrollRAF) cancelAnimationFrame(autoScrollRAF) + autoScrollRAF = null +} + function setDropTarget(key) { document.querySelectorAll('.group-header').forEach((h) => h.classList.remove('drop-target')) const headerEl = key != null ? document.querySelector(`[data-group-header="${key}"]`) : null @@ -400,6 +461,8 @@ function onHandleDown(e, item) { item, el: li, startY: e.clientY, + startScrollY: window.scrollY, + lastClientY: e.clientY, height, anchors, originalKey, @@ -412,6 +475,10 @@ function onHandleDown(e, item) { li.style.position = 'relative' li.style.boxShadow = '0 4px 10px rgba(0, 0, 0, 0.2)' e.currentTarget.setPointerCapture(e.pointerId) + startAutoScroll( + () => dragReorder?.lastClientY, + () => updateHandleDrag(dragReorder.lastClientY), + ) } // Shifts the sibling items around the current drop slot to visually open a @@ -458,20 +525,34 @@ function updateGap() { dragReorder.shiftedEls = nextShifted } -function onHandleMove(e) { +// Shared by real pointermove events and the auto-scroll loop (which re-runs +// this against the same last-known pointer position while the page scrolls +// itself). scrollDelta folds the scroll-since-drag-start into both the +// dragged element's offset and the pointer position used against the +// anchors' rects, which were captured once at drag start and never +// re-measured — see buildAnchors' comment. +function updateHandleDrag(clientY) { if (!dragReorder) return - const dy = e.clientY - dragReorder.startY + dragReorder.lastClientY = clientY + const scrollDelta = window.scrollY - dragReorder.startScrollY + const dy = clientY - dragReorder.startY + scrollDelta dragReorder.el.style.transform = `translateY(${dy}px)` - const { targetKey, insertAfterItemId } = findTarget(dragReorder.anchors, e.clientY) + const { targetKey, insertAfterItemId } = findTarget(dragReorder.anchors, clientY + scrollDelta) dragReorder.targetKey = targetKey dragReorder.insertAfterItemId = insertAfterItemId setDropTarget(targetKey) updateGap() } +function onHandleMove(e) { + if (!dragReorder) return + updateHandleDrag(e.clientY) +} + function onHandleEnd() { if (!dragReorder) return + stopAutoScroll() const { el, item, targetKey, insertAfterItemId, shiftedEls } = dragReorder el.style.transform = '' el.style.transition = '' @@ -533,17 +614,32 @@ function onRecurHandleDown(e, index) { const li = e.currentTarget.closest('li') const els = Array.from(li.parentElement.children) const rects = els.map((el) => el.getBoundingClientRect()) - recurDrag = { index, target: index, startY: e.clientY, rects, els, height: rects[index].height } + recurDrag = { + index, + target: index, + startY: e.clientY, + startScrollY: window.scrollY, + lastClientY: e.clientY, + rects, + els, + height: rects[index].height, + } els[index].style.zIndex = 5 els[index].style.position = 'relative' els[index].style.boxShadow = '0 4px 10px rgba(0, 0, 0, 0.2)' e.currentTarget.setPointerCapture(e.pointerId) + startAutoScroll( + () => recurDrag?.lastClientY, + () => updateRecurDrag(recurDrag.lastClientY), + ) } -function onRecurHandleMove(e) { +// See updateHandleDrag above for why scrollDelta is folded in here too. +function updateRecurDrag(clientY) { if (!recurDrag) return - const { index, rects, els, height, startY } = recurDrag - const dy = e.clientY - startY + recurDrag.lastClientY = clientY + const { index, rects, els, height, startY, startScrollY } = recurDrag + const dy = clientY - startY + (window.scrollY - startScrollY) els[index].style.transform = `translateY(${dy}px)` const draggedCenter = rects[index].top + height / 2 + dy @@ -566,8 +662,14 @@ function onRecurHandleMove(e) { }) } +function onRecurHandleMove(e) { + if (!recurDrag) return + updateRecurDrag(e.clientY) +} + function onRecurHandleEnd() { if (!recurDrag) return + stopAutoScroll() const { index, target, els } = recurDrag els.forEach((el) => { el.style.transform = '' @@ -673,7 +775,14 @@ onMounted(init) {{ group.sublist ? group.sublist.name : 'No sublist' }} - {{ group.items.length }} +