diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index f0ac55905e1..bc0f7861a39 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1081,6 +1081,8 @@
"importFailed": "Import Failed",
"importSuccessful": "Import Successful",
"invalidUpload": "Invalid Upload",
+ "layerCopiedToClipboard": "Layer Copied to Clipboard",
+ "layerSavedToAssets": "Layer Saved to Assets",
"loadedWithWarnings": "Workflow Loaded with Warnings",
"maskSavedAssets": "Mask Saved to Assets",
"maskSentControlnetAssets": "Mask Sent to ControlNet & Assets",
@@ -1101,6 +1103,8 @@
"problemCopyingCanvas": "Problem Copying Canvas",
"problemCopyingCanvasDesc": "Unable to export base layer",
"problemCopyingImage": "Unable to Copy Image",
+ "problemCopyingLayer": "Unable to Copy Layer",
+ "problemSavingLayer": "Unable to Save Layer",
"problemDownloadingImage": "Unable to Download Image",
"problemDownloadingCanvas": "Problem Downloading Canvas",
"problemDownloadingCanvasDesc": "Unable to export base layer",
@@ -1592,6 +1596,7 @@
"removeBookmark": "Remove Bookmark",
"saveCanvasToGallery": "Save Canvas to Gallery",
"saveBboxToGallery": "Save Bbox to Gallery",
+ "saveLayerToAssets": "Save Layer to Assets",
"newControlLayerFromBbox": "New Control Layer from Bbox",
"newRasterLayerFromBbox": "New Raster Layer from Bbox",
"savedToGalleryOk": "Saved to Gallery",
@@ -1669,6 +1674,7 @@
"sendToGallery": "Send To Gallery",
"sendToGalleryDesc": "Pressing Invoke generates and saves a unique image to your gallery.",
"sendToCanvas": "Send To Canvas",
+ "copyToClipboard": "Copy to Clipboard",
"sendToCanvasDesc": "Pressing Invoke stages your work in progress on the canvas.",
"viewProgressInViewer": "View progress and outputs in the Image Viewer.",
"viewProgressOnCanvas": "View progress and stage outputs on the Canvas.",
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems.tsx
index 19c673fd691..cd244f5da66 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems.tsx
@@ -1,7 +1,9 @@
import { MenuGroup } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
+import { CanvasEntityMenuItemsCopyToClipboard } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCopyToClipboard';
import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete';
import { CanvasEntityMenuItemsFilter } from 'features/controlLayers/components/common/CanvasEntityMenuItemsFilter';
+import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSave';
import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform';
import {
EntityIdentifierContext,
@@ -9,7 +11,11 @@ import {
} from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useEntityTitle } from 'features/controlLayers/hooks/useEntityTitle';
import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
-import { isFilterableEntityIdentifier, isTransformableEntityIdentifier } from 'features/controlLayers/store/types';
+import {
+ isFilterableEntityIdentifier,
+ isSaveableEntityIdentifier,
+ isTransformableEntityIdentifier,
+} from 'features/controlLayers/store/types';
import { memo } from 'react';
const CanvasContextMenuSelectedEntityMenuItemsContent = memo(() => {
@@ -20,6 +26,8 @@ const CanvasContextMenuSelectedEntityMenuItemsContent = memo(() => {
{isFilterableEntityIdentifier(entityIdentifier) && }
{isTransformableEntityIdentifier(entityIdentifier) && }
+ {isSaveableEntityIdentifier(entityIdentifier) && }
+ {isSaveableEntityIdentifier(entityIdentifier) && }
);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx
index a59270c6bed..d5d2c3042d3 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx
@@ -7,6 +7,8 @@ import { EntityListSelectedEntityActionBarOpacity } from 'features/controlLayers
import { EntityListSelectedEntityActionBarTransformButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarTransformButton';
import { memo } from 'react';
+import { EntityListSelectedEntityActionBarSaveToAssetsButton } from './EntityListSelectedEntityActionBarSaveToAssetsButton';
+
export const EntityListSelectedEntityActionBar = memo(() => {
return (
@@ -16,6 +18,7 @@ export const EntityListSelectedEntityActionBar = memo(() => {
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarSaveToAssetsButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarSaveToAssetsButton.tsx
new file mode 100644
index 00000000000..eb4319f04b9
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarSaveToAssetsButton.tsx
@@ -0,0 +1,44 @@
+import { IconButton } from '@invoke-ai/ui-library';
+import { useAppSelector } from 'app/store/storeHooks';
+import { useEntityAdapterSafe } from 'features/controlLayers/contexts/EntityAdapterContext';
+import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
+import { useSaveLayerToAssets } from 'features/controlLayers/hooks/useSaveLayerToAssets';
+import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
+import { isSaveableEntityIdentifier } from 'features/controlLayers/store/types';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiFloppyDiskBold } from 'react-icons/pi';
+
+export const EntityListSelectedEntityActionBarSaveToAssetsButton = memo(() => {
+ const { t } = useTranslation();
+ const isBusy = useCanvasIsBusy();
+ const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
+ const adapter = useEntityAdapterSafe(selectedEntityIdentifier);
+ const saveLayerToAssets = useSaveLayerToAssets();
+ const onClick = useCallback(() => {
+ saveLayerToAssets(adapter);
+ }, [saveLayerToAssets, adapter]);
+
+ if (!selectedEntityIdentifier) {
+ return null;
+ }
+
+ if (!isSaveableEntityIdentifier(selectedEntityIdentifier)) {
+ return null;
+ }
+
+ return (
+ }
+ />
+ );
+});
+
+EntityListSelectedEntityActionBarSaveToAssetsButton.displayName = 'EntityListSelectedEntityActionBarSaveToAssetsButton';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerMenuItems.tsx
index c82cf23a3a9..d3ac4717bfa 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerMenuItems.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerMenuItems.tsx
@@ -1,8 +1,10 @@
import { MenuDivider } from '@invoke-ai/ui-library';
import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/common/CanvasEntityMenuItemsArrange';
+import { CanvasEntityMenuItemsCopyToClipboard } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCopyToClipboard';
import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete';
import { CanvasEntityMenuItemsDuplicate } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDuplicate';
import { CanvasEntityMenuItemsFilter } from 'features/controlLayers/components/common/CanvasEntityMenuItemsFilter';
+import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSave';
import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform';
import { ControlLayerMenuItemsConvertControlToRaster } from 'features/controlLayers/components/ControlLayer/ControlLayerMenuItemsConvertControlToRaster';
import { ControlLayerMenuItemsTransparencyEffect } from 'features/controlLayers/components/ControlLayer/ControlLayerMenuItemsTransparencyEffect';
@@ -19,6 +21,8 @@ export const ControlLayerMenuItems = memo(() => {
+
+
>
);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx
index 2f387927ff3..db4965474f7 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx
@@ -1,8 +1,10 @@
import { MenuDivider } from '@invoke-ai/ui-library';
import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/common/CanvasEntityMenuItemsArrange';
+import { CanvasEntityMenuItemsCopyToClipboard } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCopyToClipboard';
import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete';
import { CanvasEntityMenuItemsDuplicate } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDuplicate';
import { CanvasEntityMenuItemsFilter } from 'features/controlLayers/components/common/CanvasEntityMenuItemsFilter';
+import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSave';
import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform';
import { RasterLayerMenuItemsConvertRasterToControl } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsConvertRasterToControl';
import { memo } from 'react';
@@ -17,6 +19,8 @@ export const RasterLayerMenuItems = memo(() => {
+
+
>
);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsCopyToClipboard.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsCopyToClipboard.tsx
new file mode 100644
index 00000000000..9d9882399bc
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsCopyToClipboard.tsx
@@ -0,0 +1,28 @@
+import { MenuItem } from '@invoke-ai/ui-library';
+import { useEntityAdapterSafe } from 'features/controlLayers/contexts/EntityAdapterContext';
+import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
+import { useCopyLayerToClipboard } from 'features/controlLayers/hooks/useCopyLayerToClipboard';
+import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiCopyBold } from 'react-icons/pi';
+
+export const CanvasEntityMenuItemsCopyToClipboard = memo(() => {
+ const { t } = useTranslation();
+ const entityIdentifier = useEntityIdentifierContext();
+ const adapter = useEntityAdapterSafe(entityIdentifier);
+ const isInteractable = useIsEntityInteractable(entityIdentifier);
+ const copyLayerToClipboard = useCopyLayerToClipboard();
+
+ const onClick = useCallback(() => {
+ copyLayerToClipboard(adapter);
+ }, [copyLayerToClipboard, adapter]);
+
+ return (
+ } isDisabled={!isInteractable}>
+ {t('controlLayers.copyToClipboard')}
+
+ );
+});
+
+CanvasEntityMenuItemsCopyToClipboard.displayName = 'CanvasEntityMenuItemsCopyToClipboard';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsSave.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsSave.tsx
new file mode 100644
index 00000000000..9aa3134de86
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsSave.tsx
@@ -0,0 +1,27 @@
+import { MenuItem } from '@invoke-ai/ui-library';
+import { useEntityAdapterSafe } from 'features/controlLayers/contexts/EntityAdapterContext';
+import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
+import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
+import { useSaveLayerToAssets } from 'features/controlLayers/hooks/useSaveLayerToAssets';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiFloppyDiskBold } from 'react-icons/pi';
+
+export const CanvasEntityMenuItemsSave = memo(() => {
+ const { t } = useTranslation();
+ const entityIdentifier = useEntityIdentifierContext();
+ const adapter = useEntityAdapterSafe(entityIdentifier);
+ const isInteractable = useIsEntityInteractable(entityIdentifier);
+ const saveLayerToAssets = useSaveLayerToAssets();
+ const onClick = useCallback(() => {
+ saveLayerToAssets(adapter);
+ }, [saveLayerToAssets, adapter]);
+
+ return (
+ } isDisabled={!isInteractable}>
+ {t('controlLayers.saveLayerToAssets')}
+
+ );
+});
+
+CanvasEntityMenuItemsSave.displayName = 'CanvasEntityMenuItemsSave';
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCopyLayerToClipboard.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCopyLayerToClipboard.ts
new file mode 100644
index 00000000000..9221c3e2cb1
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCopyLayerToClipboard.ts
@@ -0,0 +1,44 @@
+import type { CanvasEntityAdapterControlLayer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterControlLayer';
+import type { CanvasEntityAdapterInpaintMask } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterInpaintMask';
+import type { CanvasEntityAdapterRasterLayer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer';
+import type { CanvasEntityAdapterRegionalGuidance } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRegionalGuidance';
+import { canvasToBlob } from 'features/controlLayers/konva/util';
+import { copyBlobToClipboard } from 'features/system/util/copyBlobToClipboard';
+import { toast } from 'features/toast/toast';
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+
+export const useCopyLayerToClipboard = () => {
+ const { t } = useTranslation();
+ const copyLayerToCipboard = useCallback(
+ async (
+ adapter:
+ | CanvasEntityAdapterRasterLayer
+ | CanvasEntityAdapterControlLayer
+ | CanvasEntityAdapterInpaintMask
+ | CanvasEntityAdapterRegionalGuidance
+ | null
+ ) => {
+ if (!adapter) {
+ return;
+ }
+ try {
+ const canvas = adapter.getCanvas();
+ const blob = await canvasToBlob(canvas);
+ copyBlobToClipboard(blob);
+ toast({
+ status: 'info',
+ title: t('toast.layerCopiedToClipboard'),
+ });
+ } catch (error) {
+ toast({
+ status: 'error',
+ title: t('toast.problemCopyingLayer'),
+ });
+ }
+ },
+ [t]
+ );
+
+ return copyLayerToCipboard;
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useSaveLayerToAssets.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useSaveLayerToAssets.ts
new file mode 100644
index 00000000000..d4e64aa48df
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useSaveLayerToAssets.ts
@@ -0,0 +1,57 @@
+import { useAppSelector } from 'app/store/storeHooks';
+import type { CanvasEntityAdapterControlLayer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterControlLayer';
+import type { CanvasEntityAdapterInpaintMask } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterInpaintMask';
+import type { CanvasEntityAdapterRasterLayer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer';
+import type { CanvasEntityAdapterRegionalGuidance } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRegionalGuidance';
+import { canvasToBlob } from 'features/controlLayers/konva/util';
+import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
+import { toast } from 'features/toast/toast';
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useUploadImageMutation } from 'services/api/endpoints/images';
+
+export const useSaveLayerToAssets = () => {
+ const { t } = useTranslation();
+ const [uploadImage] = useUploadImageMutation();
+ const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
+
+ const saveLayerToAssets = useCallback(
+ async (
+ adapter:
+ | CanvasEntityAdapterRasterLayer
+ | CanvasEntityAdapterControlLayer
+ | CanvasEntityAdapterInpaintMask
+ | CanvasEntityAdapterRegionalGuidance
+ | null
+ ) => {
+ if (!adapter) {
+ return;
+ }
+ try {
+ const canvas = adapter.getCanvas();
+ const blob = await canvasToBlob(canvas);
+ const file = new File([blob], `layer-${adapter.id}.png`, { type: 'image/png' });
+ await uploadImage({
+ file,
+ image_category: 'user',
+ is_intermediate: false,
+ postUploadAction: { type: 'TOAST' },
+ board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
+ });
+
+ toast({
+ status: 'info',
+ title: t('toast.layerSavedToAssets'),
+ });
+ } catch (error) {
+ toast({
+ status: 'error',
+ title: t('toast.problemSavingLayer'),
+ });
+ }
+ },
+ [t, autoAddBoardId, uploadImage]
+ );
+
+ return saveLayerToAssets;
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
index a6990a910a5..bdddaeecbb8 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -384,6 +384,12 @@ export function isTransformableEntityIdentifier(
);
}
+export function isSaveableEntityIdentifier(
+ entityIdentifier: CanvasEntityIdentifier
+): entityIdentifier is CanvasEntityIdentifier<'raster_layer'> | CanvasEntityIdentifier<'control_layer'> {
+ return isRasterLayerEntityIdentifier(entityIdentifier) || isControlLayerEntityIdentifier(entityIdentifier);
+}
+
export function isRenderableEntity(entity: CanvasEntityState): entity is CanvasRenderableEntityState {
return isRenderableEntityType(entity.type);
}