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),
+ };
+};