feat(ui): improved arrow key navigation in gallery

- Fix preexisting bug where gallery network requests were duplicated when triggering infinite scroll
- Refactor `useNextPrevImage` to not use `state => state` as an input selector - logic split up into different hooks
- Remove use instant scroll for arrow key navigation - smooth scroll is janky when you hold the arrow down and it fires rapidly
- Move gallery nav hotkeys to GalleryImageGrid component, so they work whenever the gallery is open (previously didn't work on canvas or workflow editor tabs)
- Use nanostores for gallery grid refs instead of passing context with virtuoso's context feature, making it much simpler to do the imperative gallery nav
- General gallery hook/component cleanup
This commit is contained in:
psychedelicious 2024-01-07 01:15:39 +11:00
parent 60815807f9
commit f8c3db72e9
15 changed files with 510 additions and 449 deletions

View File

@ -11,13 +11,11 @@ import type {
import ProgressImage from 'features/gallery/components/CurrentImage/ProgressImage';
import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer';
import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
import { useNextPrevImage } from 'features/gallery/hooks/useNextPrevImage';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import type { AnimationProps } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { FaImage } from 'react-icons/fa';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
@ -39,61 +37,6 @@ const CurrentImagePreview = () => {
(s) => s.ui.shouldShowProgressInViewer
);
const {
handlePrevImage,
handleNextImage,
isOnLastImage,
handleLoadMoreImages,
areMoreImagesAvailable,
isFetching,
handleTopImage,
handleBottomImage
} = useNextPrevImage();
useHotkeys(
'left',
() => {
handlePrevImage();
},
[handlePrevImage]
);
useHotkeys(
'right',
() => {
if (isOnLastImage && areMoreImagesAvailable && !isFetching) {
handleLoadMoreImages();
return;
}
if (!isOnLastImage) {
handleNextImage();
}
},
[
isOnLastImage,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,
handleNextImage,
]
);
useHotkeys(
'up',
() => {
handleTopImage();
},
[handleTopImage]
);
useHotkeys(
'down',
() => {
handleBottomImage();
},
[handleBottomImage]
);
const { currentData: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken);
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {

View File

@ -13,9 +13,9 @@ import type {
ImageDTOsDraggableData,
TypesafeDraggableData,
} from 'features/dnd/types';
import type { VirtuosoGalleryContext } from 'features/gallery/components/ImageGrid/types';
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
import { useMultiselect } from 'features/gallery/hooks/useMultiselect';
import { useScrollToVisible } from 'features/gallery/hooks/useScrollToVisible';
import { useScrollIntoView } from 'features/gallery/hooks/useScrollIntoView';
import type { MouseEvent } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -35,12 +35,11 @@ const imageIconStyleOverrides: SystemStyleObject = {
interface HoverableImageProps {
imageName: string;
index: number;
virtuosoContext: VirtuosoGalleryContext;
}
const GalleryImage = (props: HoverableImageProps) => {
const dispatch = useAppDispatch();
const { imageName, virtuosoContext } = props;
const { imageName } = props;
const { currentData: imageDTO } = useGetImageDTOQuery(imageName);
const shift = useStore($shift);
const { t } = useTranslation();
@ -50,11 +49,10 @@ const GalleryImage = (props: HoverableImageProps) => {
const customStarUi = useStore($customStarUI);
const imageContainerRef = useScrollToVisible(
const imageContainerRef = useScrollIntoView(
isSelected,
props.index,
selectionCount,
virtuosoContext
selectionCount
);
const handleDelete = useCallback(
@ -131,12 +129,22 @@ const GalleryImage = (props: HoverableImageProps) => {
return '';
}, [imageDTO?.starred, customStarUi]);
const dataTestId = useMemo(
() => getGalleryImageDataTestId(imageDTO?.image_name),
[imageDTO?.image_name]
);
if (!imageDTO) {
return <IAIFillSkeleton />;
}
return (
<Box w="full" h="full" className='gallerygrid-image' data-testid={`image-${imageDTO.image_name}`}>
<Box
w="full"
h="full"
className="gallerygrid-image"
data-testid={dataTestId}
>
<Flex
ref={imageContainerRef}
userSelect="none"

View File

@ -4,13 +4,12 @@ import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { InvButton } from 'common/components/InvButton/InvButton';
import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
import type { VirtuosoGalleryContext } from 'features/gallery/components/ImageGrid/types';
import { $useNextPrevImageState } from 'features/gallery/hooks/useNextPrevImage';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { IMAGE_LIMIT } from 'features/gallery/store/types';
import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
import { useGalleryHotkeys } from 'features/gallery/hooks/useGalleryHotkeys';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import type { CSSProperties } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaExclamationCircle, FaImage } from 'react-icons/fa';
import type {
@ -20,10 +19,6 @@ import type {
VirtuosoGridHandle,
} from 'react-virtuoso';
import { VirtuosoGrid } from 'react-virtuoso';
import {
useLazyListImagesQuery,
useListImagesQuery,
} from 'services/api/endpoints/images';
import { useBoardTotal } from 'services/api/hooks/useBoardTotal';
import GalleryImage from './GalleryImage';
@ -46,52 +41,20 @@ const GalleryImageGrid = () => {
);
const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
const { currentViewTotal } = useBoardTotal(selectedBoardId);
const queryArgs = useAppSelector(selectListImagesBaseQueryArgs);
const virtuosoRangeRef = useRef<ListRange | null>(null);
const virtuosoRef = useRef<VirtuosoGridHandle>(null);
const { currentData, isFetching, isSuccess, isError } =
useListImagesQuery(queryArgs);
const [listImages] = useLazyListImagesQuery();
const areMoreAvailable = useMemo(() => {
if (!currentData || !currentViewTotal) {
return false;
}
return currentData.ids.length < currentViewTotal;
}, [currentData, currentViewTotal]);
const handleLoadMoreImages = useCallback(() => {
if (!areMoreAvailable) {
return;
}
listImages({
...queryArgs,
offset: currentData?.ids.length ?? 0,
limit: IMAGE_LIMIT,
});
}, [areMoreAvailable, listImages, queryArgs, currentData?.ids.length]);
const virtuosoContext = useMemo<VirtuosoGalleryContext>(() => {
return {
virtuosoRef,
rootRef,
virtuosoRangeRef,
};
}, []);
const itemContentFunc: ItemContent<EntityId, VirtuosoGalleryContext> =
useCallback(
(index, imageName, virtuosoContext) => (
const {
areMoreImagesAvailable,
handleLoadMoreImages,
queryResult: { currentData, isFetching, isSuccess, isError },
} = useGalleryImages();
useGalleryHotkeys();
const itemContentFunc: ItemContent<EntityId, void> = useCallback(
(index, imageName) => (
<GalleryImage
key={imageName}
index={index}
imageName={imageName as string}
virtuosoContext={virtuosoContext}
/>
),
[]
@ -116,8 +79,10 @@ const GalleryImageGrid = () => {
}, []);
useEffect(() => {
$useNextPrevImageState.setKey('virtuosoRef', virtuosoRef);
$useNextPrevImageState.setKey('virtuosoRangeRef', virtuosoRangeRef);
virtuosoGridRefs.set({ rootRef, virtuosoRangeRef, virtuosoRef });
return () => {
virtuosoGridRefs.set({});
};
}, []);
if (!currentData) {
@ -142,7 +107,7 @@ const GalleryImageGrid = () => {
if (isSuccess && currentData) {
return (
<>
<Box ref={rootRef} data-overlayscrollbars="" h="100%" id='gallery-grid'>
<Box ref={rootRef} data-overlayscrollbars="" h="100%" id="gallery-grid">
<VirtuosoGrid
style={virtuosoStyles}
data={currentData.ids}
@ -152,13 +117,12 @@ const GalleryImageGrid = () => {
itemContent={itemContentFunc}
ref={virtuosoRef}
rangeChanged={onRangeChanged}
context={virtuosoContext}
overscan={10}
/>
</Box>
<InvButton
onClick={handleLoadMoreImages}
isDisabled={!areMoreAvailable}
isDisabled={!areMoreImagesAvailable}
isLoading={isFetching}
loadingText={t('gallery.loading')}
flexShrink={0}

View File

@ -3,12 +3,16 @@ import { Box, forwardRef } from '@chakra-ui/react';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
// This is exported so that we can use it to calculate the number of images per row
// for the directional gallery navigation.
export const GALLERY_IMAGE_PADDING_PX = 6;
type ItemContainerProps = PropsWithChildren & FlexProps;
const ItemContainer = forwardRef((props: ItemContainerProps, ref) => (
<Box
className="item-container"
ref={ref}
p={1.5}
p={`${GALLERY_IMAGE_PADDING_PX}px`}
data-testid="image-item-container"
>
{props.children}

View File

@ -0,0 +1,2 @@
export const getGalleryImageDataTestId = (imageName?: string) =>
`gallery-image-${imageName}`;

View File

@ -1,8 +1,11 @@
import { atom } from 'nanostores';
import type { RefObject } from 'react';
import type { ListRange, VirtuosoGridHandle } from 'react-virtuoso';
export type VirtuosoGalleryContext = {
virtuosoRef: RefObject<VirtuosoGridHandle>;
rootRef: RefObject<HTMLDivElement>;
virtuosoRangeRef: RefObject<ListRange>;
export type VirtuosoGridRefs = {
virtuosoRef?: RefObject<VirtuosoGridHandle>;
rootRef?: RefObject<HTMLDivElement>;
virtuosoRangeRef?: RefObject<ListRange>;
};
export const virtuosoGridRefs = atom<VirtuosoGridRefs>({});

View File

@ -1,7 +1,8 @@
import type { ChakraProps } from '@chakra-ui/react';
import { Box, Flex, Spinner } from '@chakra-ui/react';
import { InvIconButton } from 'common/components/InvIconButton/InvIconButton';
import { useNextPrevImage } from 'features/gallery/hooks/useNextPrevImage';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaAngleDoubleRight, FaAngleLeft, FaAngleRight } from 'react-icons/fa';
@ -14,15 +15,14 @@ const nextPrevButtonStyles: ChakraProps['sx'] = {
const NextPrevImageButtons = () => {
const { t } = useTranslation();
const { handleLeftImage, handleRightImage, isOnFirstImage, isOnLastImage } =
useGalleryNavigation();
const {
handlePrevImage,
handleNextImage,
isOnFirstImage,
isOnLastImage,
handleLoadMoreImages,
areMoreImagesAvailable,
isFetching,
} = useNextPrevImage();
handleLoadMoreImages,
queryResult: { isFetching },
} = useGalleryImages();
return (
<Box pos="relative" h="full" w="full">
@ -37,7 +37,7 @@ const NextPrevImageButtons = () => {
aria-label={t('accessibility.previousImage')}
icon={<FaAngleLeft size={64} />}
variant="unstyled"
onClick={handlePrevImage}
onClick={handleLeftImage}
boxSize={16}
sx={nextPrevButtonStyles}
/>
@ -54,7 +54,7 @@ const NextPrevImageButtons = () => {
aria-label={t('accessibility.nextImage')}
icon={<FaAngleRight size={64} />}
variant="unstyled"
onClick={handleNextImage}
onClick={handleRightImage}
boxSize={16}
sx={nextPrevButtonStyles}
/>

View File

@ -0,0 +1,79 @@
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation';
import { useHotkeys } from 'react-hotkeys-hook';
/**
* Registers gallery hotkeys. This hook is a singleton.
*/
export const useGalleryHotkeys = () => {
const {
areMoreImagesAvailable,
handleLoadMoreImages,
queryResult: { isFetching },
} = useGalleryImages();
const {
handleLeftImage,
handleRightImage,
handleUpImage,
handleDownImage,
isOnLastImage,
areImagesBelowCurrent,
} = useGalleryNavigation();
useHotkeys(
'left',
() => {
handleLeftImage();
},
[handleLeftImage]
);
useHotkeys(
'right',
() => {
if (isOnLastImage && areMoreImagesAvailable && !isFetching) {
handleLoadMoreImages();
return;
}
if (!isOnLastImage) {
handleRightImage();
}
},
[
isOnLastImage,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,
handleRightImage,
]
);
useHotkeys(
'up',
() => {
handleUpImage();
},
{ preventDefault: true },
[handleUpImage]
);
useHotkeys(
'down',
() => {
if (!areImagesBelowCurrent && areMoreImagesAvailable && !isFetching) {
handleLoadMoreImages();
return;
}
handleDownImage();
},
{ preventDefault: true },
[
areImagesBelowCurrent,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,
handleDownImage,
]
);
};

View File

@ -0,0 +1,73 @@
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { IMAGE_LIMIT } from 'features/gallery/store/types';
import { atom } from 'nanostores';
import { useCallback, useMemo } from 'react';
import {
useGetBoardAssetsTotalQuery,
useGetBoardImagesTotalQuery,
} from 'services/api/endpoints/boards';
import { useListImagesQuery } from 'services/api/endpoints/images';
import type { ListImagesArgs } from 'services/api/types';
export type UseGalleryImagesReturn = {
handleLoadMoreImages: () => void;
areMoreImagesAvailable: boolean;
queryResult: ReturnType<typeof useListImagesQuery>;
};
// The gallery is a singleton but multiple components need access to its query data.
// If we don't define the query args outside of the hook, then each component will
// have its own query args and trigger multiple requests. We use an atom to store
// the query args outside of the hook so that all consumers use the same query args.
const $queryArgs = atom<ListImagesArgs | null>(null);
/**
* Provides access to the gallery images and a way to imperatively fetch more.
*
* This hook is a singleton.
*/
export const useGalleryImages = (): UseGalleryImagesReturn => {
const galleryView = useAppSelector((s) => s.gallery.galleryView);
const baseQueryArgs = useAppSelector(selectListImagesBaseQueryArgs);
const queryArgs = useStore($queryArgs);
const queryResult = useListImagesQuery(queryArgs ?? baseQueryArgs);
const boardId = useMemo(
() => baseQueryArgs.board_id ?? 'none',
[baseQueryArgs.board_id]
);
const { data: assetsTotal } = useGetBoardAssetsTotalQuery(boardId);
const { data: imagesTotal } = useGetBoardImagesTotalQuery(boardId);
const currentViewTotal = useMemo(
() => (galleryView === 'images' ? imagesTotal?.total : assetsTotal?.total),
[assetsTotal?.total, galleryView, imagesTotal?.total]
);
const loadedImagesCount = useMemo(
() => queryResult.data?.ids.length ?? 0,
[queryResult.data?.ids.length]
);
const areMoreImagesAvailable = useMemo(() => {
if (!currentViewTotal || !queryResult.data) {
return false;
}
return queryResult.data.ids.length < currentViewTotal;
}, [queryResult.data, currentViewTotal]);
const handleLoadMoreImages = useCallback(() => {
// To load more images, we update the query args with an offset and limit.
const _queryArgs: ListImagesArgs = loadedImagesCount
? {
...baseQueryArgs,
offset: loadedImagesCount,
limit: IMAGE_LIMIT,
}
: baseQueryArgs;
$queryArgs.set(_queryArgs);
}, [baseQueryArgs, loadedImagesCount]);
return {
areMoreImagesAvailable,
handleLoadMoreImages,
queryResult,
};
};

View File

@ -0,0 +1,216 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
import { GALLERY_IMAGE_PADDING_PX } from 'features/gallery/components/ImageGrid/ImageGridItemContainer';
import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { getIsVisible } from 'features/gallery/util/getIsVisible';
import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
import { clamp } from 'lodash-es';
import { useCallback, useMemo } from 'react';
import type { ImageDTO } from 'services/api/types';
import { imagesSelectors } from 'services/api/util';
/**
* This hook is used to navigate the gallery using the arrow keys.
*
* The gallery is rendered as a grid. In order to navigate the grid,
* we need to know how many images are in each row and whether or not
* an image is visible in the gallery.
*
* We use direct DOM query selectors to check if an image is visible
* to avoid having to track a ref for each image.
*/
/**
* Gets the number of images per row in the gallery by grabbing their DOM elements.
*/
const getImagesPerRow = (): number => {
const imageRect = Object.values(
document.getElementsByClassName('gallerygrid-image')
)[0]?.getBoundingClientRect();
// We have to manually take into account the padding of the image container, else
// imagesPerRow will be wrong when the gallery is large or images are very small.
const widthOfGalleryImage = imageRect
? imageRect.width + GALLERY_IMAGE_PADDING_PX * 2
: 0;
const galleryGridRect = document
.getElementById('gallery-grid')
?.getBoundingClientRect();
const widthOfGalleryGrid = galleryGridRect?.width ?? 0;
const imagesPerRow = Math.floor(widthOfGalleryGrid / widthOfGalleryImage);
return imagesPerRow;
};
/**
* Scrolls to the image with the given name.
* If the image is not fully visible, it will not be scrolled to.
* @param imageName The image name to scroll to.
* @param index The index of the image in the gallery.
*/
const scrollToImage = (imageName: string, index: number) => {
const virtuosoContext = virtuosoGridRefs.get();
const range = virtuosoContext.virtuosoRangeRef?.current;
const root = virtuosoContext.rootRef?.current;
const virtuoso = virtuosoContext.virtuosoRef?.current;
if (!range || !virtuoso || !root) {
return;
}
const imageElement = document.querySelector(
`[data-testid="${getGalleryImageDataTestId(imageName)}"]`
);
const itemRect = imageElement?.getBoundingClientRect();
const rootRect = root.getBoundingClientRect();
if (!itemRect || !getIsVisible(itemRect, rootRect)) {
virtuoso.scrollToIndex({
index,
align: getScrollToIndexAlign(index, range),
});
}
};
// Utilities to get the image to the left, right, up, or down of the current image.
const getLeftImage = (images: ImageDTO[], currentIndex: number) => {
const index = clamp(currentIndex - 1, 0, images.length - 1);
const image = images[index];
return { index, image };
};
const getRightImage = (images: ImageDTO[], currentIndex: number) => {
const index = clamp(currentIndex + 1, 0, images.length - 1);
const image = images[index];
return { index, image };
};
const getUpImage = (images: ImageDTO[], currentIndex: number) => {
const imagesPerRow = getImagesPerRow();
// If we are on the first row, we want to stay on the first row, not go to first image
const isOnFirstRow = currentIndex < imagesPerRow;
const index = isOnFirstRow
? currentIndex
: clamp(currentIndex - imagesPerRow, 0, images.length - 1);
const image = images[index];
return { index, image };
};
const getDownImage = (images: ImageDTO[], currentIndex: number) => {
const imagesPerRow = getImagesPerRow();
// If we are on the first row, we want to stay on the first row, not go to last image
const isOnLastRow = currentIndex >= images.length - imagesPerRow;
const index = isOnLastRow
? currentIndex
: clamp(currentIndex + imagesPerRow, 0, images.length - 1);
const image = images[index];
return { index, image };
};
const getImageFuncs = {
left: getLeftImage,
right: getRightImage,
up: getUpImage,
down: getDownImage,
};
export type UseGalleryNavigationReturn = {
handleLeftImage: () => void;
handleRightImage: () => void;
handleUpImage: () => void;
handleDownImage: () => void;
isOnFirstImage: boolean;
isOnLastImage: boolean;
areImagesBelowCurrent: boolean;
};
/**
* Provides access to the gallery navigation via arrow keys.
* Also provides information about the current image's position in the gallery,
* useful for determining whether to load more images or display navigatin
* buttons.
*/
export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
const dispatch = useAppDispatch();
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
const {
queryResult: { data },
} = useGalleryImages();
const loadedImagesCount = useMemo(
() => data?.ids.length ?? 0,
[data?.ids.length]
);
const lastSelectedImageIndex = useMemo(() => {
if (!data || !lastSelectedImage) {
return 0;
}
return imagesSelectors
.selectAll(data)
.findIndex((i) => i.image_name === lastSelectedImage.image_name);
}, [lastSelectedImage, data]);
const handleNavigation = useCallback(
(direction: 'left' | 'right' | 'up' | 'down') => {
if (!data) {
return;
}
const { index, image } = getImageFuncs[direction](
imagesSelectors.selectAll(data),
lastSelectedImageIndex
);
if (!image) {
return;
}
dispatch(imageSelected(image));
scrollToImage(image.image_name, index);
},
[dispatch, lastSelectedImageIndex, data]
);
const isOnFirstImage = useMemo(
() => lastSelectedImageIndex === 0,
[lastSelectedImageIndex]
);
const isOnLastImage = useMemo(
() => lastSelectedImageIndex === loadedImagesCount - 1,
[lastSelectedImageIndex, loadedImagesCount]
);
const areImagesBelowCurrent = useMemo(() => {
const imagesPerRow = getImagesPerRow();
return lastSelectedImageIndex + imagesPerRow < loadedImagesCount;
}, [lastSelectedImageIndex, loadedImagesCount]);
const handleLeftImage = useCallback(() => {
handleNavigation('left');
}, [handleNavigation]);
const handleRightImage = useCallback(() => {
handleNavigation('right');
}, [handleNavigation]);
const handleUpImage = useCallback(() => {
handleNavigation('up');
}, [handleNavigation]);
const handleDownImage = useCallback(() => {
handleNavigation('down');
}, [handleNavigation]);
return {
handleLeftImage,
handleRightImage,
handleUpImage,
handleDownImage,
isOnFirstImage,
isOnLastImage,
areImagesBelowCurrent,
};
};

View File

@ -1,6 +1,6 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import {
selectGallerySlice,
selectionChanged,
@ -8,29 +8,24 @@ import {
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import type { MouseEvent } from 'react';
import { useCallback, useMemo } from 'react';
import { useListImagesQuery } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import { imagesSelectors } from 'services/api/util';
const selector = createMemoizedSelector(
[selectGallerySlice, selectListImagesBaseQueryArgs],
(gallery, queryArgs) => {
return {
queryArgs,
selection: gallery.selection,
};
}
const selectGallerySelection = createMemoizedSelector(
selectGallerySlice,
(gallery) => gallery.selection
);
const EMPTY_ARRAY: ImageDTO[] = [];
export const useMultiselect = (imageDTO?: ImageDTO) => {
const dispatch = useAppDispatch();
const { queryArgs, selection } = useAppSelector(selector);
const { imageDTOs } = useListImagesQuery(queryArgs, {
selectFromResult: (result) => ({
imageDTOs: result.data ? imagesSelectors.selectAll(result.data) : [],
}),
});
const selection = useAppSelector(selectGallerySelection);
const { data } = useGalleryImages().queryResult;
const imageDTOs = useMemo(
() => (data ? imagesSelectors.selectAll(data) : EMPTY_ARRAY),
[data]
);
const isMultiSelectEnabled = useFeatureStatus('multiselect').isFeatureEnabled;

View File

@ -1,244 +0,0 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import type { RootState } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { IMAGE_LIMIT } from 'features/gallery/store/types';
import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
import { clamp } from 'lodash-es';
import { map } from 'nanostores';
import type { RefObject } from 'react';
import { useCallback } from 'react';
import type { ListRange, VirtuosoGridHandle } from 'react-virtuoso';
import { boardsApi } from 'services/api/endpoints/boards';
import {
imagesApi,
useLazyListImagesQuery,
} from 'services/api/endpoints/images';
import type { ListImagesArgs } from 'services/api/types';
import { imagesSelectors } from 'services/api/util';
export type UseNextPrevImageState = {
virtuosoRef: RefObject<VirtuosoGridHandle> | undefined;
virtuosoRangeRef: RefObject<ListRange> | undefined;
};
export const $useNextPrevImageState = map<UseNextPrevImageState>({
virtuosoRef: undefined,
virtuosoRangeRef: undefined,
});
export const nextPrevImageButtonsSelector = createMemoizedSelector(
[(state: RootState) => state, selectListImagesBaseQueryArgs],
(state, baseQueryArgs) => {
const { data, status } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
const { data: totalsData } =
state.gallery.galleryView === 'images'
? boardsApi.endpoints.getBoardImagesTotal.select(
baseQueryArgs.board_id ?? 'none'
)(state)
: boardsApi.endpoints.getBoardAssetsTotal.select(
baseQueryArgs.board_id ?? 'none'
)(state);
const lastSelectedImage =
state.gallery.selection[state.gallery.selection.length - 1];
const isFetching = status === 'pending';
if (!data || !lastSelectedImage || totalsData?.total === 0) {
return {
isFetching,
queryArgs: baseQueryArgs,
isOnFirstImage: true,
isOnLastImage: true,
};
}
const queryArgs: ListImagesArgs = {
...baseQueryArgs,
offset: data.ids.length,
limit: IMAGE_LIMIT,
};
const images = imagesSelectors.selectAll(data);
const currentImageIndex = images.findIndex(
(i) => i.image_name === lastSelectedImage.image_name
);
const widthOfGalleryImage = Object.values(document.getElementsByClassName("gallerygrid-image"))[0]?.clientWidth
const widthOfGalleryGrid = document.getElementById("gallery-grid")?.clientWidth
const imagesPerRow = Math.floor((widthOfGalleryGrid ?? 0) / (widthOfGalleryImage ?? 1))
const nextImageIndex = clamp(currentImageIndex + 1, 0, images.length - 1);
const prevImageIndex = clamp(currentImageIndex - 1, 0, images.length - 1);
const topImageIndex = clamp(currentImageIndex - imagesPerRow,0, images.length - 1)
const bottomImageIndex = clamp(currentImageIndex + imagesPerRow,0, images.length - 1)
const nextImageId = images[nextImageIndex]?.image_name;
const prevImageId = images[prevImageIndex]?.image_name;
const topImageId = images[topImageIndex]?.image_name
const bottomImageId = images[bottomImageIndex]?.image_name
const nextImage = nextImageId
? imagesSelectors.selectById(data, nextImageId)
: undefined;
const prevImage = prevImageId
? imagesSelectors.selectById(data, prevImageId)
: undefined;
const topImage = topImageId
? imagesSelectors.selectById(data, topImageId)
: undefined;
const bottomImage = bottomImageId
? imagesSelectors.selectById(data, bottomImageId)
: undefined;
const imagesLength = images.length;
return {
loadedImagesCount: images.length,
currentImageIndex,
areMoreImagesAvailable: (totalsData?.total ?? 0) > imagesLength,
isFetching: status === 'pending',
nextImage,
prevImage,
nextImageIndex,
prevImageIndex,
queryArgs,
topImageIndex,
topImage,
bottomImageIndex,
bottomImage
};
}
);
export const useNextPrevImage = () => {
const dispatch = useAppDispatch();
const {
nextImage,
nextImageIndex,
prevImage,
prevImageIndex,
areMoreImagesAvailable,
isFetching,
queryArgs,
loadedImagesCount,
currentImageIndex,
topImageIndex,
topImage,
bottomImageIndex,
bottomImage
} = useAppSelector(nextPrevImageButtonsSelector);
const handlePrevImage = useCallback(() => {
prevImage && dispatch(imageSelected(prevImage));
const range = $useNextPrevImageState.get().virtuosoRangeRef?.current;
const virtuoso = $useNextPrevImageState.get().virtuosoRef?.current;
if (!range || !virtuoso) {
return;
}
if (
prevImageIndex !== undefined &&
(prevImageIndex < range.startIndex || prevImageIndex > range.endIndex)
) {
virtuoso.scrollToIndex({
index: prevImageIndex,
behavior: 'smooth',
align: getScrollToIndexAlign(prevImageIndex, range),
});
}
}, [dispatch, prevImage, prevImageIndex]);
const handleNextImage = useCallback(() => {
nextImage && dispatch(imageSelected(nextImage));
const range = $useNextPrevImageState.get().virtuosoRangeRef?.current;
const virtuoso = $useNextPrevImageState.get().virtuosoRef?.current;
if (!range || !virtuoso) {
return;
}
if (
nextImageIndex !== undefined &&
(nextImageIndex < range.startIndex || nextImageIndex > range.endIndex)
) {
virtuoso.scrollToIndex({
index: nextImageIndex,
behavior: 'smooth',
align: getScrollToIndexAlign(nextImageIndex, range),
});
}
}, [dispatch, nextImage, nextImageIndex]);
const handleTopImage = useCallback(() => {
topImage && dispatch(imageSelected(topImage));
const range = $useNextPrevImageState.get().virtuosoRangeRef?.current;
const virtuoso = $useNextPrevImageState.get().virtuosoRef?.current;
if (!range || !virtuoso) {
return;
}
if (
topImageIndex !== undefined &&
(topImageIndex < range.startIndex || topImageIndex > range.endIndex)
) {
virtuoso.scrollToIndex({
index: topImageIndex,
behavior: 'smooth',
align: getScrollToIndexAlign(topImageIndex, range),
});
}
},[dispatch, topImage, topImageIndex])
const handleBottomImage = useCallback(() => {
bottomImage && dispatch(imageSelected(bottomImage));
const range = $useNextPrevImageState.get().virtuosoRangeRef?.current;
const virtuoso = $useNextPrevImageState.get().virtuosoRef?.current;
if (!range || !virtuoso) {
return;
}
if (
bottomImageIndex !== undefined &&
(bottomImageIndex < range.startIndex || bottomImageIndex > range.endIndex)
) {
virtuoso.scrollToIndex({
index: bottomImageIndex,
behavior: 'smooth',
align: getScrollToIndexAlign(bottomImageIndex, range),
});
}
},[dispatch, bottomImage, bottomImageIndex])
const [listImages] = useLazyListImagesQuery();
const handleLoadMoreImages = useCallback(() => {
listImages(queryArgs);
}, [listImages, queryArgs]);
return {
handlePrevImage,
handleNextImage,
isOnFirstImage: currentImageIndex === 0,
isOnLastImage:
currentImageIndex !== undefined &&
currentImageIndex === loadedImagesCount - 1,
nextImage,
prevImage,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,
handleTopImage,
handleBottomImage
};
};

View File

@ -0,0 +1,52 @@
import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
import { getIsVisible } from 'features/gallery/util/getIsVisible';
import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
import { useEffect, useRef } from 'react';
/**
* Scrolls an image into view when it is selected. This is necessary because
* the image grid is virtualized, so the image may not be visible when it is
* selected.
*
* Also handles when an image is selected programmatically - for example, when
* auto-switching the new gallery images.
*
* @param isSelected Whether the image is selected.
* @param index The index of the image in the gallery.
* @param selectionCount The number of images selected.
* @returns
*/
export const useScrollIntoView = (
isSelected: boolean,
index: number,
selectionCount: number
) => {
const imageContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isSelected || selectionCount !== 1) {
return;
}
const virtuosoContext = virtuosoGridRefs.get();
const range = virtuosoContext.virtuosoRangeRef?.current;
const root = virtuosoContext.rootRef?.current;
const virtuoso = virtuosoContext.virtuosoRef?.current;
if (!range || !virtuoso || !root) {
return;
}
const itemRect = imageContainerRef.current?.getBoundingClientRect();
const rootRect = root.getBoundingClientRect();
if (!itemRect || !getIsVisible(itemRect, rootRect)) {
virtuoso.scrollToIndex({
index,
align: getScrollToIndexAlign(index, range),
});
}
}, [isSelected, index, selectionCount]);
return imageContainerRef;
};

View File

@ -1,46 +0,0 @@
import type { VirtuosoGalleryContext } from 'features/gallery/components/ImageGrid/types';
import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
import { useEffect, useRef } from 'react';
export const useScrollToVisible = (
isSelected: boolean,
index: number,
selectionCount: number,
virtuosoContext: VirtuosoGalleryContext
) => {
const imageContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (
!isSelected ||
selectionCount !== 1 ||
!virtuosoContext.rootRef.current ||
!virtuosoContext.virtuosoRef.current ||
!virtuosoContext.virtuosoRangeRef.current ||
!imageContainerRef.current
) {
return;
}
const itemRect = imageContainerRef.current.getBoundingClientRect();
const rootRect = virtuosoContext.rootRef.current.getBoundingClientRect();
const itemIsVisible =
itemRect.top >= rootRect.top &&
itemRect.bottom <= rootRect.bottom &&
itemRect.left >= rootRect.left &&
itemRect.right <= rootRect.right;
if (!itemIsVisible) {
virtuosoContext.virtuosoRef.current.scrollToIndex({
index,
behavior: 'smooth',
align: getScrollToIndexAlign(
index,
virtuosoContext.virtuosoRangeRef.current
),
});
}
}, [isSelected, index, selectionCount, virtuosoContext]);
return imageContainerRef;
};

View File

@ -0,0 +1,12 @@
/**
* Gets whether the item is visible in the root element.
*/
export const getIsVisible = (itemRect: DOMRect, rootRect: DOMRect) => {
return (
itemRect.top >= rootRect.top &&
itemRect.bottom <= rootRect.bottom &&
itemRect.left >= rootRect.left &&
itemRect.right <= rootRect.right
);
};