diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index f0ac55905e..bc0f7861a3 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 19c673fd69..cd244f5da6 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 a59270c6be..d5d2c3042d 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 0000000000..eb4319f04b --- /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 c82cf23a3a..d3ac4717bf 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 2f387927ff..db4965474f 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 0000000000..9d9882399b --- /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 0000000000..9aa3134de8 --- /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 0000000000..9221c3e2cb --- /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 0000000000..d4e64aa48d --- /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 a6990a910a..bdddaeecbb 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); }