feat(ui): split up tool chooser buttons

Prep for distinct toolbars for generation vs canvas modes
This commit is contained in:
psychedelicious 2024-07-08 17:08:59 +10:00
parent 77acc7baed
commit e4376e21dd
10 changed files with 344 additions and 192 deletions

View File

@ -0,0 +1,33 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiBoundingBoxBold } from 'react-icons/pi';
export const BboxToolButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isDisabled = useAppSelector((s) => s.canvasV2.session.isStaging);
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'bbox');
const onClick = useCallback(() => {
dispatch(toolChanged('bbox'));
}, [dispatch]);
useHotkeys('q', onClick, [onClick]);
return (
<IconButton
aria-label={`${t('controlLayers.bbox')} (Q)`}
tooltip={`${t('controlLayers.bbox')} (Q)`}
icon={<PiBoundingBoxBold />}
variant={isSelected ? 'solid' : 'outline'}
onClick={onClick}
isDisabled={isDisabled}
/>
);
});
BboxToolButton.displayName = 'BboxToolButton';

View File

@ -0,0 +1,39 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice';
import { isDrawableEntityType } from 'features/controlLayers/store/types';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiPaintBrushBold } from 'react-icons/pi';
export const BrushToolButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'brush');
const isDisabled = useAppSelector((s) => {
const entityType = s.canvasV2.selectedEntityIdentifier?.type;
const isDrawingToolAllowed = entityType ? isDrawableEntityType(entityType) : false;
const isStaging = s.canvasV2.session.isStaging;
return !isDrawingToolAllowed || isStaging;
});
const onClick = useCallback(() => {
dispatch(toolChanged('brush'));
}, [dispatch]);
useHotkeys('b', onClick, { enabled: !isDisabled }, [isDisabled, onClick]);
return (
<IconButton
aria-label={`${t('unifiedCanvas.brush')} (B)`}
tooltip={`${t('unifiedCanvas.brush')} (B)`}
icon={<PiPaintBrushBold />}
variant={isSelected ? 'solid' : 'outline'}
onClick={onClick}
isDisabled={isDisabled}
/>
);
});
BrushToolButton.displayName = 'BrushToolButton';

View File

@ -0,0 +1,39 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice';
import { isDrawableEntityType } from 'features/controlLayers/store/types';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiEraserBold } from 'react-icons/pi';
export const EraserToolButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'eraser');
const isDisabled = useAppSelector((s) => {
const entityType = s.canvasV2.selectedEntityIdentifier?.type;
const isDrawingToolAllowed = entityType ? isDrawableEntityType(entityType) : false;
const isStaging = s.canvasV2.session.isStaging;
return !isDrawingToolAllowed || isStaging;
});
const onClick = useCallback(() => {
dispatch(toolChanged('eraser'));
}, [dispatch]);
useHotkeys('e', onClick, { enabled: !isDisabled }, [isDisabled, onClick]);
return (
<IconButton
aria-label={`${t('unifiedCanvas.eraser')} (E)`}
tooltip={`${t('unifiedCanvas.eraser')} (E)`}
icon={<PiEraserBold />}
variant={isSelected ? 'solid' : 'outline'}
onClick={onClick}
isDisabled={isDisabled}
/>
);
});
EraserToolButton.displayName = 'EraserToolButton';

View File

@ -0,0 +1,35 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiCursorBold } from 'react-icons/pi';
export const MoveToolButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'move');
const isDisabled = useAppSelector(
(s) => s.canvasV2.selectedEntityIdentifier === null || s.canvasV2.session.isStaging
);
const onClick = useCallback(() => {
dispatch(toolChanged('move'));
}, [dispatch]);
useHotkeys('v', onClick, { enabled: !isDisabled }, [isDisabled, onClick]);
return (
<IconButton
aria-label={`${t('unifiedCanvas.move')} (V)`}
tooltip={`${t('unifiedCanvas.move')} (V)`}
icon={<PiCursorBold />}
variant={isSelected ? 'solid' : 'outline'}
onClick={onClick}
isDisabled={isDisabled}
/>
);
});
MoveToolButton.displayName = 'MoveToolButton';

View File

