From ecd9b7478b19b6f1fa05d684af1330a5a56c8f86 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Fri, 5 Apr 2024 02:03:00 +0200 Subject: [PATCH] Add range selection (#707) --- package.json | 1 + src/components/MangaCard.tsx | 32 ++++++++---- src/components/chapter/ChapterCard.tsx | 27 ++++++---- src/components/chapter/ChapterList.tsx | 8 ++- .../collection/useSelectableCollection.ts | 50 +++++++++++++++---- .../navbar/action/CategorySelect.tsx | 8 +-- src/screens/Library.tsx | 11 ++-- yarn.lock | 5 ++ 8 files changed, 101 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index a1e30d0a..7555738c 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "react-i18next": "^14.1.0", "react-router-dom": "^6.22.3", "react-virtuoso": "^4.7.7", + "use-long-press": "^3.2.0", "use-query-params": "^2.2.1", "vite": "^5.2.7", "vite-tsconfig-paths": "^4.3.2" diff --git a/src/components/MangaCard.tsx b/src/components/MangaCard.tsx index e996a3a2..b464e807 100644 --- a/src/components/MangaCard.tsx +++ b/src/components/MangaCard.tsx @@ -14,6 +14,7 @@ import { Avatar, Box, CardContent, Stack, styled, Tooltip } from '@mui/material' import { useTranslation } from 'react-i18next'; import PopupState, { bindMenu } from 'material-ui-popup-state'; import { useState } from 'react'; +import { useLongPress } from 'use-long-press'; import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import { SpinnerImage } from '@/components/util/SpinnerImage'; import { TManga, TPartialManga } from '@/typings.ts'; @@ -116,6 +117,20 @@ export const MangaCard = (props: MangaCardProps) => { 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) { return ( <> @@ -126,12 +141,13 @@ export const MangaCard = (props: MangaCardProps) => { {(popupState) => ( <> { const isMigrateSelectMode = mode === 'migrate.select'; const isSelectionMode = selected !== null; - const handleClick = isMigrateSelectMode || isSelectionMode; - if (!handleClick) { + const shouldHandleClick = isMigrateSelectMode || isSelectionMode; + if (!shouldHandleClick) { return; } @@ -142,7 +158,7 @@ export const MangaCard = (props: MangaCardProps) => { return; } - handleSelection?.(id, !selected); + handleClick(e); }} to={mangaLinkTo} style={{ textDecoration: 'none' }} @@ -334,14 +350,8 @@ export const MangaCard = (props: MangaCardProps) => { { - if (selected === null) { - return; - } - - e.preventDefault(); - handleSelection?.(id, !selected); - }} + onClick={handleClick} + {...longPressBind()} > void; + onSelect: (selected: boolean, isShiftKey?: boolean) => void; selected: boolean | null; } @@ -41,15 +42,20 @@ export const ChapterCard: React.FC = (props: IProps) => { const { chapter, allChapters, downloadChapter: dc, showChapterNumber, onSelect, selected } = props; const isSelecting = selected !== null; - const handleClick = (event: React.MouseEvent) => { - if (isSelecting) { - event.preventDefault(); - event.stopPropagation(); - onSelect(!selected); - } + const { isDownloaded } = chapter; + + const handleClick = (e: MouseEvent | TouchEvent) => { + if (!isSelecting) return; + + e.preventDefault(); + e.stopPropagation(); + onSelect(!selected, e.shiftKey); }; - const { isDownloaded } = chapter; + const longPressBind = useLongPress((e) => { + e.shiftKey = true; + handleClick(e); + }); return (
  • @@ -86,7 +92,8 @@ export const ChapterCard: React.FC = (props: IProps) => { style={{ color: theme.palette.text[chapter.isRead ? 'disabled' : 'primary'], }} - onClick={handleClick} + onClick={(e) => handleClick(e)} + {...longPressBind()} > = ({ manga, isRefreshing }) => { const { data: chaptersData, loading: isLoading } = requestManager.useGetMangaChapters(manga.id); const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]); + const chapterIds = useMemo(() => chapters.map((chapter) => chapter.id), [chapters]); + 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]); @@ -202,7 +204,9 @@ export const ChapterList: React.FC = ({ manga, isRefreshing }) => { {...chaptersWithMeta[index]} allChapters={chapters} 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} diff --git a/src/components/collection/useSelectableCollection.ts b/src/components/collection/useSelectableCollection.ts index bebf0090..4d54f82f 100644 --- a/src/components/collection/useSelectableCollection.ts +++ b/src/components/collection/useSelectableCollection.ts @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { useState } from 'react'; +import { useRef, useState } from 'react'; export type SelectableCollectionReturnType = { selectedItemIds: Id[]; @@ -15,9 +15,9 @@ export type SelectableCollectionReturnType void; - handleSelectAll: (selectAll: boolean, itemIds: Id[], key?: Key) => void; - setSelectionForKey: (key: Key, itemIds: Id[]) => void; + handleSelection: (id: Id, selected: boolean, options?: { selectRange?: boolean; key?: Key }) => void; + handleSelectAll: (selectAll: boolean, ids: Id[], key?: Key) => void; + setSelectionForKey: (key: Key, ids: Id[]) => void; getSelectionForKey: (key: Key) => Id[]; clearSelection: () => void; }; @@ -25,10 +25,12 @@ export type SelectableCollectionReturnType( totalCount: number, { + itemIds = [], keyCount = totalCount, currentKey, initialState = {} as Record, }: { + itemIds?: Id[]; keyCount?: number; currentKey: Key; initialState?: Record; @@ -36,6 +38,8 @@ export const useSelectableCollection = => { const [keyToSelectedItemIds, setKeyToSelectedItemIds] = useState>(initialState); + const lastSelectedItemInfoRef = useRef<{ id: Id; key: Key }>(); + const selectedItemIds = [...new Set(Object.values(keyToSelectedItemIds).flat())]; const areAllItemsSelected = selectedItemIds.length === totalCount; const areNoItemsSelected = !selectedItemIds.length; @@ -44,28 +48,52 @@ export const useSelectableCollection = { + if (areNoItemsForKeySelected) { + lastSelectedItemInfoRef.current = undefined; + } + + const handleSelection: SelectableCollectionReturnType['handleSelection'] = ( + id, + selected, + { selectRange = false, key = currentKey } = {}, + ) => { 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) { setKeyToSelectedItemIds((prevState) => ({ ...prevState, - [key]: prevState[key]?.filter((selectedItemId) => selectedItemId !== id) ?? [], + [key]: prevState[key]?.filter((selectedItemId) => !selectedIds.includes(selectedItemId)) ?? [], })); return; } setKeyToSelectedItemIds((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) { case true: setKeyToSelectedItemIds((prevState) => ({ ...prevState, - [key]: [...itemIds], + [key]: [...ids], })); break; case false: @@ -79,10 +107,10 @@ export const useSelectableCollection = { + const setSelectionForKey = (key: Key, ids: Id[]) => { setKeyToSelectedItemIds((prevState) => ({ ...prevState, - [key]: [...itemIds], + [key]: [...ids], })); }; diff --git a/src/components/navbar/action/CategorySelect.tsx b/src/components/navbar/action/CategorySelect.tsx index 8cabb3dc..ce5419f7 100644 --- a/src/components/navbar/action/CategorySelect.tsx +++ b/src/components/navbar/action/CategorySelect.tsx @@ -181,15 +181,15 @@ export function CategorySelect(props: Props) { isSingleSelectionMode, )} onChange={(checked) => { - handleSelection(category.id, false, 'categoriesToAdd'); - handleSelection(category.id, false, 'categoriesToRemove'); + handleSelection(category.id, false, { key: 'categoriesToAdd' }); + handleSelection(category.id, false, { key: 'categoriesToRemove' }); if (checked) { - handleSelection(category.id, true, 'categoriesToAdd'); + handleSelection(category.id, true, { key: 'categoriesToAdd' }); } if (checked === false) { - handleSelection(category.id, true, 'categoriesToRemove'); + handleSelection(category.id, true, { key: 'categoriesToRemove' }); } }} label={category.name} diff --git a/src/screens/Library.tsx b/src/screens/Library.tsx index 46cdfd2b..8d0d82d4 100644 --- a/src/screens/Library.tsx +++ b/src/screens/Library.tsx @@ -68,6 +68,8 @@ export function Library() { const categoryMangas = categoryMangaResponse?.mangas.nodes ?? []; const { visibleMangas: mangas, showFilteredOutMessage } = useGetVisibleLibraryMangas(categoryMangas); + const mangaIds = useMemo(() => mangas.map((manga) => manga.id), [mangas]); + const [isSelectModeActive, setIsSelectModeActive] = useState(false); const { areNoItemsForKeySelected: areNoItemsSelected, @@ -76,11 +78,14 @@ export function Library() { handleSelectAll, handleSelection, clearSelection, - } = useSelectableCollection(mangas.length, { currentKey: activeTab?.id.toString() }); + } = useSelectableCollection(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))); - handleSelection(id, selected); + handleSelection(id, selected, selectOptions); }; const selectedMangas = useMemo( diff --git a/yarn.lock b/yarn.lock index ecab4391..dee59240 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6703,6 +6703,11 @@ urlpattern-polyfill@^8.0.0: resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz#99f096e35eff8bf4b5a2aa7d58a1523d6ebc7ce5" 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: version "1.1.3" resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99"