feat(ui): restore recall functionality

- Restore recall functionality to `CurrentImageButtons` and `ImageContextMenu`.
- Debounce metadata requests for `ImageMetadataViewer` and `CurrentImageButtons` by 500ms. It's possible to scroll through these really fast, so we want to debounce the network requests.
- `ImageContextMenu` is lazy-mounted so it does not need to be debounced; it makes the metadata request as soon as you click it.
- Move next/prev image selection logic into hook and add the hotkeys for this to `CurrentImageButtons`. The hotkeys now work when metadata viewer is open.

I will follow up with improved loading state during the debounced calls in the future
This commit is contained in:
psychedelicious 2023-07-13 12:46:54 +10:00
parent a43c900961
commit 6bea7bac36
10 changed files with 420 additions and 339 deletions

View File

@ -108,6 +108,7 @@
"roarr": "^7.15.0", "roarr": "^7.15.0",
"serialize-error": "^11.0.0", "serialize-error": "^11.0.0",
"socket.io-client": "^4.7.0", "socket.io-client": "^4.7.0",
"use-debounce": "^9.0.4",
"use-image": "^1.1.1", "use-image": "^1.1.1",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"zod": "^3.21.4" "zod": "^3.21.4"

View File

@ -1,6 +1,7 @@
import { import {
AnyAction, AnyAction,
ThunkDispatch, ThunkDispatch,
autoBatchEnhancer,
combineReducers, combineReducers,
configureStore, configureStore,
} from '@reduxjs/toolkit'; } from '@reduxjs/toolkit';
@ -79,14 +80,18 @@ const rememberedKeys: (keyof typeof allReducers)[] = [
export const store = configureStore({ export const store = configureStore({
reducer: rememberedRootReducer, reducer: rememberedRootReducer,
enhancers: [ enhancers: (existingEnhancers) => {
rememberEnhancer(window.localStorage, rememberedKeys, { return existingEnhancers
persistDebounce: 300, .concat(
serialize, rememberEnhancer(window.localStorage, rememberedKeys, {
unserialize, persistDebounce: 300,
prefix: LOCALSTORAGE_PREFIX, serialize,
}), unserialize,
], prefix: LOCALSTORAGE_PREFIX,
})
)
.concat(autoBatchEnhancer());
},
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ getDefaultMiddleware({
immutableCheck: false, immutableCheck: false,

View File

@ -45,7 +45,11 @@ import {
FaShare, FaShare,
FaShareAlt, FaShareAlt,
} from 'react-icons/fa'; } from 'react-icons/fa';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import {
useGetImageDTOQuery,
useGetImageMetadataQuery,
} from 'services/api/endpoints/images';
import { useDebounce } from 'use-debounce';
import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions'; import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions';
const currentImageButtonsSelector = createSelector( const currentImageButtonsSelector = createSelector(
@ -128,10 +132,23 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
const { recallBothPrompts, recallSeed, recallAllParameters } = const { recallBothPrompts, recallSeed, recallAllParameters } =
useRecallParameters(); useRecallParameters();
const { currentData: image } = useGetImageDTOQuery( const [debouncedMetadataQueryArg, debounceState] = useDebounce(
lastSelectedImage,
500
);
const { currentData: image, isFetching } = useGetImageDTOQuery(
lastSelectedImage ?? skipToken lastSelectedImage ?? skipToken
); );
const { currentData: metadataData } = useGetImageMetadataQuery(
debounceState.isPending()
? skipToken
: debouncedMetadataQueryArg ?? skipToken
);
const metadata = metadataData?.metadata;
// const handleCopyImage = useCallback(async () => { // const handleCopyImage = useCallback(async () => {
// if (!image?.url) { // if (!image?.url) {
// return; // return;
@ -193,29 +210,26 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
}, [toaster, t, image]); }, [toaster, t, image]);
const handleClickUseAllParameters = useCallback(() => { const handleClickUseAllParameters = useCallback(() => {
recallAllParameters(image); recallAllParameters(metadata);
}, [image, recallAllParameters]); }, [metadata, recallAllParameters]);
useHotkeys( useHotkeys(
'a', 'a',
() => { () => {
handleClickUseAllParameters; handleClickUseAllParameters;
}, },
[image, recallAllParameters] [metadata, recallAllParameters]
); );
const handleUseSeed = useCallback(() => { const handleUseSeed = useCallback(() => {
recallSeed(image?.metadata?.seed); recallSeed(metadata?.seed);
}, [image, recallSeed]); }, [metadata?.seed, recallSeed]);
useHotkeys('s', handleUseSeed, [image]); useHotkeys('s', handleUseSeed, [image]);
const handleUsePrompt = useCallback(() => { const handleUsePrompt = useCallback(() => {
recallBothPrompts( recallBothPrompts(metadata?.positive_prompt, metadata?.negative_prompt);
image?.metadata?.positive_conditioning, }, [metadata?.negative_prompt, metadata?.positive_prompt, recallBothPrompts]);
image?.metadata?.negative_conditioning
);
}, [image, recallBothPrompts]);
useHotkeys('p', handleUsePrompt, [image]); useHotkeys('p', handleUsePrompt, [image]);
@ -440,7 +454,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
icon={<FaQuoteRight />} icon={<FaQuoteRight />}
tooltip={`${t('parameters.usePrompt')} (P)`} tooltip={`${t('parameters.usePrompt')} (P)`}
aria-label={`${t('parameters.usePrompt')} (P)`} aria-label={`${t('parameters.usePrompt')} (P)`}
isDisabled={!image?.metadata?.positive_conditioning} isDisabled={!metadata?.positive_prompt}
onClick={handleUsePrompt} onClick={handleUsePrompt}
/> />
@ -448,7 +462,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
icon={<FaSeedling />} icon={<FaSeedling />}
tooltip={`${t('parameters.useSeed')} (S)`} tooltip={`${t('parameters.useSeed')} (S)`}
aria-label={`${t('parameters.useSeed')} (S)`} aria-label={`${t('parameters.useSeed')} (S)`}
isDisabled={!image?.metadata?.seed} isDisabled={!metadata?.seed}
onClick={handleUseSeed} onClick={handleUseSeed}
/> />
@ -456,10 +470,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
icon={<FaAsterisk />} icon={<FaAsterisk />}
tooltip={`${t('parameters.useAll')} (A)`} tooltip={`${t('parameters.useAll')} (A)`}
aria-label={`${t('parameters.useAll')} (A)`} aria-label={`${t('parameters.useAll')} (A)`}
isDisabled={ isDisabled={!metadata}
// not sure what this list should be
!['t2l', 'l2l', 'inpaint'].includes(String(image?.metadata?.type))
}
onClick={handleClickUseAllParameters} onClick={handleClickUseAllParameters}
/> />
</ButtonGroup> </ButtonGroup>

View File

@ -11,7 +11,9 @@ import IAIDndImage from 'common/components/IAIDndImage';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySlice'; import { selectLastSelectedImage } from 'features/gallery/store/gallerySlice';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { useNextPrevImage } from '../hooks/useNextPrevImage';
import ImageMetadataViewer from './ImageMetaDataViewer/ImageMetadataViewer'; import ImageMetadataViewer from './ImageMetaDataViewer/ImageMetadataViewer';
import NextPrevImageButtons from './NextPrevImageButtons'; import NextPrevImageButtons from './NextPrevImageButtons';
@ -49,6 +51,45 @@ const CurrentImagePreview = () => {
shouldAntialiasProgressImage, shouldAntialiasProgressImage,
} = useAppSelector(imagesSelector); } = useAppSelector(imagesSelector);
const {
handlePrevImage,
handleNextImage,
prevImageId,
nextImageId,
isOnLastImage,
handleLoadMoreImages,
areMoreImagesAvailable,
isFetching,
} = useNextPrevImage();
useHotkeys(
'left',
() => {
handlePrevImage();
},
[prevImageId]
);
useHotkeys(
'right',
() => {
if (isOnLastImage && areMoreImagesAvailable && !isFetching) {
handleLoadMoreImages();
return;
}
if (!isOnLastImage) {
handleNextImage();
}
},
[
nextImageId,
isOnLastImage,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,
]
);
const { const {
currentData: imageDTO, currentData: imageDTO,
isLoading, isLoading,

View File

@ -6,10 +6,7 @@ import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu'; import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu';
import { import { imagesAddedToBatch } from 'features/batch/store/batchSlice';
imagesAddedToBatch,
selectionAddedToBatch,
} from 'features/batch/store/batchSlice';
import { import {
resizeAndScaleCanvas, resizeAndScaleCanvas,
setInitialCanvasImage, setInitialCanvasImage,
@ -24,6 +21,7 @@ import { useTranslation } from 'react-i18next';
import { FaExpand, FaFolder, FaShare, FaTrash } from 'react-icons/fa'; import { FaExpand, FaFolder, FaShare, FaTrash } from 'react-icons/fa';
import { IoArrowUndoCircleOutline } from 'react-icons/io5'; import { IoArrowUndoCircleOutline } from 'react-icons/io5';
import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages'; import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages';
import { useGetImageMetadataQuery } from 'services/api/endpoints/images';
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
import { AddImageToBoardContext } from '../../../app/contexts/AddImageToBoardContext'; import { AddImageToBoardContext } from '../../../app/contexts/AddImageToBoardContext';
import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions'; import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions';
@ -38,24 +36,17 @@ const ImageContextMenu = ({ image, children }: Props) => {
() => () =>
createSelector( createSelector(
[stateSelector], [stateSelector],
({ gallery, batch }) => { ({ gallery }) => {
const selectionCount = gallery.selection.length; const selectionCount = gallery.selection.length;
const isInBatch = batch.imageNames.includes(image.image_name);
return { selectionCount, isInBatch }; return { selectionCount };
}, },
defaultSelectorOptions defaultSelectorOptions
), ),
[image.image_name] []
); );
const { selectionCount, isInBatch } = useAppSelector(selector); const { selectionCount } = useAppSelector(selector);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t } = useTranslation();
const toaster = useAppToaster();
const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled;
const { onClickAddToBoard } = useContext(AddImageToBoardContext); const { onClickAddToBoard } = useContext(AddImageToBoardContext);
@ -66,178 +57,17 @@ const ImageContextMenu = ({ image, children }: Props) => {
dispatch(imageToDeleteSelected(image)); dispatch(imageToDeleteSelected(image));
}, [dispatch, image]); }, [dispatch, image]);
const { recallBothPrompts, recallSeed, recallAllParameters } =
useRecallParameters();
const [removeFromBoard] = useRemoveImageFromBoardMutation();
// Recall parameters handlers
const handleRecallPrompt = useCallback(() => {
recallBothPrompts(
image.metadata?.positive_conditioning,
image.metadata?.negative_conditioning
);
}, [
image.metadata?.negative_conditioning,
image.metadata?.positive_conditioning,
recallBothPrompts,
]);
const handleRecallSeed = useCallback(() => {
recallSeed(image.metadata?.seed);
}, [image, recallSeed]);
const handleSendToImageToImage = useCallback(() => {
dispatch(sentImageToImg2Img());
dispatch(initialImageSelected(image));
}, [dispatch, image]);
// const handleRecallInitialImage = useCallback(() => {
// recallInitialImage(image.metadata.invokeai?.node?.image);
// }, [image, recallInitialImage]);
const handleSendToCanvas = () => {
dispatch(sentImageToCanvas());
dispatch(setInitialCanvasImage(image));
dispatch(resizeAndScaleCanvas());
dispatch(setActiveTab('unifiedCanvas'));
toaster({
title: t('toast.sentToUnifiedCanvas'),
status: 'success',
duration: 2500,
isClosable: true,
});
};
const handleUseAllParameters = useCallback(() => {
recallAllParameters(image);
}, [image, recallAllParameters]);
const handleLightBox = () => {
// dispatch(setCurrentImage(image));
// dispatch(setIsLightboxOpen(true));
};
const handleAddToBoard = useCallback(() => { const handleAddToBoard = useCallback(() => {
onClickAddToBoard(image); onClickAddToBoard(image);
}, [image, onClickAddToBoard]); }, [image, onClickAddToBoard]);
const handleRemoveFromBoard = useCallback(() => {
if (!image.board_id) {
return;
}
removeFromBoard({ board_id: image.board_id, image_name: image.image_name });
}, [image.board_id, image.image_name, removeFromBoard]);
const handleOpenInNewTab = () => {
window.open(image.image_url, '_blank');
};
const handleAddSelectionToBatch = useCallback(() => {
dispatch(selectionAddedToBatch());
}, [dispatch]);
const handleAddToBatch = useCallback(() => {
dispatch(imagesAddedToBatch([image.image_name]));
}, [dispatch, image.image_name]);
return ( return (
<ContextMenu<HTMLDivElement> <ContextMenu<HTMLDivElement>
menuProps={{ size: 'sm', isLazy: true }} menuProps={{ size: 'sm', isLazy: true }}
renderMenu={() => ( renderMenu={() => (
<MenuList sx={{ visibility: 'visible !important' }}> <MenuList sx={{ visibility: 'visible !important' }}>
{selectionCount === 1 ? ( {selectionCount === 1 ? (
<> <SingleSelectionMenuItems image={image} />
<MenuItem
icon={<ExternalLinkIcon />}
onClickCapture={handleOpenInNewTab}
>
{t('common.openInNewTab')}
</MenuItem>
{isLightboxEnabled && (
<MenuItem icon={<FaExpand />} onClickCapture={handleLightBox}>
{t('parameters.openInViewer')}
</MenuItem>
)}
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleRecallPrompt}
isDisabled={
image?.metadata?.positive_conditioning === undefined
}
>
{t('parameters.usePrompt')}
</MenuItem>
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleRecallSeed}
isDisabled={image?.metadata?.seed === undefined}
>
{t('parameters.useSeed')}
</MenuItem>
{/* <MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleRecallInitialImage}
isDisabled={image?.metadata?.type !== 'img2img'}
>
{t('parameters.useInitImg')}
</MenuItem> */}
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleUseAllParameters}
isDisabled={
// what should these be
!['t2l', 'l2l', 'inpaint'].includes(
String(image?.metadata?.type)
)
}
>
{t('parameters.useAll')}
</MenuItem>
<MenuItem
icon={<FaShare />}
onClickCapture={handleSendToImageToImage}
id="send-to-img2img"
>
{t('parameters.sendToImg2Img')}
</MenuItem>
{isCanvasEnabled && (
<MenuItem
icon={<FaShare />}
onClickCapture={handleSendToCanvas}
id="send-to-canvas"
>
{t('parameters.sendToUnifiedCanvas')}
</MenuItem>
)}
{/* <MenuItem
icon={<FaFolder />}
isDisabled={isInBatch}
onClickCapture={handleAddToBatch}
>
Add to Batch
</MenuItem> */}
<MenuItem icon={<FaFolder />} onClickCapture={handleAddToBoard}>
{image.board_id ? 'Change Board' : 'Add to Board'}
</MenuItem>
{image.board_id && (
<MenuItem
icon={<FaFolder />}
onClickCapture={handleRemoveFromBoard}
>
Remove from Board
</MenuItem>
)}
<MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />}
onClickCapture={handleDelete}
>
{t('gallery.deleteImage')}
</MenuItem>
</>
) : ( ) : (
<> <>
<MenuItem <MenuItem
@ -271,3 +101,185 @@ const ImageContextMenu = ({ image, children }: Props) => {
}; };
export default memo(ImageContextMenu); export default memo(ImageContextMenu);
type SingleSelectionMenuItemsProps = {
image: ImageDTO;
};
const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
const { image } = props;
const selector = useMemo(
() =>
createSelector(
[stateSelector],
({ batch }) => {
const isInBatch = batch.imageNames.includes(image.image_name);
return { isInBatch };
},
defaultSelectorOptions
),
[image.image_name]
);
const { isInBatch } = useAppSelector(selector);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const toaster = useAppToaster();
const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled;
const { onClickAddToBoard } = useContext(AddImageToBoardContext);
const { currentData } = useGetImageMetadataQuery(image.image_name);
const metadata = currentData?.metadata;
const handleDelete = useCallback(() => {
if (!image) {
return;
}
dispatch(imageToDeleteSelected(image));
}, [dispatch, image]);
const { recallBothPrompts, recallSeed, recallAllParameters } =
useRecallParameters();
const [removeFromBoard] = useRemoveImageFromBoardMutation();
// Recall parameters handlers
const handleRecallPrompt = useCallback(() => {
recallBothPrompts(metadata?.positive_prompt, metadata?.negative_prompt);
}, [metadata?.negative_prompt, metadata?.positive_prompt, recallBothPrompts]);
const handleRecallSeed = useCallback(() => {
recallSeed(metadata?.seed);
}, [metadata?.seed, recallSeed]);
const handleSendToImageToImage = useCallback(() => {
dispatch(sentImageToImg2Img());
dispatch(initialImageSelected(image));
}, [dispatch, image]);
const handleSendToCanvas = () => {
dispatch(sentImageToCanvas());
dispatch(setInitialCanvasImage(image));
dispatch(resizeAndScaleCanvas());
dispatch(setActiveTab('unifiedCanvas'));
toaster({
title: t('toast.sentToUnifiedCanvas'),
status: 'success',
duration: 2500,
isClosable: true,
});
};
const handleUseAllParameters = useCallback(() => {
console.log(metadata);
recallAllParameters(metadata);
}, [metadata, recallAllParameters]);
const handleLightBox = () => {
// dispatch(setCurrentImage(image));
// dispatch(setIsLightboxOpen(true));
};
const handleAddToBoard = useCallback(() => {
onClickAddToBoard(image);
}, [image, onClickAddToBoard]);
const handleRemoveFromBoard = useCallback(() => {
if (!image.board_id) {
return;
}
removeFromBoard({ board_id: image.board_id, image_name: image.image_name });
}, [image.board_id, image.image_name, removeFromBoard]);
const handleOpenInNewTab = () => {
window.open(image.image_url, '_blank');
};
const handleAddToBatch = useCallback(() => {
dispatch(imagesAddedToBatch([image.image_name]));
}, [dispatch, image.image_name]);
return (
<>
<MenuItem icon={<ExternalLinkIcon />} onClickCapture={handleOpenInNewTab}>
{t('common.openInNewTab')}
</MenuItem>
{isLightboxEnabled && (
<MenuItem icon={<FaExpand />} onClickCapture={handleLightBox}>
{t('parameters.openInViewer')}
</MenuItem>
)}
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleRecallPrompt}
isDisabled={
metadata?.positive_prompt === undefined &&
metadata?.negative_prompt === undefined
}
>
{t('parameters.usePrompt')}
</MenuItem>
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleRecallSeed}
isDisabled={metadata?.seed === undefined}
>
{t('parameters.useSeed')}
</MenuItem>
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleUseAllParameters}
isDisabled={!metadata}
>
{t('parameters.useAll')}
</MenuItem>
<MenuItem
icon={<FaShare />}
onClickCapture={handleSendToImageToImage}
id="send-to-img2img"
>
{t('parameters.sendToImg2Img')}
</MenuItem>
{isCanvasEnabled && (
<MenuItem
icon={<FaShare />}
onClickCapture={handleSendToCanvas}
id="send-to-canvas"
>
{t('parameters.sendToUnifiedCanvas')}
</MenuItem>
)}
<MenuItem
icon={<FaFolder />}
isDisabled={isInBatch}
onClickCapture={handleAddToBatch}
>
Add to Batch
</MenuItem>
<MenuItem icon={<FaFolder />} onClickCapture={handleAddToBoard}>
{image.board_id ? 'Change Board' : 'Add to Board'}
</MenuItem>
{image.board_id && (
<MenuItem icon={<FaFolder />} onClickCapture={handleRemoveFromBoard}>
Remove from Board
</MenuItem>
)}
<MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />}
onClickCapture={handleDelete}
>
{t('gallery.deleteImage')}
</MenuItem>
</>
);
};

View File

@ -13,6 +13,7 @@ import { skipToken } from '@reduxjs/toolkit/dist/query';
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { useGetImageMetadataQuery } from 'services/api/endpoints/images'; import { useGetImageMetadataQuery } from 'services/api/endpoints/images';
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
import { useDebounce } from 'use-debounce';
import ImageMetadataActions from './ImageMetadataActions'; import ImageMetadataActions from './ImageMetadataActions';
import MetadataJSONViewer from './MetadataJSONViewer'; import MetadataJSONViewer from './MetadataJSONViewer';
@ -27,16 +28,26 @@ const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => {
// dispatch(setShouldShowImageDetails(false)); // dispatch(setShouldShowImageDetails(false));
// }); // });
const { data } = useGetImageMetadataQuery(image?.image_name ?? skipToken); const [debouncedMetadataQueryArg, debounceState] = useDebounce(
const metadata = data?.metadata; image.image_name,
500
);
const { currentData } = useGetImageMetadataQuery(
debounceState.isPending()
? skipToken
: debouncedMetadataQueryArg ?? skipToken
);
const metadata = currentData?.metadata;
const graph = currentData?.graph;
const tabData = useMemo(() => { const tabData = useMemo(() => {
const _tabData: { label: string; data: object; copyTooltip: string }[] = []; const _tabData: { label: string; data: object; copyTooltip: string }[] = [];
if (data?.metadata) { if (metadata) {
_tabData.push({ _tabData.push({
label: 'Core Metadata', label: 'Core Metadata',
data: data?.metadata, data: metadata,
copyTooltip: 'Copy Core Metadata JSON', copyTooltip: 'Copy Core Metadata JSON',
}); });
} }
@ -49,15 +60,15 @@ const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => {
}); });
} }
if (data?.graph) { if (graph) {
_tabData.push({ _tabData.push({
label: 'Graph', label: 'Graph',
data: data?.graph, data: graph,
copyTooltip: 'Copy Graph JSON', copyTooltip: 'Copy Graph JSON',
}); });
} }
return _tabData; return _tabData;
}, [data?.metadata, data?.graph, image]); }, [metadata, graph, image]);
return ( return (
<Flex <Flex

View File

@ -1,18 +1,8 @@
import { ChakraProps, Flex, Grid, IconButton, Spinner } from '@chakra-ui/react'; import { ChakraProps, Flex, Grid, IconButton, Spinner } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
imageSelected,
selectFilteredImages,
selectImagesById,
} from 'features/gallery/store/gallerySlice';
import { clamp, isEqual } from 'lodash-es';
import { memo, useCallback, useState } from 'react'; import { memo, useCallback, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaAngleDoubleRight, FaAngleLeft, FaAngleRight } from 'react-icons/fa'; import { FaAngleDoubleRight, FaAngleLeft, FaAngleRight } from 'react-icons/fa';
import { receivedPageOfImages } from 'services/api/thunks/image'; import { useNextPrevImage } from '../hooks/useNextPrevImage';
const nextPrevButtonTriggerAreaStyles: ChakraProps['sx'] = { const nextPrevButtonTriggerAreaStyles: ChakraProps['sx'] = {
height: '100%', height: '100%',
@ -24,74 +14,18 @@ const nextPrevButtonStyles: ChakraProps['sx'] = {
color: 'base.100', color: 'base.100',
}; };
export const nextPrevImageButtonsSelector = createSelector(
[stateSelector, selectFilteredImages],
(state, filteredImages) => {
const { total, isFetching } = state.gallery;
const lastSelectedImage =
state.gallery.selection[state.gallery.selection.length - 1];
if (!lastSelectedImage || filteredImages.length === 0) {
return {
isOnFirstImage: true,
isOnLastImage: true,
};
}
const currentImageIndex = filteredImages.findIndex(
(i) => i.image_name === lastSelectedImage
);
const nextImageIndex = clamp(
currentImageIndex + 1,
0,
filteredImages.length - 1
);
const prevImageIndex = clamp(
currentImageIndex - 1,
0,
filteredImages.length - 1
);
const nextImageId = filteredImages[nextImageIndex].image_name;
const prevImageId = filteredImages[prevImageIndex].image_name;
const nextImage = selectImagesById(state, nextImageId);
const prevImage = selectImagesById(state, prevImageId);
const imagesLength = filteredImages.length;
return {
isOnFirstImage: currentImageIndex === 0,
isOnLastImage:
!isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1,
areMoreImagesAvailable: total > imagesLength,
isFetching,
nextImage,
prevImage,
nextImageId,
prevImageId,
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
},
}
);
const NextPrevImageButtons = () => { const NextPrevImageButtons = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
handlePrevImage,
handleNextImage,
isOnFirstImage, isOnFirstImage,
isOnLastImage, isOnLastImage,
nextImageId, handleLoadMoreImages,
prevImageId,
areMoreImagesAvailable, areMoreImagesAvailable,
isFetching, isFetching,
} = useAppSelector(nextPrevImageButtonsSelector); } = useNextPrevImage();
const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] =
useState<boolean>(false); useState<boolean>(false);
@ -104,50 +38,6 @@ const NextPrevImageButtons = () => {
setShouldShowNextPrevButtons(false); setShouldShowNextPrevButtons(false);
}, []); }, []);
const handlePrevImage = useCallback(() => {
prevImageId && dispatch(imageSelected(prevImageId));
}, [dispatch, prevImageId]);
const handleNextImage = useCallback(() => {
nextImageId && dispatch(imageSelected(nextImageId));
}, [dispatch, nextImageId]);
const handleLoadMoreImages = useCallback(() => {
dispatch(
receivedPageOfImages({
is_intermediate: false,
})
);
}, [dispatch]);
useHotkeys(
'left',
() => {
handlePrevImage();
},
[prevImageId]
);
useHotkeys(
'right',
() => {
if (isOnLastImage && areMoreImagesAvailable && !isFetching) {
handleLoadMoreImages();
return;
}
if (!isOnLastImage) {
handleNextImage();
}
},
[
nextImageId,
isOnLastImage,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,
]
);
return ( return (
<Flex <Flex
sx={{ sx={{

View File

@ -0,0 +1,108 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
imageSelected,
selectFilteredImages,
selectImagesById,
} from 'features/gallery/store/gallerySlice';
import { clamp, isEqual } from 'lodash-es';
import { useCallback } from 'react';
import { receivedPageOfImages } from 'services/api/thunks/image';
export const nextPrevImageButtonsSelector = createSelector(
[stateSelector, selectFilteredImages],
(state, filteredImages) => {
const { total, isFetching } = state.gallery;
const lastSelectedImage =
state.gallery.selection[state.gallery.selection.length - 1];
if (!lastSelectedImage || filteredImages.length === 0) {
return {
isOnFirstImage: true,
isOnLastImage: true,
};
}
const currentImageIndex = filteredImages.findIndex(
(i) => i.image_name === lastSelectedImage
);
const nextImageIndex = clamp(
currentImageIndex + 1,
0,
filteredImages.length - 1
);
const prevImageIndex = clamp(
currentImageIndex - 1,
0,
filteredImages.length - 1
);
const nextImageId = filteredImages[nextImageIndex].image_name;
const prevImageId = filteredImages[prevImageIndex].image_name;
const nextImage = selectImagesById(state, nextImageId);
const prevImage = selectImagesById(state, prevImageId);
const imagesLength = filteredImages.length;
return {
isOnFirstImage: currentImageIndex === 0,
isOnLastImage:
!isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1,
areMoreImagesAvailable: total > imagesLength,
isFetching,
nextImage,
prevImage,
nextImageId,
prevImageId,
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
},
}
);
export const useNextPrevImage = () => {
const dispatch = useAppDispatch();
const {
isOnFirstImage,
isOnLastImage,
nextImageId,
prevImageId,
areMoreImagesAvailable,
isFetching,
} = useAppSelector(nextPrevImageButtonsSelector);
const handlePrevImage = useCallback(() => {
prevImageId && dispatch(imageSelected(prevImageId));
}, [dispatch, prevImageId]);
const handleNextImage = useCallback(() => {
nextImageId && dispatch(imageSelected(nextImageId));
}, [dispatch, nextImageId]);
const handleLoadMoreImages = useCallback(() => {
dispatch(
receivedPageOfImages({
is_intermediate: false,
})
);
}, [dispatch]);
return {
handlePrevImage,
handleNextImage,
isOnFirstImage,
isOnLastImage,
nextImageId,
prevImageId,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,
};
};

View File

@ -2,6 +2,7 @@ import { useAppToaster } from 'app/components/Toaster';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { UnsafeImageMetadata } from 'services/api/endpoints/images';
import { isImageField } from 'services/api/guards'; import { isImageField } from 'services/api/guards';
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
import { initialImageSelected, modelSelected } from '../store/actions'; import { initialImageSelected, modelSelected } from '../store/actions';
@ -269,28 +270,24 @@ export const useRecallParameters = () => {
); );
const recallAllParameters = useCallback( const recallAllParameters = useCallback(
(image: ImageDTO | undefined) => { (metadata: UnsafeImageMetadata['metadata'] | undefined) => {
if (!image || !image.metadata) { if (!metadata) {
allParameterNotSetToast(); allParameterNotSetToast();
return; return;
} }
const { const {
cfg_scale, cfg_scale,
height, height,
model, model,
positive_conditioning, positive_prompt,
negative_conditioning, negative_prompt,
scheduler, scheduler,
seed, seed,
steps, steps,
width, width,
strength, strength,
clip, } = metadata;
extra,
latents,
unet,
vae,
} = image.metadata;
if (isValidCfgScale(cfg_scale)) { if (isValidCfgScale(cfg_scale)) {
dispatch(setCfgScale(cfg_scale)); dispatch(setCfgScale(cfg_scale));
@ -298,11 +295,11 @@ export const useRecallParameters = () => {
if (isValidMainModel(model)) { if (isValidMainModel(model)) {
dispatch(modelSelected(model)); dispatch(modelSelected(model));
} }
if (isValidPositivePrompt(positive_conditioning)) { if (isValidPositivePrompt(positive_prompt)) {
dispatch(setPositivePrompt(positive_conditioning)); dispatch(setPositivePrompt(positive_prompt));
} }
if (isValidNegativePrompt(negative_conditioning)) { if (isValidNegativePrompt(negative_prompt)) {
dispatch(setNegativePrompt(negative_conditioning)); dispatch(setNegativePrompt(negative_prompt));
} }
if (isValidScheduler(scheduler)) { if (isValidScheduler(scheduler)) {
dispatch(setScheduler(scheduler)); dispatch(setScheduler(scheduler));

View File

@ -6409,6 +6409,11 @@ use-composed-ref@^1.3.0:
resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda" resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda"
integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ== integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==
use-debounce@^9.0.4:
version "9.0.4"
resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-9.0.4.tgz#51d25d856fbdfeb537553972ce3943b897f1ac85"
integrity sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ==
use-image@^1.1.1: use-image@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/use-image/-/use-image-1.1.1.tgz#bdd3f2e1718393ffc0e56136f993467103d9d2df" resolved "https://registry.yarnpkg.com/use-image/-/use-image-1.1.1.tgz#bdd3f2e1718393ffc0e56136f993467103d9d2df"