Improve manga grid/list rendering

This commit is contained in:
schroda
2025-01-13 01:29:40 +01:00
parent 63a6cfcfb3
commit 74c61a5a29
6 changed files with 372 additions and 363 deletions

View File

@@ -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<Id extends number | string, Key extends string = string> = {
selectedItemIds: Id[];
@@ -52,73 +52,75 @@ export const useSelectableCollection = <Id extends number | string, Key extends
lastSelectedItemInfoRef.current = undefined;
}
const handleSelection: SelectableCollectionReturnType<Id, Key>['handleSelection'] = (
id,
selected,
{ selectRange = false, key = currentKey } = {},
) => {
const deselect = !selected;
const handleSelection: SelectableCollectionReturnType<Id, Key>['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,

View File

@@ -19,6 +19,8 @@ interface LibraryMangaGridProps
isLoading: boolean;
}
const loadMoreNoop = () => undefined;
export const LibraryMangaGrid: React.FC<LibraryMangaGridProps> = ({
showFilteredOutMessage,
message,
@@ -41,7 +43,7 @@ export const LibraryMangaGrid: React.FC<LibraryMangaGridProps> = ({
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}

View File

@@ -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,

View File

@@ -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(
() => (
<ContinueReadingButton
showContinueReadingButton={showContinueReadingButton && mode === 'default'}
chapter={firstUnreadChapter}
mangaLinkTo={mangaLinkTo}
/>
),
[showContinueReadingButton, firstUnreadChapter, mangaLinkTo],
);
const mangaBadges = useMemo(
() => (
<MangaBadges
inLibraryIndicator={inLibraryIndicator}
isInLibrary={isInLibrary}
unread={unreadCount}
downloadCount={downloadCount}
updateLibraryState={updateLibraryState}
mode={mode}
/>
),
[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={
<ContinueReadingButton
showContinueReadingButton={showContinueReadingButton && mode === 'default'}
chapter={firstUnreadChapter}
mangaLinkTo={mangaLinkTo}
/>
}
mangaBadges={
<MangaBadges
inLibraryIndicator={inLibraryIndicator}
isInLibrary={isInLibrary}
unread={unreadCount}
downloadCount={downloadCount}
updateLibraryState={updateLibraryState}
mode={mode}
/>
}
/>
{!!handleSelection && popupState.isOpen && (
<Menu {...bindMenu(popupState)}>
@@ -165,4 +163,4 @@ export const MangaCard = (props: MangaCardProps) => {
</PopupState>
</>
);
};
});

View File

@@ -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<HTMLButtonElement>(null);
export const MangaGridCard = memo(
({
manga,
longPressBind,
popupState,
handleClick,
mangaLinkTo,
selected,
inLibraryIndicator,
isInLibrary,
gridLayout,
handleSelection,
continueReadingButton,
mangaBadges,
mode,
}: SpecificMangaCardProps) => {
const optionButtonRef = useRef<HTMLButtonElement>(null);
const { id, title } = manga;
const { id, title } = manga;
return (
<Link
component={RouterLink}
{...longPressBind(() => popupState.open(optionButtonRef.current))}
onClick={handleClick}
to={mangaLinkTo}
state={{ mangaTitle: title }}
sx={{ textDecoration: 'none', touchCallout: 'none' }}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
m: 0.25,
outline: selected ? '4px solid' : undefined,
borderRadius: selected ? '1px' : undefined,
outlineColor: (theme) => 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 (
<Link
component={RouterLink}
{...longPressBind(() => popupState.open(optionButtonRef.current))}
onClick={handleClick}
to={mangaLinkTo}
state={{ mangaTitle: title }}
sx={{ textDecoration: 'none', touchCallout: 'none' }}
>
<Card
<Box
sx={{
// force standard aspect ratio of manga covers
aspectRatio: MANGA_COVER_ASPECT_RATIO,
display: 'flex',
flexDirection: 'column',
m: 0.25,
outline: selected ? '4px solid' : undefined,
borderRadius: selected ? '1px' : undefined,
outlineColor: (theme) => 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',
},
},
}}
>
<CardActionArea
<Card
sx={{
position: 'relative',
height: '100%',
// force standard aspect ratio of manga covers
aspectRatio: MANGA_COVER_ASPECT_RATIO,
display: 'flex',
}}
>
<SpinnerImage
alt={title}
src={Mangas.getThumbnailUrl(manga)}
imgStyle={
inLibraryIndicator && isInLibrary
? {
height: '100%',
width: '100%',
objectFit: 'cover',
filter: 'brightness(0.4)',
}
: {
height: '100%',
width: '100%',
objectFit: 'cover',
}
}
spinnerStyle={{
display: 'grid',
placeItems: 'center',
}}
/>
<Stack
direction="row"
<CardActionArea
sx={{
alignItems: 'start',
justifyContent: 'space-between',
position: 'absolute',
top: (theme) => theme.spacing(1),
left: (theme) => theme.spacing(1),
right: (theme) => theme.spacing(1),
position: 'relative',
height: '100%',
}}
>
{mangaBadges}
<MangaOptionButton
ref={optionButtonRef}
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
<SpinnerImage
alt={title}
src={Mangas.getThumbnailUrl(manga)}
imgStyle={
inLibraryIndicator && isInLibrary
? {
height: '100%',
width: '100%',
objectFit: 'cover',
filter: 'brightness(0.4)',
}
: {
height: '100%',
width: '100%',
objectFit: 'cover',
}
}
spinnerStyle={{
display: 'grid',
placeItems: 'center',
}}
/>
</Stack>
<>
{gridLayout !== GridLayout.Comfortable && (
<>
<BottomGradient />
<BottomGradientDoubledDown />
</>
)}
<Stack
direction="row"
sx={{
justifyContent: gridLayout !== GridLayout.Comfortable ? 'space-between' : 'end',
alignItems: 'end',
alignItems: 'start',
justifyContent: 'space-between',
position: 'absolute',
bottom: 0,
width: '100%',
p: 1,
gap: 1,
top: (theme) => theme.spacing(1),
left: (theme) => theme.spacing(1),
right: (theme) => theme.spacing(1),
}}
>
{gridLayout !== GridLayout.Comfortable && (
<Tooltip title={title} placement="top">
<TypographyMaxLines
component="h3"
sx={{
color: 'white',
textShadow: '0px 0px 3px #000000',
}}
>
{title}
</TypographyMaxLines>
</Tooltip>
)}
{continueReadingButton}
{mangaBadges}
<MangaOptionButton
ref={optionButtonRef}
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
/>
</Stack>
</>
</CardActionArea>
</Card>
{gridLayout === GridLayout.Comfortable && (
<Stack sx={{ pb: 1 }}>
<Tooltip title={title} placement="top">
<TypographyMaxLines
component="h3"
sx={{
color: (theme) => (selected ? theme.palette.primary.contrastText : 'text.primary'),
height: '3rem',
pt: 0.5,
}}
>
{title}
</TypographyMaxLines>
</Tooltip>
</Stack>
)}
</Box>
</Link>
);
};
<>
{gridLayout !== GridLayout.Comfortable && (
<>
<BottomGradient />
<BottomGradientDoubledDown />
</>
)}
<Stack
direction="row"
sx={{
justifyContent: gridLayout !== GridLayout.Comfortable ? 'space-between' : 'end',
alignItems: 'end',
position: 'absolute',
bottom: 0,
width: '100%',
p: 1,
gap: 1,
}}
>
{gridLayout !== GridLayout.Comfortable && (
<Tooltip title={title} placement="top">
<TypographyMaxLines
component="h3"
sx={{
color: 'white',
textShadow: '0px 0px 3px #000000',
}}
>
{title}
</TypographyMaxLines>
</Tooltip>
)}
{continueReadingButton}
</Stack>
</>
</CardActionArea>
</Card>
{gridLayout === GridLayout.Comfortable && (
<Stack sx={{ pb: 1 }}>
<Tooltip title={title} placement="top">
<TypographyMaxLines
component="h3"
sx={{
color: (theme) =>
selected ? theme.palette.primary.contrastText : 'text.primary',
height: '3rem',
pt: 0.5,
}}
>
{title}
</TypographyMaxLines>
</Tooltip>
</Stack>
)}
</Box>
</Link>
);
},
);

View File

@@ -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<HTMLButtonElement>(null);
export const MangaListCard = memo(
({
manga,
longPressBind,
popupState,
handleClick,
mangaLinkTo,
selected,
inLibraryIndicator,
isInLibrary,
handleSelection,
continueReadingButton,
mangaBadges,
mode,
}: SpecificMangaCardProps) => {
const optionButtonRef = useRef<HTMLButtonElement>(null);
const { id, title } = manga;
const { id, title } = manga;
return (
<Card>
<CardActionArea
component={RouterLink}
to={mangaLinkTo}
state={{ mangaTitle: title }}
onClick={handleClick}
{...longPressBind(() => 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',
},
},
}}
>
<CardContent
return (
<Card>
<CardActionArea
component={RouterLink}
to={mangaLinkTo}
state={{ mangaTitle: title }}
onClick={handleClick}
{...longPressBind(() => 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',
},
},
}}
>
<Avatar
variant="rounded"
sx={{
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 1,
}}
>
<SpinnerImage
spinnerStyle={{ small: true }}
imgStyle={{
objectFit: 'cover',
width: '100%',
height: '100%',
imageRendering: 'pixelated',
filter: inLibraryIndicator && isInLibrary ? 'brightness(0.4)' : undefined,
}}
alt={manga.title}
src={Mangas.getThumbnailUrl(manga)}
/>
</Avatar>
<Box
<CardContent
sx={{
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
width: 'min-content',
}}
>
<Tooltip title={title} placement="top">
<TypographyMaxLines variant="h6" component="h3">
{title}
</TypographyMaxLines>
</Tooltip>
</Box>
<Stack
direction="row"
sx={{
justifyContent: 'space-between',
alignItems: 'center',
gap: 0.5,
padding: 1.5,
position: 'relative',
}}
>
{mangaBadges}
{continueReadingButton}
<MangaOptionButton
ref={optionButtonRef}
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
asCheckbox
/>
</Stack>
</CardContent>
</CardActionArea>
</Card>
);
};
<Avatar
variant="rounded"
sx={{
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 1,
}}
>
<SpinnerImage
spinnerStyle={{ small: true }}
imgStyle={{
objectFit: 'cover',
width: '100%',
height: '100%',
imageRendering: 'pixelated',
filter: inLibraryIndicator && isInLibrary ? 'brightness(0.4)' : undefined,
}}
alt={manga.title}
src={Mangas.getThumbnailUrl(manga)}
/>
</Avatar>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
width: 'min-content',
}}
>
<Tooltip title={title} placement="top">
<TypographyMaxLines variant="h6" component="h3">
{title}
</TypographyMaxLines>
</Tooltip>
</Box>
<Stack
direction="row"
sx={{
alignItems: 'center',
gap: 0.5,
}}
>
{mangaBadges}
{continueReadingButton}
<MangaOptionButton
ref={optionButtonRef}
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
asCheckbox
/>
</Stack>
</CardContent>
</CardActionArea>
</Card>
);
},
);