From 0dfb57525a01b28554de61011527d79b478ed8f0 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Thu, 20 Aug 2026 09:16:06 +0200 Subject: [PATCH] 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. --- src/renderer/App.tsx | 57 ++++++++---- .../views/downloads/DownloadsTable.tsx | 19 +++- src/renderer/views/downloads/column-drag.ts | 53 ++++++++---- src/renderer/views/downloads/downloads.css | 17 ++-- tests/app-shell.test.tsx | 9 ++ tests/column-drag.test.ts | 86 ++++++++++++++----- tests/downloads-view.test.tsx | 31 +++++++ 7 files changed, 205 insertions(+), 67 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7a0f453..5e4c76a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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(null); const columnDragSettleTimerRef = useRef(null); + const columnDragAnimationsRef = useRef([]); 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(null); const [historyEntries, setHistoryEntries] = useState([]); @@ -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 }) }; diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx index c732943..3b1a4ca 100644 --- a/src/renderer/views/downloads/DownloadsTable.tsx +++ b/src/renderer/views/downloads/DownloadsTable.tsx @@ -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(".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 ( -
+
actions.onSetVisibleSelection(visibleIds, event.target.checked)} ref={(input) => { if (input) input.indeterminate = mixedSelection; }} type="checkbox" /> {columnOrder.map((column, index) => { const definition = downloadColumnDefinitions[column]; diff --git a/src/renderer/views/downloads/column-drag.ts b/src/renderer/views/downloads/column-drag.ts index d203355..6a572f3 100644 --- a/src/renderer/views/downloads/column-drag.ts +++ b/src/renderer/views/downloads/column-drag.ts @@ -4,6 +4,8 @@ export interface ColumnDragMeasurement { width: number; } +export const DOWNLOAD_COLUMN_MOVE_DURATION_MS = 220; + export interface ColumnDragPreview { order: string[]; offsets: Record; @@ -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(".downloads-table"); const header = element.closest(".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("[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("[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 { diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css index a6c4a62..a845908 100644 --- a/src/renderer/views/downloads/downloads.css +++ b/src/renderer/views/downloads/downloads.css @@ -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; diff --git a/tests/app-shell.test.tsx b/tests/app-shell.test.tsx index 1d92d4e..dd0c887 100644 --- a/tests/app-shell.test.tsx +++ b/tests/app-shell.test.tsx @@ -73,6 +73,15 @@ describe("desktop shell", () => { expect(stateUpdates).not.toContain("syncColumnOrderFromSnapshot"); }); + it("commits the target column grid before waiting for animation cleanup", () => { + const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); + const pointerUp = source.slice(source.indexOf("onColumnPointerUp:"), source.indexOf("onColumnPointerCancel:")); + + expect(pointerUp.indexOf("commitDownloadColumnDrag")).toBeGreaterThanOrEqual(0); + expect(pointerUp.indexOf("commitDownloadColumnDrag")).toBeLessThan(pointerUp.indexOf("columnDragSettleTimerRef.current = window.setTimeout")); + expect(pointerUp).toContain("snapshot.settings.animatePackageDisclosure"); + }); + it("keeps application menus mounted for animated opening and closing", () => { const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8"); diff --git a/tests/column-drag.test.ts b/tests/column-drag.test.ts index ea3cb01..6ced94c 100644 --- a/tests/column-drag.test.ts +++ b/tests/column-drag.test.ts @@ -54,49 +54,89 @@ describe("animated download column drag", () => { }); it.each([ - { draggedId: "name", measurements: columns, next: ["size", "name", "status"] }, - { - draggedId: "name", - measurements: [ - { id: "size", left: 0, width: 150 }, - { id: "name", left: 150, width: 300 }, - { id: "status", left: 450, width: 100 } - ], - next: ["name", "size", "status"] - } - ])("clears transforms before committing a $draggedId grid move", ({ draggedId, measurements, next }) => { + { afterLeft: 150, beforeLeft: 0, expectedDelta: -150, next: ["size", "name", "status"] }, + { afterLeft: 0, beforeLeft: 150, expectedDelta: 150, next: ["name", "size", "status"] } + ])("commits the target grid before animating from a $expectedDelta px inverse offset", ({ afterLeft, beforeLeft, expectedDelta, next }) => { const commitDownloadColumnDrag = (columnDrag as unknown as { - commitDownloadColumnDrag?: (session: DownloadColumnDragSession, order: string[], commit: (order: string[]) => void) => void; + commitDownloadColumnDrag?: (session: DownloadColumnDragSession, order: string[], commit: (order: string[]) => void, prepare: () => void) => Animation[]; }).commitDownloadColumnDrag; expect(commitDownloadColumnDrag).toBeTypeOf("function"); if (!commitDownloadColumnDrag) return; const events: string[] = []; + let committed = false; + const outerAnimate = vi.fn(() => ({ finished: Promise.resolve() } as unknown as Animation)); + const animate = vi.fn(() => ({ finished: Promise.resolve() } as unknown as Animation)); + const motionTarget = { animate } as unknown as HTMLElement; + const element = { + animate: outerAnimate, + children: [motionTarget], + getBoundingClientRect: () => ({ left: committed ? afterLeft : beforeLeft, width: 300 }) + } as unknown as HTMLElement; const root = { - classList: { remove: () => events.push("clear-classes") }, - dataset: { columnDragging: draggedId }, - querySelectorAll: () => [], + classList: { add: () => events.push("add-class"), remove: () => events.push("clear-classes") }, + dataset: { columnDragging: "name" }, + querySelectorAll: (selector: string) => selector === "[data-download-column]" ? [element] : [], style: { removeProperty: (property: string) => events.push(`clear:${property}`) } } as unknown as HTMLElement; const session = { active: true, - draggedId, - measurements, + draggedId: "name", + measurements: columns, pointerId: -1, - preview: calculateColumnDragPreview(measurements, draggedId, next[0] === draggedId ? -160 : 160), + preview: calculateColumnDragPreview(columns, "name", next[0] === "name" ? -160 : 160), root, startX: 0 } as DownloadColumnDragSession; - commitDownloadColumnDrag(session, next, (order) => { + const animations = commitDownloadColumnDrag(session, next, (order) => { + committed = true; events.push(`commit:${order.join("|")}`); - }); + }, () => events.push("prepare-grid")); - expect(events.at(-1)).toBe(`commit:${next.join("|")}`); - expect(events.slice(0, -1)).toContain("clear:--downloads-column-drag-name"); - expect(events.slice(0, -1)).toContain("clear:--downloads-column-drag-size"); + expect(animations).toHaveLength(1); + expect(events).toContain("prepare-grid"); + expect(events.indexOf("prepare-grid")).toBeLessThan(events.indexOf(`commit:${next.join("|")}`)); + expect(events).toContain(`commit:${next.join("|")}`); + expect(outerAnimate).not.toHaveBeenCalled(); + expect(animate).toHaveBeenCalledWith([ + { transform: `translate3d(${expectedDelta}px, 0, 0)` }, + { transform: "translate3d(0, 0, 0)" } + ], expect.objectContaining({ duration: 220 })); + }); + + it("commits immediately without WAAPI when application animations are disabled", () => { + const animate = vi.fn(() => ({ finished: Promise.resolve() } as unknown as Animation)); + let committed = false; + const element = { + children: [{ animate }], + getBoundingClientRect: () => ({ left: committed ? 150 : 0, width: 300 }) + } as unknown as HTMLElement; + const root = { + classList: { add: vi.fn(), remove: vi.fn() }, + dataset: {}, + querySelectorAll: (selector: string) => selector === "[data-download-column]" ? [element] : [], + style: { removeProperty: vi.fn() } + } as unknown as HTMLElement; + const session = { + active: true, + draggedId: "name", + measurements: columns, + pointerId: -1, + preview: calculateColumnDragPreview(columns, "name", 160), + root, + startX: 0 + } as DownloadColumnDragSession; + + const animations = columnDrag.commitDownloadColumnDrag(session, ["size", "name", "status"], () => { + committed = true; + }, () => {}, false); + + expect(committed).toBe(true); + expect(animations).toEqual([]); + expect(animate).not.toHaveBeenCalled(); }); it("serializes and coalesces rapid persistence requests", async () => { diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index e69fce3..feb0431 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -55,6 +55,7 @@ import { getPackageProgress, getPackageSizeProgress } from "../src/renderer/views/downloads/DownloadsTable"; +import * as downloadsTableModule from "../src/renderer/views/downloads/DownloadsTable"; import { DOWNLOAD_FILE_ROW_HEIGHT, DOWNLOAD_PACKAGE_ROW_HEIGHT, @@ -71,6 +72,36 @@ import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime(); describe("Downloadtabellen-Spalten", () => { + it("derives grid tracks from the same column order that renders the cells", () => { + const downloadGridTemplateForOrder = (downloadsTableModule as unknown as { + downloadGridTemplateForOrder?: (order: readonly string[]) => string; + }).downloadGridTemplateForOrder; + expect(downloadGridTemplateForOrder).toBeTypeOf("function"); + if (!downloadGridTemplateForOrder) return; + + const order = ["size", "name"]; + const expected = `36px ${downloadColumnDefinitions.size.width} ${downloadColumnDefinitions.name.width} 60px`; + const header = DownloadsTableHeader({ + actions: createActions(), + columnOrder: order, + gridTemplate: `${downloadColumnDefinitions.name.width} ${downloadColumnDefinitions.size.width}`, + selectedCount: 0, + sortColumn: "name", + sortDirection: "asc", + visibleIds: [] + }); + + expect(downloadGridTemplateForOrder(order)).toBe(expected); + expect(header.props.style.gridTemplateColumns).toBe(expected); + }); + + it("never interpolates download grid tracks while column contents move", () => { + const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8"); + + expect(css).toMatch(/\.downloads-table-header,\s*\.downloads-package-row,\s*\.downloads-item-row\s*\{[^}]*transition-property:\s*none;/s); + expect(css).toMatch(/\.downloads-table\.is-column-drag-motion-disabled\.is-column-drag-active\s+\[data-download-column\]\s*\{[^}]*transition:\s*none\s*!important;/s); + }); + it("verteilt die Breite mit ausreichend Platz für vollständige Überschriften", () => { expect(downloadColumnDefinitions.name.width).toBe("minmax(var(--downloads-name-min, 290px), 2.3fr)"); expect(downloadColumnDefinitions.progress.width).toBe("minmax(var(--downloads-progress-min, 105px), 0.85fr)");