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')} + + + + + + + ); +}); + +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,