mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
Merge branch 'main' into boards-ui-update
This commit is contained in:
@ -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",
|
||||
|
@ -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);
|
||||
|
@ -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));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
@ -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));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
@ -91,7 +91,7 @@ export const addInvocationCompleteEventListener = (startAppListening: AppStartLi
|
||||
);
|
||||
}
|
||||
|
||||
dispatch(offsetChanged(0));
|
||||
dispatch(offsetChanged({ offset: 0 }));
|
||||
|
||||
if (!imageDTO.board_id && gallery.selectedBoardId !== 'none') {
|
||||
dispatch(
|
||||
|
@ -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')}
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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')}
|
||||
|
@ -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"
|
||||
/>
|
||||
|
@ -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}
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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]
|
||||
);
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -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;
|
||||
|
Reference in New Issue
Block a user