diff --git a/src/tribler/ui/package-lock.json b/src/tribler/ui/package-lock.json index 84240576e5..67051fb292 100644 --- a/src/tribler/ui/package-lock.json +++ b/src/tribler/ui/package-lock.json @@ -35,12 +35,12 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.48.2", + "react-hotkeys-hook": "^4.6.1", "react-i18next": "^14.1.1", "react-resizable-panels": "^2.0.16", "react-router-dom": "^6.16.0", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", - "use-keyboard-shortcut": "^1.1.6", "zod": "^3.22.4" }, "devDependencies": { @@ -3499,6 +3499,15 @@ "react-dom": ">=16" } }, + "node_modules/react-hotkeys-hook": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.6.1.tgz", + "integrity": "sha512-XlZpbKUj9tkfgPgT9gA+1p7Ey6vFIZHttUjPqpTdyT5nqQ8mHL7elxvSbaC+dpSiHUSmr21Ya1mDxBZG3aje4Q==", + "peerDependencies": { + "react": ">=16.8.1", + "react-dom": ">=16.8.1" + } + }, "node_modules/react-i18next": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.1.tgz", @@ -4070,15 +4079,6 @@ } } }, - "node_modules/use-keyboard-shortcut": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/use-keyboard-shortcut/-/use-keyboard-shortcut-1.1.6.tgz", - "integrity": "sha512-tOKjR7eKXQIfAgFMwhelpWSRTrwE1GaB8CE/47ohtcVCKxUdn7UbfNNhiigTkATpUVoPu+5tRPbHrwjIBgTYoQ==", - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", @@ -6340,6 +6340,12 @@ "goober": "^2.1.10" } }, + "react-hotkeys-hook": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.6.1.tgz", + "integrity": "sha512-XlZpbKUj9tkfgPgT9gA+1p7Ey6vFIZHttUjPqpTdyT5nqQ8mHL7elxvSbaC+dpSiHUSmr21Ya1mDxBZG3aje4Q==", + "requires": {} + }, "react-i18next": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.1.tgz", @@ -6703,12 +6709,6 @@ "tslib": "^2.0.0" } }, - "use-keyboard-shortcut": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/use-keyboard-shortcut/-/use-keyboard-shortcut-1.1.6.tgz", - "integrity": "sha512-tOKjR7eKXQIfAgFMwhelpWSRTrwE1GaB8CE/47ohtcVCKxUdn7UbfNNhiigTkATpUVoPu+5tRPbHrwjIBgTYoQ==", - "requires": {} - }, "use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", diff --git a/src/tribler/ui/package.json b/src/tribler/ui/package.json index ea36dd34f7..9e1139556a 100644 --- a/src/tribler/ui/package.json +++ b/src/tribler/ui/package.json @@ -36,12 +36,12 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.48.2", + "react-hotkeys-hook": "^4.6.1", "react-i18next": "^14.1.1", "react-resizable-panels": "^2.0.16", "react-router-dom": "^6.16.0", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", - "use-keyboard-shortcut": "^1.1.6", "zod": "^3.22.4" }, "devDependencies": { diff --git a/src/tribler/ui/src/components/ui/simple-table.tsx b/src/tribler/ui/src/components/ui/simple-table.tsx index 85bb735123..0511dbeae7 100644 --- a/src/tribler/ui/src/components/ui/simple-table.tsx +++ b/src/tribler/ui/src/components/ui/simple-table.tsx @@ -1,8 +1,8 @@ -import { SetStateAction, useEffect, useRef, useState } from 'react'; +import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { getCoreRowModel, useReactTable, flexRender, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getSortedRowModel } from '@tanstack/react-table'; import type { ColumnDef, Row, PaginationState, RowSelectionState, ColumnFiltersState, ExpandedState, ColumnDefTemplate, HeaderContext, SortingState, VisibilityState, Header, Column } from '@tanstack/react-table'; -import { cn } from '@/lib/utils'; +import { cn, isMac } from '@/lib/utils'; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel } from './select'; import { Button } from './button'; import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, DotsHorizontalIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon } from '@radix-ui/react-icons'; @@ -10,9 +10,9 @@ import * as SelectPrimitive from "@radix-ui/react-select" import type { Table as ReactTable } from '@tanstack/react-table'; import { useTranslation } from 'react-i18next'; import { useResizeObserver } from '@/hooks/useResizeObserver'; -import useKeyboardShortcut from 'use-keyboard-shortcut'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from './dropdown-menu'; import { triblerService } from '@/services/tribler.service'; +import { useHotkeys } from 'react-hotkeys-hook'; declare module '@tanstack/table-core/build/lib/types' { @@ -67,6 +67,55 @@ function setState(type: "columns" | "sorting", name: string, state: SortingState triblerService.setSettings({ ui: triblerService.guiSettings }); } +function updateRowSelection( + table: ReactTable, + rowSelection: RowSelectionState, + setRowSelection: Dispatch>, + fromId: string | undefined, + change: number, + allowMulti?: boolean) { + + if (fromId === undefined) return; + + let rows = table.getSortedRowModel().rows; + let fromRow = rows.find((row) => row.id === fromId); + let fromIndex = fromRow?.index; + if (fromIndex === undefined) return; + + let selectedRowIDs = Object.keys(rowSelection); + let selectedRowIndexes = rows.filter(row => selectedRowIDs.includes(row.id)).map((row) => row.index); + let maxIndex = Math.max(...selectedRowIndexes); + let minIndex = Math.min(...selectedRowIndexes); + + // If there are gaps in the selection, ignore all but the most recently clicked item. + let gaps = false; + for (let i = minIndex; i <= maxIndex; i++) { + if (!selectedRowIndexes.includes(i)) gaps = true; + } + let toIndex = fromIndex === maxIndex ? minIndex : maxIndex; + if (gaps) toIndex = fromIndex + + // Calculate new toIndex, depending on whether we're going up/down in the list + let newIndex = toIndex + change; + if (newIndex >= 0 && newIndex < rows.length) { + toIndex = newIndex; + } + + if (!allowMulti) fromIndex = toIndex; + + // Set fromIndex..toIndex as the new row selection + let selection: any = {}; + for (let row of rows) { + if ((row.index >= fromIndex && row.index <= toIndex) || + (row.index <= fromIndex && row.index >= toIndex)) { + selection[row.id] = true; + } + } + setRowSelection(selection); + + document.querySelector("[data-state='selected']")?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); +} + interface ReactTableProps { data: T[]; columns: ColumnDef[]; @@ -117,6 +166,7 @@ function SimpleTable({ const [columnFilters, setColumnFilters] = useState(filters || []) const [expanded, setExpanded] = useState({}); const [sorting, setSorting] = useState(getState("sorting", storeSortingState) || []); + const [startId, setStartId] = useState(undefined); //Get stored column visibility and add missing visibilities with their defaults. const visibilityState = getState("columns", allowColumnToggle) || {}; @@ -128,35 +178,25 @@ function SimpleTable({ } const [columnVisibility, setColumnVisibility] = useState(visibilityState); - useKeyboardShortcut(["Control", "A"], () => { + + useHotkeys(isMac() ? 'meta+a' : 'ctrl+a', event => { if (allowMultiSelect) { table.toggleAllRowsSelected(true); } - }, { overrideSystem: true, repeatOnHold: false }); - useKeyboardShortcut(["ArrowUp"], () => { - let ids = Object.keys(rowSelection); - let rows = table.getSortedRowModel().rows; - let index = rows.findIndex((row) => ids.includes(row.id)); - let next = rows[index - 1] || rows[0]; - - let selection: any = {}; - selection[next.id.toString()] = true; - table.setRowSelection(selection); - - document.querySelector("[data-state='selected']")?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); - }); - useKeyboardShortcut(["ArrowDown"], () => { - let ids = Object.keys(rowSelection); - let rows = table.getSortedRowModel().rows; - let index = rows.findLastIndex((row) => ids.includes(row.id)); - let next = rows[index + 1] || rows[rows.length - 1]; - - let selection: any = {}; - selection[next.id.toString()] = true; - table.setRowSelection(selection); - - document.querySelector("[data-state='selected']")?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); + event.preventDefault(); }); + useHotkeys('shift+ArrowUp', () => { + updateRowSelection(table, rowSelection, setRowSelection, startId, -1, allowMultiSelect) + }, [rowSelection, startId]); + useHotkeys('shift+ArrowDown', () => { + updateRowSelection(table, rowSelection, setRowSelection, startId, 1, allowMultiSelect) + }, [rowSelection, startId]); + useHotkeys('ArrowUp', () => { + updateRowSelection(table, rowSelection, setRowSelection, startId, -1, false) + }, [rowSelection, startId]); + useHotkeys('ArrowDown', () => { + updateRowSelection(table, rowSelection, setRowSelection, startId, 1, false) + }, [rowSelection, startId]); const table = useReactTable({ data, @@ -207,6 +247,12 @@ function SimpleTable({ onSelectedRowsChange( table.getSelectedRowModel().flatRows.map((row) => row.original), ) + + const rowIds = Object.keys(rowSelection); + if (rowIds.length === 1) { + setStartId(rowIds[0]); + } + }, [rowSelection, table, onSelectedRowsChange]) useEffect(() => { @@ -301,17 +347,31 @@ function SimpleTable({ { if (!allowSelect && !allowMultiSelect) - return + return; - if (allowMultiSelect && (event.ctrlKey || event.shiftKey)) { + if (allowMultiSelect && (isMac() ? event.metaKey : event.ctrlKey)) { row.toggleSelected(!row.getIsSelected()); + if (!row.getIsSelected()) setStartId(row.id) + return; + } + + let rows = table.getSortedRowModel().rows; + let startRow = rows.find((row) => row.id === startId); + + if (startRow && allowMultiSelect && event.shiftKey) { + let selection: any = {}; + for (let i = Math.min(row.index, startRow.index); i <= Math.max(row.index, startRow.index); i++) { + selection[rows[i].id] = true; + } + setRowSelection(selection); } else { const selected = row.getIsSelected() table.resetRowSelection(); row.toggleSelected(!selected); + if (!selected) setStartId(row.id) } }} onDoubleClick={() => { diff --git a/src/tribler/ui/src/lib/utils.ts b/src/tribler/ui/src/lib/utils.ts index 4b1745af5d..78f18d63ce 100644 --- a/src/tribler/ui/src/lib/utils.ts +++ b/src/tribler/ui/src/lib/utils.ts @@ -236,3 +236,7 @@ export async function downloadFilesAsZip(files: FileLink[], zipName: string) { } } } + +export function isMac() { + return navigator.userAgent.includes('Mac'); +}