Skip to content

feat(ui): support pasting directly to canvas #7619

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,6 @@
"gallery": {
"gallery": "Gallery",
"alwaysShowImageSizeBadge": "Always Show Image Size Badge",
"assets": "Assets",
"assetsTab": "Files you’ve uploaded for use in your projects.",
"autoAssignBoardOnClick": "Auto-Assign Board on Click",
"autoSwitchNewImages": "Auto-Switch to New Images",
Expand Down Expand Up @@ -1248,6 +1247,8 @@
"problemCopyingLayer": "Unable to Copy Layer",
"problemSavingLayer": "Unable to Save Layer",
"problemDownloadingImage": "Unable to Download Image",
"pasteSuccess": "Pasted to {{destination}}",
"pasteFailed": "Paste Failed",
"prunedQueue": "Pruned Queue",
"sentToCanvas": "Sent to Canvas",
"sentToUpscale": "Sent to Upscale",
Expand Down Expand Up @@ -1816,6 +1817,14 @@
"newControlLayer": "New $t(controlLayers.controlLayer)",
"newInpaintMask": "New $t(controlLayers.inpaintMask)",
"newRegionalGuidance": "New $t(controlLayers.regionalGuidance)",
"pasteTo": "Paste To",
"pasteToAssets": "Assets",
"pasteToAssetsDesc": "Paste to Assets",
"pasteToBbox": "Bbox",
"pasteToBboxDesc": "New Layer (in Bbox)",
"pasteToCanvas": "Canvas",
"pasteToCanvasDesc": "New Layer (in Canvas)",
"pastedTo": "Pasted to {{destination}}",
"transparency": "Transparency",
"enableTransparencyEffect": "Enable Transparency Effect",
"disableTransparencyEffect": "Disable Transparency Effect",
Expand Down
5 changes: 5 additions & 0 deletions invokeai/frontend/web/src/app/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import { useFocusRegionWatcher } from 'common/hooks/focus';
import { useClearStorage } from 'common/hooks/useClearStorage';
import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal';
import { CanvasPasteModal } from 'features/controlLayers/components/CanvasPasteModal';
import {
NewCanvasSessionDialog,
NewGallerySessionDialog,
} from 'features/controlLayers/components/NewSessionConfirmationAlertDialog';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal';
import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone';
import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal';
Expand Down Expand Up @@ -112,6 +114,9 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
<ImageContextMenu />
<FullscreenDropzone />
<VideosModal />
<CanvasManagerProviderGate>
<CanvasPasteModal />
</CanvasManagerProviderGate>
</ErrorBoundary>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { Menu, MenuButton, MenuGroup, MenuItem, MenuList } from '@invoke-ai/ui-l
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { CanvasContextMenuItemsCropCanvasToBbox } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuItemsCropCanvasToBbox';
import { NewLayerIcon } from 'features/controlLayers/components/common/icons';
import { useCopyCanvasToClipboard } from 'features/controlLayers/hooks/copyHooks';
import {
useCopyCanvasToClipboard,
useNewControlLayerFromBbox,
useNewGlobalReferenceImageFromBbox,
useNewRasterLayerFromBbox,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {
Button,
Flex,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppStore } from 'app/store/nanostores/store';
import { useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { createNewCanvasEntityFromImage } from 'features/imageActions/actions';
import { toast } from 'features/toast/toast';
import { atom } from 'nanostores';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiBoundingBoxBold, PiImageBold } from 'react-icons/pi';
import { useUploadImageMutation } from 'services/api/endpoints/images';

const $imageFile = atom<File | null>(null);
export const setFileToPaste = (file: File) => $imageFile.set(file);
const clearFileToPaste = () => $imageFile.set(null);

export const CanvasPasteModal = memo(() => {
useAssertSingleton('CanvasPasteModal');
const { dispatch, getState } = useAppStore();
const { t } = useTranslation();
const imageToPaste = useStore($imageFile);
const canvasManager = useCanvasManager();
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const [uploadImage, { isLoading }] = useUploadImageMutation({ fixedCacheKey: 'canvasPasteModal' });

const getPosition = useCallback(
(destination: 'canvas' | 'bbox') => {
const { x, y } = canvasManager.stateApi.getBbox().rect;
if (destination === 'bbox') {
return { x, y };
}
const rasterLayerAdapters = canvasManager.compositor.getVisibleAdaptersOfType('raster_layer');
if (rasterLayerAdapters.length === 0) {
return { x, y };
}
{
const { x, y } = canvasManager.compositor.getRectOfAdapters(rasterLayerAdapters);
return { x, y };
}
},
[canvasManager.compositor, canvasManager.stateApi]
);

const handlePaste = useCallback(
async (file: File, destination: 'assets' | 'canvas' | 'bbox') => {
try {
const is_intermediate = destination !== 'assets';
const imageDTO = await uploadImage({
file,
is_intermediate,
image_category: 'user',
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
}).unwrap();

if (destination !== 'assets') {
createNewCanvasEntityFromImage({
type: 'raster_layer',
imageDTO,
dispatch,
getState,
overrides: { position: getPosition(destination) },
});
}
} catch {
toast({
title: t('toast.pasteFailed'),
status: 'error',
});
} finally {
clearFileToPaste();
toast({
title: t('toast.pasteSuccess', {
destination:
destination === 'assets'
? t('controlLayers.pasteToAssets')
: destination === 'bbox'
? t('controlLayers.pasteToBbox')
: t('controlLayers.pasteToCanvas'),
}),
status: 'success',
});
}
},
[autoAddBoardId, dispatch, getPosition, getState, t, uploadImage]
);

const pasteToAssets = useCallback(() => {
if (!imageToPaste) {
return;
}
handlePaste(imageToPaste, 'assets');
}, [handlePaste, imageToPaste]);

const pasteToCanvas = useCallback(() => {
if (!imageToPaste) {
return;
}
handlePaste(imageToPaste, 'canvas');
}, [handlePaste, imageToPaste]);

const pasteToBbox = useCallback(() => {
if (!imageToPaste) {
return;
}
handlePaste(imageToPaste, 'bbox');
}, [handlePaste, imageToPaste]);

return (
<Modal isOpen={imageToPaste !== null} onClose={clearFileToPaste} useInert={false} isCentered size="2xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>{t('controlLayers.pasteTo')}</ModalHeader>
<ModalCloseButton />
<ModalBody display="flex" justifyContent="center">
<Flex flexDir="column" gap={4} w="min-content">
<Button size="lg" onClick={pasteToCanvas} isDisabled={isLoading} leftIcon={<PiImageBold />}>
{t('controlLayers.pasteToCanvasDesc')}
</Button>
<Button size="lg" onClick={pasteToBbox} isDisabled={isLoading} leftIcon={<PiBoundingBoxBold />}>
{t('controlLayers.pasteToBboxDesc')}
</Button>
<Button size="lg" onClick={pasteToAssets} isDisabled={isLoading} variant="ghost">
{t('controlLayers.pasteToAssetsDesc')}
</Button>
</Flex>
</ModalBody>
<ModalFooter>
<Button onClick={clearFileToPaste} variant="ghost" isLoading={isLoading}>
{t('common.cancel')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
});

CanvasPasteModal.displayName = 'CanvasPasteModal';
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
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/copyHooks';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useCopyLayerToClipboard } from 'features/controlLayers/hooks/useCopyLayerToClipboard';
import { useEntityIsEmpty } from 'features/controlLayers/hooks/useEntityIsEmpty';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { logger } from 'app/logging/logger';
import { withResultAsync } from 'common/util/result';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
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 { startCase } from 'lodash-es';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { serializeError } from 'serialize-error';
Expand Down Expand Up @@ -53,3 +55,39 @@ export const useCopyLayerToClipboard = () => {

return copyLayerToCipboard;
};

export const useCopyCanvasToClipboard = (region: 'canvas' | 'bbox') => {
const { t } = useTranslation();
const canvasManager = useCanvasManager();
const copyCanvasToClipboard = useCallback(async () => {
const rect =
region === 'bbox'
? canvasManager.stateApi.getBbox().rect
: canvasManager.compositor.getVisibleRectOfType('raster_layer');

if (rect.width === 0 || rect.height === 0) {
toast({
title: t('controlLayers.copyRegionError', { region: startCase(region) }),
description: t('controlLayers.regionIsEmpty'),
status: 'warning',
});
return;
}

const result = await withResultAsync(async () => {
const rasterAdapters = canvasManager.compositor.getVisibleAdaptersOfType('raster_layer');
const canvasElement = canvasManager.compositor.getCompositeCanvas(rasterAdapters, rect);
const blob = await canvasToBlob(canvasElement);
copyBlobToClipboard(blob);
});

if (result.isOk()) {
toast({ title: t('controlLayers.regionCopiedToClipboard', { region: startCase(region) }) });
} else {
log.error({ error: serializeError(result.error) }, 'Failed to save canvas to gallery');
toast({ title: t('controlLayers.copyRegionError', { region: startCase(region) }), status: 'error' });
}
}, [canvasManager.compositor, canvasManager.stateApi, region, t]);

return copyCanvasToClipboard;
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { deepClone } from 'common/util/deepClone';
import { withResultAsync } from 'common/util/result';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectDefaultIPAdapter } from 'features/controlLayers/hooks/addLayerHooks';
import { canvasToBlob, getPrefixedId } from 'features/controlLayers/konva/util';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import {
controlLayerAdded,
entityRasterized,
Expand All @@ -27,9 +27,7 @@ import type {
import { imageDTOToImageObject, imageDTOToImageWithDims, initialControlNet } from 'features/controlLayers/store/util';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import type { BoardId } from 'features/gallery/store/types';
import { copyBlobToClipboard } from 'features/system/util/copyBlobToClipboard';
import { toast } from 'features/toast/toast';
import { startCase } from 'lodash-es';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { serializeError } from 'serialize-error';
Expand Down Expand Up @@ -152,42 +150,6 @@ export const useSaveBboxToGallery = () => {
return func;
};

export const useCopyCanvasToClipboard = (region: 'canvas' | 'bbox') => {
const { t } = useTranslation();
const canvasManager = useCanvasManager();
const copyCanvasToClipboard = useCallback(async () => {
const rect =
region === 'bbox'
? canvasManager.stateApi.getBbox().rect
: canvasManager.compositor.getVisibleRectOfType('raster_layer');

if (rect.width === 0 || rect.height === 0) {
toast({
title: t('controlLayers.copyRegionError', { region: startCase(region) }),
description: t('controlLayers.regionIsEmpty'),
status: 'warning',
});
return;
}

const result = await withResultAsync(async () => {
const rasterAdapters = canvasManager.compositor.getVisibleAdaptersOfType('raster_layer');
const canvasElement = canvasManager.compositor.getCompositeCanvas(rasterAdapters, rect);
const blob = await canvasToBlob(canvasElement);
copyBlobToClipboard(blob);
});

if (result.isOk()) {
toast({ title: t('controlLayers.regionCopiedToClipboard', { region: startCase(region) }) });
} else {
log.error({ error: serializeError(result.error) }, 'Failed to save canvas to gallery');
toast({ title: t('controlLayers.copyRegionError', { region: startCase(region) }), status: 'error' });
}
}, [canvasManager.compositor, canvasManager.stateApi, region, t]);

return copyCanvasToClipboard;
};

export const useNewRegionalReferenceImageFromBbox = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
Expand Down
17 changes: 16 additions & 1 deletion invokeai/frontend/web/src/features/dnd/FullscreenDropzone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import { containsFiles, getFiles } from '@atlaskit/pragmatic-drag-and-drop/exter
import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Flex, Heading } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { getStore } from 'app/store/nanostores/store';
import { useAppSelector } from 'app/store/storeHooks';
import { setFileToPaste } from 'features/controlLayers/components/CanvasPasteModal';
import { DndDropOverlay } from 'features/dnd/DndDropOverlay';
import type { DndTargetState } from 'features/dnd/types';
import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { selectMaxImageUploadCount } from 'features/system/store/configSlice';
import { toast } from 'features/toast/toast';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { uploadImages } from 'services/api/endpoints/images';
Expand Down Expand Up @@ -71,6 +75,8 @@ export const FullscreenDropzone = memo(() => {
const ref = useRef<HTMLDivElement>(null);
const maxImageUploadCount = useAppSelector(selectMaxImageUploadCount);
const [dndState, setDndState] = useState<DndTargetState>('idle');
const activeTab = useAppSelector(selectActiveTab);
const isImageViewerOpen = useStore($imageViewer);

const validateAndUploadFiles = useCallback(
(files: File[]) => {
Expand All @@ -92,6 +98,15 @@ export const FullscreenDropzone = memo(() => {
});
return;
}

// While on the canvas tab and when pasting a single image, canvas may want to create a new layer. Let it handle
// the paste event.
const [firstImageFile] = files;
if (!isImageViewerOpen && activeTab === 'canvas' && files.length === 1 && firstImageFile) {
setFileToPaste(firstImageFile);
return;
}

const autoAddBoardId = selectAutoAddBoardId(getState());

const uploadArgs: UploadImageArg[] = files.map((file, i) => ({
Expand All @@ -104,7 +119,7 @@ export const FullscreenDropzone = memo(() => {

uploadImages(uploadArgs);
},
[maxImageUploadCount, t]
[activeTab, isImageViewerOpen, maxImageUploadCount, t]
);

const onPaste = useCallback(
Expand Down
Loading