From 16377aa0d6c771afceadc4420d3b9f19fa6af554 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 22 Jul 2026 10:33:41 +0330 Subject: [PATCH] fix(ui): stabilize download table column interactions --- src/components/DownloadItem.tsx | 21 ++-- src/components/DownloadTable.tsx | 165 +++++++++++++++++++------ src/index.css | 43 +++---- src/utils/downloadTableColumns.test.ts | 14 ++- src/utils/downloadTableColumns.ts | 22 +++- 5 files changed, 197 insertions(+), 68 deletions(-) diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 9e385ca..2d466de 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -11,6 +11,7 @@ import { } from '../utils/downloadProgress'; import { COLUMN_ALIGNMENT_JUSTIFY, + getColumnGridColumn, type DownloadColumnAlignment, type DownloadTableColumnKey } from '../utils/downloadTableColumns'; @@ -23,7 +24,7 @@ interface DownloadItemProps { columnOrder: DownloadTableColumnKey[]; columnAlignments: Record; tableGridTemplate: string; - tableMinWidth: number; + tableMinWidth: number | string; setContextMenu: (menu: { x: number; y: number; id: string }) => void; handlePause: (id: string, skipConfirm?: boolean) => void; handleResume: (item: DownloadItemType) => void; @@ -94,12 +95,16 @@ export const DownloadItem = React.memo(({ const columnStyle = (key: DownloadTableColumnKey): React.CSSProperties => ({ '--column-justify': COLUMN_ALIGNMENT_JUSTIFY[columnAlignments[key]], + gridColumn: getColumnGridColumn(key, columnOrder), } as React.CSSProperties); + const trailingColumnClass = (key: DownloadTableColumnKey) => + key === columnOrder[columnOrder.length - 1] ? 'download-column-cell--trailing' : ''; + const cells: Record = { 'File Name': (
@@ -114,7 +119,7 @@ export const DownloadItem = React.memo(({ ), Size: (
(({ ), Status: (
{download.status === 'completed' ? ( @@ -197,23 +202,23 @@ export const DownloadItem = React.memo(({
), Speed: ( -
+
{displaySpeed}
), ETA: ( -
+
{displayEta}
), 'Date Added': ( -
+
{download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'} diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index f7dbeb7..5448ba0 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -37,7 +37,9 @@ import { DEFAULT_COLUMN_ALIGNMENTS, DEFAULT_COLUMN_ORDER, DEFAULT_COLUMN_WIDTHS, + buildColumnGridTemplate, columnIndex, + getColumnGridColumn, normalizeColumnAlignments, normalizeColumnOrder, normalizeColumnWidths, @@ -108,6 +110,10 @@ export const DownloadTable: React.FC = ({ filter }) => { const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null); const [animationParent] = useAutoAnimate(); + const [headerAnimationParent] = useAutoAnimate({ + duration: 140, + easing: 'ease-out', + }); const [selectedIds, setSelectedIds] = useState>(new Set()); const [lastSelectedId, setLastSelectedId] = useState(null); const [sortConfig, setSortConfig] = useState({ column: 'Date Added', direction: 'desc' }); @@ -121,7 +127,9 @@ export const DownloadTable: React.FC = ({ filter }) => { active: boolean; } | null>(null); const headerElementsRef = useRef(new Map()); + const headerContainerRef = useRef(null); const columnDragStateRef = useRef(null); + const columnDragOrderRef = useRef(null); const columnDropFlashTimerRef = useRef(null); const suppressHeaderClickRef = useRef(false); const selectedIdsRef = useRef(selectedIds); @@ -152,45 +160,77 @@ export const DownloadTable: React.FC = ({ filter }) => { } }); const [columnDragState, setColumnDragState] = useState(null); + const [columnDragOrder, setColumnDragOrder] = useState(null); const [columnMenu, setColumnMenu] = useState(null); const [columnDropFlashKey, setColumnDropFlashKey] = useState(null); + const [, setMenuViewportVersion] = useState(0); const columnWidthsRef = useRef(columnWidths); + const columnOrderRef = useRef(columnOrder); columnWidthsRef.current = columnWidths; + columnOrderRef.current = columnOrder; + columnDragOrderRef.current = columnDragOrder; const normalizedColumnWidths = columnWidths.map((width, index) => Math.max(COLUMN_MINIMUMS[index], width) ); - const orderedColumns = useMemo(() => columnOrder, [columnOrder]); - const orderedColumnWidths = orderedColumns.map(key => normalizedColumnWidths[columnIndex(key)]); - const tableGridTemplate = [ - ...orderedColumnWidths.slice(0, -1).map(width => `${width}px`), - 'minmax(0, 1fr)', - `${orderedColumnWidths[orderedColumnWidths.length - 1]}px` - ].join(' '); + const orderedColumns = columnDragOrder ?? columnOrder; + const tableGridTemplate = buildColumnGridTemplate(orderedColumns, normalizedColumnWidths); const tableMinWidth = normalizedColumnWidths.reduce( (total, width) => total + width, 0 ); + const tableMinWidthWithPadding = 'calc(' + tableMinWidth + 'px + var(--download-row-padding-x) + var(--download-row-padding-x))'; + const downloadsViewRef = useRef(null); const updateColumnDragState = (next: ColumnDragState | null) => { columnDragStateRef.current = next; setColumnDragState(next); }; - const getColumnDropPosition = (key: DownloadTableColumnKey, pointerX: number) => { - const remainingColumns = orderedColumns.filter(columnKey => columnKey !== key); + const reorderColumn = ( + order: DownloadTableColumnKey[], + key: DownloadTableColumnKey, + dropIndex: number + ): DownloadTableColumnKey[] => { + const remainingColumns = order.filter(columnKey => columnKey !== key); + remainingColumns.splice(dropIndex, 0, key); + return remainingColumns; + }; + + const getColumnLayoutBounds = (element: HTMLDivElement) => { + // AutoAnimate transforms the visual box; offsetLeft/offsetWidth retain the + // stable grid geometry needed for hit-testing during a live reorder. + const container = headerContainerRef.current; + if (!container) { + const rect = element.getBoundingClientRect(); + return { left: rect.left, right: rect.right }; + } + + const containerRect = container.getBoundingClientRect(); + return { + left: containerRect.left + element.offsetLeft, + right: containerRect.left + element.offsetLeft + element.offsetWidth, + }; + }; + + const getColumnDropPosition = ( + key: DownloadTableColumnKey, + pointerX: number, + order: DownloadTableColumnKey[] = columnDragOrderRef.current ?? columnOrderRef.current + ) => { + const remainingColumns = order.filter(columnKey => columnKey !== key); const targetIndex = remainingColumns.findIndex(columnKey => { const element = headerElementsRef.current.get(columnKey); if (!element) return false; - const rect = element.getBoundingClientRect(); - return pointerX < rect.left + rect.width / 2; + const bounds = getColumnLayoutBounds(element); + return pointerX < bounds.left + (bounds.right - bounds.left) / 2; }); const dropIndex = targetIndex === -1 ? remainingColumns.length : targetIndex; const markerElement = dropIndex < remainingColumns.length ? headerElementsRef.current.get(remainingColumns[dropIndex]) : headerElementsRef.current.get(remainingColumns[remainingColumns.length - 1]); - const markerRect = markerElement?.getBoundingClientRect(); - const markerX = markerRect - ? dropIndex < remainingColumns.length ? markerRect.left : markerRect.right + const markerBounds = markerElement ? getColumnLayoutBounds(markerElement) : null; + const markerX = markerBounds + ? dropIndex < remainingColumns.length ? markerBounds.left : markerBounds.right : pointerX; return { dropIndex, markerX }; }; @@ -219,7 +259,10 @@ export const DownloadTable: React.FC = ({ filter }) => { gesture.active = true; suppressHeaderClickRef.current = true; const rect = event.currentTarget.getBoundingClientRect(); - const { dropIndex, markerX } = getColumnDropPosition(key, event.clientX); + const currentOrder = columnOrderRef.current; + columnDragOrderRef.current = [...currentOrder]; + setColumnDragOrder([...currentOrder]); + const { dropIndex, markerX } = getColumnDropPosition(key, event.clientX, currentOrder); updateColumnDragState({ key, startX: gesture.startX, @@ -233,11 +276,18 @@ export const DownloadTable: React.FC = ({ filter }) => { } else { const current = columnDragStateRef.current; if (!current) return; - const { dropIndex, markerX } = getColumnDropPosition(key, event.clientX); + const currentOrder = columnDragOrderRef.current ?? columnOrderRef.current; + const { dropIndex, markerX } = getColumnDropPosition(key, event.clientX, currentOrder); + const nextOrder = reorderColumn(currentOrder, key, dropIndex); + const orderChanged = nextOrder.some((columnKey, index) => columnKey !== currentOrder[index]); + if (orderChanged) { + columnDragOrderRef.current = nextOrder; + setColumnDragOrder(nextOrder); + } updateColumnDragState({ ...current, pointerX: event.clientX, - dropIndex, + dropIndex: nextOrder.indexOf(key), markerX, }); } @@ -252,6 +302,9 @@ export const DownloadTable: React.FC = ({ filter }) => { const current = columnDragStateRef.current; dragGestureRef.current = null; updateColumnDragState(null); + const nextOrder = columnDragOrderRef.current ?? columnOrderRef.current; + columnDragOrderRef.current = null; + setColumnDragOrder(null); if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } @@ -263,10 +316,7 @@ export const DownloadTable: React.FC = ({ filter }) => { suppressHeaderClickRef.current = false; }, 0); - const remainingColumns = orderedColumns.filter(columnKey => columnKey !== key); - const nextOrder = [...remainingColumns]; - nextOrder.splice(current.dropIndex, 0, key); - if (nextOrder.join('|') === orderedColumns.join('|')) return; + if (nextOrder.join('|') === columnOrderRef.current.join('|')) return; setColumnOrder(nextOrder); persistColumnOrder(nextOrder); @@ -317,16 +367,30 @@ export const DownloadTable: React.FC = ({ filter }) => { document.addEventListener('visibilitychange', handlePointerUp); }; + const clampMenuPosition = useCallback((x: number, y: number, menuWidth: number, menuHeight: number) => { + const workspaceRect = downloadsViewRef.current?.getBoundingClientRect(); + const minX = Math.max(8, (workspaceRect?.left ?? 0) + 8); + const minY = Math.max(8, (workspaceRect?.top ?? 0) + 8); + const maxX = Math.max(minX, Math.min( + window.innerWidth - menuWidth - 8, + (workspaceRect?.right ?? window.innerWidth) - menuWidth - 8 + )); + const maxY = Math.max(minY, Math.min( + window.innerHeight - menuHeight - 8, + (workspaceRect?.bottom ?? window.innerHeight) - menuHeight - 8 + )); + return { + x: Math.min(Math.max(minX, x), maxX), + y: Math.min(Math.max(minY, y), maxY), + }; + }, []); + const openColumnMenu = (key: DownloadTableColumnKey, x: number, y: number) => { - const menuWidth = 188; - const menuHeight = 220; - const maxX = Math.max(8, window.innerWidth - menuWidth - 8); - const maxY = Math.max(8, window.innerHeight - menuHeight - 8); + const position = clampMenuPosition(x, y, 188, 220); setContextMenu(null); setColumnMenu({ key, - x: Math.min(Math.max(8, x), maxX), - y: Math.min(Math.max(8, y), maxY), + ...position, }); }; @@ -386,18 +450,29 @@ export const DownloadTable: React.FC = ({ filter }) => { if (!dragGestureRef.current) return; dragGestureRef.current = null; columnDragStateRef.current = null; + columnDragOrderRef.current = null; setColumnDragState(null); + setColumnDragOrder(null); suppressHeaderClickRef.current = false; }; window.addEventListener('blur', cancelColumnDrag); + window.addEventListener('resize', cancelColumnDrag); document.addEventListener('visibilitychange', cancelColumnDrag); return () => { window.removeEventListener('blur', cancelColumnDrag); + window.removeEventListener('resize', cancelColumnDrag); document.removeEventListener('visibilitychange', cancelColumnDrag); }; }, []); + useEffect(() => { + if (!columnMenu && !contextMenu) return; + const updateMenuViewport = () => setMenuViewportVersion(version => version + 1); + window.addEventListener('resize', updateMenuViewport); + return () => window.removeEventListener('resize', updateMenuViewport); + }, [columnMenu, contextMenu]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (document.querySelector('.app-modal-backdrop') || document.querySelector('.app-modal')) return; @@ -610,8 +685,11 @@ export const DownloadTable: React.FC = ({ filter }) => { setLastSelectedId(menu.id); } setColumnMenu(null); - setContextMenu(menu); - }, []); + setContextMenu({ + ...menu, + ...clampMenuPosition(menu.x, menu.y, 200, 300), + }); + }, [clampMenuPosition]); const handleMoveInQueue = useCallback((id: string, direction: 'up' | 'down') => { const ids = selectedIdsRef.current.has(id) @@ -772,9 +850,15 @@ export const DownloadTable: React.FC = ({ filter }) => { const columnAlignmentStyle = (key: DownloadTableColumnKey): React.CSSProperties => ({ '--column-justify': COLUMN_ALIGNMENT_JUSTIFY[columnAlignments[key]], } as React.CSSProperties); + const columnMenuPosition = columnMenu + ? clampMenuPosition(columnMenu.x, columnMenu.y, 188, 220) + : null; + const contextMenuPosition = contextMenu + ? clampMenuPosition(contextMenu.x, contextMenu.y, 200, 300) + : null; return ( -
+
= ({ filter }) => {
{ + headerContainerRef.current = element; + headerAnimationParent(element); + }} className={`download-table-header ${columnDragState ? 'is-column-dragging' : ''}`} - style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }} + style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidthWithPadding }} > {orderedColumns.map(key => { const label = columnLabels.get(key) ?? key; @@ -860,7 +948,10 @@ export const DownloadTable: React.FC = ({ filter }) => { ? activeSort.direction === 'asc' ? 'ascending' : 'descending' : 'none'} className={`download-column-header group ${isDragging ? 'is-dragging' : ''} ${isDropFlashing ? 'is-drop-flashing' : ''}`} - style={columnAlignmentStyle(key)} + style={{ + ...columnAlignmentStyle(key), + gridColumn: getColumnGridColumn(key, orderedColumns), + }} onContextMenu={event => { event.preventDefault(); event.stopPropagation(); @@ -942,7 +1033,7 @@ export const DownloadTable: React.FC = ({ filter }) => { )} -
+
{sortedDownloads.length === 0 ? (
@@ -984,7 +1075,7 @@ export const DownloadTable: React.FC = ({ filter }) => { columnOrder={orderedColumns} columnAlignments={columnAlignments} tableGridTemplate={tableGridTemplate} - tableMinWidth={tableMinWidth} + tableMinWidth={tableMinWidthWithPadding} setContextMenu={handleContextMenu} handlePause={handlePause} handleResume={handleResume} @@ -1006,7 +1097,7 @@ export const DownloadTable: React.FC = ({ filter }) => {
event.stopPropagation()} >
@@ -1050,8 +1141,8 @@ export const DownloadTable: React.FC = ({ filter }) => { role="menu" className="app-modal fixed z-50 min-w-[180px] overflow-visible py-1.5 text-[12px] font-medium text-text-primary" style={{ - top: Math.min(Math.max(8, contextMenu.y), Math.max(8, window.innerHeight - 300)), - left: Math.min(Math.max(8, contextMenu.x), Math.max(8, window.innerWidth - 200)) + top: contextMenuPosition?.y, + left: contextMenuPosition?.x, }} onClick={(e) => e.stopPropagation()} > diff --git a/src/index.css b/src/index.css index f2c86c6..bc17603 100644 --- a/src/index.css +++ b/src/index.css @@ -1887,6 +1887,7 @@ html[data-list-density="relaxed"] { .download-table-header { flex-shrink: 0; + position: relative; height: var(--download-header-height); display: grid; align-items: center; @@ -1926,7 +1927,6 @@ html[data-list-density="relaxed"] { min-width: 0; overflow: hidden; padding-inline: var(--download-column-padding-x); - padding-inline-end: calc(var(--download-column-padding-x) + 30px); cursor: grab; transition: background-color 120ms ease, opacity 120ms ease, box-shadow 120ms ease; } @@ -1936,7 +1936,8 @@ html[data-list-density="relaxed"] { } .download-column-header.is-dragging { - opacity: 0.22; + opacity: 0; + pointer-events: none; } .download-column-header.is-drop-flashing { @@ -1975,7 +1976,8 @@ html[data-list-density="relaxed"] { .download-column-options { position: absolute; - inset-inline-end: 8px; + right: 8px; + left: auto; top: 50%; z-index: 4; display: inline-flex; @@ -2008,15 +2010,6 @@ html[data-list-density="relaxed"] { padding-inline-start: 0; } -.download-column-header:last-child { - padding-inline-end: 30px; -} - -.download-column-header:last-child, -.download-row > div:last-child { - grid-column: 7; -} - .download-column-header[data-column-key="File Name"] { direction: ltr; text-align: left; @@ -2025,7 +2018,8 @@ html[data-list-density="relaxed"] { .column-resize-handle { position: absolute; top: 0; - inset-inline-end: -4px; + right: -4px; + left: auto; bottom: 0; z-index: 5; width: 8px; @@ -2111,12 +2105,6 @@ html[data-list-density="relaxed"] .download-ghost-row { padding-inline-start: 0; } -.download-row > div:last-child { - padding-inline-end: 0; -} - - - .download-row.is-selected { background: hsl(var(--accent-color) / 0.26) !important; box-shadow: inset 0 0 0 1px hsl(var(--accent-color) / 0.28); @@ -2138,6 +2126,10 @@ html[data-list-density="relaxed"] .download-ghost-row { overflow: hidden; } +.download-row:hover .download-column-cell--trailing .download-cell-content { + visibility: hidden; +} + .download-file-cell { direction: ltr; text-align: left; @@ -2215,13 +2207,15 @@ html[data-list-density="relaxed"] .download-ghost-row { .download-row-actions { position: absolute; top: 50%; - inset-inline-end: var(--download-row-padding-x); + right: var(--download-row-padding-x); + left: auto; z-index: 2; transform: translateY(-50%); padding-inline-start: 4px; border-radius: 6px; background: hsl(var(--surface-overlay) / 0.94); max-width: 100%; + padding-inline: 4px; } .download-column-menu { @@ -2256,7 +2250,7 @@ html[data-list-density="relaxed"] .download-ghost-row { font-size: var(--download-row-font-size); font-weight: 650; pointer-events: none; - transition: left 70ms ease-out, top 120ms ease-out, width 120ms ease-out; + transition: top 120ms ease-out, width 120ms ease-out; animation: column-drag-preview-in 120ms ease-out; } @@ -2298,6 +2292,13 @@ html[data-list-density="relaxed"] .download-ghost-row { margin-inline-start: 0.25rem; } +.app-workspace--sidebar-right .download-context-submenu { + inset-inline-start: auto; + inset-inline-end: 100%; + margin-inline-start: 0; + margin-inline-end: 0.25rem; +} + html[dir="rtl"] .download-context-menu-chevron { transform: scaleX(-1); } diff --git a/src/utils/downloadTableColumns.test.ts b/src/utils/downloadTableColumns.test.ts index 4563338..4b6d86e 100644 --- a/src/utils/downloadTableColumns.test.ts +++ b/src/utils/downloadTableColumns.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_COLUMN_ALIGNMENTS, DEFAULT_COLUMN_ORDER, + DEFAULT_COLUMN_WIDTHS, + buildColumnGridTemplate, + getColumnGridColumn, normalizeColumnAlignments, normalizeColumnOrder, normalizeColumnWidths, @@ -26,7 +29,7 @@ describe('download table column preferences', () => { 220, 80, 80, - 140, + 144, ]); expect(normalizeColumnWidths([0, 100, 220, 100, 80, 170])[0]).toBe(160); @@ -50,4 +53,13 @@ describe('download table column preferences', () => { expect(normalizeColumnOrder(null)).toEqual([...DEFAULT_COLUMN_ORDER]); expect(normalizeColumnAlignments(null)).toEqual(DEFAULT_COLUMN_ALIGNMENTS); }); + + it('keeps user widths fixed while reserving a shared trailing spacer track', () => { + expect(buildColumnGridTemplate([...DEFAULT_COLUMN_ORDER], [...DEFAULT_COLUMN_WIDTHS])).toBe( + '340px 100px 220px 100px 80px minmax(0, 1fr) 170px' + ); + expect(getColumnGridColumn('Date Added', [...DEFAULT_COLUMN_ORDER])).toBe('7'); + expect(getColumnGridColumn('Date Added', ['Date Added', ...DEFAULT_COLUMN_ORDER.slice(0, -1)])).toBeUndefined(); + expect(getColumnGridColumn('ETA', ['Date Added', 'File Name', 'Size', 'Status', 'Speed', 'ETA'])).toBe('7'); + }); }); diff --git a/src/utils/downloadTableColumns.ts b/src/utils/downloadTableColumns.ts index 4557068..813627a 100644 --- a/src/utils/downloadTableColumns.ts +++ b/src/utils/downloadTableColumns.ts @@ -13,7 +13,7 @@ export const DEFAULT_COLUMN_ORDER = [ ] as const satisfies readonly DownloadTableColumnKey[]; export const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170] as const; -export const COLUMN_MINIMUMS = [160, 58, 92, 58, 48, 112] as const; +export const COLUMN_MINIMUMS = [160, 58, 92, 58, 48, 144] as const; export const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths'; export const COLUMN_ORDER_STORAGE_KEY = 'firelink-download-column-order'; @@ -87,3 +87,23 @@ export const normalizeColumnAlignments = (value: unknown): Record DEFAULT_COLUMN_ORDER.indexOf(key); + +export const buildColumnGridTemplate = ( + order: DownloadTableColumnKey[], + widths: number[] +): string => { + const normalizedWidths = normalizeColumnWidths(widths); + const orderedWidths = order.map(key => normalizedWidths[columnIndex(key)]); + return [ + ...orderedWidths.slice(0, -1).map(width => `${width}px`), + 'minmax(0, 1fr)', + `${orderedWidths[orderedWidths.length - 1]}px`, + ].join(' '); +}; + +export const getColumnGridColumn = ( + key: DownloadTableColumnKey, + order: DownloadTableColumnKey[] +): string | undefined => key === order[order.length - 1] + ? `${order.length + 1}` + : undefined;