feat(ui): split up StagingAreaToolbar

This commit is contained in:
psychedelicious
2024-09-23 09:58:57 +10:00
committed by Kent Keirsey
parent 752fb88210
commit f25e28a933
10 changed files with 367 additions and 205 deletions

View File

@@ -1,217 +1,31 @@
import { Button, ButtonGroup, IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { INTERACTION_SCOPES, useScopeOnMount } from 'common/hooks/interactionScopes';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import {
selectCanvasStagingAreaSlice,
stagingAreaImageAccepted,
stagingAreaNextStagedImageSelected,
stagingAreaPrevStagedImageSelected,
stagingAreaReset,
stagingAreaStagedImageDiscarded,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { memo, useCallback, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import {
PiArrowLeftBold,
PiArrowRightBold,
PiCheckBold,
PiEyeBold,
PiEyeSlashBold,
PiFloppyDiskBold,
PiTrashSimpleBold,
PiXBold,
} from 'react-icons/pi';
import { useAddImagesToBoardMutation, useChangeImageIsIntermediateMutation } from 'services/api/endpoints/images';
const selectStagedImageIndex = createSelector(
selectCanvasStagingAreaSlice,
(stagingArea) => stagingArea.selectedStagedImageIndex
);
const selectSelectedImage = createSelector(
[selectCanvasStagingAreaSlice, selectStagedImageIndex],
(stagingArea, index) => stagingArea.stagedImages[index] ?? null
);
const selectImageCount = createSelector(selectCanvasStagingAreaSlice, (stagingArea) => stagingArea.stagedImages.length);
import { ButtonGroup } from '@invoke-ai/ui-library';
import { useScopeOnMount } from 'common/hooks/interactionScopes';
import { StagingAreaToolbarAcceptButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton';
import { StagingAreaToolbarDiscardAllButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton';
import { StagingAreaToolbarDiscardSelectedButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton';
import { StagingAreaToolbarImageCountButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton';
import { StagingAreaToolbarNextButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton';
import { StagingAreaToolbarPrevButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton';
import { StagingAreaToolbarSaveSelectedToGalleryButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton';
import { StagingAreaToolbarToggleShowResultsButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarToggleShowResultsButton';
import { memo } from 'react';
export const StagingAreaToolbar = memo(() => {
const dispatch = useAppDispatch();
const canvasManager = useCanvasManager();
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const index = useAppSelector(selectStagedImageIndex);
const selectedImage = useAppSelector(selectSelectedImage);
const imageCount = useAppSelector(selectImageCount);
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
const isCanvasActive = useStore(INTERACTION_SCOPES.canvas.$isActive);
const [addImageToBoard] = useAddImagesToBoardMutation();
const [changeIsImageIntermediate] = useChangeImageIsIntermediateMutation();
useScopeOnMount('stagingArea');
const { t } = useTranslation();
const onPrev = useCallback(() => {
dispatch(stagingAreaPrevStagedImageSelected());
}, [dispatch]);
const onNext = useCallback(() => {
dispatch(stagingAreaNextStagedImageSelected());
}, [dispatch]);
const onAccept = useCallback(() => {
if (!selectedImage) {
return;
}
dispatch(stagingAreaImageAccepted({ index }));
}, [dispatch, index, selectedImage]);
const onDiscardOne = useCallback(() => {
if (!selectedImage) {
return;
}
if (imageCount === 1) {
dispatch(stagingAreaReset());
} else {
dispatch(stagingAreaStagedImageDiscarded({ index }));
}
}, [selectedImage, imageCount, dispatch, index]);
const onDiscardAll = useCallback(() => {
dispatch(stagingAreaReset());
}, [dispatch]);
const onToggleShouldShowStagedImage = useCallback(() => {
canvasManager.stagingArea.$shouldShowStagedImage.set(!shouldShowStagedImage);
}, [canvasManager.stagingArea.$shouldShowStagedImage, shouldShowStagedImage]);
const onSaveStagingImage = useCallback(async () => {
if (!selectedImage) {
return;
}
if (autoAddBoardId !== 'none') {
await addImageToBoard({ imageDTOs: [selectedImage.imageDTO], board_id: autoAddBoardId }).unwrap();
// The changeIsImageIntermediate request will use the board_id on this specific imageDTO object, so we need to
// update it before making the request - else the optimistic board updates will get out of whack.
changeIsImageIntermediate({
imageDTO: { ...selectedImage.imageDTO, board_id: autoAddBoardId },
is_intermediate: false,
});
} else {
changeIsImageIntermediate({
imageDTO: selectedImage.imageDTO,
is_intermediate: false,
});
}
}, [addImageToBoard, autoAddBoardId, changeIsImageIntermediate, selectedImage]);
useHotkeys(
['left'],
onPrev,
{
preventDefault: true,
enabled: isCanvasActive && shouldShowStagedImage && imageCount > 1,
},
[isCanvasActive, shouldShowStagedImage, imageCount]
);
useHotkeys(
['right'],
onNext,
{
preventDefault: true,
enabled: isCanvasActive && shouldShowStagedImage && imageCount > 1,
},
[isCanvasActive, shouldShowStagedImage, imageCount]
);
useHotkeys(
['enter'],
onAccept,
{
preventDefault: true,
enabled: isCanvasActive && shouldShowStagedImage && imageCount > 1,
},
[isCanvasActive, shouldShowStagedImage, imageCount]
);
const counterText = useMemo(() => {
if (imageCount > 0) {
return `${(index ?? 0) + 1} of ${imageCount}`;
} else {
return `0 of 0`;
}
}, [imageCount, index]);
return (
<>
<ButtonGroup borderRadius="base" shadow="dark-lg">
<IconButton
tooltip={`${t('unifiedCanvas.previous')} (Left)`}
aria-label={`${t('unifiedCanvas.previous')} (Left)`}
icon={<PiArrowLeftBold />}
onClick={onPrev}
colorScheme="invokeBlue"
isDisabled={imageCount <= 1 || !shouldShowStagedImage}
/>
<Button colorScheme="base" pointerEvents="none" minW={28}>
{counterText}
</Button>
<IconButton
tooltip={`${t('unifiedCanvas.next')} (Right)`}
aria-label={`${t('unifiedCanvas.next')} (Right)`}
icon={<PiArrowRightBold />}
onClick={onNext}
colorScheme="invokeBlue"
isDisabled={imageCount <= 1 || !shouldShowStagedImage}
/>
<StagingAreaToolbarPrevButton />
<StagingAreaToolbarImageCountButton />
<StagingAreaToolbarNextButton />
</ButtonGroup>
<ButtonGroup borderRadius="base" shadow="dark-lg">
<IconButton
tooltip={`${t('unifiedCanvas.accept')} (Enter)`}
aria-label={`${t('unifiedCanvas.accept')} (Enter)`}
icon={<PiCheckBold />}
onClick={onAccept}
colorScheme="invokeBlue"
isDisabled={!selectedImage}
/>
<IconButton
tooltip={shouldShowStagedImage ? t('unifiedCanvas.showResultsOn') : t('unifiedCanvas.showResultsOff')}
aria-label={shouldShowStagedImage ? t('unifiedCanvas.showResultsOn') : t('unifiedCanvas.showResultsOff')}
data-alert={!shouldShowStagedImage}
icon={shouldShowStagedImage ? <PiEyeBold /> : <PiEyeSlashBold />}
onClick={onToggleShouldShowStagedImage}
colorScheme="invokeBlue"
/>
<IconButton
tooltip={`${t('unifiedCanvas.saveToGallery')} (Shift+S)`}
aria-label={t('unifiedCanvas.saveToGallery')}
icon={<PiFloppyDiskBold />}
onClick={onSaveStagingImage}
colorScheme="invokeBlue"
isDisabled={!selectedImage || !selectedImage.imageDTO.is_intermediate}
/>
<IconButton
tooltip={`${t('unifiedCanvas.discardCurrent')}`}
aria-label={t('unifiedCanvas.discardCurrent')}
icon={<PiXBold />}
onClick={onDiscardOne}
colorScheme="invokeBlue"
fontSize={16}
isDisabled={!selectedImage}
/>
<IconButton
tooltip={`${t('unifiedCanvas.discardAll')} (Esc)`}
aria-label={t('unifiedCanvas.discardAll')}
icon={<PiTrashSimpleBold />}
onClick={onDiscardAll}
colorScheme="error"
fontSize={16}
/>
<StagingAreaToolbarAcceptButton />
<StagingAreaToolbarToggleShowResultsButton />
<StagingAreaToolbarSaveSelectedToGalleryButton />
<StagingAreaToolbarDiscardSelectedButton />
<StagingAreaToolbarDiscardAllButton />
</ButtonGroup>
</>
);

View File

@@ -0,0 +1,57 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { INTERACTION_SCOPES } from 'common/hooks/interactionScopes';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import {
selectImageCount,
selectSelectedImage,
selectStagedImageIndex,
stagingAreaImageAccepted,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiCheckBold } from 'react-icons/pi';
export const StagingAreaToolbarAcceptButton = memo(() => {
const dispatch = useAppDispatch();
const canvasManager = useCanvasManager();
const index = useAppSelector(selectStagedImageIndex);
const selectedImage = useAppSelector(selectSelectedImage);
const imageCount = useAppSelector(selectImageCount);
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
const isCanvasActive = useStore(INTERACTION_SCOPES.canvas.$isActive);
const { t } = useTranslation();
const acceptSelected = useCallback(() => {
if (!selectedImage) {
return;
}
dispatch(stagingAreaImageAccepted({ index }));
}, [dispatch, index, selectedImage]);
useHotkeys(
['enter'],
acceptSelected,
{
preventDefault: true,
enabled: isCanvasActive && shouldShowStagedImage && imageCount > 1,
},
[isCanvasActive, shouldShowStagedImage, imageCount]
);
return (
<IconButton
tooltip={`${t('unifiedCanvas.accept')} (Enter)`}
aria-label={`${t('unifiedCanvas.accept')} (Enter)`}
icon={<PiCheckBold />}
onClick={acceptSelected}
colorScheme="invokeBlue"
isDisabled={!selectedImage}
/>
);
});
StagingAreaToolbarAcceptButton.displayName = 'StagingAreaToolbarAcceptButton';

View File

@@ -0,0 +1,28 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { stagingAreaReset } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold } from 'react-icons/pi';
export const StagingAreaToolbarDiscardAllButton = memo(() => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const discardAll = useCallback(() => {
dispatch(stagingAreaReset());
}, [dispatch]);
return (
<IconButton
tooltip={`${t('unifiedCanvas.discardAll')} (Esc)`}
aria-label={t('unifiedCanvas.discardAll')}
icon={<PiTrashSimpleBold />}
onClick={discardAll}
colorScheme="error"
fontSize={16}
/>
);
});
StagingAreaToolbarDiscardAllButton.displayName = 'StagingAreaToolbarDiscardAllButton';

View File

@@ -0,0 +1,46 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
selectImageCount,
selectSelectedImage,
selectStagedImageIndex,
stagingAreaReset,
stagingAreaStagedImageDiscarded,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
export const StagingAreaToolbarDiscardSelectedButton = memo(() => {
const dispatch = useAppDispatch();
const index = useAppSelector(selectStagedImageIndex);
const selectedImage = useAppSelector(selectSelectedImage);
const imageCount = useAppSelector(selectImageCount);
const { t } = useTranslation();
const discardSelected = useCallback(() => {
if (!selectedImage) {
return;
}
if (imageCount === 1) {
dispatch(stagingAreaReset());
} else {
dispatch(stagingAreaStagedImageDiscarded({ index }));
}
}, [selectedImage, imageCount, dispatch, index]);
return (
<IconButton
tooltip={`${t('unifiedCanvas.discardCurrent')}`}
aria-label={t('unifiedCanvas.discardCurrent')}
icon={<PiXBold />}
onClick={discardSelected}
colorScheme="invokeBlue"
fontSize={16}
isDisabled={!selectedImage}
/>
);
});
StagingAreaToolbarDiscardSelectedButton.displayName = 'StagingAreaToolbarDiscardSelectedButton';

View File

@@ -0,0 +1,25 @@
import { Button } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectImageCount, selectStagedImageIndex } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo, useMemo } from 'react';
export const StagingAreaToolbarImageCountButton = memo(() => {
const index = useAppSelector(selectStagedImageIndex);
const imageCount = useAppSelector(selectImageCount);
const counterText = useMemo(() => {
if (imageCount > 0) {
return `${(index ?? 0) + 1} of ${imageCount}`;
} else {
return `0 of 0`;
}
}, [imageCount, index]);
return (
<Button colorScheme="base" pointerEvents="none" minW={28}>
{counterText}
</Button>
);
});
StagingAreaToolbarImageCountButton.displayName = 'StagingAreaToolbarImageCountButton';

View File

@@ -0,0 +1,50 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { INTERACTION_SCOPES } from 'common/hooks/interactionScopes';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import {
selectImageCount,
stagingAreaNextStagedImageSelected,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiArrowRightBold } from 'react-icons/pi';
export const StagingAreaToolbarNextButton = memo(() => {
const dispatch = useAppDispatch();
const canvasManager = useCanvasManager();
const imageCount = useAppSelector(selectImageCount);
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
const isCanvasActive = useStore(INTERACTION_SCOPES.canvas.$isActive);
const { t } = useTranslation();
const selectNext = useCallback(() => {
dispatch(stagingAreaNextStagedImageSelected());
}, [dispatch]);
useHotkeys(
['right'],
selectNext,
{
preventDefault: true,
enabled: isCanvasActive && shouldShowStagedImage && imageCount > 1,
},
[isCanvasActive, shouldShowStagedImage, imageCount]
);
return (
<IconButton
tooltip={`${t('unifiedCanvas.next')} (Right)`}
aria-label={`${t('unifiedCanvas.next')} (Right)`}
icon={<PiArrowRightBold />}
onClick={selectNext}
colorScheme="invokeBlue"
isDisabled={imageCount <= 1 || !shouldShowStagedImage}
/>
);
});
StagingAreaToolbarNextButton.displayName = 'StagingAreaToolbarNextButton';

View File

@@ -0,0 +1,50 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { INTERACTION_SCOPES } from 'common/hooks/interactionScopes';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import {
selectImageCount,
stagingAreaPrevStagedImageSelected,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiArrowLeftBold } from 'react-icons/pi';
export const StagingAreaToolbarPrevButton = memo(() => {
const dispatch = useAppDispatch();
const canvasManager = useCanvasManager();
const imageCount = useAppSelector(selectImageCount);
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
const isCanvasActive = useStore(INTERACTION_SCOPES.canvas.$isActive);
const { t } = useTranslation();
const selectPrev = useCallback(() => {
dispatch(stagingAreaPrevStagedImageSelected());
}, [dispatch]);
useHotkeys(
['left'],
selectPrev,
{
preventDefault: true,
enabled: isCanvasActive && shouldShowStagedImage && imageCount > 1,
},
[isCanvasActive, shouldShowStagedImage, imageCount]
);
return (
<IconButton
tooltip={`${t('unifiedCanvas.previous')} (Left)`}
aria-label={`${t('unifiedCanvas.previous')} (Left)`}
icon={<PiArrowLeftBold />}
onClick={selectPrev}
colorScheme="invokeBlue"
isDisabled={imageCount <= 1 || !shouldShowStagedImage}
/>
);
});
StagingAreaToolbarPrevButton.displayName = 'StagingAreaToolbarPrevButton';

View File

@@ -0,0 +1,50 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectSelectedImage } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFloppyDiskBold } from 'react-icons/pi';
import { useAddImagesToBoardMutation, useChangeImageIsIntermediateMutation } from 'services/api/endpoints/images';
export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => {
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const selectedImage = useAppSelector(selectSelectedImage);
const [addImageToBoard] = useAddImagesToBoardMutation();
const [changeIsImageIntermediate] = useChangeImageIsIntermediateMutation();
const { t } = useTranslation();
const saveSelectedImageToGallery = useCallback(async () => {
if (!selectedImage) {
return;
}
if (autoAddBoardId !== 'none') {
await addImageToBoard({ imageDTOs: [selectedImage.imageDTO], board_id: autoAddBoardId }).unwrap();
// The changeIsImageIntermediate request will use the board_id on this specific imageDTO object, so we need to
// update it before making the request - else the optimistic board updates will get out of whack.
changeIsImageIntermediate({
imageDTO: { ...selectedImage.imageDTO, board_id: autoAddBoardId },
is_intermediate: false,
});
} else {
changeIsImageIntermediate({
imageDTO: selectedImage.imageDTO,
is_intermediate: false,
});
}
}, [addImageToBoard, autoAddBoardId, changeIsImageIntermediate, selectedImage]);
return (
<IconButton
tooltip={t('unifiedCanvas.saveToGallery')}
aria-label={t('unifiedCanvas.saveToGallery')}
icon={<PiFloppyDiskBold />}
onClick={saveSelectedImageToGallery}
colorScheme="invokeBlue"
isDisabled={!selectedImage || !selectedImage.imageDTO.is_intermediate}
/>
);
});
StagingAreaToolbarSaveSelectedToGalleryButton.displayName = 'StagingAreaToolbarSaveSelectedToGalleryButton';

View File

@@ -0,0 +1,30 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiEyeBold, PiEyeSlashBold } from 'react-icons/pi';
export const StagingAreaToolbarToggleShowResultsButton = memo(() => {
const canvasManager = useCanvasManager();
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
const { t } = useTranslation();
const toggleShowResults = useCallback(() => {
canvasManager.stagingArea.$shouldShowStagedImage.set(!shouldShowStagedImage);
}, [canvasManager.stagingArea.$shouldShowStagedImage, shouldShowStagedImage]);
return (
<IconButton
tooltip={shouldShowStagedImage ? t('unifiedCanvas.showResultsOn') : t('unifiedCanvas.showResultsOff')}
aria-label={shouldShowStagedImage ? t('unifiedCanvas.showResultsOn') : t('unifiedCanvas.showResultsOff')}
data-alert={!shouldShowStagedImage}
icon={shouldShowStagedImage ? <PiEyeBold /> : <PiEyeSlashBold />}
onClick={toggleShowResults}
colorScheme="invokeBlue"
/>
);
});
StagingAreaToolbarToggleShowResultsButton.displayName = 'StagingAreaToolbarToggleShowResultsButton';

View File

@@ -90,3 +90,15 @@ export const selectIsStaging = createSelector(
return data.in_progress > 0 || data.pending > 0;
}
);
export const selectStagedImageIndex = createSelector(
selectCanvasStagingAreaSlice,
(stagingArea) => stagingArea.selectedStagedImageIndex
);
export const selectSelectedImage = createSelector(
[selectCanvasStagingAreaSlice, selectStagedImageIndex],
(stagingArea, index) => stagingArea.stagedImages[index] ?? null
);
export const selectImageCount = createSelector(
selectCanvasStagingAreaSlice,
(stagingArea) => stagingArea.stagedImages.length
);