@ -0,0 +1,39 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice';
import { isDrawableEntityType } from 'features/controlLayers/store/types';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiRectangleBold } from 'react-icons/pi';
export const RectToolButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'rect');
const isDisabled = useAppSelector((s) => {
const entityType = s.canvasV2.selectedEntityIdentifier?.type;
const isDrawingToolAllowed = entityType ? isDrawableEntityType(entityType) : false;
const isStaging = s.canvasV2.session.isStaging;
return !isDrawingToolAllowed || isStaging;
});
const onClick = useCallback(() => {
dispatch(toolChanged('rect'));
}, [dispatch]);
useHotkeys('u', onClick, { enabled: !isDisabled }, [isDisabled, onClick]);
return (
<IconButton
aria-label={`${t('controlLayers.rectangle')} (U)`}
tooltip={`${t('controlLayers.rectangle')} (U)`}
icon={<PiRectangleBold />}
variant={isSelected ? 'solid' : 'outline'}
onClick={onClick}
isDisabled={isDisabled}
/>
);
});
RectToolButton.displayName = 'RectToolButton';

View File

@ -1,199 +1,25 @@
import { ButtonGroup, IconButton } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
caDeleted,
imReset,
ipaDeleted,
layerDeleted,
layerReset,
rgDeleted,
rgReset,
selectCanvasV2Slice,
toolChanged,
} from 'features/controlLayers/store/canvasV2Slice';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { useCallback, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import {
PiBoundingBoxBold,
PiCursorBold,
PiEraserBold,
PiHandBold,
PiPaintBrushBold,
PiRectangleBold,
} from 'react-icons/pi';
const DRAWING_TOOL_TYPES = ['layer', 'regional_guidance', 'inpaint_mask'];
const getIsDrawingToolEnabled = (entityIdentifier: CanvasEntityIdentifier | null) => {
if (!entityIdentifier) {
return false;
}
return DRAWING_TOOL_TYPES.includes(entityIdentifier.type);
};
const selectSelectedEntityIdentifier = createMemoizedSelector(
selectCanvasV2Slice,
(canvasV2State) => canvasV2State.selectedEntityIdentifier
);
import { ButtonGroup } from '@invoke-ai/ui-library';
import { BboxToolButton } from 'features/controlLayers/components/BboxToolButton';
import { BrushToolButton } from 'features/controlLayers/components/BrushToolButton';
import { EraserToolButton } from 'features/controlLayers/components/EraserToolButton';
import { MoveToolButton } from 'features/controlLayers/components/MoveToolButton';
import { RectToolButton } from 'features/controlLayers/components/RectToolButton';
import { ViewToolButton } from 'features/controlLayers/components/ViewToolButton';
import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanvasDeleteLayerHotkey';
import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey';
export const ToolChooser: React.FC = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging);
const isDrawingToolDisabled = useMemo(
() => !getIsDrawingToolEnabled(selectedEntityIdentifier),
[selectedEntityIdentifier]
);
const isMoveToolDisabled = useMemo(() => selectedEntityIdentifier === null, [selectedEntityIdentifier]);
const tool = useAppSelector((s) => s.canvasV2.tool.selected);
const setToolToBrush = useCallback(() => {
dispatch(toolChanged('brush'));
}, [dispatch]);
useHotkeys('b', setToolToBrush, { enabled: !isDrawingToolDisabled && !isStaging }, [
isDrawingToolDisabled,
isStaging,
setToolToBrush,
]);
const setToolToEraser = useCallback(() => {
dispatch(toolChanged('eraser'));
}, [dispatch]);
useHotkeys('e', setToolToEraser, { enabled: !isDrawingToolDisabled && !isStaging }, [
isDrawingToolDisabled,
isStaging,
setToolToEraser,
]);
const setToolToRect = useCallback(() => {
dispatch(toolChanged('rect'));
}, [dispatch]);
useHotkeys('u', setToolToRect, { enabled: !isDrawingToolDisabled && !isStaging }, [
isDrawingToolDisabled,
isStaging,
setToolToRect,
]);
const setToolToMove = useCallback(() => {
dispatch(toolChanged('move'));
}, [dispatch]);
useHotkeys('v', setToolToMove, { enabled: !isMoveToolDisabled && !isStaging }, [
isMoveToolDisabled,
isStaging,
setToolToMove,
]);
const setToolToView = useCallback(() => {
dispatch(toolChanged('view'));
}, [dispatch]);
useHotkeys('h', setToolToView, [setToolToView]);
const setToolToBbox = useCallback(() => {
dispatch(toolChanged('bbox'));
}, [dispatch]);
useHotkeys('q', setToolToBbox, [setToolToBbox]);
const resetSelectedLayer = useCallback(() => {
if (selectedEntityIdentifier === null) {
return;
}
const { type, id } = selectedEntityIdentifier;
if (type === 'layer') {
dispatch(layerReset({ id }));
}
if (type === 'regional_guidance') {
dispatch(rgReset({ id }));
}
if (type === 'inpaint_mask') {
dispatch(imReset());
}
}, [dispatch, selectedEntityIdentifier]);
const isResetEnabled = useMemo(
() =>
(!isStaging && selectedEntityIdentifier?.type === 'layer') ||
selectedEntityIdentifier?.type === 'regional_guidance' ||
selectedEntityIdentifier?.type === 'inpaint_mask',
[isStaging, selectedEntityIdentifier?.type]
);
useHotkeys('shift+c', resetSelectedLayer, { enabled: isResetEnabled }, [
isResetEnabled,
isStaging,
resetSelectedLayer,
]);
const deleteSelectedLayer = useCallback(() => {
if (selectedEntityIdentifier === null) {
return;
}
const { type, id } = selectedEntityIdentifier;
if (type === 'layer') {
dispatch(layerDeleted({ id }));
}
if (type === 'regional_guidance') {
dispatch(rgDeleted({ id }));
}
if (type === 'control_adapter') {
dispatch(caDeleted({ id }));
}
if (type === 'ip_adapter') {
dispatch(ipaDeleted({ id }));
}
}, [dispatch, selectedEntityIdentifier]);
const isDeleteEnabled = useMemo(
() => selectedEntityIdentifier !== null && !isStaging,
[selectedEntityIdentifier, isStaging]
);
useHotkeys('shift+d', deleteSelectedLayer, { enabled: isDeleteEnabled }, [isDeleteEnabled, deleteSelectedLayer]);
useCanvasResetLayerHotkey();
useCanvasDeleteLayerHotkey();
return (
<ButtonGroup isAttached>
<IconButton
aria-label={`${t('unifiedCanvas.brush')} (B)`}
tooltip={`${t('unifiedCanvas.brush')} (B)`}
icon={<PiPaintBrushBold />}
variant={tool === 'brush' ? 'solid' : 'outline'}
onClick={setToolToBrush}
isDisabled={isDrawingToolDisabled || isStaging}
/>
<IconButton
aria-label={`${t('unifiedCanvas.eraser')} (E)`}
tooltip={`${t('unifiedCanvas.eraser')} (E)`}
icon={<PiEraserBold />}
variant={tool === 'eraser' ? 'solid' : 'outline'}
onClick={setToolToEraser}
isDisabled={isDrawingToolDisabled || isStaging}
/>
<IconButton
aria-label={`${t('controlLayers.rectangle')} (U)`}
tooltip={`${t('controlLayers.rectangle')} (U)`}
icon={<PiRectangleBold />}
variant={tool === 'rect' ? 'solid' : 'outline'}
onClick={setToolToRect}
isDisabled={isDrawingToolDisabled || isStaging}
/>
<IconButton
aria-label={`${t('unifiedCanvas.move')} (V)`}
tooltip={`${t('unifiedCanvas.move')} (V)`}
icon={<PiCursorBold />}
variant={tool === 'move' ? 'solid' : 'outline'}
onClick={setToolToMove}
isDisabled={isMoveToolDisabled || isStaging}
/>
<IconButton
aria-label={`${t('unifiedCanvas.view')} (H)`}
tooltip={`${t('unifiedCanvas.view')} (H)`}
icon={<PiHandBold />}
variant={tool === 'view' ? 'solid' : 'outline'}
onClick={setToolToView}
isDisabled={isStaging}
/>
<IconButton
aria-label={`${t('controlLayers.bbox')} (Q)`}
tooltip={`${t('controlLayers.bbox')} (Q)`}
icon={<PiBoundingBoxBold />}
variant={tool === 'bbox' ? 'solid' : 'outline'}
onClick={setToolToBbox}
isDisabled={isStaging}
/>
<BrushToolButton />
<EraserToolButton />
<RectToolButton />
<MoveToolButton />
<ViewToolButton />
<BboxToolButton />
</ButtonGroup>
);
};

