refactor(ui): layer interaction locking

Previously we maintained an `isInteractable` flag, which was derived from these layer flags:
- Locked/unlocked
- Enabled/disabled
- Layer's type visible/hidden

When a layer was not interactable, we blocked all layer actions.

After comparing to the behaviour in Affinity and considering user feedback, I've loosened these restrictions while maintaining safety. First, some definitions.

There two kinds of layer actions - mutating actions and non-mutating actions.
- Mutating actions are drawing on the layer, cropping it, filtering it, converting it, etc. Anything that changes the layer.
- Non-mutating actions are copying the layer, saving the layer to gallery, etc. Anything that _uses_ the layer.

Then, there are two broad canvas states - busy and not busy. "Busy" means the canvas is actively filtering, staging, compositing layers together, etc - something that is "single-threaded" by nature.

And here are the revised restrictions:
- When canvas is busy, you cannot initiate any layer actions.
- When the canvas is not busy, and the layer is locked, you initiate any mutating actions.
- When the canvas is not busy and the layer is not locked, you can initiate any layer action.

Besides safely giving users more freedom, it also fixes an issue where the context menu for a layer was disabled if it was not the selected layer.
This commit is contained in:
psychedelicious
2024-10-31 12:49:05 +10:00
parent 7ff1b635c8
commit 2826ab48a2
28 changed files with 242 additions and 224 deletions

View File

