feat(ui): rects on regional prompt UI

This commit is contained in:
psychedelicious 2024-04-20 23:21:32 +10:00 committed by Kent Keirsey
parent cfddbda578
commit 4895875ded
7 changed files with 257 additions and 117 deletions

View File

@ -1522,6 +1522,7 @@
"autoNegative": "Auto Negative", "autoNegative": "Auto Negative",
"toggleVisibility": "Toggle Layer Visibility", "toggleVisibility": "Toggle Layer Visibility",
"resetRegion": "Reset Region", "resetRegion": "Reset Region",
"debugLayers": "Debug Layers" "debugLayers": "Debug Layers",
"rectangle": "Rectangle"
} }
} }

View File

@ -6,6 +6,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useMouseEvents } from 'features/regionalPrompts/hooks/mouseEventHooks'; import { useMouseEvents } from 'features/regionalPrompts/hooks/mouseEventHooks';
import { import {
$cursorPosition, $cursorPosition,
$lastMouseDownPos,
$tool, $tool,
isVectorMaskLayer, isVectorMaskLayer,
layerBboxChanged, layerBboxChanged,
@ -13,7 +14,7 @@ import {
layerTranslated, layerTranslated,
selectRegionalPromptsSlice, selectRegionalPromptsSlice,
} from 'features/regionalPrompts/store/regionalPromptsSlice'; } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { renderBbox, renderLayers,renderToolPreview } from 'features/regionalPrompts/util/renderers'; import { renderBbox, renderLayers, renderToolPreview } from 'features/regionalPrompts/util/renderers';
import Konva from 'konva'; import Konva from 'konva';
import type { IRect } from 'konva/lib/types'; import type { IRect } from 'konva/lib/types';
import { atom } from 'nanostores'; import { atom } from 'nanostores';
@ -40,6 +41,7 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem
const tool = useStore($tool); const tool = useStore($tool);
const { onMouseDown, onMouseUp, onMouseMove, onMouseEnter, onMouseLeave } = useMouseEvents(); const { onMouseDown, onMouseUp, onMouseMove, onMouseEnter, onMouseLeave } = useMouseEvents();
const cursorPosition = useStore($cursorPosition); const cursorPosition = useStore($cursorPosition);
const lastMouseDownPos = useStore($lastMouseDownPos);
const selectedLayerIdColor = useAppSelector(selectSelectedLayerColor); const selectedLayerIdColor = useAppSelector(selectSelectedLayerColor);
const onLayerPosChanged = useCallback( const onLayerPosChanged = useCallback(
@ -130,8 +132,8 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem
if (!stage) { if (!stage) {
return; return;
} }
renderToolPreview(stage, tool, selectedLayerIdColor, cursorPosition, state.brushSize); renderToolPreview(stage, tool, selectedLayerIdColor, cursorPosition, lastMouseDownPos, state.brushSize);
}, [stage, tool, cursorPosition, state.brushSize, selectedLayerIdColor]); }, [stage, tool, selectedLayerIdColor, cursorPosition, lastMouseDownPos, state.brushSize]);
useLayoutEffect(() => { useLayoutEffect(() => {
log.trace('Rendering layers'); log.trace('Rendering layers');

View File

@ -1,27 +1,33 @@
import { ButtonGroup, IconButton } from '@invoke-ai/ui-library'; import { ButtonGroup, IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { $tool } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { $tool } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiArrowsOutCardinalBold, PiEraserBold, PiPaintBrushBold } from 'react-icons/pi'; import { PiArrowsOutCardinalBold, PiEraserBold, PiPaintBrushBold, PiRectangleBold } from 'react-icons/pi';
export const ToolChooser: React.FC = () => { export const ToolChooser: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const isDisabled = useAppSelector((s) => s.regionalPrompts.present.layers.length === 0);
const tool = useStore($tool); const tool = useStore($tool);
const setToolToBrush = useCallback(() => { const setToolToBrush = useCallback(() => {
$tool.set('brush'); $tool.set('brush');
}, []); }, []);
useHotkeys('b', setToolToBrush, []); useHotkeys('b', setToolToBrush, { enabled: !isDisabled }, [isDisabled]);
const setToolToEraser = useCallback(() => { const setToolToEraser = useCallback(() => {
$tool.set('eraser'); $tool.set('eraser');
}, []); }, []);
useHotkeys('e', setToolToEraser, []); useHotkeys('e', setToolToEraser, { enabled: !isDisabled }, [isDisabled]);
const setToolToRect = useCallback(() => {
$tool.set('rect');
}, []);
useHotkeys('u', setToolToRect, { enabled: !isDisabled }, [isDisabled]);
const setToolToMove = useCallback(() => { const setToolToMove = useCallback(() => {
$tool.set('move'); $tool.set('move');
}, []); }, []);
useHotkeys('v', setToolToMove, []); useHotkeys('v', setToolToMove, { enabled: !isDisabled }, [isDisabled]);
return ( return (
<ButtonGroup isAttached> <ButtonGroup isAttached>
@ -31,6 +37,7 @@ export const ToolChooser: React.FC = () => {
icon={<PiPaintBrushBold />} icon={<PiPaintBrushBold />}
variant={tool === 'brush' ? 'solid' : 'outline'} variant={tool === 'brush' ? 'solid' : 'outline'}
onClick={setToolToBrush} onClick={setToolToBrush}
isDisabled={isDisabled}
/> />
<IconButton <IconButton
aria-label={`${t('unifiedCanvas.eraser')} (E)`} aria-label={`${t('unifiedCanvas.eraser')} (E)`}
@ -38,6 +45,15 @@ export const ToolChooser: React.FC = () => {
icon={<PiEraserBold />} icon={<PiEraserBold />}
variant={tool === 'eraser' ? 'solid' : 'outline'} variant={tool === 'eraser' ? 'solid' : 'outline'}
onClick={setToolToEraser} onClick={setToolToEraser}
isDisabled={isDisabled}
/>
<IconButton
aria-label={`${t('regionalPrompts.rectangle')} (U)`}
tooltip={`${t('regionalPrompts.rectangle')} (U)`}
icon={<PiRectangleBold />}
variant={tool === 'rect' ? 'solid' : 'outline'}
onClick={setToolToRect}
isDisabled={isDisabled}
/> />
<IconButton <IconButton
aria-label={`${t('unifiedCanvas.move')} (V)`} aria-label={`${t('unifiedCanvas.move')} (V)`}
@ -45,6 +61,7 @@ export const ToolChooser: React.FC = () => {
icon={<PiArrowsOutCardinalBold />} icon={<PiArrowsOutCardinalBold />}
variant={tool === 'move' ? 'solid' : 'outline'} variant={tool === 'move' ? 'solid' : 'outline'}
onClick={setToolToMove} onClick={setToolToMove}
isDisabled={isDisabled}
/> />
</ButtonGroup> </ButtonGroup>
); );

View File

@ -1,7 +1,7 @@
/* eslint-disable i18next/no-literal-string */ /* eslint-disable i18next/no-literal-string */
import { ButtonGroup, IconButton } from '@invoke-ai/ui-library'; import { ButtonGroup, IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { redoRegionalPrompts, undoRegionalPrompts } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { redo, undo } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -12,30 +12,30 @@ export const UndoRedoButtonGroup = memo(() => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const mayUndo = useAppSelector((s) => s.regionalPrompts.past.length > 0); const mayUndo = useAppSelector((s) => s.regionalPrompts.past.length > 0);
const undo = useCallback(() => { const handleUndo = useCallback(() => {
dispatch(undoRegionalPrompts()); dispatch(undo());
}, [dispatch]); }, [dispatch]);
useHotkeys(['meta+z', 'ctrl+z'], undo, { enabled: mayUndo, preventDefault: true }, [mayUndo, undo]); useHotkeys(['meta+z', 'ctrl+z'], handleUndo, { enabled: mayUndo, preventDefault: true }, [mayUndo, undo]);
const mayRedo = useAppSelector((s) => s.regionalPrompts.future.length > 0); const mayRedo = useAppSelector((s) => s.regionalPrompts.future.length > 0);
const redo = useCallback(() => { const handleRedo = useCallback(() => {
dispatch(redoRegionalPrompts()); dispatch(redo());
}, [dispatch]); }, [dispatch]);
useHotkeys(['meta+shift+z', 'ctrl+shift+z'], redo, { enabled: mayRedo, preventDefault: true }, [mayRedo, redo]); useHotkeys(['meta+shift+z', 'ctrl+shift+z'], handleRedo, { enabled: mayRedo, preventDefault: true }, [mayRedo, redo]);
return ( return (
<ButtonGroup> <ButtonGroup>
<IconButton <IconButton
aria-label={t('unifiedCanvas.undo')} aria-label={t('unifiedCanvas.undo')}
tooltip={t('unifiedCanvas.undo')} tooltip={t('unifiedCanvas.undo')}
onClick={undo} onClick={handleUndo}
icon={<PiArrowCounterClockwiseBold />} icon={<PiArrowCounterClockwiseBold />}
isDisabled={!mayUndo} isDisabled={!mayUndo}
/> />
<IconButton <IconButton
aria-label={t('unifiedCanvas.redo')} aria-label={t('unifiedCanvas.redo')}
tooltip={t('unifiedCanvas.redo')} tooltip={t('unifiedCanvas.redo')}
onClick={redo} onClick={handleRedo}
icon={<PiArrowClockwiseBold />} icon={<PiArrowClockwiseBold />}
isDisabled={!mayRedo} isDisabled={!mayRedo}
/> />

View File

@ -4,9 +4,11 @@ import {
$cursorPosition, $cursorPosition,
$isMouseDown, $isMouseDown,
$isMouseOver, $isMouseOver,
$lastMouseDownPos,
$tool, $tool,
maskLayerLineAdded, maskLayerLineAdded,
maskLayerPointsAdded, maskLayerPointsAdded,
maskLayerRectAdded,
} from 'features/regionalPrompts/store/regionalPromptsSlice'; } from 'features/regionalPrompts/store/regionalPromptsSlice';
import type Konva from 'konva'; import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node'; import type { KonvaEventObject } from 'konva/lib/Node';
@ -58,6 +60,7 @@ export const useMouseEvents = () => {
return; return;
} }
$isMouseDown.set(true); $isMouseDown.set(true);
$lastMouseDownPos.set(pos);
if (!selectedLayerId) { if (!selectedLayerId) {
return; return;
} }
@ -81,12 +84,26 @@ export const useMouseEvents = () => {
if (!stage) { if (!stage) {
return; return;
} }
// const tool = getTool(); $isMouseDown.set(false);
if ((tool === 'brush' || tool === 'eraser') && $isMouseDown.get()) { const pos = $cursorPosition.get();
$isMouseDown.set(false); const lastPos = $lastMouseDownPos.get();
const tool = $tool.get();
if (pos && lastPos && selectedLayerId && tool === 'rect') {
dispatch(
maskLayerRectAdded({
layerId: selectedLayerId,
rect: {
x: Math.min(pos.x, lastPos.x),
y: Math.min(pos.y, lastPos.y),
width: Math.abs(pos.x - lastPos.x),
height: Math.abs(pos.y - lastPos.y),
},
})
);
} }
$lastMouseDownPos.set(null);
}, },
[tool] [dispatch, selectedLayerId]
); );
const onMouseMove = useCallback( const onMouseMove = useCallback(
@ -99,7 +116,6 @@ export const useMouseEvents = () => {
if (!pos || !selectedLayerId) { if (!pos || !selectedLayerId) {
return; return;
} }
// const tool = getTool();
if (getIsFocused(stage) && $isMouseOver.get() && $isMouseDown.get() && (tool === 'brush' || tool === 'eraser')) { if (getIsFocused(stage) && $isMouseOver.get() && $isMouseDown.get() && (tool === 'brush' || tool === 'eraser')) {
dispatch(maskLayerPointsAdded({ layerId: selectedLayerId, point: [Math.floor(pos.x), Math.floor(pos.y)] })); dispatch(maskLayerPointsAdded({ layerId: selectedLayerId, point: [Math.floor(pos.x), Math.floor(pos.y)] }));
} }

View File

@ -1,5 +1,5 @@
import type { PayloadAction, UnknownAction } from '@reduxjs/toolkit'; import type { PayloadAction, UnknownAction } from '@reduxjs/toolkit';
import { createAction, createSlice, isAnyOf } from '@reduxjs/toolkit'; import { createSlice, isAnyOf } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store'; import type { PersistConfig, RootState } from 'app/store/store';
import { moveBackward, moveForward, moveToBack, moveToFront } from 'common/util/arrayUtils'; import { moveBackward, moveForward, moveToBack, moveToFront } from 'common/util/arrayUtils';
import type { ParameterAutoNegative } from 'features/parameters/types/parameterSchemas'; import type { ParameterAutoNegative } from 'features/parameters/types/parameterSchemas';
@ -13,7 +13,7 @@ import { v4 as uuidv4 } from 'uuid';
type DrawingTool = 'brush' | 'eraser'; type DrawingTool = 'brush' | 'eraser';
export type RPTool = DrawingTool | 'move'; export type Tool = DrawingTool | 'move' | 'rect';
type VectorMaskLine = { type VectorMaskLine = {
id: string; id: string;
@ -81,7 +81,7 @@ export const initialRegionalPromptsState: RegionalPromptsState = {
brushSize: 100, brushSize: 100,
brushColor: { r: 255, g: 0, b: 0, a: 1 }, brushColor: { r: 255, g: 0, b: 0, a: 1 },
layers: [], layers: [],
globalMaskLayerOpacity: 0.5, // This currently doesn't work globalMaskLayerOpacity: 0.5, // this globally changes all mask layers' opacity
isEnabled: false, isEnabled: false,
}; };
@ -92,7 +92,7 @@ export const regionalPromptsSlice = createSlice({
name: 'regionalPrompts', name: 'regionalPrompts',
initialState: initialRegionalPromptsState, initialState: initialRegionalPromptsState,
reducers: { reducers: {
//#region Any Layers //#region All Layers
layerAdded: { layerAdded: {
reducer: (state, action: PayloadAction<Layer['kind'], string, { uuid: string }>) => { reducer: (state, action: PayloadAction<Layer['kind'], string, { uuid: string }>) => {
const kind = action.payload; const kind = action.payload;
@ -189,6 +189,7 @@ export const regionalPromptsSlice = createSlice({
state.selectedLayerId = null; state.selectedLayerId = null;
}, },
//#endregion //#endregion
//#region Mask Layers //#region Mask Layers
maskLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => { maskLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => {
const { layerId, prompt } = action.payload; const { layerId, prompt } = action.payload;
@ -258,6 +259,29 @@ export const regionalPromptsSlice = createSlice({
layer.bboxNeedsUpdate = true; layer.bboxNeedsUpdate = true;
} }
}, },
maskLayerRectAdded: {
reducer: (state, action: PayloadAction<{ layerId: string; rect: IRect }, string, { uuid: string }>) => {
const { layerId, rect } = action.payload;
if (rect.height === 0 || rect.width === 0) {
// Ignore zero-area rectangles
return;
}
const layer = state.layers.find((l) => l.id === layerId);
if (layer) {
const id = getVectorMaskLayerRectId(layer.id, action.meta.uuid);
layer.objects.push({
kind: 'vector_mask_rect',
id,
x: rect.x - layer.x,
y: rect.y - layer.y,
width: rect.width,
height: rect.height,
});
layer.bboxNeedsUpdate = true;
}
},
prepare: (payload: { layerId: string; rect: IRect }) => ({ payload, meta: { uuid: uuidv4() } }),
},
maskLayerAutoNegativeChanged: ( maskLayerAutoNegativeChanged: (
state, state,
action: PayloadAction<{ layerId: string; autoNegative: ParameterAutoNegative }> action: PayloadAction<{ layerId: string; autoNegative: ParameterAutoNegative }>
@ -269,6 +293,7 @@ export const regionalPromptsSlice = createSlice({
} }
}, },
//#endregion //#endregion
//#region General //#region General
brushSizeChanged: (state, action: PayloadAction<number>) => { brushSizeChanged: (state, action: PayloadAction<number>) => {
state.brushSize = action.payload; state.brushSize = action.payload;
@ -282,6 +307,18 @@ export const regionalPromptsSlice = createSlice({
isEnabledChanged: (state, action: PayloadAction<boolean>) => { isEnabledChanged: (state, action: PayloadAction<boolean>) => {
state.isEnabled = action.payload; state.isEnabled = action.payload;
}, },
undo: (state) => {
// Invalidate the bbox for all layers to prevent stale bboxes
for (const layer of state.layers) {
layer.bboxNeedsUpdate = true;
}
},
redo: (state) => {
// Invalidate the bbox for all layers to prevent stale bboxes
for (const layer of state.layers) {
layer.bboxNeedsUpdate = true;
}
},
//#endregion //#endregion
}, },
}); });
@ -318,7 +355,7 @@ class LayerColors {
} }
export const { export const {
// Any layer actions // All layer actions
layerAdded, layerAdded,
layerDeleted, layerDeleted,
layerMovedBackward, layerMovedBackward,
@ -331,17 +368,20 @@ export const {
layerBboxChanged, layerBboxChanged,
layerVisibilityToggled, layerVisibilityToggled,
allLayersDeleted, allLayersDeleted,
// Vector mask layer actions // Mask layer actions
maskLayerAutoNegativeChanged, maskLayerAutoNegativeChanged,
maskLayerPreviewColorChanged, maskLayerPreviewColorChanged,
maskLayerLineAdded, maskLayerLineAdded,
maskLayerNegativePromptChanged, maskLayerNegativePromptChanged,
maskLayerPointsAdded, maskLayerPointsAdded,
maskLayerPositivePromptChanged, maskLayerPositivePromptChanged,
maskLayerRectAdded,
// General actions // General actions
isEnabledChanged, isEnabledChanged,
brushSizeChanged, brushSizeChanged,
globalMaskLayerOpacityChanged, globalMaskLayerOpacityChanged,
undo,
redo,
} = regionalPromptsSlice.actions; } = regionalPromptsSlice.actions;
export const selectRegionalPromptsSlice = (state: RootState) => state.regionalPrompts; export const selectRegionalPromptsSlice = (state: RootState) => state.regionalPrompts;
@ -353,24 +393,29 @@ const migrateRegionalPromptsState = (state: any): any => {
export const $isMouseDown = atom(false); export const $isMouseDown = atom(false);
export const $isMouseOver = atom(false); export const $isMouseOver = atom(false);
export const $tool = atom<RPTool>('brush'); export const $lastMouseDownPos = atom<Vector2d | null>(null);
export const $tool = atom<Tool>('brush');
export const $cursorPosition = atom<Vector2d | null>(null); export const $cursorPosition = atom<Vector2d | null>(null);
// IDs for singleton layers and objects // IDs for singleton Konva layers and objects
export const TOOL_PREVIEW_LAYER_ID = 'tool_preview_layer'; export const TOOL_PREVIEW_LAYER_ID = 'tool_preview_layer';
export const BRUSH_FILL_ID = 'brush_fill'; export const TOOL_PREVIEW_BRUSH_GROUP_ID = 'tool_preview_layer.brush_group';
export const BRUSH_BORDER_INNER_ID = 'brush_border_inner'; export const TOOL_PREVIEW_BRUSH_FILL_ID = 'tool_preview_layer.brush_fill';
export const BRUSH_BORDER_OUTER_ID = 'brush_border_outer'; export const TOOL_PREVIEW_BRUSH_BORDER_INNER_ID = 'tool_preview_layer.brush_border_inner';
export const TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID = 'tool_preview_layer.brush_border_outer';
export const TOOL_PREVIEW_RECT_ID = 'tool_preview_layer.rect';
// Names (aka classes) for Konva layers and objects // Names (aka classes) for Konva layers and objects
export const VECTOR_MASK_LAYER_NAME = 'vector_mask_layer'; export const VECTOR_MASK_LAYER_NAME = 'vector_mask_layer';
export const VECTOR_MASK_LAYER_LINE_NAME = 'vector_mask_layer.line'; export const VECTOR_MASK_LAYER_LINE_NAME = 'vector_mask_layer.line';
export const VECTOR_MASK_LAYER_OBJECT_GROUP_NAME = 'vector_mask_layer.object_group'; export const VECTOR_MASK_LAYER_OBJECT_GROUP_NAME = 'vector_mask_layer.object_group';
export const VECTOR_MASK_LAYER_RECT_NAME = 'vector_mask_layer.rect';
export const LAYER_BBOX_NAME = 'layer.bbox'; export const LAYER_BBOX_NAME = 'layer.bbox';
// Getters for non-singleton layer and object IDs // Getters for non-singleton layer and object IDs
const getVectorMaskLayerId = (layerId: string) => `${VECTOR_MASK_LAYER_NAME}_${layerId}`; const getVectorMaskLayerId = (layerId: string) => `${VECTOR_MASK_LAYER_NAME}_${layerId}`;
const getVectorMaskLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`; const getVectorMaskLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`;
const getVectorMaskLayerRectId = (layerId: string, lineId: string) => `${layerId}.rect_${lineId}`;
export const getVectorMaskLayerObjectGroupId = (layerId: string, groupId: string) => export const getVectorMaskLayerObjectGroupId = (layerId: string, groupId: string) =>
`${layerId}.objectGroup_${groupId}`; `${layerId}.objectGroup_${groupId}`;
export const getLayerBboxId = (layerId: string) => `${layerId}.bbox`; export const getLayerBboxId = (layerId: string) => `${layerId}.bbox`;
@ -382,28 +427,25 @@ export const regionalPromptsPersistConfig: PersistConfig<RegionalPromptsState> =
persistDenylist: [], persistDenylist: [],
}; };
// Payload-less actions for `redux-undo`
export const undoRegionalPrompts = createAction(`${regionalPromptsSlice.name}/undo`);
export const redoRegionalPrompts = createAction(`${regionalPromptsSlice.name}/redo`);
// These actions are _individually_ grouped together as single undoable actions // These actions are _individually_ grouped together as single undoable actions
const undoableGroupByMatcher = isAnyOf( const undoableGroupByMatcher = isAnyOf(
layerTranslated,
brushSizeChanged, brushSizeChanged,
globalMaskLayerOpacityChanged, globalMaskLayerOpacityChanged,
isEnabledChanged, isEnabledChanged,
maskLayerPositivePromptChanged, maskLayerPositivePromptChanged,
maskLayerNegativePromptChanged, maskLayerNegativePromptChanged,
layerTranslated,
maskLayerPreviewColorChanged maskLayerPreviewColorChanged
); );
// These are used to group actions into logical lines below (hate typos)
const LINE_1 = 'LINE_1'; const LINE_1 = 'LINE_1';
const LINE_2 = 'LINE_2'; const LINE_2 = 'LINE_2';
export const regionalPromptsUndoableConfig: UndoableOptions<RegionalPromptsState, UnknownAction> = { export const regionalPromptsUndoableConfig: UndoableOptions<RegionalPromptsState, UnknownAction> = {
limit: 64, limit: 64,
undoType: undoRegionalPrompts.type, undoType: regionalPromptsSlice.actions.undo.type,
redoType: redoRegionalPrompts.type, redoType: regionalPromptsSlice.actions.redo.type,
groupBy: (action, state, history) => { groupBy: (action, state, history) => {
// Lines are started with `maskLayerLineAdded` and may have any number of subsequent `maskLayerPointsAdded` events. // Lines are started with `maskLayerLineAdded` and may have any number of subsequent `maskLayerPointsAdded` events.
// We can use a double-buffer-esque trick to group each "logical" line as a single undoable action, without grouping // We can use a double-buffer-esque trick to group each "logical" line as a single undoable action, without grouping
@ -423,7 +465,7 @@ export const regionalPromptsUndoableConfig: UndoableOptions<RegionalPromptsState
}, },
filter: (action, _state, _history) => { filter: (action, _state, _history) => {
// Ignore all actions from other slices // Ignore all actions from other slices
if (!action.type.startsWith('regionalPrompts/')) { if (!action.type.startsWith(regionalPromptsSlice.name)) {
return false; return false;
} }
// This action is triggered on state changes, including when we undo. If we do not ignore this action, when we // This action is triggered on state changes, including when we undo. If we do not ignore this action, when we

View File

@ -1,21 +1,24 @@
import { getStore } from 'app/store/nanostores/store'; import { getStore } from 'app/store/nanostores/store';
import { rgbColorToString } from 'features/canvas/util/colorToString'; import { rgbColorToString } from 'features/canvas/util/colorToString';
import { getScaledFlooredCursorPosition } from 'features/regionalPrompts/hooks/mouseEventHooks'; import { getScaledFlooredCursorPosition } from 'features/regionalPrompts/hooks/mouseEventHooks';
import type { Layer, RPTool, VectorMaskLayer } from 'features/regionalPrompts/store/regionalPromptsSlice'; import type { Layer, Tool, VectorMaskLayer } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { import {
$isMouseOver, $isMouseOver,
$tool, $tool,
BRUSH_BORDER_INNER_ID,
BRUSH_BORDER_OUTER_ID,
BRUSH_FILL_ID,
getLayerBboxId, getLayerBboxId,
getVectorMaskLayerObjectGroupId, getVectorMaskLayerObjectGroupId,
isVectorMaskLayer, isVectorMaskLayer,
LAYER_BBOX_NAME, LAYER_BBOX_NAME,
TOOL_PREVIEW_BRUSH_BORDER_INNER_ID,
TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID,
TOOL_PREVIEW_BRUSH_FILL_ID,
TOOL_PREVIEW_BRUSH_GROUP_ID,
TOOL_PREVIEW_LAYER_ID, TOOL_PREVIEW_LAYER_ID,
TOOL_PREVIEW_RECT_ID,
VECTOR_MASK_LAYER_LINE_NAME, VECTOR_MASK_LAYER_LINE_NAME,
VECTOR_MASK_LAYER_NAME, VECTOR_MASK_LAYER_NAME,
VECTOR_MASK_LAYER_OBJECT_GROUP_NAME, VECTOR_MASK_LAYER_OBJECT_GROUP_NAME,
VECTOR_MASK_LAYER_RECT_NAME,
} from 'features/regionalPrompts/store/regionalPromptsSlice'; } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { getKonvaLayerBbox } from 'features/regionalPrompts/util/bbox'; import { getKonvaLayerBbox } from 'features/regionalPrompts/util/bbox';
import Konva from 'konva'; import Konva from 'konva';
@ -41,125 +44,163 @@ const getIsSelected = (layerId?: string | null) => {
return layerId === getStore().getState().regionalPrompts.present.selectedLayerId; return layerId === getStore().getState().regionalPrompts.present.selectedLayerId;
}; };
const selectVectorMaskObjects = (node: Konva.Node) => {
return node.name() === VECTOR_MASK_LAYER_LINE_NAME || node.name() === VECTOR_MASK_LAYER_RECT_NAME;
};
/** /**
* Renders the brush preview for the selected tool. * Renders the brush preview for the selected tool.
* @param stage The konva stage to render on. * @param stage The konva stage to render on.
* @param tool The selected tool. * @param tool The selected tool.
* @param color The selected layer's color. * @param color The selected layer's color.
* @param cursorPos The cursor position. * @param cursorPos The cursor position.
* @param lastMouseDownPos The position of the last mouse down event - used for the rect tool.
* @param brushSize The brush size. * @param brushSize The brush size.
*/ */
export const renderToolPreview = ( export const renderToolPreview = (
stage: Konva.Stage, stage: Konva.Stage,
tool: RPTool, tool: Tool,
color: RgbColor | null, color: RgbColor | null,
cursorPos: Vector2d | null, cursorPos: Vector2d | null,
lastMouseDownPos: Vector2d | null,
brushSize: number brushSize: number
) => { ) => {
const layerCount = stage.find(`.${VECTOR_MASK_LAYER_NAME}`).length; const layerCount = stage.find(`.${VECTOR_MASK_LAYER_NAME}`).length;
// Update the stage's pointer style // Update the stage's pointer style
if (tool === 'move') { if (layerCount === 0) {
stage.container().style.cursor = 'default';
} else if (layerCount === 0) {
// We have no layers, so we should not render any tool // We have no layers, so we should not render any tool
stage.container().style.cursor = 'default'; stage.container().style.cursor = 'default';
} else if (tool === 'move') {
// Move tool gets a pointer
stage.container().style.cursor = 'default';
} else if (tool === 'rect') {
// Move rect gets a crosshair
stage.container().style.cursor = 'crosshair';
} else { } else {
// Else we use the brush preview
stage.container().style.cursor = 'none'; stage.container().style.cursor = 'none';
} }
let toolPreviewLayer = stage.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`);
// Create the layer if it doesn't exist // Create the layer if it doesn't exist
let layer = stage.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`); if (!toolPreviewLayer) {
if (!layer) {
// Initialize the brush preview layer & add to the stage // Initialize the brush preview layer & add to the stage
layer = new Konva.Layer({ id: TOOL_PREVIEW_LAYER_ID, visible: tool !== 'move', listening: false }); toolPreviewLayer = new Konva.Layer({ id: TOOL_PREVIEW_LAYER_ID, visible: tool !== 'move', listening: false });
stage.add(layer); stage.add(toolPreviewLayer);
// The brush preview is hidden and shown as the mouse leaves and enters the stage
// Add handlers to show/hide the brush preview layer
stage.on('mousemove', (e) => { stage.on('mousemove', (e) => {
const tool = $tool.get();
e.target e.target
.getStage() .getStage()
?.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`) ?.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`)
?.visible($tool.get() !== 'move'); ?.visible(tool === 'brush' || tool === 'eraser');
}); });
stage.on('mouseleave', (e) => { stage.on('mouseleave', (e) => {
e.target.getStage()?.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(false); e.target.getStage()?.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(false);
}); });
stage.on('mouseenter', (e) => { stage.on('mouseenter', (e) => {
const tool = $tool.get();
e.target e.target
.getStage() .getStage()
?.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`) ?.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`)
?.visible($tool.get() !== 'move'); ?.visible(tool === 'brush' || tool === 'eraser');
}); });
}
if (!$isMouseOver.get()) { // Create the brush preview group & circles
layer.visible(false); const brushPreviewGroup = new Konva.Group({ id: TOOL_PREVIEW_BRUSH_GROUP_ID });
return; const brushPreviewFill = new Konva.Circle({
} id: TOOL_PREVIEW_BRUSH_FILL_ID,
// ...but we may want to hide it if it is visible, when using the move tool or when there are no layers
layer.visible(tool !== 'move' && layerCount > 0);
// No need to render the brush preview if the cursor position or color is missing
if (!cursorPos || !color) {
return;
}
// Create and/or update the fill circle
let fill = layer.findOne<Konva.Circle>(`#${BRUSH_FILL_ID}`);
if (!fill) {
fill = new Konva.Circle({
id: BRUSH_FILL_ID,
listening: false, listening: false,
strokeEnabled: false, strokeEnabled: false,
}); });
layer.add(fill); brushPreviewGroup.add(brushPreviewFill);
} const brushPreviewBorderInner = new Konva.Circle({
fill.setAttrs({ id: TOOL_PREVIEW_BRUSH_BORDER_INNER_ID,
x: cursorPos.x,
y: cursorPos.y,
radius: brushSize / 2,
fill: rgbColorToString(color),
globalCompositeOperation: tool === 'brush' ? 'source-over' : 'destination-out',
});
// Create and/or update the inner border of the brush preview
let borderInner = layer.findOne<Konva.Circle>(`#${BRUSH_BORDER_INNER_ID}`);
if (!borderInner) {
borderInner = new Konva.Circle({
id: BRUSH_BORDER_INNER_ID,
listening: false, listening: false,
stroke: BRUSH_BORDER_INNER_COLOR, stroke: BRUSH_BORDER_INNER_COLOR,
strokeWidth: 1, strokeWidth: 1,
strokeEnabled: true, strokeEnabled: true,
}); });
layer.add(borderInner); brushPreviewGroup.add(brushPreviewBorderInner);
} const brushPreviewBorderOuter = new Konva.Circle({
borderInner.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius: brushSize / 2 }); id: TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID,
// Create and/or update the outer border of the brush preview
let borderOuter = layer.findOne<Konva.Circle>(`#${BRUSH_BORDER_OUTER_ID}`);
if (!borderOuter) {
borderOuter = new Konva.Circle({
id: BRUSH_BORDER_OUTER_ID,
listening: false, listening: false,
stroke: BRUSH_BORDER_OUTER_COLOR, stroke: BRUSH_BORDER_OUTER_COLOR,
strokeWidth: 1, strokeWidth: 1,
strokeEnabled: true, strokeEnabled: true,
}); });
layer.add(borderOuter); brushPreviewGroup.add(brushPreviewBorderOuter);
toolPreviewLayer.add(brushPreviewGroup);
// Create the rect preview
const rectPreview = new Konva.Rect({ id: TOOL_PREVIEW_RECT_ID, listening: false, stroke: 'white', strokeWidth: 1 });
toolPreviewLayer.add(rectPreview);
}
if (!$isMouseOver.get() || layerCount === 0) {
// We can bail early if the mouse isn't over the stage or there are no layers
toolPreviewLayer.visible(false);
return;
}
toolPreviewLayer.visible(true);
const brushPreviewGroup = stage.findOne<Konva.Group>(`#${TOOL_PREVIEW_BRUSH_GROUP_ID}`);
assert(brushPreviewGroup, 'Brush preview group not found');
const rectPreview = stage.findOne<Konva.Rect>(`#${TOOL_PREVIEW_RECT_ID}`);
assert(rectPreview, 'Rect preview not found');
// No need to render the brush preview if the cursor position or color is missing
if (cursorPos && color && (tool === 'brush' || tool === 'eraser')) {
// Update the fill circle
const brushPreviewFill = brushPreviewGroup.findOne<Konva.Circle>(`#${TOOL_PREVIEW_BRUSH_FILL_ID}`);
brushPreviewFill?.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
radius: brushSize / 2,
fill: rgbColorToString(color),
globalCompositeOperation: tool === 'brush' ? 'source-over' : 'destination-out',
});
// Update the inner border of the brush preview
const brushPreviewInner = toolPreviewLayer.findOne<Konva.Circle>(`#${TOOL_PREVIEW_BRUSH_BORDER_INNER_ID}`);
brushPreviewInner?.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius: brushSize / 2 });
// Update the outer border of the brush preview
const brushPreviewOuter = toolPreviewLayer.findOne<Konva.Circle>(`#${TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID}`);
brushPreviewOuter?.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
radius: brushSize / 2 + 1,
});
brushPreviewGroup.visible(true);
} else {
brushPreviewGroup.visible(false);
}
if (cursorPos && lastMouseDownPos && tool === 'rect') {
const rectPreview = toolPreviewLayer.findOne<Konva.Rect>(`#${TOOL_PREVIEW_RECT_ID}`);
rectPreview?.setAttrs({
x: Math.min(cursorPos.x, lastMouseDownPos.x),
y: Math.min(cursorPos.y, lastMouseDownPos.y),
width: Math.abs(cursorPos.x - lastMouseDownPos.x),
height: Math.abs(cursorPos.y - lastMouseDownPos.y),
});
rectPreview?.visible(true);
} else {
rectPreview?.visible(false);
} }
borderOuter.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
radius: brushSize / 2 + 1,
});
}; };
const renderVectorMaskLayer = ( const renderVectorMaskLayer = (
stage: Konva.Stage, stage: Konva.Stage,
vmLayer: VectorMaskLayer, vmLayer: VectorMaskLayer,
vmLayerIndex: number, vmLayerIndex: number,
tool: RPTool, tool: Tool,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void onLayerPosChanged?: (layerId: string, x: number, y: number) => void
) => { ) => {
let konvaLayer = stage.findOne<Konva.Layer>(`#${vmLayer.id}`); let konvaLayer = stage.findOne<Konva.Layer>(`#${vmLayer.id}`);
@ -233,7 +274,7 @@ const renderVectorMaskLayer = (
let groupNeedsCache = false; let groupNeedsCache = false;
const objectIds = vmLayer.objects.map(mapId); const objectIds = vmLayer.objects.map(mapId);
for (const objectNode of konvaObjectGroup.find(`.${VECTOR_MASK_LAYER_LINE_NAME}`)) { for (const objectNode of konvaObjectGroup.find(selectVectorMaskObjects)) {
if (!objectIds.includes(objectNode.id())) { if (!objectIds.includes(objectNode.id())) {
objectNode.destroy(); objectNode.destroy();
groupNeedsCache = true; groupNeedsCache = true;
@ -272,6 +313,26 @@ const renderVectorMaskLayer = (
vectorMaskLine.stroke(rgbColor); vectorMaskLine.stroke(rgbColor);
groupNeedsCache = true; groupNeedsCache = true;
} }
} else if (reduxObject.kind === 'vector_mask_rect') {
let konvaObject = stage.findOne<Konva.Rect>(`#${reduxObject.id}`);
if (!konvaObject) {
konvaObject = new Konva.Rect({
id: reduxObject.id,
key: reduxObject.id,
name: VECTOR_MASK_LAYER_RECT_NAME,
x: reduxObject.x,
y: reduxObject.y,
width: reduxObject.width,
height: reduxObject.height,
listening: false,
});
konvaObjectGroup.add(konvaObject);
}
// Only update the color if it has changed.
if (konvaObject.fill() !== rgbColor) {
konvaObject.fill(rgbColor);
groupNeedsCache = true;
}
} }
} }
@ -309,7 +370,7 @@ const renderVectorMaskLayer = (
export const renderLayers = ( export const renderLayers = (
stage: Konva.Stage, stage: Konva.Stage,
reduxLayers: Layer[], reduxLayers: Layer[],
tool: RPTool, tool: Tool,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void onLayerPosChanged?: (layerId: string, x: number, y: number) => void
) => { ) => {
const reduxLayerIds = reduxLayers.map(mapId); const reduxLayerIds = reduxLayers.map(mapId);
@ -342,16 +403,17 @@ export const renderBbox = (
stage: Konva.Stage, stage: Konva.Stage,
reduxLayers: Layer[], reduxLayers: Layer[],
selectedLayerId: string | null, selectedLayerId: string | null,
tool: RPTool, tool: Tool,
onBboxChanged: (layerId: string, bbox: IRect | null) => void, onBboxChanged: (layerId: string, bbox: IRect | null) => void,
onBboxMouseDown: (layerId: string) => void onBboxMouseDown: (layerId: string) => void
) => { ) => {
// Hide all bboxes so they don't interfere with getClientRect
for (const bboxRect of stage.find<Konva.Rect>(`.${LAYER_BBOX_NAME}`)) {
bboxRect.visible(false);
bboxRect.listening(false);
}
// No selected layer or not using the move tool - nothing more to do here // No selected layer or not using the move tool - nothing more to do here
if (tool !== 'move') { if (tool !== 'move') {
for (const bboxRect of stage.find<Konva.Rect>(`.${LAYER_BBOX_NAME}`)) {
bboxRect.visible(false);
bboxRect.listening(false);
}
return; return;
} }
@ -406,10 +468,10 @@ export const renderBbox = (
rect.setAttrs({ rect.setAttrs({
visible: true, visible: true,
listening: true, listening: true,
x: bbox.x - 1, x: bbox.x,
y: bbox.y - 1, y: bbox.y,
width: bbox.width + 2, width: bbox.width,
height: bbox.height + 2, height: bbox.height,
stroke: reduxLayer.id === selectedLayerId ? BBOX_SELECTED_STROKE : BBOX_NOT_SELECTED_STROKE, stroke: reduxLayer.id === selectedLayerId ? BBOX_SELECTED_STROKE : BBOX_NOT_SELECTED_STROKE,
}); });
} }