From 9302911ac7d2665991c2dbaf627f1a011c63dbad Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 23 Jul 2026 07:40:02 +0330 Subject: [PATCH] fix(downloads): harden queue drag and reorder behavior --- src/components/DownloadItem.tsx | 13 +- src/components/DownloadTable.tsx | 303 ++++++++++++++++++++++++----- src/index.css | 66 ++++++- src/store/useDownloadStore.test.ts | 34 ++++ src/store/useDownloadStore.ts | 18 +- src/utils/queueOrdering.test.ts | 5 + 6 files changed, 388 insertions(+), 51 deletions(-) diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index b586bca..484c743 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -388,7 +388,18 @@ export const DownloadItem = React.memo(({ } }} onPointerDown={event => { - if (isQueueReorderable) onQueueDragStart(download.id, event); + // Modifier clicks belong to selection. Starting a row drag first can + // capture the pointer and suppress the click that applies Cmd/Ctrl or + // Shift selection. + if ( + isQueueReorderable && + !event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey + ) { + onQueueDragStart(download.id, event); + } }} onClick={(e) => onClick(e, download)} onKeyDown={event => { diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index 0f13988..c7b9c1d 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -49,8 +49,7 @@ import { type DownloadTableColumnKey } from '../utils/downloadTableColumns'; import { - moveSelectedBlockToIndex, - targetIndexForBoundary + moveSelectedBlockToIndex } from '../utils/queueOrdering'; export interface DownloadTableStatusSummary { @@ -115,11 +114,25 @@ interface QueueDragState { ids: string[]; startX: number; startY: number; + pointerY: number; + pointerOffsetY: number; active: boolean; targetIndex: number; markerTop: number; } +interface QueueDragRowGeometry { + top: number; + height: number; + space: number; +} + +interface QueueDragGeometry { + listTop: number; + scrollTop: number; + rows: Map; +} + export const DownloadTable: React.FC = ({ filter, onSummaryChange }) => { const { t } = useTranslation(); const { @@ -146,7 +159,10 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC }, []); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null); - const [animationParent] = useAutoAnimate(); + const [animationParent] = useAutoAnimate({ + duration: 120, + easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)', + }); const [headerAnimationParent, setHeaderAnimationEnabled] = useAutoAnimate({ duration: 140, easing: 'ease-out', @@ -190,10 +206,22 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC const queueDragCaptureTargetRef = useRef(null); const queueDragCapturePointerIdRef = useRef(null); const queueDragPreviewOrderRef = useRef(null); + const queueDragGeometryRef = useRef(null); const queueReorderPendingCountRef = useRef(0); const suppressQueueClickRef = useRef(false); const [queueDragState, setQueueDragState] = useState(null); const [queueDragPreviewOrder, setQueueDragPreviewOrder] = useState(null); + + useEffect(() => { + const className = 'is-queue-dragging'; + if (queueDragState?.active) { + document.body.classList.add(className); + } else { + document.body.classList.remove(className); + } + return () => document.body.classList.remove(className); + }, [queueDragState?.active]); + selectedIdsRef.current = selectedIds; lastSelectedIdRef.current = lastSelectedId; const [columnWidths, setColumnWidths] = useState(() => { @@ -590,7 +618,11 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC useEffect(() => () => { resizeCleanupRef.current?.(); columnDragCleanupRef.current?.(); - queueDragCleanupRef.current?.(); + try { + queueDragCleanupRef.current?.(); + } catch (error) { + console.error('Failed to clean up queue drag listeners during unmount:', error); + } columnDragCleanupRef.current = null; queueDragCleanupRef.current = null; queueDragStateRef.current = null; @@ -598,8 +630,12 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC const queueCapturePointerId = queueDragCapturePointerIdRef.current; queueDragCaptureTargetRef.current = null; queueDragCapturePointerIdRef.current = null; - if (queueCaptureTarget && queueCapturePointerId !== null && queueCaptureTarget.hasPointerCapture(queueCapturePointerId)) { - queueCaptureTarget.releasePointerCapture(queueCapturePointerId); + try { + if (queueCaptureTarget && queueCapturePointerId !== null && queueCaptureTarget.hasPointerCapture(queueCapturePointerId)) { + queueCaptureTarget.releasePointerCapture(queueCapturePointerId); + } + } catch (error) { + console.warn('Failed to release queue pointer capture during unmount:', error); } const captureTarget = columnDragCaptureTargetRef.current; const capturePointerId = columnDragCapturePointerIdRef.current; @@ -703,6 +739,35 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC return queueRowsById(list).get(id) || null; }; + const captureQueueDragGeometry = (items: DownloadItem[]): QueueDragGeometry | null => { + const list = queueListElementRef.current; + if (!list) return null; + + const listRect = list.getBoundingClientRect(); + const rowsById = queueRowsById(list); + const rows = new Map(); + for (const item of items) { + const row = rowsById.get(item.id); + if (!row) continue; + + const rect = row.getBoundingClientRect(); + const styles = window.getComputedStyle(row); + const marginTop = Number.parseFloat(styles.marginTop) || 0; + const marginBottom = Number.parseFloat(styles.marginBottom) || 0; + rows.set(item.id, { + top: rect.top, + height: rect.height, + space: rect.height + marginTop + marginBottom, + }); + } + + return { + listTop: listRect.top, + scrollTop: list.scrollTop, + rows, + }; + }; + const queueDropPosition = ( clientY: number, items: DownloadItem[], @@ -711,33 +776,54 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC const list = queueListElementRef.current; if (!list || items.length === 0) return { targetIndex: 0, markerTop: 0 }; const listRect = list.getBoundingClientRect(); - // Build the DOM index once per pointer update. Looking up each row by - // scanning list.children inside the loop made large queue drags O(n^2). - const rowsById = queueRowsById(list); - let boundaryIndex = items.length; + const geometry = queueDragGeometryRef.current; + if (!geometry) return { targetIndex: 0, markerTop: 0 }; + + // Hit-test against the geometry captured before the preview starts. The + // rendered rows may be in the middle of an AutoAnimate FLIP transition; + // their transformed client rects are visual output, not stable drop slots. + // Keeping the logical slots immutable prevents marker jitter and makes + // upward and downward drops use the same coordinate system. The pointer + // is compared with the original row centers so a drag started in a row + // does not immediately jump past that row when its space is removed. + const scrollDelta = list.scrollTop - geometry.scrollTop; + let selectedSpaceBefore = 0; + let remainingIndex = 0; let markerTop = 0; - for (let index = 0; index < items.length; index += 1) { - const row = rowsById.get(items[index].id); + for (const item of items) { + const row = geometry.rows.get(item.id); if (!row) continue; - const rect = row.getBoundingClientRect(); - if (clientY < rect.top + rect.height / 2) { - boundaryIndex = index; - markerTop = rect.top - listRect.top + list.scrollTop; + const markerSlotTop = row.top - geometry.listTop + geometry.scrollTop - selectedSpaceBefore; + const rowTop = listRect.top + row.top - geometry.listTop - scrollDelta; + if (clientY < rowTop + row.height / 2) { + markerTop = markerSlotTop; break; } - markerTop = rect.bottom - listRect.top + list.scrollTop; + + if (selectedIds.has(item.id)) { + // Before and after a selected row are the same logical insertion slot + // once that row is removed from the list. + markerTop = markerSlotTop; + selectedSpaceBefore += row.space; + } else { + markerTop = markerSlotTop + row.height; + remainingIndex += 1; + } } return { - targetIndex: targetIndexForBoundary(items, selectedIds, boundaryIndex), + targetIndex: remainingIndex, markerTop: Math.max(0, markerTop) }; }; const clearQueueDragPreview = () => { queueDragPreviewOrderRef.current = null; + queueDragGeometryRef.current = null; queueDragBaseItemsRef.current = []; queueDragItemsRef.current = queueReorderableDownloadsRef.current; - setQueueDragPreviewOrder(null); + if (isMountedRef.current) { + setQueueDragPreviewOrder(null); + } }; const releaseQueuePointerCapture = () => { @@ -757,11 +843,26 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC } }; - const trackQueueReorderOperation = (operation: Promise): Promise => { + const trackQueueReorderOperation = (operation: () => Promise): Promise => { queueReorderPendingCountRef.current += 1; - return operation.finally(() => { - queueReorderPendingCountRef.current = Math.max(0, queueReorderPendingCountRef.current - 1); + return Promise.resolve() + .then(operation) + .finally(() => { + queueReorderPendingCountRef.current = Math.max(0, queueReorderPendingCountRef.current - 1); + }); + }; + + const queueDragBeforeId = ( + previewOrder: string[] | null, + selectedIds: ReadonlySet + ): string | null => { + if (!previewOrder) return null; + let lastSelectedIndex = -1; + previewOrder.forEach((id, index) => { + if (selectedIds.has(id)) lastSelectedIndex = index; }); + if (lastSelectedIndex === -1) return null; + return previewOrder.slice(lastSelectedIndex + 1).find(id => !selectedIds.has(id)) ?? null; }; const finishQueueDrag = (cancelled = false) => { @@ -769,14 +870,16 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC if (!current) return; const cleanup = queueDragCleanupRef.current; queueDragCleanupRef.current = null; + queueDragStateRef.current = null; + if (isMountedRef.current) { + setQueueDragState(null); + } try { cleanup?.(); } catch (error) { console.error('Failed to clean up queue drag listeners:', error); } finally { releaseQueuePointerCapture(); - queueDragStateRef.current = null; - setQueueDragState(null); } if (current.active) { suppressQueueClickRef.current = true; @@ -787,8 +890,17 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC if (!cancelled && current.active) { const committedPreviewOrder = queueDragPreviewOrderRef.current; + const targetBeforeId = queueDragBeforeId( + committedPreviewOrder, + new Set(current.ids) + ); const reorderOperation = trackQueueReorderOperation( - moveManyInQueueToPosition(current.ids, current.queueId, current.targetIndex) + () => moveManyInQueueToPosition( + current.ids, + current.queueId, + current.targetIndex, + targetBeforeId + ) ); void reorderOperation .catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error)) @@ -805,6 +917,7 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC } window.requestAnimationFrame(() => { + if (!isMountedRef.current) return; queueRowForId(current.sourceId)?.focus({ preventScroll: true }); }); }; @@ -813,11 +926,18 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC id: string, event: React.PointerEvent ) => { - const captureTarget = event.currentTarget; + // Keep pointer capture on the persistent list instead of the row being + // reordered. React moves row nodes during the live preview; a row-owned + // capture can be lost when that node changes position in the DOM. + const captureTarget = queueListElementRef.current ?? event.currentTarget; const pointerId = event.pointerId; if ( !queueReorderingEnabled || event.button !== 0 || + event.shiftKey || + event.metaKey || + event.ctrlKey || + event.altKey || queueDragStateRef.current || queueDragPreviewOrderRef.current || queueReorderPendingCountRef.current > 0 @@ -854,9 +974,11 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC clearQueueDragPreview(); const queueId = source.queueId || MAIN_QUEUE_ID; const selectedIdSet = new Set(ids); + const sourceRect = event.currentTarget.getBoundingClientRect(); const initialItems = queueDragItemsRef.current .filter(download => (download.queueId || MAIN_QUEUE_ID) === queueId); queueDragBaseItemsRef.current = initialItems; + queueDragGeometryRef.current = captureQueueDragGeometry(initialItems); const initialPosition = queueDropPosition(event.clientY, initialItems, selectedIdSet); const initialState: QueueDragState = { pointerId: event.pointerId, @@ -865,6 +987,8 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC ids, startX: event.clientX, startY: event.clientY, + pointerY: event.clientY, + pointerOffsetY: event.clientY - sourceRect.top, active: false, ...initialPosition }; @@ -879,39 +1003,91 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC // Window-level listeners remain the best-effort cleanup fallback. } - const pointerMove = (pointerEvent: PointerEvent) => { - if (pointerEvent.pointerId !== pointerId) return; + let pointerMoveFrame: number | null = null; + let pendingPointerPosition: { clientX: number; clientY: number } | null = null; + + const applyPointerMove = (clientX: number, clientY: number) => { const drag = queueDragStateRef.current; if (!drag) return; const distance = Math.hypot( - pointerEvent.clientX - drag.startX, - pointerEvent.clientY - drag.startY + clientX - drag.startX, + clientY - drag.startY ); if (!drag.active && distance < 5) return; - const items = queueDragItemsRef.current - .filter(download => (download.queueId || MAIN_QUEUE_ID) === drag.queueId); const baseItems = queueDragBaseItemsRef.current; - const nextPosition = queueDropPosition(pointerEvent.clientY, items, new Set(drag.ids)); + const selectedIdSet = new Set(drag.ids); + const nextPosition = queueDropPosition(clientY, baseItems, selectedIdSet); const previewItems = moveSelectedBlockToIndex( baseItems, - new Set(drag.ids), + selectedIdSet, nextPosition.targetIndex ); - queueDragItemsRef.current = previewItems; - queueDragPreviewOrderRef.current = previewItems.map(item => item.id); - setQueueDragPreviewOrder(queueDragPreviewOrderRef.current); + const nextPreviewOrder = previewItems.map(item => item.id); + const currentPreviewOrder = queueDragPreviewOrderRef.current; + const previewChanged = currentPreviewOrder === null || + currentPreviewOrder.length !== nextPreviewOrder.length || + currentPreviewOrder.some((itemId, index) => itemId !== nextPreviewOrder[index]); + if (previewChanged) { + queueDragItemsRef.current = previewItems; + queueDragPreviewOrderRef.current = nextPreviewOrder; + setQueueDragPreviewOrder(nextPreviewOrder); + } suppressQueueClickRef.current = true; - const nextState = { ...drag, ...nextPosition, active: true }; - queueDragStateRef.current = nextState; - setQueueDragState(nextState); + if ( + !drag.active || + previewChanged || + nextPosition.markerTop !== drag.markerTop || + clientY !== drag.pointerY + ) { + const nextState = { ...drag, ...nextPosition, active: true }; + nextState.pointerY = clientY; + queueDragStateRef.current = nextState; + setQueueDragState(nextState); + } + }; + + const flushPointerMove = () => { + if (pointerMoveFrame !== null) { + window.cancelAnimationFrame(pointerMoveFrame); + pointerMoveFrame = null; + } + const pending = pendingPointerPosition; + pendingPointerPosition = null; + if (pending) applyPointerMove(pending.clientX, pending.clientY); + }; + + const pointerMove = (pointerEvent: PointerEvent) => { + if (pointerEvent.pointerId !== pointerId) return; + pendingPointerPosition = { + clientX: pointerEvent.clientX, + clientY: pointerEvent.clientY, + }; pointerEvent.preventDefault(); + if (pointerMoveFrame === null) { + pointerMoveFrame = window.requestAnimationFrame(() => { + pointerMoveFrame = null; + const pending = pendingPointerPosition; + pendingPointerPosition = null; + if (pending) applyPointerMove(pending.clientX, pending.clientY); + }); + } }; const pointerUp = (pointerEvent: PointerEvent) => { - if (pointerEvent.pointerId === pointerId) finishQueueDrag(); + if (pointerEvent.pointerId === pointerId) { + flushPointerMove(); + finishQueueDrag(); + } }; const pointerCancel = (pointerEvent: PointerEvent) => { - if (pointerEvent.pointerId === pointerId) finishQueueDrag(true); + if (pointerEvent.pointerId === pointerId) { + pendingPointerPosition = null; + if (pointerMoveFrame !== null) { + window.cancelAnimationFrame(pointerMoveFrame); + pointerMoveFrame = null; + } + finishQueueDrag(true); + } }; const lostPointerCapture = () => finishQueueDrag(true); const cancel = () => finishQueueDrag(true); @@ -922,6 +1098,11 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC document.addEventListener('visibilitychange', cancel); captureTarget.addEventListener('lostpointercapture', lostPointerCapture); queueDragCleanupRef.current = () => { + pendingPointerPosition = null; + if (pointerMoveFrame !== null) { + window.cancelAnimationFrame(pointerMoveFrame); + pointerMoveFrame = null; + } window.removeEventListener('pointermove', pointerMove); window.removeEventListener('pointerup', pointerUp); window.removeEventListener('pointercancel', pointerCancel); @@ -1220,7 +1401,7 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC ? Array.from(selectedIdsRef.current).filter(selectedId => currentQueueIds.has(selectedId)) : [id]; if (ids.length === 0) return; - void trackQueueReorderOperation(moveInQueue(ids, direction)) + void trackQueueReorderOperation(() => moveInQueue(ids, direction)) .catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error)); }, [moveInQueue, showInteractionError, t]); @@ -1229,7 +1410,7 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC queueDragStateRef.current || queueDragPreviewOrderRef.current ) return; - void trackQueueReorderOperation(moveInQueue(ids, direction)) + void trackQueueReorderOperation(() => moveInQueue(ids, direction)) .catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error)); }, [moveInQueue, showInteractionError, t]); @@ -1391,6 +1572,21 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC const contextMenuPosition = contextMenu ? clampMenuPosition(contextMenu.x, contextMenu.y, 200, 300) : null; + const queueDragPreviewItem = queueDragState?.active + ? queueDragState.ids + .map(id => queueReorderableDownloads.find(download => download.id === id)) + .find((download): download is DownloadItem => Boolean(download)) + : null; + const queueDragPreviewTop = (() => { + if (!queueDragState?.active) return 0; + const list = queueListElementRef.current; + if (!list) return 0; + const listRect = list.getBoundingClientRect(); + return Math.max( + 0, + queueDragState.pointerY - listRect.top + list.scrollTop - queueDragState.pointerOffsetY + ); + })(); return (
@@ -1665,6 +1861,25 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC onClick={handleItemClick} /> ))} + {queueDragState?.active && queueDragPreviewItem ? ( + + ) : null} {queueDragState?.active ? (
span:first-child { + display: block; min-width: 0; overflow: hidden; + padding-block: 1px; text-overflow: ellipsis; + line-height: 1.2; } .downloads-queue-reorder-shortcut { @@ -2213,6 +2217,55 @@ body.is-column-resizing .column-resize-handle:hover::after { background: hsl(var(--accent-color)); box-shadow: 0 0 0 1px hsl(var(--accent-color) / 0.18), 0 0 8px hsl(var(--accent-color) / 0.55); transform: translateY(-1px); + transition: top 100ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 100ms ease; + will-change: top; +} + +.download-queue-drag-preview { + position: absolute; + inset-inline: 0; + z-index: 10; + display: flex; + align-items: center; + gap: 10px; + height: var(--download-row-height); + margin: var(--download-row-margin) 0; + padding: 0 var(--download-row-padding-x); + overflow: hidden; + border: 1px solid hsl(var(--accent-color) / 0.58); + border-radius: 6px; + background: hsl(var(--surface-overlay) / 0.94); + box-shadow: 0 8px 20px hsl(0 0% 0% / 0.28), 0 0 0 1px hsl(var(--accent-color) / 0.14); + color: hsl(var(--text-primary)); + font-size: var(--download-row-font-size); + pointer-events: none; + transform: translateZ(0); + transition: top 70ms linear; + will-change: top; +} + +.download-queue-drag-preview-icon { + display: inline-flex; + flex: 0 0 auto; + align-items: center; +} + +.download-queue-drag-preview-name { + min-width: 0; + overflow: hidden; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.download-queue-drag-preview-count { + flex: 0 0 auto; + padding: 2px 6px; + border-radius: 999px; + background: hsl(var(--accent-color) / 0.2); + color: hsl(var(--text-primary)); + font-size: 10px; + font-weight: 700; } .download-row { @@ -2267,18 +2320,25 @@ html[data-list-density="relaxed"] .download-ghost-row { } .download-row.is-queue-drag-source { - opacity: 0.48; + opacity: 0.16; + box-shadow: inset 0 0 0 1px hsl(var(--accent-color) / 0.3); } .download-row.is-queue-reorderable { cursor: grab; touch-action: none; + transition: background-color 120ms ease, box-shadow 120ms ease, opacity 120ms ease; } .download-row.is-queue-reorderable:active { cursor: grabbing; } +body.is-queue-dragging, +body.is-queue-dragging * { + cursor: grabbing !important; +} + .download-row:focus-visible { outline: 2px solid hsl(var(--accent-color) / 0.7); outline-offset: -1px; diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 4e432f8..fcb1ede 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -1826,6 +1826,40 @@ describe('useDownloadStore', () => { expect(useDownloadStore.getState().downloads.find(item => item.id === 'staged')?.queuePosition).toBe(2); }); + it('keeps a drag anchored when the queue changes before its serialized operation runs', async () => { + useDownloadStore.setState({ + downloads: [ + { id: 'a', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 0 }, + { id: 'b', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 1 }, + { id: 'c', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 2 } + ] as any[], + backendRegisteredIds: new Set() + }); + + const operation = useDownloadStore.getState().moveManyInQueueToPosition( + 'b', + 'concurrent-drag-queue', + 2, + 'c' + ); + useDownloadStore.setState({ + downloads: [ + { id: 'a', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 0 }, + { id: 'b', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 1 }, + { id: 'new', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 2 }, + { id: 'c', status: 'queued', queueId: 'concurrent-drag-queue', queuePosition: 3 } + ] as any[] + }); + + await operation; + + expect(useDownloadStore.getState().downloads + .filter(item => item.queueId === 'concurrent-drag-queue') + .sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0)) + .map(item => item.id) + ).toEqual(['a', 'new', 'b', 'c']); + }); + it('rolls back a failed drag move and rejects so the UI can report the failure', async () => { useDownloadStore.setState({ downloads: [ diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 87e6dca..e4b38d2 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -707,7 +707,12 @@ interface DownloadState { unregisterBackendIds: (ids: string[]) => void; applyProperties: (id: string, updates: Partial) => Promise; moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise; - moveManyInQueueToPosition: (ids: string | string[], queueId: string, targetIndex: number) => Promise; + moveManyInQueueToPosition: ( + ids: string | string[], + queueId: string, + targetIndex: number, + beforeId?: string | null + ) => Promise; removeFromQueue: (id: string) => Promise; isAddModalOpen: boolean; pendingAddUrls: string; @@ -959,7 +964,7 @@ export const useDownloadStore = create((set, get) => { queueReorderPromises.set(queueId, trackedOperation); return trackedOperation; }, - moveManyInQueueToPosition: (idOrIds, queueId, targetIndex) => { + moveManyInQueueToPosition: (idOrIds, queueId, targetIndex, beforeId) => { const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; if (ids.length === 0) return Promise.resolve(); @@ -978,7 +983,14 @@ export const useDownloadStore = create((set, get) => { ...activeQueueItems(allDownloads, queueId), ...queueItems ].map(item => [item.id, item.queuePosition])); - const reordered = moveSelectedBlockToIndex(queueItems, selectedIds, targetIndex); + const unselectedItems = queueItems.filter(item => !selectedIds.has(item.id)); + const anchoredTargetIndex = beforeId + ? unselectedItems.findIndex(item => item.id === beforeId) + : -1; + const resolvedTargetIndex = anchoredTargetIndex >= 0 + ? anchoredTargetIndex + : Math.max(0, Math.min(targetIndex, unselectedItems.length)); + const reordered = moveSelectedBlockToIndex(queueItems, selectedIds, resolvedTargetIndex); set(state => ({ downloads: applyQueueOrder(state.downloads, queueId, reordered) })); const registeredIdsToMove = selectedItems diff --git a/src/utils/queueOrdering.test.ts b/src/utils/queueOrdering.test.ts index 75c3003..43e5aa3 100644 --- a/src/utils/queueOrdering.test.ts +++ b/src/utils/queueOrdering.test.ts @@ -13,6 +13,11 @@ describe('queue ordering', () => { .toEqual(['a', 'b', 'd', 'c']); }); + it('moves a selected block to the end for a downward drop', () => { + expect(moveSelectedBlockToIndex(items, ['b', 'c'], 2).map(item => item.id)) + .toEqual(['a', 'd', 'b', 'c']); + }); + it('translates pointer boundaries after selected rows are removed', () => { expect(targetIndexForBoundary(items, ['b', 'd'], 2)).toBe(1); expect(targetIndexForBoundary(items, ['b', 'd'], 3)).toBe(2);