Add range selection (#707)
This commit is contained in:
@@ -52,6 +52,7 @@
|
|||||||
"react-i18next": "^14.1.0",
|
"react-i18next": "^14.1.0",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.22.3",
|
||||||
"react-virtuoso": "^4.7.7",
|
"react-virtuoso": "^4.7.7",
|
||||||
|
"use-long-press": "^3.2.0",
|
||||||
"use-query-params": "^2.2.1",
|
"use-query-params": "^2.2.1",
|
||||||
"vite": "^5.2.7",
|
"vite": "^5.2.7",
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
"vite-tsconfig-paths": "^4.3.2"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Avatar, Box, CardContent, Stack, styled, Tooltip } from '@mui/material'
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import PopupState, { bindMenu } from 'material-ui-popup-state';
|
import PopupState, { bindMenu } from 'material-ui-popup-state';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useLongPress } from 'use-long-press';
|
||||||
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
|
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
|
||||||
import { SpinnerImage } from '@/components/util/SpinnerImage';
|
import { SpinnerImage } from '@/components/util/SpinnerImage';
|
||||||
import { TManga, TPartialManga } from '@/typings.ts';
|
import { TManga, TPartialManga } from '@/typings.ts';
|
||||||
@@ -116,6 +117,20 @@ export const MangaCard = (props: MangaCardProps) => {
|
|||||||
|
|
||||||
const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false);
|
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) {
|
if (gridLayout !== GridLayout.List) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -126,12 +141,13 @@ export const MangaCard = (props: MangaCardProps) => {
|
|||||||
{(popupState) => (
|
{(popupState) => (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
|
{...longPressBind()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const isMigrateSelectMode = mode === 'migrate.select';
|
const isMigrateSelectMode = mode === 'migrate.select';
|
||||||
const isSelectionMode = selected !== null;
|
const isSelectionMode = selected !== null;
|
||||||
|
|
||||||
const handleClick = isMigrateSelectMode || isSelectionMode;
|
const shouldHandleClick = isMigrateSelectMode || isSelectionMode;
|
||||||
if (!handleClick) {
|
if (!shouldHandleClick) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +158,7 @@ export const MangaCard = (props: MangaCardProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSelection?.(id, !selected);
|
handleClick(e);
|
||||||
}}
|
}}
|
||||||
to={mangaLinkTo}
|
to={mangaLinkTo}
|
||||||
style={{ textDecoration: 'none' }}
|
style={{ textDecoration: 'none' }}
|
||||||
@@ -334,14 +350,8 @@ export const MangaCard = (props: MangaCardProps) => {
|
|||||||
<CardActionArea
|
<CardActionArea
|
||||||
component={Link}
|
component={Link}
|
||||||
to={mangaLinkTo}
|
to={mangaLinkTo}
|
||||||
onClick={(e) => {
|
onClick={handleClick}
|
||||||
if (selected === null) {
|
{...longPressBind()}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
handleSelection?.(id, !selected);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<CardContent
|
<CardContent
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ import CardContent from '@mui/material/CardContent';
|
|||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import { useTheme } from '@mui/material/styles';
|
import { useTheme } from '@mui/material/styles';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import React, { TouchEvent } from 'react';
|
import React, { MouseEvent, TouchEvent } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
|
import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
|
||||||
|
import { useLongPress } from 'use-long-press';
|
||||||
import { getUploadDateString } from '@/util/date.ts';
|
import { getUploadDateString } from '@/util/date.ts';
|
||||||
import { DownloadStateIndicator } from '@/components/molecules/DownloadStateIndicator.tsx';
|
import { DownloadStateIndicator } from '@/components/molecules/DownloadStateIndicator.tsx';
|
||||||
import { DownloadType } from '@/lib/graphql/generated/graphql.ts';
|
import { DownloadType } from '@/lib/graphql/generated/graphql.ts';
|
||||||
@@ -30,7 +31,7 @@ interface IProps {
|
|||||||
allChapters: TChapter[];
|
allChapters: TChapter[];
|
||||||
downloadChapter: DownloadType | undefined;
|
downloadChapter: DownloadType | undefined;
|
||||||
showChapterNumber: boolean;
|
showChapterNumber: boolean;
|
||||||
onSelect: (selected: boolean) => void;
|
onSelect: (selected: boolean, isShiftKey?: boolean) => void;
|
||||||
selected: boolean | null;
|
selected: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,15 +42,20 @@ export const ChapterCard: React.FC<IProps> = (props: IProps) => {
|
|||||||
const { chapter, allChapters, downloadChapter: dc, showChapterNumber, onSelect, selected } = props;
|
const { chapter, allChapters, downloadChapter: dc, showChapterNumber, onSelect, selected } = props;
|
||||||
const isSelecting = selected !== null;
|
const isSelecting = selected !== null;
|
||||||
|
|
||||||
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
const { isDownloaded } = chapter;
|
||||||
if (isSelecting) {
|
|
||||||
event.preventDefault();
|
const handleClick = (e: MouseEvent | TouchEvent) => {
|
||||||
event.stopPropagation();
|
if (!isSelecting) return;
|
||||||
onSelect(!selected);
|
|
||||||
}
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect(!selected, e.shiftKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { isDownloaded } = chapter;
|
const longPressBind = useLongPress((e) => {
|
||||||
|
e.shiftKey = true;
|
||||||
|
handleClick(e);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
@@ -86,7 +92,8 @@ export const ChapterCard: React.FC<IProps> = (props: IProps) => {
|
|||||||
style={{
|
style={{
|
||||||
color: theme.palette.text[chapter.isRead ? 'disabled' : 'primary'],
|
color: theme.palette.text[chapter.isRead ? 'disabled' : 'primary'],
|
||||||
}}
|
}}
|
||||||
onClick={handleClick}
|
onClick={(e) => handleClick(e)}
|
||||||
|
{...longPressBind()}
|
||||||
>
|
>
|
||||||
<CardContent
|
<CardContent
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -74,8 +74,10 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
|
|||||||
const { data: chaptersData, loading: isLoading } = requestManager.useGetMangaChapters(manga.id);
|
const { data: chaptersData, loading: isLoading } = requestManager.useGetMangaChapters(manga.id);
|
||||||
const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]);
|
const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]);
|
||||||
|
|
||||||
|
const chapterIds = useMemo(() => chapters.map((chapter) => chapter.id), [chapters]);
|
||||||
|
|
||||||
const { areNoItemsSelected, areAllItemsSelected, selectedItemIds, handleSelectAll, handleSelection } =
|
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]);
|
const visibleChapters = useMemo(() => filterAndSortChapters(chapters, options), [chapters, options]);
|
||||||
|
|
||||||
@@ -202,7 +204,9 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
|
|||||||
{...chaptersWithMeta[index]}
|
{...chaptersWithMeta[index]}
|
||||||
allChapters={chapters}
|
allChapters={chapters}
|
||||||
showChapterNumber={options.showChapterNumber}
|
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}
|
useWindowScroll={window.innerWidth < 900}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
export type SelectableCollectionReturnType<Id extends number | string, Key extends string = string> = {
|
export type SelectableCollectionReturnType<Id extends number | string, Key extends string = string> = {
|
||||||
selectedItemIds: Id[];
|
selectedItemIds: Id[];
|
||||||
@@ -15,9 +15,9 @@ export type SelectableCollectionReturnType<Id extends number | string, Key exten
|
|||||||
areNoItemsSelected: boolean;
|
areNoItemsSelected: boolean;
|
||||||
areAllItemsForKeySelected: boolean;
|
areAllItemsForKeySelected: boolean;
|
||||||
areNoItemsForKeySelected: boolean;
|
areNoItemsForKeySelected: boolean;
|
||||||
handleSelection: (id: Id, selected: boolean, key?: Key) => void;
|
handleSelection: (id: Id, selected: boolean, options?: { selectRange?: boolean; key?: Key }) => void;
|
||||||
handleSelectAll: (selectAll: boolean, itemIds: Id[], key?: Key) => void;
|
handleSelectAll: (selectAll: boolean, ids: Id[], key?: Key) => void;
|
||||||
setSelectionForKey: (key: Key, itemIds: Id[]) => void;
|
setSelectionForKey: (key: Key, ids: Id[]) => void;
|
||||||
getSelectionForKey: (key: Key) => Id[];
|
getSelectionForKey: (key: Key) => Id[];
|
||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
};
|
};
|
||||||
@@ -25,10 +25,12 @@ export type SelectableCollectionReturnType<Id extends number | string, Key exten
|
|||||||
export const useSelectableCollection = <Id extends number | string, Key extends string = 'default'>(
|
export const useSelectableCollection = <Id extends number | string, Key extends string = 'default'>(
|
||||||
totalCount: number,
|
totalCount: number,
|
||||||
{
|
{
|
||||||
|
itemIds = [],
|
||||||
keyCount = totalCount,
|
keyCount = totalCount,
|
||||||
currentKey,
|
currentKey,
|
||||||
initialState = {} as Record<Key, Id[]>,
|
initialState = {} as Record<Key, Id[]>,
|
||||||
}: {
|
}: {
|
||||||
|
itemIds?: Id[];
|
||||||
keyCount?: number;
|
keyCount?: number;
|
||||||
currentKey: Key;
|
currentKey: Key;
|
||||||
initialState?: Record<Key, Id[]>;
|
initialState?: Record<Key, Id[]>;
|
||||||
@@ -36,6 +38,8 @@ export const useSelectableCollection = <Id extends number | string, Key extends
|
|||||||
): SelectableCollectionReturnType<Id, Key> => {
|
): SelectableCollectionReturnType<Id, Key> => {
|
||||||
const [keyToSelectedItemIds, setKeyToSelectedItemIds] = useState<Record<string, Id[]>>(initialState);
|
const [keyToSelectedItemIds, setKeyToSelectedItemIds] = useState<Record<string, Id[]>>(initialState);
|
||||||
|
|
||||||
|
const lastSelectedItemInfoRef = useRef<{ id: Id; key: Key }>();
|
||||||
|
|
||||||
const selectedItemIds = [...new Set(Object.values(keyToSelectedItemIds).flat())];
|
const selectedItemIds = [...new Set(Object.values(keyToSelectedItemIds).flat())];
|
||||||
const areAllItemsSelected = selectedItemIds.length === totalCount;
|
const areAllItemsSelected = selectedItemIds.length === totalCount;
|
||||||
const areNoItemsSelected = !selectedItemIds.length;
|
const areNoItemsSelected = !selectedItemIds.length;
|
||||||
@@ -44,28 +48,52 @@ export const useSelectableCollection = <Id extends number | string, Key extends
|
|||||||
const areAllItemsForKeySelected = keySelectedItemIds.length === keyCount;
|
const areAllItemsForKeySelected = keySelectedItemIds.length === keyCount;
|
||||||
const areNoItemsForKeySelected = keySelectedItemIds.length === 0;
|
const areNoItemsForKeySelected = keySelectedItemIds.length === 0;
|
||||||
|
|
||||||
const handleSelection = (id: Id, selected: boolean, key: Key = currentKey) => {
|
if (areNoItemsForKeySelected) {
|
||||||
|
lastSelectedItemInfoRef.current = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelection: SelectableCollectionReturnType<Id, Key>['handleSelection'] = (
|
||||||
|
id,
|
||||||
|
selected,
|
||||||
|
{ selectRange = false, key = currentKey } = {},
|
||||||
|
) => {
|
||||||
const deselect = !selected;
|
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) {
|
if (deselect) {
|
||||||
setKeyToSelectedItemIds((prevState) => ({
|
setKeyToSelectedItemIds((prevState) => ({
|
||||||
...prevState,
|
...prevState,
|
||||||
[key]: prevState[key]?.filter((selectedItemId) => selectedItemId !== id) ?? [],
|
[key]: prevState[key]?.filter((selectedItemId) => !selectedIds.includes(selectedItemId)) ?? [],
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setKeyToSelectedItemIds((prevState) => ({
|
setKeyToSelectedItemIds((prevState) => ({
|
||||||
...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) {
|
switch (selectAll) {
|
||||||
case true:
|
case true:
|
||||||
setKeyToSelectedItemIds((prevState) => ({
|
setKeyToSelectedItemIds((prevState) => ({
|
||||||
...prevState,
|
...prevState,
|
||||||
[key]: [...itemIds],
|
[key]: [...ids],
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
case false:
|
case false:
|
||||||
@@ -79,10 +107,10 @@ export const useSelectableCollection = <Id extends number | string, Key extends
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSelectionForKey = (key: Key, itemIds: Id[]) => {
|
const setSelectionForKey = (key: Key, ids: Id[]) => {
|
||||||
setKeyToSelectedItemIds((prevState) => ({
|
setKeyToSelectedItemIds((prevState) => ({
|
||||||
...prevState,
|
...prevState,
|
||||||
[key]: [...itemIds],
|
[key]: [...ids],
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -181,15 +181,15 @@ export function CategorySelect(props: Props) {
|
|||||||
isSingleSelectionMode,
|
isSingleSelectionMode,
|
||||||
)}
|
)}
|
||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
handleSelection(category.id, false, 'categoriesToAdd');
|
handleSelection(category.id, false, { key: 'categoriesToAdd' });
|
||||||
handleSelection(category.id, false, 'categoriesToRemove');
|
handleSelection(category.id, false, { key: 'categoriesToRemove' });
|
||||||
|
|
||||||
if (checked) {
|
if (checked) {
|
||||||
handleSelection(category.id, true, 'categoriesToAdd');
|
handleSelection(category.id, true, { key: 'categoriesToAdd' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checked === false) {
|
if (checked === false) {
|
||||||
handleSelection(category.id, true, 'categoriesToRemove');
|
handleSelection(category.id, true, { key: 'categoriesToRemove' });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
label={category.name}
|
label={category.name}
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export function Library() {
|
|||||||
const categoryMangas = categoryMangaResponse?.mangas.nodes ?? [];
|
const categoryMangas = categoryMangaResponse?.mangas.nodes ?? [];
|
||||||
const { visibleMangas: mangas, showFilteredOutMessage } = useGetVisibleLibraryMangas(categoryMangas);
|
const { visibleMangas: mangas, showFilteredOutMessage } = useGetVisibleLibraryMangas(categoryMangas);
|
||||||
|
|
||||||
|
const mangaIds = useMemo(() => mangas.map((manga) => manga.id), [mangas]);
|
||||||
|
|
||||||
const [isSelectModeActive, setIsSelectModeActive] = useState(false);
|
const [isSelectModeActive, setIsSelectModeActive] = useState(false);
|
||||||
const {
|
const {
|
||||||
areNoItemsForKeySelected: areNoItemsSelected,
|
areNoItemsForKeySelected: areNoItemsSelected,
|
||||||
@@ -76,11 +78,14 @@ export function Library() {
|
|||||||
handleSelectAll,
|
handleSelectAll,
|
||||||
handleSelection,
|
handleSelection,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
} = useSelectableCollection<TManga['id'], string>(mangas.length, { currentKey: activeTab?.id.toString() });
|
} = useSelectableCollection<TManga['id'], string>(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)));
|
setIsSelectModeActive(!!(selectedItemIds.length + (selected ? 1 : -1)));
|
||||||
handleSelection(id, selected);
|
handleSelection(id, selected, selectOptions);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedMangas = useMemo(
|
const selectedMangas = useMemo(
|
||||||
|
|||||||
@@ -6703,6 +6703,11 @@ urlpattern-polyfill@^8.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz#99f096e35eff8bf4b5a2aa7d58a1523d6ebc7ce5"
|
resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz#99f096e35eff8bf4b5a2aa7d58a1523d6ebc7ce5"
|
||||||
integrity sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==
|
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:
|
use-memo-one@^1.1.1:
|
||||||
version "1.1.3"
|
version "1.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99"
|
resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99"
|
||||||
|
|||||||
Reference in New Issue
Block a user