mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(downloads): harden queue drag and reorder behavior
This commit is contained in:
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user