diff --git a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx index 041a7d9..6e1ea7a 100644 --- a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx +++ b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx @@ -3,6 +3,7 @@ import { buildDownloadLogicalRows, type DownloadLogicalRow } from "./downloads-m import { activateDownloadDisclosureTransition, DOWNLOAD_DISCLOSURE_DURATION_MS, + getDownloadDisclosurePinnedIds, mergeDownloadDisclosureRows, prepareDownloadDisclosureTransition, scheduleDownloadDisclosureActivation, @@ -98,6 +99,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D desiredRowsRef.current = desiredRows; const previousRowsRef = useRef(desiredRows); const transitionRowsRef = useRef(null); + const transitionPinnedIdsRef = useRef([]); const [transitionRows, setTransitionRows] = useState(null); useEffect(() => { @@ -108,10 +110,16 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D const prepared = prepareDownloadDisclosureTransition(transitionRowsRef.current ?? previousRowsRef.current, desiredRowsRef.current); if (!prepared.animated) { transitionRowsRef.current = null; + transitionPinnedIdsRef.current = []; previousRowsRef.current = desiredRowsRef.current; setTransitionRows(null); return; } + transitionPinnedIdsRef.current = getDownloadDisclosurePinnedIds(prepared.rows, { + scrollTop: viewport.scrollTop, + viewportHeight: viewport.viewportHeight, + overscan: DOWNLOAD_VIRTUAL_OVERSCAN_ROWS + }); transitionRowsRef.current = prepared.rows; setTransitionRows(prepared.rows); let settleTimer = 0; @@ -122,6 +130,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D settleTimer = window.setTimeout(() => { previousRowsRef.current = desiredRowsRef.current; transitionRowsRef.current = null; + transitionPinnedIdsRef.current = []; setTransitionRows(null); }, DOWNLOAD_DISCLOSURE_DURATION_MS); }); @@ -136,7 +145,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D scrollTop: viewport.scrollTop, viewportHeight: viewport.viewportHeight, overscan: DOWNLOAD_VIRTUAL_OVERSCAN_ROWS, - pinnedIds: [model.editingPackageId] + pinnedIds: [model.editingPackageId, ...transitionPinnedIdsRef.current] }), [model.editingPackageId, renderedRows, viewport.scrollTop, viewport.viewportHeight]); const spacerStyle = { "--downloads-virtual-total-height": `${virtualWindow.totalHeight}px` } as CSSProperties; diff --git a/src/renderer/views/downloads/download-disclosure-transition.ts b/src/renderer/views/downloads/download-disclosure-transition.ts index 95db89d..79acfd7 100644 --- a/src/renderer/views/downloads/download-disclosure-transition.ts +++ b/src/renderer/views/downloads/download-disclosure-transition.ts @@ -1,5 +1,9 @@ import type { DownloadLogicalRow } from "./downloads-model"; -import { DOWNLOAD_FILE_ROW_HEIGHT } from "./download-virtualizer"; +import { + DOWNLOAD_FILE_ROW_HEIGHT, + calculateDownloadVirtualWindow, + type DownloadVirtualWindowOptions +} from "./download-virtualizer"; export const DOWNLOAD_DISCLOSURE_DURATION_MS = 300; export const DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS = 64; @@ -149,3 +153,13 @@ export function activateDownloadDisclosureTransition(rows: readonly DownloadDisc : { ...row, height: 0, disclosureOpacity: 0 }; }); } + +export function getDownloadDisclosurePinnedIds( + preparedRows: readonly DownloadDisclosureRow[], + options: DownloadVirtualWindowOptions +): string[] { + const windowOptions = { ...options, pinnedIds: [] }; + const startRows = calculateDownloadVirtualWindow(preparedRows, windowOptions).rows; + const targetRows = calculateDownloadVirtualWindow(activateDownloadDisclosureTransition(preparedRows), windowOptions).rows; + return [...new Set([...startRows, ...targetRows].map((entry) => entry.id))]; +} diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 3bd23af..53cc7f0 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -21,6 +21,7 @@ import { import { DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS, activateDownloadDisclosureTransition, + getDownloadDisclosurePinnedIds, mergeDownloadDisclosureRows, prepareDownloadDisclosureTransition, scheduleDownloadDisclosureActivation, @@ -302,6 +303,45 @@ describe("virtualisierte Paketanimation", () => { expect(stableDownloadDisclosureRows(collapsed).map((row) => row.id)).toEqual(["package-a", "package-b", "b-1"]); }); + it("mountet beim Zuklappen auch erst im Ziel-Viewport sichtbare Folgepakete vor der Bewegung", () => { + const childIds = Array.from({ length: 26 }, (_, index) => `first-${index}`); + const followingPackages = Array.from({ length: 36 }, (_, index) => pkg(`following-${index}`, `Folge ${index}`, [`following-item-${index}`])); + const firstPackage = pkg("first", "Erstes Paket", childIds); + const packageOrder = [firstPackage.id, ...followingPackages.map((entry) => entry.id)]; + const packages = Object.fromEntries([firstPackage, ...followingPackages].map((entry) => [entry.id, entry])); + const queueItems = Object.fromEntries([ + ...childIds.map((id) => [id, item(id, firstPackage.id, "queued")] as const), + ...followingPackages.map((entry, index) => [`following-item-${index}`, item(`following-item-${index}`, entry.id, "queued")] as const) + ]); + const buildRows = (collapsed: boolean) => buildDownloadLogicalRows(buildDownloadsViewModel({ + packageOrder, + packages, + items: queueItems, + displayMode: "packages", + filter: "all", + providerFilter: "all", + query: "", + collapsedPackageIds: collapsed ? packageOrder : followingPackages.map((entry) => entry.id), + selectedIds: [], + hideExtractedItems: false, + showAllPackages: true, + renderLimit: 100 + })); + const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(buildRows(false)), buildRows(true)); + const options = { scrollTop: 0, viewportHeight: 720, overscan: 8 }; + const startWindow = calculateDownloadVirtualWindow(prepared.rows, options); + const targetWindow = calculateDownloadVirtualWindow(activateDownloadDisclosureTransition(prepared.rows), options); + const startIds = new Set(startWindow.rows.map((entry) => entry.id)); + const targetOnlyIds = targetWindow.rows.map((entry) => entry.id).filter((id) => !startIds.has(id)); + const pinnedIds = getDownloadDisclosurePinnedIds(prepared.rows, options); + const mountedStartWindow = calculateDownloadVirtualWindow(prepared.rows, { ...options, pinnedIds }); + const mountedStartIds = new Set(mountedStartWindow.rows.map((entry) => entry.id)); + + expect(targetOnlyIds.length).toBeGreaterThan(10); + expect(targetOnlyIds.every((id) => mountedStartIds.has(id))).toBe(true); + expect(pinnedIds.length).toBeLessThan(80); + }); + it("überspringt die Bewegung bei riesigen Einzelpaketen und hält das DOM-Fenster begrenzt", () => { const count = DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS + 10_000; const hugePackage = pkg("huge-package", "Groß", Array.from({ length: count }, (_, index) => `huge-${index}`));