feat(ui): prevent layer interactions when transforming or filtering

This commit is contained in:
psychedelicious
2024-09-03 20:16:31 +10:00
parent c4ab0c9c96
commit d535ea6119
17 changed files with 139 additions and 55 deletions

View File

@@ -1,6 +1,7 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { controlLayerConvertedToRasterLayer } from 'features/controlLayers/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -9,6 +10,7 @@ import { PiLightningBold } from 'react-icons/pi';
export const ControlLayerMenuItemsControlToRaster = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isBusy = useCanvasIsBusy();
const entityIdentifier = useEntityIdentifierContext('control_layer');
const convertControlLayerToRasterLayer = useCallback(() => {
@@ -16,7 +18,7 @@ export const ControlLayerMenuItemsControlToRaster = memo(() => {
}, [dispatch, entityIdentifier]);
return (
<MenuItem onClick={convertControlLayerToRasterLayer} icon={<PiLightningBold />}>
<MenuItem onClick={convertControlLayerToRasterLayer} icon={<PiLightningBold />} isDisabled={isBusy}>
{t('controlLayers.convertToRasterLayer')}
</MenuItem>
);

View File

@@ -1,6 +1,7 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { rasterLayerConvertedToControlLayer } from 'features/controlLayers/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -10,13 +11,14 @@ export const RasterLayerMenuItemsRasterToControl = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext('raster_layer');
const isBusy = useCanvasIsBusy();
const convertRasterLayerToControlLayer = useCallback(() => {
dispatch(rasterLayerConvertedToControlLayer({ entityIdentifier }));
}, [dispatch, entityIdentifier]);
return (
<MenuItem onClick={convertRasterLayerToControlLayer} icon={<PiLightningBold />}>
<MenuItem onClick={convertRasterLayerToControlLayer} icon={<PiLightningBold />} isDisabled={isBusy}>
{t('controlLayers.convertToControlLayer')}
</MenuItem>
);

View File

@@ -2,6 +2,7 @@ import { MenuItem } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import {
rgIPAdapterAdded,
rgNegativePromptChanged,
@@ -15,6 +16,7 @@ export const RegionalGuidanceMenuItemsAddPromptsAndIPAdapter = memo(() => {
const entityIdentifier = useEntityIdentifierContext('regional_guidance');
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isBusy = useCanvasIsBusy();
const selectValidActions = useMemo(
() =>
createMemoizedSelector(selectCanvasSlice, (canvas) => {
@@ -39,13 +41,15 @@ export const RegionalGuidanceMenuItemsAddPromptsAndIPAdapter = memo(() => {
return (
<>
<MenuItem onClick={addPositivePrompt} isDisabled={!validActions.canAddPositivePrompt}>
<MenuItem onClick={addPositivePrompt} isDisabled={!validActions.canAddPositivePrompt || isBusy}>
{t('controlLayers.addPositivePrompt')}
</MenuItem>
<MenuItem onClick={addNegativePrompt} isDisabled={!validActions.canAddNegativePrompt}>
<MenuItem onClick={addNegativePrompt} isDisabled={!validActions.canAddNegativePrompt || isBusy}>
{t('controlLayers.addNegativePrompt')}
</MenuItem>
<MenuItem onClick={addIPAdapter}>{t('controlLayers.addIPAdapter')}</MenuItem>
<MenuItem onClick={addIPAdapter} isDisabled={isBusy}>
{t('controlLayers.addIPAdapter')}
</MenuItem>
</>
);
});

View File

@@ -2,6 +2,7 @@ import { MenuItem } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import {
entityArrangedBackwardOne,
entityArrangedForwardOne,
@@ -55,6 +56,7 @@ export const CanvasEntityMenuItemsArrange = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext();
const isBusy = useCanvasIsBusy();
const selectValidActions = useMemo(
() =>
createMemoizedSelector(selectCanvasSlice, (canvas) => {
@@ -86,16 +88,24 @@ export const CanvasEntityMenuItemsArrange = memo(() => {
return (
<>
<MenuItem onClick={moveToFront} isDisabled={!validActions.canMoveToFront} icon={<PiArrowLineUpBold />}>
<MenuItem onClick={moveToFront} isDisabled={!validActions.canMoveToFront || isBusy} icon={<PiArrowLineUpBold />}>
{t('controlLayers.moveToFront')}
</MenuItem>
<MenuItem onClick={moveForwardOne} isDisabled={!validActions.canMoveForwardOne} icon={<PiArrowUpBold />}>
<MenuItem
onClick={moveForwardOne}
isDisabled={!validActions.canMoveForwardOne || isBusy}
icon={<PiArrowUpBold />}
>
{t('controlLayers.moveForward')}
</MenuItem>
<MenuItem onClick={moveBackwardOne} isDisabled={!validActions.canMoveBackwardOne} icon={<PiArrowDownBold />}>
<MenuItem
onClick={moveBackwardOne}
isDisabled={!validActions.canMoveBackwardOne || isBusy}
icon={<PiArrowDownBold />}
>
{t('controlLayers.moveBackward')}
</MenuItem>
<MenuItem onClick={moveToBack} isDisabled={!validActions.canMoveToBack} icon={<PiArrowLineDownBold />}>
<MenuItem onClick={moveToBack} isDisabled={!validActions.canMoveToBack || isBusy} icon={<PiArrowLineDownBold />}>
{t('controlLayers.moveToBack')}
</MenuItem>
</>

View File

@@ -1,6 +1,7 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { entityDeleted } from 'features/controlLayers/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -10,13 +11,14 @@ export const CanvasEntityMenuItemsDelete = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext();
const isBusy = useCanvasIsBusy();
const deleteEntity = useCallback(() => {
dispatch(entityDeleted({ entityIdentifier }));
}, [dispatch, entityIdentifier]);
return (
<MenuItem onClick={deleteEntity} icon={<PiTrashSimpleBold />} color="error.300">
<MenuItem onClick={deleteEntity} icon={<PiTrashSimpleBold />} isDestructive isDisabled={isBusy}>
{t('common.delete')}
</MenuItem>
);

View File

@@ -1,6 +1,7 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { entityDuplicated } from 'features/controlLayers/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -10,13 +11,14 @@ export const CanvasEntityMenuItemsDuplicate = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext();
const isBusy = useCanvasIsBusy();
const onClick = useCallback(() => {
dispatch(entityDuplicated({ entityIdentifier }));
}, [dispatch, entityIdentifier]);
return (
<MenuItem onClick={onClick} icon={<PiCopyFill />}>
<MenuItem onClick={onClick} icon={<PiCopyFill />} isDisabled={isBusy}>
{t('controlLayers.duplicate')}
</MenuItem>
);

View File

@@ -1,6 +1,7 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiShootingStarBold } from 'react-icons/pi';
@@ -9,13 +10,14 @@ export const CanvasEntityMenuItemsFilter = memo(() => {
const { t } = useTranslation();
const canvasManager = useCanvasManager();
const entityIdentifier = useEntityIdentifierContext();
const isBusy = useCanvasIsBusy();
const onClick = useCallback(() => {
canvasManager.filter.initialize(entityIdentifier);
}, [entityIdentifier, canvasManager.filter]);
}, [canvasManager.filter, entityIdentifier]);
return (
<MenuItem onClick={onClick} icon={<PiShootingStarBold />}>
<MenuItem onClick={onClick} icon={<PiShootingStarBold />} isDisabled={isBusy}>
{t('controlLayers.filter.filter')}
</MenuItem>
);

View File

@@ -1,7 +1,6 @@
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 { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useEntityAdapter } from 'features/controlLayers/hooks/useEntityAdapter';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -10,16 +9,15 @@ import { PiFrameCornersBold } from 'react-icons/pi';
export const CanvasEntityMenuItemsTransform = memo(() => {
const { t } = useTranslation();
const entityIdentifier = useEntityIdentifierContext();
const canvasManager = useCanvasManager();
const adapter = useEntityAdapter(entityIdentifier);
const isTransforming = useStore(canvasManager.stateApi.$isTranforming);
const isBusy = useCanvasIsBusy();
const onClick = useCallback(() => {
adapter.transformer.startTransform();
}, [adapter.transformer]);
return (
<MenuItem onClick={onClick} icon={<PiFrameCornersBold />} isDisabled={isTransforming}>
<MenuItem onClick={onClick} icon={<PiFrameCornersBold />} isDisabled={isBusy}>
{t('controlLayers.transform.transform')}
</MenuItem>
);

View File

@@ -0,0 +1,9 @@
import { useStore } from '@nanostores/react';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
export const useCanvasIsBusy = () => {
const canvasManager = useCanvasManager();
const isBusy = useStore(canvasManager.$isBusy);
return isBusy;
};

View File

@@ -126,7 +126,8 @@ export class CanvasBboxModule extends CanvasModuleBase {
this.konva.group.visible(true);
// We need to reach up to the preview layer to enable/disable listening so that the bbox can be interacted with.
this.manager.konva.previewLayer.listening(tool === 'bbox');
// If the mangaer is busy, we disable listening so the bbox cannot be interacted with.
this.manager.konva.previewLayer.listening(tool === 'bbox' && !this.manager.$isBusy.get());
this.konva.proxyRect.setAttrs({
x,

View File

@@ -207,6 +207,10 @@ export class CanvasEntityLayerAdapter extends CanvasModuleBase {
return null;
};
isInteractable = (): boolean => {
return this.state.isEnabled && !this.state.isLocked;
};
destroy = (): void => {
this.log.debug('Destroying module');
this.renderer.destroy();

View File

@@ -170,6 +170,10 @@ export class CanvasEntityMaskAdapter extends CanvasModuleBase {
return canvas;
};
isInteractable = (): boolean => {
return this.state.isEnabled && !this.state.isLocked;
};
destroy = () => {
this.log.debug('Destroying module');
this.transformer.destroy();

View File

@@ -500,6 +500,13 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
syncInteractionState = () => {
this.log.trace('Syncing interaction state');
if (this.manager.$isBusy.get()) {
// If the canvas is busy, we can't interact with the transformer
this.parent.konva.layer.listening(false);
this.setInteractionMode('off');
return;
}
const pixelRect = this.$pixelRect.get();
const isPendingRectCalculation = this.$isPendingRectCalculation.get();

View File

@@ -16,7 +16,8 @@ import { CanvasToolModule } from 'features/controlLayers/konva/CanvasToolModule'
import { CanvasWorkerModule } from 'features/controlLayers/konva/CanvasWorkerModule.js';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import Konva from 'konva';
import { atom } from 'nanostores';
import type { Atom } from 'nanostores';
import { atom, computed } from 'nanostores';
import type { Logger } from 'roarr';
import { CanvasBackgroundModule } from './CanvasBackgroundModule';
@@ -73,6 +74,11 @@ export class CanvasManager extends CanvasModuleBase {
_isDebugging: boolean = false;
/**
* Whether the canvas is currently busy with a transformation or filtering operation.
*/
$isBusy: Atom<boolean>;
constructor(stage: Konva.Stage, container: HTMLDivElement, store: AppStore, socket: AppSocket) {
super();
this.id = getPrefixedId(this.type);
@@ -120,6 +126,10 @@ export class CanvasManager extends CanvasModuleBase {
this.konva.previewLayer.add(this.progressImage.konva.group);
this.konva.previewLayer.add(this.bbox.konva.group);
this.konva.previewLayer.add(this.tool.konva.group);
this.$isBusy = computed([this.filter.$isFiltering, this.stateApi.$isTranforming], (isFiltering, isTransforming) => {
return isFiltering || isTransforming;
});
}
enableDebugging() {

View File

@@ -71,6 +71,7 @@ export class CanvasRenderingModule extends CanvasModuleBase {
await this.renderInpaintMasks(state, prevState);
await this.renderBbox(state, prevState);
this.arrangeEntities(state, prevState);
this.manager.tool.syncCursorStyle();
};
renderSettings = () => {

View File

@@ -1,3 +1,4 @@
import type { Property } from 'csstype';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getPrefixedId, getRectUnion } from 'features/controlLayers/konva/util';
@@ -306,6 +307,10 @@ export class CanvasStageModule extends CanvasModuleBase {
return pixels / this.getScale();
};
setCursor = (cursor: Property.Cursor) => {
this.container.style.cursor = cursor;
};
setIsDraggable = (isDraggable: boolean) => {
this.konva.stage.draggable(isDraggable);
};

View File

@@ -143,46 +143,29 @@ export class CanvasToolModule extends CanvasModuleBase {
};
syncCursorStyle = () => {
this.log.trace('Syncing cursor style');
const stage = this.manager.stage;
const renderedEntityCount = this.manager.stateApi.getRenderedEntityCount();
const selectedEntity = this.manager.stateApi.getSelectedEntity();
const isMouseDown = this.$isMouseDown.get();
const tool = this.$tool.get();
const isDrawable =
!!selectedEntity &&
selectedEntity.state.isEnabled &&
!selectedEntity.state.isLocked &&
isDrawableEntity(selectedEntity.state);
// Update the stage's pointer style
if (this.manager.stateApi.$isTranforming.get() || renderedEntityCount === 0) {
// We are transforming and/or have no layers, so we should not render any tool
stage.container.style.cursor = 'default';
} else if (tool === 'view') {
// view tool gets a hand
stage.container.style.cursor = isMouseDown ? 'grabbing' : 'grab';
// Bbox tool gets default
} else if (tool === 'bbox') {
stage.container.style.cursor = 'default';
} else if (tool === 'colorPicker') {
// Color picker gets none
stage.container.style.cursor = 'none';
} else if (isDrawable) {
if (tool === 'move') {
// Move gets default arrow
stage.container.style.cursor = 'default';
} else if (tool === 'rect') {
// Rect gets a crosshair
stage.container.style.cursor = 'crosshair';
} else if (tool === 'brush' || tool === 'eraser') {
// Hide the native cursor and use the konva-rendered brush preview
stage.container.style.cursor = 'none';
}
if (tool === 'view') {
stage.setCursor(isMouseDown ? 'grabbing' : 'grab');
} else if (this.manager.stateApi.getRenderedEntityCount() === 0) {
stage.setCursor('not-allowed');
} else if (this.manager.stateApi.$isTranforming.get()) {
stage.setCursor('not-allowed');
} else if (this.manager.filter.$isFiltering.get()) {
stage.setCursor('not-allowed');
} else if (!this.manager.stateApi.getSelectedEntity()?.adapter.isInteractable()) {
stage.setCursor('not-allowed');
} else if (tool === 'colorPicker' || tool === 'brush' || tool === 'eraser') {
stage.setCursor('none');
} else if (tool === 'move' || tool === 'bbox') {
stage.setCursor('default');
} else if (tool === 'rect') {
stage.setCursor('crosshair');
} else {
// isDrawable === 'false'
// Non-drawable layers don't have tools
stage.container.style.cursor = 'not-allowed';
stage.setCursor('not-allowed');
}
};
@@ -302,7 +285,25 @@ export class CanvasToolModule extends CanvasModuleBase {
};
};
getCanDraw = (): boolean => {
if (this.manager.stateApi.getRenderedEntityCount() === 0) {
return false;
} else if (this.manager.stateApi.$isTranforming.get()) {
return false;
} else if (this.manager.filter.$isFiltering.get()) {
return false;
} else if (!this.manager.stateApi.getSelectedEntity()?.adapter.isInteractable()) {
return false;
} else {
return true;
}
};
onStageMouseEnter = async (_: KonvaEventObject<MouseEvent>) => {
if (!this.getCanDraw()) {
return;
}
const cursorPos = this.syncLastCursorPos();
try {
const isMouseDown = this.$isMouseDown.get();
@@ -356,6 +357,10 @@ export class CanvasToolModule extends CanvasModuleBase {
};
onStageMouseDown = async (e: KonvaEventObject<MouseEvent>) => {
if (!this.getCanDraw()) {
return;
}
this.$isMouseDown.set(getIsPrimaryMouseDown(e));
const cursorPos = this.syncLastCursorPos();
@@ -473,6 +478,10 @@ export class CanvasToolModule extends CanvasModuleBase {
};
onStageMouseUp = (_: KonvaEventObject<MouseEvent>) => {
if (!this.getCanDraw()) {
return;
}
try {
this.$isMouseDown.set(false);
const cursorPos = this.syncLastCursorPos();
@@ -516,6 +525,10 @@ export class CanvasToolModule extends CanvasModuleBase {
};
onStageMouseMove = async (_: KonvaEventObject<MouseEvent>) => {
if (!this.getCanDraw()) {
return;
}
try {
const tool = this.$tool.get();
const cursorPos = this.syncLastCursorPos();
@@ -595,6 +608,10 @@ export class CanvasToolModule extends CanvasModuleBase {
};
onStageMouseLeave = (_: KonvaEventObject<MouseEvent>) => {
if (!this.getCanDraw()) {
return;
}
this.$lastCursorPos.set(null);
this.$lastMouseDownPos.set(null);
const selectedEntity = this.manager.stateApi.getSelectedEntity();
@@ -607,6 +624,10 @@ export class CanvasToolModule extends CanvasModuleBase {
};
onStageMouseWheel = (e: KonvaEventObject<WheelEvent>) => {
if (!this.getCanDraw()) {
return;
}
e.evt.preventDefault();
if (!e.evt.ctrlKey && !e.evt.metaKey) {