mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-29 05:19:45 +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 { 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+4
-3
@@ -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);
|
||||
|
||||
@@ -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