diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index a8f52742d4..d573175fe8 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -572,6 +572,7 @@ "uploadFailedInvalidUploadDesc": "Must be single PNG or JPEG image", "downloadImageStarted": "Image Download Started", "imageCopied": "Image Copied", + "problemCopyingImage": "Unable to Copy Image", "imageLinkCopied": "Image Link Copied", "problemCopyingImageLink": "Unable to Copy Image Link", "imageNotLoaded": "No Image Loaded", diff --git a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx index 991398f5a0..c024622d2e 100644 --- a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx +++ b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx @@ -21,6 +21,7 @@ import { ImageDTO } from 'services/api/types'; import { mode } from 'theme/util/mode'; import IAIDraggable from './IAIDraggable'; import IAIDroppable from './IAIDroppable'; +import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; type IAIDndImageProps = { imageDTO: ImageDTO | undefined; @@ -96,119 +97,124 @@ const IAIDndImage = (props: IAIDndImageProps) => { }; return ( - - {imageDTO && ( + + {(ref) => ( - } - width={imageDTO.width} - height={imageDTO.height} - onError={onError} - draggable={false} - sx={{ - objectFit: 'contain', - maxW: 'full', - maxH: 'full', - borderRadius: 'base', - shadow: isSelected ? 'selected.light' : undefined, - _dark: { shadow: isSelected ? 'selected.dark' : undefined }, - ...imageSx, - }} - /> - {withMetadataOverlay && } - - )} - {!imageDTO && !isUploadDisabled && ( - <> - - - + } + width={imageDTO.width} + height={imageDTO.height} + onError={onError} + draggable={false} + sx={{ + objectFit: 'contain', + maxW: 'full', + maxH: 'full', + borderRadius: 'base', + shadow: isSelected ? 'selected.light' : undefined, + _dark: { shadow: isSelected ? 'selected.dark' : undefined }, + ...imageSx, + }} + /> + {withMetadataOverlay && } + + )} + {!imageDTO && !isUploadDisabled && ( + <> + + + + + + )} + {!imageDTO && isUploadDisabled && noContentFallback} + {!isDropDisabled && ( + + )} + {imageDTO && !isDragDisabled && ( + + )} + {onClickReset && withResetIcon && imageDTO && ( + - - + )} + )} - {!imageDTO && isUploadDisabled && noContentFallback} - {!isDropDisabled && ( - - )} - {imageDTO && !isDragDisabled && ( - - )} - {onClickReset && withResetIcon && imageDTO && ( - - )} - + ); }; diff --git a/invokeai/frontend/web/src/features/canvas/components/IAICanvasToolbar/IAICanvasToolbar.tsx b/invokeai/frontend/web/src/features/canvas/components/IAICanvasToolbar/IAICanvasToolbar.tsx index 04d3e81379..e90c771c63 100644 --- a/invokeai/frontend/web/src/features/canvas/components/IAICanvasToolbar/IAICanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/canvas/components/IAICanvasToolbar/IAICanvasToolbar.tsx @@ -48,6 +48,7 @@ import IAICanvasRedoButton from './IAICanvasRedoButton'; import IAICanvasSettingsButtonPopover from './IAICanvasSettingsButtonPopover'; import IAICanvasToolChooserOptions from './IAICanvasToolChooserOptions'; import IAICanvasUndoButton from './IAICanvasUndoButton'; +import { useCopyImageToClipboard } from 'features/ui/hooks/useCopyImageToClipboard'; export const selector = createSelector( [systemSelector, canvasSelector, isStagingSelector], @@ -79,6 +80,7 @@ const IAICanvasToolbar = () => { const canvasBaseLayer = getCanvasBaseLayer(); const { t } = useTranslation(); + const { isClipboardAPIAvailable } = useCopyImageToClipboard(); const { openUploader } = useImageUploader(); @@ -136,10 +138,10 @@ const IAICanvasToolbar = () => { handleCopyImageToClipboard(); }, { - enabled: () => !isStaging, + enabled: () => !isStaging && isClipboardAPIAvailable, preventDefault: true, }, - [canvasBaseLayer, isProcessing] + [canvasBaseLayer, isProcessing, isClipboardAPIAvailable] ); useHotkeys( @@ -189,6 +191,9 @@ const IAICanvasToolbar = () => { }; const handleCopyImageToClipboard = () => { + if (!isClipboardAPIAvailable) { + return; + } dispatch(canvasCopiedToClipboard()); }; @@ -256,13 +261,15 @@ const IAICanvasToolbar = () => { onClick={handleSaveToGallery} isDisabled={isStaging} /> - } - onClick={handleCopyImageToClipboard} - isDisabled={isStaging} - /> + {isClipboardAPIAvailable && ( + } + onClick={handleCopyImageToClipboard} + isDisabled={isStaging} + /> + )} { const toaster = useAppToaster(); const { t } = useTranslation(); + const { isClipboardAPIAvailable, copyImageToClipboard } = + useCopyImageToClipboard(); + const { recallBothPrompts, recallSeed, recallAllParameters } = useRecallParameters(); @@ -128,7 +132,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { 500 ); - const { currentData: image, isFetching } = useGetImageDTOQuery( + const { currentData: imageDTO, isFetching } = useGetImageDTOQuery( lastSelectedImage ?? skipToken ); @@ -142,15 +146,15 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { const handleCopyImageLink = useCallback(() => { const getImageUrl = () => { - if (!image) { + if (!imageDTO) { return; } - if (image.image_url.startsWith('http')) { - return image.image_url; + if (imageDTO.image_url.startsWith('http')) { + return imageDTO.image_url; } - return window.location.toString() + image.image_url; + return window.location.toString() + imageDTO.image_url; }; const url = getImageUrl(); @@ -174,7 +178,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { isClosable: true, }); }); - }, [toaster, t, image]); + }, [toaster, t, imageDTO]); const handleClickUseAllParameters = useCallback(() => { recallAllParameters(metadata); @@ -192,31 +196,31 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { recallSeed(metadata?.seed); }, [metadata?.seed, recallSeed]); - useHotkeys('s', handleUseSeed, [image]); + useHotkeys('s', handleUseSeed, [imageDTO]); const handleUsePrompt = useCallback(() => { recallBothPrompts(metadata?.positive_prompt, metadata?.negative_prompt); }, [metadata?.negative_prompt, metadata?.positive_prompt, recallBothPrompts]); - useHotkeys('p', handleUsePrompt, [image]); + useHotkeys('p', handleUsePrompt, [imageDTO]); const handleSendToImageToImage = useCallback(() => { dispatch(sentImageToImg2Img()); - dispatch(initialImageSelected(image)); - }, [dispatch, image]); + dispatch(initialImageSelected(imageDTO)); + }, [dispatch, imageDTO]); - useHotkeys('shift+i', handleSendToImageToImage, [image]); + useHotkeys('shift+i', handleSendToImageToImage, [imageDTO]); const handleClickUpscale = useCallback(() => { // selectedImage && dispatch(runESRGAN(selectedImage)); }, []); const handleDelete = useCallback(() => { - if (!image) { + if (!imageDTO) { return; } - dispatch(imageToDeleteSelected(image)); - }, [dispatch, image]); + dispatch(imageToDeleteSelected(imageDTO)); + }, [dispatch, imageDTO]); useHotkeys( 'Shift+U', @@ -236,7 +240,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { }, [ isUpscalingEnabled, - image, + imageDTO, isESRGANAvailable, shouldDisableToolbarButtons, isConnected, @@ -268,7 +272,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { [ isFaceRestoreEnabled, - image, + imageDTO, isGFPGANAvailable, shouldDisableToolbarButtons, isConnected, @@ -283,10 +287,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { ); const handleSendToCanvas = useCallback(() => { - if (!image) return; + if (!imageDTO) return; dispatch(sentImageToCanvas()); - dispatch(setInitialCanvasImage(image)); + dispatch(setInitialCanvasImage(imageDTO)); dispatch(requestCanvasRescale()); if (activeTabName !== 'unifiedCanvas') { @@ -299,12 +303,12 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { duration: 2500, isClosable: true, }); - }, [image, dispatch, activeTabName, toaster, t]); + }, [imageDTO, dispatch, activeTabName, toaster, t]); useHotkeys( 'i', () => { - if (image) { + if (imageDTO) { handleClickShowImageDetails(); } else { toaster({ @@ -315,13 +319,20 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { }); } }, - [image, shouldShowImageDetails, toaster] + [imageDTO, shouldShowImageDetails, toaster] ); const handleClickProgressImagesToggle = useCallback(() => { dispatch(setShouldShowProgressInViewer(!shouldShowProgressInViewer)); }, [dispatch, shouldShowProgressInViewer]); + const handleCopyImage = useCallback(() => { + if (!imageDTO) { + return; + } + copyImageToClipboard(imageDTO.image_url); + }, [copyImageToClipboard, imageDTO]); + return ( <> { } /> } @@ -369,13 +380,15 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { )} - {/* } - > - {t('parameters.copyImage')} - */} + {isClipboardAPIAvailable && ( + } + > + {t('parameters.copyImage')} + + )} { {t('parameters.copyImageToLink')} - + } size="sm" w="100%"> {t('parameters.downloadImage')} @@ -443,7 +456,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { { ['children']; }; @@ -64,20 +64,25 @@ const ImageContextMenu = ({ imageDTO, children }: Props) => { return ( menuProps={{ size: 'sm', isLazy: true }} - menuButtonProps={{ bg: 'transparent', _hover: { bg: 'transparent' } }} - renderMenu={() => ( - - {selectionCount === 1 ? ( - - ) : ( - - )} - - )} + menuButtonProps={{ + bg: 'transparent', + _hover: { bg: 'transparent' }, + }} + renderMenu={() => + imageDTO ? ( + + {selectionCount === 1 ? ( + + ) : ( + + )} + + ) : null + } > {children} 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 fda984e2c3..3dea6fea34 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx @@ -14,10 +14,11 @@ import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletio import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; import { initialImageSelected } from 'features/parameters/store/actions'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; +import { useCopyImageToClipboard } from 'features/ui/hooks/useCopyImageToClipboard'; import { setActiveTab } from 'features/ui/store/uiSlice'; import { memo, useCallback, useContext, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { FaFolder, FaShare, FaTrash } from 'react-icons/fa'; +import { FaCopy, FaFolder, FaShare, FaTrash } from 'react-icons/fa'; import { IoArrowUndoCircleOutline } from 'react-icons/io5'; import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages'; import { useGetImageMetadataQuery } from 'services/api/endpoints/images'; @@ -61,6 +62,9 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => { const { currentData } = useGetImageMetadataQuery(imageDTO.image_name); + const { isClipboardAPIAvailable, copyImageToClipboard } = + useCopyImageToClipboard(); + const metadata = currentData?.metadata; const handleDelete = useCallback(() => { @@ -130,11 +134,20 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => { dispatch(imagesAddedToBatch([imageDTO.image_name])); }, [dispatch, imageDTO.image_name]); + const handleCopyImage = useCallback(() => { + copyImageToClipboard(imageDTO.image_url); + }, [copyImageToClipboard, imageDTO.image_url]); + return ( <> } onClickCapture={handleOpenInNewTab}> {t('common.openInNewTab')} + {isClipboardAPIAvailable && ( + } onClickCapture={handleCopyImage}> + {t('parameters.copyImage')} + + )} } onClickCapture={handleRecallPrompt} diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvas/UnifiedCanvasBeta/UnifiedCanvasToolbar/UnifiedCanvasCopyToClipboard.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvas/UnifiedCanvasBeta/UnifiedCanvasToolbar/UnifiedCanvasCopyToClipboard.tsx index 5e23a4c0d6..a68794a930 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvas/UnifiedCanvasBeta/UnifiedCanvasToolbar/UnifiedCanvasCopyToClipboard.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/UnifiedCanvas/UnifiedCanvasBeta/UnifiedCanvasToolbar/UnifiedCanvasCopyToClipboard.tsx @@ -4,6 +4,8 @@ import IAIIconButton from 'common/components/IAIIconButton'; import { canvasCopiedToClipboard } from 'features/canvas/store/actions'; import { isStagingSelector } from 'features/canvas/store/canvasSelectors'; import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider'; +import { useCopyImageToClipboard } from 'features/ui/hooks/useCopyImageToClipboard'; +import { useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { FaCopy } from 'react-icons/fa'; @@ -11,6 +13,7 @@ import { FaCopy } from 'react-icons/fa'; export default function UnifiedCanvasCopyToClipboard() { const isStaging = useAppSelector(isStagingSelector); const canvasBaseLayer = getCanvasBaseLayer(); + const { isClipboardAPIAvailable } = useCopyImageToClipboard(); const isProcessing = useAppSelector( (state: RootState) => state.system.isProcessing @@ -25,15 +28,22 @@ export default function UnifiedCanvasCopyToClipboard() { handleCopyImageToClipboard(); }, { - enabled: () => !isStaging, + enabled: () => !isStaging && isClipboardAPIAvailable, preventDefault: true, }, - [canvasBaseLayer, isProcessing] + [canvasBaseLayer, isProcessing, isClipboardAPIAvailable] ); - const handleCopyImageToClipboard = () => { + const handleCopyImageToClipboard = useCallback(() => { + if (!isClipboardAPIAvailable) { + return; + } dispatch(canvasCopiedToClipboard()); - }; + }, [dispatch, isClipboardAPIAvailable]); + + if (!isClipboardAPIAvailable) { + return null; + } return ( { + const toaster = useAppToaster(); + const { t } = useTranslation(); + + const isClipboardAPIAvailable = useMemo(() => { + return Boolean(navigator.clipboard) && Boolean(window.ClipboardItem); + }, []); + + const copyImageToClipboard = useCallback( + async (image_url: string) => { + if (!isClipboardAPIAvailable) { + toaster({ + title: t('toast.problemCopyingImage'), + description: "Your browser doesn't support the Clipboard API.", + status: 'error', + duration: 2500, + isClosable: true, + }); + } + try { + const response = await fetch(image_url); + const blob = await response.blob(); + await navigator.clipboard.write([ + new ClipboardItem({ + [blob.type]: blob, + }), + ]); + toaster({ + title: t('toast.imageCopied'), + status: 'success', + duration: 2500, + isClosable: true, + }); + } catch (err) { + toaster({ + title: t('toast.problemCopyingImage'), + description: String(err), + status: 'error', + duration: 2500, + isClosable: true, + }); + } + }, + [isClipboardAPIAvailable, t, toaster] + ); + + return { isClipboardAPIAvailable, copyImageToClipboard }; +};