diff --git a/src/App.tsx b/src/App.tsx index 2e42193..a8e72b3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads'; import { schedulerCompletionState } from './utils/schedulerCompletion'; -import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Sidebar, SidebarFilter } from "./components/Sidebar"; import { DownloadTable, type DownloadTableStatusSummary } from "./components/DownloadTable"; // Keep the primary Add action eager so the modal cannot disappear behind a @@ -198,6 +198,16 @@ function App() { const fontFamily = useSettingsStore(state => state.fontFamily); const appFontSize = useSettingsStore(state => state.appFontSize); const listRowDensity = useSettingsStore(state => state.listRowDensity); + const pageTransitionKey = activeView === 'downloads' + ? `${activeView}:${filter}` + : activeView; + const [isPageTransitionActive, setIsPageTransitionActive] = useState(false); + + useLayoutEffect(() => { + setIsPageTransitionActive(false); + const frame = window.requestAnimationFrame(() => setIsPageTransitionActive(true)); + return () => window.cancelAnimationFrame(frame); + }, [pageTransitionKey]); useEffect(() => { const locale = languagePreference === 'system' @@ -1110,16 +1120,18 @@ function App() { )}
}> - {activeView === 'downloads' && ( - - )} - {activeView === 'settings' && } - {activeView === 'scheduler' && } - {activeView === 'speedLimiter' && } - {activeView === 'logs' && } +
+ {activeView === 'downloads' && ( + + )} + {activeView === 'settings' && } + {activeView === 'scheduler' && } + {activeView === 'speedLimiter' && } + {activeView === 'logs' && } +
diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index f9b99ca..cbdb5f3 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -62,10 +62,17 @@ export const DownloadItem = React.memo(({ const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]); const rowRef = React.useRef(null); const [isRowHovered, setIsRowHovered] = React.useState(false); - const [isRowFocused, setIsRowFocused] = React.useState(false); + const [isRowKeyboardFocused, setIsRowKeyboardFocused] = React.useState(false); + const [isActionHovered, setIsActionHovered] = React.useState(false); + const [isActionFocused, setIsActionFocused] = React.useState(false); const [actionPosition, setActionPosition] = React.useState(); const hasRowActions = download.status !== 'completed'; - const isActionVisible = hasRowActions && (isRowHovered || isRowFocused); + const isActionVisible = hasRowActions && ( + isRowHovered || + isActionHovered || + isRowKeyboardFocused || + isActionFocused + ); const mediaQualityLabel = (() => { if (!download.isMedia || typeof download.mediaQuality !== 'string') return undefined; const normalized = download.mediaQuality.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim(); @@ -332,6 +339,23 @@ export const DownloadItem = React.memo(({ }} onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} + onMouseEnter={() => { + setIsActionHovered(true); + updateActionPosition(); + }} + onMouseLeave={() => setIsActionHovered(false)} + onFocusCapture={event => { + const target = event.target; + const keyboardFocused = target instanceof HTMLElement && target.matches(':focus-visible'); + setIsActionFocused(keyboardFocused); + if (keyboardFocused) updateActionPosition(); + }} + onBlurCapture={event => { + const nextTarget = event.relatedTarget; + if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) { + setIsActionFocused(false); + } + }} > {canPauseDownload(download.status) && ( - {contextMenu && ( + {contextMenu && createPortal(
e.stopPropagation()} > @@ -561,7 +607,8 @@ export const Sidebar: React.FC = (props) => { {t($ => $.actions.deleteQueue)} )} -
+ , + document.body )} ); diff --git a/src/index.css b/src/index.css index 8e1c595..0bb6a01 100644 --- a/src/index.css +++ b/src/index.css @@ -1731,8 +1731,9 @@ html[data-list-density="relaxed"] { padding: 32px; } - .settings-page-transition { - animation: settings-page-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1); + .settings-page-transition, + .app-page-transition { + animation: app-page-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1); transform-origin: top center; } @@ -3331,7 +3332,7 @@ html[dir="rtl"] .download-context-menu-chevron { to { opacity: 1; } } -@keyframes settings-page-in { +@keyframes app-page-in { from { opacity: 0; transform: translateY(6px); diff --git a/src/utils/downloadSelection.test.ts b/src/utils/downloadSelection.test.ts new file mode 100644 index 0000000..2db5285 --- /dev/null +++ b/src/utils/downloadSelection.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { updateDownloadSelection } from './downloadSelection'; + +const orderedIds = ['a', 'b', 'c', 'd']; + +describe('download selection', () => { + it('collapses select-all to the clicked row', () => { + const result = updateDownloadSelection({ + orderedIds, + selectedIds: new Set(orderedIds), + lastSelectedId: 'd', + targetId: 'b', + extendRange: false, + toggle: false, + }); + + expect([...result.selectedIds]).toEqual(['b']); + expect(result.lastSelectedId).toBe('b'); + }); + + it('toggles a row without losing the rest of the selection', () => { + const result = updateDownloadSelection({ + orderedIds, + selectedIds: new Set(['a', 'c']), + lastSelectedId: 'a', + targetId: 'c', + extendRange: false, + toggle: true, + }); + + expect([...result.selectedIds]).toEqual(['a']); + }); + + it('extends from the existing anchor for a range selection', () => { + const result = updateDownloadSelection({ + orderedIds, + selectedIds: new Set(['a']), + lastSelectedId: 'a', + targetId: 'c', + extendRange: true, + toggle: false, + }); + + expect([...result.selectedIds]).toEqual(['a', 'b', 'c']); + expect(result.lastSelectedId).toBe('a'); + }); +}); diff --git a/src/utils/downloadSelection.ts b/src/utils/downloadSelection.ts new file mode 100644 index 0000000..381289a --- /dev/null +++ b/src/utils/downloadSelection.ts @@ -0,0 +1,53 @@ +export interface DownloadSelectionResult { + selectedIds: Set; + lastSelectedId: string | null; +} + +interface DownloadSelectionOptions { + orderedIds: readonly string[]; + selectedIds: ReadonlySet; + lastSelectedId: string | null; + targetId: string; + extendRange: boolean; + toggle: boolean; +} + +/** Apply the table's click-selection rules without depending on React state timing. */ +export const updateDownloadSelection = ({ + orderedIds, + selectedIds, + lastSelectedId, + targetId, + extendRange, + toggle, +}: DownloadSelectionOptions): DownloadSelectionResult => { + const targetIndex = orderedIds.indexOf(targetId); + if (targetIndex === -1) { + return { selectedIds: new Set(selectedIds), lastSelectedId }; + } + + if (extendRange && lastSelectedId) { + const anchorIndex = orderedIds.indexOf(lastSelectedId); + if (anchorIndex !== -1) { + const nextSelectedIds = toggle ? new Set(selectedIds) : new Set(); + const start = Math.min(anchorIndex, targetIndex); + const end = Math.max(anchorIndex, targetIndex); + for (let index = start; index <= end; index += 1) { + nextSelectedIds.add(orderedIds[index]); + } + return { selectedIds: nextSelectedIds, lastSelectedId }; + } + } + + if (toggle) { + const nextSelectedIds = new Set(selectedIds); + if (nextSelectedIds.has(targetId)) { + nextSelectedIds.delete(targetId); + } else { + nextSelectedIds.add(targetId); + } + return { selectedIds: nextSelectedIds, lastSelectedId: targetId }; + } + + return { selectedIds: new Set([targetId]), lastSelectedId: targetId }; +}; diff --git a/src/utils/floatingPosition.test.ts b/src/utils/floatingPosition.test.ts new file mode 100644 index 0000000..e1622a2 --- /dev/null +++ b/src/utils/floatingPosition.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { clampFloatingPosition } from './floatingPosition'; + +describe('floating surface positioning', () => { + it('keeps a variable-height menu inside the viewport', () => { + expect(clampFloatingPosition(350, 580, 192, 220, 400, 640)).toEqual({ + x: 200, + y: 412, + }); + }); + + it('keeps oversized surfaces anchored to the safe gutter', () => { + expect(clampFloatingPosition(-20, -10, 700, 900, 400, 640)).toEqual({ + x: 8, + y: 8, + }); + }); +}); diff --git a/src/utils/floatingPosition.ts b/src/utils/floatingPosition.ts new file mode 100644 index 0000000..8fcc99d --- /dev/null +++ b/src/utils/floatingPosition.ts @@ -0,0 +1,23 @@ +export interface FloatingPosition { + x: number; + y: number; +} + +/** Keep a fixed-position surface inside the viewport with a small safe gutter. */ +export const clampFloatingPosition = ( + x: number, + y: number, + width: number, + height: number, + viewportWidth: number, + viewportHeight: number, + gutter = 8 +): FloatingPosition => { + const maxX = Math.max(gutter, viewportWidth - width - gutter); + const maxY = Math.max(gutter, viewportHeight - height - gutter); + + return { + x: Math.min(Math.max(gutter, x), maxX), + y: Math.min(Math.max(gutter, y), maxY), + }; +};