feat(ui): make image hotkeys global

This commit is contained in:
psychedelicious
2024-09-30 17:56:00 +10:00
parent 8cf0d8c8d3
commit 7167a5d3f4
7 changed files with 220 additions and 215 deletions

View File

@@ -11,7 +11,7 @@ import { PiFlowArrowBold } from 'react-icons/pi';
export const ImageMenuItemLoadWorkflow = memo(() => {
const { t } = useTranslation();
const imageDTO = useImageDTOContext();
const { getAndLoadEmbeddedWorkflow, getAndLoadEmbeddedWorkflowResult } = useGetAndLoadEmbeddedWorkflow({});
const [getAndLoadEmbeddedWorkflow, { isLoading }] = useGetAndLoadEmbeddedWorkflow();
const hasTemplates = useStore($hasTemplates);
const onClick = useCallback(() => {
@@ -20,7 +20,7 @@ export const ImageMenuItemLoadWorkflow = memo(() => {
return (
<MenuItem
icon={getAndLoadEmbeddedWorkflowResult.isLoading ? <SpinnerIcon /> : <PiFlowArrowBold />}
icon={isLoading ? <SpinnerIcon /> : <PiFlowArrowBold />}
onClickCapture={onClick}
isDisabled={!imageDTO.has_workflow || !hasTemplates}
>

View File

@@ -1,5 +1,4 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { SpinnerIcon } from 'features/gallery/components/ImageContextMenu/SpinnerIcon';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { useImageActions } from 'features/gallery/hooks/useImageActions';
import { memo } from 'react';
@@ -16,53 +15,24 @@ export const ImageMenuItemMetadataRecallActions = memo(() => {
const { t } = useTranslation();
const imageDTO = useImageDTOContext();
const {
recallAll,
remix,
recallSeed,
recallPrompts,
hasMetadata,
hasSeed,
hasPrompts,
isLoadingMetadata,
createAsPreset,
} = useImageActions(imageDTO?.image_name);
const { recallAll, remix, recallSeed, recallPrompts, hasMetadata, hasSeed, hasPrompts, createAsPreset } =
useImageActions(imageDTO);
return (
<>
<MenuItem
icon={isLoadingMetadata ? <SpinnerIcon /> : <PiArrowsCounterClockwiseBold />}
onClickCapture={remix}
isDisabled={isLoadingMetadata || !hasMetadata}
>
<MenuItem icon={<PiArrowsCounterClockwiseBold />} onClickCapture={remix} isDisabled={!hasMetadata}>
{t('parameters.remixImage')}
</MenuItem>
<MenuItem
icon={isLoadingMetadata ? <SpinnerIcon /> : <PiQuotesBold />}
onClickCapture={recallPrompts}
isDisabled={isLoadingMetadata || !hasPrompts}
>
<MenuItem icon={<PiQuotesBold />} onClickCapture={recallPrompts} isDisabled={!hasPrompts}>
{t('parameters.usePrompt')}
</MenuItem>
<MenuItem
icon={isLoadingMetadata ? <SpinnerIcon /> : <PiPlantBold />}
onClickCapture={recallSeed}
isDisabled={isLoadingMetadata || !hasSeed}
>
<MenuItem icon={<PiPlantBold />} onClickCapture={recallSeed} isDisabled={!hasSeed}>
{t('parameters.useSeed')}
</MenuItem>
<MenuItem
icon={isLoadingMetadata ? <SpinnerIcon /> : <PiAsteriskBold />}
onClickCapture={recallAll}
isDisabled={isLoadingMetadata || !hasMetadata}
>
<MenuItem icon={<PiAsteriskBold />} onClickCapture={recallAll} isDisabled={!hasMetadata}>
{t('parameters.useAll')}
</MenuItem>
<MenuItem
icon={isLoadingMetadata ? <SpinnerIcon /> : <PiPaintBrushBold />}
onClickCapture={createAsPreset}
isDisabled={isLoadingMetadata || !hasPrompts}
>
<MenuItem icon={<PiPaintBrushBold />} onClickCapture={createAsPreset} isDisabled={!hasPrompts}>
{t('stylePresets.useForTemplate')}
</MenuItem>
</>

View File

@@ -1,24 +1,16 @@
import { ButtonGroup, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { skipToken } from '@reduxjs/toolkit/query';
import { adHocPostProcessingRequested } from 'app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useIsRegionFocused } from 'common/hooks/interactionScopes';
import { useAppSelector } from 'app/store/storeHooks';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems';
import { useImageActions } from 'features/gallery/hooks/useImageActions';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { parseAndRecallImageDimensions } from 'features/metadata/util/handlers';
import { $templates } from 'features/nodes/store/nodesSlice';
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
import { PostProcessingPopover } from 'features/parameters/components/PostProcessing/PostProcessingPopover';
import { useIsQueueMutationInProgress } from 'features/queue/hooks/useIsQueueMutationInProgress';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { useGetAndLoadEmbeddedWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow';
import { size } from 'lodash-es';
import { memo, useCallback, useMemo } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import {
PiArrowsCounterClockwiseBold,
@@ -30,110 +22,31 @@ import {
PiRulerBold,
} from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { $isConnected, $progressImage } from 'services/events/stores';
import type { ImageDTO } from 'services/api/types';
const CurrentImageButtons = () => {
const dispatch = useAppDispatch();
const isConnected = useStore($isConnected);
const isStaging = useAppSelector(selectIsStaging);
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
const progressImage = useStore($progressImage);
const shouldDisableToolbarButtons = useMemo(() => {
return Boolean(progressImage) || !lastSelectedImage;
}, [lastSelectedImage, progressImage]);
const templates = useStore($templates);
const isUpscalingEnabled = useFeatureStatus('upscaling');
const isQueueMutationInProgress = useIsQueueMutationInProgress();
const { t } = useTranslation();
const isGalleryFocused = useIsRegionFocused('gallery');
const isViewerFocused = useIsRegionFocused('viewer');
const { currentData: imageDTO } = useGetImageDTOQuery(lastSelectedImage?.image_name ?? skipToken);
const { recallAll, remix, recallSeed, recallPrompts, hasMetadata, hasSeed, hasPrompts, isLoadingMetadata } =
useImageActions(lastSelectedImage?.image_name);
if (!imageDTO) {
return null;
}
const { getAndLoadEmbeddedWorkflow, getAndLoadEmbeddedWorkflowResult } = useGetAndLoadEmbeddedWorkflow({});
return <CurrentImageButtonsContent imageDTO={imageDTO} />;
};
const handleLoadWorkflow = useCallback(() => {
if (!lastSelectedImage || !lastSelectedImage.has_workflow) {
return;
}
getAndLoadEmbeddedWorkflow(lastSelectedImage.image_name);
}, [getAndLoadEmbeddedWorkflow, lastSelectedImage]);
export default memo(CurrentImageButtons);
const handleUseSize = useCallback(() => {
if (isStaging) {
return;
}
parseAndRecallImageDimensions(lastSelectedImage);
}, [isStaging, lastSelectedImage]);
const handleClickUpscale = useCallback(() => {
if (!imageDTO) {
return;
}
dispatch(adHocPostProcessingRequested({ imageDTO }));
}, [dispatch, imageDTO]);
const handleDelete = useCallback(() => {
if (!imageDTO) {
return;
}
dispatch(imagesToDeleteSelected([imageDTO]));
}, [dispatch, imageDTO]);
useRegisteredHotkeys({
id: 'loadWorkflow',
category: 'viewer',
callback: handleLoadWorkflow,
options: { enabled: isGalleryFocused || isViewerFocused },
dependencies: [handleLoadWorkflow, isGalleryFocused, isViewerFocused],
});
useRegisteredHotkeys({
id: 'recallAll',
category: 'viewer',
callback: recallAll,
options: { enabled: isGalleryFocused || isViewerFocused },
dependencies: [recallAll, isGalleryFocused, isViewerFocused],
});
useRegisteredHotkeys({
id: 'recallSeed',
category: 'viewer',
callback: recallSeed,
options: { enabled: isGalleryFocused || isViewerFocused },
dependencies: [recallSeed, isGalleryFocused, isViewerFocused],
});
useRegisteredHotkeys({
id: 'recallPrompts',
category: 'viewer',
callback: recallPrompts,
options: { enabled: isGalleryFocused || isViewerFocused },
dependencies: [recallPrompts, isGalleryFocused, isViewerFocused],
});
useRegisteredHotkeys({
id: 'remix',
category: 'viewer',
callback: remix,
options: { enabled: isGalleryFocused || isViewerFocused },
dependencies: [remix, isGalleryFocused, isViewerFocused],
});
useRegisteredHotkeys({
id: 'useSize',
category: 'viewer',
callback: handleUseSize,
options: { enabled: isGalleryFocused || isViewerFocused },
dependencies: [handleUseSize, isGalleryFocused, isViewerFocused],
});
useRegisteredHotkeys({
id: 'runPostprocessing',
category: 'viewer',
callback: handleClickUpscale,
options: { enabled: Boolean(isUpscalingEnabled && isViewerFocused && isConnected) },
dependencies: [isUpscalingEnabled, imageDTO, shouldDisableToolbarButtons, isConnected, isViewerFocused],
});
const CurrentImageButtonsContent = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
const { t } = useTranslation();
const hasTemplates = useStore($hasTemplates);
const imageActions = useImageActions(imageDTO);
const isStaging = useAppSelector(selectIsStaging);
const isUpscalingEnabled = useFeatureStatus('upscaling');
return (
<>
<ButtonGroup isDisabled={shouldDisableToolbarButtons}>
<ButtonGroup>
<Menu isLazy>
<MenuButton
as={IconButton}
@@ -146,68 +59,62 @@ const CurrentImageButtons = () => {
</Menu>
</ButtonGroup>
<ButtonGroup isDisabled={shouldDisableToolbarButtons}>
<ButtonGroup>
<IconButton
icon={<PiFlowArrowBold />}
tooltip={`${t('nodes.loadWorkflow')} (W)`}
aria-label={`${t('nodes.loadWorkflow')} (W)`}
isDisabled={!imageDTO?.has_workflow || !size(templates)}
onClick={handleLoadWorkflow}
isLoading={getAndLoadEmbeddedWorkflowResult.isLoading}
isDisabled={!imageActions.hasWorkflow || !hasTemplates}
onClick={imageActions.loadWorkflow}
/>
<IconButton
isLoading={isLoadingMetadata}
icon={<PiArrowsCounterClockwiseBold />}
tooltip={`${t('parameters.remixImage')} (R)`}
aria-label={`${t('parameters.remixImage')} (R)`}
isDisabled={!hasMetadata}
onClick={remix}
isDisabled={!imageActions.hasMetadata}
onClick={imageActions.remix}
/>
<IconButton
isLoading={isLoadingMetadata}
icon={<PiQuotesBold />}
tooltip={`${t('parameters.usePrompt')} (P)`}
aria-label={`${t('parameters.usePrompt')} (P)`}
isDisabled={!hasPrompts}
onClick={recallPrompts}
isDisabled={!imageActions.hasPrompts}
onClick={imageActions.recallPrompts}
/>
<IconButton
isLoading={isLoadingMetadata}
icon={<PiPlantBold />}
tooltip={`${t('parameters.useSeed')} (S)`}
aria-label={`${t('parameters.useSeed')} (S)`}
isDisabled={!hasSeed}
onClick={recallSeed}
isDisabled={!imageActions.hasSeed}
onClick={imageActions.recallSeed}
/>
<IconButton
isLoading={isLoadingMetadata}
icon={<PiRulerBold />}
tooltip={`${t('parameters.useSize')} (D)`}
aria-label={`${t('parameters.useSize')} (D)`}
onClick={handleUseSize}
onClick={imageActions.recallSize}
isDisabled={isStaging}
/>
<IconButton
isLoading={isLoadingMetadata}
icon={<PiAsteriskBold />}
tooltip={`${t('parameters.useAll')} (A)`}
aria-label={`${t('parameters.useAll')} (A)`}
isDisabled={!hasMetadata}
onClick={recallAll}
isDisabled={!imageActions.hasMetadata}
onClick={imageActions.recallAll}
/>
</ButtonGroup>
{isUpscalingEnabled && (
<ButtonGroup isDisabled={isQueueMutationInProgress}>
{isUpscalingEnabled && <PostProcessingPopover imageDTO={imageDTO} />}
<ButtonGroup>
<PostProcessingPopover imageDTO={imageDTO} />
</ButtonGroup>
)}
<ButtonGroup>
<DeleteImageButton onClick={handleDelete} />
<DeleteImageButton onClick={imageActions.delete} />
</ButtonGroup>
</>
);
};
});
export default memo(CurrentImageButtons);
CurrentImageButtonsContent.displayName = 'CurrentImageButtonsContent';

View File

@@ -1,7 +1,15 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useStore } from '@nanostores/react';
import { adHocPostProcessingRequested } from 'app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { handlers, parseAndRecallAllMetadata, parseAndRecallPrompts } from 'features/metadata/util/handlers';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import {
handlers,
parseAndRecallAllMetadata,
parseAndRecallImageDimensions,
parseAndRecallPrompts,
} from 'features/metadata/util/handlers';
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal';
import {
activeStylePresetIdChanged,
@@ -9,22 +17,23 @@ import {
} from 'features/stylePresets/store/stylePresetSlice';
import { toast } from 'features/toast/toast';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { useGetAndLoadEmbeddedWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
import type { ImageDTO } from 'services/api/types';
export const useImageActions = (image_name?: string) => {
export const useImageActions = (imageDTO: ImageDTO) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const activeStylePresetId = useAppSelector(selectStylePresetActivePresetId);
const isStaging = useAppSelector(selectIsStaging);
const activeTabName = useAppSelector(selectActiveTab);
const { metadata, isLoading: isLoadingMetadata } = useDebouncedMetadata(image_name);
const { metadata } = useDebouncedMetadata(imageDTO.image_name);
const [hasMetadata, setHasMetadata] = useState(false);
const [hasSeed, setHasSeed] = useState(false);
const [hasPrompts, setHasPrompts] = useState(false);
const { data: imageDTO } = useGetImageDTOQuery(image_name ?? skipToken);
const hasTemplates = useStore($hasTemplates);
useEffect(() => {
const parseMetadata = async () => {
@@ -68,66 +77,107 @@ export const useImageActions = (image_name?: string) => {
}, [dispatch, activeStylePresetId, t]);
const recallAll = useCallback(() => {
if (!metadata) {
return;
}
parseAndRecallAllMetadata(metadata, activeTabName === 'canvas', isStaging ? ['width', 'height'] : []);
clearStylePreset();
}, [metadata, activeTabName, isStaging, clearStylePreset]);
const remix = useCallback(() => {
if (!metadata) {
return;
}
// Recalls all metadata parameters except seed
parseAndRecallAllMetadata(metadata, activeTabName === 'canvas', ['seed']);
clearStylePreset();
}, [activeTabName, metadata, clearStylePreset]);
const recallSeed = useCallback(() => {
if (!metadata) {
return;
}
handlers.seed.parse(metadata).then((seed) => {
handlers.seed.recall && handlers.seed.recall(seed, true);
});
}, [metadata]);
const recallPrompts = useCallback(() => {
if (!metadata) {
return;
}
parseAndRecallPrompts(metadata);
clearStylePreset();
}, [metadata, clearStylePreset]);
const createAsPreset = useCallback(async () => {
if (image_name && metadata && imageDTO) {
let positivePrompt;
let negativePrompt;
try {
positivePrompt = await handlers.positivePrompt.parse(metadata);
} catch (error) {
positivePrompt = '';
}
try {
negativePrompt = await handlers.negativePrompt.parse(metadata);
} catch (error) {
negativePrompt = '';
}
$stylePresetModalState.set({
prefilledFormData: {
name: '',
positivePrompt,
negativePrompt,
imageUrl: imageDTO.image_url,
type: 'user',
},
updatingStylePresetId: null,
isModalOpen: true,
});
if (!metadata) {
return;
}
}, [image_name, metadata, imageDTO]);
let positivePrompt;
let negativePrompt;
try {
positivePrompt = await handlers.positivePrompt.parse(metadata);
} catch (error) {
positivePrompt = '';
}
try {
negativePrompt = await handlers.negativePrompt.parse(metadata);
} catch (error) {
negativePrompt = '';
}
$stylePresetModalState.set({
prefilledFormData: {
name: '',
positivePrompt,
negativePrompt,
imageUrl: imageDTO.image_url,
type: 'user',
},
updatingStylePresetId: null,
isModalOpen: true,
});
}, [metadata, imageDTO]);
const [getAndLoadEmbeddedWorkflow] = useGetAndLoadEmbeddedWorkflow();
const loadWorkflow = useCallback(() => {
if (!imageDTO.has_workflow || !hasTemplates) {
return;
}
getAndLoadEmbeddedWorkflow(imageDTO.image_name);
}, [getAndLoadEmbeddedWorkflow, hasTemplates, imageDTO.has_workflow, imageDTO.image_name]);
const recallSize = useCallback(() => {
if (isStaging) {
return;
}
parseAndRecallImageDimensions(imageDTO);
}, [imageDTO, isStaging]);
const upscale = useCallback(() => {
dispatch(adHocPostProcessingRequested({ imageDTO }));
}, [dispatch, imageDTO]);
const _delete = useCallback(() => {
dispatch(imagesToDeleteSelected([imageDTO]));
}, [dispatch, imageDTO]);
return {
hasMetadata,
hasSeed,
hasPrompts,
recallAll,
remix,
recallSeed,
recallPrompts,
hasMetadata,
hasSeed,
hasPrompts,
isLoadingMetadata,
createAsPreset,
loadWorkflow,
hasWorkflow: imageDTO.has_workflow,
recallSize,
upscale,
delete: _delete,
};
};

View File

@@ -10,19 +10,10 @@ type UseGetAndLoadEmbeddedWorkflowOptions = {
onError?: () => void;
};
type UseGetAndLoadEmbeddedWorkflowReturn = {
getAndLoadEmbeddedWorkflow: (imageName: string) => Promise<void>;
getAndLoadEmbeddedWorkflowResult: ReturnType<typeof useLazyGetImageWorkflowQuery>[1];
};
type UseGetAndLoadEmbeddedWorkflow = (
options?: UseGetAndLoadEmbeddedWorkflowOptions
) => UseGetAndLoadEmbeddedWorkflowReturn;
export const useGetAndLoadEmbeddedWorkflow: UseGetAndLoadEmbeddedWorkflow = (options) => {
export const useGetAndLoadEmbeddedWorkflow = (options?: UseGetAndLoadEmbeddedWorkflowOptions) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const [_getAndLoadEmbeddedWorkflow, getAndLoadEmbeddedWorkflowResult] = useLazyGetImageWorkflowQuery();
const [_getAndLoadEmbeddedWorkflow, result] = useLazyGetImageWorkflowQuery();
const getAndLoadEmbeddedWorkflow = useCallback(
async (imageName: string) => {
try {
@@ -50,5 +41,5 @@ export const useGetAndLoadEmbeddedWorkflow: UseGetAndLoadEmbeddedWorkflow = (opt
[_getAndLoadEmbeddedWorkflow, dispatch, options, t]
);
return { getAndLoadEmbeddedWorkflow, getAndLoadEmbeddedWorkflowResult };
return [getAndLoadEmbeddedWorkflow, result] as const;
};