fix(ui): harden navigation and queue interactions

This commit is contained in:
NimBold
2026-07-28 22:11:38 +03:30
parent 7e8f1c7d7b
commit 62f8c83c57
9 changed files with 299 additions and 68 deletions
+23 -11
View File
@@ -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() {
)}
<div className="flex-1 flex flex-col overflow-hidden relative">
<Suspense fallback={<PageLoadingFallback />}>
{activeView === 'downloads' && (
<DownloadTable
filter={filter}
onSummaryChange={handleDownloadTableSummaryChange}
/>
)}
{activeView === 'settings' && <SettingsView />}
{activeView === 'scheduler' && <SchedulerView />}
{activeView === 'speedLimiter' && <SpeedLimiterView />}
{activeView === 'logs' && <LogsView />}
<div className={`${isPageTransitionActive ? 'app-page-transition' : ''} flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden`}>
{activeView === 'downloads' && (
<DownloadTable
filter={filter}
onSummaryChange={handleDownloadTableSummaryChange}
/>
)}
{activeView === 'settings' && <SettingsView />}
{activeView === 'scheduler' && <SchedulerView />}
{activeView === 'speedLimiter' && <SpeedLimiterView />}
{activeView === 'logs' && <LogsView />}
</div>
</Suspense>
</div>
+31 -5
View File
@@ -62,10 +62,17 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
const rowRef = React.useRef<HTMLDivElement>(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<React.CSSProperties | undefined>();
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<DownloadItemProps>(({
}}
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) && (
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title={t($ => $.downloads.actions.pause)}>
@@ -374,13 +398,15 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
}
}}
onFocus={() => {
setIsRowFocused(true);
if (hasRowActions) updateActionPosition();
const target = document.activeElement;
const keyboardFocused = target instanceof HTMLElement && target.matches(':focus-visible');
setIsRowKeyboardFocused(keyboardFocused);
if (hasRowActions && keyboardFocused) updateActionPosition();
}}
onBlur={event => {
const nextTarget = event.relatedTarget;
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
setIsRowFocused(false);
setIsRowKeyboardFocused(false);
}
}}
onPointerDown={event => {
+45 -41
View File
@@ -50,6 +50,7 @@ import {
import {
moveSelectedBlockToIndex
} from '../utils/queueOrdering';
import { updateDownloadSelection } from '../utils/downloadSelection';
export interface DownloadTableStatusSummary {
summary: DownloadSummary;
@@ -222,9 +223,18 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const queueRowAnimationGenerationRef = useRef(0);
const queueReorderPendingCountRef = useRef(0);
const suppressQueueClickRef = useRef(false);
const suppressQueueClickTimerRef = useRef<number | null>(null);
const [queueDragState, setQueueDragState] = useState<QueueDragState | null>(null);
const [queueDragPreviewOrder, setQueueDragPreviewOrder] = useState<string[] | null>(null);
const clearQueueClickSuppression = useCallback(() => {
suppressQueueClickRef.current = false;
if (suppressQueueClickTimerRef.current !== null) {
window.clearTimeout(suppressQueueClickTimerRef.current);
suppressQueueClickTimerRef.current = null;
}
}, []);
useEffect(() => {
const className = 'is-queue-dragging';
if (queueDragState?.active) {
@@ -765,6 +775,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
columnDragCleanupRef.current = null;
queueDragCleanupRef.current = null;
queueDragStateRef.current = null;
clearQueueClickSuppression();
const queueCaptureTarget = queueDragCaptureTargetRef.current;
const queueCapturePointerId = queueDragCapturePointerIdRef.current;
queueDragCaptureTargetRef.current = null;
@@ -1042,9 +1053,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
releaseQueuePointerCapture();
}
if (current.active) {
if (suppressQueueClickTimerRef.current !== null) {
window.clearTimeout(suppressQueueClickTimerRef.current);
}
suppressQueueClickRef.current = true;
window.setTimeout(() => {
suppressQueueClickTimerRef.current = window.setTimeout(() => {
suppressQueueClickRef.current = false;
suppressQueueClickTimerRef.current = null;
}, 0);
}
@@ -1102,6 +1117,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
queueDragPreviewOrderRef.current ||
queueReorderPendingCountRef.current > 0
) return;
clearQueueClickSuppression();
if (
event.target instanceof Element &&
event.target.closest('button, a, input, textarea, select, [role="menu"], .download-row-actions')
@@ -1156,13 +1172,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
setQueueDragState(initialState);
queueDragCaptureTargetRef.current = captureTarget;
queueDragCapturePointerIdRef.current = pointerId;
try {
captureTarget.setPointerCapture(pointerId);
} catch {
// The pointer may have been cancelled between pointerdown and capture.
// Window-level listeners remain the best-effort cleanup fallback.
}
let pointerMoveFrame: number | null = null;
let pendingPointerPosition: { clientX: number; clientY: number } | null = null;
@@ -1175,6 +1184,15 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
);
if (!drag.active && distance < 5) return;
if (!drag.active) {
try {
captureTarget.setPointerCapture(pointerId);
} catch {
// The pointer may have been cancelled before the drag threshold.
// Window-level listeners remain the best-effort cleanup fallback.
}
}
const baseItems = queueDragBaseItemsRef.current;
const selectedIdSet = new Set(drag.ids);
queueDragStateRef.current = { ...drag, pointerY: clientY };
@@ -1224,7 +1242,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
clientX: pointerEvent.clientX,
clientY: pointerEvent.clientY,
};
pointerEvent.preventDefault();
const drag = queueDragStateRef.current;
if (drag && (drag.active || Math.hypot(
pointerEvent.clientX - drag.startX,
pointerEvent.clientY - drag.startY
) >= 5)) {
pointerEvent.preventDefault();
}
if (pointerMoveFrame === null) {
pointerMoveFrame = window.requestAnimationFrame(() => {
pointerMoveFrame = null;
@@ -1648,44 +1672,24 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}, [filter, isQueueFilter]);
const handleItemClick = useCallback((e: React.MouseEvent, item: DownloadItem) => {
if (suppressQueueClickRef.current) {
suppressQueueClickRef.current = false;
clearQueueClickSuppression();
return;
}
if (e.detail === 2) {
handleDownloadDoubleClick(item);
return;
}
const currentSortedDownloads = sortedDownloadsRef.current;
const currentSelectedIds = selectedIdsRef.current;
const currentLastSelectedId = lastSelectedIdRef.current;
if (e.shiftKey && currentLastSelectedId) {
const currentIndex = currentSortedDownloads.findIndex(d => d.id === item.id);
const lastIndex = currentSortedDownloads.findIndex(d => d.id === currentLastSelectedId);
if (currentIndex !== -1 && lastIndex !== -1) {
const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex);
const newSelected = (e.metaKey || e.ctrlKey) ? new Set(currentSelectedIds) : new Set<string>();
for (let i = start; i <= end; i++) {
newSelected.add(currentSortedDownloads[i].id);
}
setSelectedIds(newSelected);
}
} else if (e.metaKey || e.ctrlKey) {
const newSelected = new Set(currentSelectedIds);
if (newSelected.has(item.id)) {
newSelected.delete(item.id);
} else {
newSelected.add(item.id);
}
setSelectedIds(newSelected);
setLastSelectedId(item.id);
} else {
setSelectedIds(new Set([item.id]));
setLastSelectedId(item.id);
}
}, [handleDownloadDoubleClick]);
const nextSelection = updateDownloadSelection({
orderedIds: sortedDownloadsRef.current.map(download => download.id),
selectedIds: selectedIdsRef.current,
lastSelectedId: lastSelectedIdRef.current,
targetId: item.id,
extendRange: e.shiftKey,
toggle: e.metaKey || e.ctrlKey,
});
setSelectedIds(nextSelection.selectedIds);
setLastSelectedId(nextSelection.lastSelectedId);
}, [clearQueueClickSuppression, handleDownloadDoubleClick]);
const handleContextMenu = useCallback((menu: { x: number; y: number; id: string }) => {
if (!selectedIdsRef.current.has(menu.id)) {
+55 -8
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
Inbox, Zap, CheckCircle2, CircleDashed,
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion,
@@ -11,6 +12,7 @@ import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { isTransferActiveStatus } from '../utils/downloads';
import { clampFloatingPosition } from '../utils/floatingPosition';
import { useTranslation } from 'react-i18next';
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
@@ -25,13 +27,15 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore();
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
const { addToast } = useToast();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [isAddingQueue, setIsAddingQueue] = useState(false);
const [newQueueName, setNewQueueName] = useState('');
const [renamingQueueId, setRenamingQueueId] = useState<string | null>(null);
const [editingQueueName, setEditingQueueName] = useState('');
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
const [foldersCollapsed, setFoldersCollapsed] = useState(() =>
window.localStorage.getItem('firelink-folders-collapsed') === 'true'
);
@@ -49,6 +53,46 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const rejectedAddQueueNameRef = useRef<string | null>(null);
const rejectedRenameRef = useRef<{ queueId: string; name: string } | null>(null);
useLayoutEffect(() => {
if (!contextMenu) return;
if (!queues.some(queue => queue.id === contextMenu.id)) {
setContextMenu(null);
setContextMenuPosition(null);
return;
}
const updateContextMenuPosition = () => {
const menu = contextMenuRef.current;
if (!menu) return;
const rect = menu.getBoundingClientRect();
const nextPosition = clampFloatingPosition(
contextMenu.x,
contextMenu.y,
rect.width,
rect.height,
window.innerWidth,
window.innerHeight
);
setContextMenuPosition(current => (
current?.x === nextPosition.x && current.y === nextPosition.y
? current
: nextPosition
));
};
updateContextMenuPosition();
const resizeObserver = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(updateContextMenuPosition);
const menu = contextMenuRef.current;
if (menu) resizeObserver?.observe(menu);
window.addEventListener('resize', updateContextMenuPosition);
return () => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updateContextMenuPosition);
};
}, [contextMenu, queues, i18n.language]);
useEffect(() => {
const handleCloseMenu = () => setContextMenu(null);
const handleEscape = (event: KeyboardEvent) => {
@@ -156,6 +200,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const handleQueueContextMenu = (e: React.MouseEvent, id: string) => {
e.preventDefault();
e.stopPropagation();
setContextMenuPosition(null);
setContextMenu({ x: e.clientX, y: e.clientY, id });
};
@@ -431,13 +476,14 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
</button>
</div>
{contextMenu && (
{contextMenu && createPortal(
<div
role="menu"
className="fixed z-50 w-48 py-1 rounded-xl shadow-lg border border-border-modal bg-bg-context-menu backdrop-blur-xl animate-fade-in text-[13px] text-text-primary overflow-hidden"
style={{
top: Math.min(contextMenu.y, window.innerHeight - 200),
left: Math.min(contextMenu.x, window.innerWidth - 200)
ref={contextMenuRef}
className="fixed z-[70] w-48 py-1 rounded-xl shadow-lg border border-border-modal bg-bg-context-menu backdrop-blur-xl animate-fade-in text-[13px] text-text-primary overflow-hidden"
style={{
top: contextMenuPosition?.y ?? contextMenu.y,
left: contextMenuPosition?.x ?? contextMenu.x,
}}
onClick={e => e.stopPropagation()}
>
@@ -561,7 +607,8 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
{t($ => $.actions.deleteQueue)}
</button>
)}
</div>
</div>,
document.body
)}
</aside>
);
+4 -3
View File
@@ -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);
+47
View File
@@ -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');
});
});
+53
View File
@@ -0,0 +1,53 @@
export interface DownloadSelectionResult {
selectedIds: Set<string>;
lastSelectedId: string | null;
}
interface DownloadSelectionOptions {
orderedIds: readonly string[];
selectedIds: ReadonlySet<string>;
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<string>();
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 };
};
+18
View File
@@ -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,
});
});
});
+23
View File
@@ -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),
};
};