@@ -2,7 +2,8 @@ import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked';
import {
controlLayerConvertedToInpaintMask,
controlLayerConvertedToRasterLayer,
@@ -17,7 +18,8 @@ export const ControlLayerMenuItemsConvertToSubMenu = memo(() => {
const subMenu = useSubMenu();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext('control_layer');
const isInteractable = useIsEntityInteractable(entityIdentifier);
const isBusy = useCanvasIsBusy();
const isLocked = useEntityIsLocked(entityIdentifier);
const convertToInpaintMask = useCallback(() => {
dispatch(controlLayerConvertedToInpaintMask({ entityIdentifier, replace: true }));
@@ -32,19 +34,19 @@ export const ControlLayerMenuItemsConvertToSubMenu = memo(() => {
}, [dispatch, entityIdentifier]);
return (
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiSwapBold />}>
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiSwapBold />} isDisabled={isLocked || isBusy}>
<Menu {...subMenu.menuProps}>
<MenuButton {...subMenu.menuButtonProps}>
<SubMenuButtonContent label={t('controlLayers.convertControlLayerTo')} />
</MenuButton>
<MenuList {...subMenu.menuListProps}>
<MenuItem onClick={convertToInpaintMask} icon={<PiSwapBold />} isDisabled={!isInteractable}>
<MenuItem onClick={convertToInpaintMask} icon={<PiSwapBold />} isDisabled={isLocked || isBusy}>
{t('controlLayers.inpaintMask')}
</MenuItem>
<MenuItem onClick={convertToRegionalGuidance} icon={<PiSwapBold />} isDisabled={!isInteractable}>
<MenuItem onClick={convertToRegionalGuidance} icon={<PiSwapBold />} isDisabled={isLocked || isBusy}>
{t('controlLayers.regionalGuidance')}
</MenuItem>
<MenuItem onClick={convertToRasterLayer} icon={<PiSwapBold />} isDisabled={!isInteractable}>
<MenuItem onClick={convertToRasterLayer} icon={<PiSwapBold />} isDisabled={isLocked || isBusy}>
{t('controlLayers.rasterLayer')}
</MenuItem>
</MenuList>

View File

@@ -3,7 +3,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { CanvasEntityMenuItemsCopyToClipboard } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCopyToClipboard';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import {
controlLayerConvertedToInpaintMask,
controlLayerConvertedToRasterLayer,
@@ -18,7 +18,7 @@ export const ControlLayerMenuItemsCopyToSubMenu = memo(() => {
const subMenu = useSubMenu();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext('control_layer');
const isInteractable = useIsEntityInteractable(entityIdentifier);
const isBusy = useCanvasIsBusy();
const copyToInpaintMask = useCallback(() => {
dispatch(controlLayerConvertedToInpaintMask({ entityIdentifier }));
@@ -33,20 +33,20 @@ export const ControlLayerMenuItemsCopyToSubMenu = memo(() => {
}, [dispatch, entityIdentifier]);
return (
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiCopyBold />}>
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiCopyBold />} isDisabled={isBusy}>
<Menu {...subMenu.menuProps}>
<MenuButton {...subMenu.menuButtonProps}>
<SubMenuButtonContent label={t('controlLayers.copyControlLayerTo')} />
</MenuButton>
<MenuList {...subMenu.menuListProps}>
<CanvasEntityMenuItemsCopyToClipboard />
<MenuItem onClick={copyToInpaintMask} icon={<PiCopyBold />} isDisabled={!isInteractable}>
<MenuItem onClick={copyToInpaintMask} icon={<PiCopyBold />} isDisabled={isBusy}>
{t('controlLayers.newInpaintMask')}
</MenuItem>
<MenuItem onClick={copyToRegionalGuidance} icon={<PiCopyBold />} isDisabled={!isInteractable}>
<MenuItem onClick={copyToRegionalGuidance} icon={<PiCopyBold />} isDisabled={isBusy}>
{t('controlLayers.newRegionalGuidance')}
</MenuItem>
<MenuItem onClick={copyToRasterLayer} icon={<PiCopyBold />} isDisabled={!isInteractable}>
<MenuItem onClick={copyToRasterLayer} icon={<PiCopyBold />} isDisabled={isBusy}>
{t('controlLayers.newRasterLayer')}
</MenuItem>
</MenuList>

View File

@@ -2,7 +2,7 @@ import { MenuItem } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked';
import { controlLayerWithTransparencyEffectToggled } from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors';
import { memo, useCallback, useMemo } from 'react';
@@ -13,7 +13,7 @@ export const ControlLayerMenuItemsTransparencyEffect = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext('control_layer');
const isInteractable = useIsEntityInteractable(entityIdentifier);
const isLocked = useEntityIsLocked(entityIdentifier);
const selectWithTransparencyEffect = useMemo(
() =>
createSelector(selectCanvasSlice, (canvas) => {
@@ -28,7 +28,7 @@ export const ControlLayerMenuItemsTransparencyEffect = memo(() => {
}, [dispatch, entityIdentifier]);
return (
<MenuItem onClick={onToggle} icon={<PiDropHalfBold />} isDisabled={!isInteractable}>
<MenuItem onClick={onToggle} icon={<PiDropHalfBold />} isDisabled={isLocked}>
{withTransparencyEffect
? t('controlLayers.disableTransparencyEffect')
: t('controlLayers.enableTransparencyEffect')}

View File

@@ -2,7 +2,8 @@ import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked';
import { inpaintMaskConvertedToRegionalGuidance } from 'features/controlLayers/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -13,20 +14,21 @@ export const InpaintMaskMenuItemsConvertToSubMenu = memo(() => {
const subMenu = useSubMenu();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext('inpaint_mask');
const isInteractable = useIsEntityInteractable(entityIdentifier);
const isBusy = useCanvasIsBusy();
const isLocked = useEntityIsLocked(entityIdentifier);
const convertToRegionalGuidance = useCallback(() => {
dispatch(inpaintMaskConvertedToRegionalGuidance({ entityIdentifier, replace: true }));
}, [dispatch, entityIdentifier]);
return (
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiSwapBold />}>
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiSwapBold />} isDisabled={isBusy || isLocked}>
<Menu {...subMenu.menuProps}>
<MenuButton {...subMenu.menuButtonProps}>
<SubMenuButtonContent label={t('controlLayers.convertInpaintMaskTo')} />
</MenuButton>
<MenuList {...subMenu.menuListProps}>
<MenuItem onClick={convertToRegionalGuidance} icon={<PiSwapBold />} isDisabled={!isInteractable}>
<MenuItem onClick={convertToRegionalGuidance} icon={<PiSwapBold />} isDisabled={isBusy || isLocked}>
{t('controlLayers.regionalGuidance')}
</MenuItem>
</MenuList>

View File

@@ -3,7 +3,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { CanvasEntityMenuItemsCopyToClipboard } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCopyToClipboard';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { inpaintMaskConvertedToRegionalGuidance } from 'features/controlLayers/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -14,21 +14,21 @@ export const InpaintMaskMenuItemsCopyToSubMenu = memo(() => {
const subMenu = useSubMenu();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext('inpaint_mask');
const isInteractable = useIsEntityInteractable(entityIdentifier);
const isBusy = useCanvasIsBusy();
const copyToRegionalGuidance = useCallback(() => {
dispatch(inpaintMaskConvertedToRegionalGuidance({ entityIdentifier }));
}, [dispatch, entityIdentifier]);
return (
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiCopyBold />}>
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiCopyBold />} isDisabled={isBusy}>
<Menu {...subMenu.menuProps}>
<MenuButton {...subMenu.menuButtonProps}>
<SubMenuButtonContent label={t('controlLayers.copyInpaintMaskTo')} />
</MenuButton>
<MenuList {...subMenu.menuListProps}>
<CanvasEntityMenuItemsCopyToClipboard />
<MenuItem onClick={copyToRegionalGuidance} icon={<PiCopyBold />} isDisabled={!isInteractable}>
<MenuItem onClick={copyToRegionalGuidance} icon={<PiCopyBold />} isDisabled={isBusy}>
{t('controlLayers.newRegionalGuidance')}
</MenuItem>
</MenuList>

View File

@@ -3,7 +3,8 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { selectDefaultControlAdapter } from 'features/controlLayers/hooks/addLayerHooks';
import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked';
import {
rasterLayerConvertedToControlLayer,
rasterLayerConvertedToInpaintMask,
@@ -20,7 +21,8 @@ export const RasterLayerMenuItemsConvertToSubMenu = memo(() => {
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext('raster_layer');
const defaultControlAdapter = useAppSelector(selectDefaultControlAdapter);
const isInteractable = useIsEntityInteractable(entityIdentifier);
const isBusy = useCanvasIsBusy();
const isLocked = useEntityIsLocked(entityIdentifier);
const convertToInpaintMask = useCallback(() => {
dispatch(rasterLayerConvertedToInpaintMask({ entityIdentifier, replace: true }));
@@ -41,19 +43,19 @@ export const RasterLayerMenuItemsConvertToSubMenu = memo(() => {
}, [defaultControlAdapter, dispatch, entityIdentifier]);
return (
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiSwapBold />}>
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiSwapBold />} isDisabled={isBusy || isLocked}>
<Menu {...subMenu.menuProps}>
<MenuButton {...subMenu.menuButtonProps}>
<SubMenuButtonContent label={t('controlLayers.convertRasterLayerTo')} />
</MenuButton>
<MenuList {...subMenu.menuListProps}>
<MenuItem onClick={convertToInpaintMask} icon={<PiSwapBold />} isDisabled={!isInteractable}>
<MenuItem onClick={convertToInpaintMask} icon={<PiSwapBold />} isDisabled={isBusy || isLocked}>
{t('controlLayers.inpaintMask')}
</MenuItem>
<MenuItem onClick={convertToRegionalGuidance} icon={<PiSwapBold />} isDisabled={!isInteractable}>
<MenuItem onClick={convertToRegionalGuidance} icon={<PiSwapBold />} isDisabled={isBusy || isLocked}>
{t('controlLayers.regionalGuidance')}
</MenuItem>
<MenuItem onClick={convertToControlLayer} icon={<PiSwapBold />} isDisabled={!isInteractable}>
<MenuItem onClick={convertToControlLayer} icon={<PiSwapBold />} isDisabled={isBusy || isLocked}>
{t('controlLayers.controlLayer')}
</MenuItem>
</MenuList>

View File

@@ -4,7 +4,7 @@ import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { CanvasEntityMenuItemsCopyToClipboard } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCopyToClipboard';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { selectDefaultControlAdapter } from 'features/controlLayers/hooks/addLayerHooks';
import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import {
rasterLayerConvertedToControlLayer,
rasterLayerConvertedToInpaintMask,
@@ -21,7 +21,7 @@ export const RasterLayerMenuItemsCopyToSubMenu = memo(() => {
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext('raster_layer');
const defaultControlAdapter = useAppSelector(selectDefaultControlAdapter);
const isInteractable = useIsEntityInteractable(entityIdentifier);
const isBusy = useCanvasIsBusy();
const copyToInpaintMask = useCallback(() => {
dispatch(rasterLayerConvertedToInpaintMask({ entityIdentifier }));
@@ -41,20 +41,20 @@ export const RasterLayerMenuItemsCopyToSubMenu = memo(() => {
}, [defaultControlAdapter, dispatch, entityIdentifier]);
return (
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiCopyBold />}>
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiCopyBold />} isDisabled={isBusy}>
<Menu {...subMenu.menuProps}>
<MenuButton {...subMenu.menuButtonProps}>
<SubMenuButtonContent label={t('controlLayers.copyRasterLayerTo')} />
</MenuButton>
<MenuList {...subMenu.menuListProps}>
<CanvasEntityMenuItemsCopyToClipboard />
<MenuItem onClick={copyToInpaintMask} icon={<PiCopyBold />} isDisabled={!isInteractable}>
<MenuItem onClick={copyToInpaintMask} icon={<PiCopyBold />} isDisabled={isBusy}>
{t('controlLayers.newInpaintMask')}
</MenuItem>
<MenuItem onClick={copyToRegionalGuidance} icon={<PiCopyBold />} isDisabled={!isInteractable}>
<MenuItem onClick={copyToRegionalGuidance} icon={<PiCopyBold />} isDisabled={isBusy}>
{t('controlLayers.newRegionalGuidance')}
</MenuItem>
<MenuItem onClick={copyToControlLayer} icon={<PiCopyBold />} isDisabled={!isInteractable}>
<MenuItem onClick={copyToControlLayer} icon={<PiCopyBold />} isDisabled={isBusy}>
{t('controlLayers.newControlLayer')}
</MenuItem>
</MenuList>

View File

@@ -2,7 +2,8 @@ import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked';
import { rgConvertedToInpaintMask } from 'features/controlLayers/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -13,20 +14,21 @@ export const RegionalGuidanceMenuItemsConvertToSubMenu = memo(() => {
const subMenu = useSubMenu();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext('regional_guidance');
const isInteractable = useIsEntityInteractable(entityIdentifier);
const isBusy = useCanvasIsBusy();
const isLocked = useEntityIsLocked(entityIdentifier);
const convertToInpaintMask = useCallback(() => {
dispatch(rgConvertedToInpaintMask({ entityIdentifier, replace: true }));
}, [dispatch, entityIdentifier]);
return (
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiSwapBold />}>
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiSwapBold />} isDisabled={isLocked || isBusy}>
<Menu {...subMenu.menuProps}>
<MenuButton {...subMenu.menuButtonProps}>
<SubMenuButtonContent label={t('controlLayers.convertRegionalGuidanceTo')} />
</MenuButton>
<MenuList {...subMenu.menuListProps}>
<MenuItem onClick={convertToInpaintMask} icon={<PiSwapBold />} isDisabled={!isInteractable}>
<MenuItem onClick={convertToInpaintMask} icon={<PiSwapBold />} isDisabled={isLocked || isBusy}>
{t('controlLayers.inpaintMask')}
</MenuItem>
</MenuList>

View File

@@ -3,7 +3,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { CanvasEntityMenuItemsCopyToClipboard } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCopyToClipboard';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { rgConvertedToInpaintMask } from 'features/controlLayers/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -14,21 +14,21 @@ export const RegionalGuidanceMenuItemsCopyToSubMenu = memo(() => {
const subMenu = useSubMenu();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext('regional_guidance');
const isInteractable = useIsEntityInteractable(entityIdentifier);
const isBusy = useCanvasIsBusy();
const copyToInpaintMask = useCallback(() => {
dispatch(rgConvertedToInpaintMask({ entityIdentifier }));
}, [dispatch, entityIdentifier]);
return (
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiCopyBold />}>
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiCopyBold />} isDisabled={isBusy}>
<Menu {...subMenu.menuProps}>
<MenuButton {...subMenu.menuButtonProps}>
<SubMenuButtonContent label={t('controlLayers.copyRegionalGuidanceTo')} />
</MenuButton>
<MenuList {...subMenu.menuListProps}>
<CanvasEntityMenuItemsCopyToClipboard />
<MenuItem onClick={copyToInpaintMask} icon={<PiCopyBold />} isDisabled={!isInteractable}>
<MenuItem onClick={copyToInpaintMask} icon={<PiCopyBold />} isDisabled={isBusy}>
{t('controlLayers.newInpaintMask')}
</MenuItem>
</MenuList>

View File

@@ -2,7 +2,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { IconMenuItem } from 'common/components/IconMenuItem';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import {
entityArrangedBackwardOne,
entityArrangedForwardOne,
@@ -56,7 +56,7 @@ export const CanvasEntityMenuItemsArrange = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext();
const isInteractable = useIsEntityInteractable(entityIdentifier);
const isBusy = useCanvasIsBusy();
const selectValidActions = useMemo(
() =>
createMemoizedSelector(selectCanvasSlice, (canvas) => {
@@ -92,28 +92,28 @@ export const CanvasEntityMenuItemsArrange = memo(() => {
aria-label={t('controlLayers.moveToFront')}
tooltip={t('controlLayers.moveToFront')}
onClick={moveToFront}
isDisabled={!validActions.canMoveToFront || !isInteractable}
isDisabled={!validActions.canMoveToFront || isBusy}
icon={<PiArrowLineUpBold />}
/>
<IconMenuItem
aria-label={t('controlLayers.moveForward')}
tooltip={t('controlLayers.moveForward')}
onClick={moveForwardOne}
isDisabled={!validActions.canMoveForwardOne || !isInteractable}
isDisabled={!validActions.canMoveForwardOne || isBusy}
icon={<PiArrowUpBold />}
/>
<IconMenuItem
aria-label={t('controlLayers.moveBackward')}
tooltip={t('controlLayers.moveBackward')}
onClick={moveBackwardOne}
isDisabled={!validActions.canMoveBackwardOne || !isInteractable}
isDisabled={!validActions.canMoveBackwardOne || isBusy}
icon={<PiArrowDownBold />}
/>
<IconMenuItem
aria-label={t('controlLayers.moveToBack')}
tooltip={t('controlLayers.moveToBack')}
onClick={moveToBack}
isDisabled={!validActions.canMoveToBack || !isInteractable}
isDisabled={!validActions.canMoveToBack || isBusy}
icon={<PiArrowLineDownBold />}
/>
</>

View File

@@ -1,9 +1,9 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useEntityAdapterSafe } from 'features/controlLayers/contexts/EntityAdapterContext';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useCopyLayerToClipboard } from 'features/controlLayers/hooks/useCopyLayerToClipboard';
import { useEntityIsEmpty } from 'features/controlLayers/hooks/useEntityIsEmpty';
import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCopyBold } from 'react-icons/pi';
@@ -12,7 +12,7 @@ export const CanvasEntityMenuItemsCopyToClipboard = memo(() => {
const { t } = useTranslation();
const entityIdentifier = useEntityIdentifierContext();
const adapter = useEntityAdapterSafe(entityIdentifier);
const isInteractable = useIsEntityInteractable(entityIdentifier);
const isBusy = useCanvasIsBusy();
const isEmpty = useEntityIsEmpty(entityIdentifier);
const copyLayerToClipboard = useCopyLayerToClipboard();
@@ -21,7 +21,7 @@ export const CanvasEntityMenuItemsCopyToClipboard = memo(() => {
}, [copyLayerToClipboard, adapter]);
return (
<MenuItem onClick={onClick} icon={<PiCopyBold />} isDisabled={!isInteractable || isEmpty}>
<MenuItem onClick={onClick} icon={<PiCopyBold />} isDisabled={isBusy || isEmpty}>
{t('common.clipboard')}
</MenuItem>
);

View File

@@ -1,7 +1,8 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useEntityAdapterSafe } from 'features/controlLayers/contexts/EntityAdapterContext';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCropBold } from 'react-icons/pi';
@@ -10,7 +11,8 @@ export const CanvasEntityMenuItemsCropToBbox = memo(() => {
const { t } = useTranslation();
const entityIdentifier = useEntityIdentifierContext();
const adapter = useEntityAdapterSafe(entityIdentifier);
const isInteractable = useIsEntityInteractable(entityIdentifier);
const isBusy = useCanvasIsBusy();
const isLocked = useEntityIsLocked(entityIdentifier);
const onClick = useCallback(() => {
if (!adapter) {
return;
@@ -19,7 +21,7 @@ export const CanvasEntityMenuItemsCropToBbox = memo(() => {
}, [adapter]);
return (
<MenuItem onClick={onClick} icon={<PiCropBold />} isDisabled={!isInteractable}>
<MenuItem onClick={onClick} icon={<PiCropBold />} isDisabled={isBusy || isLocked}>
{t('controlLayers.cropLayerToBbox')}
</MenuItem>
);

View File

@@ -2,7 +2,7 @@ import { MenuItem } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { IconMenuItem } from 'common/components/IconMenuItem';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { entityDeleted } from 'features/controlLayers/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -16,7 +16,7 @@ export const CanvasEntityMenuItemsDelete = memo(({ asIcon = false }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext();
const isInteractable = useIsEntityInteractable(entityIdentifier);
const isBusy = useCanvasIsBusy();
const deleteEntity = useCallback(() => {
dispatch(entityDeleted({ entityIdentifier }));
@@ -30,13 +30,13 @@ export const CanvasEntityMenuItemsDelete = memo(({ asIcon = false }: Props) => {
onClick={deleteEntity}
icon={<PiTrashSimpleBold />}
isDestructive
isDisabled={!isInteractable}
isDisabled={isBusy}
/>
);
}
return (
<MenuItem onClick={deleteEntity} icon={<PiTrashSimpleBold />} isDestructive isDisabled={!isInteractable}>
<MenuItem onClick={deleteEntity} icon={<PiTrashSimpleBold />} isDestructive isDisabled={isBusy}>
{t('common.delete')}
</MenuItem>
);

View File

@@ -1,7 +1,7 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { IconMenuItem } from 'common/components/IconMenuItem';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { entityDuplicated } from 'features/controlLayers/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -11,7 +11,7 @@ export const CanvasEntityMenuItemsDuplicate = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext();
const isInteractable = useIsEntityInteractable(entityIdentifier);
const isBusy = useCanvasIsBusy();
const onClick = useCallback(() => {
dispatch(entityDuplicated({ entityIdentifier }));
@@ -23,7 +23,7 @@ export const CanvasEntityMenuItemsDuplicate = memo(() => {
tooltip={t('controlLayers.duplicate')}
onClick={onClick}
icon={<PiCopyFill />}
isDisabled={!isInteractable}
isDisabled={isBusy}
/>
);
});

View File

@@ -1,7 +1,7 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useEntityAdapterSafe } from 'features/controlLayers/contexts/EntityAdapterContext';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useIsEntityInteractable } from 'features/controlLayers/hooks/useEntityIsInteractable';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useSaveLayerToAssets } from 'features/controlLayers/hooks/useSaveLayerToAssets';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -11,14 +11,14 @@ export const CanvasEntityMenuItemsSave = memo(() => {
const { t } = useTranslation();
const entityIdentifier = useEntityIdentifierContext();
const adapter = useEntityAdapterSafe(entityIdentifier);
const isInteractable = useIsEntityInteractable(entityIdentifier);
const isBusy = useCanvasIsBusy();
const saveLayerToAssets = useSaveLayerToAssets();
const onClick = useCallback(() => {
saveLayerToAssets(adapter);
}, [saveLayerToAssets, adapter]);
return (
<MenuItem onClick={onClick} icon={<PiFloppyDiskBold />} isDisabled={!isInteractable}>
<MenuItem onClick={onClick} icon={<PiFloppyDiskBold />} isDisabled={isBusy}>
{t('controlLayers.saveLayerToAssets')}
</MenuItem>
);

View File

@@ -1,9 +1,8 @@
import { useStore } from '@nanostores/react';
import { $false } from 'app/store/nanostores/util';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { useEntityAdapterSafe } from 'features/controlLayers/contexts/EntityAdapterContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked';
import { entityReset } from 'features/controlLayers/store/canvasSlice';
import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { isMaskEntityIdentifier } from 'features/controlLayers/store/types';
@@ -14,30 +13,30 @@ import { useCallback, useMemo } from 'react';
export function useCanvasResetLayerHotkey() {
useAssertSingleton(useCanvasResetLayerHotkey.name);
const dispatch = useAppDispatch();
const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
const entityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
const isBusy = useCanvasIsBusy();
const adapter = useEntityAdapterSafe(selectedEntityIdentifier);
const isInteractable = useStore(adapter?.$isInteractable ?? $false);
const adapter = useEntityAdapterSafe(entityIdentifier);
const isLocked = useEntityIsLocked(entityIdentifier);
const imageViewer = useImageViewer();
const resetSelectedLayer = useCallback(() => {
if (selectedEntityIdentifier === null || adapter === null) {
if (entityIdentifier === null || adapter === null) {
return;
}
adapter.bufferRenderer.clearBuffer();
dispatch(entityReset({ entityIdentifier: selectedEntityIdentifier }));
}, [adapter, dispatch, selectedEntityIdentifier]);
dispatch(entityReset({ entityIdentifier }));
}, [adapter, dispatch, entityIdentifier]);
const isResetEnabled = useMemo(
() => selectedEntityIdentifier !== null && isMaskEntityIdentifier(selectedEntityIdentifier),
[selectedEntityIdentifier]
const isResetAllowed = useMemo(
() => entityIdentifier !== null && isMaskEntityIdentifier(entityIdentifier),
[entityIdentifier]
);
useRegisteredHotkeys({
id: 'resetSelected',
category: 'canvas',
callback: resetSelectedLayer,
options: { enabled: isResetEnabled && !isBusy && isInteractable && !imageViewer.isOpen },
dependencies: [isResetEnabled, isBusy, isInteractable, resetSelectedLayer, imageViewer.isOpen],
options: { enabled: isResetAllowed && !isBusy && !isLocked && !imageViewer.isOpen },
dependencies: [isResetAllowed, isBusy, isLocked, resetSelectedLayer, imageViewer.isOpen],
});
}

View File

@@ -1,8 +1,8 @@
import { useStore } from '@nanostores/react';
import { $false } from 'app/store/nanostores/util';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useEntityAdapterSafe } from 'features/controlLayers/contexts/EntityAdapterContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useEntityIsEmpty } from 'features/controlLayers/hooks/useEntityIsEmpty';
import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { isFilterableEntityIdentifier } from 'features/controlLayers/store/types';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
@@ -13,8 +13,8 @@ export const useEntityFilter = (entityIdentifier: CanvasEntityIdentifier | null)
const adapter = useEntityAdapterSafe(entityIdentifier);
const imageViewer = useImageViewer();
const isBusy = useCanvasIsBusy();
const isInteractable = useStore(adapter?.$isInteractable ?? $false);
const isEmpty = useStore(adapter?.$isEmpty ?? $false);
const isLocked = useEntityIsLocked(entityIdentifier);
const isEmpty = useEntityIsEmpty(entityIdentifier);
const isDisabled = useMemo(() => {
if (!entityIdentifier) {
@@ -29,14 +29,14 @@ export const useEntityFilter = (entityIdentifier: CanvasEntityIdentifier | null)
if (isBusy) {
return true;
}
if (!isInteractable) {
if (isLocked) {
return true;
}
if (isEmpty) {
return true;
}
return false;
}, [entityIdentifier, adapter, isBusy, isInteractable, isEmpty]);
}, [entityIdentifier, adapter, isBusy, isLocked, isEmpty]);
const start = useCallback(() => {
if (isDisabled) {

View File

@@ -3,8 +3,11 @@ import { buildSelectHasObjects } from 'features/controlLayers/store/selectors';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { useMemo } from 'react';
export const useEntityIsEmpty = (entityIdentifier: CanvasEntityIdentifier) => {
const selectHasObjects = useMemo(() => buildSelectHasObjects(entityIdentifier), [entityIdentifier]);
export const useEntityIsEmpty = (entityIdentifier: CanvasEntityIdentifier | null) => {
const selectHasObjects = useMemo(
() => (entityIdentifier ? buildSelectHasObjects(entityIdentifier) : () => false),
[entityIdentifier]
);
const hasObjects = useAppSelector(selectHasObjects);
return !hasObjects;

View File

@@ -1,13 +0,0 @@
import { useStore } from '@nanostores/react';
import { $true } from 'app/store/nanostores/util';
import { useEntityAdapterSafe } from 'features/controlLayers/contexts/EntityAdapterContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
export const useIsEntityInteractable = (entityIdentifier: CanvasEntityIdentifier) => {
const isBusy = useCanvasIsBusy();
const adapter = useEntityAdapterSafe(entityIdentifier);
const isInteractable = useStore(adapter?.$isInteractable ?? $true);
return !isBusy && isInteractable;
};

View File

@@ -4,10 +4,13 @@ import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/se
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { useMemo } from 'react';
export const useEntityIsLocked = (entityIdentifier: CanvasEntityIdentifier) => {
export const useEntityIsLocked = (entityIdentifier: CanvasEntityIdentifier | null) => {
const selectIsLocked = useMemo(
() =>
createSelector(selectCanvasSlice, (canvas) => {
if (!entityIdentifier) {
return false;
}
const entity = selectEntity(canvas, entityIdentifier);
if (!entity) {
return false;

View File

@@ -1,8 +1,8 @@
import { useStore } from '@nanostores/react';
import { $false } from 'app/store/nanostores/util';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useEntityAdapterSafe } from 'features/controlLayers/contexts/EntityAdapterContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useEntityIsEmpty } from 'features/controlLayers/hooks/useEntityIsEmpty';
import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { isSegmentableEntityIdentifier } from 'features/controlLayers/store/types';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
@@ -13,8 +13,8 @@ export const useEntitySegmentAnything = (entityIdentifier: CanvasEntityIdentifie
const adapter = useEntityAdapterSafe(entityIdentifier);
const imageViewer = useImageViewer();
const isBusy = useCanvasIsBusy();
const isInteractable = useStore(adapter?.$isInteractable ?? $false);
const isEmpty = useStore(adapter?.$isEmpty ?? $false);
const isLocked = useEntityIsLocked(entityIdentifier);
const isEmpty = useEntityIsEmpty(entityIdentifier);
const isDisabled = useMemo(() => {
if (!entityIdentifier) {
@@ -29,14 +29,14 @@ export const useEntitySegmentAnything = (entityIdentifier: CanvasEntityIdentifie
if (isBusy) {
return true;
}
if (!isInteractable) {
if (isLocked) {
return true;
}
if (isEmpty) {
return true;
}
return false;
}, [entityIdentifier, adapter, isBusy, isInteractable, isEmpty]);
}, [entityIdentifier, adapter, isBusy, isLocked, isEmpty]);
const start = useCallback(() => {
if (isDisabled) {

View File

@@ -1,8 +1,8 @@
import { useStore } from '@nanostores/react';
import { $false } from 'app/store/nanostores/util';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useEntityAdapterSafe } from 'features/controlLayers/contexts/EntityAdapterContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useEntityIsEmpty } from 'features/controlLayers/hooks/useEntityIsEmpty';
import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { isTransformableEntityIdentifier } from 'features/controlLayers/store/types';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
@@ -13,8 +13,8 @@ export const useEntityTransform = (entityIdentifier: CanvasEntityIdentifier | nu
const adapter = useEntityAdapterSafe(entityIdentifier);
const imageViewer = useImageViewer();
const isBusy = useCanvasIsBusy();
const isInteractable = useStore(adapter?.$isInteractable ?? $false);
const isEmpty = useStore(adapter?.$isEmpty ?? $false);
const isLocked = useEntityIsLocked(entityIdentifier);
const isEmpty = useEntityIsEmpty(entityIdentifier);
const isDisabled = useMemo(() => {
if (!entityIdentifier) {
@@ -29,14 +29,14 @@ export const useEntityTransform = (entityIdentifier: CanvasEntityIdentifier | nu
if (isBusy) {
return true;
}
if (!isInteractable) {
if (isLocked) {
return true;
}
if (isEmpty) {
return true;
}
return false;
}, [entityIdentifier, adapter, isBusy, isInteractable, isEmpty]);
}, [entityIdentifier, adapter, isBusy, isLocked, isEmpty]);
const start = useCallback(async () => {
if (isDisabled) {

View File

@@ -12,17 +12,26 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import type { CanvasSegmentAnythingModule } from 'features/controlLayers/konva/CanvasSegmentAnythingModule';
import { getKonvaNodeDebugAttrs, getRectIntersection } from 'features/controlLayers/konva/util';
import { selectIsolatedLayerPreview } from 'features/controlLayers/store/canvasSettingsSlice';
import {
buildSelectIsHidden,
selectIsolatedLayerPreview,
selectIsolatedStagingPreview,
} from 'features/controlLayers/store/canvasSettingsSlice';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import {
buildSelectIsSelected,
getSelectIsTypeHidden,
selectBboxRect,
selectCanvasSlice,
selectEntity,
} from 'features/controlLayers/store/selectors';
import type { CanvasEntityIdentifier, CanvasRenderableEntityState, Rect } from 'features/controlLayers/store/types';
import {
type CanvasEntityIdentifier,
type CanvasRenderableEntityState,
isRasterLayerEntityIdentifier,
type Rect,
} from 'features/controlLayers/store/types';
import Konva from 'konva';
import { atom, computed } from 'nanostores';
import { atom } from 'nanostores';
import rafThrottle from 'raf-throttle';
import type { Logger } from 'roarr';
import type { ImageDTO } from 'services/api/types';
@@ -175,7 +184,14 @@ export abstract class CanvasEntityAdapterBase<
}
};
selectIsHidden: Selector<RootState, boolean>;
/**
* A selector that selects whether the entity type is hidden.
*/
selectIsTypeHidden: Selector<RootState, boolean>;
/**
* A selector that selects whether the entity is selected.
*/
selectIsSelected: Selector<RootState, boolean>;
/**
@@ -209,17 +225,11 @@ export abstract class CanvasEntityAdapterBase<
/**
* Whether this entity is hidden. This is synced with the entity's group type visibility.
*/
$isHidden = atom(false);
$isEntityTypeHidden = atom(false);
/**
* Whether this entity is empty. This is computed based on the entity's objects.
*/
$isEmpty = atom(true);
/**
* Whether this entity is interactable. This is computed based on the entity's locked, disabled, and hidden states.
*/
$isInteractable = computed([this.$isLocked, this.$isDisabled, this.$isHidden], (isLocked, isDisabled, isHidden) => {
return !isLocked && !isDisabled && !isHidden;
});
/**
* A cache of the entity's canvas element. This is generated from a clone of the entity's Konva layer.
*/
@@ -260,19 +270,18 @@ export abstract class CanvasEntityAdapterBase<
assert(state !== undefined, 'Missing entity state on creation');
this.state = state;
this.selectIsHidden = buildSelectIsHidden(this.entityIdentifier);
this.selectIsTypeHidden = getSelectIsTypeHidden(this.entityIdentifier.type);
this.selectIsSelected = buildSelectIsSelected(this.entityIdentifier);
/**
* There are a number of reason we may need to show or hide a layer:
* - The entity is enabled/disabled
* - The entity type is hidden/shown
* - Staging status changes and `isolatedStagingPreview` is enabled
* - Global filtering status changes and `isolatedFilteringPreview` is enabled
* - Global transforming status changes and `isolatedTransformingPreview` is enabled
* - The entity is selected or deselected (only selected and onscreen entities are rendered)
* - `isolatedStagingPreview` is enabled and we start or stop staging
* - `isolatedLayerPreview` is enabled and we start or stop filtering, transforming, select-object-ing
* - The entity is selected or deselected (only selected and onscreen entities are rendered as a perf optimization)
*/
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(this.selectIsHidden, this.syncVisibility));
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(this.selectIsTypeHidden, this.syncVisibility));
this.subscriptions.add(
this.manager.stateApi.createStoreSubscription(selectIsolatedLayerPreview, this.syncVisibility)
);
@@ -285,7 +294,9 @@ export abstract class CanvasEntityAdapterBase<
* The tool preview may need to be updated when the entity is locked or disabled. For example, when we disable the
* entity, we should hide the tool preview & change the cursor.
*/
this.subscriptions.add(this.$isInteractable.subscribe(this.manager.tool.render));
this.subscriptions.add(this.$isDisabled.subscribe(this.manager.tool.render));
this.subscriptions.add(this.$isLocked.subscribe(this.manager.tool.render));
this.subscriptions.add(this.$isEntityTypeHidden.subscribe(this.manager.tool.render));
/**
* When the stage is transformed in any way (panning, zooming, resizing) or the entity is moved, we need to update
@@ -404,10 +415,9 @@ export abstract class CanvasEntityAdapterBase<
*/
syncIsEnabled = () => {
this.log.trace('Updating visibility');
this.konva.layer.visible(this.state.isEnabled);
this.renderer.syncKonvaCache(this.state.isEnabled);
this.transformer.syncInteractionState();
this.$isDisabled.set(!this.state.isEnabled);
this.syncVisibility();
this.transformer.syncInteractionState();
};
/**
@@ -419,6 +429,7 @@ export abstract class CanvasEntityAdapterBase<
if (didRender) {
// If the objects have changed, we need to recalculate the transformer's bounding box.
this.transformer.requestRectCalculation();
this.transformer.syncInteractionState();
}
};
@@ -437,45 +448,70 @@ export abstract class CanvasEntityAdapterBase<
};
syncVisibility = rafThrottle(() => {
// Handle the base hidden state
if (this.manager.stateApi.runSelector(this.selectIsHidden)) {
/**
* If the entity type is hidden, so should the entity be hidden.
*/
if (this.manager.stateApi.runSelector(this.selectIsTypeHidden)) {
this.setVisibility(false);
return;
}
const isolatedLayerPreview = this.manager.stateApi.runSelector(selectIsolatedLayerPreview);
// Handle isolated preview modes - if another entity is filtering or transforming, we may need to hide this entity.
if (isolatedLayerPreview) {
const filteringEntityIdentifier = this.manager.stateApi.$filteringAdapter.get()?.entityIdentifier;
if (filteringEntityIdentifier && filteringEntityIdentifier.id !== this.id) {
if (this.manager.stateApi.runSelector(selectIsolatedStagingPreview)) {
/**
* When staging w/ isolatedStagingPreview enabled, we only show raster layers.
*
* This allows the user to easily see how the new generation fits in with the rest of the canvas without the
* other layer types getting in the way.
*/
const isStaging = this.manager.stateApi.runSelector(selectIsStaging);
const isRasterLayer = isRasterLayerEntityIdentifier(this.entityIdentifier);
if (isStaging && !isRasterLayer) {
this.setVisibility(false);
return;
}
}
if (isolatedLayerPreview) {
const transformingEntity = this.manager.stateApi.$transformingAdapter.get();
if (this.manager.stateApi.runSelector(selectIsolatedLayerPreview)) {
/**
* Handle isolated preview modes - if another entity is filtering, transforming, or select-object-ing, we may need
* to hide this entity.
*/
const filteringAdapter = this.manager.stateApi.$filteringAdapter.get();
if (filteringAdapter && filteringAdapter !== this) {
this.setVisibility(false);
return;
}
const transformingAdapter = this.manager.stateApi.$transformingAdapter.get();
if (
transformingEntity &&
transformingEntity.entityIdentifier.id !== this.id &&
transformingAdapter &&
transformingAdapter !== this &&
// Silent transforms should be transparent to the user, so we don't need to hide the entity.
!transformingEntity.transformer.$silentTransform.get()
!transformingAdapter.transformer.$silentTransform.get()
) {
this.setVisibility(false);
return;
}
}
if (isolatedLayerPreview) {
const segmentingEntity = this.manager.stateApi.$segmentingAdapter.get();
if (segmentingEntity && segmentingEntity.entityIdentifier.id !== this.id) {
const segmentingAdapter = this.manager.stateApi.$segmentingAdapter.get();
if (segmentingAdapter && segmentingAdapter !== this) {
this.setVisibility(false);
return;
}
}
// If the entity is not selected and offscreen, we can hide it
/**
* Disabled entities should be hidden.
*/
if (this.$isDisabled.get()) {
this.setVisibility(false);
return;
}
/**
* When the entity is offscreen and not selected, we should hide it. If it is selected and offscreen, it still needs
* to be visible so the user can interact with it.
*/
if (!this.$isOnScreen.get() && !this.manager.stateApi.getIsSelected(this.entityIdentifier.id)) {
this.setVisibility(false);
return;
@@ -485,17 +521,14 @@ export abstract class CanvasEntityAdapterBase<
});
setVisibility = (isVisible: boolean) => {
const isHidden = this.$isHidden.get();
const isLayerVisible = this.konva.layer.visible();
if (isHidden === !isVisible && isLayerVisible === isVisible) {
if (isLayerVisible === isVisible) {
// No change
return;
}
this.log.trace(isVisible ? 'Showing' : 'Hiding');
this.$isHidden.set(!isVisible);
this.konva.layer.visible(isVisible);
this.renderer.syncKonvaCache();
};
@@ -505,8 +538,8 @@ export abstract class CanvasEntityAdapterBase<
syncIsLocked = () => {
// The only thing we need to do is update the transformer's interaction state. For tool interactions, like drawing
// shapes, we defer to the CanvasToolModule to handle the locked state.
this.transformer.syncInteractionState();
this.$isLocked.set(this.state.isLocked);
this.transformer.syncInteractionState();
};
/**
@@ -566,9 +599,8 @@ export abstract class CanvasEntityAdapterBase<
hasCache: this.$canvasCache.get() !== null,
isLocked: this.$isLocked.get(),
isDisabled: this.$isDisabled.get(),
isHidden: this.$isHidden.get(),
isEntityTypeHidden: this.$isEntityTypeHidden.get(),
isEmpty: this.$isEmpty.get(),
isInteractable: this.$isInteractable.get(),
isOnScreen: this.$isOnScreen.get(),
intersectsBbox: this.$intersectsBbox.get(),
konva: getKonvaNodeDebugAttrs(this.konva.layer),

View File

@@ -219,6 +219,10 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
return;
}
if (!this.parent.konva.layer.visible()) {
return;
}
this.log.trace('Updating compositing rect fill');
assert(this.konva.compositing, 'Missing compositing rect');
@@ -244,6 +248,10 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
return;
}
if (!this.parent.konva.layer.visible()) {
return;
}
this.log.trace('Updating compositing rect size');
assert(this.konva.compositing, 'Missing compositing rect');
@@ -262,6 +270,10 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
return;
}
if (!this.parent.konva.layer.visible()) {
return;
}
this.log.trace('Updating compositing rect position');
assert(this.konva.compositing, 'Missing compositing rect');
@@ -272,6 +284,10 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
};
updateOpacity = throttle(() => {
if (!this.parent.konva.layer.visible()) {
return;
}
this.log.trace('Updating opacity');
const opacity = this.parent.state.opacity;

View File

@@ -640,6 +640,13 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
return;
}
if (this.parent.$isLocked.get()) {
// The layer is locked, it should not be interactable
this.parent.konva.layer.listening(false);
this._setInteractionMode('off');
return;
}
if (!this.$isTransforming.get() && tool === 'move') {
// We are moving this layer, it must be listening
this.parent.konva.layer.listening(true);

View File

@@ -36,7 +36,6 @@ import {
selectGridSize,
} from 'features/controlLayers/store/selectors';
import type {
CanvasEntityType,
CanvasState,
EntityBrushLineAddedPayload,
EntityEraserLineAddedPayload,
@@ -546,24 +545,6 @@ export class CanvasStateApiModule extends CanvasModuleBase {
return this.getCanvasState().selectedEntityIdentifier?.id === id;
};
/**
* Checks if an entity type is hidden. Individual entities are not hidden; the entire entity type is hidden.
*/
getIsTypeHidden = (type: CanvasEntityType): boolean => {
switch (type) {
case 'raster_layer':
return this.getRasterLayersState().isHidden;
case 'control_layer':
return this.getControlLayersState().isHidden;
case 'inpaint_mask':
return this.getInpaintMasksState().isHidden;
case 'regional_guidance':
return this.getRegionsState().isHidden;
default:
assert(false, 'Unhandled entity type');
}
};
/**
* Gets the number of entities that are currently rendered on the canvas.
*/

View File

@@ -161,6 +161,7 @@ export class CanvasToolModule extends CanvasModuleBase {
const tool = this.$tool.get();
const segmentingAdapter = this.manager.stateApi.$segmentingAdapter.get();
const transformingAdapter = this.manager.stateApi.$transformingAdapter.get();
const selectedEntityAdapter = this.manager.stateApi.getSelectedEntityAdapter();
if (this.manager.stage.getIsDragging()) {
this.tools.view.syncCursorStyle();
@@ -178,7 +179,11 @@ export class CanvasToolModule extends CanvasModuleBase {
this.tools.bbox.syncCursorStyle();
} else if (this.manager.stateApi.getRenderedEntityCount() === 0) {
stage.setCursor('not-allowed');
} else if (!this.manager.stateApi.getSelectedEntityAdapter()?.$isInteractable.get()) {
} else if (selectedEntityAdapter?.$isDisabled.get()) {
stage.setCursor('not-allowed');
} else if (selectedEntityAdapter?.$isEntityTypeHidden.get()) {
stage.setCursor('not-allowed');
} else if (selectedEntityAdapter?.$isLocked.get()) {
stage.setCursor('not-allowed');
} else if (tool === 'brush') {
this.tools.brush.syncCursorStyle();
@@ -301,7 +306,15 @@ export class CanvasToolModule extends CanvasModuleBase {
return false;
}
if (!selectedEntity.$isInteractable.get()) {
if (selectedEntity.$isDisabled.get()) {
return false;
}
if (selectedEntity.$isEntityTypeHidden.get()) {
return false;
}
if (selectedEntity.$isLocked.get()) {
return false;
}

View File

@@ -1,23 +1,21 @@
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from 'app/store/store';
import { selectIsolatedStagingPreview } from 'features/controlLayers/store/canvasSettingsSlice';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice';
import type {
CanvasControlLayerState,
CanvasEntityIdentifier,
CanvasEntityState,
CanvasEntityType,
CanvasInpaintMaskState,
CanvasMetadata,
CanvasRasterLayerState,
CanvasRegionalGuidanceState,
CanvasRenderableEntityIdentifier,
CanvasRenderableEntityState,
CanvasRenderableEntityType,
CanvasState,
} from 'features/controlLayers/store/types';
import { isRasterLayerEntityIdentifier } from 'features/controlLayers/store/types';
import { getGridSize, getOptimalDimension } from 'features/parameters/util/optimalDimension';
import type { Equals } from 'tsafe';
import { assert } from 'tsafe';
/**
@@ -325,15 +323,18 @@ export const selectSelectedEntityFill = createSelector(
}
);
const selectRasterLayersIsHidden = createSelector(selectCanvasSlice, (canvas) => canvas.rasterLayers.isHidden);
const selectControlLayersIsHidden = createSelector(selectCanvasSlice, (canvas) => canvas.controlLayers.isHidden);
const selectInpaintMasksIsHidden = createSelector(selectCanvasSlice, (canvas) => canvas.inpaintMasks.isHidden);
const selectRegionalGuidanceIsHidden = createSelector(selectCanvasSlice, (canvas) => canvas.regionalGuidance.isHidden);
export const selectRasterLayersIsHidden = createSelector(selectCanvasSlice, (canvas) => canvas.rasterLayers.isHidden);
export const selectControlLayersIsHidden = createSelector(selectCanvasSlice, (canvas) => canvas.controlLayers.isHidden);
export const selectInpaintMasksIsHidden = createSelector(selectCanvasSlice, (canvas) => canvas.inpaintMasks.isHidden);
export const selectRegionalGuidanceIsHidden = createSelector(
selectCanvasSlice,
(canvas) => canvas.regionalGuidance.isHidden
);
/**
* Returns the hidden selector for the given entity type.
*/
const getSelectIsTypeHidden = (type: CanvasEntityType) => {
export const getSelectIsTypeHidden = (type: CanvasRenderableEntityType) => {
switch (type) {
case 'raster_layer':
return selectRasterLayersIsHidden;
@@ -344,44 +345,10 @@ const getSelectIsTypeHidden = (type: CanvasEntityType) => {
case 'regional_guidance':
return selectRegionalGuidanceIsHidden;
default:
assert(false, 'Unhandled entity type');
assert<Equals<typeof type, never>>(false, 'Unhandled entity type');
}
};
/**
* Builds a selector taht selects if the entity is hidden.
*/
export const buildSelectIsHidden = (entityIdentifier: CanvasEntityIdentifier) => {
const selectIsTypeHidden = getSelectIsTypeHidden(entityIdentifier.type);
return createSelector(
[selectCanvasSlice, selectIsTypeHidden, selectIsStaging, selectIsolatedStagingPreview],
(canvas, isTypeHidden, isStaging, isolatedStagingPreview) => {
const entity = selectEntity(canvas, entityIdentifier);
// An entity is hidden if:
// - The entity type is hidden
// - The entity is disabled
// - The entity is not a raster layer and we are staging and the option to show only raster layers is enabled
if (!entity) {
return true;
}
if (isTypeHidden) {
return true;
}
if (!entity.isEnabled) {
return true;
}
if (isStaging && isolatedStagingPreview) {
// When staging, we only show raster layers. This allows the user to easily see how the new generation fits in
// with the rest of the canvas without the masks and control layers getting in the way.
return !isRasterLayerEntityIdentifier(entityIdentifier);
}
return false;
}
);
};
/**
* Builds a selector taht selects if the entity is selected.
*/