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:
@@ -4649,7 +4649,7 @@ export function App(): ReactElement {
|
|||||||
sortColumn: downloadsSortColumn,
|
sortColumn: downloadsSortColumn,
|
||||||
sortDirection: downloadsSortDescending ? "desc" : "asc",
|
sortDirection: downloadsSortDescending ? "desc" : "asc",
|
||||||
disclosureRevision: downloadDisclosureRevision,
|
disclosureRevision: downloadDisclosureRevision,
|
||||||
animatePackageDisclosure: snapshot.settings.animatePackageDisclosure,
|
animationsEnabled: snapshot.settings.animatePackageDisclosure,
|
||||||
status: {
|
status: {
|
||||||
packages: snapshot.stats.totalPackages,
|
packages: snapshot.stats.totalPackages,
|
||||||
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)),
|
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)),
|
||||||
|
|||||||
@@ -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"],
|
["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."],
|
["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"],
|
["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"],
|
["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)"],
|
["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"],
|
["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;
|
sortColumn?: DownloadSortColumn;
|
||||||
sortDirection?: "asc" | "desc";
|
sortDirection?: "asc" | "desc";
|
||||||
disclosureRevision: number;
|
disclosureRevision: number;
|
||||||
animatePackageDisclosure: boolean;
|
animationsEnabled: boolean;
|
||||||
status: DownloadsStatusModel;
|
status: DownloadsStatusModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ import {
|
|||||||
stableDownloadDisclosureRows,
|
stableDownloadDisclosureRows,
|
||||||
type DownloadDisclosureRow
|
type DownloadDisclosureRow
|
||||||
} from "./download-disclosure-transition";
|
} 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 type { DownloadsViewActions, DownloadsViewModel } from "./DownloadsView";
|
||||||
import { ItemRow, PackageCard } from "./DownloadsTable";
|
import { ItemRow, PackageCard } from "./DownloadsTable";
|
||||||
import { calculateDownloadVirtualWindow, DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT, DOWNLOAD_VIRTUAL_OVERSCAN_ROWS } from "./download-virtualizer";
|
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 transitionRowsRef = useRef<DownloadDisclosureRow[] | null>(null);
|
||||||
const transitionPinnedIdsRef = useRef<string[]>([]);
|
const transitionPinnedIdsRef = useRef<string[]>([]);
|
||||||
const [transitionRows, setTransitionRows] = useState<DownloadDisclosureRow[] | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!transitionRowsRef.current) previousRowsRef.current = desiredRows;
|
if (!transitionRowsRef.current) previousRowsRef.current = desiredRows;
|
||||||
@@ -120,7 +144,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
|
|||||||
const prepared = prepareDownloadDisclosureTransition(
|
const prepared = prepareDownloadDisclosureTransition(
|
||||||
transitionRowsRef.current ?? previousRowsRef.current,
|
transitionRowsRef.current ?? previousRowsRef.current,
|
||||||
desiredRowsRef.current,
|
desiredRowsRef.current,
|
||||||
model.animatePackageDisclosure
|
model.animationsEnabled
|
||||||
);
|
);
|
||||||
if (!prepared.animated) {
|
if (!prepared.animated) {
|
||||||
transitionRowsRef.current = null;
|
transitionRowsRef.current = null;
|
||||||
@@ -152,24 +176,64 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
|
|||||||
cancelActivation();
|
cancelActivation();
|
||||||
if (settleTimer) window.clearTimeout(settleTimer);
|
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 renderedRows = useMemo<DownloadDisclosureRow[]>(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : stableDownloadDisclosureRows(desiredRows), [desiredRows, transitionRows]);
|
||||||
const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(renderedRows, {
|
const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(renderedRows, {
|
||||||
scrollTop: viewport.scrollTop,
|
scrollTop: viewport.scrollTop,
|
||||||
viewportHeight: viewport.viewportHeight,
|
viewportHeight: viewport.viewportHeight,
|
||||||
overscan: DOWNLOAD_VIRTUAL_OVERSCAN_ROWS,
|
overscan: DOWNLOAD_VIRTUAL_OVERSCAN_ROWS,
|
||||||
pinnedIds: [model.editingPackageId, ...transitionPinnedIdsRef.current]
|
pinnedIds: [model.editingPackageId, ...transitionPinnedIdsRef.current, ...orderTransitionPinnedIds]
|
||||||
}), [model.editingPackageId, renderedRows, viewport.scrollTop, viewport.viewportHeight]);
|
}), [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;
|
const spacerStyle = { "--downloads-virtual-total-height": `${virtualWindow.totalHeight}px` } as CSSProperties;
|
||||||
|
|
||||||
return (
|
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}
|
||||||
{!state ? (
|
{!state ? (
|
||||||
<div className="downloads-virtual-spacer" style={spacerStyle}>
|
<div className="downloads-virtual-spacer" style={spacerStyle}>
|
||||||
{virtualWindow.rows.map((entry) => (
|
{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)}
|
{renderVirtualRow(entry.source, model, actions)}
|
||||||
</div>
|
</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 };
|
||||||
|
}
|
||||||
@@ -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);
|
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-download-motion-disabled .downloads-virtual-spacer,
|
||||||
.downloads-table-body.is-disclosure-motion-disabled .downloads-virtual-row {
|
.downloads-table-body.is-download-motion-disabled .downloads-virtual-row {
|
||||||
transition: none !important;
|
transition: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -568,7 +568,7 @@ export function buildSettingsFormViewModel({
|
|||||||
title: "Oberfläche und Bedienung",
|
title: "Oberfläche und Bedienung",
|
||||||
fields: [
|
fields: [
|
||||||
{ id: "collapseNewPackages", kind: "switch", label: "Neue Pakete eingeklappt zeigen", value: settings.collapseNewPackages },
|
{ 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: "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: "confirmDeleteSelection", kind: "switch", label: "Vor dem Löschen nachfragen", value: settings.confirmDeleteSelection },
|
||||||
{ id: "backupIncludeDownloads", kind: "switch", label: "Download-Liste mitsichern", value: settings.backupIncludeDownloads },
|
{ id: "backupIncludeDownloads", kind: "switch", label: "Download-Liste mitsichern", value: settings.backupIncludeDownloads },
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ import {
|
|||||||
scheduleDownloadDisclosureActivation,
|
scheduleDownloadDisclosureActivation,
|
||||||
stableDownloadDisclosureRows
|
stableDownloadDisclosureRows
|
||||||
} from "../src/renderer/views/downloads/download-disclosure-transition";
|
} from "../src/renderer/views/downloads/download-disclosure-transition";
|
||||||
|
import {
|
||||||
|
DOWNLOAD_ORDER_TRANSITION_DURATION_MS,
|
||||||
|
getDownloadOrderTransitionPinnedIds,
|
||||||
|
getDownloadOrderTransformKeyframes,
|
||||||
|
isDownloadPackageOrderChange
|
||||||
|
} from "../src/renderer/views/downloads/download-order-transition";
|
||||||
import {
|
import {
|
||||||
DownloadsContent,
|
DownloadsContent,
|
||||||
DownloadsFooter,
|
DownloadsFooter,
|
||||||
@@ -241,12 +247,36 @@ describe("virtualisierte Paketanimation", () => {
|
|||||||
expect(prepared.rows.some((row) => row.type === "item-group")).toBe(false);
|
expect(prepared.rows.some((row) => row.type === "item-group")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deaktiviert auch die CSS-Bewegung der virtuellen Folgezeilen", () => {
|
it("deaktiviert alle Downloadbewegungen über den allgemeinen Animationsschalter", () => {
|
||||||
const html = renderToStaticMarkup(<DownloadsContent actions={createActions()} model={withRuntime(createInput(), { animatePackageDisclosure: false })} />);
|
const html = renderToStaticMarkup(<DownloadsContent actions={createActions()} model={withRuntime(createInput(), { animationsEnabled: false })} />);
|
||||||
const css = readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
const css = readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||||||
|
|
||||||
expect(html).toContain('class="downloads-table-body is-disclosure-motion-disabled"');
|
expect(html).toContain('class="downloads-table-body is-download-motion-disabled"');
|
||||||
expect(css).toMatch(/\.downloads-table-body\.is-disclosure-motion-disabled \.downloads-virtual-spacer,\s*\.downloads-table-body\.is-disclosure-motion-disabled \.downloads-virtual-row\s*\{[^}]*transition:\s*none !important;/s);
|
expect(css).toMatch(/\.downloads-table-body\.is-download-motion-disabled \.downloads-virtual-spacer,\s*\.downloads-table-body\.is-download-motion-disabled \.downloads-virtual-row\s*\{[^}]*transition:\s*none !important;/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins only previously visible rows for a real priority reorder", () => {
|
||||||
|
expect(DOWNLOAD_ORDER_TRANSITION_DURATION_MS).toBe(1500);
|
||||||
|
expect(isDownloadPackageOrderChange(["a", "b", "c"], ["b", "c", "a"])).toBe(true);
|
||||||
|
expect(isDownloadPackageOrderChange(["a", "b"], ["a", "c"])).toBe(false);
|
||||||
|
expect(getDownloadOrderTransitionPinnedIds({
|
||||||
|
enabled: true,
|
||||||
|
previousOrder: ["a", "b", "c"],
|
||||||
|
nextOrder: ["b", "c", "a"],
|
||||||
|
previousVisibleIds: ["a", "a:items", "b"],
|
||||||
|
activePinnedIds: []
|
||||||
|
})).toEqual(["a", "a:items", "b"]);
|
||||||
|
expect(getDownloadOrderTransitionPinnedIds({
|
||||||
|
enabled: false,
|
||||||
|
previousOrder: ["a", "b", "c"],
|
||||||
|
nextOrder: ["b", "c", "a"],
|
||||||
|
previousVisibleIds: ["a", "b"],
|
||||||
|
activePinnedIds: ["c"]
|
||||||
|
})).toEqual([]);
|
||||||
|
expect(getDownloadOrderTransformKeyframes(240, 159, 315)).toEqual([
|
||||||
|
{ transform: "translateY(84px)" },
|
||||||
|
{ transform: "translateY(240px)" }
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("zeichnet den Startzustand in einem eigenen Frame vor der Aktivierung", () => {
|
it("zeichnet den Startzustand in einem eigenen Frame vor der Aktivierung", () => {
|
||||||
@@ -681,7 +711,7 @@ function withRuntime(input: DownloadsModelInput, overrides: Record<string, unkno
|
|||||||
scheduleLabel: "",
|
scheduleLabel: "",
|
||||||
packageSpeedBps: { "package-a": 12_000_000 },
|
packageSpeedBps: { "package-a": 12_000_000 },
|
||||||
disclosureRevision: 0,
|
disclosureRevision: 0,
|
||||||
animatePackageDisclosure: true,
|
animationsEnabled: true,
|
||||||
editingPackageId: null,
|
editingPackageId: null,
|
||||||
editingName: "",
|
editingName: "",
|
||||||
columnOrder: ["name", "size", "hoster", "progress"] as const,
|
columnOrder: ["name", "size", "hoster", "progress"] as const,
|
||||||
@@ -1177,6 +1207,16 @@ describe("downloads App integration", () => {
|
|||||||
expect(source).toMatch(/disabled=\{allMatch\}[\s\S]{0,260}window\.rd\.setPackagePriority/);
|
expect(source).toMatch(/disabled=\{allMatch\}[\s\S]{0,260}window\.rd\.setPackagePriority/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps previously visible package rows mounted while priority order glides", () => {
|
||||||
|
const source = readFileSync(new URL("../src/renderer/views/downloads/VirtualizedDownloadsBody.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||||
|
|
||||||
|
expect(source).toContain("getDownloadOrderTransitionPinnedIds");
|
||||||
|
expect(source).toContain("animateDownloadOrderRows");
|
||||||
|
expect(source).toContain("orderTransitionPinnedIdsRef.current");
|
||||||
|
expect(source).toContain("data-download-row-id={entry.id}");
|
||||||
|
expect(source).toContain("DOWNLOAD_ORDER_TRANSITION_DURATION_MS");
|
||||||
|
});
|
||||||
|
|
||||||
it("updates the clipboard checkbox optimistically before IPC reconciliation", () => {
|
it("updates the clipboard checkbox optimistically before IPC reconciliation", () => {
|
||||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ describe("renderer localization", () => {
|
|||||||
expect(translateUiText("Einstellungen speichern", "en")).toBe("Save settings");
|
expect(translateUiText("Einstellungen speichern", "en")).toBe("Save settings");
|
||||||
expect(translateUiText("Save settings", "de")).toBe("Einstellungen speichern");
|
expect(translateUiText("Save settings", "de")).toBe("Einstellungen speichern");
|
||||||
expect(translateUiText("Passwort/Zugang", "en")).toBe("Password/access");
|
expect(translateUiText("Passwort/Zugang", "en")).toBe("Password/access");
|
||||||
|
expect(translateUiText("Animationen", "en")).toBe("Animations");
|
||||||
|
expect(translateUiText("Animations", "de")).toBe("Animationen");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("translates dynamic update and pagination text", () => {
|
it("translates dynamic update and pagination text", () => {
|
||||||
|
|||||||
@@ -501,7 +501,7 @@ describe("settings views", () => {
|
|||||||
expect(settingsCss).toMatch(/\.settings-select\.is-open\s+\.settings-select-options\s*\{[^}]*opacity:\s*1[^}]*transform:\s*translateY\(0\)/s);
|
expect(settingsCss).toMatch(/\.settings-select\.is-open\s+\.settings-select-options\s*\{[^}]*opacity:\s*1[^}]*transform:\s*translateY\(0\)/s);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers a general switch for package expand and collapse motion", () => {
|
it("offers one general switch for interface animations", () => {
|
||||||
const form = buildSettingsFormViewModel({
|
const form = buildSettingsFormViewModel({
|
||||||
settings: { ...createRendererSettings(defaultSettings()), archivePasswordList: "", notifyUrl: "" },
|
settings: { ...createRendererSettings(defaultSettings()), archivePasswordList: "", notifyUrl: "" },
|
||||||
section: "allgemein",
|
section: "allgemein",
|
||||||
@@ -514,7 +514,7 @@ describe("settings views", () => {
|
|||||||
expect(animation).toEqual({
|
expect(animation).toEqual({
|
||||||
id: "animatePackageDisclosure",
|
id: "animatePackageDisclosure",
|
||||||
kind: "switch",
|
kind: "switch",
|
||||||
label: "Paket-Ein-/Ausklappen animieren",
|
label: "Animationen",
|
||||||
value: true
|
value: true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -192,6 +192,27 @@ describe("visual fixtures", () => {
|
|||||||
expect(dense).toHaveProperty("remoteDiagnostics.status.running", false);
|
expect(dense).toHaveProperty("remoteDiagnostics.status.running", false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emits the priority-sorted snapshot so visual tests can observe package motion", async () => {
|
||||||
|
const dense = createVisualFixture("dense");
|
||||||
|
const api = createVisualElectronApi(dense);
|
||||||
|
const snapshots: string[][] = [];
|
||||||
|
const unsubscribe = api.onStateUpdate((snapshot) => snapshots.push([...snapshot.session.packageOrder]));
|
||||||
|
const target = dense.snapshot.session.packageOrder[0];
|
||||||
|
|
||||||
|
await api.setPackagePriority(target, "low");
|
||||||
|
|
||||||
|
expect(dense.snapshot.session.packageOrder.at(-1)).toBe(target);
|
||||||
|
expect(snapshots.at(-1)?.at(-1)).toBe(target);
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can boot the isolated renderer with animations disabled", async () => {
|
||||||
|
const dense = createVisualFixture("dense");
|
||||||
|
const api = createVisualElectronApi(dense, "?animations=off");
|
||||||
|
|
||||||
|
expect((await api.getSnapshot()).settings.animatePackageDisclosure).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("boots the dense query once and waits for both visible package names", async () => {
|
it("boots the dense query once and waits for both visible package names", async () => {
|
||||||
expect(typeof window).toBe("undefined");
|
expect(typeof window).toBe("undefined");
|
||||||
const harness = createTestVisualBootstrap(
|
const harness = createTestVisualBootstrap(
|
||||||
|
|||||||
@@ -12,11 +12,19 @@ export function createVisualElectronApi(
|
|||||||
fixture: VisualFixture,
|
fixture: VisualFixture,
|
||||||
search = typeof window === "undefined" ? "" : window.location.search
|
search = typeof window === "undefined" ? "" : window.location.search
|
||||||
): ElectronApi {
|
): ElectronApi {
|
||||||
const historyState = new URLSearchParams(search).get("history-state");
|
const searchParams = new URLSearchParams(search);
|
||||||
|
const historyState = searchParams.get("history-state");
|
||||||
|
if (searchParams.get("animations") === "off") fixture.snapshot.settings.animatePackageDisclosure = false;
|
||||||
let historyRequestCount = 0;
|
let historyRequestCount = 0;
|
||||||
|
const stateUpdateListeners = new Set<Parameters<ElectronApi["onStateUpdate"]>[0]>();
|
||||||
|
const emitStateUpdate = (): void => {
|
||||||
|
const snapshot = clone(fixture.snapshot);
|
||||||
|
for (const listener of stateUpdateListeners) listener(snapshot);
|
||||||
|
};
|
||||||
const updateSettings = (settings: RendererSettingsUpdate): RendererSettings => {
|
const updateSettings = (settings: RendererSettingsUpdate): RendererSettings => {
|
||||||
const { archivePasswordList: _archivePasswordList, notifyUrl: _notifyUrl, ...safe } = settings;
|
const { archivePasswordList: _archivePasswordList, notifyUrl: _notifyUrl, ...safe } = settings;
|
||||||
Object.assign(fixture.snapshot.settings, safe);
|
Object.assign(fixture.snapshot.settings, safe);
|
||||||
|
emitStateUpdate();
|
||||||
return clone(fixture.snapshot.settings);
|
return clone(fixture.snapshot.settings);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -376,9 +384,24 @@ export function createVisualElectronApi(
|
|||||||
: { ok: false, reason: "entry-not-found" },
|
: { ok: false, reason: "entry-not-found" },
|
||||||
setPackagePriority: async (packageId, priority) => {
|
setPackagePriority: async (packageId, priority) => {
|
||||||
const entry = fixture.snapshot.session.packages[packageId];
|
const entry = fixture.snapshot.session.packages[packageId];
|
||||||
if (entry) {
|
if (!entry || entry.priority === priority) return;
|
||||||
entry.priority = priority;
|
entry.priority = priority;
|
||||||
|
const order = fixture.snapshot.session.packageOrder;
|
||||||
|
const currentIndex = order.indexOf(packageId);
|
||||||
|
if (currentIndex >= 0) {
|
||||||
|
order.splice(currentIndex, 1);
|
||||||
|
const priorityRank = { high: 0, normal: 1, low: 2 } as const;
|
||||||
|
let insertAt = order.length;
|
||||||
|
for (let index = 0; index < order.length; index += 1) {
|
||||||
|
const otherPriority = fixture.snapshot.session.packages[order[index]]?.priority || "normal";
|
||||||
|
if (priorityRank[otherPriority] > priorityRank[priority]) {
|
||||||
|
insertAt = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order.splice(insertAt, 0, packageId);
|
||||||
}
|
}
|
||||||
|
emitStateUpdate();
|
||||||
},
|
},
|
||||||
skipItems: async (itemIds) => {
|
skipItems: async (itemIds) => {
|
||||||
for (const itemId of itemIds) {
|
for (const itemId of itemIds) {
|
||||||
@@ -413,7 +436,10 @@ export function createVisualElectronApi(
|
|||||||
fixture.snapshot.session.running = true;
|
fixture.snapshot.session.running = true;
|
||||||
},
|
},
|
||||||
reportRendererError: () => {},
|
reportRendererError: () => {},
|
||||||
onStateUpdate: () => stableNoopUnsubscribe,
|
onStateUpdate: (callback) => {
|
||||||
|
stateUpdateListeners.add(callback);
|
||||||
|
return () => stateUpdateListeners.delete(callback);
|
||||||
|
},
|
||||||
onClipboardDetected: () => stableNoopUnsubscribe,
|
onClipboardDetected: () => stableNoopUnsubscribe,
|
||||||
onUpdateInstallProgress: () => stableNoopUnsubscribe
|
onUpdateInstallProgress: () => stableNoopUnsubscribe
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user