feat(downloads): animate priority reordering

Rename the package disclosure option to a general animations switch and use it for both disclosure and priority-order motion. Keep previously visible virtual rows mounted during reorders, animate measured start and target positions for 1.5 seconds, and cancel all motion immediately when the setting is disabled. Extend the isolated renderer fixture so enabled and disabled behavior can be measured without touching user download state.
This commit is contained in:
Sucukdeluxe
2026-08-15 03:49:11 +02:00
parent 135b2e7316
commit 6e65332d93
12 changed files with 243 additions and 23 deletions
+1 -1
View File
@@ -4649,7 +4649,7 @@ export function App(): ReactElement {
sortColumn: downloadsSortColumn,
sortDirection: downloadsSortDescending ? "desc" : "asc",
disclosureRevision: downloadDisclosureRevision,
animatePackageDisclosure: snapshot.settings.animatePackageDisclosure,
animationsEnabled: snapshot.settings.animatePackageDisclosure,
status: {
packages: snapshot.stats.totalPackages,
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)),
+1 -1
View File
@@ -12,7 +12,7 @@ const pairs = [
["Download-Ordner", "Download folder"], ["Paketname (optional)", "Package name (optional)"], ["Max. gleichzeitige Downloads", "Max. concurrent downloads"], ["Automatische Wiederholungen", "Automatic retries"],
["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."],
["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Nur letzte 100 Einträge", "Last 100 entries only"], ["Nur letzte 250 Einträge", "Last 250 entries only"], ["Dauerhaft", "Permanent"],
["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"], ["Paket-Ein-/Ausklappen animieren", "Animate package expand/collapse"],
["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"], ["Animationen", "Animations"],
["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"],
["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"],
["Melden, wenn ein Paket fertig ist", "Notify when a package completes"], ["Melden, wenn ein Paket fehlschlägt", "Notify when a package fails"], ["Melden, wenn alles fertig ist", "Notify when everything completes"],
@@ -48,7 +48,7 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
sortColumn?: DownloadSortColumn;
sortDirection?: "asc" | "desc";
disclosureRevision: number;
animatePackageDisclosure: boolean;
animationsEnabled: boolean;
status: DownloadsStatusModel;
}
@@ -10,6 +10,14 @@ import {
stableDownloadDisclosureRows,
type DownloadDisclosureRow
} from "./download-disclosure-transition";
import {
DOWNLOAD_ORDER_TRANSITION_DURATION_MS,
animateDownloadOrderRows,
captureDownloadOrderRowTops,
getDownloadOrderTransitionPinnedIds,
getDownloadPackageOrder,
isDownloadPackageOrderChange
} from "./download-order-transition";
import type { DownloadsViewActions, DownloadsViewModel } from "./DownloadsView";
import { ItemRow, PackageCard } from "./DownloadsTable";
import { calculateDownloadVirtualWindow, DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT, DOWNLOAD_VIRTUAL_OVERSCAN_ROWS } from "./download-virtualizer";
@@ -111,6 +119,22 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
const transitionRowsRef = useRef<DownloadDisclosureRow[] | null>(null);
const transitionPinnedIdsRef = useRef<string[]>([]);
const [transitionRows, setTransitionRows] = useState<DownloadDisclosureRow[] | null>(null);
const packageOrder = getDownloadPackageOrder(desiredRows);
const previousPackageOrderRef = useRef<readonly string[]>(packageOrder);
const previousVisibleIdsRef = useRef<readonly string[]>([]);
const orderTransitionPinnedIdsRef = useRef<string[]>([]);
const orderTransitionTimerRef = useRef(0);
const orderAnimationsRef = useRef<Animation[]>([]);
const orderRowTopsRef = useRef<Map<string, number>>(new Map());
const [, setOrderTransitionRevision] = useState(0);
const packageOrderChanged = isDownloadPackageOrderChange(previousPackageOrderRef.current, packageOrder);
const orderTransitionPinnedIds = getDownloadOrderTransitionPinnedIds({
enabled: model.animationsEnabled,
previousOrder: previousPackageOrderRef.current,
nextOrder: packageOrder,
previousVisibleIds: previousVisibleIdsRef.current,
activePinnedIds: orderTransitionPinnedIdsRef.current
});
useEffect(() => {
if (!transitionRowsRef.current) previousRowsRef.current = desiredRows;
@@ -120,7 +144,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
const prepared = prepareDownloadDisclosureTransition(
transitionRowsRef.current ?? previousRowsRef.current,
desiredRowsRef.current,
model.animatePackageDisclosure
model.animationsEnabled
);
if (!prepared.animated) {
transitionRowsRef.current = null;
@@ -152,24 +176,64 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
cancelActivation();
if (settleTimer) window.clearTimeout(settleTimer);
};
}, [model.animatePackageDisclosure, model.disclosureRevision, model.displayMode]);
}, [model.animationsEnabled, model.disclosureRevision, model.displayMode]);
const renderedRows = useMemo<DownloadDisclosureRow[]>(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : stableDownloadDisclosureRows(desiredRows), [desiredRows, transitionRows]);
const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(renderedRows, {
scrollTop: viewport.scrollTop,
viewportHeight: viewport.viewportHeight,
overscan: DOWNLOAD_VIRTUAL_OVERSCAN_ROWS,
pinnedIds: [model.editingPackageId, ...transitionPinnedIdsRef.current]
}), [model.editingPackageId, renderedRows, viewport.scrollTop, viewport.viewportHeight]);
pinnedIds: [model.editingPackageId, ...transitionPinnedIdsRef.current, ...orderTransitionPinnedIds]
}), [model.editingPackageId, orderTransitionPinnedIds, renderedRows, viewport.scrollTop, viewport.viewportHeight]);
useRendererLayoutEffect(() => {
previousPackageOrderRef.current = packageOrder;
previousVisibleIdsRef.current = virtualWindow.rows.map((entry) => entry.id);
const body = bodyRef.current;
if (!body) return;
if (!model.animationsEnabled) {
for (const animation of orderAnimationsRef.current) animation.cancel();
orderAnimationsRef.current = [];
orderRowTopsRef.current = captureDownloadOrderRowTops(body);
if (orderTransitionTimerRef.current) window.clearTimeout(orderTransitionTimerRef.current);
orderTransitionTimerRef.current = 0;
orderTransitionPinnedIdsRef.current = [];
return;
}
if (!packageOrderChanged) {
if (orderAnimationsRef.current.every((animation) => animation.playState === "finished" || animation.playState === "idle")) {
orderAnimationsRef.current = [];
orderRowTopsRef.current = captureDownloadOrderRowTops(body);
}
return;
}
const previousTops = orderAnimationsRef.current.length > 0 ? captureDownloadOrderRowTops(body) : orderRowTopsRef.current;
for (const animation of orderAnimationsRef.current) animation.cancel();
const animated = animateDownloadOrderRows(body, previousTops);
orderAnimationsRef.current = animated.animations;
orderRowTopsRef.current = animated.targetTops;
orderTransitionPinnedIdsRef.current = orderTransitionPinnedIds;
if (orderTransitionTimerRef.current) window.clearTimeout(orderTransitionTimerRef.current);
orderTransitionTimerRef.current = window.setTimeout(() => {
orderTransitionPinnedIdsRef.current = [];
orderTransitionTimerRef.current = 0;
setOrderTransitionRevision((revision) => revision + 1);
}, DOWNLOAD_ORDER_TRANSITION_DURATION_MS);
}, [model.animationsEnabled, orderTransitionPinnedIds, packageOrder, packageOrderChanged, virtualWindow.rows]);
useEffect(() => () => {
if (orderTransitionTimerRef.current) window.clearTimeout(orderTransitionTimerRef.current);
for (const animation of orderAnimationsRef.current) animation.cancel();
}, []);
const spacerStyle = { "--downloads-virtual-total-height": `${virtualWindow.totalHeight}px` } as CSSProperties;
return (
<div className={`downloads-table-body${model.animatePackageDisclosure ? "" : " is-disclosure-motion-disabled"}`} data-visual-region="downloads-table-body" ref={bodyRef} role="rowgroup">
<div className={`downloads-table-body${model.animationsEnabled ? "" : " is-download-motion-disabled"}`} data-visual-region="downloads-table-body" ref={bodyRef} role="rowgroup">
{state}
{!state ? (
<div className="downloads-virtual-spacer" style={spacerStyle}>
{virtualWindow.rows.map((entry) => (
<div className={`downloads-virtual-row${entry.source.type === "item-group" ? " is-disclosure-group" : ""}${disclosureClassName(entry.source)}`} data-download-virtual-index={entry.index} key={`${entry.source.type}:${entry.id}`} style={rowStyle(entry.top, entry.height, disclosureOpacity(entry.source))}>
<div className={`downloads-virtual-row${entry.source.type === "item-group" ? " is-disclosure-group" : ""}${disclosureClassName(entry.source)}`} data-download-row-id={entry.id} data-download-virtual-index={entry.index} key={`${entry.source.type}:${entry.id}`} style={rowStyle(entry.top, entry.height, disclosureOpacity(entry.source))}>
{renderVirtualRow(entry.source, model, actions)}
</div>
))}
@@ -0,0 +1,67 @@
import type { DownloadLogicalRow } from "./downloads-model";
export const DOWNLOAD_ORDER_TRANSITION_DURATION_MS = 1500;
export const DOWNLOAD_ORDER_TRANSITION_MAX_PINNED_ROWS = 96;
const DOWNLOAD_ORDER_TRANSITION_EASING = "cubic-bezier(0.22, 1, 0.36, 1)";
export interface DownloadOrderAnimationResult {
animations: Animation[];
targetTops: Map<string, number>;
}
export function getDownloadPackageOrder(rows: readonly DownloadLogicalRow[]): string[] {
return rows.filter((row) => row.type === "package").map((row) => row.id);
}
export function isDownloadPackageOrderChange(previous: readonly string[], next: readonly string[]): boolean {
if (previous.length !== next.length || previous.length < 2) return false;
const previousIds = new Set(previous);
if (next.some((id) => !previousIds.has(id))) return false;
return next.some((id, index) => previous[index] !== id);
}
export function getDownloadOrderTransitionPinnedIds(input: {
enabled: boolean;
previousOrder: readonly string[];
nextOrder: readonly string[];
previousVisibleIds: readonly string[];
activePinnedIds: readonly string[];
}): string[] {
if (!input.enabled) return [];
if (!isDownloadPackageOrderChange(input.previousOrder, input.nextOrder)) return [...input.activePinnedIds];
return [...new Set([...input.activePinnedIds, ...input.previousVisibleIds])].slice(0, DOWNLOAD_ORDER_TRANSITION_MAX_PINNED_ROWS);
}
export function captureDownloadOrderRowTops(container: HTMLElement): Map<string, number> {
const tops = new Map<string, number>();
for (const element of container.querySelectorAll<HTMLElement>(".downloads-virtual-row[data-download-row-id]")) {
const id = element.dataset.downloadRowId;
if (id) tops.set(id, element.getBoundingClientRect().top);
}
return tops;
}
export function getDownloadOrderTransformKeyframes(virtualTop: number, previousTop: number, targetTop: number): Keyframe[] {
const offset = previousTop - targetTop;
return [
{ transform: `translateY(${virtualTop + offset}px)` },
{ transform: `translateY(${virtualTop}px)` }
];
}
export function animateDownloadOrderRows(container: HTMLElement, previousTops: ReadonlyMap<string, number>): DownloadOrderAnimationResult {
const targetTops = captureDownloadOrderRowTops(container);
const animations: Animation[] = [];
for (const element of container.querySelectorAll<HTMLElement>(".downloads-virtual-row[data-download-row-id]")) {
const id = element.dataset.downloadRowId;
const previousTop = id ? previousTops.get(id) : undefined;
const targetTop = id ? targetTops.get(id) : undefined;
const virtualTop = Number.parseFloat(element.style.getPropertyValue("--downloads-virtual-row-top"));
if (previousTop === undefined || targetTop === undefined || !Number.isFinite(virtualTop) || Math.abs(previousTop - targetTop) < 0.5) continue;
animations.push(element.animate(getDownloadOrderTransformKeyframes(virtualTop, previousTop, targetTop), {
duration: DOWNLOAD_ORDER_TRANSITION_DURATION_MS,
easing: DOWNLOAD_ORDER_TRANSITION_EASING
}));
}
return { animations, targetTops };
}
+2 -2
View File
@@ -302,8 +302,8 @@
transition: height 1500ms cubic-bezier(0.22, 1, 0.36, 1), transform 1500ms cubic-bezier(0.22, 1, 0.36, 1), opacity 1500ms cubic-bezier(0.22, 1, 0.36, 1);
}
.downloads-table-body.is-disclosure-motion-disabled .downloads-virtual-spacer,
.downloads-table-body.is-disclosure-motion-disabled .downloads-virtual-row {
.downloads-table-body.is-download-motion-disabled .downloads-virtual-spacer,
.downloads-table-body.is-download-motion-disabled .downloads-virtual-row {
transition: none !important;
}
@@ -568,7 +568,7 @@ export function buildSettingsFormViewModel({
title: "Oberfläche und Bedienung",
fields: [
{ id: "collapseNewPackages", kind: "switch", label: "Neue Pakete eingeklappt zeigen", value: settings.collapseNewPackages },
{ id: "animatePackageDisclosure", kind: "switch", label: "Paket-Ein-/Ausklappen animieren", value: settings.animatePackageDisclosure },
{ id: "animatePackageDisclosure", kind: "switch", label: "Animationen", value: settings.animatePackageDisclosure },
{ id: "minimizeToTray", kind: "switch", label: "In den Infobereich minimieren", value: settings.minimizeToTray },
{ id: "confirmDeleteSelection", kind: "switch", label: "Vor dem Löschen nachfragen", value: settings.confirmDeleteSelection },
{ id: "backupIncludeDownloads", kind: "switch", label: "Download-Liste mitsichern", value: settings.backupIncludeDownloads },