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"