mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-09 18:59:36 +00:00
fix(ui): harden navigation and queue interactions
This commit is contained in:
@@ -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 => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user