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 };