fix(downloads): harden queue drag and reorder behavior

This commit is contained in:
NimBold
2026-07-23 07:40:02 +03:30
parent 18e9200b74
commit 9302911ac7
6 changed files with 388 additions and 51 deletions
+12 -1
View File
@@ -388,7 +388,18 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
}
}}
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 => {
+259 -44
View File
@@ -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<string, QueueDragRowGeometry>;
}
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryChange }) => {
const { t } = useTranslation();
const {
@@ -146,7 +159,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}, []);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
const [animationParent] = useAutoAnimate<HTMLDivElement>();
const [animationParent] = useAutoAnimate<HTMLDivElement>({
duration: 120,
easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)',
});
const [headerAnimationParent, setHeaderAnimationEnabled] = useAutoAnimate<HTMLDivElement>({
duration: 140,
easing: 'ease-out',
@@ -190,10 +206,22 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const queueDragCaptureTargetRef = useRef<HTMLElement | null>(null);
const queueDragCapturePointerIdRef = useRef<number | null>(null);
const queueDragPreviewOrderRef = useRef<string[] | null>(null);
const queueDragGeometryRef = useRef<QueueDragGeometry | null>(null);
const queueReorderPendingCountRef = useRef(0);
const suppressQueueClickRef = useRef(false);
const [queueDragState, setQueueDragState] = useState<QueueDragState | null>(null);
const [queueDragPreviewOrder, setQueueDragPreviewOrder] = useState<string[] | null>(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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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<string, QueueDragRowGeometry>();
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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ filter, onSummaryC
}
};
const trackQueueReorderOperation = (operation: Promise<void>): Promise<void> => {
const trackQueueReorderOperation = (operation: () => Promise<void>): Promise<void> => {
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>
): 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ filter, onSummaryC
}
window.requestAnimationFrame(() => {
if (!isMountedRef.current) return;
queueRowForId(current.sourceId)?.focus({ preventScroll: true });
});
};
@@ -813,11 +926,18 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
id: string,
event: React.PointerEvent<HTMLDivElement>
) => {
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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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 (
<div ref={downloadsViewRef} className="downloads-view flex-1 flex flex-col h-full min-w-0">
@@ -1665,6 +1861,25 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
onClick={handleItemClick}
/>
))}
{queueDragState?.active && queueDragPreviewItem ? (
<div
className="download-queue-drag-preview"
style={{ top: `${queueDragPreviewTop}px` }}
aria-hidden="true"
>
<span className="download-queue-drag-preview-icon">
{getCategoryIcon(queueDragPreviewItem.category)}
</span>
<span className="download-queue-drag-preview-name">
{queueDragPreviewItem.fileName}
</span>
{queueDragState.ids.length > 1 ? (
<span className="download-queue-drag-preview-count">
+{queueDragState.ids.length - 1}
</span>
) : null}
</div>
) : null}
{queueDragState?.active ? (
<div
className="download-queue-drop-marker"
+63 -3
View File
@@ -1981,20 +1981,24 @@ html[data-list-density="relaxed"] {
gap: 7px;
min-width: 0;
max-width: 420px;
overflow: hidden;
min-height: 17px;
overflow: visible;
color: hsl(var(--text-muted));
font-size: 10px;
font-weight: 500;
line-height: 1;
line-height: 1.2;
letter-spacing: 0;
white-space: nowrap;
text-overflow: ellipsis;
}
.downloads-queue-reorder-hint > 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;
+34
View File
@@ -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: [
+15 -3
View File
@@ -707,7 +707,12 @@ interface DownloadState {
unregisterBackendIds: (ids: string[]) => void;
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise<void>;
moveManyInQueueToPosition: (ids: string | string[], queueId: string, targetIndex: number) => Promise<void>;
moveManyInQueueToPosition: (
ids: string | string[],
queueId: string,
targetIndex: number,
beforeId?: string | null
) => Promise<void>;
removeFromQueue: (id: string) => Promise<void>;
isAddModalOpen: boolean;
pendingAddUrls: string;
@@ -959,7 +964,7 @@ export const useDownloadStore = create<DownloadState>((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<DownloadState>((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
+5
View File
@@ -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);