import { memo, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement } from "react"; import type { DownloadItem } from "../../../shared/types"; import { compactProviderLabels, compactDownloadServiceLabel, extractHoster, formatAudioStripSummary, formatDateTime, formatHosterLabel, formatSpeedMbps, humanSize, normalizeDownloadServiceLabel, providerLabels } from "../../download-format"; import type { DownloadPackageRow } from "./downloads-model"; export type DownloadSortColumn = "name" | "size" | "hoster" | "progress"; const DOWNLOAD_SELECTION_COLUMN_WIDTH = "36px"; const DOWNLOAD_ACTION_COLUMN_WIDTH = "60px"; const PACKAGE_ROW_DISCLOSURE_EXCLUSION_SELECTOR = "button, input, select, textarea, a, [contenteditable='true'], .downloads-copyable, .downloads-meter"; const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; type HosterLabel = ReturnType; function HosterLabelContent({ label }: { label: HosterLabel }): ReactElement { const [iconFailed, setIconFailed] = useState(false); if (label.iconSrc && !iconFailed) return setIconFailed(true)} src={label.iconSrc} />; return {label.compact}; } function HosterLabels({ labels }: { labels: HosterLabel[] }): ReactElement { return ( label.title).join(", ")}> {labels.map((label) => )} ); } function downloadGridTemplate(gridTemplate: string): string { return `${DOWNLOAD_SELECTION_COLUMN_WIDTH} ${gridTemplate} ${DOWNLOAD_ACTION_COLUMN_WIDTH}`; } 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; } export const downloadColumnDefinitions: Record = { name: { label: "Name", width: "minmax(var(--downloads-name-min, 290px), 2.3fr)", sortable: "name" }, size: { label: "Geladen / Größe", width: "minmax(var(--downloads-size-min, 140px), 1.1fr)", sortable: "size" }, progress: { label: "Fortschritt", width: "minmax(var(--downloads-progress-min, 105px), 0.85fr)", sortable: "progress" }, hoster: { label: "Hoster", width: "minmax(var(--downloads-hoster-min, 90px), 0.85fr)", sortable: "hoster" }, account: { label: "Service", width: "minmax(var(--downloads-service-min, 90px), 0.85fr)" }, prio: { label: "Priorität", width: "minmax(var(--downloads-priority-min, 85px), 0.8fr)" }, status: { label: "Status", width: "minmax(var(--downloads-status-min, 210px), 1.2fr)" }, speed: { label: "Geschwindigkeit", width: "minmax(var(--downloads-speed-min, 120px), 1fr)" }, availability: { label: "Verfügbarkeit", width: "minmax(var(--downloads-availability-min, 110px), 1fr)" }, added: { label: "Hinzugefügt am", width: "minmax(var(--downloads-added-min, 135px), 1fr)" } }; export type AvailabilityState = "online" | "partial" | "offline" | "checking"; export function getAvailabilitySummary(items: DownloadItem[]): { online: number; total: number; state: AvailabilityState } { const total = items.length; const online = items.filter((item) => item.onlineStatus === "online").length; const offline = items.filter((item) => item.onlineStatus === "offline").length; if (total > 0 && online === total) return { online, total, state: "online" }; if (total > 0 && offline === total) return { online, total, state: "offline" }; if (total > 0 && online + offline === total) return { online, total, state: "partial" }; return { online, total, state: "checking" }; } function Availability({ online, total, state, text }: { online: number; total: number; state: AvailabilityState; text?: string }): ReactElement { const symbol = state === "online" ? "✓" : state === "offline" ? "×" : "●"; const label = text ?? `${online}/${total} online`; if (text) { return {text}; } return ( {online} {total} online ); } export interface DownloadsTableActions { onSetVisibleSelection: (ids: string[], selected: boolean) => void; onToggleSelection: (id: string, ctrlKey: boolean, shiftKey: boolean) => void; onSelectionMouseDown: (id: string, event: ReactMouseEvent) => void; onSelectionMouseEnter: (id: string) => void; onTogglePackageCollapse: (packageId: string) => void; onStartPackageRename: (packageId: string, packageName: string) => void; onPackageRenameChange: (name: string) => void; onCommitPackageRename: (packageId: string, value: string) => void; onCancelPackageRename: (packageId: string) => void; onCancelPackage: (packageId: string) => void; onMovePackageUp: (packageId: string) => void; onMovePackageDown: (packageId: string) => void; onRemoveItem: (itemId: string) => void; onOpenContextMenu: (id: string, x: number, y: number, packageId?: string) => void; onColumnPointerDown: (column: string, event: DownloadColumnPointerInput) => void; onColumnPointerMove: (column: string, event: DownloadColumnPointerInput) => void; onColumnPointerUp: (column: string, event: DownloadColumnPointerInput) => void; onColumnPointerCancel: (column: string, event: DownloadColumnPointerInput) => void; onColumnContextMenu: (column: string, x: number, y: number) => void; onSortColumn: (column: DownloadSortColumn) => void; } export interface DownloadColumnPointerInput { clientX: number; currentTarget: HTMLDivElement; pointerId: number; preventDefault: () => void; } function displayedStatus(item: DownloadItem, sessionRunning: boolean): string { const value = item.fullStatus.trim(); if (value === "Wartet") return ""; if (sessionRunning) return value; if (item.status !== "queued" && item.status !== "reconnect_wait") return value; if (value === "Paket gestoppt") return value; if (/^Entpacken\b/i.test(value) || /^Entpackt\b/i.test(value) || /^Entpack-Fehler\b/i.test(value) || /^Fertig\b/i.test(value)) return value; return ""; } function progress(value: number): number { return Math.max(0, Math.min(100, Math.round(value || 0))); } export function compactDownloadStatus(value: string): string { const status = value.trim(); const runtimeWait = status.match(/^(Starte\.\.\.|Starting\.\.\.|Warte auf Daten|Waiting for data|Warte auf Festplatte|Waiting for disk)(?:\s+\([^)]*\))?$/i); if (runtimeWait) return runtimeWait[1]; if (/Link wird umgewandelt/i.test(status)) return "Umwandeln"; if (/Download läuft\b/i.test(status)) return "Download läuft"; if (/Download running\b/i.test(status)) return "Download running"; if (/^Passwort gefunden\b/i.test(status)) return "Passwort gefunden"; if (/^Password found\b/i.test(status)) return "Password found"; const passwordCracking = status.match(/^(Passwort knacken|Cracking password):?\s*(\d+)%\s*(?:\((\d+\/\d+)\))?/i); if (passwordCracking) { return `${passwordCracking[1]}: ${passwordCracking[2]}%${passwordCracking[3] ? ` (${passwordCracking[3]})` : ""}`; } if (/^Entpack-Fehler\b/i.test(status)) return "Entpack-Fehler"; if (/^Extraction error\b/i.test(status)) return "Extraction error"; if (/^Entpacken\s*-\s*(?:Error|Fehler)\b/i.test(status)) return "Entpack-Fehler"; if (/^Extracting\s*-\s*(?:Error|Fehler)\b/i.test(status)) return "Extraction error"; const extractionPending = status.match(/^(Entpacken|Extracting)\s*-\s*(Ausstehend|Pending|Warten auf Parts|Waiting for parts)/i); if (extractionPending) return `${extractionPending[1]} - ${extractionPending[2]}`; const extracting = status.match(/Entpacken\s+(\d+)%/i); if (extracting) return `Entpacken - ${extracting[1]}%`; const extractingEnglish = status.match(/Extracting\s+(\d+)%/i); if (extractingEnglish) return `Extracting - ${extractingEnglish[1]}%`; const finalizing = status.match(/^(Finalisieren|Finalizing)\b/i); if (finalizing) { const fraction = status.match(/\(([^)]*)\)/); if (fraction) { const values = fraction[1].split("/"); if (values.length !== 2 || values.some((value) => !value.trim())) return finalizing[1]; const current = Number(values[0]); const total = Number(values[1]); if (Number.isFinite(current) && Number.isFinite(total) && total > 0) return `${finalizing[1]} - ${progress((current / total) * 100)}%`; return finalizing[1]; } const percentage = status.match(/-\s*(-?\d+(?:\.\d+)?)%/); if (percentage) return `${finalizing[1]} - ${progress(Number(percentage[1]))}%`; return finalizing[1]; } if (/^Fehler(?:\s*:|$)/i.test(status)) return "Fehler"; if (/^Error(?:\s*:|$)/i.test(status)) return "Error"; return status; } function DownloadMeter({ value, text }: { value: number; text: string }): ReactElement { const normalized = progress(value); const style = { "--downloads-progress": `${normalized}%` } as CSSProperties; return ( ); } function DownloadStatusCell({ status, title }: { status: string; title?: string }): ReactElement { const visibleStatus = compactDownloadStatus(status); const statusTitle = /^(Finalisieren|Finalizing)\b/i.test(status) ? visibleStatus : title || status; return ( ); } function DownloadServiceCell({ label }: { label: string }): ReactElement { const full = normalizeDownloadServiceLabel(label); const compact = compactDownloadServiceLabel(label); return ( ); } function itemCell(item: DownloadItem, column: string, sessionRunning: boolean): ReactElement | null { const displayStatus = displayedStatus(item, sessionRunning); const retrySuffix = item.retries > 0 ? ` (R${item.retries})` : ""; const error = item.lastError.trim(); const statusTitle = displayStatus ? error && error !== displayStatus && !displayStatus.includes(error) ? `${displayStatus}${retrySuffix}\n${error}` : `${displayStatus}${retrySuffix}` : error; if (column === "name") { return {item.fileName}; } if (column === "size") { const total = item.totalBytes || item.downloadedBytes || 0; const value = total > 0 ? progress((item.downloadedBytes / total) * 100) : 0; const text = `${humanSize(item.downloadedBytes)} / ${humanSize(total)}`; return {total > 0 ? : null}; } if (column === "progress") { const value = progress(item.progressPercent); return ; } if (column === "hoster") { const hoster = extractHoster(item.url); const label = formatHosterLabel(hoster); return ; } if (column === "account") { const full = item.providerLabel || (item.provider ? providerLabels[item.provider] : ""); return ; } if (column === "prio") return ; if (column === "status") return ; if (column === "speed") return {item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}; if (column === "availability") { const state = item.onlineStatus === "online" ? "online" : item.onlineStatus === "offline" ? "offline" : "checking"; const text = state === "online" ? "Online" : state === "offline" ? "Offline" : item.onlineStatus === "checking" ? "Prüfung" : "Ungeprüft"; return ; } if (column === "added") return {formatDateTime(item.createdAt)}; return null; } export interface ItemRowProps { item: DownloadItem; selected: boolean; sessionRunning?: boolean; columnOrder: readonly string[]; gridTemplate: string; actions: DownloadsTableActions; } export function ItemRowContent({ item, selected, sessionRunning = true, columnOrder, gridTemplate, actions }: ItemRowProps): ReactElement { return (
{ event.stopPropagation(); actions.onToggleSelection(item.id, event.ctrlKey || event.metaKey, event.shiftKey); }} onMouseDown={(event) => { event.stopPropagation(); actions.onSelectionMouseDown(item.id, event); }} onMouseEnter={() => actions.onSelectionMouseEnter(item.id)} onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); actions.onOpenContextMenu(item.id, event.clientX, event.clientY, item.packageId); }} > {}} onClick={(event) => { event.stopPropagation(); actions.onToggleSelection(item.id, true, event.shiftKey); }} type="checkbox" /> {columnOrder.map((column) => {itemCell(item, column, sessionRunning)})}
); } export function areItemRowPropsEqual(previous: ItemRowProps, next: ItemRowProps): boolean { const a = previous.item; const b = next.item; return a.id === b.id && a.updatedAt === b.updatedAt && a.status === b.status && a.fileName === b.fileName && a.url === b.url && a.provider === b.provider && a.providerLabel === b.providerLabel && a.providerAccountId === b.providerAccountId && a.providerAccountLabel === b.providerAccountLabel && a.fullStatus === b.fullStatus && a.lastError === b.lastError && a.onlineStatus === b.onlineStatus && a.progressPercent === b.progressPercent && a.speedBps === b.speedBps && a.downloadedBytes === b.downloadedBytes && a.totalBytes === b.totalBytes && a.retries === b.retries && a.createdAt === b.createdAt && previous.selected === next.selected && previous.sessionRunning === next.sessionRunning && previous.columnOrder === next.columnOrder && previous.gridTemplate === next.gridTemplate && previous.actions === next.actions; } export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual); interface PackageItemsTransitionProps { actions: DownloadsTableActions; collapsed: boolean; columnOrder: readonly string[]; gridTemplate: string; id: string; items: DownloadItem[]; selectedIds: ReadonlySet; sessionRunning: boolean; } function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate, id, items, selectedIds, sessionRunning }: PackageItemsTransitionProps): ReactElement | null { const [renderItems, setRenderItems] = useState(!collapsed); const animationRef = useRef(null); const containerRef = useRef(null); const innerRef = useRef(null); const initialRenderRef = useRef(true); useRendererLayoutEffect(() => { if (initialRenderRef.current) { initialRenderRef.current = false; return; } if (!renderItems) { if (!collapsed) setRenderItems(true); return; } const container = containerRef.current; const inner = innerRef.current; if (!container || !inner) return; animationRef.current?.cancel(); const targetHeight = inner.scrollHeight; const animation = collapsed ? container.animate([{ height: `${targetHeight}px`, opacity: 1 }, { height: "0px", opacity: 0 }], { duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" }) : container.animate([{ height: "0px", opacity: 0 }, { height: `${targetHeight}px`, opacity: 1 }], { duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" }); animationRef.current = animation; animation.onfinish = () => { if (animationRef.current !== animation) return; animationRef.current = null; if (collapsed) { setRenderItems(false); } else { animation.cancel(); } }; return () => { animation.onfinish = null; animation.cancel(); if (animationRef.current === animation) animationRef.current = null; }; }, [collapsed, renderItems]); if (!renderItems) return null; return (
{items.map((item) => )}
); } export function getPackageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } { let done = Math.max(0, Number(row.package.cleanedCompletedItemCount || 0)); let failed = 0; let cancelled = 0; let extracted = Math.max(0, Number(row.package.cleanedExtractedItemCount || 0)); let extracting = false; let activeProgress = 0; let extractingProgress = 0; for (const item of row.allItems) { if (item.status === "completed") done += 1; else if (item.status === "failed") failed += 1; else if (item.status === "cancelled") cancelled += 1; const fullStatus = item.fullStatus || ""; if (fullStatus.startsWith("Entpackt")) { extracted += 1; } else if (fullStatus.startsWith("Entpacken")) { extracting = true; const match = fullStatus.match(/^Entpacken\s+(\d+)%/); if (match) extractingProgress += Number(match[1]) / 100; } if (item.status === "downloading" || (item.status === "queued" && (item.progressPercent || 0) > 0)) { activeProgress += (item.progressPercent || 0) / 100; } } const total = Math.max(1, Math.max(0, Number(row.package.cleanedCompletedItemCount || 0)) + row.allItems.length); const allDownloaded = done + failed + cancelled >= total; const allExtracted = extracted >= total; const useExtractSplit = extracting || row.package.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0); const downloadProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(((done + activeProgress) / total) * (useExtractSplit ? 50 : 100))); const extractionProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50)); const value = Math.min(100, useExtractSplit ? downloadProgress + extractionProgress : downloadProgress); return { done, failed, cancelled, total, value }; } export function getPackageSizeProgress(row: DownloadPackageRow): { downloaded: number; total: number; value: number } { const downloaded = Math.max(0, Number(row.package.cleanedDownloadedBytes || 0)) + row.allItems.reduce((sum, item) => sum + item.downloadedBytes, 0); const total = Math.max(0, Number(row.package.cleanedTotalBytes || 0)) + row.allItems.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0); return { downloaded, total, value: total > 0 ? progress((downloaded / total) * 100) : 0 }; } function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: number, editing: boolean, editingName: string, actions: DownloadsTableActions, finishRename: (value: string) => void): ReactElement | null { const entry = row.package; const stats = getPackageProgress(row); if (column === "name") { return ( {editing ? finishRename(editingName)} onChange={(event) => actions.onPackageRenameChange(event.target.value)} onKeyDown={(event: ReactKeyboardEvent) => { if (event.key === "Enter") { event.preventDefault(); finishRename(editingName); event.currentTarget.blur(); } else if (event.key === "Escape") { event.preventDefault(); actions.onCancelPackageRename(entry.id); } }} /> : { event.stopPropagation(); actions.onStartPackageRename(entry.id, entry.name); }} title={entry.name}>{entry.name}} ); } if (column === "size") { const { downloaded, total, value } = getPackageSizeProgress(row); const text = `${humanSize(downloaded)} / ${humanSize(total)}`; return {total > 0 ? : null}; } if (column === "progress") return ; if (column === "hoster") { const labels = [...new Set(row.items.map((item) => extractHoster(item.url)).filter(Boolean))].map(formatHosterLabel); return ; } if (column === "account") { const full = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean)); return ; } if (column === "prio") return {entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}; if (column === "status") { const audio = entry.audioStripSummary ? formatAudioStripSummary(entry.audioStripSummary) : null; const rawPostProcessLabel = entry.postProcessLabel?.trim() || ""; const compactPostProcessLabel = compactDownloadStatus(rawPostProcessLabel); const postProcessLabel = entry.status === "extracting" && compactPostProcessLabel === rawPostProcessLabel && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel) ? "Entpacken - Ausstehend" : compactPostProcessLabel; const extractFailure = row.allItems.find((item) => /^Entpack-Fehler\b/i.test(item.fullStatus || "")); const waitsForDisk = row.allItems.some((item) => compactDownloadStatus(item.fullStatus || "") === "Warte auf Festplatte"); const details = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${extractFailure ? " · Entpack-Fehler" : ""}${audio ? ` · ${audio.text}` : ""}`; const downloading = entry.status === "downloading" || entry.status === "validating" || row.items.some((item) => item.status === "downloading" || item.status === "validating"); const status = postProcessLabel && (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting") ? postProcessLabel : extractFailure ? "Entpack-Fehler" : waitsForDisk ? "Warte auf Festplatte" : downloading ? "Download läuft" : details; const statusDetails = extractFailure ? `${details}\n${extractFailure.fullStatus}` : details; const title = audio?.tooltip ? `${statusDetails}\n${audio.tooltip}` : statusDetails; return ; } if (column === "speed") return {packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}; if (column === "availability") { const availability = getAvailabilitySummary(row.allItems); const text = availability.state === "checking" ? row.allItems.some((item) => item.onlineStatus === "checking") ? "Prüfung" : "Ungeprüft" : undefined; return ; } if (column === "added") return {formatDateTime(entry.createdAt)}; return null; } export interface PackageCardProps { row: DownloadPackageRow; selectedIds: Set; selectedVersion: number; editing: boolean; editingName: string; packageSpeedBps: number; sessionRunning?: boolean; columnOrder: readonly string[]; gridTemplate: string; renderItems?: boolean; actions: DownloadsTableActions; } export function PackageCardContent({ row, selectedIds, editing, editingName, packageSpeedBps, sessionRunning = true, columnOrder, gridTemplate, renderItems = true, actions }: PackageCardProps): ReactElement { const entry = row.package; let renameFinished = false; const finishRename = (value: string): void => { if (renameFinished) return; renameFinished = true; actions.onCommitPackageRename(entry.id, value); }; return (
{ event.preventDefault(); event.stopPropagation(); actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id); }} onDragStart={(event) => event.preventDefault()} >
{ if (event.detail > 1) return; if (!event.ctrlKey && !event.metaKey && !event.shiftKey && selectedIds.size === 1 && selectedIds.has(entry.id)) return; actions.onToggleSelection(entry.id, event.ctrlKey || event.metaKey, event.shiftKey); }} onDoubleClick={(event) => { if (event.ctrlKey || event.metaKey || event.shiftKey || isPackageRowDisclosureExcluded(event.target)) return; event.preventDefault(); actions.onTogglePackageCollapse(entry.id); }} onMouseDown={(event) => actions.onSelectionMouseDown(entry.id, event)} onMouseEnter={() => actions.onSelectionMouseEnter(entry.id)} > {}} onClick={(event) => { event.stopPropagation(); actions.onToggleSelection(entry.id, true, event.shiftKey); }} type="checkbox" /> {columnOrder.map((column) => {packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)})}
{renderItems ? : null}
); } export function arePackageCardPropsEqual(previous: PackageCardProps, next: PackageCardProps): boolean { const a = previous.row.package; const b = next.row.package; if (a.id !== b.id || a.updatedAt !== b.updatedAt || a.status !== b.status || a.enabled !== b.enabled || a.name !== b.name || a.priority !== b.priority || a.createdAt !== b.createdAt) return false; if (previous.packageSpeedBps !== next.packageSpeedBps || previous.editing !== next.editing || previous.editingName !== next.editingName || previous.row.collapsed !== next.row.collapsed || previous.sessionRunning !== next.sessionRunning || previous.columnOrder !== next.columnOrder || previous.gridTemplate !== next.gridTemplate || previous.renderItems !== next.renderItems || previous.actions !== next.actions) return false; if (previous.selectedVersion !== next.selectedVersion || previous.selectedIds !== next.selectedIds) { if (previous.selectedIds.has(a.id) !== next.selectedIds.has(a.id)) return false; for (const itemId of b.itemIds) { if (previous.selectedIds.has(itemId) !== next.selectedIds.has(itemId)) return false; } } if (previous.row.items.length !== next.row.items.length) return false; for (let index = 0; index < previous.row.items.length; index += 1) { const oldItem = previous.row.items[index]; const newItem = next.row.items[index]; if (!oldItem || !newItem || !areItemRowPropsEqual({ actions: previous.actions, columnOrder: previous.columnOrder, gridTemplate: previous.gridTemplate, item: oldItem, selected: previous.selectedIds.has(oldItem.id), sessionRunning: previous.sessionRunning }, { actions: next.actions, columnOrder: next.columnOrder, gridTemplate: next.gridTemplate, item: newItem, selected: next.selectedIds.has(newItem.id), sessionRunning: next.sessionRunning })) return false; } return true; } export const PackageCard = memo(PackageCardContent, arePackageCardPropsEqual); export interface DownloadsTableHeaderProps { actions: DownloadsTableActions; columnOrder: readonly string[]; gridTemplate: string; sortColumn: DownloadSortColumn; sortDirection: "asc" | "desc"; selectedCount: number; visibleIds: string[]; } function moveColumnWithPointerActions(column: string, direction: -1 | 1, element: HTMLDivElement, actions: DownloadsTableActions): void { const sibling = direction < 0 ? element.previousElementSibling : element.nextElementSibling; if (!sibling?.matches(".downloads-column-header")) return; const currentRect = element.getBoundingClientRect(); const siblingRect = sibling.getBoundingClientRect(); const startX = currentRect.left + currentRect.width / 2; const clientX = siblingRect.left + siblingRect.width / 2 + direction; const pointerId = -1; const pointerEvent = (x: number): DownloadColumnPointerInput => ({ clientX: x, currentTarget: element, pointerId, preventDefault: () => {} }); actions.onColumnPointerDown(column, pointerEvent(startX)); actions.onColumnPointerMove(column, pointerEvent(clientX)); actions.onColumnPointerUp(column, pointerEvent(clientX)); } export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortColumn, sortDirection, selectedCount, visibleIds }: DownloadsTableHeaderProps): ReactElement { 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]; if (!definition) return null; const ariaSort = definition.sortable ? sortColumn === definition.sortable ? sortDirection === "asc" ? "ascending" : "descending" : "none" : undefined; return (
{ event.preventDefault(); actions.onColumnContextMenu(column, event.clientX, event.clientY); }} onPointerCancel={(event) => actions.onColumnPointerCancel(column, event)} onPointerDown={(event) => { if (event.button !== 0 || !event.isPrimary) return; event.currentTarget.setPointerCapture(event.pointerId); actions.onColumnPointerDown(column, event); }} onPointerMove={(event) => actions.onColumnPointerMove(column, event)} onPointerUp={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); actions.onColumnPointerUp(column, event); }} role="columnheader" > {definition.sortable ? : {definition.label}} event.stopPropagation()} role="group"> {index > 0 ? : null} {index < columnOrder.length - 1 ? : null}
); })} Aktion
); }