mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-02-13 02:45:17 -05:00
fix(ui): gallery updates on image completion
This commit is contained in:
@@ -11,7 +11,6 @@ import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddlewar
|
||||
import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear';
|
||||
import { addEnsureImageIsSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/ensureImageIsSelectedListener';
|
||||
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 { addImageRemovedFromBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard';
|
||||
@@ -47,7 +46,6 @@ addDeleteBoardAndImagesFulfilledListener(startAppListening);
|
||||
|
||||
// Gallery
|
||||
addGalleryImageClickedListener(startAppListening);
|
||||
addGalleryOffsetChangedListener(startAppListening);
|
||||
|
||||
// User Invoked
|
||||
addEnqueueRequestedLinear(startAppListening);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import { selectImageCollectionQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { uniq } from 'lodash-es';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
@@ -50,7 +50,7 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen
|
||||
effect: (action, { dispatch, getState }) => {
|
||||
const { imageName, shiftKey, ctrlKey, metaKey, altKey } = action.payload;
|
||||
const state = getState();
|
||||
const queryArgs = selectImageCollectionQueryArgs(state);
|
||||
const queryArgs = selectListImagesQueryArgs(state);
|
||||
|
||||
// Get cached image names for selection operations
|
||||
const imageNames = getCachedImageNames(state, queryArgs);
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
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 === lastImage?.image_name)) {
|
||||
dispatch(selectionChanged(lastImage ? [lastImage.image_name] : []));
|
||||
}
|
||||
} else {
|
||||
// We've gone forwards
|
||||
const firstImage = imageDTOs[0];
|
||||
if (!selection.some((selectedImage) => selectedImage === firstImage?.image_name)) {
|
||||
dispatch(selectionChanged(firstImage ? [firstImage.image_name] : []));
|
||||
}
|
||||
}
|
||||
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 !== lastImage.image_name) {
|
||||
dispatch(imageToCompareChanged(lastImage.image_name));
|
||||
}
|
||||
} else {
|
||||
// We've gone forwards
|
||||
const firstImage = imageDTOs[0];
|
||||
if (firstImage && imageToCompare !== firstImage.image_name) {
|
||||
dispatch(imageToCompareChanged(firstImage.image_name));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -39,6 +39,7 @@ import { authToastMiddleware } from 'services/api/authToastMiddleware';
|
||||
import type { JsonObject } from 'type-fest';
|
||||
|
||||
import { STORAGE_PREFIX } from './constants';
|
||||
import { getDebugLoggerMiddleware } from './middleware/debugLoggerMiddleware';
|
||||
import { actionSanitizer } from './middleware/devtools/actionSanitizer';
|
||||
import { actionsDenylist } from './middleware/devtools/actionsDenylist';
|
||||
import { stateSanitizer } from './middleware/devtools/stateSanitizer';
|
||||
@@ -176,7 +177,7 @@ export const createStore = (uniqueStoreKey?: string, persist = true) =>
|
||||
.concat(api.middleware)
|
||||
.concat(dynamicMiddlewares)
|
||||
.concat(authToastMiddleware)
|
||||
// .concat(getDebugLoggerMiddleware())
|
||||
.concat(getDebugLoggerMiddleware())
|
||||
.prepend(listenerMiddleware.middleware),
|
||||
enhancers: (getDefaultEnhancers) => {
|
||||
const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer());
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress';
|
||||
import { DndImage } from 'features/dnd/DndImage';
|
||||
import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer';
|
||||
import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
|
||||
import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common';
|
||||
import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
|
||||
import type { AnimationProps } from 'framer-motion';
|
||||
@@ -115,7 +114,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu
|
||||
left={0}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<NextPrevImageButtons />
|
||||
{/* <NextPrevImageButtons /> */}
|
||||
</Box>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -5,8 +5,8 @@ import { EMPTY_ARRAY } from 'app/store/constants';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import {
|
||||
selectGalleryImageMinimumWidth,
|
||||
selectImageCollectionQueryArgs,
|
||||
selectLastSelectedImage,
|
||||
selectListImagesQueryArgs,
|
||||
} from 'features/gallery/store/gallerySelectors';
|
||||
import { selectionChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
} from 'react-virtuoso';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { useGetImageNamesQuery, useListImagesQuery } from 'services/api/endpoints/images';
|
||||
import type { ImageDTO, ListImagesArgs } from 'services/api/types';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
import { GalleryImage } from './ImageGrid/GalleryImage';
|
||||
@@ -30,32 +30,36 @@ import { GalleryImage } from './ImageGrid/GalleryImage';
|
||||
const log = logger('gallery');
|
||||
|
||||
// Constants
|
||||
const PAGE_SIZE = 100;
|
||||
const VIEWPORT_BUFFER = 2048;
|
||||
const SCROLL_SEEK_VELOCITY_THRESHOLD = 4096;
|
||||
const DEBOUNCE_DELAY = 500;
|
||||
const SPINNER_OPACITY = 0.3;
|
||||
|
||||
type ListImagesQueryArgs = ReturnType<typeof selectListImagesQueryArgs>;
|
||||
|
||||
type GridContext = {
|
||||
queryArgs: ListImagesArgs;
|
||||
queryArgs: ListImagesQueryArgs;
|
||||
imageNames: string[];
|
||||
};
|
||||
|
||||
export const useDebouncedImageCollectionQueryArgs = () => {
|
||||
const _galleryQueryArgs = useAppSelector(selectImageCollectionQueryArgs);
|
||||
const _galleryQueryArgs = useAppSelector(selectListImagesQueryArgs);
|
||||
const [queryArgs] = useDebounce(_galleryQueryArgs, DEBOUNCE_DELAY);
|
||||
return queryArgs;
|
||||
};
|
||||
|
||||
// Hook to get an image DTO from cache or trigger loading
|
||||
const useImageDTOFromListQuery = (index: number, imageName: string, queryArgs: ListImagesArgs): ImageDTO | null => {
|
||||
const useImageDTOFromListQuery = (
|
||||
index: number,
|
||||
imageName: string,
|
||||
queryArgs: ListImagesQueryArgs
|
||||
): ImageDTO | null => {
|
||||
const { arg, options } = useMemo(() => {
|
||||
const pageOffset = Math.floor(index / PAGE_SIZE) * PAGE_SIZE;
|
||||
const pageOffset = Math.floor(index / queryArgs.limit) * queryArgs.limit;
|
||||
return {
|
||||
arg: {
|
||||
...queryArgs,
|
||||
offset: pageOffset,
|
||||
limit: PAGE_SIZE,
|
||||
} satisfies Parameters<typeof useListImagesQuery>[0],
|
||||
options: {
|
||||
selectFromResult: ({ data }) => {
|
||||
@@ -76,7 +80,7 @@ const useImageDTOFromListQuery = (index: number, imageName: string, queryArgs: L
|
||||
|
||||
// Individual image component that gets its data from RTK Query cache
|
||||
const ImageAtPosition = memo(
|
||||
({ index, queryArgs, imageName }: { index: number; imageName: string; queryArgs: ListImagesArgs }) => {
|
||||
({ index, queryArgs, imageName }: { index: number; imageName: string; queryArgs: ListImagesQueryArgs }) => {
|
||||
const imageDTO = useImageDTOFromListQuery(index, imageName, queryArgs);
|
||||
|
||||
if (!imageDTO) {
|
||||
@@ -198,30 +202,27 @@ const scrollIntoView = (
|
||||
return;
|
||||
};
|
||||
|
||||
const getImageIndex = (imageName: string | undefined, imageNames: string[]) => {
|
||||
if (!imageName || imageNames.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const index = imageNames.findIndex((n) => n === imageName);
|
||||
return index >= 0 ? index : 0;
|
||||
};
|
||||
|
||||
// Hook for keyboard navigation using physical DOM measurements
|
||||
const useKeyboardNavigation = (
|
||||
imageNames: string[],
|
||||
virtuosoRef: React.RefObject<VirtuosoGridHandle>,
|
||||
rootRef: React.RefObject<HTMLDivElement>,
|
||||
rangeRef: MutableRefObject<ListRange>
|
||||
rootRef: React.RefObject<HTMLDivElement>
|
||||
) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
|
||||
|
||||
// Get current index of selected image
|
||||
const currentIndex = useMemo(() => {
|
||||
if (!lastSelectedImage || imageNames.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const index = imageNames.findIndex((name) => name === lastSelectedImage);
|
||||
return index >= 0 ? index : 0;
|
||||
}, [lastSelectedImage, imageNames]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const rootEl = rootRef.current;
|
||||
const virtuosoGridHandle = virtuosoRef.current;
|
||||
const range = rangeRef.current;
|
||||
if (!rootEl || !virtuosoGridHandle) {
|
||||
return;
|
||||
}
|
||||
@@ -248,23 +249,24 @@ const useKeyboardNavigation = (
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const currentIndex = getImageIndex(lastSelectedImage, imageNames);
|
||||
let newIndex = currentIndex;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
if (currentIndex > 0) {
|
||||
newIndex = currentIndex - 1;
|
||||
} else {
|
||||
// Wrap to last image
|
||||
newIndex = imageNames.length - 1;
|
||||
// } else {
|
||||
// // Wrap to last image
|
||||
// newIndex = imageNames.length - 1;
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (currentIndex < imageNames.length - 1) {
|
||||
newIndex = currentIndex + 1;
|
||||
} else {
|
||||
// Wrap to first image
|
||||
newIndex = 0;
|
||||
// } else {
|
||||
// // Wrap to first image
|
||||
// newIndex = 0;
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
@@ -289,11 +291,10 @@ const useKeyboardNavigation = (
|
||||
const newImageName = imageNames[newIndex];
|
||||
if (newImageName) {
|
||||
dispatch(selectionChanged([newImageName]));
|
||||
scrollIntoView(newIndex, rootEl, virtuosoGridHandle, range);
|
||||
}
|
||||
}
|
||||
},
|
||||
[rootRef, virtuosoRef, rangeRef, imageNames, currentIndex, dispatch]
|
||||
[rootRef, virtuosoRef, imageNames, lastSelectedImage, dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -304,6 +305,30 @@ const useKeyboardNavigation = (
|
||||
}, [handleKeyDown]);
|
||||
};
|
||||
|
||||
const useKeepSelectedImageInView = (
|
||||
imageNames: string[],
|
||||
virtuosoRef: React.RefObject<VirtuosoGridHandle>,
|
||||
rootRef: React.RefObject<HTMLDivElement>,
|
||||
rangeRef: MutableRefObject<ListRange>
|
||||
) => {
|
||||
const imageName = useAppSelector(selectLastSelectedImage);
|
||||
|
||||
useEffect(() => {
|
||||
const virtuosoGridHandle = virtuosoRef.current;
|
||||
const rootEl = rootRef.current;
|
||||
const range = rangeRef.current;
|
||||
|
||||
if (!virtuosoGridHandle || !rootEl || !imageNames || imageNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
const index = imageName ? imageNames.indexOf(imageName) : 0;
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
scrollIntoView(index, rootEl, virtuosoGridHandle, range);
|
||||
}, [imageName, imageNames, rangeRef, rootRef, virtuosoRef]);
|
||||
};
|
||||
|
||||
const getImageNamesQueryOptions = {
|
||||
selectFromResult: ({ data, isLoading }) => ({
|
||||
imageNames: data ?? EMPTY_ARRAY,
|
||||
@@ -316,21 +341,15 @@ export const NewGallery = memo(() => {
|
||||
const queryArgs = useDebouncedImageCollectionQueryArgs();
|
||||
const virtuosoRef = useRef<VirtuosoGridHandle>(null);
|
||||
const rangeRef = useRef<ListRange>({ startIndex: 0, endIndex: 0 });
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get the ordered list of image names - this is our primary data source for virtualization
|
||||
const { imageNames, isLoading } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions);
|
||||
|
||||
// Reset scroll position when query parameters change
|
||||
useEffect(() => {
|
||||
if (virtuosoRef.current && imageNames.length > 0) {
|
||||
virtuosoRef.current.scrollToIndex({ index: 0, behavior: 'auto' });
|
||||
}
|
||||
}, [queryArgs, imageNames.length]);
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
useKeepSelectedImageInView(imageNames, virtuosoRef, rootRef, rangeRef);
|
||||
|
||||
// Enable keyboard navigation
|
||||
useKeyboardNavigation(imageNames, virtuosoRef, rootRef, rangeRef);
|
||||
useKeyboardNavigation(imageNames, virtuosoRef, rootRef);
|
||||
|
||||
const [scroller, setScroller] = useState<HTMLElement | null>(null);
|
||||
const [initialize, osInstance] = useOverlayScrollbars({
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import type { ChakraProps } 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, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
|
||||
|
||||
const NextPrevImageButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineStart' | 'insetInlineEnd'] }) => {
|
||||
const { t } = useTranslation();
|
||||
const { prevImage, nextImage, isOnFirstImageOfView, isOnLastImageOfView } = useGalleryNavigation();
|
||||
|
||||
const { isFetching } = useGalleryImages().queryResult;
|
||||
const { isNextEnabled, goNext, isPrevEnabled, goPrev } = useGalleryPagination();
|
||||
|
||||
const shouldShowLeftArrow = useMemo(() => {
|
||||
if (!isOnFirstImageOfView) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
|
||||
import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation';
|
||||
import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination';
|
||||
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
|
||||
import { selectActiveTab, selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors';
|
||||
@@ -13,8 +11,6 @@ import { useListImagesQuery } from 'services/api/endpoints/images';
|
||||
* Registers gallery hotkeys. This hook is a singleton.
|
||||
*/
|
||||
export const useGalleryHotkeys = () => {
|
||||
// useAssertSingleton('useGalleryHotkeys');
|
||||
const { goNext, goPrev, isNextEnabled, isPrevEnabled } = useGalleryPagination();
|
||||
const selection = useAppSelector((s) => s.gallery.selection);
|
||||
const queryArgs = useAppSelector(selectListImagesQueryArgs);
|
||||
const queryResult = useListImagesQuery(queryArgs);
|
||||
@@ -34,17 +30,6 @@ export const useGalleryHotkeys = () => {
|
||||
return canvasRightPanelTab === 'gallery';
|
||||
}, [appTab, canvasRightPanelTab]);
|
||||
|
||||
const {
|
||||
handleLeftImage,
|
||||
handleRightImage,
|
||||
handleUpImage,
|
||||
handleDownImage,
|
||||
isOnFirstRow,
|
||||
isOnLastRow,
|
||||
isOnFirstImageOfView,
|
||||
isOnLastImageOfView,
|
||||
} = useGalleryNavigation();
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavLeft',
|
||||
category: 'gallery',
|
||||
|
||||
@@ -1,32 +1,13 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { SkipToken } from '@reduxjs/toolkit/query';
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { selectGallerySlice } from 'features/gallery/store/gallerySlice';
|
||||
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
|
||||
import type { ListBoardsArgs, ListImagesArgs, SQLiteDirection } from 'services/api/types';
|
||||
import type { ListBoardsArgs, ListImagesArgs } from 'services/api/types';
|
||||
import type { SetNonNullable } from 'type-fest';
|
||||
|
||||
export const selectFirstSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0));
|
||||
export const selectLastSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1));
|
||||
|
||||
export const selectGalleryLimit = createSelector(selectGallerySlice, (gallery) => gallery.limit);
|
||||
export const selectListImagesQueryArgs = createMemoizedSelector(
|
||||
selectGallerySlice,
|
||||
(gallery): ListImagesArgs | SkipToken =>
|
||||
gallery.limit
|
||||
? {
|
||||
board_id: gallery.selectedBoardId,
|
||||
categories: gallery.galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
|
||||
offset: gallery.offset,
|
||||
limit: gallery.limit,
|
||||
is_intermediate: false,
|
||||
starred_first: gallery.starredFirst,
|
||||
order_dir: gallery.orderDir,
|
||||
search_term: gallery.searchTerm,
|
||||
}
|
||||
: skipToken
|
||||
);
|
||||
|
||||
export const selectListBoardsQueryArgs = createMemoizedSelector(
|
||||
selectGallerySlice,
|
||||
(gallery): ListBoardsArgs => ({
|
||||
@@ -37,16 +18,35 @@ export const selectListBoardsQueryArgs = createMemoizedSelector(
|
||||
);
|
||||
|
||||
export const selectAutoAddBoardId = createSelector(selectGallerySlice, (gallery) => gallery.autoAddBoardId);
|
||||
export const selectAutoSwitch = createSelector(selectGallerySlice, (gallery) => gallery.shouldAutoSwitch);
|
||||
export const selectSelectedBoardId = createSelector(selectGallerySlice, (gallery) => gallery.selectedBoardId);
|
||||
export const selectGalleryView = createSelector(selectGallerySlice, (gallery) => gallery.galleryView);
|
||||
export const selectGalleryQueryCategories = createSelector(selectGalleryView, (galleryView) =>
|
||||
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES
|
||||
);
|
||||
export const selectGallerySearchTerm = createSelector(selectGallerySlice, (gallery) => gallery.searchTerm);
|
||||
export const selectGalleryOrderDir = createSelector(selectGallerySlice, (gallery) => gallery.orderDir);
|
||||
export const selectGalleryStarredFirst = createSelector(selectGallerySlice, (gallery) => gallery.starredFirst);
|
||||
|
||||
export const selectImageCollectionQueryArgs = createMemoizedSelector(selectGallerySlice, (gallery) => ({
|
||||
board_id: gallery.selectedBoardId === 'none' ? undefined : gallery.selectedBoardId,
|
||||
categories: gallery.galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
|
||||
search_term: gallery.searchTerm || undefined,
|
||||
order_dir: gallery.orderDir as SQLiteDirection,
|
||||
is_intermediate: false,
|
||||
starred_first: true,
|
||||
}));
|
||||
export const selectListImagesQueryArgs = createMemoizedSelector(
|
||||
[
|
||||
selectSelectedBoardId,
|
||||
selectGalleryQueryCategories,
|
||||
selectGallerySearchTerm,
|
||||
selectGalleryOrderDir,
|
||||
selectGalleryStarredFirst,
|
||||
],
|
||||
(board_id, categories, search_term, order_dir, starred_first) =>
|
||||
({
|
||||
board_id,
|
||||
categories,
|
||||
search_term,
|
||||
order_dir,
|
||||
starred_first,
|
||||
is_intermediate: false, // We don't show intermediate images in the gallery
|
||||
limit: 100, // Page size is _always_ 100
|
||||
}) satisfies SetNonNullable<ListImagesArgs, 'limit'>
|
||||
);
|
||||
export const selectAutoAssignBoardOnClick = createSelector(
|
||||
selectGallerySlice,
|
||||
(gallery) => gallery.autoAssignBoardOnClick
|
||||
|
||||
@@ -16,8 +16,6 @@ const initialGalleryState: GalleryState = {
|
||||
selectedBoardId: 'none',
|
||||
galleryView: 'images',
|
||||
boardSearchText: '',
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
starredFirst: true,
|
||||
orderDir: 'DESC',
|
||||
searchTerm: '',
|
||||
@@ -114,7 +112,6 @@ export const gallerySlice = createSlice({
|
||||
boardIdSelected: (state, action: PayloadAction<{ boardId: BoardId; selectedImageName?: string }>) => {
|
||||
state.selectedBoardId = action.payload.boardId;
|
||||
state.galleryView = 'images';
|
||||
state.offset = 0;
|
||||
},
|
||||
autoAddBoardIdChanged: (state, action: PayloadAction<BoardId>) => {
|
||||
if (!action.payload) {
|
||||
@@ -125,7 +122,6 @@ export const gallerySlice = createSlice({
|
||||
},
|
||||
galleryViewChanged: (state, action: PayloadAction<GalleryView>) => {
|
||||
state.galleryView = action.payload;
|
||||
state.offset = 0;
|
||||
},
|
||||
boardSearchTextChanged: (state, action: PayloadAction<string>) => {
|
||||
state.boardSearchText = action.payload;
|
||||
@@ -143,13 +139,6 @@ export const gallerySlice = createSlice({
|
||||
comparisonFitChanged: (state, action: PayloadAction<'contain' | 'fill'>) => {
|
||||
state.comparisonFit = 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;
|
||||
},
|
||||
shouldShowArchivedBoardsChanged: (state, action: PayloadAction<boolean>) => {
|
||||
state.shouldShowArchivedBoards = action.payload;
|
||||
},
|
||||
@@ -161,7 +150,6 @@ export const gallerySlice = createSlice({
|
||||
},
|
||||
searchTermChanged: (state, action: PayloadAction<string>) => {
|
||||
state.searchTerm = action.payload;
|
||||
state.offset = 0;
|
||||
},
|
||||
boardsListOrderByChanged: (state, action: PayloadAction<BoardRecordOrderBy>) => {
|
||||
state.boardsListOrderBy = action.payload;
|
||||
@@ -188,8 +176,6 @@ export const {
|
||||
comparedImagesSwapped,
|
||||
comparisonFitChanged,
|
||||
comparisonModeCycled,
|
||||
offsetChanged,
|
||||
limitChanged,
|
||||
orderDirChanged,
|
||||
starredFirstChanged,
|
||||
shouldShowArchivedBoardsChanged,
|
||||
@@ -212,5 +198,5 @@ export const galleryPersistConfig: PersistConfig<GalleryState> = {
|
||||
name: gallerySlice.name,
|
||||
initialState: initialGalleryState,
|
||||
migrate: migrateGalleryState,
|
||||
persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'offset', 'limit', 'imageToCompare'],
|
||||
persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'imageToCompare'],
|
||||
};
|
||||
|
||||
@@ -18,8 +18,6 @@ export type GalleryState = {
|
||||
selectedBoardId: BoardId;
|
||||
galleryView: GalleryView;
|
||||
boardSearchText: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
starredFirst: boolean;
|
||||
orderDir: OrderDir;
|
||||
searchTerm: string;
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { NodeProps } from '@xyflow/react';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import { DndImage } from 'features/dnd/DndImage';
|
||||
import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
|
||||
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
||||
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
|
||||
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
|
||||
@@ -75,7 +74,7 @@ const Wrapper = (props: PropsWithChildren<{ nodeProps: NodeProps }>) => {
|
||||
{props.children}
|
||||
{isHovering && (
|
||||
<motion.div key="nextPrevButtons" initial={initial} animate={animate} exit={exit} style={styles}>
|
||||
<NextPrevImageButtons inset={2} />
|
||||
{/* <NextPrevImageButtons inset={2} /> */}
|
||||
</motion.div>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
UploadImageArg,
|
||||
} from 'services/api/types';
|
||||
import { getCategories, getListImagesUrl } from 'services/api/util';
|
||||
import stableHash from 'stable-hash';
|
||||
import type { Param0 } from 'tsafe';
|
||||
import type { JsonObject } from 'type-fest';
|
||||
|
||||
@@ -53,7 +54,8 @@ export const imagesApi = api.injectEndpoints({
|
||||
providesTags: (result, error, queryArgs) => {
|
||||
return [
|
||||
// Make the tags the same as the cache key
|
||||
{ type: 'ImageList', id: JSON.stringify(queryArgs) },
|
||||
{ type: 'ImageList', id: stableHash(queryArgs) },
|
||||
{ type: 'Board', id: queryArgs.board_id ?? 'none' },
|
||||
'FetchOnReconnect',
|
||||
];
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import { $authToken } from 'app/store/nanostores/authToken';
|
||||
import { $baseUrl } from 'app/store/nanostores/baseUrl';
|
||||
import { $projectId } from 'app/store/nanostores/projectId';
|
||||
import queryString from 'query-string';
|
||||
import stableHash from 'stable-hash';
|
||||
|
||||
const tagTypes = [
|
||||
'AppVersion',
|
||||
@@ -110,6 +111,7 @@ export const api = customCreateApi({
|
||||
tagTypes,
|
||||
endpoints: () => ({}),
|
||||
invalidationBehavior: 'immediately',
|
||||
serializeQueryArgs: stableHash,
|
||||
});
|
||||
|
||||
function getCircularReplacer() {
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { addAppListener } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppDispatch, AppGetState } from 'app/store/store';
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import { boardIdSelected, galleryViewChanged, imageSelected, offsetChanged } from 'features/gallery/store/gallerySlice';
|
||||
import {
|
||||
selectAutoSwitch,
|
||||
selectGalleryView,
|
||||
selectListImagesQueryArgs,
|
||||
selectSelectedBoardId,
|
||||
} from 'features/gallery/store/gallerySelectors';
|
||||
import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice';
|
||||
import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
|
||||
import { isImageField, isImageFieldCollection } from 'features/nodes/types/common';
|
||||
import { zNodeStatus } from 'features/nodes/types/invocation';
|
||||
import { isCanvasOutputEvent } from 'features/nodes/util/graph/graphBuilderUtils';
|
||||
import type { ApiTagDescription } from 'services/api';
|
||||
import { boardsApi } from 'services/api/endpoints/boards';
|
||||
import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images';
|
||||
import type { ImageDTO, S } from 'services/api/types';
|
||||
import { getCategories, getListImagesUrl } from 'services/api/util';
|
||||
import { getCategories } from 'services/api/util';
|
||||
import { $lastProgressEvent } from 'services/events/stores';
|
||||
import stableHash from 'stable-hash';
|
||||
import type { Param0 } from 'tsafe';
|
||||
import { objectEntries } from 'tsafe';
|
||||
import type { JsonObject } from 'type-fest';
|
||||
@@ -37,22 +44,25 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi
|
||||
const boardTotalAdditions: Record<string, number> = {};
|
||||
const boardTagIdsToInvalidate: Set<string> = new Set();
|
||||
const imageListTagIdsToInvalidate: Set<string> = new Set();
|
||||
const listImagesArg = selectListImagesQueryArgs(getState());
|
||||
|
||||
for (const imageDTO of imageDTOs) {
|
||||
if (imageDTO.is_intermediate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boardId = imageDTO.board_id ?? 'none';
|
||||
const board_id = imageDTO.board_id ?? 'none';
|
||||
// update the total images for the board
|
||||
boardTotalAdditions[boardId] = (boardTotalAdditions[boardId] || 0) + 1;
|
||||
boardTotalAdditions[board_id] = (boardTotalAdditions[board_id] || 0) + 1;
|
||||
// invalidate the board tag
|
||||
boardTagIdsToInvalidate.add(boardId);
|
||||
boardTagIdsToInvalidate.add(board_id);
|
||||
// invalidate the image list tag
|
||||
imageListTagIdsToInvalidate.add(
|
||||
getListImagesUrl({
|
||||
board_id: boardId,
|
||||
stableHash({
|
||||
...listImagesArg,
|
||||
categories: getCategories(imageDTO),
|
||||
board_id,
|
||||
offset: 0,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -86,48 +96,79 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi
|
||||
}));
|
||||
dispatch(imagesApi.util.invalidateTags([...boardTags, ...imageListTags]));
|
||||
|
||||
// Finally, we may need to autoswitch to the new image. We'll only do it for the last image in the list.
|
||||
const autoSwitch = selectAutoSwitch(getState());
|
||||
|
||||
if (!autoSwitch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Finally, we may need to autoswitch to the new image. We'll only do it for the last image in the list.
|
||||
const lastImageDTO = imageDTOs.at(-1);
|
||||
|
||||
if (!lastImageDTO) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { image_name, board_id } = lastImageDTO;
|
||||
const { image_name } = lastImageDTO;
|
||||
const board_id = lastImageDTO.board_id ?? 'none';
|
||||
|
||||
const { shouldAutoSwitch, selectedBoardId, galleryView, offset } = getState().gallery;
|
||||
/**
|
||||
* Auto-switch needs a bit of care to avoid race conditions - we need to invalidate the appropriate image list
|
||||
* query cache, and only after it has loaded, select the new image.
|
||||
*/
|
||||
const queryArgs = {
|
||||
...listImagesArg,
|
||||
categories: getCategories(lastImageDTO),
|
||||
board_id,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
// If auto-switch is enabled, select the new image
|
||||
if (shouldAutoSwitch) {
|
||||
// If the image is from a different board, switch to that board - this will also select the image
|
||||
if (board_id && board_id !== selectedBoardId) {
|
||||
dispatch(
|
||||
boardIdSelected({
|
||||
boardId: board_id,
|
||||
selectedImageName: image_name,
|
||||
})
|
||||
);
|
||||
} else if (!board_id && selectedBoardId !== 'none') {
|
||||
dispatch(
|
||||
boardIdSelected({
|
||||
boardId: 'none',
|
||||
selectedImageName: image_name,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Else just select the image, no need to switch boards
|
||||
dispatch(imageSelected(lastImageDTO.image_name));
|
||||
dispatch(
|
||||
addAppListener({
|
||||
predicate: (action) => {
|
||||
if (!imagesApi.endpoints.listImages.matchFulfilled(action)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (galleryView !== 'images') {
|
||||
// We also need to update the gallery view to images. This also updates the offset.
|
||||
dispatch(galleryViewChanged('images'));
|
||||
} else if (offset > 0) {
|
||||
// If we are not at the start of the gallery, reset the offset.
|
||||
dispatch(offsetChanged({ offset: 0 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stableHash(action.meta.arg.originalArgs) !== stableHash(queryArgs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
effect: (_action, { getState, dispatch, unsubscribe }) => {
|
||||
// This is a one-time listener - we always unsubscribe after the first match
|
||||
unsubscribe();
|
||||
|
||||
// Auto-switch may have been disabled while we were waiting for the query to resolve - bail if so
|
||||
const autoSwitch = selectAutoSwitch(getState());
|
||||
if (!autoSwitch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedBoardId = selectSelectedBoardId(getState());
|
||||
|
||||
// If the image is from a different board, switch to that board & select the image - otherwise just select the
|
||||
// image. This implicitly changes the view to 'images' if it was not already.
|
||||
if (board_id !== selectedBoardId) {
|
||||
dispatch(
|
||||
boardIdSelected({
|
||||
boardId: board_id,
|
||||
selectedImageName: image_name,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Ensure we are on the 'images' gallery view - that's where this image will be displayed
|
||||
const galleryView = selectGalleryView(getState());
|
||||
if (galleryView !== 'images') {
|
||||
dispatch(galleryViewChanged('images'));
|
||||
}
|
||||
// Else just select the image, no need to switch boards
|
||||
dispatch(imageSelected(lastImageDTO.image_name));
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const getResultImageDTOs = async (data: S['InvocationCompleteEvent']): Promise<ImageDTO[]> => {
|
||||
@@ -151,69 +192,23 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi
|
||||
return imageDTOs;
|
||||
};
|
||||
|
||||
const handleOriginWorkflows = async (data: S['InvocationCompleteEvent']) => {
|
||||
const { result, invocation_source_id } = data;
|
||||
|
||||
const nes = deepClone($nodeExecutionStates.get()[invocation_source_id]);
|
||||
if (nes) {
|
||||
nes.status = zNodeStatus.enum.COMPLETED;
|
||||
if (nes.progress !== null) {
|
||||
nes.progress = 1;
|
||||
}
|
||||
nes.outputs.push(result);
|
||||
upsertExecutionState(nes.nodeId, nes);
|
||||
}
|
||||
|
||||
await addImagesToGallery(data);
|
||||
};
|
||||
|
||||
const handleOriginCanvas = async (data: S['InvocationCompleteEvent']) => {
|
||||
if (!isCanvasOutputEvent(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await addImagesToGallery(data);
|
||||
|
||||
// // We expect only a single image in the canvas output
|
||||
// const imageDTO = (await getResultImageDTOs(data))[0];
|
||||
|
||||
// if (!imageDTO) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// flushSync(() => {
|
||||
// dispatch(
|
||||
// stagingAreaImageStaged({
|
||||
// stagingAreaImage: { type: 'staged', sessionId: data.session_id, imageDTO, offsetX: 0, offsetY: 0 },
|
||||
// })
|
||||
// );
|
||||
// });
|
||||
|
||||
// const progressData = $progressImages.get()[data.session_id];
|
||||
// if (progressData) {
|
||||
// $progressImages.setKey(data.session_id, { ...progressData, isFinished: true, resultImage: imageDTO });
|
||||
// } else {
|
||||
// $progressImages.setKey(data.session_id, { sessionId: data.session_id, isFinished: true, resultImage: imageDTO });
|
||||
// }
|
||||
|
||||
// $lastCanvasProgressImage.set(null);
|
||||
};
|
||||
|
||||
const handleOriginOther = async (data: S['InvocationCompleteEvent']) => {
|
||||
await addImagesToGallery(data);
|
||||
};
|
||||
|
||||
return async (data: S['InvocationCompleteEvent']) => {
|
||||
log.debug({ data } as JsonObject, `Invocation complete (${data.invocation.type}, ${data.invocation_source_id})`);
|
||||
|
||||
if (data.origin === 'workflows') {
|
||||
await handleOriginWorkflows(data);
|
||||
} else if (data.origin === 'canvas') {
|
||||
await handleOriginCanvas(data);
|
||||
} else {
|
||||
await handleOriginOther(data);
|
||||
const nodeExecutionState = $nodeExecutionStates.get()[data.invocation_source_id];
|
||||
|
||||
if (nodeExecutionState) {
|
||||
const _nodeExecutionState = deepClone(nodeExecutionState);
|
||||
_nodeExecutionState.status = zNodeStatus.enum.COMPLETED;
|
||||
if (_nodeExecutionState.progress !== null) {
|
||||
_nodeExecutionState.progress = 1;
|
||||
}
|
||||
_nodeExecutionState.outputs.push(data.result);
|
||||
upsertExecutionState(_nodeExecutionState.nodeId, _nodeExecutionState);
|
||||
}
|
||||
|
||||
await addImagesToGallery(data);
|
||||
|
||||
$lastProgressEvent.set(null);
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user