Merge branch 'main' into boards-ui-update

This commit is contained in:
chainchompa
2024-07-08 22:06:26 -04:00
committed by GitHub
134 changed files with 835 additions and 676 deletions

View File

@ -21,6 +21,7 @@
"archived": "Archived",
"autoAddBoard": "Auto-Add Board",
"boards": "Boards",
"selectedForAutoAdd": "Selected for Auto-Add",
"bottomMessage": "Deleting this board and its images will reset any features currently using them.",
"cancel": "Cancel",
"changeBoard": "Change Board",

View File

@ -1,7 +1,6 @@
import type { TypedStartListening } from '@reduxjs/toolkit';
import { createListenerMiddleware } from '@reduxjs/toolkit';
import { addCommitStagingAreaImageListener } from 'app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener';
import { addFirstListImagesListener } from 'app/store/middleware/listenerMiddleware/listeners/addFirstListImagesListener.ts';
import { addAnyEnqueuedListener } from 'app/store/middleware/listenerMiddleware/listeners/anyEnqueued';
import { addAppConfigReceivedListener } from 'app/store/middleware/listenerMiddleware/listeners/appConfigReceived';
import { addAppStartedListener } from 'app/store/middleware/listenerMiddleware/listeners/appStarted';
@ -23,9 +22,10 @@ import { addEnqueueRequestedCanvasListener } from 'app/store/middleware/listener
import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear';
import { addEnqueueRequestedNodes } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes';
import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
import { addGalleryOffsetChangedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged';
import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema';
import { addImageAddedToBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard';
import { addRequestedSingleImageDeletionListener } from 'app/store/middleware/listenerMiddleware/listeners/imageDeleted';
import { addImageDeletionListeners } from 'app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners';
import { addImageDroppedListener } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped';
import { addImageRemovedFromBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard';
import { addImagesStarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesStarred';
@ -69,7 +69,7 @@ const startAppListening = listenerMiddleware.startListening as AppStartListening
addImageUploadedFulfilledListener(startAppListening);
// Image deleted
addRequestedSingleImageDeletionListener(startAppListening);
addImageDeletionListeners(startAppListening);
addDeleteBoardAndImagesFulfilledListener(startAppListening);
addImageToDeleteSelectedListener(startAppListening);
@ -79,6 +79,7 @@ addImagesUnstarredListener(startAppListening);
// Gallery
addGalleryImageClickedListener(startAppListening);
addGalleryOffsetChangedListener(startAppListening);
// User Invoked
addEnqueueRequestedCanvasListener(startAppListening);
@ -137,7 +138,6 @@ addModelSelectedListener(startAppListening);
addAppStartedListener(startAppListening);
addModelsLoadedListener(startAppListening);
addAppConfigReceivedListener(startAppListening);
addFirstListImagesListener(startAppListening);
// Ad-hoc upscale workflwo
addUpscaleRequestedListener(startAppListening);

View File

@ -1,27 +0,0 @@
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { IMAGE_CATEGORIES } from 'features/gallery/store/types';
import { imagesApi } from 'services/api/endpoints/images';
import { getListImagesUrl } from 'services/api/util';
export const addFirstListImagesListener = (startAppListening: AppStartListening) => {
startAppListening({
matcher: imagesApi.endpoints.listImages.matchFulfilled,
effect: async (action, { dispatch, unsubscribe, cancelActiveListeners }) => {
// Only run this listener on the first listImages request for no-board images
if (action.meta.arg.queryCacheKey !== getListImagesUrl({ board_id: 'none', categories: IMAGE_CATEGORIES })) {
return;
}
// this should only run once
cancelActiveListeners();
unsubscribe();
const data = action.payload;
if (data.items.length > 0) {
dispatch(imageSelected(data.items[0] ?? null));
}
},
});
};

View File

@ -0,0 +1,119 @@
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageToCompareChanged, offsetChanged, selectionChanged } from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images';
export const addGalleryOffsetChangedListener = (startAppListening: AppStartListening) => {
/**
* When the user changes pages in the gallery, we need to wait until the next page of images is loaded, then maybe
* update the selection.
*
* There are a three scenarios:
*
* 1. The page is changed by clicking the pagination buttons. No changes to selection are needed.
*
* 2. The page is changed by using the arrow keys (without alt).
* - When going backwards, select the last image.
* - When going forwards, select the first image.
*
* 3. The page is changed by using the arrows keys with alt. This means the user is changing the comparison image.
* - When going backwards, select the last image _as the comparison image_.
* - When going forwards, select the first image _as the comparison image_.
*/
startAppListening({
actionCreator: offsetChanged,
effect: async (action, { dispatch, getState, getOriginalState, take, cancelActiveListeners }) => {
// Cancel any active listeners to prevent the selection from changing without user input
cancelActiveListeners();
const { withHotkey } = action.payload;
if (!withHotkey) {
// User changed pages by clicking the pagination buttons - no changes to selection
return;
}
const originalState = getOriginalState();
const prevOffset = originalState.gallery.offset;
const offset = getState().gallery.offset;
if (offset === prevOffset) {
// The page didn't change - bail
return;
}
/**
* We need to wait until the next page of images is loaded before updating the selection, so we use the correct
* page of images.
*
* The simplest way to do it would be to use `take` to wait for the next fulfilled action, but RTK-Q doesn't
* dispatch an action on cache hits. This means the `take` will only return if the cache is empty. If the user
* changes to a cached page - a common situation - the `take` will never resolve.
*
* So we need to take a two-step approach. First, check if we have data in the cache for the page of images. If
* we have data cached, use it to update the selection. If we don't have data cached, wait for the next fulfilled
* action, which updates the cache, then use the cache to update the selection.
*/
// Check if we have data in the cache for the page of images
const queryArgs = selectListImagesQueryArgs(getState());
let { data } = imagesApi.endpoints.listImages.select(queryArgs)(getState());
// No data yet - wait for the network request to complete
if (!data) {
const takeResult = await take(imagesApi.endpoints.listImages.matchFulfilled, 5000);
if (!takeResult) {
// The request didn't complete in time - bail
return;
}
data = takeResult[0].payload;
}
// We awaited a network request - state could have changed, get fresh state
const state = getState();
const { selection, imageToCompare } = state.gallery;
const imageDTOs = data?.items;
if (!imageDTOs) {
// The page didn't load - bail
return;
}
if (withHotkey === 'arrow') {
// User changed pages by using the arrow keys - selection changes to first or last image depending
if (offset < prevOffset) {
// We've gone backwards
const lastImage = imageDTOs[imageDTOs.length - 1];
if (!selection.some((selectedImage) => selectedImage.image_name === lastImage?.image_name)) {
dispatch(selectionChanged(lastImage ? [lastImage] : []));
}
} else {
// We've gone forwards
const firstImage = imageDTOs[0];
if (!selection.some((selectedImage) => selectedImage.image_name === firstImage?.image_name)) {
dispatch(selectionChanged(firstImage ? [firstImage] : []));
}
}
return;
}
if (withHotkey === 'alt+arrow') {
// User changed pages by using the arrow keys with alt - comparison image changes to first or last depending
if (offset < prevOffset) {
// We've gone backwards
const lastImage = imageDTOs[imageDTOs.length - 1];
if (lastImage && imageToCompare?.image_name !== lastImage.image_name) {
dispatch(imageToCompareChanged(lastImage));
}
} else {
// We've gone forwards
const firstImage = imageDTOs[0];
if (firstImage && imageToCompare?.image_name !== firstImage.image_name) {
dispatch(imageToCompareChanged(firstImage));
}
}
return;
}
},
});
};

View File

@ -22,11 +22,11 @@ import { imageSelected } from 'features/gallery/store/gallerySlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { isImageFieldInputInstance } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { forEach } from 'lodash-es';
import { api } from 'services/api';
import { forEach, intersectionBy } from 'lodash-es';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
// Some utils to delete images from different parts of the app
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
state.nodes.present.nodes.forEach((node) => {
if (!isInvocationNode(node)) {
@ -97,10 +97,11 @@ const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image
});
};
export const addRequestedSingleImageDeletionListener = (startAppListening: AppStartListening) => {
export const addImageDeletionListeners = (startAppListening: AppStartListening) => {
// Handle single image deletion
startAppListening({
actionCreator: imageDeletionConfirmed,
effect: async (action, { dispatch, getState, condition }) => {
effect: async (action, { dispatch, getState }) => {
const { imageDTOs, imagesUsage } = action.payload;
if (imageDTOs.length !== 1 || imagesUsage.length !== 1) {
@ -116,49 +117,46 @@ export const addRequestedSingleImageDeletionListener = (startAppListening: AppSt
return;
}
dispatch(isModalOpenChanged(false));
const state = getState();
try {
const state = getState();
await dispatch(imagesApi.endpoints.deleteImage.initiate(imageDTO)).unwrap();
// We need to reset the features where the image is in use - none of these work if their image(s) don't exist
if (imageUsage.isCanvasImage) {
dispatch(resetCanvas());
}
if (state.gallery.selection.some((i) => i.image_name === imageDTO.image_name)) {
// The deleted image was a selected image, we need to select the next image
const newSelection = state.gallery.selection.filter((i) => i.image_name !== imageDTO.image_name);
if (newSelection.length > 0) {
return;
}
// Get the current list of images and select the same index
const baseQueryArgs = selectListImagesQueryArgs(state);
const data = imagesApi.endpoints.listImages.select(baseQueryArgs)(state).data;
if (data) {
const deletedImageIndex = data.items.findIndex((i) => i.image_name === imageDTO.image_name);
const nextImage = data.items[deletedImageIndex + 1] ?? data.items[0] ?? null;
dispatch(imageSelected(nextImage));
}
}
// We need to reset the features where the image is in use - none of these work if their image(s) don't exist
if (imageUsage.isCanvasImage) {
dispatch(resetCanvas());
}
imageDTOs.forEach((imageDTO) => {
deleteControlAdapterImages(state, dispatch, imageDTO);
deleteNodesImages(state, dispatch, imageDTO);
deleteControlLayerImages(state, dispatch, imageDTO);
});
// Delete from server
const { requestId } = dispatch(imagesApi.endpoints.deleteImage.initiate(imageDTO));
// Wait for successful deletion, then trigger boards to re-fetch
const wasImageDeleted = await condition(
(action) => imagesApi.endpoints.deleteImage.matchFulfilled(action) && action.meta.requestId === requestId,
30000
);
if (wasImageDeleted) {
dispatch(api.util.invalidateTags([{ type: 'Board', id: imageDTO.board_id ?? 'none' }]));
}
const lastSelectedImage = state.gallery.selection[state.gallery.selection.length - 1]?.image_name;
if (imageDTO && imageDTO?.image_name === lastSelectedImage) {
const baseQueryArgs = selectListImagesQueryArgs(state);
const { data } = imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
if (data && data.items) {
const newlySelectedImage = data?.items.find((img) => img.image_name !== imageDTO?.image_name);
dispatch(imageSelected(newlySelectedImage || null));
} else {
dispatch(imageSelected(null));
}
} catch {
// no-op
} finally {
dispatch(isModalOpenChanged(false));
}
},
});
// Handle multiple image deletion
startAppListening({
actionCreator: imageDeletionConfirmed,
effect: async (action, { dispatch, getState }) => {
@ -170,20 +168,18 @@ export const addRequestedSingleImageDeletionListener = (startAppListening: AppSt
}
try {
// Delete from server
await dispatch(imagesApi.endpoints.deleteImages.initiate({ imageDTOs })).unwrap();
const state = getState();
const queryArgs = selectListImagesQueryArgs(state);
const { data } = imagesApi.endpoints.listImages.select(queryArgs)(state);
await dispatch(imagesApi.endpoints.deleteImages.initiate({ imageDTOs })).unwrap();
if (data && data.items[0]) {
dispatch(imageSelected(data.items[0]));
} else {
dispatch(imageSelected(null));
if (intersectionBy(state.gallery.selection, imageDTOs, 'image_name').length > 0) {
// Some selected images were deleted, need to select the next image
const queryArgs = selectListImagesQueryArgs(state);
const { data } = imagesApi.endpoints.listImages.select(queryArgs)(state);
if (data) {
dispatch(imageSelected(null));
}
}
dispatch(isModalOpenChanged(false));
// We need to reset the features where the image is in use - none of these work if their image(s) don't exist
if (imagesUsage.some((i) => i.isCanvasImage)) {
@ -197,14 +193,20 @@ export const addRequestedSingleImageDeletionListener = (startAppListening: AppSt
});
} catch {
// no-op
} finally {
dispatch(isModalOpenChanged(false));
}
},
});
// When we list images, if no images is selected, select the first one.
startAppListening({
matcher: imagesApi.endpoints.deleteImage.matchPending,
effect: () => {
//
matcher: imagesApi.endpoints.listImages.matchFulfilled,
effect: (action, { dispatch, getState }) => {
const selection = getState().gallery.selection;
if (selection.length === 0) {
dispatch(imageSelected(action.payload.items[0] ?? null));
}
},
});

View File

@ -91,7 +91,7 @@ export const addInvocationCompleteEventListener = (startAppListening: AppStartLi
);
}
dispatch(offsetChanged(0));
dispatch(offsetChanged({ offset: 0 }));
if (!imageDTO.board_id && gallery.selectedBoardId !== 'none') {
dispatch(

View File

@ -71,13 +71,11 @@ const BoardContextMenu = ({ board, setBoardToDelete, children }: Props) => {
() => (
<MenuList visibility="visible">
<MenuGroup title={boardName}>
<MenuItem
icon={<PiPlusBold />}
isDisabled={isSelectedForAutoAdd || autoAssignBoardOnClick}
onClick={handleSetAutoAdd}
>
{t('boards.menuItemAutoAdd')}
</MenuItem>
{!autoAssignBoardOnClick && (
<MenuItem icon={<PiPlusBold />} isDisabled={isSelectedForAutoAdd} onClick={handleSetAutoAdd}>
{isSelectedForAutoAdd ? t('boards.selectedForAutoAdd') : t('boards.menuItemAutoAdd')}
</MenuItem>
)}
{isBulkDownloadEnabled && (
<MenuItem icon={<PiDownloadBold />} onClickCapture={handleBulkDownload}>
{t('boards.downloadBoard')}

View File

@ -55,10 +55,10 @@ const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps
const handleSelectBoard = useCallback(() => {
dispatch(boardIdSelected({ boardId: board_id }));
if (autoAssignBoardOnClick && !board.archived) {
if (autoAssignBoardOnClick) {
dispatch(autoAddBoardIdChanged(board_id));
}
}, [board_id, autoAssignBoardOnClick, dispatch, board.archived]);
}, [board_id, autoAssignBoardOnClick, dispatch]);
const [updateBoard, { isLoading: isUpdateBoardLoading }] = useUpdateBoardMutation();

View File

@ -33,13 +33,11 @@ const NoBoardBoardContextMenu = ({ children }: Props) => {
() => (
<MenuList visibility="visible">
<MenuGroup title={t('boards.uncategorized')}>
<MenuItem
icon={<PiPlusBold />}
isDisabled={isSelectedForAutoAdd || autoAssignBoardOnClick}
onClick={handleSetAutoAdd}
>
{t('boards.menuItemAutoAdd')}
</MenuItem>
{!autoAssignBoardOnClick && (
<MenuItem icon={<PiPlusBold />} isDisabled={isSelectedForAutoAdd} onClick={handleSetAutoAdd}>
{isSelectedForAutoAdd ? t('boards.selectedForAutoAdd') : t('boards.menuItemAutoAdd')}
</MenuItem>
)}
{isBulkDownloadEnabled && (
<MenuItem icon={<PiDownloadBold />} onClickCapture={handleBulkDownload}>
{t('boards.downloadBoard')}

View File

@ -1,11 +1,20 @@
import { Button, Flex, IconButton, Spacer } from '@invoke-ai/ui-library';
import { ELLIPSIS, useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination';
import { useCallback } from 'react';
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
export const GalleryPagination = () => {
const { goPrev, goNext, isPrevEnabled, isNextEnabled, pageButtons, goToPage, currentPage, total } =
useGalleryPagination();
const onClickPrev = useCallback(() => {
goPrev();
}, [goPrev]);
const onClickNext = useCallback(() => {
goNext();
}, [goNext]);
if (!total) {
return null;
}
@ -16,7 +25,7 @@ export const GalleryPagination = () => {
size="sm"
aria-label="prev"
icon={<PiCaretLeftBold />}
onClick={goPrev}
onClick={onClickPrev}
isDisabled={!isPrevEnabled}
variant="ghost"
/>
@ -45,7 +54,7 @@ export const GalleryPagination = () => {
size="sm"
aria-label="next"
icon={<PiCaretRightBold />}
onClick={goNext}
onClick={onClickNext}
isDisabled={!isNextEnabled}
variant="ghost"
/>

View File

@ -1,18 +1,23 @@
import { IconButton, Input, InputGroup, InputRightElement } from '@invoke-ai/ui-library';
import { IconButton, Input, InputGroup, InputRightElement, Spinner } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { searchTermChanged } from 'features/gallery/store/gallerySlice';
import { debounce } from 'lodash-es';
import type { ChangeEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
import { useListImagesQuery } from 'services/api/endpoints/images';
export const GallerySearch = () => {
const dispatch = useAppDispatch();
const { searchTerm } = useAppSelector((s) => s.gallery);
const searchTerm = useAppSelector((s) => s.gallery.searchTerm);
const { t } = useTranslation();
const [searchTermInput, setSearchTermInput] = useState(searchTerm);
const queryArgs = useAppSelector(selectListImagesQueryArgs);
const { isPending } = useListImagesQuery(queryArgs, {
selectFromResult: ({ isLoading, isFetching }) => ({ isPending: isLoading || isFetching }),
});
const debouncedSetSearchTerm = useMemo(() => {
return debounce((value: string) => {
dispatch(searchTermChanged(value));
@ -40,7 +45,12 @@ export const GallerySearch = () => {
onChange={handleChangeInput}
data-testid="image-search-input"
/>
{searchTermInput.length && (
{isPending && (
<InputRightElement h="full" pe={2}>
<Spinner size="sm" opacity={0.5} />
</InputRightElement>
)}
{!isPending && searchTermInput.length && (
<InputRightElement h="full" pe={2}>
<IconButton
onClick={handleClearInput}

View File

@ -17,8 +17,8 @@ export const GallerySelectionCountTag = () => {
}, [dispatch]);
const onSelectPage = useCallback(() => {
dispatch(selectionChanged(imageDTOs));
}, [dispatch, imageDTOs]);
dispatch(selectionChanged([...selection, ...imageDTOs]));
}, [dispatch, selection, imageDTOs]);
useHotkeys(['ctrl+a', 'meta+a'], onSelectPage, { preventDefault: true }, [onSelectPage]);

View File

@ -1,11 +1,11 @@
import type { ChakraProps } from '@invoke-ai/ui-library';
import { Box, Flex, IconButton, Spinner } from '@invoke-ai/ui-library';
import { Box, IconButton } from '@invoke-ai/ui-library';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation';
import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination';
import { memo } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDoubleRightBold, PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
const nextPrevButtonStyles: ChakraProps['sx'] = {
color: 'base.100',
@ -14,52 +14,78 @@ const nextPrevButtonStyles: ChakraProps['sx'] = {
const NextPrevImageButtons = () => {
const { t } = useTranslation();
const { prevImage, nextImage, isOnFirstImage, isOnLastImage } = useGalleryNavigation();
const { prevImage, nextImage, isOnFirstImageOfView, isOnLastImageOfView } = useGalleryNavigation();
const { isFetching } = useGalleryImages().queryResult;
const { isNextEnabled, goNext } = useGalleryPagination();
const { isNextEnabled, goNext, isPrevEnabled, goPrev } = useGalleryPagination();
const shouldShowLeftArrow = useMemo(() => {
if (!isOnFirstImageOfView) {
return true;
}
if (isPrevEnabled) {
return true;
}
return false;
}, [isOnFirstImageOfView, isPrevEnabled]);
const onClickLeftArrow = useCallback(() => {
if (isOnFirstImageOfView) {
if (isPrevEnabled && !isFetching) {
goPrev('arrow');
}
} else {
prevImage();
}
}, [goPrev, isFetching, isOnFirstImageOfView, isPrevEnabled, prevImage]);
const shouldShowRightArrow = useMemo(() => {
if (!isOnLastImageOfView) {
return true;
}
if (isNextEnabled) {
return true;
}
return false;
}, [isNextEnabled, isOnLastImageOfView]);
const onClickRightArrow = useCallback(() => {
if (isOnLastImageOfView) {
if (isNextEnabled && !isFetching) {
goNext('arrow');
}
} else {
nextImage();
}
}, [goNext, isFetching, isNextEnabled, isOnLastImageOfView, nextImage]);
return (
<Box pos="relative" h="full" w="full">
<Box pos="absolute" top="50%" transform="translate(0, -50%)" insetInlineStart={1}>
{!isOnFirstImage && (
{shouldShowLeftArrow && (
<IconButton
aria-label={t('accessibility.previousImage')}
icon={<PiCaretLeftBold size={64} />}
variant="unstyled"
onClick={prevImage}
onClick={onClickLeftArrow}
boxSize={16}
sx={nextPrevButtonStyles}
isDisabled={isFetching}
/>
)}
</Box>
<Box pos="absolute" top="50%" transform="translate(0, -50%)" insetInlineEnd={6}>
{!isOnLastImage && (
{shouldShowRightArrow && (
<IconButton
aria-label={t('accessibility.nextImage')}
icon={<PiCaretRightBold size={64} />}
variant="unstyled"
onClick={nextImage}
onClick={onClickRightArrow}
boxSize={16}
sx={nextPrevButtonStyles}
isDisabled={isFetching}
/>
)}
{isOnLastImage && isNextEnabled && !isFetching && (
<IconButton
aria-label={t('accessibility.loadMore')}
icon={<PiCaretDoubleRightBold size={64} />}
variant="unstyled"
onClick={goNext}
boxSize={16}
sx={nextPrevButtonStyles}
/>
)}
{isOnLastImage && isNextEnabled && isFetching && (
<Flex w={16} h={16} alignItems="center" justifyContent="center">
<Spinner opacity={0.5} size="xl" />
</Flex>
)}
</Box>
</Box>
);

View File

@ -28,7 +28,8 @@ export const useGalleryHotkeys = () => {
handleRightImage,
handleUpImage,
handleDownImage,
areImagesBelowCurrent,
isOnFirstRow,
isOnLastRow,
isOnFirstImageOfView,
isOnLastImageOfView,
} = useGalleryNavigation();
@ -37,7 +38,7 @@ export const useGalleryHotkeys = () => {
['left', 'alt+left'],
(e) => {
if (isOnFirstImageOfView && isPrevEnabled && !queryResult.isFetching) {
goPrev();
goPrev(e.altKey ? 'alt+arrow' : 'arrow');
return;
}
canNavigateGallery && handleLeftImage(e.altKey);
@ -52,7 +53,7 @@ export const useGalleryHotkeys = () => {
return;
}
if (isOnLastImageOfView && isNextEnabled && !queryResult.isFetching) {
goNext();
goNext(e.altKey ? 'alt+arrow' : 'arrow');
return;
}
if (!isOnLastImageOfView) {
@ -65,22 +66,26 @@ export const useGalleryHotkeys = () => {
useHotkeys(
['up', 'alt+up'],
(e) => {
if (isOnFirstRow && isPrevEnabled && !queryResult.isFetching) {
goPrev(e.altKey ? 'alt+arrow' : 'arrow');
return;
}
handleUpImage(e.altKey);
},
{ preventDefault: true },
[handleUpImage]
[handleUpImage, canNavigateGallery, isOnFirstRow, goPrev, isPrevEnabled, queryResult.isFetching]
);
useHotkeys(
['down', 'alt+down'],
(e) => {
if (!areImagesBelowCurrent && isNextEnabled && !queryResult.isFetching) {
goNext();
if (isOnLastRow && isNextEnabled && !queryResult.isFetching) {
goNext(e.altKey ? 'alt+arrow' : 'arrow');
return;
}
handleDownImage(e.altKey);
},
{ preventDefault: true },
[areImagesBelowCurrent, goNext, isNextEnabled, queryResult.isFetching, handleDownImage]
[isOnLastRow, goNext, isNextEnabled, queryResult.isFetching, handleDownImage]
);
};

View File

@ -112,7 +112,8 @@ type UseGalleryNavigationReturn = {
nextImage: () => void;
isOnFirstImage: boolean;
isOnLastImage: boolean;
areImagesBelowCurrent: boolean;
isOnFirstRow: boolean;
isOnLastRow: boolean;
isOnFirstImageOfView: boolean;
isOnLastImageOfView: boolean;
};
@ -167,10 +168,11 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
[lastSelectedImageIndex, loadedImagesCount]
);
const areImagesBelowCurrent = useMemo(() => {
const imagesPerRow = getImagesPerRow();
return lastSelectedImageIndex + imagesPerRow < loadedImagesCount;
}, [lastSelectedImageIndex, loadedImagesCount]);
const isOnFirstRow = useMemo(() => lastSelectedImageIndex < getImagesPerRow(), [lastSelectedImageIndex]);
const isOnLastRow = useMemo(
() => lastSelectedImageIndex >= loadedImagesCount - getImagesPerRow(),
[lastSelectedImageIndex, loadedImagesCount]
);
const isOnFirstImageOfView = useMemo(() => {
return lastSelectedImageIndex === 0;
@ -223,7 +225,8 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
handleDownImage,
isOnFirstImage,
isOnLastImage,
areImagesBelowCurrent,
isOnFirstRow,
isOnLastRow,
nextImage,
prevImage,
isOnFirstImageOfView,

View File

@ -80,25 +80,31 @@ export const useGalleryPagination = () => {
return offset > 0;
}, [count, offset]);
const goNext = useCallback(() => {
dispatch(offsetChanged(offset + (limit || 0)));
}, [dispatch, offset, limit]);
const goNext = useCallback(
(withHotkey?: 'arrow' | 'alt+arrow') => {
dispatch(offsetChanged({ offset: offset + (limit || 0), withHotkey }));
},
[dispatch, offset, limit]
);
const goPrev = useCallback(() => {
dispatch(offsetChanged(Math.max(offset - (limit || 0), 0)));
}, [dispatch, offset, limit]);
const goPrev = useCallback(
(withHotkey?: 'arrow' | 'alt+arrow') => {
dispatch(offsetChanged({ offset: Math.max(offset - (limit || 0), 0), withHotkey }));
},
[dispatch, offset, limit]
);
const goToPage = useCallback(
(page: number) => {
dispatch(offsetChanged(page * (limit || 0)));
dispatch(offsetChanged({ offset: page * (limit || 0) }));
},
[dispatch, limit]
);
const goToFirst = useCallback(() => {
dispatch(offsetChanged(0));
dispatch(offsetChanged({ offset: 0 }));
}, [dispatch]);
const goToLast = useCallback(() => {
dispatch(offsetChanged((pages - 1) * (limit || 0)));
dispatch(offsetChanged({ offset: (pages - 1) * (limit || 0) }));
}, [dispatch, pages, limit]);
// handle when total/pages decrease and user is on high page number (ie bulk removing or deleting)
@ -127,44 +133,22 @@ export const useGalleryPagination = () => {
return Math.min((currentPage + 1) * (limit || 0), total);
}, [currentPage, limit, total]);
const api = useMemo(
() => ({
count,
total,
currentPage,
pages,
isNextEnabled,
isPrevEnabled,
goNext,
goPrev,
goToPage,
goToFirst,
goToLast,
pageButtons,
isFirstEnabled,
isLastEnabled,
rangeDisplay,
numberOnPage,
}),
[
count,
total,
currentPage,
pages,
isNextEnabled,
isPrevEnabled,
goNext,
goPrev,
goToPage,
goToFirst,
goToLast,
pageButtons,
isFirstEnabled,
isLastEnabled,
rangeDisplay,
numberOnPage,
]
);
return api;
return {
count,
total,
currentPage,
pages,
isNextEnabled,
isPrevEnabled,
goNext,
goPrev,
goToPage,
goToFirst,
goToLast,
pageButtons,
isFirstEnabled,
isLastEnabled,
rangeDisplay,
numberOnPage,
};
};

View File

@ -19,7 +19,7 @@ const initialGalleryState: GalleryState = {
limit: 20,
offset: 0,
starredFirst: true,
orderDir: 'ASC',
orderDir: 'DESC',
searchTerm: '',
isImageViewerOpen: true,
imageToCompare: null,
@ -104,8 +104,9 @@ export const gallerySlice = createSlice({
comparisonFitChanged: (state, action: PayloadAction<'contain' | 'fill'>) => {
state.comparisonFit = action.payload;
},
offsetChanged: (state, action: PayloadAction<number>) => {
state.offset = action.payload;
offsetChanged: (state, action: PayloadAction<{ offset: number; withHotkey?: 'arrow' | 'alt+arrow' }>) => {
const { offset } = action.payload;
state.offset = offset;
},
limitChanged: (state, action: PayloadAction<number>) => {
state.limit = action.payload;
@ -121,6 +122,7 @@ export const gallerySlice = createSlice({
},
searchTermChanged: (state, action: PayloadAction<string>) => {
state.searchTerm = action.payload;
state.offset = 0;
},
},
});

View File

@ -29,7 +29,7 @@ export const authToastMiddleware: Middleware = () => (next) => (action) => {
const endpointName = parsed.meta?.arg?.endpointName;
if (endpointName === 'getImageDTO') {
// do not show toast if problem is image access
return;
return next(action);
}
const customMessage = parsed.payload.data.detail !== 'Forbidden' ? parsed.payload.data.detail : undefined;