mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
feat(ui): add canvas context menu
So far, this includes: - Save Canvas to Gallery - Save Bbox to Gallery - Send Bbox to Regional IP Adapter - Send Bbox to Global IP Adapter - Send Bbox to Control Layer - Send Bbox to Raster Layer
This commit is contained in:
committed by
Kent Keirsey
parent
54c94bd713
commit
e5a53be42b
@@ -1662,8 +1662,12 @@
|
||||
"bookmark": "Bookmark for Quick Switch",
|
||||
"fitBboxToLayers": "Fit Bbox To Layers",
|
||||
"removeBookmark": "Remove Bookmark",
|
||||
"saveCanvasToGallery": "Save Canvas To Gallery",
|
||||
"saveBboxToGallery": "Save Bbox To Gallery",
|
||||
"saveCanvasToGallery": "Save Canvas to Gallery",
|
||||
"saveBboxToGallery": "Save Bbox to Gallery",
|
||||
"sendBboxToRegionalIPAdapter": "Send Bbox to Regional IP Adapter",
|
||||
"sendBboxToGlobalIPAdapter": "Send Bbox to Global IP Adapter",
|
||||
"sendBboxToControlLayer": "Send Bbox to Control Layer",
|
||||
"sendBboxToRasterLayer": "Send Bbox to Raster Layer",
|
||||
"savedToGalleryOk": "Saved to Gallery",
|
||||
"savedToGalleryError": "Error saving to gallery",
|
||||
"mergeVisible": "Merge Visible",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { MenuItem } from '@invoke-ai/ui-library';
|
||||
import {
|
||||
useIsSavingCanvas,
|
||||
useSaveBboxAsControlLayer,
|
||||
useSaveBboxAsGlobalIPAdapter,
|
||||
useSaveBboxAsRasterLayer,
|
||||
useSaveBboxAsRegionalGuidanceIPAdapter,
|
||||
useSaveBboxToGallery,
|
||||
useSaveCanvasToGallery,
|
||||
} from 'features/controlLayers/hooks/saveCanvasHooks';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiFloppyDiskBold, PiShareFatBold } from 'react-icons/pi';
|
||||
|
||||
export const CanvasContextMenuItems = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const isSaving = useIsSavingCanvas();
|
||||
const saveCanvasToGallery = useSaveCanvasToGallery();
|
||||
const saveBboxToGallery = useSaveBboxToGallery();
|
||||
const saveBboxAsRegionalGuidanceIPAdapter = useSaveBboxAsRegionalGuidanceIPAdapter();
|
||||
const saveBboxAsIPAdapter = useSaveBboxAsGlobalIPAdapter();
|
||||
const saveBboxAsRasterLayer = useSaveBboxAsRasterLayer();
|
||||
const saveBboxAsControlLayer = useSaveBboxAsControlLayer();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem icon={<PiFloppyDiskBold />} isLoading={isSaving.isTrue} onClick={saveCanvasToGallery}>
|
||||
{t('controlLayers.saveCanvasToGallery')}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<PiFloppyDiskBold />} isLoading={isSaving.isTrue} onClick={saveBboxToGallery}>
|
||||
{t('controlLayers.saveBboxToGallery')}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<PiShareFatBold />} isLoading={isSaving.isTrue} onClick={saveBboxAsIPAdapter}>
|
||||
{t('controlLayers.sendBboxToGlobalIPAdapter')}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<PiShareFatBold />} isLoading={isSaving.isTrue} onClick={saveBboxAsRegionalGuidanceIPAdapter}>
|
||||
{t('controlLayers.sendBboxToRegionalIPAdapter')}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<PiShareFatBold />} isLoading={isSaving.isTrue} onClick={saveBboxAsControlLayer}>
|
||||
{t('controlLayers.sendBboxToControlLayer')}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<PiShareFatBold />} isLoading={isSaving.isTrue} onClick={saveBboxAsRasterLayer}>
|
||||
{t('controlLayers.sendBboxToRasterLayer')}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasContextMenuItems.displayName = 'CanvasContextMenuItems';
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { ContextMenu, Flex, MenuList } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useScopeOnFocus } from 'common/hooks/interactionScopes';
|
||||
import { CanvasContextMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuItems';
|
||||
import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea';
|
||||
import { Filter } from 'features/controlLayers/components/Filters/Filter';
|
||||
import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD';
|
||||
@@ -13,13 +14,23 @@ import { Transform } from 'features/controlLayers/components/Transform/Transform
|
||||
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern';
|
||||
import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import { memo, useRef } from 'react';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
|
||||
export const CanvasTabContent = memo(() => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const dynamicGrid = useAppSelector(selectDynamicGrid);
|
||||
const showHUD = useAppSelector(selectShowHUD);
|
||||
|
||||
const renderMenu = useCallback(() => {
|
||||
return (
|
||||
<CanvasManagerProviderGate>
|
||||
<MenuList>
|
||||
<CanvasContextMenuItems />
|
||||
</MenuList>
|
||||
</CanvasManagerProviderGate>
|
||||
);
|
||||
}, []);
|
||||
|
||||
useScopeOnFocus('canvas', ref);
|
||||
|
||||
return (
|
||||
@@ -36,31 +47,42 @@ export const CanvasTabContent = memo(() => {
|
||||
justifyContent="center"
|
||||
>
|
||||
<CanvasToolbar />
|
||||
<Flex position="relative" w="full" h="full" bg={dynamicGrid ? 'base.850' : 'base.900'} borderRadius="base">
|
||||
{!dynamicGrid && (
|
||||
<ContextMenu<HTMLDivElement> renderMenu={renderMenu}>
|
||||
{(ref) => (
|
||||
<Flex
|
||||
position="absolute"
|
||||
ref={ref}
|
||||
position="relative"
|
||||
w="full"
|
||||
h="full"
|
||||
bg={dynamicGrid ? 'base.850' : 'base.900'}
|
||||
borderRadius="base"
|
||||
bgImage={TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL}
|
||||
top={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
left={0}
|
||||
opacity={0.1}
|
||||
/>
|
||||
)}
|
||||
<InvokeCanvasComponent />
|
||||
<CanvasManagerProviderGate>
|
||||
{showHUD && (
|
||||
<Flex position="absolute" top={1} insetInlineStart={1} pointerEvents="none">
|
||||
<CanvasHUD />
|
||||
</Flex>
|
||||
)}
|
||||
<Flex position="absolute" top={1} insetInlineEnd={1} pointerEvents="none">
|
||||
<CanvasSelectedEntityStatusAlert />
|
||||
>
|
||||
{!dynamicGrid && (
|
||||
<Flex
|
||||
position="absolute"
|
||||
borderRadius="base"
|
||||
bgImage={TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL}
|
||||
top={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
left={0}
|
||||
opacity={0.1}
|
||||
/>
|
||||
)}
|
||||
<InvokeCanvasComponent />
|
||||
<CanvasManagerProviderGate>
|
||||
{showHUD && (
|
||||
<Flex position="absolute" top={1} insetInlineStart={1} pointerEvents="none">
|
||||
<CanvasHUD />
|
||||
</Flex>
|
||||
)}
|
||||
<Flex position="absolute" top={1} insetInlineEnd={1} pointerEvents="none">
|
||||
<CanvasSelectedEntityStatusAlert />
|
||||
</Flex>
|
||||
</CanvasManagerProviderGate>
|
||||
</Flex>
|
||||
</CanvasManagerProviderGate>
|
||||
</Flex>
|
||||
)}
|
||||
</ContextMenu>
|
||||
<Flex position="absolute" bottom={4} gap={2} align="center" justify="center">
|
||||
<CanvasManagerProviderGate>
|
||||
<StagingAreaIsStagingGate>
|
||||
|
||||
@@ -1,47 +1,24 @@
|
||||
import { IconButton, useShiftModifier } from '@invoke-ai/ui-library';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { buildUseBoolean } from 'common/hooks/useBoolean';
|
||||
import { isOk, withResultAsync } from 'common/util/result';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { memo, useCallback } from 'react';
|
||||
import {
|
||||
useIsSavingCanvas,
|
||||
useSaveBboxToGallery,
|
||||
useSaveCanvasToGallery,
|
||||
} from 'features/controlLayers/hooks/saveCanvasHooks';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiFloppyDiskBold } from 'react-icons/pi';
|
||||
import { serializeError } from 'serialize-error';
|
||||
|
||||
const log = logger('canvas');
|
||||
|
||||
const [useIsSaving] = buildUseBoolean(false);
|
||||
|
||||
export const CanvasToolbarSaveToGalleryButton = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const shift = useShiftModifier();
|
||||
const canvasManager = useCanvasManager();
|
||||
const isSaving = useIsSaving();
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
isSaving.setTrue();
|
||||
|
||||
const rect = shift ? canvasManager.stateApi.getBbox().rect : canvasManager.stage.getVisibleRect('raster_layer');
|
||||
|
||||
const result = await withResultAsync(() =>
|
||||
canvasManager.compositor.rasterizeAndUploadCompositeRasterLayer(rect, true)
|
||||
);
|
||||
|
||||
if (isOk(result)) {
|
||||
toast({ title: t('controlLayers.savedToGalleryOk') });
|
||||
} else {
|
||||
log.error({ error: serializeError(result.error) }, 'Failed to save canvas to gallery');
|
||||
toast({ title: t('controlLayers.savedToGalleryError'), status: 'error' });
|
||||
}
|
||||
|
||||
isSaving.setFalse();
|
||||
}, [canvasManager.compositor, canvasManager.stage, canvasManager.stateApi, isSaving, shift, t]);
|
||||
const isSaving = useIsSavingCanvas();
|
||||
const saveCanvasToGallery = useSaveCanvasToGallery();
|
||||
const saveBboxToGallery = useSaveBboxToGallery();
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
onClick={shift ? saveBboxToGallery : saveCanvasToGallery}
|
||||
icon={<PiFloppyDiskBold />}
|
||||
isLoading={isSaving.isTrue}
|
||||
aria-label={shift ? t('controlLayers.saveBboxToGallery') : t('controlLayers.saveCanvasToGallery')}
|
||||
|
||||
@@ -49,20 +49,24 @@ export const selectDefaultControlAdapter = createSelector(
|
||||
}
|
||||
);
|
||||
|
||||
const selectDefaultIPAdapter = createSelector(selectModelConfigsQuery, selectBase, (query, base): IPAdapterConfig => {
|
||||
const { data } = query;
|
||||
let model: IPAdapterModelConfig | null = null;
|
||||
if (data) {
|
||||
const modelConfigs = modelConfigsAdapterSelectors.selectAll(data).filter(isIPAdapterModelConfig);
|
||||
const compatibleModels = modelConfigs.filter((m) => (base ? m.base === base : true));
|
||||
model = compatibleModels[0] ?? modelConfigs[0] ?? null;
|
||||
export const selectDefaultIPAdapter = createSelector(
|
||||
selectModelConfigsQuery,
|
||||
selectBase,
|
||||
(query, base): IPAdapterConfig => {
|
||||
const { data } = query;
|
||||
let model: IPAdapterModelConfig | null = null;
|
||||
if (data) {
|
||||
const modelConfigs = modelConfigsAdapterSelectors.selectAll(data).filter(isIPAdapterModelConfig);
|
||||
const compatibleModels = modelConfigs.filter((m) => (base ? m.base === base : true));
|
||||
model = compatibleModels[0] ?? modelConfigs[0] ?? null;
|
||||
}
|
||||
const ipAdapter = deepClone(initialIPAdapter);
|
||||
if (model) {
|
||||
ipAdapter.model = zModelIdentifierField.parse(model);
|
||||
}
|
||||
return ipAdapter;
|
||||
}
|
||||
const ipAdapter = deepClone(initialIPAdapter);
|
||||
if (model) {
|
||||
ipAdapter.model = zModelIdentifierField.parse(model);
|
||||
}
|
||||
return ipAdapter;
|
||||
});
|
||||
);
|
||||
|
||||
export const useAddControlLayer = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { buildUseBoolean } from 'common/hooks/useBoolean';
|
||||
import { isOk, withResultAsync } from 'common/util/result';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { selectDefaultControlAdapter, selectDefaultIPAdapter } from 'features/controlLayers/hooks/addLayerHooks';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import { controlLayerAdded, ipaAdded, rasterLayerAdded, rgAdded } from 'features/controlLayers/store/canvasSlice';
|
||||
import type {
|
||||
CanvasControlLayerState,
|
||||
CanvasIPAdapterState,
|
||||
CanvasRasterLayerState,
|
||||
CanvasRegionalGuidanceState,
|
||||
Rect,
|
||||
RegionalGuidanceIPAdapterConfig,
|
||||
} from 'features/controlLayers/store/types';
|
||||
import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/types';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { serializeError } from 'serialize-error';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
const log = logger('canvas');
|
||||
|
||||
export const [useIsSavingCanvas] = buildUseBoolean(false);
|
||||
|
||||
type UseSaveCanvasArg = {
|
||||
region: 'canvas' | 'bbox';
|
||||
saveToGallery: boolean;
|
||||
onSave?: (imageDTO: ImageDTO, rect: Rect) => void;
|
||||
};
|
||||
|
||||
const useSaveCanvas = ({ region, saveToGallery, onSave }: UseSaveCanvasArg) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const canvasManager = useCanvasManager();
|
||||
const isSaving = useIsSavingCanvas();
|
||||
|
||||
const saveCanvas = useCallback(async () => {
|
||||
isSaving.setTrue();
|
||||
|
||||
const rect =
|
||||
region === 'bbox' ? canvasManager.stateApi.getBbox().rect : canvasManager.stage.getVisibleRect('raster_layer');
|
||||
|
||||
const result = await withResultAsync(() =>
|
||||
canvasManager.compositor.rasterizeAndUploadCompositeRasterLayer(rect, saveToGallery)
|
||||
);
|
||||
|
||||
if (isOk(result)) {
|
||||
if (onSave) {
|
||||
onSave(result.value, rect);
|
||||
}
|
||||
toast({ title: t('controlLayers.savedToGalleryOk') });
|
||||
} else {
|
||||
log.error({ error: serializeError(result.error) }, 'Failed to save canvas to gallery');
|
||||
toast({ title: t('controlLayers.savedToGalleryError'), status: 'error' });
|
||||
}
|
||||
|
||||
isSaving.setFalse();
|
||||
}, [
|
||||
canvasManager.compositor,
|
||||
canvasManager.stage,
|
||||
canvasManager.stateApi,
|
||||
isSaving,
|
||||
onSave,
|
||||
region,
|
||||
saveToGallery,
|
||||
t,
|
||||
]);
|
||||
|
||||
return saveCanvas;
|
||||
};
|
||||
|
||||
export const useSaveCanvasToGallery = () => {
|
||||
const saveCanvasToGalleryArg = useMemo<UseSaveCanvasArg>(() => ({ region: 'canvas', saveToGallery: true }), []);
|
||||
const saveCanvasToGallery = useSaveCanvas(saveCanvasToGalleryArg);
|
||||
return saveCanvasToGallery;
|
||||
};
|
||||
|
||||
export const useSaveBboxToGallery = () => {
|
||||
const saveBboxToGalleryArg = useMemo<UseSaveCanvasArg>(() => ({ region: 'bbox', saveToGallery: true }), []);
|
||||
const saveBboxToGallery = useSaveCanvas(saveBboxToGalleryArg);
|
||||
return saveBboxToGallery;
|
||||
};
|
||||
|
||||
export const useSaveBboxAsRegionalGuidanceIPAdapter = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const defaultIPAdapter = useAppSelector(selectDefaultIPAdapter);
|
||||
|
||||
const saveBboxAsRegionalGuidanceIPAdapterArg = useMemo<UseSaveCanvasArg>(() => {
|
||||
const onSave = (imageDTO: ImageDTO) => {
|
||||
const ipAdapter: RegionalGuidanceIPAdapterConfig = {
|
||||
...defaultIPAdapter,
|
||||
id: getPrefixedId('regional_guidance_ip_adapter'),
|
||||
image: imageDTOToImageWithDims(imageDTO),
|
||||
};
|
||||
const overrides: Partial<CanvasRegionalGuidanceState> = {
|
||||
ipAdapters: [ipAdapter],
|
||||
};
|
||||
|
||||
dispatch(rgAdded({ overrides, isSelected: true }));
|
||||
};
|
||||
|
||||
return { region: 'bbox', saveToGallery: true, onSave };
|
||||
}, [defaultIPAdapter, dispatch]);
|
||||
const saveBboxAsRegionalGuidanceIPAdapter = useSaveCanvas(saveBboxAsRegionalGuidanceIPAdapterArg);
|
||||
return saveBboxAsRegionalGuidanceIPAdapter;
|
||||
};
|
||||
|
||||
export const useSaveBboxAsGlobalIPAdapter = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const defaultIPAdapter = useAppSelector(selectDefaultIPAdapter);
|
||||
|
||||
const saveBboxAsIPAdapterArg = useMemo<UseSaveCanvasArg>(() => {
|
||||
const onSave = (imageDTO: ImageDTO) => {
|
||||
const overrides: Partial<CanvasIPAdapterState> = {
|
||||
ipAdapter: {
|
||||
...defaultIPAdapter,
|
||||
image: imageDTOToImageWithDims(imageDTO),
|
||||
},
|
||||
};
|
||||
dispatch(ipaAdded({ overrides, isSelected: true }));
|
||||
};
|
||||
|
||||
return { region: 'bbox', saveToGallery: true, onSave };
|
||||
}, [defaultIPAdapter, dispatch]);
|
||||
const saveBboxAsIPAdapter = useSaveCanvas(saveBboxAsIPAdapterArg);
|
||||
return saveBboxAsIPAdapter;
|
||||
};
|
||||
|
||||
export const useSaveBboxAsRasterLayer = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const saveBboxAsRasterLayerArg = useMemo<UseSaveCanvasArg>(() => {
|
||||
const onSave = (imageDTO: ImageDTO, rect: Rect) => {
|
||||
const overrides: Partial<CanvasRasterLayerState> = {
|
||||
objects: [imageDTOToImageObject(imageDTO)],
|
||||
position: { x: rect.x, y: rect.y },
|
||||
};
|
||||
dispatch(rasterLayerAdded({ overrides, isSelected: true }));
|
||||
};
|
||||
|
||||
return { region: 'bbox', saveToGallery: true, onSave };
|
||||
}, [dispatch]);
|
||||
const saveBboxAsRasterLayer = useSaveCanvas(saveBboxAsRasterLayerArg);
|
||||
return saveBboxAsRasterLayer;
|
||||
};
|
||||
|
||||
export const useSaveBboxAsControlLayer = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const defaultControlAdapter = useAppSelector(selectDefaultControlAdapter);
|
||||
|
||||
const saveBboxAsControlLayerArg = useMemo<UseSaveCanvasArg>(() => {
|
||||
const onSave = (imageDTO: ImageDTO, rect: Rect) => {
|
||||
const overrides: Partial<CanvasControlLayerState> = {
|
||||
objects: [imageDTOToImageObject(imageDTO)],
|
||||
controlAdapter: defaultControlAdapter,
|
||||
position: { x: rect.x, y: rect.y },
|
||||
};
|
||||
dispatch(controlLayerAdded({ overrides, isSelected: true }));
|
||||
};
|
||||
|
||||
return { region: 'bbox', saveToGallery: true, onSave };
|
||||
}, [defaultControlAdapter, dispatch]);
|
||||
const saveBboxAsControlLayer = useSaveCanvas(saveBboxAsControlLayerArg);
|
||||
return saveBboxAsControlLayer;
|
||||
};
|
||||
Reference in New Issue
Block a user