fix(downloads): animate table column reordering

This commit is contained in:
NimBold
2026-07-25 16:23:07 +03:30
parent f3af5b0584
commit d62e0429a4
2 changed files with 154 additions and 18 deletions
+6 -3
View File
@@ -189,6 +189,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
'File Name': ( 'File Name': (
<div <div
className="download-column-cell download-file-cell download-column-file-name" className="download-column-cell download-file-cell download-column-file-name"
data-column-key="File Name"
style={columnStyle('File Name')} style={columnStyle('File Name')}
> >
<div className="download-cell-content"> <div className="download-cell-content">
@@ -209,6 +210,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
Size: ( Size: (
<div <div
className="download-column-cell download-cell-truncate download-size-cell tabular-nums" className="download-column-cell download-cell-truncate download-size-cell tabular-nums"
data-column-key="Size"
style={columnStyle('Size')} style={columnStyle('Size')}
title={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel} title={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
aria-label={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel} aria-label={hasDownloadedAmount ? downloadedSizeLabel : completedSizeLabel}
@@ -231,6 +233,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
Status: ( Status: (
<div <div
className="download-column-cell download-status-cell" className="download-column-cell download-status-cell"
data-column-key="Status"
data-column-alignment={columnAlignments.Status} data-column-alignment={columnAlignments.Status}
style={columnStyle('Status')} style={columnStyle('Status')}
> >
@@ -294,21 +297,21 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</div> </div>
), ),
Speed: ( Speed: (
<div className="download-column-cell download-cell-truncate" style={columnStyle('Speed')}> <div className="download-column-cell download-cell-truncate" data-column-key="Speed" style={columnStyle('Speed')}>
<span className="download-cell-content tabular-nums" title={displaySpeed}> <span className="download-cell-content tabular-nums" title={displaySpeed}>
{displaySpeed} {displaySpeed}
</span> </span>
</div> </div>
), ),
ETA: ( ETA: (
<div className="download-column-cell download-cell-truncate" style={columnStyle('ETA')}> <div className="download-column-cell download-cell-truncate" data-column-key="ETA" style={columnStyle('ETA')}>
<span className="download-cell-content tabular-nums" title={displayEta}> <span className="download-cell-content tabular-nums" title={displayEta}>
{displayEta} {displayEta}
</span> </span>
</div> </div>
), ),
'Date Added': ( 'Date Added': (
<div className="download-column-cell download-cell-right download-column-date-added" style={columnStyle('Date Added')}> <div className="download-column-cell download-cell-right download-column-date-added" data-column-key="Date Added" style={columnStyle('Date Added')}>
<span <span
className="download-cell-content download-date-value tabular-nums" className="download-cell-content download-date-value tabular-nums"
title={dateAddedLabel} title={dateAddedLabel}
+148 -15
View File
@@ -4,7 +4,6 @@ import { useDownloadProgressStore } from '../store/downloadProgressStore';
import { useToast } from '../contexts/ToastContext'; import { useToast } from '../contexts/ToastContext';
import { useSettingsStore } from '../store/useSettingsStore'; import { useSettingsStore } from '../store/useSettingsStore';
import { SidebarFilter } from './Sidebar'; import { SidebarFilter } from './Sidebar';
import { useAutoAnimate } from '@formkit/auto-animate/react';
import { import {
Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion,
ArrowDownCircle, ArrowUp, ArrowDown, Command, ChevronRight, ChevronUp, ChevronDown, MoreHorizontal, ArrowDownCircle, ArrowUp, ArrowDown, Command, ChevronRight, ChevronUp, ChevronDown, MoreHorizontal,
@@ -87,6 +86,8 @@ const persistColumnAlignments = (alignments: Record<DownloadTableColumnKey, Down
const QUEUE_ROW_REORDER_DURATION = 180; const QUEUE_ROW_REORDER_DURATION = 180;
const QUEUE_ROW_REORDER_EASING = 'cubic-bezier(0.2, 0.8, 0.2, 1)'; const QUEUE_ROW_REORDER_EASING = 'cubic-bezier(0.2, 0.8, 0.2, 1)';
const COLUMN_REORDER_DURATION = QUEUE_ROW_REORDER_DURATION;
const COLUMN_REORDER_EASING = QUEUE_ROW_REORDER_EASING;
interface ColumnDragState { interface ColumnDragState {
key: DownloadTableColumnKey; key: DownloadTableColumnKey;
@@ -136,6 +137,11 @@ interface QueueDragGeometry {
rows: Map<string, QueueDragRowGeometry>; rows: Map<string, QueueDragRowGeometry>;
} }
interface ColumnMotionLayout {
element: HTMLElement;
left: number;
}
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryChange }) => { export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryChange }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
@@ -162,10 +168,6 @@ 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 [headerAnimationParent, setHeaderAnimationEnabled] = useAutoAnimate<HTMLDivElement>({
duration: 140,
easing: 'ease-out',
});
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null); const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
const [sortConfig, setSortConfig] = useState<DownloadSortConfig>({ column: 'Date Added', direction: 'desc' }); const [sortConfig, setSortConfig] = useState<DownloadSortConfig>({ column: 'Date Added', direction: 'desc' });
@@ -182,8 +184,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const headerContainerRef = useRef<HTMLDivElement>(null); const headerContainerRef = useRef<HTMLDivElement>(null);
const headerRef = useCallback((element: HTMLDivElement | null) => { const headerRef = useCallback((element: HTMLDivElement | null) => {
headerContainerRef.current = element; headerContainerRef.current = element;
headerAnimationParent(element); }, []);
}, [headerAnimationParent]);
const columnDragStateRef = useRef<ColumnDragState | null>(null); const columnDragStateRef = useRef<ColumnDragState | null>(null);
const columnDragOrderRef = useRef<DownloadTableColumnKey[] | null>(null); const columnDragOrderRef = useRef<DownloadTableColumnKey[] | null>(null);
const columnDragLayoutRef = useRef<Map<DownloadTableColumnKey, ColumnLayoutBounds> | null>(null); const columnDragLayoutRef = useRef<Map<DownloadTableColumnKey, ColumnLayoutBounds> | null>(null);
@@ -192,6 +193,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const columnDragCapturePointerIdRef = useRef<number | null>(null); const columnDragCapturePointerIdRef = useRef<number | null>(null);
const columnDragCleanupRef = useRef<(() => void) | null>(null); const columnDragCleanupRef = useRef<(() => void) | null>(null);
const columnDropFlashTimerRef = useRef<number | null>(null); const columnDropFlashTimerRef = useRef<number | null>(null);
const columnMotionFirstRef = useRef<ColumnMotionLayout[] | null>(null);
const columnMotionElementsRef = useRef(new Set<HTMLElement>());
const columnMotionFrameRef = useRef<number | null>(null);
const columnMotionTimerRef = useRef<number | null>(null);
const columnMotionGenerationRef = useRef(0);
const suppressHeaderClickRef = useRef(false); const suppressHeaderClickRef = useRef(false);
const selectedIdsRef = useRef(selectedIds); const selectedIdsRef = useRef(selectedIds);
const lastSelectedIdRef = useRef(lastSelectedId); const lastSelectedIdRef = useRef(lastSelectedId);
@@ -264,6 +270,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
Math.max(COLUMN_MINIMUMS[index], width) Math.max(COLUMN_MINIMUMS[index], width)
); );
const orderedColumns = columnDragOrderRef.current ?? columnDragOrder ?? columnOrder; const orderedColumns = columnDragOrderRef.current ?? columnDragOrder ?? columnOrder;
const columnMotionOrderKey = orderedColumns.join('\u0001');
const tableGridTemplate = buildColumnGridTemplate(orderedColumns, normalizedColumnWidths); const tableGridTemplate = buildColumnGridTemplate(orderedColumns, normalizedColumnWidths);
const tableMinWidth = normalizedColumnWidths.reduce( const tableMinWidth = normalizedColumnWidths.reduce(
(total, width) => total + width, (total, width) => total + width,
@@ -278,6 +285,121 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
queueRowsContainerElementRef.current = element; queueRowsContainerElementRef.current = element;
}, []); }, []);
const captureColumnMotionLayout = (): ColumnMotionLayout[] => {
const header = headerContainerRef.current;
const body = queueRowsContainerElementRef.current;
const verticalViewport = queueListElementRef.current?.getBoundingClientRect();
const headerElements = header
? Array.from(header.children).filter(
(element): element is HTMLElement => element instanceof HTMLElement && Boolean(element.dataset.columnKey)
)
: [];
const bodyElements: HTMLElement[] = [];
if (body) {
for (const row of Array.from(body.children)) {
if (!(row instanceof HTMLElement) || !row.dataset.downloadId) continue;
if (verticalViewport) {
const rowRect = row.getBoundingClientRect();
if (rowRect.bottom < verticalViewport.top || rowRect.top > verticalViewport.bottom) continue;
}
const motionElement = row.querySelector<HTMLElement>('.download-row-motion');
if (!motionElement) continue;
bodyElements.push(...Array.from(motionElement.children).filter(
(element): element is HTMLElement => element instanceof HTMLElement && Boolean(element.dataset.columnKey)
));
}
}
return [...headerElements, ...bodyElements].map(element => ({
element,
left: element.getBoundingClientRect().left,
}));
};
const clearColumnMotionStyles = () => {
columnMotionGenerationRef.current += 1;
if (columnMotionFrameRef.current !== null) {
window.cancelAnimationFrame(columnMotionFrameRef.current);
columnMotionFrameRef.current = null;
}
if (columnMotionTimerRef.current !== null) {
window.clearTimeout(columnMotionTimerRef.current);
columnMotionTimerRef.current = null;
}
for (const element of columnMotionElementsRef.current) {
element.style.removeProperty('transition');
element.style.removeProperty('transform');
element.style.removeProperty('will-change');
}
columnMotionElementsRef.current.clear();
};
const prepareColumnMotion = () => {
const prefersReducedMotion = typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
clearColumnMotionStyles();
columnMotionFirstRef.current = null;
return;
}
// Capture the visual position before cancelling an in-flight transition.
// The next FLIP pass can then continue from what the user actually sees
// instead of snapping back to the previous logical grid position.
const first = captureColumnMotionLayout();
clearColumnMotionStyles();
columnMotionFirstRef.current = first;
};
useLayoutEffect(() => {
const first = columnMotionFirstRef.current;
columnMotionFirstRef.current = null;
if (!first || first.length === 0) return;
const prefersReducedMotion = typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) return;
const moved = first.flatMap(({ element, left }) => {
if (!element.isConnected) return [];
const deltaX = left - element.getBoundingClientRect().left;
return Math.abs(deltaX) > 0.5 ? [{ element, deltaX }] : [];
});
if (moved.length === 0) return;
const generation = ++columnMotionGenerationRef.current;
for (const { element, deltaX } of moved) {
columnMotionElementsRef.current.add(element);
element.style.willChange = 'transform';
element.style.transition = 'none';
element.style.transform = `translate3d(${deltaX}px, 0, 0)`;
}
// Commit the inverse transforms before releasing them on the next frame so
// React's grid reflow becomes a smooth physical-LTR motion for headers and
// every visible row cell alike.
void headerContainerRef.current?.offsetHeight;
void queueRowsContainerElementRef.current?.offsetHeight;
columnMotionFrameRef.current = window.requestAnimationFrame(() => {
columnMotionFrameRef.current = null;
if (columnMotionGenerationRef.current !== generation) return;
for (const { element } of moved) {
element.style.transition = `transform ${COLUMN_REORDER_DURATION}ms ${COLUMN_REORDER_EASING}`;
element.style.transform = 'translate3d(0, 0, 0)';
}
});
columnMotionTimerRef.current = window.setTimeout(() => {
columnMotionTimerRef.current = null;
if (columnMotionGenerationRef.current !== generation) return;
for (const { element } of moved) {
element.style.removeProperty('transition');
element.style.removeProperty('transform');
element.style.removeProperty('will-change');
columnMotionElementsRef.current.delete(element);
}
}, COLUMN_REORDER_DURATION + 60);
}, [columnMotionOrderKey]);
const updateColumnDragState = (next: ColumnDragState | null) => { const updateColumnDragState = (next: ColumnDragState | null) => {
columnDragStateRef.current = next; columnDragStateRef.current = next;
setColumnDragState(next); setColumnDragState(next);
@@ -294,8 +416,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}; };
const getColumnLayoutBounds = (element: HTMLDivElement) => { const getColumnLayoutBounds = (element: HTMLDivElement) => {
// AutoAnimate transforms the visual box; offsetLeft/offsetWidth retain the // CSS FLIP transforms affect the visual box; offsetLeft/offsetWidth retain
// stable grid geometry needed for hit-testing during a live reorder. // the stable grid geometry needed for hit-testing during a live reorder.
const container = headerContainerRef.current; const container = headerContainerRef.current;
if (!container) { if (!container) {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@@ -348,6 +470,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const current = columnDragStateRef.current; const current = columnDragStateRef.current;
const nextOrder = columnDragOrderRef.current ?? columnOrderRef.current; const nextOrder = columnDragOrderRef.current ?? columnOrderRef.current;
const captureTarget = columnDragCaptureTargetRef.current; const captureTarget = columnDragCaptureTargetRef.current;
const shouldAnimateCancel = cancelled && gesture.active && Boolean(current);
if (shouldAnimateCancel) {
prepareColumnMotion();
}
dragGestureRef.current = null; dragGestureRef.current = null;
columnDragTargetRef.current = null; columnDragTargetRef.current = null;
columnDragCaptureTargetRef.current = null; columnDragCaptureTargetRef.current = null;
@@ -358,7 +484,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
columnDragCleanupRef.current = null; columnDragCleanupRef.current = null;
updateColumnDragState(null); updateColumnDragState(null);
setColumnDragOrder(null); setColumnDragOrder(null);
setHeaderAnimationEnabled(true);
if (captureTarget?.hasPointerCapture(pointerId)) { if (captureTarget?.hasPointerCapture(pointerId)) {
captureTarget.releasePointerCapture(pointerId); captureTarget.releasePointerCapture(pointerId);
} }
@@ -416,9 +541,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const rect = target?.getBoundingClientRect(); const rect = target?.getBoundingClientRect();
if (!rect) return; if (!rect) return;
// The header children are reordered in the live grid while dragging. // The header children are reordered in the live grid while dragging.
// AutoAnimate's MutationObserver must not animate the same structural // The custom FLIP pass animates the visual boxes while offset geometry
// changes that drive pointer hit-testing; it is restored on completion. // remains stable for pointer hit-testing.
setHeaderAnimationEnabled(false);
const currentOrder = columnOrderRef.current; const currentOrder = columnOrderRef.current;
columnDragLayoutRef.current = captureColumnLayout(currentOrder); columnDragLayoutRef.current = captureColumnLayout(currentOrder);
columnDragOrderRef.current = [...currentOrder]; columnDragOrderRef.current = [...currentOrder];
@@ -442,6 +566,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const nextOrder = reorderColumn(currentOrder, key, dropIndex); const nextOrder = reorderColumn(currentOrder, key, dropIndex);
const orderChanged = nextOrder.some((columnKey, index) => columnKey !== currentOrder[index]); const orderChanged = nextOrder.some((columnKey, index) => columnKey !== currentOrder[index]);
if (orderChanged) { if (orderChanged) {
prepareColumnMotion();
columnDragOrderRef.current = nextOrder; columnDragOrderRef.current = nextOrder;
setColumnDragOrder(nextOrder); setColumnDragOrder(nextOrder);
} }
@@ -594,6 +719,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const widths = [...DEFAULT_COLUMN_WIDTHS]; const widths = [...DEFAULT_COLUMN_WIDTHS];
const order = [...DEFAULT_COLUMN_ORDER]; const order = [...DEFAULT_COLUMN_ORDER];
const alignments = { ...DEFAULT_COLUMN_ALIGNMENTS }; const alignments = { ...DEFAULT_COLUMN_ALIGNMENTS };
if (order.join('|') !== columnOrderRef.current.join('|')) {
prepareColumnMotion();
}
setColumnWidths(widths); setColumnWidths(widths);
columnWidthsRef.current = widths; columnWidthsRef.current = widths;
columnOrderRef.current = order; columnOrderRef.current = order;
@@ -627,6 +755,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
useEffect(() => () => { useEffect(() => () => {
resizeCleanupRef.current?.(); resizeCleanupRef.current?.();
columnDragCleanupRef.current?.(); columnDragCleanupRef.current?.();
columnMotionFirstRef.current = null;
clearColumnMotionStyles();
try { try {
queueDragCleanupRef.current?.(); queueDragCleanupRef.current?.();
} catch (error) { } catch (error) {
@@ -661,7 +791,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
useEffect(() => { useEffect(() => {
const cancelColumnDrag = () => { const cancelColumnDrag = () => {
if (!dragGestureRef.current) return; const gesture = dragGestureRef.current;
if (!gesture) return;
if (gesture.active && columnDragStateRef.current) {
prepareColumnMotion();
}
columnDragCleanupRef.current?.(); columnDragCleanupRef.current?.();
columnDragCleanupRef.current = null; columnDragCleanupRef.current = null;
const captureTarget = columnDragCaptureTargetRef.current; const captureTarget = columnDragCaptureTargetRef.current;
@@ -676,7 +810,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
columnDragStateRef.current = null; columnDragStateRef.current = null;
columnDragOrderRef.current = null; columnDragOrderRef.current = null;
columnDragLayoutRef.current = null; columnDragLayoutRef.current = null;
setHeaderAnimationEnabled(true);
setColumnDragState(null); setColumnDragState(null);
setColumnDragOrder(null); setColumnDragOrder(null);
suppressHeaderClickRef.current = false; suppressHeaderClickRef.current = false;