diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersEditor.tsx
index 78439e6d1b..9a300a9a10 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersEditor.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersEditor.tsx
@@ -6,6 +6,7 @@ import { ControlLayersToolbar } from 'features/controlLayers/components/ControlL
import { Filter } from 'features/controlLayers/components/Filters/Filter';
import { StageComponent } from 'features/controlLayers/components/StageComponent';
import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
+import { Transform } from 'features/controlLayers/components/Transform';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { memo, useRef } from 'react';
@@ -36,6 +37,7 @@ export const CanvasEditor = memo(() => {
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx
index 5dcb801b4e..26df1cf1d8 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx
@@ -22,13 +22,14 @@ export const ControlLayersToolbar = memo(() => {
+
{tool === 'brush' && }
{tool === 'eraser' && }
-
+
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 2ad6a48f10..f901876515 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx
@@ -3,6 +3,7 @@ import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/
import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete';
import { CanvasEntityMenuItemsFilter } from 'features/controlLayers/components/common/CanvasEntityMenuItemsFilter';
import { CanvasEntityMenuItemsReset } from 'features/controlLayers/components/common/CanvasEntityMenuItemsReset';
+import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform';
import { RasterLayerMenuItemsRasterToControl } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsRasterToControl';
import { memo } from 'react';
@@ -11,6 +12,7 @@ export const RasterLayerMenuItems = memo(() => {
<>
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBboxButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBboxButton.tsx
index 407efcb9f9..05ae60117a 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBboxButton.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBboxButton.tsx
@@ -1,7 +1,8 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice';
-import { memo, useCallback } from 'react';
+import { memo, useCallback, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiBoundingBoxBold } from 'react-icons/pi';
@@ -9,14 +10,18 @@ import { PiBoundingBoxBold } from 'react-icons/pi';
export const ToolBboxButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
- const isDisabled = useAppSelector((s) => s.canvasV2.session.isStaging || s.canvasV2.tool.isTransforming);
+ const isTransforming = useIsTransforming();
+ const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging);
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'bbox');
+ const isDisabled = useMemo(() => {
+ return isTransforming || isStaging;
+ }, [isStaging, isTransforming]);
const onClick = useCallback(() => {
dispatch(toolChanged('bbox'));
}, [dispatch]);
- useHotkeys('q', onClick, { enabled: !isDisabled }, [onClick, isDisabled]);
+ useHotkeys('q', onClick, { enabled: !isDisabled || isSelected }, [onClick, isSelected, isDisabled]);
return (
{
const { t } = useTranslation();
const dispatch = useAppDispatch();
+ const isTransforming = useIsTransforming();
+ const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging);
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'brush');
- const isDisabled = useAppSelector((s) => {
- const entityType = s.canvasV2.selectedEntityIdentifier?.type;
- const isDrawingToolAllowed = entityType ? isDrawableEntityType(entityType) : false;
- const isStaging = s.canvasV2.session.isStaging;
- return !isDrawingToolAllowed || isStaging || s.canvasV2.tool.isTransforming;
+ const isDrawingToolAllowed = useAppSelector((s) => {
+ if (!s.canvasV2.selectedEntityIdentifier?.type) {
+ return false;
+ }
+ return isDrawableEntityType(s.canvasV2.selectedEntityIdentifier.type);
});
+ const isDisabled = useMemo(() => {
+ return isTransforming || isStaging || !isDrawingToolAllowed;
+ }, [isDrawingToolAllowed, isStaging, isTransforming]);
+
const onClick = useCallback(() => {
dispatch(toolChanged('brush'));
}, [dispatch]);
- useHotkeys('b', onClick, { enabled: !isDisabled }, [isDisabled, onClick]);
+ useHotkeys('b', onClick, { enabled: !isDisabled || isSelected }, [isDisabled, isSelected, onClick]);
return (
{
useCanvasResetLayerHotkey();
useCanvasDeleteLayerHotkey();
- const isTransforming = useAppSelector((s) => s.canvasV2.tool.isTransforming);
return (
<>
-
+
@@ -28,7 +25,6 @@ export const ToolChooser: React.FC = () => {
-
>
);
};
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserButton.tsx
index 924c7098b6..62d6650769 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserButton.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserButton.tsx
@@ -1,8 +1,9 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice';
import { isDrawableEntityType } from 'features/controlLayers/store/types';
-import { memo, useCallback } from 'react';
+import { memo, useCallback, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiEraserBold } from 'react-icons/pi';
@@ -10,19 +11,24 @@ import { PiEraserBold } from 'react-icons/pi';
export const ToolEraserButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
+ const isTransforming = useIsTransforming();
+ const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging);
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'eraser');
- const isDisabled = useAppSelector((s) => {
- const entityType = s.canvasV2.selectedEntityIdentifier?.type;
- const isDrawingToolAllowed = entityType ? isDrawableEntityType(entityType) : false;
- const isStaging = s.canvasV2.session.isStaging;
- return !isDrawingToolAllowed || isStaging || s.canvasV2.tool.isTransforming;
+ const isDrawingToolAllowed = useAppSelector((s) => {
+ if (!s.canvasV2.selectedEntityIdentifier?.type) {
+ return false;
+ }
+ return isDrawableEntityType(s.canvasV2.selectedEntityIdentifier.type);
});
+ const isDisabled = useMemo(() => {
+ return isTransforming || isStaging || !isDrawingToolAllowed;
+ }, [isDrawingToolAllowed, isStaging, isTransforming]);
const onClick = useCallback(() => {
dispatch(toolChanged('eraser'));
}, [dispatch]);
- useHotkeys('e', onClick, { enabled: !isDisabled }, [isDisabled, onClick]);
+ useHotkeys('e', onClick, { enabled: !isDisabled || isSelected }, [isDisabled, isSelected, onClick]);
return (
{
const { t } = useTranslation();
const dispatch = useAppDispatch();
- const isDisabled = useAppSelector((s) => s.canvasV2.session.isStaging || s.canvasV2.tool.isTransforming);
+ const isTransforming = useIsTransforming();
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'eyeDropper');
+ const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging);
+
+ const isDisabled = useMemo(() => {
+ return isTransforming || isStaging;
+ }, [isStaging, isTransforming]);
const onClick = useCallback(() => {
dispatch(toolChanged('eyeDropper'));
}, [dispatch]);
- useHotkeys('i', onClick, { enabled: !isDisabled }, [onClick, isDisabled]);
+ useHotkeys('i', onClick, { enabled: !isDisabled || isSelected }, [onClick, isSelected, isDisabled]);
return (
{
const { t } = useTranslation();
const dispatch = useAppDispatch();
+ const isTransforming = useIsTransforming();
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'move');
- const isDisabled = useAppSelector(
- (s) => s.canvasV2.selectedEntityIdentifier === null || s.canvasV2.session.isStaging || s.canvasV2.tool.isTransforming
- );
+ const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging);
+ const isDrawingToolAllowed = useAppSelector((s) => {
+ if (!s.canvasV2.selectedEntityIdentifier?.type) {
+ return false;
+ }
+ return isDrawableEntityType(s.canvasV2.selectedEntityIdentifier.type);
+ });
+ const isDisabled = useMemo(() => {
+ return isTransforming || isStaging || !isDrawingToolAllowed;
+ }, [isDrawingToolAllowed, isStaging, isTransforming]);
const onClick = useCallback(() => {
dispatch(toolChanged('move'));
}, [dispatch]);
- useHotkeys('v', onClick, { enabled: !isDisabled }, [isDisabled, onClick]);
+ useHotkeys('v', onClick, { enabled: !isDisabled || isSelected }, [isDisabled, isSelected, onClick]);
return (
{
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'rect');
- const isDisabled = useAppSelector((s) => {
- const entityType = s.canvasV2.selectedEntityIdentifier?.type;
- const isDrawingToolAllowed = entityType ? isDrawableEntityType(entityType) : false;
- const isStaging = s.canvasV2.session.isStaging;
- return !isDrawingToolAllowed || isStaging || s.canvasV2.tool.isTransforming;
+ const isTransforming = useIsTransforming();
+ const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging);
+ const isDrawingToolAllowed = useAppSelector((s) => {
+ if (!s.canvasV2.selectedEntityIdentifier?.type) {
+ return false;
+ }
+ return isDrawableEntityType(s.canvasV2.selectedEntityIdentifier.type);
});
+ const isDisabled = useMemo(() => {
+ return isTransforming || isStaging || !isDrawingToolAllowed;
+ }, [isDrawingToolAllowed, isStaging, isTransforming]);
+
const onClick = useCallback(() => {
dispatch(toolChanged('rect'));
}, [dispatch]);
- useHotkeys('u', onClick, { enabled: !isDisabled }, [isDisabled, onClick]);
+ useHotkeys('u', onClick, { enabled: !isDisabled || isSelected }, [isDisabled, isSelected, onClick]);
return (
{
- const { t } = useTranslation();
- const canvasManager = useStore($canvasManager);
- const transformingEntity = useStore($transformingEntity);
- const isDisabled = useAppSelector(
- (s) => s.canvasV2.selectedEntityIdentifier === null || s.canvasV2.session.isStaging
- );
-
- const onTransform = useCallback(() => {
- if (!canvasManager) {
- return;
- }
- canvasManager.startTransform();
- }, [canvasManager]);
-
- const onApplyTransformation = useCallback(() => {
- if (!canvasManager) {
- return;
- }
- canvasManager.applyTransform();
- }, [canvasManager]);
-
- const onCancelTransformation = useCallback(() => {
- if (!canvasManager) {
- return;
- }
- canvasManager.cancelTransform();
- }, [canvasManager]);
-
- useHotkeys(['ctrl+t', 'meta+t'], onTransform, { enabled: !isDisabled }, [isDisabled, onTransform]);
-
- if (transformingEntity) {
- return (
- <>
-
-
- >
- );
- }
-
- return (
- }
- variant="solid"
- onClick={onTransform}
- isDisabled={isDisabled}
- />
- );
-});
-
-ToolTransformButton.displayName = 'ToolTransformButton';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolViewButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolViewButton.tsx
index 22156a3401..3fe209a78b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolViewButton.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolViewButton.tsx
@@ -1,7 +1,8 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice';
-import { memo, useCallback } from 'react';
+import { memo, useCallback, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiHandBold } from 'react-icons/pi';
@@ -9,13 +10,17 @@ import { PiHandBold } from 'react-icons/pi';
export const ToolViewButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
+ const isTransforming = useIsTransforming();
+ const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging);
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'view');
- const isDisabled = useAppSelector((s) => s.canvasV2.session.isStaging || s.canvasV2.tool.isTransforming);
+ const isDisabled = useMemo(() => {
+ return isTransforming || isStaging;
+ }, [isStaging, isTransforming]);
const onClick = useCallback(() => {
dispatch(toolChanged('view'));
}, [dispatch]);
- useHotkeys('h', onClick, { enabled: !isDisabled }, [onClick]);
+ useHotkeys('h', onClick, { enabled: !isDisabled || isSelected }, [onClick, isSelected, isDisabled]);
return (
{
+ const { t } = useTranslation();
+ const entityIdentifier = useEntityIdentifierContext();
+ const adapter = useEntityAdapter(entityIdentifier);
+ const isProcessing = useStore(adapter.transformer.$isProcessing);
+
+ const applyTransform = useCallback(() => {
+ adapter.transformer.applyTransform();
+ }, [adapter.transformer]);
+
+ const cancelFilter = useCallback(() => {
+ adapter.transformer.stopTransform();
+ }, [adapter.transformer]);
+
+ return (
+
+
+ {t('controlLayers.tool.transform')}
+
+
+ }
+ onClick={applyTransform}
+ isLoading={isProcessing}
+ loadingText={t('common.apply')}
+ >
+ {t('common.apply')}
+
+ } onClick={cancelFilter} isLoading={isProcessing} loadingText={t('common.cancel')}>
+ {t('common.cancel')}
+
+
+
+ );
+});
+
+TransformBox.displayName = 'Transform';
+
+export const Transform = () => {
+ const canvasManager = useCanvasManager();
+ const transformingEntity = useStore(canvasManager.stateApi.$transformingEntity);
+
+ if (!transformingEntity) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsTransform.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsTransform.tsx
new file mode 100644
index 0000000000..a4c5dab05d
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsTransform.tsx
@@ -0,0 +1,28 @@
+import { MenuItem } from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
+import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
+import { useEntityAdapter } from 'features/controlLayers/hooks/useEntityAdapter';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiFrameCornersBold } from 'react-icons/pi';
+
+export const CanvasEntityMenuItemsTransform = memo(() => {
+ const { t } = useTranslation();
+ const entityIdentifier = useEntityIdentifierContext();
+ const canvasManager = useCanvasManager();
+ const adapter = useEntityAdapter(entityIdentifier);
+ const transformingEntity = useStore(canvasManager.stateApi.$transformingEntity);
+
+ const onClick = useCallback(() => {
+ adapter.transformer.startTransform();
+ }, [adapter.transformer]);
+
+ return (
+ } isDisabled={Boolean(transformingEntity)}>
+ {t('controlLayers.tool.transform')}
+
+ );
+});
+
+CanvasEntityMenuItemsTransform.displayName = 'CanvasEntityMenuItemsTransform';
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useIsTransforming.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useIsTransforming.ts
new file mode 100644
index 0000000000..dac961350e
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useIsTransforming.ts
@@ -0,0 +1,12 @@
+import { useStore } from '@nanostores/react';
+import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
+import { useMemo } from 'react';
+
+export const useIsTransforming = () => {
+ const canvasManager = useCanvasManager();
+ const transformingEntity = useStore(canvasManager.stateApi.$transformingEntity);
+ const isTransforming = useMemo(() => {
+ return Boolean(transformingEntity);
+ }, [transformingEntity]);
+ return isTransforming;
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts
index fe1fd26a81..e5571965fd 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts
@@ -6,6 +6,7 @@ import type { CanvasMaskAdapter } from 'features/controlLayers/konva/CanvasMaskA
import {
$isDrawing,
$isMouseDown,
+ $isProcessingTransform,
$lastAddedPoint,
$lastCursorPos,
$lastMouseDownPos,
@@ -230,6 +231,7 @@ export class CanvasStateApiModule {
};
$transformingEntity = $transformingEntity;
+ $isProcessingTransform = $isProcessingTransform;
$toolState: WritableAtom = atom();
$currentFill: WritableAtom = atom();
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts
index 6d8494beca..658c616500 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts
@@ -7,6 +7,7 @@ import type { Coordinate, Rect } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { GroupConfig } from 'konva/lib/Group';
import { debounce, get } from 'lodash-es';
+import { atom } from 'nanostores';
import type { Logger } from 'roarr';
/**
@@ -89,6 +90,8 @@ export class CanvasTransformer {
*/
isTransformEnabled: boolean = false;
+ $isProcessing = atom(false);
+
konva: {
transformer: Konva.Transformer;
proxyRect: Konva.Rect;
@@ -493,13 +496,14 @@ export class CanvasTransformer {
startTransform = () => {
this.log.debug('Starting transform');
this.isTransforming = true;
-
+ this.manager.stateApi.setTool('move')
// When transforming, we want the stage to still be movable if the view tool is selected. If the transformer or
// interaction rect are listening, it will interrupt the stage's drag events. So we should disable listening
// when the view tool is selected
const shouldListen = this.manager.stateApi.getToolState().selected !== 'view';
this.parent.konva.layer.listening(shouldListen);
this.setInteractionMode('all');
+ this.manager.stateApi.$transformingEntity.set(this.parent.getEntityIdentifier());
};
/**
@@ -507,6 +511,7 @@ export class CanvasTransformer {
*/
applyTransform = async () => {
this.log.debug('Applying transform');
+ this.$isProcessing.set(true);
const rect = this.getRelativeRect();
await this.parent.renderer.rasterize({ rect, replaceObjects: true });
this.requestRectCalculation();
@@ -530,6 +535,8 @@ export class CanvasTransformer {
this.updatePosition();
this.updateBbox();
this.syncInteractionState();
+ this.manager.stateApi.$transformingEntity.set(null);
+ this.$isProcessing.set(false);
};
/**
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts
index 688457d18f..cae88826a8 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts
@@ -630,6 +630,7 @@ export const $lastMouseDownPos = atom(null);
export const $lastCursorPos = atom(null);
export const $spaceKey = atom(false);
export const $transformingEntity = atom(null);
+export const $isProcessingTransform = atom(false);
export const canvasV2PersistConfig: PersistConfig = {
name: canvasV2Slice.name,