View File

@ -0,0 +1,32 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiHandBold } from 'react-icons/pi';
export const ViewToolButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'view');
const isDisabled = useAppSelector((s) => s.canvasV2.session.isStaging);
const onClick = useCallback(() => {
dispatch(toolChanged('view'));
}, [dispatch]);
useHotkeys('h', onClick, [onClick]);
return (
<IconButton
aria-label={`${t('unifiedCanvas.view')} (H)`}
tooltip={`${t('unifiedCanvas.view')} (H)`}
icon={<PiHandBold />}
variant={isSelected ? 'solid' : 'outline'}
onClick={onClick}
isDisabled={isDisabled}
/>
);
});
ViewToolButton.displayName = 'ViewToolButton';

View File

@ -0,0 +1,50 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import {
caDeleted,
ipaDeleted,
layerDeleted,
rgDeleted,
selectCanvasV2Slice,
} from 'features/controlLayers/store/canvasV2Slice';
import { useCallback, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
const selectSelectedEntityIdentifier = createMemoizedSelector(
selectCanvasV2Slice,
(canvasV2State) => canvasV2State.selectedEntityIdentifier
);
export function useCanvasDeleteLayerHotkey() {
useAssertSingleton(useCanvasDeleteLayerHotkey.name);
const dispatch = useAppDispatch();
const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging);
const deleteSelectedLayer = useCallback(() => {
if (selectedEntityIdentifier === null) {
return;
}
const { type, id } = selectedEntityIdentifier;
if (type === 'layer') {
dispatch(layerDeleted({ id }));
}
if (type === 'regional_guidance') {
dispatch(rgDeleted({ id }));
}
if (type === 'control_adapter') {
dispatch(caDeleted({ id }));
}
if (type === 'ip_adapter') {
dispatch(ipaDeleted({ id }));
}
}, [dispatch, selectedEntityIdentifier]);
const isDeleteEnabled = useMemo(
() => selectedEntityIdentifier !== null && !isStaging,
[selectedEntityIdentifier, isStaging]
);
useHotkeys('shift+d', deleteSelectedLayer, { enabled: isDeleteEnabled }, [isDeleteEnabled, deleteSelectedLayer]);
}

