Add range selection (#707)

This commit is contained in:
schroda
2024-04-05 02:03:00 +02:00
committed by GitHub
parent 568aaebc3e
commit ecd9b7478b
8 changed files with 101 additions and 41 deletions

View File

@@ -52,6 +52,7 @@
"react-i18next": "^14.1.0", "react-i18next": "^14.1.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-virtuoso": "^4.7.7", "react-virtuoso": "^4.7.7",
"use-long-press": "^3.2.0",
"use-query-params": "^2.2.1", "use-query-params": "^2.2.1",
"vite": "^5.2.7", "vite": "^5.2.7",
"vite-tsconfig-paths": "^4.3.2" "vite-tsconfig-paths": "^4.3.2"

View File

@@ -14,6 +14,7 @@ import { Avatar, Box, CardContent, Stack, styled, Tooltip } from '@mui/material'
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import PopupState, { bindMenu } from 'material-ui-popup-state'; import PopupState, { bindMenu } from 'material-ui-popup-state';
import { useState } from 'react'; import { useState } from 'react';
import { useLongPress } from 'use-long-press';
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { SpinnerImage } from '@/components/util/SpinnerImage'; import { SpinnerImage } from '@/components/util/SpinnerImage';
import { TManga, TPartialManga } from '@/typings.ts'; import { TManga, TPartialManga } from '@/typings.ts';
@@ -116,6 +117,20 @@ export const MangaCard = (props: MangaCardProps) => {
const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false); const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false);
const handleClick = (e: React.MouseEvent | React.TouchEvent) => {
if (selected === null) {
return;
}
e.preventDefault();
handleSelection?.(id, !selected, { selectRange: e.shiftKey });
};
const longPressBind = useLongPress((e) => {
e.shiftKey = true;
handleClick(e);
});
if (gridLayout !== GridLayout.List) { if (gridLayout !== GridLayout.List) {
return ( return (
<> <>
@@ -126,12 +141,13 @@ export const MangaCard = (props: MangaCardProps) => {
{(popupState) => ( {(popupState) => (
<> <>
<Link <Link
{...longPressBind()}
onClick={(e) => { onClick={(e) => {
const isMigrateSelectMode = mode === 'migrate.select'; const isMigrateSelectMode = mode === 'migrate.select';
const isSelectionMode = selected !== null; const isSelectionMode = selected !== null;
const handleClick = isMigrateSelectMode || isSelectionMode; const shouldHandleClick = isMigrateSelectMode || isSelectionMode;
if (!handleClick) { if (!shouldHandleClick) {
return; return;
} }
@@ -142,7 +158,7 @@ export const MangaCard = (props: MangaCardProps) => {
return; return;
} }
handleSelection?.(id, !selected); handleClick(e);
}} }}
to={mangaLinkTo} to={mangaLinkTo}
style={{ textDecoration: 'none' }} style={{ textDecoration: 'none' }}
@@ -334,14 +350,8 @@ export const MangaCard = (props: MangaCardProps) => {
<CardActionArea <CardActionArea
component={Link} component={Link}
to={mangaLinkTo} to={mangaLinkTo}
onClick={(e) => { onClick={handleClick}
if (selected === null) { {...longPressBind()}
return;
}
e.preventDefault();
handleSelection?.(id, !selected);
}}
> >
<CardContent <CardContent
sx={{ sx={{

View File

@@ -14,10 +14,11 @@ import CardContent from '@mui/material/CardContent';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import React, { TouchEvent } from 'react'; import React, { MouseEvent, TouchEvent } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state'; import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
import { useLongPress } from 'use-long-press';
import { getUploadDateString } from '@/util/date.ts'; import { getUploadDateString } from '@/util/date.ts';
import { DownloadStateIndicator } from '@/components/molecules/DownloadStateIndicator.tsx'; import { DownloadStateIndicator } from '@/components/molecules/DownloadStateIndicator.tsx';
import { DownloadType } from '@/lib/graphql/generated/graphql.ts'; import { DownloadType } from '@/lib/graphql/generated/graphql.ts';
@@ -30,7 +31,7 @@ interface IProps {
allChapters: TChapter[]; allChapters: TChapter[];
downloadChapter: DownloadType | undefined; downloadChapter: DownloadType | undefined;
showChapterNumber: boolean; showChapterNumber: boolean;
onSelect: (selected: boolean) => void; onSelect: (selected: boolean, isShiftKey?: boolean) => void;
selected: boolean | null; selected: boolean | null;
} }
@@ -41,15 +42,20 @@ export const ChapterCard: React.FC<IProps> = (props: IProps) => {
const { chapter, allChapters, downloadChapter: dc, showChapterNumber, onSelect, selected } = props; const { chapter, allChapters, downloadChapter: dc, showChapterNumber, onSelect, selected } = props;
const isSelecting = selected !== null; const isSelecting = selected !== null;
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => { const { isDownloaded } = chapter;
if (isSelecting) {
event.preventDefault(); const handleClick = (e: MouseEvent | TouchEvent) => {
event.stopPropagation(); if (!isSelecting) return;
onSelect(!selected);
} e.preventDefault();
e.stopPropagation();
onSelect(!selected, e.shiftKey);
}; };
const { isDownloaded } = chapter; const longPressBind = useLongPress((e) => {
e.shiftKey = true;
handleClick(e);
});
return ( return (
<li> <li>
@@ -86,7 +92,8 @@ export const ChapterCard: React.FC<IProps> = (props: IProps) => {
style={{ style={{
color: theme.palette.text[chapter.isRead ? 'disabled' : 'primary'], color: theme.palette.text[chapter.isRead ? 'disabled' : 'primary'],
}} }}
onClick={handleClick} onClick={(e) => handleClick(e)}
{...longPressBind()}
> >
<CardContent <CardContent
sx={{ sx={{

View File

@@ -74,8 +74,10 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
const { data: chaptersData, loading: isLoading } = requestManager.useGetMangaChapters(manga.id); const { data: chaptersData, loading: isLoading } = requestManager.useGetMangaChapters(manga.id);
const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]); const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]);
const chapterIds = useMemo(() => chapters.map((chapter) => chapter.id), [chapters]);
const { areNoItemsSelected, areAllItemsSelected, selectedItemIds, handleSelectAll, handleSelection } = const { areNoItemsSelected, areAllItemsSelected, selectedItemIds, handleSelectAll, handleSelection } =
useSelectableCollection(chapters.length, { currentKey: 'default' }); useSelectableCollection(chapters.length, { itemIds: chapterIds, currentKey: 'default' });
const visibleChapters = useMemo(() => filterAndSortChapters(chapters, options), [chapters, options]); const visibleChapters = useMemo(() => filterAndSortChapters(chapters, options), [chapters, options]);
@@ -202,7 +204,9 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
{...chaptersWithMeta[index]} {...chaptersWithMeta[index]}
allChapters={chapters} allChapters={chapters}
showChapterNumber={options.showChapterNumber} showChapterNumber={options.showChapterNumber}
onSelect={(selected) => handleSelection(chaptersWithMeta[index].chapter.id, selected)} onSelect={(selected, selectRange) =>
handleSelection(chaptersWithMeta[index].chapter.id, selected, { selectRange })
}
/> />
)} )}
useWindowScroll={window.innerWidth < 900} useWindowScroll={window.innerWidth < 900}

View File

@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { useState } from 'react'; import { useRef, useState } from 'react';
export type SelectableCollectionReturnType<Id extends number | string, Key extends string = string> = { export type SelectableCollectionReturnType<Id extends number | string, Key extends string = string> = {
selectedItemIds: Id[]; selectedItemIds: Id[];
@@ -15,9 +15,9 @@ export type SelectableCollectionReturnType<Id extends number | string, Key exten
areNoItemsSelected: boolean; areNoItemsSelected: boolean;
areAllItemsForKeySelected: boolean; areAllItemsForKeySelected: boolean;
areNoItemsForKeySelected: boolean; areNoItemsForKeySelected: boolean;
handleSelection: (id: Id, selected: boolean, key?: Key) => void; handleSelection: (id: Id, selected: boolean, options?: { selectRange?: boolean; key?: Key }) => void;
handleSelectAll: (selectAll: boolean, itemIds: Id[], key?: Key) => void; handleSelectAll: (selectAll: boolean, ids: Id[], key?: Key) => void;
setSelectionForKey: (key: Key, itemIds: Id[]) => void; setSelectionForKey: (key: Key, ids: Id[]) => void;
getSelectionForKey: (key: Key) => Id[]; getSelectionForKey: (key: Key) => Id[];
clearSelection: () => void; clearSelection: () => void;
}; };
@@ -25,10 +25,12 @@ export type SelectableCollectionReturnType<Id extends number | string, Key exten
export const useSelectableCollection = <Id extends number | string, Key extends string = 'default'>( export const useSelectableCollection = <Id extends number | string, Key extends string = 'default'>(
totalCount: number, totalCount: number,
{ {
itemIds = [],
keyCount = totalCount, keyCount = totalCount,
currentKey, currentKey,
initialState = {} as Record<Key, Id[]>, initialState = {} as Record<Key, Id[]>,
}: { }: {
itemIds?: Id[];
keyCount?: number; keyCount?: number;
currentKey: Key; currentKey: Key;
initialState?: Record<Key, Id[]>; initialState?: Record<Key, Id[]>;
@@ -36,6 +38,8 @@ export const useSelectableCollection = <Id extends number | string, Key extends
): SelectableCollectionReturnType<Id, Key> => { ): SelectableCollectionReturnType<Id, Key> => {
const [keyToSelectedItemIds, setKeyToSelectedItemIds] = useState<Record<string, Id[]>>(initialState); const [keyToSelectedItemIds, setKeyToSelectedItemIds] = useState<Record<string, Id[]>>(initialState);
const lastSelectedItemInfoRef = useRef<{ id: Id; key: Key }>();
const selectedItemIds = [...new Set(Object.values(keyToSelectedItemIds).flat())]; const selectedItemIds = [...new Set(Object.values(keyToSelectedItemIds).flat())];
const areAllItemsSelected = selectedItemIds.length === totalCount; const areAllItemsSelected = selectedItemIds.length === totalCount;
const areNoItemsSelected = !selectedItemIds.length; const areNoItemsSelected = !selectedItemIds.length;
@@ -44,28 +48,52 @@ export const useSelectableCollection = <Id extends number | string, Key extends
const areAllItemsForKeySelected = keySelectedItemIds.length === keyCount; const areAllItemsForKeySelected = keySelectedItemIds.length === keyCount;
const areNoItemsForKeySelected = keySelectedItemIds.length === 0; const areNoItemsForKeySelected = keySelectedItemIds.length === 0;
const handleSelection = (id: Id, selected: boolean, key: Key = currentKey) => { if (areNoItemsForKeySelected) {
lastSelectedItemInfoRef.current = undefined;
}
const handleSelection: SelectableCollectionReturnType<Id, Key>['handleSelection'] = (
id,
selected,
{ selectRange = false, key = currentKey } = {},
) => {
const deselect = !selected; const deselect = !selected;
const { id: lastSelectedItemId, key: lastSelectedItemIdKey } = lastSelectedItemInfoRef.current ?? {};
lastSelectedItemInfoRef.current = { id, key };
const isSelectRange = selectRange && key === lastSelectedItemIdKey && lastSelectedItemId !== undefined;
const indexOfLastSelectedItemId = isSelectRange ? itemIds.indexOf(lastSelectedItemId) : -1;
const indexOfSelectedId = isSelectRange ? itemIds.indexOf(id) : -1;
const selectedIds = isSelectRange
? itemIds.slice(
Math.min(indexOfLastSelectedItemId, indexOfSelectedId),
Math.max(indexOfLastSelectedItemId, indexOfSelectedId) + 1,
)
: [id];
if (deselect) { if (deselect) {
setKeyToSelectedItemIds((prevState) => ({ setKeyToSelectedItemIds((prevState) => ({
...prevState, ...prevState,
[key]: prevState[key]?.filter((selectedItemId) => selectedItemId !== id) ?? [], [key]: prevState[key]?.filter((selectedItemId) => !selectedIds.includes(selectedItemId)) ?? [],
})); }));
return; return;
} }
setKeyToSelectedItemIds((prevState) => ({ setKeyToSelectedItemIds((prevState) => ({
...prevState, ...prevState,
[key]: [...new Set([...(prevState[key] ?? []), id])], [key]: [...new Set([...(prevState[key] ?? []), ...selectedIds])],
})); }));
}; };
const handleSelectAll = (selectAll: boolean, itemIds: Id[], key: Key = currentKey) => { const handleSelectAll = (selectAll: boolean, ids: Id[], key: Key = currentKey) => {
switch (selectAll) { switch (selectAll) {
case true: case true:
setKeyToSelectedItemIds((prevState) => ({ setKeyToSelectedItemIds((prevState) => ({
...prevState, ...prevState,
[key]: [...itemIds], [key]: [...ids],
})); }));
break; break;
case false: case false:
@@ -79,10 +107,10 @@ export const useSelectableCollection = <Id extends number | string, Key extends
} }
}; };
const setSelectionForKey = (key: Key, itemIds: Id[]) => { const setSelectionForKey = (key: Key, ids: Id[]) => {
setKeyToSelectedItemIds((prevState) => ({ setKeyToSelectedItemIds((prevState) => ({
...prevState, ...prevState,
[key]: [...itemIds], [key]: [...ids],
})); }));
}; };

View File

@@ -181,15 +181,15 @@ export function CategorySelect(props: Props) {
isSingleSelectionMode, isSingleSelectionMode,
)} )}
onChange={(checked) => { onChange={(checked) => {
handleSelection(category.id, false, 'categoriesToAdd'); handleSelection(category.id, false, { key: 'categoriesToAdd' });
handleSelection(category.id, false, 'categoriesToRemove'); handleSelection(category.id, false, { key: 'categoriesToRemove' });
if (checked) { if (checked) {
handleSelection(category.id, true, 'categoriesToAdd'); handleSelection(category.id, true, { key: 'categoriesToAdd' });
} }
if (checked === false) { if (checked === false) {
handleSelection(category.id, true, 'categoriesToRemove'); handleSelection(category.id, true, { key: 'categoriesToRemove' });
} }
}} }}
label={category.name} label={category.name}

View File

@@ -68,6 +68,8 @@ export function Library() {
const categoryMangas = categoryMangaResponse?.mangas.nodes ?? []; const categoryMangas = categoryMangaResponse?.mangas.nodes ?? [];
const { visibleMangas: mangas, showFilteredOutMessage } = useGetVisibleLibraryMangas(categoryMangas); const { visibleMangas: mangas, showFilteredOutMessage } = useGetVisibleLibraryMangas(categoryMangas);
const mangaIds = useMemo(() => mangas.map((manga) => manga.id), [mangas]);
const [isSelectModeActive, setIsSelectModeActive] = useState(false); const [isSelectModeActive, setIsSelectModeActive] = useState(false);
const { const {
areNoItemsForKeySelected: areNoItemsSelected, areNoItemsForKeySelected: areNoItemsSelected,
@@ -76,11 +78,14 @@ export function Library() {
handleSelectAll, handleSelectAll,
handleSelection, handleSelection,
clearSelection, clearSelection,
} = useSelectableCollection<TManga['id'], string>(mangas.length, { currentKey: activeTab?.id.toString() }); } = useSelectableCollection<TManga['id'], string>(mangas.length, {
itemIds: mangaIds,
currentKey: activeTab?.id.toString(),
});
const handleSelect = (id: number, selected: boolean) => { const handleSelect: typeof handleSelection = (id, selected, selectOptions) => {
setIsSelectModeActive(!!(selectedItemIds.length + (selected ? 1 : -1))); setIsSelectModeActive(!!(selectedItemIds.length + (selected ? 1 : -1)));
handleSelection(id, selected); handleSelection(id, selected, selectOptions);
}; };
const selectedMangas = useMemo( const selectedMangas = useMemo(

View File

@@ -6703,6 +6703,11 @@ urlpattern-polyfill@^8.0.0:
resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz#99f096e35eff8bf4b5a2aa7d58a1523d6ebc7ce5" resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz#99f096e35eff8bf4b5a2aa7d58a1523d6ebc7ce5"
integrity sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ== integrity sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==
use-long-press@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/use-long-press/-/use-long-press-3.2.0.tgz#87696a782b7059a1eadf93246b1238ba4eb43ae3"
integrity sha512-uq5o2qFR1VRjHn8Of7Fl344/AGvgk7C5Mcb4aSb1ZRVp6PkgdXJJLdRrlSTJQVkkQcDuqFbFc3mDX4COg7mRTA==
use-memo-one@^1.1.1: use-memo-one@^1.1.1:
version "1.1.3" version "1.1.3"
resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99" resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99"