feat(ui): move selected tool and tool buffer out of redux

This ephemeral state can live in the canvas classes.
This commit is contained in:
psychedelicious 2024-08-26 19:59:06 +10:00
parent 9c1732e2bb
commit 17e76981bb
21 changed files with 173 additions and 188 deletions

View File

@ -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 (
<CanvasManagerProviderGate>
<Flex w="full" gap={2} alignItems="center">
<ToggleProgressButton />
<ToolChooser />
<Spacer />
{tool === 'brush' && <ToolBrushWidth />}
{tool === 'eraser' && <ToolEraserWidth />}
<ToolSettings />
<Spacer />
<CanvasScale />
<CanvasResetViewButton />

View File

@ -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 (
<IconButton
@ -32,7 +28,7 @@ export const ToolBboxButton = memo(() => {
icon={<PiBoundingBoxBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="outline"
onClick={onClick}
onClick={selectBbox}
isDisabled={isDisabled}
/>
);

View File

@ -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 (
<IconButton
@ -40,7 +36,7 @@ export const ToolBrushButton = memo(() => {
icon={<PiPaintBrushBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="outline"
onClick={onClick}
onClick={selectBrush}
isDisabled={isDisabled}
/>
);

View File

@ -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 (
<IconButton
@ -33,7 +33,7 @@ export const ToolColorPickerButton = memo(() => {
icon={<PiEyedropperBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="outline"
onClick={onClick}
onClick={selectColorPicker}
isDisabled={isDisabled}
/>
);

View File

@ -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 (
<IconButton
@ -39,7 +35,7 @@ export const ToolEraserButton = memo(() => {
icon={<PiEraserBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="outline"
onClick={onClick}
onClick={selectEraser}
isDisabled={isDisabled}
/>
);

View File

@ -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 (
<IconButton
@ -39,7 +35,7 @@ export const ToolMoveButton = memo(() => {
icon={<PiCursorBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="outline"
onClick={onClick}
onClick={selectMove}
isDisabled={isDisabled}
/>
);

View File

@ -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 (
<IconButton
@ -40,7 +36,7 @@ export const ToolRectButton = memo(() => {
icon={<PiRectangleBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="outline"
onClick={onClick}
onClick={selectRect}
isDisabled={isDisabled}
/>
);

View File

@ -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 <ToolBrushWidth />;
}
if (tool === 'eraser') {
return <ToolEraserWidth />;
}
return null;
});
ToolSettings.displayName = 'ToolSettings';

View File

@ -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 (
<IconButton
@ -31,7 +28,7 @@ export const ToolViewButton = memo(() => {
icon={<PiHandBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="outline"
onClick={onClick}
onClick={selectView}
isDisabled={isDisabled}
/>
);

View File

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

View File

@ -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('.') };

View File

@ -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 () => {

View File

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

View File

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

View File

@ -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<RgbColor> = atom(RGBA_BLACK);
// Read-write state, ephemeral interaction state
$tool = atom<Tool>('brush');
$toolBuffer = atom<Tool | null>(null);
$isDrawing = atom<boolean>(false);
$isMouseDown = atom<boolean>(false);
$lastAddedPoint = atom<Coordinate | null>(null);

View File

@ -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<MouseEvent>) => {
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);
}
};

View File

@ -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());

View File

@ -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<EntityIdentifierPayload>) => {
const { entityIdentifier } = action.payload;
@ -424,8 +424,6 @@ export const {
eraserWidthChanged,
fillChanged,
invertScrollChanged,
toolChanged,
toolBufferChanged,
clipToBboxChanged,
canvasReset,
settingsDynamicGridToggled,

View File

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

View File

@ -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<number>) => {
@ -14,10 +14,4 @@ export const toolReducers = {
invertScrollChanged: (state, action: PayloadAction<boolean>) => {
state.tool.invertScroll = action.payload;
},
toolChanged: (state, action: PayloadAction<Tool>) => {
state.tool.selected = action.payload;
},
toolBufferChanged: (state, action: PayloadAction<Tool | null>) => {
state.tool.selectedBuffer = action.payload;
},
} satisfies SliceCaseReducers<CanvasV2State>;

View File

@ -736,8 +736,6 @@ export type CanvasV2State = {
};
loras: LoRA[];
tool: {
selected: Tool;
selectedBuffer: Tool | null;
invertScroll: boolean;
brush: { width: number };
eraser: { width: number };