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 => {
|
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)}
|
onClick={(e) => onClick(e, download)}
|
||||||
onKeyDown={event => {
|
onKeyDown={event => {
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ import {
|
|||||||
type DownloadTableColumnKey
|
type DownloadTableColumnKey
|
||||||
} from '../utils/downloadTableColumns';
|
} from '../utils/downloadTableColumns';
|
||||||
import {
|
import {
|
||||||
moveSelectedBlockToIndex,
|
moveSelectedBlockToIndex
|
||||||
targetIndexForBoundary
|
|
||||||
} from '../utils/queueOrdering';
|
} from '../utils/queueOrdering';
|
||||||
|
|
||||||
export interface DownloadTableStatusSummary {
|
export interface DownloadTableStatusSummary {
|
||||||
@@ -115,11 +114,25 @@ interface QueueDragState {
|
|||||||
ids: string[];
|
ids: string[];
|
||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
|
pointerY: number;
|
||||||
|
pointerOffsetY: number;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
targetIndex: number;
|
targetIndex: number;
|
||||||
markerTop: 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 }) => {
|
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryChange }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
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 [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>({
|
const [headerAnimationParent, setHeaderAnimationEnabled] = useAutoAnimate<HTMLDivElement>({
|
||||||
duration: 140,
|
duration: 140,
|
||||||
easing: 'ease-out',
|
easing: 'ease-out',
|
||||||
@@ -190,10 +206,22 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
const queueDragCaptureTargetRef = useRef<HTMLElement | null>(null);
|
const queueDragCaptureTargetRef = useRef<HTMLElement | null>(null);
|
||||||
const queueDragCapturePointerIdRef = useRef<number | null>(null);
|
const queueDragCapturePointerIdRef = useRef<number | null>(null);
|
||||||
const queueDragPreviewOrderRef = useRef<string[] | null>(null);
|
const queueDragPreviewOrderRef = useRef<string[] | null>(null);
|
||||||
|
const queueDragGeometryRef = useRef<QueueDragGeometry | null>(null);
|
||||||
const queueReorderPendingCountRef = useRef(0);
|
const queueReorderPendingCountRef = useRef(0);
|
||||||
const suppressQueueClickRef = useRef(false);
|
const suppressQueueClickRef = useRef(false);
|
||||||
const [queueDragState, setQueueDragState] = useState<QueueDragState | null>(null);
|
const [queueDragState, setQueueDragState] = useState<QueueDragState | null>(null);
|
||||||
const [queueDragPreviewOrder, setQueueDragPreviewOrder] = useState<string[] | 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;
|
selectedIdsRef.current = selectedIds;
|
||||||
lastSelectedIdRef.current = lastSelectedId;
|
lastSelectedIdRef.current = lastSelectedId;
|
||||||
const [columnWidths, setColumnWidths] = useState(() => {
|
const [columnWidths, setColumnWidths] = useState(() => {
|
||||||
@@ -590,7 +618,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
resizeCleanupRef.current?.();
|
resizeCleanupRef.current?.();
|
||||||
columnDragCleanupRef.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;
|
columnDragCleanupRef.current = null;
|
||||||
queueDragCleanupRef.current = null;
|
queueDragCleanupRef.current = null;
|
||||||
queueDragStateRef.current = null;
|
queueDragStateRef.current = null;
|
||||||
@@ -598,8 +630,12 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
const queueCapturePointerId = queueDragCapturePointerIdRef.current;
|
const queueCapturePointerId = queueDragCapturePointerIdRef.current;
|
||||||
queueDragCaptureTargetRef.current = null;
|
queueDragCaptureTargetRef.current = null;
|
||||||
queueDragCapturePointerIdRef.current = null;
|
queueDragCapturePointerIdRef.current = null;
|
||||||
if (queueCaptureTarget && queueCapturePointerId !== null && queueCaptureTarget.hasPointerCapture(queueCapturePointerId)) {
|
try {
|
||||||
queueCaptureTarget.releasePointerCapture(queueCapturePointerId);
|
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 captureTarget = columnDragCaptureTargetRef.current;
|
||||||
const capturePointerId = columnDragCapturePointerIdRef.current;
|
const capturePointerId = columnDragCapturePointerIdRef.current;
|
||||||
@@ -703,6 +739,35 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
return queueRowsById(list).get(id) || null;
|
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 = (
|
const queueDropPosition = (
|
||||||
clientY: number,
|
clientY: number,
|
||||||
items: DownloadItem[],
|
items: DownloadItem[],
|
||||||
@@ -711,33 +776,54 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
const list = queueListElementRef.current;
|
const list = queueListElementRef.current;
|
||||||
if (!list || items.length === 0) return { targetIndex: 0, markerTop: 0 };
|
if (!list || items.length === 0) return { targetIndex: 0, markerTop: 0 };
|
||||||
const listRect = list.getBoundingClientRect();
|
const listRect = list.getBoundingClientRect();
|
||||||
// Build the DOM index once per pointer update. Looking up each row by
|
const geometry = queueDragGeometryRef.current;
|
||||||
// scanning list.children inside the loop made large queue drags O(n^2).
|
if (!geometry) return { targetIndex: 0, markerTop: 0 };
|
||||||
const rowsById = queueRowsById(list);
|
|
||||||
let boundaryIndex = items.length;
|
// 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;
|
let markerTop = 0;
|
||||||
for (let index = 0; index < items.length; index += 1) {
|
for (const item of items) {
|
||||||
const row = rowsById.get(items[index].id);
|
const row = geometry.rows.get(item.id);
|
||||||
if (!row) continue;
|
if (!row) continue;
|
||||||
const rect = row.getBoundingClientRect();
|
const markerSlotTop = row.top - geometry.listTop + geometry.scrollTop - selectedSpaceBefore;
|
||||||
if (clientY < rect.top + rect.height / 2) {
|
const rowTop = listRect.top + row.top - geometry.listTop - scrollDelta;
|
||||||
boundaryIndex = index;
|
if (clientY < rowTop + row.height / 2) {
|
||||||
markerTop = rect.top - listRect.top + list.scrollTop;
|
markerTop = markerSlotTop;
|
||||||
break;
|
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 {
|
return {
|
||||||
targetIndex: targetIndexForBoundary(items, selectedIds, boundaryIndex),
|
targetIndex: remainingIndex,
|
||||||
markerTop: Math.max(0, markerTop)
|
markerTop: Math.max(0, markerTop)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearQueueDragPreview = () => {
|
const clearQueueDragPreview = () => {
|
||||||
queueDragPreviewOrderRef.current = null;
|
queueDragPreviewOrderRef.current = null;
|
||||||
|
queueDragGeometryRef.current = null;
|
||||||
queueDragBaseItemsRef.current = [];
|
queueDragBaseItemsRef.current = [];
|
||||||
queueDragItemsRef.current = queueReorderableDownloadsRef.current;
|
queueDragItemsRef.current = queueReorderableDownloadsRef.current;
|
||||||
setQueueDragPreviewOrder(null);
|
if (isMountedRef.current) {
|
||||||
|
setQueueDragPreviewOrder(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const releaseQueuePointerCapture = () => {
|
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;
|
queueReorderPendingCountRef.current += 1;
|
||||||
return operation.finally(() => {
|
return Promise.resolve()
|
||||||
queueReorderPendingCountRef.current = Math.max(0, queueReorderPendingCountRef.current - 1);
|
.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) => {
|
const finishQueueDrag = (cancelled = false) => {
|
||||||
@@ -769,14 +870,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
if (!current) return;
|
if (!current) return;
|
||||||
const cleanup = queueDragCleanupRef.current;
|
const cleanup = queueDragCleanupRef.current;
|
||||||
queueDragCleanupRef.current = null;
|
queueDragCleanupRef.current = null;
|
||||||
|
queueDragStateRef.current = null;
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setQueueDragState(null);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to clean up queue drag listeners:', error);
|
console.error('Failed to clean up queue drag listeners:', error);
|
||||||
} finally {
|
} finally {
|
||||||
releaseQueuePointerCapture();
|
releaseQueuePointerCapture();
|
||||||
queueDragStateRef.current = null;
|
|
||||||
setQueueDragState(null);
|
|
||||||
}
|
}
|
||||||
if (current.active) {
|
if (current.active) {
|
||||||
suppressQueueClickRef.current = true;
|
suppressQueueClickRef.current = true;
|
||||||
@@ -787,8 +890,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
|
|
||||||
if (!cancelled && current.active) {
|
if (!cancelled && current.active) {
|
||||||
const committedPreviewOrder = queueDragPreviewOrderRef.current;
|
const committedPreviewOrder = queueDragPreviewOrderRef.current;
|
||||||
|
const targetBeforeId = queueDragBeforeId(
|
||||||
|
committedPreviewOrder,
|
||||||
|
new Set(current.ids)
|
||||||
|
);
|
||||||
const reorderOperation = trackQueueReorderOperation(
|
const reorderOperation = trackQueueReorderOperation(
|
||||||
moveManyInQueueToPosition(current.ids, current.queueId, current.targetIndex)
|
() => moveManyInQueueToPosition(
|
||||||
|
current.ids,
|
||||||
|
current.queueId,
|
||||||
|
current.targetIndex,
|
||||||
|
targetBeforeId
|
||||||
|
)
|
||||||
);
|
);
|
||||||
void reorderOperation
|
void reorderOperation
|
||||||
.catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error))
|
.catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error))
|
||||||
@@ -805,6 +917,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
queueRowForId(current.sourceId)?.focus({ preventScroll: true });
|
queueRowForId(current.sourceId)?.focus({ preventScroll: true });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -813,11 +926,18 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
id: string,
|
id: string,
|
||||||
event: React.PointerEvent<HTMLDivElement>
|
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;
|
const pointerId = event.pointerId;
|
||||||
if (
|
if (
|
||||||
!queueReorderingEnabled ||
|
!queueReorderingEnabled ||
|
||||||
event.button !== 0 ||
|
event.button !== 0 ||
|
||||||
|
event.shiftKey ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.altKey ||
|
||||||
queueDragStateRef.current ||
|
queueDragStateRef.current ||
|
||||||
queueDragPreviewOrderRef.current ||
|
queueDragPreviewOrderRef.current ||
|
||||||
queueReorderPendingCountRef.current > 0
|
queueReorderPendingCountRef.current > 0
|
||||||
@@ -854,9 +974,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
clearQueueDragPreview();
|
clearQueueDragPreview();
|
||||||
const queueId = source.queueId || MAIN_QUEUE_ID;
|
const queueId = source.queueId || MAIN_QUEUE_ID;
|
||||||
const selectedIdSet = new Set(ids);
|
const selectedIdSet = new Set(ids);
|
||||||
|
const sourceRect = event.currentTarget.getBoundingClientRect();
|
||||||
const initialItems = queueDragItemsRef.current
|
const initialItems = queueDragItemsRef.current
|
||||||
.filter(download => (download.queueId || MAIN_QUEUE_ID) === queueId);
|
.filter(download => (download.queueId || MAIN_QUEUE_ID) === queueId);
|
||||||
queueDragBaseItemsRef.current = initialItems;
|
queueDragBaseItemsRef.current = initialItems;
|
||||||
|
queueDragGeometryRef.current = captureQueueDragGeometry(initialItems);
|
||||||
const initialPosition = queueDropPosition(event.clientY, initialItems, selectedIdSet);
|
const initialPosition = queueDropPosition(event.clientY, initialItems, selectedIdSet);
|
||||||
const initialState: QueueDragState = {
|
const initialState: QueueDragState = {
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
@@ -865,6 +987,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
ids,
|
ids,
|
||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
startY: event.clientY,
|
startY: event.clientY,
|
||||||
|
pointerY: event.clientY,
|
||||||
|
pointerOffsetY: event.clientY - sourceRect.top,
|
||||||
active: false,
|
active: false,
|
||||||
...initialPosition
|
...initialPosition
|
||||||
};
|
};
|
||||||
@@ -879,39 +1003,91 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
// Window-level listeners remain the best-effort cleanup fallback.
|
// Window-level listeners remain the best-effort cleanup fallback.
|
||||||
}
|
}
|
||||||
|
|
||||||
const pointerMove = (pointerEvent: PointerEvent) => {
|
let pointerMoveFrame: number | null = null;
|
||||||
if (pointerEvent.pointerId !== pointerId) return;
|
let pendingPointerPosition: { clientX: number; clientY: number } | null = null;
|
||||||
|
|
||||||
|
const applyPointerMove = (clientX: number, clientY: number) => {
|
||||||
const drag = queueDragStateRef.current;
|
const drag = queueDragStateRef.current;
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
const distance = Math.hypot(
|
const distance = Math.hypot(
|
||||||
pointerEvent.clientX - drag.startX,
|
clientX - drag.startX,
|
||||||
pointerEvent.clientY - drag.startY
|
clientY - drag.startY
|
||||||
);
|
);
|
||||||
if (!drag.active && distance < 5) return;
|
if (!drag.active && distance < 5) return;
|
||||||
|
|
||||||
const items = queueDragItemsRef.current
|
|
||||||
.filter(download => (download.queueId || MAIN_QUEUE_ID) === drag.queueId);
|
|
||||||
const baseItems = queueDragBaseItemsRef.current;
|
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(
|
const previewItems = moveSelectedBlockToIndex(
|
||||||
baseItems,
|
baseItems,
|
||||||
new Set(drag.ids),
|
selectedIdSet,
|
||||||
nextPosition.targetIndex
|
nextPosition.targetIndex
|
||||||
);
|
);
|
||||||
queueDragItemsRef.current = previewItems;
|
const nextPreviewOrder = previewItems.map(item => item.id);
|
||||||
queueDragPreviewOrderRef.current = previewItems.map(item => item.id);
|
const currentPreviewOrder = queueDragPreviewOrderRef.current;
|
||||||
setQueueDragPreviewOrder(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;
|
suppressQueueClickRef.current = true;
|
||||||
const nextState = { ...drag, ...nextPosition, active: true };
|
if (
|
||||||
queueDragStateRef.current = nextState;
|
!drag.active ||
|
||||||
setQueueDragState(nextState);
|
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();
|
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) => {
|
const pointerUp = (pointerEvent: PointerEvent) => {
|
||||||
if (pointerEvent.pointerId === pointerId) finishQueueDrag();
|
if (pointerEvent.pointerId === pointerId) {
|
||||||
|
flushPointerMove();
|
||||||
|
finishQueueDrag();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const pointerCancel = (pointerEvent: PointerEvent) => {
|
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 lostPointerCapture = () => finishQueueDrag(true);
|
||||||
const cancel = () => finishQueueDrag(true);
|
const cancel = () => finishQueueDrag(true);
|
||||||
@@ -922,6 +1098,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
document.addEventListener('visibilitychange', cancel);
|
document.addEventListener('visibilitychange', cancel);
|
||||||
captureTarget.addEventListener('lostpointercapture', lostPointerCapture);
|
captureTarget.addEventListener('lostpointercapture', lostPointerCapture);
|
||||||
queueDragCleanupRef.current = () => {
|
queueDragCleanupRef.current = () => {
|
||||||
|
pendingPointerPosition = null;
|
||||||
|
if (pointerMoveFrame !== null) {
|
||||||
|
window.cancelAnimationFrame(pointerMoveFrame);
|
||||||
|
pointerMoveFrame = null;
|
||||||
|
}
|
||||||
window.removeEventListener('pointermove', pointerMove);
|
window.removeEventListener('pointermove', pointerMove);
|
||||||
window.removeEventListener('pointerup', pointerUp);
|
window.removeEventListener('pointerup', pointerUp);
|
||||||
window.removeEventListener('pointercancel', pointerCancel);
|
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))
|
? Array.from(selectedIdsRef.current).filter(selectedId => currentQueueIds.has(selectedId))
|
||||||
: [id];
|
: [id];
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
void trackQueueReorderOperation(moveInQueue(ids, direction))
|
void trackQueueReorderOperation(() => moveInQueue(ids, direction))
|
||||||
.catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error));
|
.catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error));
|
||||||
}, [moveInQueue, showInteractionError, t]);
|
}, [moveInQueue, showInteractionError, t]);
|
||||||
|
|
||||||
@@ -1229,7 +1410,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
queueDragStateRef.current ||
|
queueDragStateRef.current ||
|
||||||
queueDragPreviewOrderRef.current
|
queueDragPreviewOrderRef.current
|
||||||
) return;
|
) return;
|
||||||
void trackQueueReorderOperation(moveInQueue(ids, direction))
|
void trackQueueReorderOperation(() => moveInQueue(ids, direction))
|
||||||
.catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error));
|
.catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error));
|
||||||
}, [moveInQueue, showInteractionError, t]);
|
}, [moveInQueue, showInteractionError, t]);
|
||||||
|
|
||||||
@@ -1391,6 +1572,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
const contextMenuPosition = contextMenu
|
const contextMenuPosition = contextMenu
|
||||||
? clampMenuPosition(contextMenu.x, contextMenu.y, 200, 300)
|
? clampMenuPosition(contextMenu.x, contextMenu.y, 200, 300)
|
||||||
: null;
|
: 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 (
|
return (
|
||||||
<div ref={downloadsViewRef} className="downloads-view flex-1 flex flex-col h-full min-w-0">
|
<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}
|
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 ? (
|
{queueDragState?.active ? (
|
||||||
<div
|
<div
|
||||||
className="download-queue-drop-marker"
|
className="download-queue-drop-marker"
|
||||||
|
|||||||
+63
-3
@@ -1981,20 +1981,24 @@ html[data-list-density="relaxed"] {
|
|||||||
gap: 7px;
|
gap: 7px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
overflow: hidden;
|
min-height: 17px;
|
||||||
|
overflow: visible;
|
||||||
color: hsl(var(--text-muted));
|
color: hsl(var(--text-muted));
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1;
|
line-height: 1.2;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-queue-reorder-hint > span:first-child {
|
.downloads-queue-reorder-hint > span:first-child {
|
||||||
|
display: block;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding-block: 1px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-queue-reorder-shortcut {
|
.downloads-queue-reorder-shortcut {
|
||||||
@@ -2213,6 +2217,55 @@ body.is-column-resizing .column-resize-handle:hover::after {
|
|||||||
background: hsl(var(--accent-color));
|
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);
|
box-shadow: 0 0 0 1px hsl(var(--accent-color) / 0.18), 0 0 8px hsl(var(--accent-color) / 0.55);
|
||||||
transform: translateY(-1px);
|
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 {
|
.download-row {
|
||||||
@@ -2267,18 +2320,25 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.download-row.is-queue-drag-source {
|
.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 {
|
.download-row.is-queue-reorderable {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
transition: background-color 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-row.is-queue-reorderable:active {
|
.download-row.is-queue-reorderable:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.is-queue-dragging,
|
||||||
|
body.is-queue-dragging * {
|
||||||
|
cursor: grabbing !important;
|
||||||
|
}
|
||||||
|
|
||||||
.download-row:focus-visible {
|
.download-row:focus-visible {
|
||||||
outline: 2px solid hsl(var(--accent-color) / 0.7);
|
outline: 2px solid hsl(var(--accent-color) / 0.7);
|
||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
|
|||||||
@@ -1826,6 +1826,40 @@ describe('useDownloadStore', () => {
|
|||||||
expect(useDownloadStore.getState().downloads.find(item => item.id === 'staged')?.queuePosition).toBe(2);
|
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 () => {
|
it('rolls back a failed drag move and rejects so the UI can report the failure', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [
|
downloads: [
|
||||||
|
|||||||
@@ -707,7 +707,12 @@ interface DownloadState {
|
|||||||
unregisterBackendIds: (ids: string[]) => void;
|
unregisterBackendIds: (ids: string[]) => void;
|
||||||
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
|
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
|
||||||
moveInQueue: (ids: string | string[], direction: 'up' | 'down') => 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>;
|
removeFromQueue: (id: string) => Promise<void>;
|
||||||
isAddModalOpen: boolean;
|
isAddModalOpen: boolean;
|
||||||
pendingAddUrls: string;
|
pendingAddUrls: string;
|
||||||
@@ -959,7 +964,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
queueReorderPromises.set(queueId, trackedOperation);
|
queueReorderPromises.set(queueId, trackedOperation);
|
||||||
return trackedOperation;
|
return trackedOperation;
|
||||||
},
|
},
|
||||||
moveManyInQueueToPosition: (idOrIds, queueId, targetIndex) => {
|
moveManyInQueueToPosition: (idOrIds, queueId, targetIndex, beforeId) => {
|
||||||
const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
|
const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
|
||||||
if (ids.length === 0) return Promise.resolve();
|
if (ids.length === 0) return Promise.resolve();
|
||||||
|
|
||||||
@@ -978,7 +983,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
...activeQueueItems(allDownloads, queueId),
|
...activeQueueItems(allDownloads, queueId),
|
||||||
...queueItems
|
...queueItems
|
||||||
].map(item => [item.id, item.queuePosition]));
|
].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) }));
|
set(state => ({ downloads: applyQueueOrder(state.downloads, queueId, reordered) }));
|
||||||
|
|
||||||
const registeredIdsToMove = selectedItems
|
const registeredIdsToMove = selectedItems
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ describe('queue ordering', () => {
|
|||||||
.toEqual(['a', 'b', 'd', 'c']);
|
.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', () => {
|
it('translates pointer boundaries after selected rows are removed', () => {
|
||||||
expect(targetIndexForBoundary(items, ['b', 'd'], 2)).toBe(1);
|
expect(targetIndexForBoundary(items, ['b', 'd'], 2)).toBe(1);
|
||||||
expect(targetIndexForBoundary(items, ['b', 'd'], 3)).toBe(2);
|
expect(targetIndexForBoundary(items, ['b', 'd'], 3)).toBe(2);
|
||||||
|
|||||||
Reference in New Issue
Block a user