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:
+41
-16
@@ -95,9 +95,9 @@ import {
|
|||||||
type StatisticsViewActions
|
type StatisticsViewActions
|
||||||
} from "./views/statistics/StatisticsView";
|
} from "./views/statistics/StatisticsView";
|
||||||
import { buildDownloadsViewModel, formatRemainingDownloadBytes, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, getRemainingDownloadBytes, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
|
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 { 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 {
|
import {
|
||||||
DownloadsContent,
|
DownloadsContent,
|
||||||
DownloadsFooter,
|
DownloadsFooter,
|
||||||
@@ -1645,11 +1645,17 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
const columnDragSessionRef = useRef<DownloadColumnDragSession | null>(null);
|
const columnDragSessionRef = useRef<DownloadColumnDragSession | null>(null);
|
||||||
const columnDragSettleTimerRef = useRef<number | null>(null);
|
const columnDragSettleTimerRef = useRef<number | null>(null);
|
||||||
|
const columnDragAnimationsRef = useRef<Animation[]>([]);
|
||||||
const suppressColumnSortRef = useRef(false);
|
const suppressColumnSortRef = useRef(false);
|
||||||
|
const cancelColumnDragAnimations = useCallback((): void => {
|
||||||
|
columnDragAnimationsRef.current.forEach((animation) => animation.cancel());
|
||||||
|
columnDragAnimationsRef.current = [];
|
||||||
|
}, []);
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
if (columnDragSettleTimerRef.current !== null) window.clearTimeout(columnDragSettleTimerRef.current);
|
if (columnDragSettleTimerRef.current !== null) window.clearTimeout(columnDragSettleTimerRef.current);
|
||||||
|
cancelColumnDragAnimations();
|
||||||
if (columnDragSessionRef.current) clearDownloadColumnDrag(columnDragSessionRef.current);
|
if (columnDragSessionRef.current) clearDownloadColumnDrag(columnDragSessionRef.current);
|
||||||
}, []);
|
}, [cancelColumnDragAnimations]);
|
||||||
const [colHeaderCtx, setColHeaderCtx] = useState<{ x: number; y: number } | null>(null);
|
const [colHeaderCtx, setColHeaderCtx] = useState<{ x: number; y: number } | null>(null);
|
||||||
const colHeaderCtxRef = useRef<HTMLDivElement>(null);
|
const colHeaderCtxRef = useRef<HTMLDivElement>(null);
|
||||||
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
|
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
|
||||||
@@ -4890,8 +4896,9 @@ export function App(): ReactElement {
|
|||||||
window.clearTimeout(columnDragSettleTimerRef.current);
|
window.clearTimeout(columnDragSettleTimerRef.current);
|
||||||
columnDragSettleTimerRef.current = null;
|
columnDragSettleTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
cancelColumnDragAnimations();
|
||||||
if (columnDragSessionRef.current) clearDownloadColumnDrag(columnDragSessionRef.current);
|
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) => {
|
onColumnPointerMove: (_column, event) => {
|
||||||
const session = columnDragSessionRef.current;
|
const session = columnDragSessionRef.current;
|
||||||
@@ -4901,34 +4908,52 @@ export function App(): ReactElement {
|
|||||||
onColumnPointerUp: (_column, event) => {
|
onColumnPointerUp: (_column, event) => {
|
||||||
const session = columnDragSessionRef.current;
|
const session = columnDragSessionRef.current;
|
||||||
if (!session || session.pointerId !== event.pointerId) return;
|
if (!session || session.pointerId !== event.pointerId) return;
|
||||||
const next = settleDownloadColumnDrag(session, false);
|
if (!session.active) {
|
||||||
if (!next) {
|
clearDownloadColumnDrag(session);
|
||||||
columnDragSessionRef.current = null;
|
columnDragSessionRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const next = session.preview.order;
|
||||||
suppressColumnSortRef.current = true;
|
suppressColumnSortRef.current = true;
|
||||||
window.setTimeout(() => { suppressColumnSortRef.current = false; }, 0);
|
window.setTimeout(() => { suppressColumnSortRef.current = false; }, 0);
|
||||||
const changed = next.join("|") !== session.measurements.map((measurement) => measurement.id).join("|");
|
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(() => {
|
columnDragSettleTimerRef.current = window.setTimeout(() => {
|
||||||
if (changed) {
|
cancelColumnDragAnimations();
|
||||||
persistColumnOrder(next);
|
session.root.classList.remove("is-column-drag-settling");
|
||||||
commitDownloadColumnDrag(session, next, (order) => flushSync(() => setColumnOrder(order)));
|
|
||||||
} else {
|
|
||||||
clearDownloadColumnDrag(session);
|
|
||||||
}
|
|
||||||
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
|
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
|
||||||
columnDragSettleTimerRef.current = null;
|
columnDragSettleTimerRef.current = null;
|
||||||
}, 220);
|
}, DOWNLOAD_COLUMN_MOVE_DURATION_MS);
|
||||||
},
|
},
|
||||||
onColumnPointerCancel: (_column, event) => {
|
onColumnPointerCancel: (_column, event) => {
|
||||||
const session = columnDragSessionRef.current;
|
const session = columnDragSessionRef.current;
|
||||||
if (!session || session.pointerId !== event.pointerId) return;
|
if (!session || session.pointerId !== event.pointerId) return;
|
||||||
settleDownloadColumnDrag(session, true);
|
if (!session.active) {
|
||||||
columnDragSettleTimerRef.current = window.setTimeout(() => {
|
|
||||||
clearDownloadColumnDrag(session);
|
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;
|
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
|
||||||
columnDragSettleTimerRef.current = null;
|
columnDragSettleTimerRef.current = null;
|
||||||
}, 220);
|
}, DOWNLOAD_COLUMN_MOVE_DURATION_MS);
|
||||||
},
|
},
|
||||||
onColumnContextMenu: (_column, x, y) => setColHeaderCtx({ x, y })
|
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}`;
|
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 {
|
function isPackageRowDisclosureExcluded(target: EventTarget | null): boolean {
|
||||||
const closest = (target as { closest?: (selector: string) => Element | null } | null)?.closest;
|
const closest = (target as { closest?: (selector: string) => Element | null } | null)?.closest;
|
||||||
return typeof closest === "function" && closest.call(target, PACKAGE_ROW_DISCLOSURE_EXCLUSION_SELECTOR) !== null;
|
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" : ""}`}
|
className={`downloads-item-row${selected ? " is-selected" : ""}`}
|
||||||
data-download-row-id={item.id}
|
data-download-row-id={item.id}
|
||||||
role="row"
|
role="row"
|
||||||
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
|
style={{ gridTemplateColumns: downloadGridTemplateForOrder(columnOrder) }}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
actions.onToggleSelection(item.id, event.ctrlKey || event.metaKey, event.shiftKey);
|
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"
|
className="downloads-package-row"
|
||||||
data-download-row-id={entry.id}
|
data-download-row-id={entry.id}
|
||||||
role="row"
|
role="row"
|
||||||
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
|
style={{ gridTemplateColumns: downloadGridTemplateForOrder(columnOrder) }}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.detail > 1) return;
|
if (event.detail > 1) return;
|
||||||
if (!event.ctrlKey && !event.metaKey && !event.shiftKey && selectedIds.size === 1 && selectedIds.has(entry.id)) 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 allSelected = visibleIds.length > 0 && selectedCount === visibleIds.length;
|
||||||
const mixedSelection = selectedCount > 0 && selectedCount < visibleIds.length;
|
const mixedSelection = selectedCount > 0 && selectedCount < visibleIds.length;
|
||||||
return (
|
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>
|
<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) => {
|
{columnOrder.map((column, index) => {
|
||||||
const definition = downloadColumnDefinitions[column];
|
const definition = downloadColumnDefinitions[column];
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface ColumnDragMeasurement {
|
|||||||
width: number;
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DOWNLOAD_COLUMN_MOVE_DURATION_MS = 220;
|
||||||
|
|
||||||
export interface ColumnDragPreview {
|
export interface ColumnDragPreview {
|
||||||
order: string[];
|
order: string[];
|
||||||
offsets: Record<string, number>;
|
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 root = element.closest<HTMLElement>(".downloads-table");
|
||||||
const header = element.closest<HTMLElement>(".downloads-table-header");
|
const header = element.closest<HTMLElement>(".downloads-table-header");
|
||||||
if (!root || !header) return null;
|
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 };
|
return { id: column.dataset.downloadColumn ?? "", left: rect.left, width: rect.width };
|
||||||
}).filter((column) => column.id && column.width > 0);
|
}).filter((column) => column.id && column.width > 0);
|
||||||
if (!measurements.some((column) => column.id === draggedId)) return null;
|
if (!measurements.some((column) => column.id === draggedId)) return null;
|
||||||
|
root.classList.toggle("is-column-drag-motion-disabled", !animationsEnabled);
|
||||||
return {
|
return {
|
||||||
active: false,
|
active: false,
|
||||||
draggedId,
|
draggedId,
|
||||||
@@ -106,22 +109,8 @@ export function updateDownloadColumnDrag(session: DownloadColumnDragSession, cli
|
|||||||
return true;
|
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 {
|
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.root.style.removeProperty("--downloads-active-drag-x");
|
||||||
session.measurements.forEach((measurement) => session.root.style.removeProperty(`--downloads-column-drag-${measurement.id}`));
|
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);
|
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(
|
export function commitDownloadColumnDrag(
|
||||||
session: DownloadColumnDragSession,
|
session: DownloadColumnDragSession,
|
||||||
order: string[],
|
order: string[],
|
||||||
commit: (order: string[]) => void
|
commit: (order: string[]) => void,
|
||||||
): 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);
|
clearDownloadColumnDrag(session);
|
||||||
|
prepare();
|
||||||
commit(order);
|
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 {
|
export interface DownloadColumnOrderPersistence {
|
||||||
|
|||||||
@@ -281,6 +281,12 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloads-table-header,
|
||||||
|
.downloads-package-row,
|
||||||
|
.downloads-item-row {
|
||||||
|
transition-property: none;
|
||||||
|
}
|
||||||
|
|
||||||
.downloads-table-body {
|
.downloads-table-body {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
contain: layout style;
|
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);
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.downloads-virtual-spacer {
|
.downloads-virtual-spacer {
|
||||||
transition-duration: 1500ms !important;
|
transition-duration: 1500ms !important;
|
||||||
@@ -600,8 +602,7 @@
|
|||||||
transition-duration: 1500ms, 1500ms, 1500ms !important;
|
transition-duration: 1500ms, 1500ms, 1500ms !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-table.is-column-drag-active [data-download-column],
|
.downloads-table.is-column-drag-active [data-download-column] {
|
||||||
.downloads-table.is-column-drag-settling [data-download-column] {
|
|
||||||
transition-duration: 220ms !important;
|
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 {
|
.downloads-column-sort {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ describe("desktop shell", () => {
|
|||||||
expect(stateUpdates).not.toContain("syncColumnOrderFromSnapshot");
|
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", () => {
|
it("keeps application menus mounted for animated opening and closing", () => {
|
||||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
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");
|
const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
|
||||||
|
|||||||
+63
-23
@@ -54,49 +54,89 @@ describe("animated download column drag", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ draggedId: "name", measurements: columns, next: ["size", "name", "status"] },
|
{ afterLeft: 150, beforeLeft: 0, expectedDelta: -150, next: ["size", "name", "status"] },
|
||||||
{
|
{ afterLeft: 0, beforeLeft: 150, expectedDelta: 150, next: ["name", "size", "status"] }
|
||||||
draggedId: "name",
|
])("commits the target grid before animating from a $expectedDelta px inverse offset", ({ afterLeft, beforeLeft, expectedDelta, next }) => {
|
||||||
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 }) => {
|
|
||||||
const commitDownloadColumnDrag = (columnDrag as unknown as {
|
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;
|
}).commitDownloadColumnDrag;
|
||||||
expect(commitDownloadColumnDrag).toBeTypeOf("function");
|
expect(commitDownloadColumnDrag).toBeTypeOf("function");
|
||||||
if (!commitDownloadColumnDrag) return;
|
if (!commitDownloadColumnDrag) return;
|
||||||
|
|
||||||
const events: string[] = [];
|
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 = {
|
const root = {
|
||||||
classList: { remove: () => events.push("clear-classes") },
|
classList: { add: () => events.push("add-class"), remove: () => events.push("clear-classes") },
|
||||||
dataset: { columnDragging: draggedId },
|
dataset: { columnDragging: "name" },
|
||||||
querySelectorAll: () => [],
|
querySelectorAll: (selector: string) => selector === "[data-download-column]" ? [element] : [],
|
||||||
style: {
|
style: {
|
||||||
removeProperty: (property: string) => events.push(`clear:${property}`)
|
removeProperty: (property: string) => events.push(`clear:${property}`)
|
||||||
}
|
}
|
||||||
} as unknown as HTMLElement;
|
} as unknown as HTMLElement;
|
||||||
const session = {
|
const session = {
|
||||||
active: true,
|
active: true,
|
||||||
draggedId,
|
draggedId: "name",
|
||||||
measurements,
|
measurements: columns,
|
||||||
pointerId: -1,
|
pointerId: -1,
|
||||||
preview: calculateColumnDragPreview(measurements, draggedId, next[0] === draggedId ? -160 : 160),
|
preview: calculateColumnDragPreview(columns, "name", next[0] === "name" ? -160 : 160),
|
||||||
root,
|
root,
|
||||||
startX: 0
|
startX: 0
|
||||||
} as DownloadColumnDragSession;
|
} as DownloadColumnDragSession;
|
||||||
|
|
||||||
commitDownloadColumnDrag(session, next, (order) => {
|
const animations = commitDownloadColumnDrag(session, next, (order) => {
|
||||||
|
committed = true;
|
||||||
events.push(`commit:${order.join("|")}`);
|
events.push(`commit:${order.join("|")}`);
|
||||||
});
|
}, () => events.push("prepare-grid"));
|
||||||
|
|
||||||
expect(events.at(-1)).toBe(`commit:${next.join("|")}`);
|
expect(animations).toHaveLength(1);
|
||||||
expect(events.slice(0, -1)).toContain("clear:--downloads-column-drag-name");
|
expect(events).toContain("prepare-grid");
|
||||||
expect(events.slice(0, -1)).toContain("clear:--downloads-column-drag-size");
|
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 () => {
|
it("serializes and coalesces rapid persistence requests", async () => {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
getPackageProgress,
|
getPackageProgress,
|
||||||
getPackageSizeProgress
|
getPackageSizeProgress
|
||||||
} from "../src/renderer/views/downloads/DownloadsTable";
|
} from "../src/renderer/views/downloads/DownloadsTable";
|
||||||
|
import * as downloadsTableModule from "../src/renderer/views/downloads/DownloadsTable";
|
||||||
import {
|
import {
|
||||||
DOWNLOAD_FILE_ROW_HEIGHT,
|
DOWNLOAD_FILE_ROW_HEIGHT,
|
||||||
DOWNLOAD_PACKAGE_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();
|
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
||||||
|
|
||||||
describe("Downloadtabellen-Spalten", () => {
|
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", () => {
|
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.name.width).toBe("minmax(var(--downloads-name-min, 290px), 2.3fr)");
|
||||||
expect(downloadColumnDefinitions.progress.width).toBe("minmax(var(--downloads-progress-min, 105px), 0.85fr)");
|
expect(downloadColumnDefinitions.progress.width).toBe("minmax(var(--downloads-progress-min, 105px), 0.85fr)");
|
||||||
|
|||||||
Reference in New Issue
Block a user