diff --git a/src/modules/collection/hooks/useSelectableCollection.ts b/src/modules/collection/hooks/useSelectableCollection.ts index 4d54f82f..979f6f03 100644 --- a/src/modules/collection/hooks/useSelectableCollection.ts +++ b/src/modules/collection/hooks/useSelectableCollection.ts @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; export type SelectableCollectionReturnType = { selectedItemIds: Id[]; @@ -52,73 +52,75 @@ export const useSelectableCollection = ['handleSelection'] = ( - id, - selected, - { selectRange = false, key = currentKey } = {}, - ) => { - const deselect = !selected; + const handleSelection: SelectableCollectionReturnType['handleSelection'] = useCallback( + (id, selected, { selectRange = false, key = currentKey } = {}) => { + const deselect = !selected; - const { id: lastSelectedItemId, key: lastSelectedItemIdKey } = lastSelectedItemInfoRef.current ?? {}; - lastSelectedItemInfoRef.current = { id, key }; + const { id: lastSelectedItemId, key: lastSelectedItemIdKey } = lastSelectedItemInfoRef.current ?? {}; + lastSelectedItemInfoRef.current = { id, key }; - const isSelectRange = selectRange && key === lastSelectedItemIdKey && lastSelectedItemId !== undefined; + const isSelectRange = selectRange && key === lastSelectedItemIdKey && lastSelectedItemId !== undefined; - const indexOfLastSelectedItemId = isSelectRange ? itemIds.indexOf(lastSelectedItemId) : -1; - const indexOfSelectedId = isSelectRange ? itemIds.indexOf(id) : -1; + 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]; + 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) => !selectedIds.includes(selectedItemId)) ?? [], + })); + return; + } - if (deselect) { setKeyToSelectedItemIds((prevState) => ({ ...prevState, - [key]: prevState[key]?.filter((selectedItemId) => !selectedIds.includes(selectedItemId)) ?? [], + [key]: [...new Set([...(prevState[key] ?? []), ...selectedIds])], })); - return; - } + }, + [currentKey, itemIds], + ); - setKeyToSelectedItemIds((prevState) => ({ - ...prevState, - [key]: [...new Set([...(prevState[key] ?? []), ...selectedIds])], - })); - }; + const handleSelectAll = useCallback( + (selectAll: boolean, ids: Id[], key: Key = currentKey) => { + switch (selectAll) { + case true: + setKeyToSelectedItemIds((prevState) => ({ + ...prevState, + [key]: [...ids], + })); + break; + case false: + setKeyToSelectedItemIds((prevState) => ({ + ...prevState, + [key]: [], + })); + break; + default: + break; + } + }, + [currentKey], + ); - const handleSelectAll = (selectAll: boolean, ids: Id[], key: Key = currentKey) => { - switch (selectAll) { - case true: - setKeyToSelectedItemIds((prevState) => ({ - ...prevState, - [key]: [...ids], - })); - break; - case false: - setKeyToSelectedItemIds((prevState) => ({ - ...prevState, - [key]: [], - })); - break; - default: - break; - } - }; - - const setSelectionForKey = (key: Key, ids: Id[]) => { + const setSelectionForKey = useCallback((key: Key, ids: Id[]) => { setKeyToSelectedItemIds((prevState) => ({ ...prevState, [key]: [...ids], })); - }; + }, []); - const getSelectionForKey = (key: Key) => keyToSelectedItemIds[key]; + const getSelectionForKey = useCallback((key: Key) => keyToSelectedItemIds[key], [keyToSelectedItemIds]); - const clearSelection = () => { + const clearSelection = useCallback(() => { setKeyToSelectedItemIds({}); - }; + }, []); return { selectedItemIds, diff --git a/src/modules/library/components/LibraryMangaGrid.tsx b/src/modules/library/components/LibraryMangaGrid.tsx index f5f0aae3..ed99805d 100644 --- a/src/modules/library/components/LibraryMangaGrid.tsx +++ b/src/modules/library/components/LibraryMangaGrid.tsx @@ -19,6 +19,8 @@ interface LibraryMangaGridProps isLoading: boolean; } +const loadMoreNoop = () => undefined; + export const LibraryMangaGrid: React.FC = ({ showFilteredOutMessage, message, @@ -41,7 +43,7 @@ export const LibraryMangaGrid: React.FC = ({ gridWrapperProps={{ sx: { p: 1 } }} {...gridProps} hasNextPage={false} - loadMore={() => undefined} + loadMore={loadMoreNoop} message={showFilteredOutMessage ? t('library.error.label.no_matches') : message} messageExtra={showFilteredOutMessage ? undefined : messageExtra} gridLayout={options.gridLayout} diff --git a/src/modules/library/screens/Library.tsx b/src/modules/library/screens/Library.tsx index 79fb98be..7ad7de86 100644 --- a/src/modules/library/screens/Library.tsx +++ b/src/modules/library/screens/Library.tsx @@ -122,10 +122,13 @@ export function Library() { currentKey: activeTab?.id.toString(), }); - const handleSelect: typeof handleSelection = (id, selected, selectOptions) => { - setIsSelectModeActive(!!(selectedItemIds.length + (selected ? 1 : -1))); - handleSelection(id, selected, selectOptions); - }; + const handleSelect: typeof handleSelection = useCallback( + (id, selected, selectOptions) => { + setIsSelectModeActive(!!(selectedItemIds.length + (selected ? 1 : -1))); + handleSelection(id, selected, selectOptions); + }, + [setIsSelectModeActive, handleSelection], + ); const selectedMangas = useMemo( () => @@ -212,7 +215,6 @@ export function Library() { isSelectModeActive, areNoItemsSelected, areAllItemsSelected, - selectedItemIds.length, mangas.length, activeTab, showTabSize, diff --git a/src/modules/manga/components/cards/MangaCard.tsx b/src/modules/manga/components/cards/MangaCard.tsx index bec88d9b..f772dfa8 100644 --- a/src/modules/manga/components/cards/MangaCard.tsx +++ b/src/modules/manga/components/cards/MangaCard.tsx @@ -7,7 +7,7 @@ */ import PopupState, { bindMenu } from 'material-ui-popup-state'; -import { useMemo, useState } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { useLongPress } from 'use-long-press'; import { MangaActionMenuItems, SingleModeProps } from '@/modules/manga/components/MangaActionMenuItems.tsx'; import { Menu } from '@/modules/core/components/menu/Menu.tsx'; @@ -42,7 +42,7 @@ const getMangaLinkTo = ( } }; -export const MangaCard = (props: MangaCardProps) => { +export const MangaCard = memo((props: MangaCardProps) => { const { manga, gridLayout, inLibraryIndicator, selected, handleSelection, mode = 'default' } = props; const { id, firstUnreadChapter, downloadCount, unreadCount } = manga; const { @@ -58,76 +58,59 @@ export const MangaCard = (props: MangaCardProps) => { const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false); - const handleClick = (event: React.MouseEvent | React.TouchEvent, openMenu?: () => void) => { - const isDefaultMode = mode === 'default'; - const isSourceMode = mode === 'source'; - const isMigrateSelectMode = mode === 'migrate.select'; - const isSelectionMode = selected !== null; - const isLongPress = !!openMenu; + const handleClick = useCallback( + (event: React.MouseEvent | React.TouchEvent, openMenu?: () => void) => { + const isDefaultMode = mode === 'default'; + const isSourceMode = mode === 'source'; + const isMigrateSelectMode = mode === 'migrate.select'; + const isSelectionMode = selected !== null; + const isLongPress = !!openMenu; - const shouldHandleClick = - isMigrateSelectMode || isSelectionMode || ((isDefaultMode || isSourceMode) && isLongPress); - if (!shouldHandleClick) { - return; - } + const shouldHandleClick = + isMigrateSelectMode || isSelectionMode || ((isDefaultMode || isSourceMode) && isLongPress); + if (!shouldHandleClick) { + return; + } - event.preventDefault(); + event.preventDefault(); - if (isSourceMode) { - updateLibraryState(); - return; - } + if (isSourceMode) { + updateLibraryState(); + return; + } - if (isSelectionMode) { - handleSelection?.(id, !selected, { selectRange: event.shiftKey }); - return; - } + if (isSelectionMode) { + handleSelection?.(id, !selected, { selectRange: event.shiftKey }); + return; + } - if (isDefaultMode) { - openMenu?.(); - return; - } + if (isDefaultMode) { + openMenu?.(); + return; + } - if (isMigrateSelectMode) { - setIsMigrateDialogOpen(true); - } - }; + if (isMigrateSelectMode) { + setIsMigrateDialogOpen(true); + } + }, + [mode, selected, updateLibraryState, handleSelection], + ); - const longPressBind = useLongPress((e, { context }) => { - e.shiftKey = true; - handleClick(e, context as () => {}); - }); + const longPressBind = useLongPress( + useCallback( + (e: any, { context }: any) => { + e.shiftKey = true; + handleClick(e, context as () => {}); + }, + [handleClick], + ), + ); const MangaCardComponent = useMemo( () => (gridLayout === GridLayout.List ? MangaListCard : MangaGridCard), [gridLayout], ); - const continueReadingButton = useMemo( - () => ( - - ), - [showContinueReadingButton, firstUnreadChapter, mangaLinkTo], - ); - - const mangaBadges = useMemo( - () => ( - - ), - [inLibraryIndicator, isInLibrary, unreadCount, downloadCount, updateLibraryState], - ); - return ( <> {isMigrateDialogOpen && ( @@ -144,8 +127,23 @@ export const MangaCard = (props: MangaCardProps) => { mangaLinkTo={mangaLinkTo} isInLibrary={isInLibrary} inLibraryIndicator={inLibraryIndicator} - continueReadingButton={continueReadingButton} - mangaBadges={mangaBadges} + continueReadingButton={ + + } + mangaBadges={ + + } /> {!!handleSelection && popupState.isOpen && ( @@ -165,4 +163,4 @@ export const MangaCard = (props: MangaCardProps) => { ); -}; +}); diff --git a/src/modules/manga/components/cards/MangaGridCard.tsx b/src/modules/manga/components/cards/MangaGridCard.tsx index d7f1481b..332da8ef 100644 --- a/src/modules/manga/components/cards/MangaGridCard.tsx +++ b/src/modules/manga/components/cards/MangaGridCard.tsx @@ -14,7 +14,7 @@ import CardActionArea from '@mui/material/CardActionArea'; import Stack from '@mui/material/Stack'; import Tooltip from '@mui/material/Tooltip'; import { styled } from '@mui/material/styles'; -import { useRef } from 'react'; +import { memo, useRef } from 'react'; import { SpinnerImage } from '@/modules/core/components/SpinnerImage.tsx'; import { MangaOptionButton } from '@/modules/manga/components/MangaOptionButton.tsx'; import { Mangas } from '@/modules/manga/services/Mangas.ts'; @@ -39,166 +39,169 @@ const BottomGradientDoubledDown = styled('div')({ background: 'linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 100%)', }); -export const MangaGridCard = ({ - manga, - longPressBind, - popupState, - handleClick, - mangaLinkTo, - selected, - inLibraryIndicator, - isInLibrary, - gridLayout, - handleSelection, - continueReadingButton, - mangaBadges, - mode, -}: SpecificMangaCardProps) => { - const optionButtonRef = useRef(null); +export const MangaGridCard = memo( + ({ + manga, + longPressBind, + popupState, + handleClick, + mangaLinkTo, + selected, + inLibraryIndicator, + isInLibrary, + gridLayout, + handleSelection, + continueReadingButton, + mangaBadges, + mode, + }: SpecificMangaCardProps) => { + const optionButtonRef = useRef(null); - const { id, title } = manga; + const { id, title } = manga; - return ( - popupState.open(optionButtonRef.current))} - onClick={handleClick} - to={mangaLinkTo} - state={{ mangaTitle: title }} - sx={{ textDecoration: 'none', touchCallout: 'none' }} - > - theme.palette.primary.main, - backgroundColor: (theme) => (selected ? theme.palette.primary.main : undefined), - '@media (hover: hover) and (pointer: fine)': { - '&:hover .manga-option-button': { - visibility: 'visible', - pointerEvents: 'all', - }, - '&:hover .source-manga-library-state-button': { - display: 'inline-flex', - }, - '&:hover .source-manga-library-state-indicator': { - display: mode === 'source' ? 'none' : 'flex', - }, - }, - }} + return ( + popupState.open(optionButtonRef.current))} + onClick={handleClick} + to={mangaLinkTo} + state={{ mangaTitle: title }} + sx={{ textDecoration: 'none', touchCallout: 'none' }} > - theme.palette.primary.main, + backgroundColor: (theme) => (selected ? theme.palette.primary.main : undefined), + '@media (hover: hover) and (pointer: fine)': { + '&:hover .manga-option-button': { + visibility: 'visible', + pointerEvents: 'all', + }, + '&:hover .source-manga-library-state-button': { + display: 'inline-flex', + }, + '&:hover .source-manga-library-state-indicator': { + display: mode === 'source' ? 'none' : 'flex', + }, + }, }} > - - - theme.spacing(1), - left: (theme) => theme.spacing(1), - right: (theme) => theme.spacing(1), + position: 'relative', + height: '100%', }} > - {mangaBadges} - - - <> - {gridLayout !== GridLayout.Comfortable && ( - <> - - - - )} theme.spacing(1), + left: (theme) => theme.spacing(1), + right: (theme) => theme.spacing(1), }} > - {gridLayout !== GridLayout.Comfortable && ( - - - {title} - - - )} - {continueReadingButton} + {mangaBadges} + - - - - {gridLayout === GridLayout.Comfortable && ( - - - (selected ? theme.palette.primary.contrastText : 'text.primary'), - height: '3rem', - pt: 0.5, - }} - > - {title} - - - - )} - - - ); -}; + <> + {gridLayout !== GridLayout.Comfortable && ( + <> + + + + )} + + {gridLayout !== GridLayout.Comfortable && ( + + + {title} + + + )} + {continueReadingButton} + + + + + {gridLayout === GridLayout.Comfortable && ( + + + + selected ? theme.palette.primary.contrastText : 'text.primary', + height: '3rem', + pt: 0.5, + }} + > + {title} + + + + )} + + + ); + }, +); diff --git a/src/modules/manga/components/cards/MangaListCard.tsx b/src/modules/manga/components/cards/MangaListCard.tsx index da9c0166..e0561c57 100644 --- a/src/modules/manga/components/cards/MangaListCard.tsx +++ b/src/modules/manga/components/cards/MangaListCard.tsx @@ -14,120 +14,122 @@ import Box from '@mui/material/Box'; import Stack from '@mui/material/Stack'; import Tooltip from '@mui/material/Tooltip'; import { Link as RouterLink } from 'react-router-dom'; -import { useRef } from 'react'; +import { memo, useRef } from 'react'; import { SpinnerImage } from '@/modules/core/components/SpinnerImage.tsx'; import { TypographyMaxLines } from '@/modules/core/components/TypographyMaxLines.tsx'; import { SpecificMangaCardProps } from '@/modules/manga/Manga.types.ts'; import { Mangas } from '@/modules/manga/services/Mangas.ts'; import { MangaOptionButton } from '@/modules/manga/components/MangaOptionButton.tsx'; -export const MangaListCard = ({ - manga, - longPressBind, - popupState, - handleClick, - mangaLinkTo, - selected, - inLibraryIndicator, - isInLibrary, - handleSelection, - continueReadingButton, - mangaBadges, - mode, -}: SpecificMangaCardProps) => { - const optionButtonRef = useRef(null); +export const MangaListCard = memo( + ({ + manga, + longPressBind, + popupState, + handleClick, + mangaLinkTo, + selected, + inLibraryIndicator, + isInLibrary, + handleSelection, + continueReadingButton, + mangaBadges, + mode, + }: SpecificMangaCardProps) => { + const optionButtonRef = useRef(null); - const { id, title } = manga; + const { id, title } = manga; - return ( - - popupState.open(optionButtonRef.current))} - sx={{ - touchCallout: 'none', - '@media (hover: hover) and (pointer: fine)': { - '&:hover .manga-option-button': { - visibility: 'visible', - pointerEvents: 'all', - }, - '&:hover .source-manga-library-state-button': { - display: 'inline-flex', - }, - '&:hover .source-manga-library-state-indicator': { - display: mode === 'source' ? 'none' : 'inline-flex', - }, - }, - }} - > - + popupState.open(optionButtonRef.current))} sx={{ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - padding: 1.5, - position: 'relative', + touchCallout: 'none', + '@media (hover: hover) and (pointer: fine)': { + '&:hover .manga-option-button': { + visibility: 'visible', + pointerEvents: 'all', + }, + '&:hover .source-manga-library-state-button': { + display: 'inline-flex', + }, + '&:hover .source-manga-library-state-indicator': { + display: mode === 'source' ? 'none' : 'inline-flex', + }, + }, }} > - - - - - - - {title} - - - - - {mangaBadges} - {continueReadingButton} - - - - - - ); -}; + + + + + + + {title} + + + + + {mangaBadges} + {continueReadingButton} + + + + + + ); + }, +);