View File

@ -0,0 +1,53 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import {
imReset,
layerReset,
rgReset,
selectCanvasV2Slice,
} from 'features/controlLayers/store/canvasV2Slice';
import { useCallback, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
const selectSelectedEntityIdentifier = createMemoizedSelector(
selectCanvasV2Slice,
(canvasV2State) => canvasV2State.selectedEntityIdentifier
);
export function useCanvasResetLayerHotkey() {
useAssertSingleton(useCanvasResetLayerHotkey.name);
const dispatch = useAppDispatch();
const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging);
const resetSelectedLayer = useCallback(() => {
if (selectedEntityIdentifier === null) {
return;
}
const { type, id } = selectedEntityIdentifier;
if (type === 'layer') {
dispatch(layerReset({ id }));
}
if (type === 'regional_guidance') {
dispatch(rgReset({ id }));
}
if (type === 'inpaint_mask') {
dispatch(imReset());
}
}, [dispatch, selectedEntityIdentifier]);
const isResetEnabled = useMemo(
() =>
(!isStaging && selectedEntityIdentifier?.type === 'layer') ||
selectedEntityIdentifier?.type === 'regional_guidance' ||
selectedEntityIdentifier?.type === 'inpaint_mask',
[isStaging, selectedEntityIdentifier?.type]
);
useHotkeys('shift+c', resetSelectedLayer, { enabled: isResetEnabled }, [
isResetEnabled,
isStaging,
resetSelectedLayer,
]);
}

View File

@ -655,7 +655,7 @@ const zImageFill = z.object({
});
const zFill = z.discriminatedUnion('type', [zColorFill, zImageFill]);
const zInpaintMaskEntity = z.object({
id: zId,
id: z.literal('inpaint_mask'),
type: z.literal('inpaint_mask'),
isEnabled: z.boolean(),
x: z.number(),
@ -945,3 +945,9 @@ export function isDrawableEntityAdapter(
): adapter is CanvasLayer | CanvasRegion | CanvasInpaintMask {
return adapter instanceof CanvasLayer || adapter instanceof CanvasRegion || adapter instanceof CanvasInpaintMask;
}
export function isDrawableEntityType(
entityType: CanvasEntity['type']
): entityType is 'layer' | 'regional_guidance' | 'inpaint_mask' {
return entityType === 'layer' || entityType === 'regional_guidance' || entityType === 'inpaint_mask';
}