diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx index 26df1cf1d8..885d47d7ff 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx @@ -1,14 +1,12 @@ /* eslint-disable i18next/no-literal-string */ import { Flex, Spacer } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; import { CanvasModeSwitcher } from 'features/controlLayers/components/CanvasModeSwitcher'; import { CanvasResetViewButton } from 'features/controlLayers/components/CanvasResetViewButton'; import { CanvasScale } from 'features/controlLayers/components/CanvasScale'; import { CanvasSettingsPopover } from 'features/controlLayers/components/Settings/CanvasSettingsPopover'; -import { ToolBrushWidth } from 'features/controlLayers/components/Tool/ToolBrushWidth'; import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser'; -import { ToolEraserWidth } from 'features/controlLayers/components/Tool/ToolEraserWidth'; import { ToolFillColorPicker } from 'features/controlLayers/components/Tool/ToolFillColorPicker'; +import { ToolSettings } from 'features/controlLayers/components/Tool/ToolSettings'; import { UndoRedoButtonGroup } from 'features/controlLayers/components/UndoRedoButtonGroup'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton'; @@ -16,15 +14,13 @@ import { ViewerToggleMenu } from 'features/gallery/components/ImageViewer/Viewer import { memo } from 'react'; export const ControlLayersToolbar = memo(() => { - const tool = useAppSelector((s) => s.canvasV2.tool.selected); return ( - {tool === 'brush' && } - {tool === 'eraser' && } + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBboxButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBboxButton.tsx index 07841e9d06..5f676d5f98 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBboxButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBboxButton.tsx @@ -1,29 +1,25 @@ import { IconButton } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; import { useIsFiltering } from 'features/controlLayers/hooks/useIsFiltering'; import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming'; -import { toolChanged } from 'features/controlLayers/store/canvasV2Slice'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiBoundingBoxBold } from 'react-icons/pi'; export const ToolBboxButton = memo(() => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); + const selectBbox = useSelectTool('bbox'); + const isSelected = useToolIsSelected('bbox'); const isFiltering = useIsFiltering(); const isTransforming = useIsTransforming(); const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging); - const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'bbox'); const isDisabled = useMemo(() => { return isTransforming || isFiltering || isStaging; }, [isFiltering, isStaging, isTransforming]); - const onClick = useCallback(() => { - dispatch(toolChanged('bbox')); - }, [dispatch]); - - useHotkeys('q', onClick, { enabled: !isDisabled || isSelected }, [onClick, isSelected, isDisabled]); + useHotkeys('q', selectBbox, { enabled: !isDisabled || isSelected }, [selectBbox, isSelected, isDisabled]); return ( { icon={} colorScheme={isSelected ? 'invokeBlue' : 'base'} variant="outline" - onClick={onClick} + onClick={selectBbox} isDisabled={isDisabled} /> ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushButton.tsx index 666b110ef4..c508ab4d70 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushButton.tsx @@ -1,21 +1,21 @@ import { IconButton } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; import { useIsFiltering } from 'features/controlLayers/hooks/useIsFiltering'; import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming'; -import { toolChanged } from 'features/controlLayers/store/canvasV2Slice'; import { isDrawableEntityType } from 'features/controlLayers/store/types'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiPaintBrushBold } from 'react-icons/pi'; export const ToolBrushButton = memo(() => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); const isFiltering = useIsFiltering(); const isTransforming = useIsTransforming(); const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging); - const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'brush'); + const selectBrush = useSelectTool('brush'); + const isSelected = useToolIsSelected('brush'); const isDrawingToolAllowed = useAppSelector((s) => { if (!s.canvasV2.selectedEntityIdentifier?.type) { return false; @@ -27,11 +27,7 @@ export const ToolBrushButton = memo(() => { return isTransforming || isFiltering || isStaging || !isDrawingToolAllowed; }, [isDrawingToolAllowed, isFiltering, isStaging, isTransforming]); - const onClick = useCallback(() => { - dispatch(toolChanged('brush')); - }, [dispatch]); - - useHotkeys('b', onClick, { enabled: !isDisabled || isSelected }, [isDisabled, isSelected, onClick]); + useHotkeys('b', selectBrush, { enabled: !isDisabled || isSelected }, [isDisabled, isSelected, selectBrush]); return ( { icon={} colorScheme={isSelected ? 'invokeBlue' : 'base'} variant="outline" - onClick={onClick} + onClick={selectBrush} isDisabled={isDisabled} /> ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolColorPickerButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolColorPickerButton.tsx index 5e271e8b82..e4258170ac 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolColorPickerButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolColorPickerButton.tsx @@ -1,30 +1,30 @@ import { IconButton } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; import { useIsFiltering } from 'features/controlLayers/hooks/useIsFiltering'; import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming'; -import { toolChanged } from 'features/controlLayers/store/canvasV2Slice'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiEyedropperBold } from 'react-icons/pi'; export const ToolColorPickerButton = memo(() => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); const isFiltering = useIsFiltering(); const isTransforming = useIsTransforming(); - const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'colorPicker'); + const selectColorPicker = useSelectTool('colorPicker'); + const isSelected = useToolIsSelected('colorPicker'); const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging); const isDisabled = useMemo(() => { return isTransforming || isFiltering || isStaging; }, [isFiltering, isStaging, isTransforming]); - const onClick = useCallback(() => { - dispatch(toolChanged('colorPicker')); - }, [dispatch]); - - useHotkeys('i', onClick, { enabled: !isDisabled || isSelected }, [onClick, isSelected, isDisabled]); + useHotkeys('i', selectColorPicker, { enabled: !isDisabled || isSelected }, [ + selectColorPicker, + isSelected, + isDisabled, + ]); return ( { icon={} colorScheme={isSelected ? 'invokeBlue' : 'base'} variant="outline" - onClick={onClick} + onClick={selectColorPicker} isDisabled={isDisabled} /> ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserButton.tsx index eb10687d2d..78b5942113 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserButton.tsx @@ -1,21 +1,21 @@ import { IconButton } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; import { useIsFiltering } from 'features/controlLayers/hooks/useIsFiltering'; import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming'; -import { toolChanged } from 'features/controlLayers/store/canvasV2Slice'; import { isDrawableEntityType } from 'features/controlLayers/store/types'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiEraserBold } from 'react-icons/pi'; export const ToolEraserButton = memo(() => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); const isFiltering = useIsFiltering(); const isTransforming = useIsTransforming(); const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging); - const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'eraser'); + const selectEraser = useSelectTool('eraser'); + const isSelected = useToolIsSelected('eraser'); const isDrawingToolAllowed = useAppSelector((s) => { if (!s.canvasV2.selectedEntityIdentifier?.type) { return false; @@ -26,11 +26,7 @@ export const ToolEraserButton = memo(() => { return isTransforming || isFiltering || isStaging || !isDrawingToolAllowed; }, [isDrawingToolAllowed, isFiltering, isStaging, isTransforming]); - const onClick = useCallback(() => { - dispatch(toolChanged('eraser')); - }, [dispatch]); - - useHotkeys('e', onClick, { enabled: !isDisabled || isSelected }, [isDisabled, isSelected, onClick]); + useHotkeys('e', selectEraser, { enabled: !isDisabled || isSelected }, [isDisabled, isSelected, selectEraser]); return ( { icon={} colorScheme={isSelected ? 'invokeBlue' : 'base'} variant="outline" - onClick={onClick} + onClick={selectEraser} isDisabled={isDisabled} /> ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolMoveButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolMoveButton.tsx index 8a9b00ae41..91a8155a8e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolMoveButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolMoveButton.tsx @@ -1,20 +1,20 @@ import { IconButton } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; import { useIsFiltering } from 'features/controlLayers/hooks/useIsFiltering'; import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming'; -import { toolChanged } from 'features/controlLayers/store/canvasV2Slice'; import { isDrawableEntityType } from 'features/controlLayers/store/types'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiCursorBold } from 'react-icons/pi'; export const ToolMoveButton = memo(() => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); const isFiltering = useIsFiltering(); const isTransforming = useIsTransforming(); - const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'move'); + const selectMove = useSelectTool('move'); + const isSelected = useToolIsSelected('move'); const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging); const isDrawingToolAllowed = useAppSelector((s) => { if (!s.canvasV2.selectedEntityIdentifier?.type) { @@ -26,11 +26,7 @@ export const ToolMoveButton = memo(() => { return isTransforming || isFiltering || isStaging || !isDrawingToolAllowed; }, [isDrawingToolAllowed, isFiltering, isStaging, isTransforming]); - const onClick = useCallback(() => { - dispatch(toolChanged('move')); - }, [dispatch]); - - useHotkeys('v', onClick, { enabled: !isDisabled || isSelected }, [isDisabled, isSelected, onClick]); + useHotkeys('v', selectMove, { enabled: !isDisabled || isSelected }, [isDisabled, isSelected, selectMove]); return ( { icon={} colorScheme={isSelected ? 'invokeBlue' : 'base'} variant="outline" - onClick={onClick} + onClick={selectMove} isDisabled={isDisabled} /> ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolRectButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolRectButton.tsx index 9c908d16f8..3b5b1e338f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolRectButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolRectButton.tsx @@ -1,18 +1,18 @@ import { IconButton } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; import { useIsFiltering } from 'features/controlLayers/hooks/useIsFiltering'; import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming'; -import { toolChanged } from 'features/controlLayers/store/canvasV2Slice'; import { isDrawableEntityType } from 'features/controlLayers/store/types'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiRectangleBold } from 'react-icons/pi'; export const ToolRectButton = memo(() => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'rect'); + const selectRect = useSelectTool('rect'); + const isSelected = useToolIsSelected('rect'); const isFiltering = useIsFiltering(); const isTransforming = useIsTransforming(); const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging); @@ -27,11 +27,7 @@ export const ToolRectButton = memo(() => { return isTransforming || isFiltering || isStaging || !isDrawingToolAllowed; }, [isDrawingToolAllowed, isFiltering, isStaging, isTransforming]); - const onClick = useCallback(() => { - dispatch(toolChanged('rect')); - }, [dispatch]); - - useHotkeys('u', onClick, { enabled: !isDisabled || isSelected }, [isDisabled, isSelected, onClick]); + useHotkeys('u', selectRect, { enabled: !isDisabled || isSelected }, [isDisabled, isSelected, selectRect]); return ( { icon={} colorScheme={isSelected ? 'invokeBlue' : 'base'} variant="outline" - onClick={onClick} + onClick={selectRect} isDisabled={isDisabled} /> ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolSettings.tsx new file mode 100644 index 0000000000..aef9e5d2e3 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolSettings.tsx @@ -0,0 +1,19 @@ +import { useStore } from '@nanostores/react'; +import { ToolBrushWidth } from 'features/controlLayers/components/Tool/ToolBrushWidth'; +import { ToolEraserWidth } from 'features/controlLayers/components/Tool/ToolEraserWidth'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { memo } from 'react'; + +export const ToolSettings = memo(() => { + const canvasManager = useCanvasManager(); + const tool = useStore(canvasManager.stateApi.$tool); + if (tool === 'brush') { + return ; + } + if (tool === 'eraser') { + return ; + } + return null; +}); + +ToolSettings.displayName = 'ToolSettings'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolViewButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolViewButton.tsx index be45717be6..6b94eaf0cc 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolViewButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolViewButton.tsx @@ -1,28 +1,25 @@ import { IconButton } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; import { useIsFiltering } from 'features/controlLayers/hooks/useIsFiltering'; import { useIsTransforming } from 'features/controlLayers/hooks/useIsTransforming'; -import { toolChanged } from 'features/controlLayers/store/canvasV2Slice'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiHandBold } from 'react-icons/pi'; export const ToolViewButton = memo(() => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); const isTransforming = useIsTransforming(); const isFiltering = useIsFiltering(); const isStaging = useAppSelector((s) => s.canvasV2.session.isStaging); - const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'view'); + const selectView = useSelectTool('view'); + const isSelected = useToolIsSelected('view'); const isDisabled = useMemo(() => { return isTransforming || isFiltering || isStaging; }, [isFiltering, isStaging, isTransforming]); - const onClick = useCallback(() => { - dispatch(toolChanged('view')); - }, [dispatch]); - useHotkeys('h', onClick, { enabled: !isDisabled || isSelected }, [onClick, isSelected, isDisabled]); + useHotkeys('h', selectView, { enabled: !isDisabled || isSelected }, [selectView, isSelected, isDisabled]); return ( { icon={} colorScheme={isSelected ? 'invokeBlue' : 'base'} variant="outline" - onClick={onClick} + onClick={selectView} isDisabled={isDisabled} /> ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/hooks.ts b/invokeai/frontend/web/src/features/controlLayers/components/Tool/hooks.ts new file mode 100644 index 0000000000..1bd546e743 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/hooks.ts @@ -0,0 +1,19 @@ +import { useStore } from '@nanostores/react'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import type { Tool } from 'features/controlLayers/store/types'; +import { computed } from 'nanostores'; +import { useCallback } from 'react'; + +export const useToolIsSelected = (tool: Tool) => { + const canvasManager = useCanvasManager(); + const isSelected = useStore(computed(canvasManager.stateApi.$tool, (t) => t === tool)); + return isSelected; +}; + +export const useSelectTool = (tool: Tool) => { + const canvasManager = useCanvasManager(); + const setTool = useCallback(() => { + canvasManager.stateApi.$tool.set(tool); + }, [canvasManager.stateApi.$tool, tool]); + return setTool; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBboxModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBboxModule.ts index b7d19b8f66..4a2692752d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBboxModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBboxModule.ts @@ -31,6 +31,11 @@ export class CanvasBboxModule { manager: CanvasManager; log: Logger; + /** + * A set of subscriptions that should be cleaned up when the transformer is destroyed. + */ + subscriptions: Set<() => void> = new Set(); + konva: { group: Konva.Group; rect: Konva.Rect; @@ -228,17 +233,19 @@ export class CanvasBboxModule { this.konva.transformer.nodes([this.konva.rect]); this.konva.group.add(this.konva.rect); this.konva.group.add(this.konva.transformer); + + this.subscriptions.add(this.manager.stateApi.$tool.listen(this.render)); } - render() { + render = () => { this.log.trace('Rendering generation bbox'); const bbox = this.manager.stateApi.getBbox(); - const toolState = this.manager.stateApi.getToolState(); + const tool = this.manager.stateApi.$tool.get(); this.konva.group.visible(true); - this.parent.getLayer().listening(toolState.selected === 'bbox'); - this.konva.group.listening(toolState.selected === 'bbox'); + this.parent.getLayer().listening(tool === 'bbox'); + this.konva.group.listening(tool === 'bbox'); this.konva.rect.setAttrs({ x: bbox.rect.x, y: bbox.rect.y, @@ -246,13 +253,21 @@ export class CanvasBboxModule { height: bbox.rect.height, scaleX: 1, scaleY: 1, - listening: toolState.selected === 'bbox', + listening: tool === 'bbox', }); this.konva.transformer.setAttrs({ - listening: toolState.selected === 'bbox', - enabledAnchors: toolState.selected === 'bbox' ? ALL_ANCHORS : NO_ANCHORS, + listening: tool === 'bbox', + enabledAnchors: tool === 'bbox' ? ALL_ANCHORS : NO_ANCHORS, }); - } + }; + + destroy = () => { + this.log.trace('Destroying generation bbox'); + for (const unsubscribe of this.subscriptions) { + unsubscribe(); + } + this.konva.group.destroy(); + }; getLoggingContext = (): SerializableObject => { return { ...this.manager.getLoggingContext(), path: this.path.join('.') }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasFilterModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasFilterModule.ts index b6e95a1d8b..a32d752465 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasFilterModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasFilterModule.ts @@ -47,7 +47,7 @@ export class CanvasFilterModule { return; } this.$adapter.set(entity.adapter); - this.manager.stateApi.setTool('view'); + this.manager.stateApi.$tool.set('view'); }; previewFilter = async () => { diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectRenderer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectRenderer.ts index f5b7b57404..c6c7194918 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectRenderer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectRenderer.ts @@ -156,11 +156,12 @@ export class CanvasObjectRenderer { this.parent.konva.layer.add(this.konva.compositing.group); } + // When switching tool, commit the buffer. This is necessary to prevent the buffer from being lost when the + // user switches tool mid-drawing, for example by pressing space to pan the stage. It's easy to press space + // to pan _before_ releasing the mouse button, which would cause the buffer to be lost if we didn't commit it. this.subscriptions.add( - this.manager.stateApi.$toolState.listen((newVal, oldVal) => { - if (newVal.selected !== oldVal.selected) { - this.commitBuffer(); - } + this.manager.stateApi.$tool.listen(() => { + this.commitBuffer(); }) ); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasRenderingModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasRenderingModule.ts index bde46351f9..dee207c792 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasRenderingModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasRenderingModule.ts @@ -145,7 +145,6 @@ export class CanvasRenderingModule { if ( !prevState || state.regions.entities !== prevState.regions.entities || - state.tool.selected !== prevState.tool.selected || state.selectedEntityIdentifier?.id !== prevState.selectedEntityIdentifier?.id ) { // Destroy the konva nodes for nonexistent entities @@ -184,7 +183,6 @@ export class CanvasRenderingModule { if ( !prevState || state.inpaintMasks.entities !== prevState.inpaintMasks.entities || - state.tool.selected !== prevState.tool.selected || state.selectedEntityIdentifier?.id !== prevState.selectedEntityIdentifier?.id ) { // Destroy the konva nodes for nonexistent entities @@ -212,7 +210,7 @@ export class CanvasRenderingModule { }; renderBbox = (state: CanvasV2State, prevState: CanvasV2State | null) => { - if (!prevState || state.bbox !== prevState.bbox || state.tool.selected !== prevState.tool.selected) { + if (!prevState || state.bbox !== prevState.bbox) { this.manager.preview.bbox.render(); } }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts index c9c20892ec..24d3c1bec1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts @@ -15,8 +15,6 @@ import { entitySelected, eraserWidthChanged, fillChanged, - toolBufferChanged, - toolChanged, } from 'features/controlLayers/store/canvasV2Slice'; import { selectAllRenderableEntities } from 'features/controlLayers/store/selectors'; import type { @@ -115,12 +113,6 @@ export class CanvasStateApiModule { setEraserWidth = (width: number) => { this.store.dispatch(eraserWidthChanged(width)); }; - setTool = (tool: Tool) => { - this.store.dispatch(toolChanged(tool)); - }; - setToolBuffer = (toolBuffer: Tool | null) => { - this.store.dispatch(toolBufferChanged(toolBuffer)); - }; setFill = (fill: RgbaColor) => { return this.store.dispatch(fillChanged(fill)); }; @@ -245,6 +237,8 @@ export class CanvasStateApiModule { $colorUnderCursor: WritableAtom = atom(RGBA_BLACK); // Read-write state, ephemeral interaction state + $tool = atom('brush'); + $toolBuffer = atom(null); $isDrawing = atom(false); $isMouseDown = atom(false); $lastAddedPoint = atom(null); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasToolModule.ts index 4123b9b190..0bce3396ea 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasToolModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasToolModule.ts @@ -231,17 +231,9 @@ export class CanvasToolModule { ); this.konva.group.add(this.konva.colorPicker.group); - this.subscriptions.add( - this.manager.stateApi.$stageAttrs.listen(() => { - this.render(); - }) - ); - - this.subscriptions.add( - this.manager.stateApi.$toolState.listen(() => { - this.render(); - }) - ); + this.subscriptions.add(this.manager.stateApi.$stageAttrs.listen(this.render)); + this.subscriptions.add(this.manager.stateApi.$toolState.listen(this.render)); + this.subscriptions.add(this.manager.stateApi.$tool.listen(this.render)); const cleanupListeners = this.setEventListeners(); @@ -261,15 +253,14 @@ export class CanvasToolModule { this.konva.colorPicker.group.visible(tool === 'colorPicker'); }; - render() { + render = () => { const stage = this.manager.stage; const renderedEntityCount = this.manager.stateApi.getRenderedEntityCount(); const toolState = this.manager.stateApi.getToolState(); const selectedEntity = this.manager.stateApi.getSelectedEntity(); const cursorPos = this.manager.stateApi.$lastCursorPos.get(); const isMouseDown = this.manager.stateApi.$isMouseDown.get(); - - const tool = toolState.selected; + const tool = this.manager.stateApi.$tool.get(); const isDrawable = selectedEntity && selectedEntity.state.isEnabled && isDrawableEntity(selectedEntity.state); @@ -447,7 +438,7 @@ export class CanvasToolModule { this.setToolVisibility(tool); } - } + }; syncLastCursorPos = (): Coordinate | null => { const pos = getScaledCursorPosition(this.konva.stage); @@ -480,9 +471,9 @@ export class CanvasToolModule { return { r, g, b }; }; - getClip( + getClip = ( entity: CanvasRegionalGuidanceState | CanvasControlLayerState | CanvasRasterLayerState | CanvasInpaintMaskState - ) { + ) => { const settings = this.manager.stateApi.getSettings(); if (settings.clipToBbox) { @@ -504,7 +495,7 @@ export class CanvasToolModule { height: height / scale, }; } - } + }; setEventListeners = (): (() => void) => { this.konva.stage.on('mouseenter', this.onStageMouseEnter); @@ -537,10 +528,11 @@ export class CanvasToolModule { onStageMouseDown = async (e: KonvaEventObject) => { this.manager.stateApi.$isMouseDown.set(true); const toolState = this.manager.stateApi.getToolState(); + const tool = this.manager.stateApi.$tool.get(); const pos = this.syncLastCursorPos(); const selectedEntity = this.manager.stateApi.getSelectedEntity(); - if (toolState.selected === 'colorPicker') { + if (tool === 'colorPicker') { const color = this.getColorUnderCursor(); if (color) { this.manager.stateApi.$colorUnderCursor.set(color); @@ -555,7 +547,7 @@ export class CanvasToolModule { this.manager.stateApi.$lastMouseDownPos.set(pos); const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); - if (toolState.selected === 'brush') { + if (tool === 'brush') { const lastLinePoint = selectedEntity.adapter.getLastPointOfLastLine('brush_line'); const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width); if (e.evt.shiftKey && lastLinePoint) { @@ -594,7 +586,7 @@ export class CanvasToolModule { this.manager.stateApi.$lastAddedPoint.set(alignedPoint); } - if (toolState.selected === 'eraser') { + if (tool === 'eraser') { const lastLinePoint = selectedEntity.adapter.getLastPointOfLastLine('eraser_line'); const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width); if (e.evt.shiftKey && lastLinePoint) { @@ -630,7 +622,7 @@ export class CanvasToolModule { this.manager.stateApi.$lastAddedPoint.set(alignedPoint); } - if (toolState.selected === 'rect') { + if (tool === 'rect') { if (selectedEntity.adapter.renderer.bufferState) { selectedEntity.adapter.renderer.commitBuffer(); } @@ -650,11 +642,10 @@ export class CanvasToolModule { const pos = this.manager.stateApi.$lastCursorPos.get(); const selectedEntity = this.manager.stateApi.getSelectedEntity(); const isDrawable = selectedEntity?.state.isEnabled; + const tool = this.manager.stateApi.$tool.get(); if (pos && isDrawable && !this.manager.stateApi.$spaceKey.get()) { - const toolState = this.manager.stateApi.getToolState(); - - if (toolState.selected === 'brush') { + if (tool === 'brush') { const drawingBuffer = selectedEntity.adapter.renderer.bufferState; if (drawingBuffer?.type === 'brush_line') { selectedEntity.adapter.renderer.commitBuffer(); @@ -663,7 +654,7 @@ export class CanvasToolModule { } } - if (toolState.selected === 'eraser') { + if (tool === 'eraser') { const drawingBuffer = selectedEntity.adapter.renderer.bufferState; if (drawingBuffer?.type === 'eraser_line') { selectedEntity.adapter.renderer.commitBuffer(); @@ -672,7 +663,7 @@ export class CanvasToolModule { } } - if (toolState.selected === 'rect') { + if (tool === 'rect') { const drawingBuffer = selectedEntity.adapter.renderer.bufferState; if (drawingBuffer?.type === 'rect') { selectedEntity.adapter.renderer.commitBuffer(); @@ -690,8 +681,9 @@ export class CanvasToolModule { const toolState = this.manager.stateApi.getToolState(); const pos = this.syncLastCursorPos(); const selectedEntity = this.manager.stateApi.getSelectedEntity(); + const tool = this.manager.stateApi.$tool.get(); - if (toolState.selected === 'colorPicker') { + if (tool === 'colorPicker') { const color = this.getColorUnderCursor(); if (color) { this.manager.stateApi.$colorUnderCursor.set(color); @@ -699,7 +691,7 @@ export class CanvasToolModule { } else { const isDrawable = selectedEntity?.state.isEnabled; if (pos && isDrawable && !this.manager.stateApi.$spaceKey.get() && getIsPrimaryMouseDown(e)) { - if (toolState.selected === 'brush') { + if (tool === 'brush') { const drawingBuffer = selectedEntity.adapter.renderer.bufferState; if (drawingBuffer) { if (drawingBuffer.type === 'brush_line') { @@ -736,7 +728,7 @@ export class CanvasToolModule { } } - if (toolState.selected === 'eraser') { + if (tool === 'eraser') { const drawingBuffer = selectedEntity.adapter.renderer.bufferState; if (drawingBuffer) { if (drawingBuffer.type === 'eraser_line') { @@ -772,7 +764,7 @@ export class CanvasToolModule { } } - if (toolState.selected === 'rect') { + if (tool === 'rect') { const drawingBuffer = selectedEntity.adapter.renderer.bufferState; if (drawingBuffer) { if (drawingBuffer.type === 'rect') { @@ -798,21 +790,22 @@ export class CanvasToolModule { const selectedEntity = this.manager.stateApi.getSelectedEntity(); const toolState = this.manager.stateApi.getToolState(); const isDrawable = selectedEntity?.state.isEnabled; + const tool = this.manager.stateApi.$tool.get(); if (pos && isDrawable && !this.manager.stateApi.$spaceKey.get() && getIsPrimaryMouseDown(e)) { const drawingBuffer = selectedEntity.adapter.renderer.bufferState; const normalizedPoint = offsetCoord(pos, selectedEntity.state.position); - if (toolState.selected === 'brush' && drawingBuffer?.type === 'brush_line') { + if (tool === 'brush' && drawingBuffer?.type === 'brush_line') { const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width); drawingBuffer.points.push(alignedPoint.x, alignedPoint.y); await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); selectedEntity.adapter.renderer.commitBuffer(); - } else if (toolState.selected === 'eraser' && drawingBuffer?.type === 'eraser_line') { + } else if (tool === 'eraser' && drawingBuffer?.type === 'eraser_line') { const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width); drawingBuffer.points.push(alignedPoint.x, alignedPoint.y); await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); selectedEntity.adapter.renderer.commitBuffer(); - } else if (toolState.selected === 'rect' && drawingBuffer?.type === 'rect') { + } else if (tool === 'rect' && drawingBuffer?.type === 'rect') { drawingBuffer.rect.width = Math.round(normalizedPoint.x - drawingBuffer.rect.x); drawingBuffer.rect.height = Math.round(normalizedPoint.y - drawingBuffer.rect.y); await selectedEntity.adapter.renderer.setBuffer(drawingBuffer); @@ -831,6 +824,7 @@ export class CanvasToolModule { } const toolState = this.manager.stateApi.getToolState(); + const tool = this.manager.stateApi.$tool.get(); let delta = e.evt.deltaY; @@ -839,9 +833,9 @@ export class CanvasToolModule { } // Holding ctrl or meta while scrolling changes the brush size - if (toolState.selected === 'brush') { + if (tool === 'brush') { this.manager.stateApi.setBrushWidth(calculateNewBrushSizeFromWheelDelta(toolState.brush.width, delta)); - } else if (toolState.selected === 'eraser') { + } else if (tool === 'eraser') { this.manager.stateApi.setEraserWidth(calculateNewBrushSizeFromWheelDelta(toolState.eraser.width, delta)); } @@ -864,8 +858,8 @@ export class CanvasToolModule { } } else if (e.key === ' ') { // Select the view tool on space key down - this.manager.stateApi.setToolBuffer(this.manager.stateApi.getToolState().selected); - this.manager.stateApi.setTool('view'); + this.manager.stateApi.$toolBuffer.set(this.manager.stateApi.$tool.get()); + this.manager.stateApi.$tool.set('view'); this.manager.stateApi.$spaceKey.set(true); this.manager.stateApi.$lastCursorPos.set(null); this.manager.stateApi.$lastMouseDownPos.set(null); @@ -881,9 +875,9 @@ export class CanvasToolModule { } if (e.key === ' ') { // Revert the tool to the previous tool on space key up - const toolBuffer = this.manager.stateApi.getToolState().selectedBuffer; - this.manager.stateApi.setTool(toolBuffer ?? 'move'); - this.manager.stateApi.setToolBuffer(null); + const toolBuffer = this.manager.stateApi.$toolBuffer.get(); + this.manager.stateApi.$tool.set(toolBuffer ?? 'move'); + this.manager.stateApi.$toolBuffer.set(null); this.manager.stateApi.$spaceKey.set(false); } }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts index 1df7aa7532..f58105a25e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts @@ -381,20 +381,10 @@ export class CanvasTransformer { ); // When the selected tool changes, we need to update the transformer's interaction state. - this.subscriptions.add( - this.manager.stateApi.$toolState.listen((newVal, oldVal) => { - if (newVal.selected !== oldVal.selected) { - this.syncInteractionState(); - } - }) - ); + this.subscriptions.add(this.manager.stateApi.$tool.listen(this.syncInteractionState)); // When the selected entity changes, we need to update the transformer's interaction state. - this.subscriptions.add( - this.manager.stateApi.$selectedEntityIdentifier.listen(() => { - this.syncInteractionState(); - }) - ); + this.subscriptions.add(this.manager.stateApi.$selectedEntityIdentifier.listen(this.syncInteractionState)); this.parent.konva.layer.add(this.konva.outlineRect); this.parent.konva.layer.add(this.konva.proxyRect); @@ -439,7 +429,7 @@ export class CanvasTransformer { return; } - const toolState = this.manager.stateApi.getToolState(); + const tool = this.manager.stateApi.$tool.get(); const isSelected = this.manager.stateApi.getIsSelected(this.parent.id); if (!this.parent.renderer.hasObjects()) { @@ -449,14 +439,14 @@ export class CanvasTransformer { return; } - if (isSelected && !this.isTransforming && toolState.selected === 'move') { + if (isSelected && !this.isTransforming && tool === 'move') { // We are moving this layer, it must be listening this.parent.konva.layer.listening(true); this.setInteractionMode('drag'); } else if (isSelected && this.isTransforming) { // When transforming, we want the stage to still be movable if the view tool is selected. If the transformer is // active, it will interrupt the stage drag events. So we should disable listening when the view tool is selected. - if (toolState.selected !== 'view') { + if (tool !== 'view') { this.parent.konva.layer.listening(true); this.setInteractionMode('all'); } else { @@ -493,11 +483,12 @@ export class CanvasTransformer { startTransform = () => { this.log.debug('Starting transform'); this.isTransforming = true; - this.manager.stateApi.setTool('move'); + this.manager.stateApi.$tool.set('move'); // When transforming, we want the stage to still be movable if the view tool is selected. If the transformer or // interaction rect are listening, it will interrupt the stage's drag events. So we should disable listening // when the view tool is selected - const shouldListen = this.manager.stateApi.getToolState().selected !== 'view'; + // TODO(psyche): We just set the tool to 'move', why would it be 'view'? Investigate and figure out if this is needed + const shouldListen = this.manager.stateApi.$tool.get() !== 'view'; this.parent.konva.layer.listening(shouldListen); this.setInteractionMode('all'); this.manager.stateApi.$transformingEntity.set(this.parent.getEntityIdentifier()); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts index 81b688c950..9b3e828028 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts @@ -58,8 +58,6 @@ const initialState: CanvasV2State = { loras: [], ipAdapters: { entities: [] }, tool: { - selected: 'view', - selectedBuffer: null, invertScroll: false, fill: { r: 31, g: 160, b: 224, a: 1 }, // invokeBlue.500 brush: { @@ -140,17 +138,19 @@ export const canvasV2Slice = createSlice({ name: 'canvasV2', initialState, reducers: { + // undoable canvas state ...rasterLayersReducers, ...controlLayersReducers, ...ipAdaptersReducers, ...regionsReducers, + ...inpaintMaskReducers, + ...bboxReducers, + // move out ...lorasReducers, ...paramsReducers, ...compositingReducers, ...settingsReducers, ...toolReducers, - ...bboxReducers, - ...inpaintMaskReducers, ...sessionReducers, entitySelected: (state, action: PayloadAction) => { const { entityIdentifier } = action.payload; @@ -424,8 +424,6 @@ export const { eraserWidthChanged, fillChanged, invertScrollChanged, - toolChanged, - toolBufferChanged, clipToBboxChanged, canvasReset, settingsDynamicGridToggled, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/sessionReducers.ts b/invokeai/frontend/web/src/features/controlLayers/store/sessionReducers.ts index 8d2aeaeb11..2e77f1220d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/sessionReducers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/sessionReducers.ts @@ -5,10 +5,6 @@ export const sessionReducers = { sessionStartedStaging: (state) => { state.session.isStaging = true; state.session.selectedStagedImageIndex = 0; - // When we start staging, the user should not be interacting with the stage except to move it around. Set the tool - // to view. - state.tool.selectedBuffer = state.tool.selected; - state.tool.selected = 'view'; }, sessionImageStaged: (state, action: PayloadAction<{ stagingAreaImage: StagingAreaImage }>) => { const { stagingAreaImage } = action.payload; @@ -39,11 +35,6 @@ export const sessionReducers = { state.session.isStaging = false; state.session.stagedImages = []; state.session.selectedStagedImageIndex = 0; - // When we finish staging, reset the tool back to the previous selection. - if (state.tool.selectedBuffer) { - state.tool.selected = state.tool.selectedBuffer; - state.tool.selectedBuffer = null; - } }, sessionModeChanged: (state, action: PayloadAction<{ mode: SessionMode }>) => { const { mode } = action.payload; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/toolReducers.ts b/invokeai/frontend/web/src/features/controlLayers/store/toolReducers.ts index c1f14d7df4..74916f783b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/toolReducers.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/toolReducers.ts @@ -1,5 +1,5 @@ import type { PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit'; -import type { CanvasV2State, RgbaColor, Tool } from 'features/controlLayers/store/types'; +import type { CanvasV2State, RgbaColor } from 'features/controlLayers/store/types'; export const toolReducers = { brushWidthChanged: (state, action: PayloadAction) => { @@ -14,10 +14,4 @@ export const toolReducers = { invertScrollChanged: (state, action: PayloadAction) => { state.tool.invertScroll = action.payload; }, - toolChanged: (state, action: PayloadAction) => { - state.tool.selected = action.payload; - }, - toolBufferChanged: (state, action: PayloadAction) => { - state.tool.selectedBuffer = action.payload; - }, } satisfies SliceCaseReducers; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 95eed0e25d..a093d89249 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -736,8 +736,6 @@ export type CanvasV2State = { }; loras: LoRA[]; tool: { - selected: Tool; - selectedBuffer: Tool | null; invertScroll: boolean; brush: { width: number }; eraser: { width: number };