Properly set virtuoso item keys
This commit is contained in:
111
src/lib/virtuoso/Virtuoso.util.tsx
Normal file
111
src/lib/virtuoso/Virtuoso.util.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) Contributors to the Suwayomi project
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
export class VirtuosoUtil {
|
||||||
|
/**
|
||||||
|
* Returns the index converted to the index of the list of group or normal items.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
*
|
||||||
|
* index 1
|
||||||
|
* groupCounts [1, 3, 1]
|
||||||
|
*
|
||||||
|
* (GCS) groups content size 2 3 1
|
||||||
|
* (SOG) size of group (with header item) 3 7 9
|
||||||
|
*
|
||||||
|
* is group header item: SOG - index === GCS + 1
|
||||||
|
* is normal item : SOG - index < GCS + 1
|
||||||
|
*
|
||||||
|
* converted index:
|
||||||
|
* is group header
|
||||||
|
* ? group index
|
||||||
|
* : index - (group index + 1)
|
||||||
|
*
|
||||||
|
* group index 0
|
||||||
|
* 3 - 0 = 3 (group - index: 0)
|
||||||
|
* 3 - 1 = 2 (normal - index: 0 = 1 - (0 + 1))
|
||||||
|
* 3 - 2 = 1 (normal - index: 1 = 2 - (0 + 1))
|
||||||
|
*
|
||||||
|
* group index 1
|
||||||
|
* 7 - 3 = 4 (group - index: 1)
|
||||||
|
* 7 - 4 = 3 (normal - index: 2 = 4 - (1 + 1))
|
||||||
|
* 7 - 5 = 2 (normal - index: 3 = 5 - (1 + 1))
|
||||||
|
* 7 - 6 = 1 (normal - index: 4 = 6 - (1 + 1))
|
||||||
|
*
|
||||||
|
* group index 2
|
||||||
|
* 9 - 7 = 2 (group - index: 2)
|
||||||
|
* 9 - 8 = 1 (normal - index: 5 = 8 - (2 + 1))
|
||||||
|
*/
|
||||||
|
static convertIndex(
|
||||||
|
index: number,
|
||||||
|
groupCounts: number[],
|
||||||
|
sizeOfGroups: number[],
|
||||||
|
): { type: 'normal' | 'group'; index: number; groupIndex: number } {
|
||||||
|
for (let groupIndex = 0; groupIndex < groupCounts.length; groupIndex++) {
|
||||||
|
const groupCount = groupCounts[groupIndex];
|
||||||
|
const sizeOfGroup = sizeOfGroups[groupIndex];
|
||||||
|
|
||||||
|
const isIndexOfGroup = index <= sizeOfGroup - 1;
|
||||||
|
if (isIndexOfGroup) {
|
||||||
|
const isGroupHeaderItem = sizeOfGroup - index === groupCount + 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: isGroupHeaderItem ? 'group' : 'normal',
|
||||||
|
index: isGroupHeaderItem ? groupIndex : index - (groupIndex + 1),
|
||||||
|
groupIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected "${index}" (${index}) and "groupCounts" (${groupCounts})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static useCreateConvertIndex(
|
||||||
|
groupCounts: number[],
|
||||||
|
): (index: number) => ReturnType<typeof VirtuosoUtil.convertIndex> {
|
||||||
|
const sizeOfGroups = useMemo(() => {
|
||||||
|
const tmpSizeOfGroups: number[] = [];
|
||||||
|
for (let i = 0; i < groupCounts.length; i++) {
|
||||||
|
const maxIndexOfPreviousGroup = tmpSizeOfGroups[i - 1] ?? 0;
|
||||||
|
tmpSizeOfGroups[i] = groupCounts[i] + 1 + maxIndexOfPreviousGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpSizeOfGroups;
|
||||||
|
}, [groupCounts]);
|
||||||
|
|
||||||
|
return useCallback((index: number) => this.convertIndex(index, groupCounts, sizeOfGroups), [sizeOfGroups]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static useCreateGroupedComputeItemKey(
|
||||||
|
groupCounts: number[],
|
||||||
|
getGroupKey: (index: number, groupIndex: number) => React.Key,
|
||||||
|
getNormalKey: (index: number, groupIndex: number) => React.Key,
|
||||||
|
): (index: number) => React.Key {
|
||||||
|
const convertIndex = this.useCreateConvertIndex(groupCounts);
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(index) => {
|
||||||
|
const { type, index: convertedIndex, groupIndex } = convertIndex(index);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'group':
|
||||||
|
return getGroupKey(convertedIndex, groupIndex);
|
||||||
|
case 'normal':
|
||||||
|
return getNormalKey(convertedIndex, groupIndex);
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`VirtuosoUtil::useCreateGroupedComputeItemKey: unexpected "converted index type" (${type})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[convertIndex, getGroupKey, getNormalKey],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -259,6 +259,7 @@ export const ChapterList = ({
|
|||||||
}}
|
}}
|
||||||
components={{ Footer: () => <Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }} /> }}
|
components={{ Footer: () => <Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }} /> }}
|
||||||
totalCount={visibleChapters.length}
|
totalCount={visibleChapters.length}
|
||||||
|
computeItemKey={(index) => visibleChapters[index].id}
|
||||||
itemContent={(index: number) => (
|
itemContent={(index: number) => (
|
||||||
<ChapterCard
|
<ChapterCard
|
||||||
{...chaptersWithMeta[index]}
|
{...chaptersWithMeta[index]}
|
||||||
|
|||||||
@@ -287,12 +287,9 @@ export const DownloadQueue: React.FC = () => {
|
|||||||
components={{
|
components={{
|
||||||
Item: HeightPreservingItem,
|
Item: HeightPreservingItem,
|
||||||
}}
|
}}
|
||||||
|
computeItemKey={(_, item) => item.manga.id}
|
||||||
itemContent={(index, item) => (
|
itemContent={(index, item) => (
|
||||||
<Draggable
|
<Draggable draggableId={`${item.manga.id}-${item.chapter.sourceOrder}`} index={index}>
|
||||||
key={`${item.manga.id}-${item.chapter.sourceOrder}`}
|
|
||||||
draggableId={`${item.manga.id}-${item.chapter.sourceOrder}`}
|
|
||||||
index={index}
|
|
||||||
>
|
|
||||||
{(draggableProvided) => (
|
{(draggableProvided) => (
|
||||||
<DownloadChapterItem
|
<DownloadChapterItem
|
||||||
provided={draggableProvided}
|
provided={draggableProvided}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { StyledGroupHeader } from '@/modules/core/components/virtuoso/StyledGrou
|
|||||||
import { StyledGroupItemWrapper } from '@/modules/core/components/virtuoso/StyledGroupItemWrapper.tsx';
|
import { StyledGroupItemWrapper } from '@/modules/core/components/virtuoso/StyledGroupItemWrapper.tsx';
|
||||||
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
|
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
|
||||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||||
|
import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx';
|
||||||
|
|
||||||
const LANGUAGE = 0;
|
const LANGUAGE = 0;
|
||||||
const EXTENSIONS = 1;
|
const EXTENSIONS = 1;
|
||||||
@@ -160,6 +161,12 @@ export function Extensions({ tabsMenuHeight }: { tabsMenuHeight: number }) {
|
|||||||
[filteredGroupedExtensions],
|
[filteredGroupedExtensions],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const computeItemKey = VirtuosoUtil.useCreateGroupedComputeItemKey(
|
||||||
|
groupCounts,
|
||||||
|
useCallback((index) => filteredGroupedExtensions[index][0], [filteredGroupedExtensions]),
|
||||||
|
useCallback((index) => visibleExtensions[index].pkgName, [visibleExtensions]),
|
||||||
|
);
|
||||||
|
|
||||||
const submitExternalExtension = (file: File) => {
|
const submitExternalExtension = (file: File) => {
|
||||||
if (file.name.toLowerCase().endsWith('apk')) {
|
if (file.name.toLowerCase().endsWith('apk')) {
|
||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
@@ -298,13 +305,12 @@ export function Extensions({ tabsMenuHeight }: { tabsMenuHeight: number }) {
|
|||||||
</StyledGroupHeader>
|
</StyledGroupHeader>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
computeItemKey={computeItemKey}
|
||||||
itemContent={(index) => {
|
itemContent={(index) => {
|
||||||
const item = visibleExtensions[index];
|
const item = visibleExtensions[index];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledGroupItemWrapper
|
<StyledGroupItemWrapper>
|
||||||
key={`${item.pkgName}_${item.isInstalled}_${item.isObsolete}_${item.hasUpdate}`}
|
|
||||||
>
|
|
||||||
<ExtensionCard
|
<ExtensionCard
|
||||||
extension={item}
|
extension={item}
|
||||||
handleUpdate={handleExtensionUpdate}
|
handleUpdate={handleExtensionUpdate}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useContext, useLayoutEffect, useMemo } from 'react';
|
import { useCallback, useContext, useLayoutEffect, useMemo } from 'react';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
|
import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
|
||||||
@@ -36,6 +36,7 @@ import { BaseMangaGrid } from '@/modules/manga/components/BaseMangaGrid.tsx';
|
|||||||
import { IMangaGridProps } from '@/modules/manga/components/MangaGrid.tsx';
|
import { IMangaGridProps } from '@/modules/manga/components/MangaGrid.tsx';
|
||||||
import { StyledGroupItemWrapper } from '@/modules/core/components/virtuoso/StyledGroupItemWrapper.tsx';
|
import { StyledGroupItemWrapper } from '@/modules/core/components/virtuoso/StyledGroupItemWrapper.tsx';
|
||||||
import { enhancedCleanup } from '@/util/Strings.ts';
|
import { enhancedCleanup } from '@/util/Strings.ts';
|
||||||
|
import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx';
|
||||||
|
|
||||||
const findDuplicatesByTitle = <Manga extends Pick<MangaType, 'title'>>(
|
const findDuplicatesByTitle = <Manga extends Pick<MangaType, 'title'>>(
|
||||||
libraryMangas: Manga[],
|
libraryMangas: Manga[],
|
||||||
@@ -180,6 +181,15 @@ export const LibraryDuplicates = () => {
|
|||||||
[mangasByTitle],
|
[mangasByTitle],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const computeItemKey = VirtuosoUtil.useCreateGroupedComputeItemKey(
|
||||||
|
mangasCountByTitle,
|
||||||
|
useCallback((index) => duplicatedTitles[index], [duplicatedTitles]),
|
||||||
|
useCallback(
|
||||||
|
(index, groupIndex) => `${duplicatedTitles[groupIndex]}-${duplicatedMangas[index].id}}`,
|
||||||
|
[duplicatedTitles, duplicatedMangas],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingPlaceholder />;
|
return <LoadingPlaceholder />;
|
||||||
}
|
}
|
||||||
@@ -203,8 +213,9 @@ export const LibraryDuplicates = () => {
|
|||||||
{duplicatedTitles[index]}
|
{duplicatedTitles[index]}
|
||||||
</StyledGroupHeader>
|
</StyledGroupHeader>
|
||||||
)}
|
)}
|
||||||
|
computeItemKey={computeItemKey}
|
||||||
itemContent={(index) => (
|
itemContent={(index) => (
|
||||||
<StyledGroupItemWrapper key={duplicatedMangas[index].id}>
|
<StyledGroupItemWrapper>
|
||||||
<MangaCard
|
<MangaCard
|
||||||
manga={duplicatedMangas[index] as IMangaGridProps['mangas'][number]}
|
manga={duplicatedMangas[index] as IMangaGridProps['mangas'][number]}
|
||||||
gridLayout={gridLayout}
|
gridLayout={gridLayout}
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ const createMangaCard = (
|
|||||||
mode?: MangaCardProps['mode'],
|
mode?: MangaCardProps['mode'],
|
||||||
) => (
|
) => (
|
||||||
<MangaCard
|
<MangaCard
|
||||||
key={manga.id}
|
|
||||||
manga={manga}
|
manga={manga}
|
||||||
gridLayout={gridLayout}
|
gridLayout={gridLayout}
|
||||||
inLibraryIndicator={inLibraryIndicator}
|
inLibraryIndicator={inLibraryIndicator}
|
||||||
@@ -195,6 +194,7 @@ const VerticalGrid = forwardRef(
|
|||||||
restoreStateFrom={snapshot}
|
restoreStateFrom={snapshot}
|
||||||
stateChanged={persistGridState}
|
stateChanged={persistGridState}
|
||||||
endReached={() => loadMore()}
|
endReached={() => loadMore()}
|
||||||
|
computeItemKey={(index) => mangas[index].id}
|
||||||
itemContent={(index) =>
|
itemContent={(index) =>
|
||||||
createMangaCard(
|
createMangaCard(
|
||||||
mangas[index],
|
mangas[index],
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'
|
|||||||
import { TypographyMaxLines } from '@/modules/core/components/TypographyMaxLines.tsx';
|
import { TypographyMaxLines } from '@/modules/core/components/TypographyMaxLines.tsx';
|
||||||
import { ChapterIdInfo, ChapterMangaInfo } from '@/modules/chapter/services/Chapters.ts';
|
import { ChapterIdInfo, ChapterMangaInfo } from '@/modules/chapter/services/Chapters.ts';
|
||||||
import { makeToast } from '@/modules/core/utils/Toast.ts';
|
import { makeToast } from '@/modules/core/utils/Toast.ts';
|
||||||
|
import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx';
|
||||||
|
|
||||||
const groupByDate = (updates: Pick<ChapterType, 'fetchedAt'>[]): [date: string, items: number][] => {
|
const groupByDate = (updates: Pick<ChapterType, 'fetchedAt'>[]): [date: string, items: number][] => {
|
||||||
if (!updates.length) {
|
if (!updates.length) {
|
||||||
@@ -74,6 +75,12 @@ export const Updates: React.FC = () => {
|
|||||||
const { data: downloaderData } = requestManager.useGetDownloadStatus();
|
const { data: downloaderData } = requestManager.useGetDownloadStatus();
|
||||||
const queue = downloaderData?.downloadStatus.queue ?? [];
|
const queue = downloaderData?.downloadStatus.queue ?? [];
|
||||||
|
|
||||||
|
const computeItemKey = VirtuosoUtil.useCreateGroupedComputeItemKey(
|
||||||
|
groupCounts,
|
||||||
|
useCallback((index) => groupedUpdates[index][0], [groupedUpdates]),
|
||||||
|
useCallback((index) => updateEntries[index].id, [updateEntries]),
|
||||||
|
);
|
||||||
|
|
||||||
const lastUpdateTimestampCompRef = useRef<HTMLElement>(null);
|
const lastUpdateTimestampCompRef = useRef<HTMLElement>(null);
|
||||||
const [lastUpdateTimestampCompHeight, setLastUpdateTimestampCompHeight] = useState(0);
|
const [lastUpdateTimestampCompHeight, setLastUpdateTimestampCompHeight] = useState(0);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -169,13 +176,14 @@ export const Updates: React.FC = () => {
|
|||||||
{groupedUpdates[index][0]}
|
{groupedUpdates[index][0]}
|
||||||
</StyledGroupHeader>
|
</StyledGroupHeader>
|
||||||
)}
|
)}
|
||||||
|
computeItemKey={computeItemKey}
|
||||||
itemContent={(index) => {
|
itemContent={(index) => {
|
||||||
const chapter = updateEntries[index];
|
const chapter = updateEntries[index];
|
||||||
const { manga } = chapter;
|
const { manga } = chapter;
|
||||||
const download = downloadForChapter(chapter);
|
const download = downloadForChapter(chapter);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledGroupItemWrapper key={index}>
|
<StyledGroupItemWrapper>
|
||||||
<Card>
|
<Card>
|
||||||
<CardActionArea
|
<CardActionArea
|
||||||
component={Link}
|
component={Link}
|
||||||
|
|||||||
Reference in New Issue
Block a user