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:
psychedelicious
2024-09-10 22:48:58 +10:00
committed by Kent Keirsey
parent 54c94bd713
commit e5a53be42b
6 changed files with 295 additions and 72 deletions

View File

@@ -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",

View File

@@ -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';

View File

@@ -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>

View File

@@ -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')}

View File

@@ -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();

View File

@@ -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;
};