fix(downloads): make column reorder frames atomic

Commit the target grid immediately, derive track definitions from the same column order as the cells, and animate only cell contents with FLIP. Disable grid-track interpolation to avoid Chromium reduced-motion mix frames, honor the application animation setting, and retain serialized persistence and import authority.
This commit is contained in:
Sucukdeluxe
2026-08-20 09:16:06 +02:00
parent 32fc1e7321
commit 0dfb57525a
7 changed files with 205 additions and 67 deletions
+41 -16
View File
@@ -95,9 +95,9 @@ import {
type StatisticsViewActions
} from "./views/statistics/StatisticsView";
import { buildDownloadsViewModel, formatRemainingDownloadBytes, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, getRemainingDownloadBytes, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
import { applyDownloadGridTemplate, downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
import { DeleteConfirmationDialog } from "./views/downloads/DeleteConfirmationDialog";
import { beginDownloadColumnDrag, clearDownloadColumnDrag, commitDownloadColumnDrag, createDownloadColumnOrderPersistence, settleDownloadColumnDrag, updateDownloadColumnDrag, type DownloadColumnDragSession, type DownloadColumnOrderPersistence } from "./views/downloads/column-drag";
import { beginDownloadColumnDrag, clearDownloadColumnDrag, commitDownloadColumnDrag, createDownloadColumnOrderPersistence, DOWNLOAD_COLUMN_MOVE_DURATION_MS, updateDownloadColumnDrag, type DownloadColumnDragSession, type DownloadColumnOrderPersistence } from "./views/downloads/column-drag";
import {
DownloadsContent,
DownloadsFooter,
@@ -1645,11 +1645,17 @@ export function App(): ReactElement {
}
const columnDragSessionRef = useRef<DownloadColumnDragSession | null>(null);
const columnDragSettleTimerRef = useRef<number | null>(null);
const columnDragAnimationsRef = useRef<Animation[]>([]);
const suppressColumnSortRef = useRef(false);
const cancelColumnDragAnimations = useCallback((): void => {
columnDragAnimationsRef.current.forEach((animation) => animation.cancel());
columnDragAnimationsRef.current = [];
}, []);
useEffect(() => () => {
if (columnDragSettleTimerRef.current !== null) window.clearTimeout(columnDragSettleTimerRef.current);
cancelColumnDragAnimations();
if (columnDragSessionRef.current) clearDownloadColumnDrag(columnDragSessionRef.current);
}, []);
}, [cancelColumnDragAnimations]);
const [colHeaderCtx, setColHeaderCtx] = useState<{ x: number; y: number } | null>(null);
const colHeaderCtxRef = useRef<HTMLDivElement>(null);
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
@@ -4890,8 +4896,9 @@ export function App(): ReactElement {
window.clearTimeout(columnDragSettleTimerRef.current);
columnDragSettleTimerRef.current = null;
}
cancelColumnDragAnimations();
if (columnDragSessionRef.current) clearDownloadColumnDrag(columnDragSessionRef.current);
columnDragSessionRef.current = beginDownloadColumnDrag(event.currentTarget, column, event.pointerId, event.clientX);
columnDragSessionRef.current = beginDownloadColumnDrag(event.currentTarget, column, event.pointerId, event.clientX, snapshot.settings.animatePackageDisclosure);
},
onColumnPointerMove: (_column, event) => {
const session = columnDragSessionRef.current;
@@ -4901,34 +4908,52 @@ export function App(): ReactElement {
onColumnPointerUp: (_column, event) => {
const session = columnDragSessionRef.current;
if (!session || session.pointerId !== event.pointerId) return;
const next = settleDownloadColumnDrag(session, false);
if (!next) {
if (!session.active) {
clearDownloadColumnDrag(session);
columnDragSessionRef.current = null;
return;
}
const next = session.preview.order;
suppressColumnSortRef.current = true;
window.setTimeout(() => { suppressColumnSortRef.current = false; }, 0);
const changed = next.join("|") !== session.measurements.map((measurement) => measurement.id).join("|");
if (changed) persistColumnOrder(next);
const animationsEnabled = snapshot.settings.animatePackageDisclosure;
columnDragAnimationsRef.current = commitDownloadColumnDrag(session, next, (order) => {
if (changed) flushSync(() => setColumnOrder(order));
}, () => applyDownloadGridTemplate(session.root, next), animationsEnabled);
if (!animationsEnabled) {
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
return;
}
columnDragSettleTimerRef.current = window.setTimeout(() => {
if (changed) {
persistColumnOrder(next);
commitDownloadColumnDrag(session, next, (order) => flushSync(() => setColumnOrder(order)));
} else {
clearDownloadColumnDrag(session);
}
cancelColumnDragAnimations();
session.root.classList.remove("is-column-drag-settling");
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
columnDragSettleTimerRef.current = null;
}, 220);
}, DOWNLOAD_COLUMN_MOVE_DURATION_MS);
},
onColumnPointerCancel: (_column, event) => {
const session = columnDragSessionRef.current;
if (!session || session.pointerId !== event.pointerId) return;
settleDownloadColumnDrag(session, true);
columnDragSettleTimerRef.current = window.setTimeout(() => {
if (!session.active) {
clearDownloadColumnDrag(session);
columnDragSessionRef.current = null;
return;
}
const originalOrder = session.measurements.map((measurement) => measurement.id);
const animationsEnabled = snapshot.settings.animatePackageDisclosure;
columnDragAnimationsRef.current = commitDownloadColumnDrag(session, originalOrder, () => {}, () => {}, animationsEnabled);
if (!animationsEnabled) {
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
return;
}
columnDragSettleTimerRef.current = window.setTimeout(() => {
cancelColumnDragAnimations();
session.root.classList.remove("is-column-drag-settling");
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
columnDragSettleTimerRef.current = null;
}, 220);
}, DOWNLOAD_COLUMN_MOVE_DURATION_MS);
},
onColumnContextMenu: (_column, x, y) => setColHeaderCtx({ x, y })
};
@@ -36,10 +36,21 @@ function HosterLabels({ labels }: { labels: HosterLabel[] }): ReactElement {
);
}
function downloadGridTemplate(gridTemplate: string): string {
export function downloadGridTemplate(gridTemplate: string): string {
return `${DOWNLOAD_SELECTION_COLUMN_WIDTH} ${gridTemplate} ${DOWNLOAD_ACTION_COLUMN_WIDTH}`;
}
export function downloadGridTemplateForOrder(columnOrder: readonly string[]): string {
return downloadGridTemplate(columnOrder.map((column) => downloadColumnDefinitions[column]?.width ?? "100px").join(" "));
}
export function applyDownloadGridTemplate(root: HTMLElement, columnOrder: readonly string[]): void {
const value = downloadGridTemplateForOrder(columnOrder);
root.querySelectorAll<HTMLElement>(".downloads-table-header, .downloads-package-row, .downloads-item-row").forEach((row) => {
row.style.gridTemplateColumns = value;
});
}
function isPackageRowDisclosureExcluded(target: EventTarget | null): boolean {
const closest = (target as { closest?: (selector: string) => Element | null } | null)?.closest;
return typeof closest === "function" && closest.call(target, PACKAGE_ROW_DISCLOSURE_EXCLUSION_SELECTOR) !== null;
@@ -260,7 +271,7 @@ export function ItemRowContent({ item, selected, sessionRunning = true, columnOr
className={`downloads-item-row${selected ? " is-selected" : ""}`}
data-download-row-id={item.id}
role="row"
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
style={{ gridTemplateColumns: downloadGridTemplateForOrder(columnOrder) }}
onClick={(event) => {
event.stopPropagation();
actions.onToggleSelection(item.id, event.ctrlKey || event.metaKey, event.shiftKey);
@@ -460,7 +471,7 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
className="downloads-package-row"
data-download-row-id={entry.id}
role="row"
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
style={{ gridTemplateColumns: downloadGridTemplateForOrder(columnOrder) }}
onClick={(event) => {
if (event.detail > 1) return;
if (!event.ctrlKey && !event.metaKey && !event.shiftKey && selectedIds.size === 1 && selectedIds.has(entry.id)) return;
@@ -533,7 +544,7 @@ export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortC
const allSelected = visibleIds.length > 0 && selectedCount === visibleIds.length;
const mixedSelection = selectedCount > 0 && selectedCount < visibleIds.length;
return (
<div className="downloads-table-header" role="row" style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}>
<div className="downloads-table-header" role="row" style={{ gridTemplateColumns: downloadGridTemplateForOrder(columnOrder) }}>
<span className="downloads-selection-cell" role="columnheader"><input aria-checked={mixedSelection ? "mixed" : allSelected} aria-label="Alle sichtbaren Downloads auswählen" checked={allSelected} onChange={(event) => actions.onSetVisibleSelection(visibleIds, event.target.checked)} ref={(input) => { if (input) input.indeterminate = mixedSelection; }} type="checkbox" /></span>
{columnOrder.map((column, index) => {
const definition = downloadColumnDefinitions[column];
+35 -18
View File
@@ -4,6 +4,8 @@ export interface ColumnDragMeasurement {
width: number;
}
export const DOWNLOAD_COLUMN_MOVE_DURATION_MS = 220;
export interface ColumnDragPreview {
order: string[];
offsets: Record<string, number>;
@@ -69,7 +71,7 @@ function setDraggedColumn(root: HTMLElement, draggedId: string, dragging: boolea
}
}
export function beginDownloadColumnDrag(element: HTMLElement, draggedId: string, pointerId: number, clientX: number): DownloadColumnDragSession | null {
export function beginDownloadColumnDrag(element: HTMLElement, draggedId: string, pointerId: number, clientX: number, animationsEnabled = true): DownloadColumnDragSession | null {
const root = element.closest<HTMLElement>(".downloads-table");
const header = element.closest<HTMLElement>(".downloads-table-header");
if (!root || !header) return null;
@@ -78,6 +80,7 @@ export function beginDownloadColumnDrag(element: HTMLElement, draggedId: string,
return { id: column.dataset.downloadColumn ?? "", left: rect.left, width: rect.width };
}).filter((column) => column.id && column.width > 0);
if (!measurements.some((column) => column.id === draggedId)) return null;
root.classList.toggle("is-column-drag-motion-disabled", !animationsEnabled);
return {
active: false,
draggedId,
@@ -106,22 +109,8 @@ export function updateDownloadColumnDrag(session: DownloadColumnDragSession, cli
return true;
}
export function settleDownloadColumnDrag(session: DownloadColumnDragSession, cancelled: boolean): string[] | null {
if (!session.active) return null;
const originalOrder = session.measurements.map((column) => column.id);
const order = cancelled ? originalOrder : session.preview.order;
const offsets = cancelled
? Object.fromEntries(originalOrder.map((id) => [id, 0]))
: session.preview.settleOffsets;
setColumnOffsets(session.root, offsets);
session.root.style.removeProperty("--downloads-active-drag-x");
setDraggedColumn(session.root, session.draggedId, false);
session.root.classList.add("is-column-drag-settling");
return order;
}
export function clearDownloadColumnDrag(session: DownloadColumnDragSession): void {
session.root.classList.remove("is-column-drag-active", "is-column-drag-settling");
session.root.classList.remove("is-column-drag-active", "is-column-drag-motion-disabled", "is-column-drag-settling");
session.root.style.removeProperty("--downloads-active-drag-x");
session.measurements.forEach((measurement) => session.root.style.removeProperty(`--downloads-column-drag-${measurement.id}`));
session.root.querySelectorAll<HTMLElement>("[data-column-dragging]").forEach((element) => delete element.dataset.columnDragging);
@@ -131,10 +120,38 @@ export function clearDownloadColumnDrag(session: DownloadColumnDragSession): voi
export function commitDownloadColumnDrag(
session: DownloadColumnDragSession,
order: string[],
commit: (order: string[]) => void
): void {
commit: (order: string[]) => void,
prepare: () => void = () => {},
animationsEnabled = true
): Animation[] {
const elements = Array.from(session.root.querySelectorAll<HTMLElement>("[data-download-column]"));
const before = new Map(elements.map((element) => [element, element.getBoundingClientRect()]));
clearDownloadColumnDrag(session);
prepare();
commit(order);
if (!animationsEnabled) return [];
session.root.classList.add("is-column-drag-settling");
const animations: Animation[] = [];
for (const element of elements) {
const first = before.get(element);
if (!first) continue;
const last = element.getBoundingClientRect();
const deltaX = first.left - last.left;
if (Math.abs(deltaX) < 0.5) continue;
for (const target of Array.from(element.children)) {
const animation = target.animate([
{ transform: `translate3d(${deltaX}px, 0, 0)` },
{ transform: "translate3d(0, 0, 0)" }
], {
duration: DOWNLOAD_COLUMN_MOVE_DURATION_MS,
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
fill: "both"
});
animation.finished.catch(() => {});
animations.push(animation);
}
}
return animations;
}
export interface DownloadColumnOrderPersistence {
+11 -6
View File
@@ -281,6 +281,12 @@
text-transform: uppercase;
}
.downloads-table-header,
.downloads-package-row,
.downloads-item-row {
transition-property: none;
}
.downloads-table-body {
overflow: visible;
contain: layout style;
@@ -587,10 +593,6 @@
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--ui-text-muted) 58%, transparent), inset -2px 0 0 color-mix(in srgb, var(--ui-text-muted) 58%, transparent);
}
.downloads-table.is-column-drag-settling [data-download-column] {
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
@media (prefers-reduced-motion: reduce) {
.downloads-virtual-spacer {
transition-duration: 1500ms !important;
@@ -600,8 +602,7 @@
transition-duration: 1500ms, 1500ms, 1500ms !important;
}
.downloads-table.is-column-drag-active [data-download-column],
.downloads-table.is-column-drag-settling [data-download-column] {
.downloads-table.is-column-drag-active [data-download-column] {
transition-duration: 220ms !important;
}
@@ -610,6 +611,10 @@
}
}
.downloads-table.is-column-drag-motion-disabled.is-column-drag-active [data-download-column] {
transition: none !important;
}
.downloads-column-sort {
width: 100%;
border: 0;