This commit is contained in:
root
2026-09-18 17:11:19 +02:00
parent 716c0163cc
commit 878158daa0
+125 -9
View File
@@ -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)
<span class="triangle" :class="{ open: !collapsed.has(groupKey(group)) }"></span>
<span class="dot" :style="{ background: group.sublist ? group.sublist.color : 'transparent' }"></span>
<span class="group-name">{{ group.sublist ? group.sublist.name : 'No sublist' }}</span>
<span class="count">{{ group.items.length }}</span>
<button
v-if="group.items.length"
type="button"
class="clear-btn"
@click.stop="clearSublistItems(group)"
>
Clear
</button>
</div>
<ul v-show="!sublists.length || !collapsed.has(groupKey(group))">
@@ -1036,10 +1145,17 @@ ul {
.group-name {
flex: 1;
}
.count {
.clear-btn {
font-weight: 400;
color: var(--muted);
font-size: 0.85rem;
background: none;
border: none;
padding: 0.2rem 0.4rem;
cursor: pointer;
}
.clear-btn:hover {
color: var(--danger);
}
.item {
position: relative;