mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-31 13:08:17 +00:00
fix(ui): harden navigation and queue interactions
This commit is contained in:
+23
-11
@@ -1,6 +1,6 @@
|
|||||||
import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads';
|
import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads';
|
||||||
import { schedulerCompletionState } from './utils/schedulerCompletion';
|
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 { Sidebar, SidebarFilter } from "./components/Sidebar";
|
||||||
import { DownloadTable, type DownloadTableStatusSummary } from "./components/DownloadTable";
|
import { DownloadTable, type DownloadTableStatusSummary } from "./components/DownloadTable";
|
||||||
// Keep the primary Add action eager so the modal cannot disappear behind a
|
// 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 fontFamily = useSettingsStore(state => state.fontFamily);
|
||||||
const appFontSize = useSettingsStore(state => state.appFontSize);
|
const appFontSize = useSettingsStore(state => state.appFontSize);
|
||||||
const listRowDensity = useSettingsStore(state => state.listRowDensity);
|
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(() => {
|
useEffect(() => {
|
||||||
const locale = languagePreference === 'system'
|
const locale = languagePreference === 'system'
|
||||||
@@ -1110,16 +1120,18 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||||
<Suspense fallback={<PageLoadingFallback />}>
|
<Suspense fallback={<PageLoadingFallback />}>
|
||||||
{activeView === 'downloads' && (
|
<div className={`${isPageTransitionActive ? 'app-page-transition' : ''} flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden`}>
|
||||||
<DownloadTable
|
{activeView === 'downloads' && (
|
||||||
filter={filter}
|
<DownloadTable
|
||||||
onSummaryChange={handleDownloadTableSummaryChange}
|
filter={filter}
|
||||||
/>
|
onSummaryChange={handleDownloadTableSummaryChange}
|
||||||
)}
|
/>
|
||||||
{activeView === 'settings' && <SettingsView />}
|
)}
|
||||||
{activeView === 'scheduler' && <SchedulerView />}
|
{activeView === 'settings' && <SettingsView />}
|
||||||
{activeView === 'speedLimiter' && <SpeedLimiterView />}
|
{activeView === 'scheduler' && <SchedulerView />}
|
||||||
{activeView === 'logs' && <LogsView />}
|
{activeView === 'speedLimiter' && <SpeedLimiterView />}
|
||||||
|
{activeView === 'logs' && <LogsView />}
|
||||||
|
</div>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -62,10 +62,17 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
||||||
const rowRef = React.useRef<HTMLDivElement>(null);
|
const rowRef = React.useRef<HTMLDivElement>(null);
|
||||||
const [isRowHovered, setIsRowHovered] = React.useState(false);
|
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 [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
|
||||||
const hasRowActions = download.status !== 'completed';
|
const hasRowActions = download.status !== 'completed';
|
||||||
const isActionVisible = hasRowActions && (isRowHovered || isRowFocused);
|
const isActionVisible = hasRowActions && (
|
||||||
|
isRowHovered ||
|
||||||
|
isActionHovered ||
|
||||||
|
isRowKeyboardFocused ||
|
||||||
|
isActionFocused
|
||||||
|
);
|
||||||
const mediaQualityLabel = (() => {
|
const mediaQualityLabel = (() => {
|
||||||
if (!download.isMedia || typeof download.mediaQuality !== 'string') return undefined;
|
if (!download.isMedia || typeof download.mediaQuality !== 'string') return undefined;
|
||||||
const normalized = download.mediaQuality.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onDoubleClick={(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) && (
|
{canPauseDownload(download.status) && (
|
||||||
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title={t($ => $.downloads.actions.pause)}>
|
<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={() => {
|
onFocus={() => {
|
||||||
setIsRowFocused(true);
|
const target = document.activeElement;
|
||||||
if (hasRowActions) updateActionPosition();
|
const keyboardFocused = target instanceof HTMLElement && target.matches(':focus-visible');
|
||||||
|
setIsRowKeyboardFocused(keyboardFocused);
|
||||||
|
if (hasRowActions && keyboardFocused) updateActionPosition();
|
||||||
}}
|
}}
|
||||||
onBlur={event => {
|
onBlur={event => {
|
||||||
const nextTarget = event.relatedTarget;
|
const nextTarget = event.relatedTarget;
|
||||||
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
|
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
|
||||||
setIsRowFocused(false);
|
setIsRowKeyboardFocused(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPointerDown={event => {
|
onPointerDown={event => {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
moveSelectedBlockToIndex
|
moveSelectedBlockToIndex
|
||||||
} from '../utils/queueOrdering';
|
} from '../utils/queueOrdering';
|
||||||
|
import { updateDownloadSelection } from '../utils/downloadSelection';
|
||||||
|
|
||||||
export interface DownloadTableStatusSummary {
|
export interface DownloadTableStatusSummary {
|
||||||
summary: DownloadSummary;
|
summary: DownloadSummary;
|
||||||
@@ -222,9 +223,18 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
const queueRowAnimationGenerationRef = useRef(0);
|
const queueRowAnimationGenerationRef = useRef(0);
|
||||||
const queueReorderPendingCountRef = useRef(0);
|
const queueReorderPendingCountRef = useRef(0);
|
||||||
const suppressQueueClickRef = useRef(false);
|
const suppressQueueClickRef = useRef(false);
|
||||||
|
const suppressQueueClickTimerRef = useRef<number | null>(null);
|
||||||
const [queueDragState, setQueueDragState] = useState<QueueDragState | null>(null);
|
const [queueDragState, setQueueDragState] = useState<QueueDragState | null>(null);
|
||||||
const [queueDragPreviewOrder, setQueueDragPreviewOrder] = useState<string[] | 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(() => {
|
useEffect(() => {
|
||||||
const className = 'is-queue-dragging';
|
const className = 'is-queue-dragging';
|
||||||
if (queueDragState?.active) {
|
if (queueDragState?.active) {
|
||||||
@@ -765,6 +775,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
columnDragCleanupRef.current = null;
|
columnDragCleanupRef.current = null;
|
||||||
queueDragCleanupRef.current = null;
|
queueDragCleanupRef.current = null;
|
||||||
queueDragStateRef.current = null;
|
queueDragStateRef.current = null;
|
||||||
|
clearQueueClickSuppression();
|
||||||
const queueCaptureTarget = queueDragCaptureTargetRef.current;
|
const queueCaptureTarget = queueDragCaptureTargetRef.current;
|
||||||
const queueCapturePointerId = queueDragCapturePointerIdRef.current;
|
const queueCapturePointerId = queueDragCapturePointerIdRef.current;
|
||||||
queueDragCaptureTargetRef.current = null;
|
queueDragCaptureTargetRef.current = null;
|
||||||
@@ -1042,9 +1053,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
releaseQueuePointerCapture();
|
releaseQueuePointerCapture();
|
||||||
}
|
}
|
||||||
if (current.active) {
|
if (current.active) {
|
||||||
|
if (suppressQueueClickTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(suppressQueueClickTimerRef.current);
|
||||||
|
}
|
||||||
suppressQueueClickRef.current = true;
|
suppressQueueClickRef.current = true;
|
||||||
window.setTimeout(() => {
|
suppressQueueClickTimerRef.current = window.setTimeout(() => {
|
||||||
suppressQueueClickRef.current = false;
|
suppressQueueClickRef.current = false;
|
||||||
|
suppressQueueClickTimerRef.current = null;
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1102,6 +1117,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
queueDragPreviewOrderRef.current ||
|
queueDragPreviewOrderRef.current ||
|
||||||
queueReorderPendingCountRef.current > 0
|
queueReorderPendingCountRef.current > 0
|
||||||
) return;
|
) return;
|
||||||
|
clearQueueClickSuppression();
|
||||||
if (
|
if (
|
||||||
event.target instanceof Element &&
|
event.target instanceof Element &&
|
||||||
event.target.closest('button, a, input, textarea, select, [role="menu"], .download-row-actions')
|
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);
|
setQueueDragState(initialState);
|
||||||
queueDragCaptureTargetRef.current = captureTarget;
|
queueDragCaptureTargetRef.current = captureTarget;
|
||||||
queueDragCapturePointerIdRef.current = pointerId;
|
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 pointerMoveFrame: number | null = null;
|
||||||
let pendingPointerPosition: { clientX: number; clientY: 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 && 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 baseItems = queueDragBaseItemsRef.current;
|
||||||
const selectedIdSet = new Set(drag.ids);
|
const selectedIdSet = new Set(drag.ids);
|
||||||
queueDragStateRef.current = { ...drag, pointerY: clientY };
|
queueDragStateRef.current = { ...drag, pointerY: clientY };
|
||||||
@@ -1224,7 +1242,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
clientX: pointerEvent.clientX,
|
clientX: pointerEvent.clientX,
|
||||||
clientY: pointerEvent.clientY,
|
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) {
|
if (pointerMoveFrame === null) {
|
||||||
pointerMoveFrame = window.requestAnimationFrame(() => {
|
pointerMoveFrame = window.requestAnimationFrame(() => {
|
||||||
pointerMoveFrame = null;
|
pointerMoveFrame = null;
|
||||||
@@ -1648,44 +1672,24 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
}, [filter, isQueueFilter]);
|
}, [filter, isQueueFilter]);
|
||||||
const handleItemClick = useCallback((e: React.MouseEvent, item: DownloadItem) => {
|
const handleItemClick = useCallback((e: React.MouseEvent, item: DownloadItem) => {
|
||||||
if (suppressQueueClickRef.current) {
|
if (suppressQueueClickRef.current) {
|
||||||
suppressQueueClickRef.current = false;
|
clearQueueClickSuppression();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.detail === 2) {
|
if (e.detail === 2) {
|
||||||
handleDownloadDoubleClick(item);
|
handleDownloadDoubleClick(item);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentSortedDownloads = sortedDownloadsRef.current;
|
const nextSelection = updateDownloadSelection({
|
||||||
const currentSelectedIds = selectedIdsRef.current;
|
orderedIds: sortedDownloadsRef.current.map(download => download.id),
|
||||||
const currentLastSelectedId = lastSelectedIdRef.current;
|
selectedIds: selectedIdsRef.current,
|
||||||
if (e.shiftKey && currentLastSelectedId) {
|
lastSelectedId: lastSelectedIdRef.current,
|
||||||
const currentIndex = currentSortedDownloads.findIndex(d => d.id === item.id);
|
targetId: item.id,
|
||||||
const lastIndex = currentSortedDownloads.findIndex(d => d.id === currentLastSelectedId);
|
extendRange: e.shiftKey,
|
||||||
|
toggle: e.metaKey || e.ctrlKey,
|
||||||
if (currentIndex !== -1 && lastIndex !== -1) {
|
});
|
||||||
const start = Math.min(currentIndex, lastIndex);
|
setSelectedIds(nextSelection.selectedIds);
|
||||||
const end = Math.max(currentIndex, lastIndex);
|
setLastSelectedId(nextSelection.lastSelectedId);
|
||||||
|
}, [clearQueueClickSuppression, handleDownloadDoubleClick]);
|
||||||
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 handleContextMenu = useCallback((menu: { x: number; y: number; id: string }) => {
|
const handleContextMenu = useCallback((menu: { x: number; y: number; id: string }) => {
|
||||||
if (!selectedIdsRef.current.has(menu.id)) {
|
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 {
|
import {
|
||||||
Inbox, Zap, CheckCircle2, CircleDashed,
|
Inbox, Zap, CheckCircle2, CircleDashed,
|
||||||
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion,
|
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion,
|
||||||
@@ -11,6 +12,7 @@ import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
|
|||||||
import { WindowDragRegion } from './WindowDragRegion';
|
import { WindowDragRegion } from './WindowDragRegion';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { isTransferActiveStatus } from '../utils/downloads';
|
import { isTransferActiveStatus } from '../utils/downloads';
|
||||||
|
import { clampFloatingPosition } from '../utils/floatingPosition';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
|
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 { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore();
|
||||||
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
|
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
const [isAddingQueue, setIsAddingQueue] = useState(false);
|
const [isAddingQueue, setIsAddingQueue] = useState(false);
|
||||||
const [newQueueName, setNewQueueName] = useState('');
|
const [newQueueName, setNewQueueName] = useState('');
|
||||||
const [renamingQueueId, setRenamingQueueId] = useState<string | null>(null);
|
const [renamingQueueId, setRenamingQueueId] = useState<string | null>(null);
|
||||||
const [editingQueueName, setEditingQueueName] = useState('');
|
const [editingQueueName, setEditingQueueName] = useState('');
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
|
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(() =>
|
const [foldersCollapsed, setFoldersCollapsed] = useState(() =>
|
||||||
window.localStorage.getItem('firelink-folders-collapsed') === 'true'
|
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 rejectedAddQueueNameRef = useRef<string | null>(null);
|
||||||
const rejectedRenameRef = useRef<{ queueId: string; name: 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(() => {
|
useEffect(() => {
|
||||||
const handleCloseMenu = () => setContextMenu(null);
|
const handleCloseMenu = () => setContextMenu(null);
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
@@ -156,6 +200,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
const handleQueueContextMenu = (e: React.MouseEvent, id: string) => {
|
const handleQueueContextMenu = (e: React.MouseEvent, id: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
setContextMenuPosition(null);
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, id });
|
setContextMenu({ x: e.clientX, y: e.clientY, id });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -431,13 +476,14 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{contextMenu && (
|
{contextMenu && createPortal(
|
||||||
<div
|
<div
|
||||||
role="menu"
|
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"
|
ref={contextMenuRef}
|
||||||
style={{
|
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"
|
||||||
top: Math.min(contextMenu.y, window.innerHeight - 200),
|
style={{
|
||||||
left: Math.min(contextMenu.x, window.innerWidth - 200)
|
top: contextMenuPosition?.y ?? contextMenu.y,
|
||||||
|
left: contextMenuPosition?.x ?? contextMenu.x,
|
||||||
}}
|
}}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -561,7 +607,8 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
{t($ => $.actions.deleteQueue)}
|
{t($ => $.actions.deleteQueue)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
+4
-3
@@ -1731,8 +1731,9 @@ html[data-list-density="relaxed"] {
|
|||||||
padding: 32px;
|
padding: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-page-transition {
|
.settings-page-transition,
|
||||||
animation: settings-page-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
.app-page-transition {
|
||||||
|
animation: app-page-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
transform-origin: top center;
|
transform-origin: top center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3331,7 +3332,7 @@ html[dir="rtl"] .download-context-menu-chevron {
|
|||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes settings-page-in {
|
@keyframes app-page-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(6px);
|
transform: translateY(6px);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user