feat(ui): transform tool ux

This commit is contained in:
psychedelicious 2024-08-22 21:58:34 +10:00
parent dcb436adb1
commit e7ae1ac9b2
18 changed files with 214 additions and 105 deletions

View File

@ -6,6 +6,7 @@ import { ControlLayersToolbar } from 'features/controlLayers/components/ControlL
import { Filter } from 'features/controlLayers/components/Filters/Filter'; import { Filter } from 'features/controlLayers/components/Filters/Filter';
import { StageComponent } from 'features/controlLayers/components/StageComponent'; import { StageComponent } from 'features/controlLayers/components/StageComponent';
import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar'; import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
import { Transform } from 'features/controlLayers/components/Transform';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { memo, useRef } from 'react'; import { memo, useRef } from 'react';
@ -36,6 +37,7 @@ export const CanvasEditor = memo(() => {
<Flex position="absolute" bottom={16}> <Flex position="absolute" bottom={16}>
<CanvasManagerProviderGate> <CanvasManagerProviderGate>
<Filter /> <Filter />
<Transform />
</CanvasManagerProviderGate> </CanvasManagerProviderGate>
</Flex> </Flex>
<CanvasDropArea /> <CanvasDropArea />

View File

@ -22,13 +22,14 @@ export const ControlLayersToolbar = memo(() => {
<Flex w="full" gap={2} alignItems="center"> <Flex w="full" gap={2} alignItems="center">
<ToggleProgressButton /> <ToggleProgressButton />
<ToolChooser /> <ToolChooser />
<Spacer />
{tool === 'brush' && <ToolBrushWidth />} {tool === 'brush' && <ToolBrushWidth />}
{tool === 'eraser' && <ToolEraserWidth />} {tool === 'eraser' && <ToolEraserWidth />}
<ToolFillColorPicker />
<Spacer /> <Spacer />
<CanvasScale /> <CanvasScale />
<CanvasResetViewButton /> <CanvasResetViewButton />
<Spacer /> <Spacer />
<ToolFillColorPicker />
<CanvasModeSwitcher /> <CanvasModeSwitcher />
<UndoRedoButtonGroup /> <UndoRedoButtonGroup />
<CanvasSettingsPopover /> <CanvasSettingsPopover />

View File

@ -3,6 +3,7 @@ import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/
import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete'; import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete';
import { CanvasEntityMenuItemsFilter } from 'features/controlLayers/components/common/CanvasEntityMenuItemsFilter'; import { CanvasEntityMenuItemsFilter } from 'features/controlLayers/components/common/CanvasEntityMenuItemsFilter';
import { CanvasEntityMenuItemsReset } from 'features/controlLayers/components/common/CanvasEntityMenuItemsReset'; 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 { RasterLayerMenuItemsRasterToControl } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsRasterToControl';
import { memo } from 'react'; import { memo } from 'react';
@ -11,6 +12,7 @@ export const RasterLayerMenuItems = memo(() => {
<> <>
<CanvasEntityMenuItemsFilter /> <CanvasEntityMenuItemsFilter />
<RasterLayerMenuItemsRasterToControl /> <RasterLayerMenuItemsRasterToControl />
<CanvasEntityMenuItemsTransform />
<MenuDivider /> <MenuDivider />
<CanvasEntityMenuItemsArrange /> <CanvasEntityMenuItemsArrange />
<MenuDivider /> <MenuDivider />

View File

@ -1,7 +1,8 @@
import { IconButton } from '@invoke-ai/ui-library'; import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice'; 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 { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiBoundingBoxBold } from 'react-icons/pi'; import { PiBoundingBoxBold } from 'react-icons/pi';
@ -9,14 +10,18 @@ import { PiBoundingBoxBold } from 'react-icons/pi';
export const ToolBboxButton = memo(() => { export const ToolBboxButton = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); 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 isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'bbox');
const isDisabled = useMemo(() => {
return isTransforming || isStaging;
}, [isStaging, isTransforming]);
const onClick = useCallback(() => { const onClick = useCallback(() => {
dispatch(toolChanged('bbox')); dispatch(toolChanged('bbox'));
}, [dispatch]); }, [dispatch]);
useHotkeys('q', onClick, { enabled: !isDisabled }, [onClick, isDisabled]); useHotkeys('q', onClick, { enabled: !isDisabled || isSelected }, [onClick, isSelected, isDisabled]);
return ( return (
<IconButton <IconButton

View File

@ -1,8 +1,9 @@
import { IconButton } from '@invoke-ai/ui-library'; import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice'; import { toolChanged } from 'features/controlLayers/store/canvasV2Slice';
import { isDrawableEntityType } from 'features/controlLayers/store/types'; 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 { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiPaintBrushBold } from 'react-icons/pi'; import { PiPaintBrushBold } from 'react-icons/pi';
@ -10,19 +11,25 @@ import { PiPaintBrushBold } from 'react-icons/pi';
export const ToolBrushButton = memo(() => { export const ToolBrushButton = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isTransforming = useIsTransforming();
const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging);
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'brush'); const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'brush');
const isDisabled = useAppSelector((s) => { const isDrawingToolAllowed = useAppSelector((s) => {
const entityType = s.canvasV2.selectedEntityIdentifier?.type; if (!s.canvasV2.selectedEntityIdentifier?.type) {
const isDrawingToolAllowed = entityType ? isDrawableEntityType(entityType) : false; return false;
const isStaging = s.canvasV2.session.isStaging; }
return !isDrawingToolAllowed || isStaging || s.canvasV2.tool.isTransforming; return isDrawableEntityType(s.canvasV2.selectedEntityIdentifier.type);
}); });
const isDisabled = useMemo(() => {
return isTransforming || isStaging || !isDrawingToolAllowed;
}, [isDrawingToolAllowed, isStaging, isTransforming]);
const onClick = useCallback(() => { const onClick = useCallback(() => {
dispatch(toolChanged('brush')); dispatch(toolChanged('brush'));
}, [dispatch]); }, [dispatch]);
useHotkeys('b', onClick, { enabled: !isDisabled }, [isDisabled, onClick]); useHotkeys('b', onClick, { enabled: !isDisabled || isSelected }, [isDisabled, isSelected, onClick]);
return ( return (
<IconButton <IconButton

View File

@ -1,5 +1,4 @@
import { ButtonGroup } from '@invoke-ai/ui-library'; import { ButtonGroup } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { ToolBboxButton } from 'features/controlLayers/components/Tool/ToolBboxButton'; import { ToolBboxButton } from 'features/controlLayers/components/Tool/ToolBboxButton';
import { ToolBrushButton } from 'features/controlLayers/components/Tool/ToolBrushButton'; import { ToolBrushButton } from 'features/controlLayers/components/Tool/ToolBrushButton';
import { ToolEyeDropperButton } from 'features/controlLayers/components/Tool/ToolEyeDropperButton'; import { ToolEyeDropperButton } from 'features/controlLayers/components/Tool/ToolEyeDropperButton';
@ -9,17 +8,15 @@ import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanv
import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey'; import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey';
import { ToolEraserButton } from './ToolEraserButton'; import { ToolEraserButton } from './ToolEraserButton';
import { ToolTransformButton } from './ToolTransformButton';
import { ToolViewButton } from './ToolViewButton'; import { ToolViewButton } from './ToolViewButton';
export const ToolChooser: React.FC = () => { export const ToolChooser: React.FC = () => {
useCanvasResetLayerHotkey(); useCanvasResetLayerHotkey();
useCanvasDeleteLayerHotkey(); useCanvasDeleteLayerHotkey();
const isTransforming = useAppSelector((s) => s.canvasV2.tool.isTransforming);
return ( return (
<> <>
<ButtonGroup isAttached isDisabled={isTransforming}> <ButtonGroup isAttached>
<ToolBrushButton /> <ToolBrushButton />
<ToolEraserButton /> <ToolEraserButton />
<ToolRectButton /> <ToolRectButton />
@ -28,7 +25,6 @@ export const ToolChooser: React.FC = () => {
<ToolBboxButton /> <ToolBboxButton />
<ToolEyeDropperButton /> <ToolEyeDropperButton />
</ButtonGroup> </ButtonGroup>
<ToolTransformButton />
</> </>
); );
}; };

View File

@ -1,8 +1,9 @@
import { IconButton } from '@invoke-ai/ui-library'; import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice'; import { toolChanged } from 'features/controlLayers/store/canvasV2Slice';
import { isDrawableEntityType } from 'features/controlLayers/store/types'; 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 { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiEraserBold } from 'react-icons/pi'; import { PiEraserBold } from 'react-icons/pi';
@ -10,19 +11,24 @@ import { PiEraserBold } from 'react-icons/pi';
export const ToolEraserButton = memo(() => { export const ToolEraserButton = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isTransforming = useIsTransforming();
const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging);
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'eraser'); const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'eraser');
const isDisabled = useAppSelector((s) => { const isDrawingToolAllowed = useAppSelector((s) => {
const entityType = s.canvasV2.selectedEntityIdentifier?.type; if (!s.canvasV2.selectedEntityIdentifier?.type) {
const isDrawingToolAllowed = entityType ? isDrawableEntityType(entityType) : false; return false;
const isStaging = s.canvasV2.session.isStaging; }
return !isDrawingToolAllowed || isStaging || s.canvasV2.tool.isTransforming; return isDrawableEntityType(s.canvasV2.selectedEntityIdentifier.type);
}); });
const isDisabled = useMemo(() => {
return isTransforming || isStaging || !isDrawingToolAllowed;
}, [isDrawingToolAllowed, isStaging, isTransforming]);
const onClick = useCallback(() => { const onClick = useCallback(() => {
dispatch(toolChanged('eraser')); dispatch(toolChanged('eraser'));
}, [dispatch]); }, [dispatch]);
useHotkeys('e', onClick, { enabled: !isDisabled }, [isDisabled, onClick]); useHotkeys('e', onClick, { enabled: !isDisabled || isSelected }, [isDisabled, isSelected, onClick]);
return ( return (
<IconButton <IconButton

View File

@ -1,7 +1,8 @@
import { IconButton } from '@invoke-ai/ui-library'; import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice'; 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 { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiEyedropperBold } from 'react-icons/pi'; import { PiEyedropperBold } from 'react-icons/pi';
@ -9,14 +10,19 @@ import { PiEyedropperBold } from 'react-icons/pi';
export const ToolEyeDropperButton = memo(() => { export const ToolEyeDropperButton = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); 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 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(() => { const onClick = useCallback(() => {
dispatch(toolChanged('eyeDropper')); dispatch(toolChanged('eyeDropper'));
}, [dispatch]); }, [dispatch]);
useHotkeys('i', onClick, { enabled: !isDisabled }, [onClick, isDisabled]); useHotkeys('i', onClick, { enabled: !isDisabled || isSelected }, [onClick, isSelected, isDisabled]);
return ( return (
<IconButton <IconButton

View File

@ -1,7 +1,9 @@
import { IconButton } from '@invoke-ai/ui-library'; import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice'; import { toolChanged } from 'features/controlLayers/store/canvasV2Slice';
import { memo, useCallback } from 'react'; import { isDrawableEntityType } from 'features/controlLayers/store/types';
import { memo, useCallback, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiCursorBold } from 'react-icons/pi'; import { PiCursorBold } from 'react-icons/pi';
@ -9,16 +11,24 @@ import { PiCursorBold } from 'react-icons/pi';
export const ToolMoveButton = memo(() => { export const ToolMoveButton = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isTransforming = useIsTransforming();
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'move'); const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'move');
const isDisabled = useAppSelector( const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging);
(s) => s.canvasV2.selectedEntityIdentifier === null || s.canvasV2.session.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(() => { const onClick = useCallback(() => {
dispatch(toolChanged('move')); dispatch(toolChanged('move'));
}, [dispatch]); }, [dispatch]);
useHotkeys('v', onClick, { enabled: !isDisabled }, [isDisabled, onClick]); useHotkeys('v', onClick, { enabled: !isDisabled || isSelected }, [isDisabled, isSelected, onClick]);
return ( return (
<IconButton <IconButton

View File

@ -1,8 +1,9 @@
import { IconButton } from '@invoke-ai/ui-library'; import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice'; import { toolChanged } from 'features/controlLayers/store/canvasV2Slice';
import { isDrawableEntityType } from 'features/controlLayers/store/types'; 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 { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiRectangleBold } from 'react-icons/pi'; import { PiRectangleBold } from 'react-icons/pi';
@ -11,18 +12,24 @@ export const ToolRectButton = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'rect'); const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'rect');
const isDisabled = useAppSelector((s) => { const isTransforming = useIsTransforming();
const entityType = s.canvasV2.selectedEntityIdentifier?.type; const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging);
const isDrawingToolAllowed = entityType ? isDrawableEntityType(entityType) : false; const isDrawingToolAllowed = useAppSelector((s) => {
const isStaging = s.canvasV2.session.isStaging; if (!s.canvasV2.selectedEntityIdentifier?.type) {
return !isDrawingToolAllowed || isStaging || s.canvasV2.tool.isTransforming; return false;
}
return isDrawableEntityType(s.canvasV2.selectedEntityIdentifier.type);
}); });
const isDisabled = useMemo(() => {
return isTransforming || isStaging || !isDrawingToolAllowed;
}, [isDrawingToolAllowed, isStaging, isTransforming]);
const onClick = useCallback(() => { const onClick = useCallback(() => {
dispatch(toolChanged('rect')); dispatch(toolChanged('rect'));
}, [dispatch]); }, [dispatch]);
useHotkeys('u', onClick, { enabled: !isDisabled }, [isDisabled, onClick]); useHotkeys('u', onClick, { enabled: !isDisabled || isSelected }, [isDisabled, isSelected, onClick]);
return ( return (
<IconButton <IconButton

View File

@ -1,63 +0,0 @@
import { Button, IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { $canvasManager } from 'features/controlLayers/konva/CanvasManager';
import { $transformingEntity } from 'features/controlLayers/store/canvasV2Slice';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiResizeBold } from 'react-icons/pi';
export const ToolTransformButton = memo(() => {
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 (
<>
<Button onClick={onApplyTransformation}>{t('common.apply')}</Button>
<Button onClick={onCancelTransformation}>{t('common.cancel')}</Button>
</>
);
}
return (
<IconButton
aria-label={`${t('controlLayers.tool.transform')} (Ctrl+T)`}
tooltip={`${t('controlLayers.tool.transform')} (Ctrl+T)`}
icon={<PiResizeBold />}
variant="solid"
onClick={onTransform}
isDisabled={isDisabled}
/>
);
});
ToolTransformButton.displayName = 'ToolTransformButton';

View File

@ -1,7 +1,8 @@
import { IconButton } from '@invoke-ai/ui-library'; import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice'; 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 { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiHandBold } from 'react-icons/pi'; import { PiHandBold } from 'react-icons/pi';
@ -9,13 +10,17 @@ import { PiHandBold } from 'react-icons/pi';
export const ToolViewButton = memo(() => { export const ToolViewButton = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isTransforming = useIsTransforming();
const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging);
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'view'); 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(() => { const onClick = useCallback(() => {
dispatch(toolChanged('view')); dispatch(toolChanged('view'));
}, [dispatch]); }, [dispatch]);
useHotkeys('h', onClick, { enabled: !isDisabled }, [onClick]); useHotkeys('h', onClick, { enabled: !isDisabled || isSelected }, [onClick, isSelected, isDisabled]);
return ( return (
<IconButton <IconButton

View File

@ -0,0 +1,75 @@
import { Button, ButtonGroup, Flex, Heading } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import {
EntityIdentifierContext,
useEntityIdentifierContext,
} from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useEntityAdapter } from 'features/controlLayers/hooks/useEntityAdapter';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCheckBold, PiXBold } from 'react-icons/pi';
const TransformBox = memo(() => {
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 (
<Flex
bg="base.800"
borderRadius="base"
p={4}
flexDir="column"
gap={4}
w={420}
h="auto"
shadow="dark-lg"
transitionProperty="height"
transitionDuration="normal"
>
<Heading size="md" color="base.300" userSelect="none">
{t('controlLayers.tool.transform')}
</Heading>
<ButtonGroup isAttached={false} size="sm" alignSelf="self-end">
<Button
leftIcon={<PiCheckBold />}
onClick={applyTransform}
isLoading={isProcessing}
loadingText={t('common.apply')}
>
{t('common.apply')}
</Button>
<Button leftIcon={<PiXBold />} onClick={cancelFilter} isLoading={isProcessing} loadingText={t('common.cancel')}>
{t('common.cancel')}
</Button>
</ButtonGroup>
</Flex>
);
});
TransformBox.displayName = 'Transform';
export const Transform = () => {
const canvasManager = useCanvasManager();
const transformingEntity = useStore(canvasManager.stateApi.$transformingEntity);
if (!transformingEntity) {
return null;
}
return (
<EntityIdentifierContext.Provider value={transformingEntity}>
<TransformBox />
</EntityIdentifierContext.Provider>
);
};

View File

@ -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 (
<MenuItem onClick={onClick} icon={<PiFrameCornersBold />} isDisabled={Boolean(transformingEntity)}>
{t('controlLayers.tool.transform')}
</MenuItem>
);
});
CanvasEntityMenuItemsTransform.displayName = 'CanvasEntityMenuItemsTransform';

View File

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

View File

@ -6,6 +6,7 @@ import type { CanvasMaskAdapter } from 'features/controlLayers/konva/CanvasMaskA
import { import {
$isDrawing, $isDrawing,
$isMouseDown, $isMouseDown,
$isProcessingTransform,
$lastAddedPoint, $lastAddedPoint,
$lastCursorPos, $lastCursorPos,
$lastMouseDownPos, $lastMouseDownPos,
@ -230,6 +231,7 @@ export class CanvasStateApiModule {
}; };
$transformingEntity = $transformingEntity; $transformingEntity = $transformingEntity;
$isProcessingTransform = $isProcessingTransform;
$toolState: WritableAtom<CanvasV2State['tool']> = atom(); $toolState: WritableAtom<CanvasV2State['tool']> = atom();
$currentFill: WritableAtom<RgbaColor> = atom(); $currentFill: WritableAtom<RgbaColor> = atom();

View File

@ -7,6 +7,7 @@ import type { Coordinate, Rect } from 'features/controlLayers/store/types';
import Konva from 'konva'; import Konva from 'konva';
import type { GroupConfig } from 'konva/lib/Group'; import type { GroupConfig } from 'konva/lib/Group';
import { debounce, get } from 'lodash-es'; import { debounce, get } from 'lodash-es';
import { atom } from 'nanostores';
import type { Logger } from 'roarr'; import type { Logger } from 'roarr';
/** /**
@ -89,6 +90,8 @@ export class CanvasTransformer {
*/ */
isTransformEnabled: boolean = false; isTransformEnabled: boolean = false;
$isProcessing = atom(false);
konva: { konva: {
transformer: Konva.Transformer; transformer: Konva.Transformer;
proxyRect: Konva.Rect; proxyRect: Konva.Rect;
@ -493,13 +496,14 @@ export class CanvasTransformer {
startTransform = () => { startTransform = () => {
this.log.debug('Starting transform'); this.log.debug('Starting transform');
this.isTransforming = true; 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 // 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 // interaction rect are listening, it will interrupt the stage's drag events. So we should disable listening
// when the view tool is selected // when the view tool is selected
const shouldListen = this.manager.stateApi.getToolState().selected !== 'view'; const shouldListen = this.manager.stateApi.getToolState().selected !== 'view';
this.parent.konva.layer.listening(shouldListen); this.parent.konva.layer.listening(shouldListen);
this.setInteractionMode('all'); this.setInteractionMode('all');
this.manager.stateApi.$transformingEntity.set(this.parent.getEntityIdentifier());
}; };
/** /**
@ -507,6 +511,7 @@ export class CanvasTransformer {
*/ */
applyTransform = async () => { applyTransform = async () => {
this.log.debug('Applying transform'); this.log.debug('Applying transform');
this.$isProcessing.set(true);
const rect = this.getRelativeRect(); const rect = this.getRelativeRect();
await this.parent.renderer.rasterize({ rect, replaceObjects: true }); await this.parent.renderer.rasterize({ rect, replaceObjects: true });
this.requestRectCalculation(); this.requestRectCalculation();
@ -530,6 +535,8 @@ export class CanvasTransformer {
this.updatePosition(); this.updatePosition();
this.updateBbox(); this.updateBbox();
this.syncInteractionState(); this.syncInteractionState();
this.manager.stateApi.$transformingEntity.set(null);
this.$isProcessing.set(false);
}; };
/** /**

View File

@ -630,6 +630,7 @@ export const $lastMouseDownPos = atom<Coordinate | null>(null);
export const $lastCursorPos = atom<Coordinate | null>(null); export const $lastCursorPos = atom<Coordinate | null>(null);
export const $spaceKey = atom<boolean>(false); export const $spaceKey = atom<boolean>(false);
export const $transformingEntity = atom<CanvasEntityIdentifier | null>(null); export const $transformingEntity = atom<CanvasEntityIdentifier | null>(null);
export const $isProcessingTransform = atom<boolean>(false);
export const canvasV2PersistConfig: PersistConfig<CanvasV2State> = { export const canvasV2PersistConfig: PersistConfig<CanvasV2State> = {
name: canvasV2Slice.name, name: canvasV2Slice.name,