diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 2d7737d79b..3589816144 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -134,6 +134,7 @@ "nodes": "Workflows", "notInstalled": "Not $t(common.installed)", "openInNewTab": "Open in New Tab", + "openInViewer": "Open in Viewer", "orderBy": "Order By", "outpaint": "outpaint", "outputs": "Outputs", @@ -1049,8 +1050,7 @@ "seamlessYAxis": "Seamless Tiling Y Axis", "seed": "Seed", "imageActions": "Image Actions", - "sendToImg2Img": "Send to Image to Image", - "sendToUnifiedCanvas": "Send To Unified Canvas", + "sendToCanvas": "Send To Canvas", "sendToUpscale": "Send To Upscale", "showOptionsPanel": "Show Side Panel (O or T)", "shuffle": "Shuffle Seed", @@ -1191,8 +1191,8 @@ "problemSavingMaskDesc": "Unable to export mask", "prunedQueue": "Pruned Queue", "resetInitialImage": "Reset Initial Image", - "sentToImageToImage": "Sent To Image To Image", - "sentToUnifiedCanvas": "Sent to Unified Canvas", + "sentToCanvas": "Sent to Canvas", + "sentToUpscale": "Sent to Upscale", "serverError": "Server Error", "sessionRef": "Session: {{sessionId}}", "setAsCanvasInitialImage": "Set as canvas initial image", diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemChangeBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemChangeBoard.tsx new file mode 100644 index 0000000000..200b08b4c2 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemChangeBoard.tsx @@ -0,0 +1,26 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { imagesToChangeSelected, isModalOpenChanged } from 'features/changeBoardModal/store/slice'; +import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiFoldersBold } from 'react-icons/pi'; + +export const ImageMenuItemChangeBoard = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const imageDTO = useImageDTOContext(); + + const onClick = useCallback(() => { + dispatch(imagesToChangeSelected([imageDTO])); + dispatch(isModalOpenChanged(true)); + }, [dispatch, imageDTO]); + + return ( + } onClickCapture={onClick}> + {t('boards.changeBoard')} + + ); +}); + +ImageMenuItemChangeBoard.displayName = 'ImageMenuItemChangeBoard'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemCopy.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemCopy.tsx new file mode 100644 index 0000000000..b585d32ba7 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemCopy.tsx @@ -0,0 +1,28 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useCopyImageToClipboard } from 'common/hooks/useCopyImageToClipboard'; +import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCopyBold } from 'react-icons/pi'; + +export const ImageMenuItemCopy = memo(() => { + const { t } = useTranslation(); + const imageDTO = useImageDTOContext(); + const { isClipboardAPIAvailable, copyImageToClipboard } = useCopyImageToClipboard(); + + const onClick = useCallback(() => { + copyImageToClipboard(imageDTO.image_url); + }, [copyImageToClipboard, imageDTO.image_url]); + + if (!isClipboardAPIAvailable) { + return null; + } + + return ( + } onClickCapture={onClick}> + {t('parameters.copyImage')} + + ); +}); + +ImageMenuItemCopy.displayName = 'ImageMenuItemCopy'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDelete.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDelete.tsx new file mode 100644 index 0000000000..1b4fe6b3f7 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDelete.tsx @@ -0,0 +1,25 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; +import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiTrashSimpleBold } from 'react-icons/pi'; + +export const ImageMenuItemDelete = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const imageDTO = useImageDTOContext(); + + const onClick = useCallback(() => { + dispatch(imagesToDeleteSelected([imageDTO])); + }, [dispatch, imageDTO]); + + return ( + } onClickCapture={onClick}> + {t('gallery.deleteImage', { count: 1 })} + + ); +}); + +ImageMenuItemDelete.displayName = 'ImageMenuItemDelete'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDownload.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDownload.tsx new file mode 100644 index 0000000000..769fb50dcc --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDownload.tsx @@ -0,0 +1,24 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useDownloadImage } from 'common/hooks/useDownloadImage'; +import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiDownloadSimpleBold } from 'react-icons/pi'; + +export const ImageMenuItemDownload = memo(() => { + const { t } = useTranslation(); + const imageDTO = useImageDTOContext(); + const { downloadImage } = useDownloadImage(); + + const onClick = useCallback(() => { + downloadImage(imageDTO.image_url, imageDTO.image_name); + }, [downloadImage, imageDTO.image_name, imageDTO.image_url]); + + return ( + } onClickCapture={onClick}> + {t('parameters.downloadImage')} + + ); +}); + +ImageMenuItemDownload.displayName = 'ImageMenuItemDownload'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow.tsx new file mode 100644 index 0000000000..53cb94c300 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow.tsx @@ -0,0 +1,32 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { SpinnerIcon } from 'features/gallery/components/ImageContextMenu/SpinnerIcon'; +import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { $hasTemplates } from 'features/nodes/store/nodesSlice'; +import { useGetAndLoadEmbeddedWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiFlowArrowBold } from 'react-icons/pi'; + +export const ImageMenuItemLoadWorkflow = memo(() => { + const { t } = useTranslation(); + const imageDTO = useImageDTOContext(); + const { getAndLoadEmbeddedWorkflow, getAndLoadEmbeddedWorkflowResult } = useGetAndLoadEmbeddedWorkflow({}); + const hasTemplates = useStore($hasTemplates); + + const onClick = useCallback(() => { + getAndLoadEmbeddedWorkflow(imageDTO.image_name); + }, [getAndLoadEmbeddedWorkflow, imageDTO.image_name]); + + return ( + : } + onClickCapture={onClick} + isDisabled={!imageDTO.has_workflow || !hasTemplates} + > + {t('nodes.loadWorkflow')} + + ); +}); + +ImageMenuItemLoadWorkflow.displayName = 'ImageMenuItemLoadWorkflow'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemMetadataRecallActions.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemMetadataRecallActions.tsx new file mode 100644 index 0000000000..6f47cf9538 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemMetadataRecallActions.tsx @@ -0,0 +1,72 @@ +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'; +import { useTranslation } from 'react-i18next'; +import { + PiArrowsCounterClockwiseBold, + PiAsteriskBold, + PiPaintBrushBold, + PiPlantBold, + PiQuotesBold, +} from 'react-icons/pi'; + +export const ImageMenuItemMetadataRecallActions = memo(() => { + const { t } = useTranslation(); + const imageDTO = useImageDTOContext(); + + const { + recallAll, + remix, + recallSeed, + recallPrompts, + hasMetadata, + hasSeed, + hasPrompts, + isLoadingMetadata, + createAsPreset, + } = useImageActions(imageDTO?.image_name); + + return ( + <> + : } + onClickCapture={remix} + isDisabled={isLoadingMetadata || !hasMetadata} + > + {t('parameters.remixImage')} + + : } + onClickCapture={recallPrompts} + isDisabled={isLoadingMetadata || !hasPrompts} + > + {t('parameters.usePrompt')} + + : } + onClickCapture={recallSeed} + isDisabled={isLoadingMetadata || !hasSeed} + > + {t('parameters.useSeed')} + + : } + onClickCapture={recallAll} + isDisabled={isLoadingMetadata || !hasMetadata} + > + {t('parameters.useAll')} + + : } + onClickCapture={createAsPreset} + isDisabled={isLoadingMetadata || !hasPrompts} + > + {t('stylePresets.useForTemplate')} + + + ); +}); + +ImageMenuItemMetadataRecallActions.displayName = 'ImageMenuItemMetadataRecallActions'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInNewTab.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInNewTab.tsx new file mode 100644 index 0000000000..e90e443dce --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInNewTab.tsx @@ -0,0 +1,18 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiArrowSquareOutBold } from 'react-icons/pi'; + +export const ImageMenuItemOpenInNewTab = memo(() => { + const { t } = useTranslation(); + const imageDTO = useImageDTOContext(); + + return ( + }> + {t('common.openInNewTab')} + + ); +}); + +ImageMenuItemOpenInNewTab.displayName = 'ImageMenuItemOpenInNewTab'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx new file mode 100644 index 0000000000..cb5313cd18 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx @@ -0,0 +1,29 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; +import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiEyeBold } from 'react-icons/pi'; + +export const ImageMenuItemOpenInViewer = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const imageDTO = useImageDTOContext(); + const imageViewer = useImageViewer(); + + const onClick = useCallback(() => { + dispatch(imageToCompareChanged(null)); + dispatch(imageSelected(imageDTO)); + imageViewer.onOpen(); + }, [dispatch, imageDTO, imageViewer]); + + return ( + } onClick={onClick}> + {t('gallery.openInViewer')} + + ); +}); + +ImageMenuItemOpenInViewer.displayName = 'ImageMenuItemOpenInViewer'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSelectForCompare.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSelectForCompare.tsx new file mode 100644 index 0000000000..068930ddf9 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSelectForCompare.tsx @@ -0,0 +1,31 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiImagesBold } from 'react-icons/pi'; + +export const ImageMenuItemSelectForCompare = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const imageDTO = useImageDTOContext(); + const selectMaySelectForCompare = useMemo( + () => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name !== imageDTO.image_name), + [imageDTO.image_name] + ); + const maySelectForCompare = useAppSelector(selectMaySelectForCompare); + + const onClick = useCallback(() => { + dispatch(imageToCompareChanged(imageDTO)); + }, [dispatch, imageDTO]); + + return ( + } isDisabled={!maySelectForCompare} onClick={onClick}> + {t('gallery.selectForCompare')} + + ); +}); + +ImageMenuItemSelectForCompare.displayName = 'ImageMenuItemSelectForCompare'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToCanvas.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToCanvas.tsx new file mode 100644 index 0000000000..6960a19071 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToCanvas.tsx @@ -0,0 +1,50 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice'; +import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; +import { imageDTOToImageObject } from 'features/controlLayers/store/types'; +import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; +import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { sentImageToCanvas } from 'features/gallery/store/actions'; +import { toast } from 'features/toast/toast'; +import { setActiveTab } from 'features/ui/store/uiSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiShareFatBold } from 'react-icons/pi'; + +const selectBboxRect = createSelector(selectCanvasSlice, (canvas) => canvas.bbox.rect); + +export const ImageMenuItemSendToCanvas = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const imageDTO = useImageDTOContext(); + const bboxRect = useAppSelector(selectBboxRect); + const imageViewer = useImageViewer(); + + const handleSendToCanvas = useCallback(() => { + const imageObject = imageDTOToImageObject(imageDTO); + const overrides: Partial = { + position: { x: bboxRect.x, y: bboxRect.y }, + objects: [imageObject], + }; + dispatch(sentImageToCanvas()); + dispatch(rasterLayerAdded({ overrides, isSelected: true })); + dispatch(setActiveTab('generation')); + imageViewer.onClose(); + toast({ + id: 'SENT_TO_CANVAS', + title: t('toast.sentToCanvas'), + status: 'success', + }); + }, [bboxRect.x, bboxRect.y, dispatch, imageDTO, imageViewer, t]); + + return ( + } onClickCapture={handleSendToCanvas} id="send-to-canvas"> + {t('parameters.sendToCanvas')} + + ); +}); + +ImageMenuItemSendToCanvas.displayName = 'ImageMenuItemSendToCanvas'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToUpscale.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToUpscale.tsx new file mode 100644 index 0000000000..13c2287bf5 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToUpscale.tsx @@ -0,0 +1,33 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; +import { toast } from 'features/toast/toast'; +import { setActiveTab } from 'features/ui/store/uiSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiShareFatBold } from 'react-icons/pi'; + +export const ImageMenuItemSendToUpscale = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const imageDTO = useImageDTOContext(); + + const handleSendToCanvas = useCallback(() => { + dispatch(upscaleInitialImageChanged(imageDTO)); + dispatch(setActiveTab('upscaling')); + toast({ + id: 'SENT_TO_CANVAS', + title: t('toast.sentToUpscale'), + status: 'success', + }); + }, [dispatch, imageDTO, t]); + + return ( + } onClickCapture={handleSendToCanvas} id="send-to-upscale"> + {t('parameters.sendToUpscale')} + + ); +}); + +ImageMenuItemSendToUpscale.displayName = 'ImageMenuItemSendToUpscale'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemStarUnstar.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemStarUnstar.tsx new file mode 100644 index 0000000000..a82e8ed2a8 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemStarUnstar.tsx @@ -0,0 +1,44 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { $customStarUI } from 'app/store/nanostores/customStarUI'; +import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiStarBold, PiStarFill } from 'react-icons/pi'; +import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; + +export const ImageMenuItemStarUnstar = memo(() => { + const { t } = useTranslation(); + const imageDTO = useImageDTOContext(); + const customStarUi = useStore($customStarUI); + const [starImages] = useStarImagesMutation(); + const [unstarImages] = useUnstarImagesMutation(); + + const starImage = useCallback(() => { + if (imageDTO) { + starImages({ imageDTOs: [imageDTO] }); + } + }, [starImages, imageDTO]); + + const unstarImage = useCallback(() => { + if (imageDTO) { + unstarImages({ imageDTOs: [imageDTO] }); + } + }, [unstarImages, imageDTO]); + + if (imageDTO.starred) { + return ( + } onClickCapture={unstarImage}> + {customStarUi ? customStarUi.off.text : t('gallery.unstarImage')} + + ); + } + + return ( + } onClickCapture={starImage}> + {customStarUi ? customStarUi.on.text : t('gallery.starImage')} + + ); +}); + +ImageMenuItemStarUnstar.displayName = 'ImageMenuItemStarUnstar'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx index 3f605a93b0..a2f4172847 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx @@ -1,234 +1,45 @@ -import { Flex, MenuDivider, MenuItem, Spinner } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; -import { createSelector } from '@reduxjs/toolkit'; -import { $customStarUI } from 'app/store/nanostores/customStarUI'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { useCopyImageToClipboard } from 'common/hooks/useCopyImageToClipboard'; -import { useDownloadImage } from 'common/hooks/useDownloadImage'; -import { imagesToChangeSelected, isModalOpenChanged } from 'features/changeBoardModal/store/slice'; -import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; -import { useImageActions } from 'features/gallery/hooks/useImageActions'; -import { sentImageToCanvas, sentImageToImg2Img } from 'features/gallery/store/actions'; -import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; -import { $templates } from 'features/nodes/store/nodesSlice'; -import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; -import { toast } from 'features/toast/toast'; -import { setActiveTab } from 'features/ui/store/uiSlice'; -import { useGetAndLoadEmbeddedWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow'; -import { size } from 'lodash-es'; -import { memo, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - PiArrowsCounterClockwiseBold, - PiAsteriskBold, - PiCopyBold, - PiDownloadSimpleBold, - PiFlowArrowBold, - PiFoldersBold, - PiImagesBold, - PiPaintBrushBold, - PiPlantBold, - PiQuotesBold, - PiShareFatBold, - PiStarBold, - PiStarFill, - PiTrashSimpleBold, -} from 'react-icons/pi'; -import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; +import { MenuDivider } from '@invoke-ai/ui-library'; +import { ImageMenuItemChangeBoard } from 'features/gallery/components/ImageContextMenu/ImageMenuItemChangeBoard'; +import { ImageMenuItemCopy } from 'features/gallery/components/ImageContextMenu/ImageMenuItemCopy'; +import { ImageMenuItemDelete } from 'features/gallery/components/ImageContextMenu/ImageMenuItemDelete'; +import { ImageMenuItemDownload } from 'features/gallery/components/ImageContextMenu/ImageMenuItemDownload'; +import { ImageMenuItemLoadWorkflow } from 'features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow'; +import { ImageMenuItemMetadataRecallActions } from 'features/gallery/components/ImageContextMenu/ImageMenuItemMetadataRecallActions'; +import { ImageMenuItemOpenInNewTab } from 'features/gallery/components/ImageContextMenu/ImageMenuItemOpenInNewTab'; +import { ImageMenuItemOpenInViewer } from 'features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer'; +import { ImageMenuItemSelectForCompare } from 'features/gallery/components/ImageContextMenu/ImageMenuItemSelectForCompare'; +import { ImageMenuItemSendToCanvas } from 'features/gallery/components/ImageContextMenu/ImageMenuItemSendToCanvas'; +import { ImageMenuItemSendToUpscale } from 'features/gallery/components/ImageContextMenu/ImageMenuItemSendToUpscale'; +import { ImageMenuItemStarUnstar } from 'features/gallery/components/ImageContextMenu/ImageMenuItemStarUnstar'; +import { ImageDTOContextProvider } from 'features/gallery/contexts/ImageDTOContext'; +import { memo } from 'react'; import type { ImageDTO } from 'services/api/types'; type SingleSelectionMenuItemsProps = { imageDTO: ImageDTO; }; -const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => { - const { imageDTO } = props; - const selectMaySelectForCompare = useMemo( - () => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name !== imageDTO.image_name), - [imageDTO.image_name] - ); - const maySelectForCompare = useAppSelector(selectMaySelectForCompare); - const dispatch = useAppDispatch(); - const { t } = useTranslation(); - const customStarUi = useStore($customStarUI); - const { downloadImage } = useDownloadImage(); - const templates = useStore($templates); - - const { - recallAll, - remix, - recallSeed, - recallPrompts, - hasMetadata, - hasSeed, - hasPrompts, - isLoadingMetadata, - createAsPreset, - } = useImageActions(imageDTO?.image_name); - - const { getAndLoadEmbeddedWorkflow, getAndLoadEmbeddedWorkflowResult } = useGetAndLoadEmbeddedWorkflow({}); - - const handleLoadWorkflow = useCallback(() => { - getAndLoadEmbeddedWorkflow(imageDTO.image_name); - }, [getAndLoadEmbeddedWorkflow, imageDTO.image_name]); - - const [starImages] = useStarImagesMutation(); - const [unstarImages] = useUnstarImagesMutation(); - - const { isClipboardAPIAvailable, copyImageToClipboard } = useCopyImageToClipboard(); - - const handleDelete = useCallback(() => { - if (!imageDTO) { - return; - } - dispatch(imagesToDeleteSelected([imageDTO])); - }, [dispatch, imageDTO]); - - const handleSendToImageToImage = useCallback(() => { - // TODO(psyche): restore send to img2img functionality - dispatch(sentImageToImg2Img()); - dispatch(setActiveTab('generation')); - }, [dispatch]); - - const handleSendToCanvas = useCallback(() => { - // TODO(psyche): restore send to canvas functionality - dispatch(sentImageToCanvas()); - dispatch(setActiveTab('generation')); - toast({ - id: 'SENT_TO_CANVAS', - title: t('toast.sentToUnifiedCanvas'), - status: 'success', - }); - }, [dispatch, t]); - - const handleChangeBoard = useCallback(() => { - dispatch(imagesToChangeSelected([imageDTO])); - dispatch(isModalOpenChanged(true)); - }, [dispatch, imageDTO]); - - const handleCopyImage = useCallback(() => { - copyImageToClipboard(imageDTO.image_url); - }, [copyImageToClipboard, imageDTO.image_url]); - - const handleStarImage = useCallback(() => { - if (imageDTO) { - starImages({ imageDTOs: [imageDTO] }); - } - }, [starImages, imageDTO]); - - const handleUnstarImage = useCallback(() => { - if (imageDTO) { - unstarImages({ imageDTOs: [imageDTO] }); - } - }, [unstarImages, imageDTO]); - - const handleDownloadImage = useCallback(() => { - downloadImage(imageDTO.image_url, imageDTO.image_name); - }, [downloadImage, imageDTO.image_name, imageDTO.image_url]); - - const handleSelectImageForCompare = useCallback(() => { - dispatch(imageToCompareChanged(imageDTO)); - }, [dispatch, imageDTO]); - - const handleSendToUpscale = useCallback(() => { - dispatch(upscaleInitialImageChanged(imageDTO)); - dispatch(setActiveTab('upscaling')); - }, [dispatch, imageDTO]); - +const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) => { return ( - <> - }> - {t('common.openInNewTab')} - - {isClipboardAPIAvailable && ( - } onClickCapture={handleCopyImage}> - {t('parameters.copyImage')} - - )} - } onClickCapture={handleDownloadImage}> - {t('parameters.downloadImage')} - - } isDisabled={!maySelectForCompare} onClick={handleSelectImageForCompare}> - {t('gallery.selectForCompare')} - + + + + + + - : } - onClickCapture={handleLoadWorkflow} - isDisabled={!imageDTO.has_workflow || !size(templates)} - > - {t('nodes.loadWorkflow')} - - : } - onClickCapture={remix} - isDisabled={isLoadingMetadata || !hasMetadata} - > - {t('parameters.remixImage')} - - : } - onClickCapture={recallPrompts} - isDisabled={isLoadingMetadata || !hasPrompts} - > - {t('parameters.usePrompt')} - - : } - onClickCapture={recallSeed} - isDisabled={isLoadingMetadata || !hasSeed} - > - {t('parameters.useSeed')} - - : } - onClickCapture={recallAll} - isDisabled={isLoadingMetadata || !hasMetadata} - > - {t('parameters.useAll')} - - : } - onClickCapture={createAsPreset} - isDisabled={isLoadingMetadata || !hasPrompts} - > - {t('stylePresets.useForTemplate')} - + + - } onClickCapture={handleSendToImageToImage} id="send-to-img2img"> - {t('parameters.sendToImg2Img')} - - } onClickCapture={handleSendToUpscale} id="send-to-upscale"> - {t('parameters.sendToUpscale')} - - } onClickCapture={handleSendToCanvas} id="send-to-canvas"> - {t('parameters.sendToUnifiedCanvas')} - + + - } onClickCapture={handleChangeBoard}> - {t('boards.changeBoard')} - - {imageDTO.starred ? ( - } onClickCapture={handleUnstarImage}> - {customStarUi ? customStarUi.off.text : t('gallery.unstarImage')} - - ) : ( - } onClickCapture={handleStarImage}> - {customStarUi ? customStarUi.on.text : t('gallery.starImage')} - - )} + + - } onClickCapture={handleDelete}> - {t('gallery.deleteImage', { count: 1 })} - - + + ); }; export default memo(SingleSelectionMenuItems); - -const SpinnerIcon = () => ( - - - -); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SpinnerIcon.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SpinnerIcon.tsx new file mode 100644 index 0000000000..c2adee8958 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SpinnerIcon.tsx @@ -0,0 +1,7 @@ +import { Flex, Spinner } from '@invoke-ai/ui-library'; + +export const SpinnerIcon = () => ( + + + +); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx index 1364307f8c..cfa3a3a256 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx @@ -9,14 +9,12 @@ import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteIm import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems'; import { useImageActions } from 'features/gallery/hooks/useImageActions'; -import { sentImageToImg2Img } from 'features/gallery/store/actions'; import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; import { parseAndRecallImageDimensions } from 'features/metadata/util/handlers'; import { $templates } from 'features/nodes/store/nodesSlice'; import { PostProcessingPopover } from 'features/parameters/components/PostProcessing/PostProcessingPopover'; import { useIsQueueMutationInProgress } from 'features/queue/hooks/useIsQueueMutationInProgress'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; -import { setActiveTab } from 'features/ui/store/uiSlice'; import { useGetAndLoadEmbeddedWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow'; import { size } from 'lodash-es'; import { memo, useCallback, useMemo } from 'react'; @@ -65,14 +63,6 @@ const CurrentImageButtons = () => { const handleUseSize = useCallback(() => { parseAndRecallImageDimensions(lastSelectedImage); }, [lastSelectedImage]); - const handleSendToImageToImage = useCallback(() => { - if (!imageDTO) { - return; - } - // TODO(psyche): restore send to img2img functionality - dispatch(sentImageToImg2Img()); - dispatch(setActiveTab('generation')); - }, [dispatch, imageDTO]); const handleClickUpscale = useCallback(() => { if (!imageDTO) { return; @@ -93,9 +83,8 @@ const CurrentImageButtons = () => { useHotkeys('p', recallPrompts, { enabled: isImageViewerActive }, [recallPrompts, isImageViewerActive]); useHotkeys('r', remix, { enabled: isImageViewerActive }, [remix, isImageViewerActive]); useHotkeys('d', handleUseSize, { enabled: isImageViewerActive }, [handleUseSize, isImageViewerActive]); - useHotkeys('shift+i', handleSendToImageToImage, { enabled: isImageViewerActive }, [imageDTO, isImageViewerActive]); useHotkeys( - 'Shift+U', + 'shift+u', handleClickUpscale, { enabled: Boolean(isUpscalingEnabled && isImageViewerActive && isConnected) }, [isUpscalingEnabled, imageDTO, shouldDisableToolbarButtons, isConnected, isImageViewerActive] diff --git a/invokeai/frontend/web/src/features/gallery/contexts/ImageDTOContext.ts b/invokeai/frontend/web/src/features/gallery/contexts/ImageDTOContext.ts new file mode 100644 index 0000000000..dcb01f5ba6 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/contexts/ImageDTOContext.ts @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; +import type { ImageDTO } from 'services/api/types'; +import { assert } from 'tsafe'; + +const ImageDTOContext = createContext(null); + +export const ImageDTOContextProvider = ImageDTOContext.Provider; + +export const useImageDTOContext = () => { + const imageDTO = useContext(ImageDTOContext); + assert(imageDTO !== null, 'useImageDTOContext must be used within ImageDTOContextProvider'); + return imageDTO; +}; diff --git a/invokeai/frontend/web/src/features/gallery/store/actions.ts b/invokeai/frontend/web/src/features/gallery/store/actions.ts index 0b42890a26..13f20f89f3 100644 --- a/invokeai/frontend/web/src/features/gallery/store/actions.ts +++ b/invokeai/frontend/web/src/features/gallery/store/actions.ts @@ -2,6 +2,4 @@ import { createAction } from '@reduxjs/toolkit'; export const sentImageToCanvas = createAction('gallery/sentImageToCanvas'); -export const sentImageToImg2Img = createAction('gallery/sentImageToImg2Img'); - export const imageDownloaded = createAction('gallery/imageDownloaded'); diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index b67ecd0eb7..73e3692c1c 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -55,7 +55,7 @@ import { } from 'features/nodes/types/field'; import type { AnyNode, InvocationNodeEdge } from 'features/nodes/types/invocation'; import { isInvocationNode, isNotesNode } from 'features/nodes/types/invocation'; -import { atom } from 'nanostores'; +import { atom, computed } from 'nanostores'; import type { MouseEvent } from 'react'; import type { Edge, EdgeChange, NodeChange, Viewport, XYPosition } from 'reactflow'; import { applyEdgeChanges, applyNodeChanges, getConnectedEdges, getIncomers, getOutgoers } from 'reactflow'; @@ -435,6 +435,7 @@ export const { export const $cursorPos = atom(null); export const $templates = atom({}); +export const $hasTemplates = computed($templates, (templates) => Object.keys(templates).length > 0); export const $copiedNodes = atom([]); export const $copiedEdges = atom([]); export const $edgesToCopiedNodes = atom([]); diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow.ts index 12c302f9c9..27fe3eb399 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow.ts @@ -16,10 +16,10 @@ type UseGetAndLoadEmbeddedWorkflowReturn = { }; type UseGetAndLoadEmbeddedWorkflow = ( - options: UseGetAndLoadEmbeddedWorkflowOptions + options?: UseGetAndLoadEmbeddedWorkflowOptions ) => UseGetAndLoadEmbeddedWorkflowReturn; -export const useGetAndLoadEmbeddedWorkflow: UseGetAndLoadEmbeddedWorkflow = ({ onSuccess, onError }) => { +export const useGetAndLoadEmbeddedWorkflow: UseGetAndLoadEmbeddedWorkflow = (options) => { const dispatch = useAppDispatch(); const { t } = useTranslation(); const [_getAndLoadEmbeddedWorkflow, getAndLoadEmbeddedWorkflowResult] = useLazyGetImageWorkflowQuery(); @@ -30,7 +30,7 @@ export const useGetAndLoadEmbeddedWorkflow: UseGetAndLoadEmbeddedWorkflow = ({ o if (data) { dispatch(workflowLoadRequested({ data, asCopy: true })); // No toast - the listener for this action does that after the workflow is loaded - onSuccess && onSuccess(); + options?.onSuccess && options?.onSuccess(); } else { toast({ id: 'PROBLEM_RETRIEVING_WORKFLOW', @@ -44,10 +44,10 @@ export const useGetAndLoadEmbeddedWorkflow: UseGetAndLoadEmbeddedWorkflow = ({ o title: t('toast.problemRetrievingWorkflow'), status: 'error', }); - onError && onError(); + options?.onError && options?.onError(); } }, - [_getAndLoadEmbeddedWorkflow, dispatch, onSuccess, t, onError] + [_getAndLoadEmbeddedWorkflow, dispatch, options, t] ); return { getAndLoadEmbeddedWorkflow, getAndLoadEmbeddedWorkflowResult };