diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 5dfa8a1731..c78e7a5fce 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1522,6 +1522,7 @@
"autoNegative": "Auto Negative",
"toggleVisibility": "Toggle Layer Visibility",
"resetRegion": "Reset Region",
- "debugLayers": "Debug Layers"
+ "debugLayers": "Debug Layers",
+ "rectangle": "Rectangle"
}
}
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx
index 64de7b3a3e..3f2465234e 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx
+++ b/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx
@@ -6,6 +6,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useMouseEvents } from 'features/regionalPrompts/hooks/mouseEventHooks';
import {
$cursorPosition,
+ $lastMouseDownPos,
$tool,
isVectorMaskLayer,
layerBboxChanged,
@@ -13,7 +14,7 @@ import {
layerTranslated,
selectRegionalPromptsSlice,
} 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 type { IRect } from 'konva/lib/types';
import { atom } from 'nanostores';
@@ -40,6 +41,7 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem
const tool = useStore($tool);
const { onMouseDown, onMouseUp, onMouseMove, onMouseEnter, onMouseLeave } = useMouseEvents();
const cursorPosition = useStore($cursorPosition);
+ const lastMouseDownPos = useStore($lastMouseDownPos);
const selectedLayerIdColor = useAppSelector(selectSelectedLayerColor);
const onLayerPosChanged = useCallback(
@@ -130,8 +132,8 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem
if (!stage) {
return;
}
- renderToolPreview(stage, tool, selectedLayerIdColor, cursorPosition, state.brushSize);
- }, [stage, tool, cursorPosition, state.brushSize, selectedLayerIdColor]);
+ renderToolPreview(stage, tool, selectedLayerIdColor, cursorPosition, lastMouseDownPos, state.brushSize);
+ }, [stage, tool, selectedLayerIdColor, cursorPosition, lastMouseDownPos, state.brushSize]);
useLayoutEffect(() => {
log.trace('Rendering layers');
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx
index 2fc4a6c380..816f10f34d 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx
+++ b/invokeai/frontend/web/src/features/regionalPrompts/components/ToolChooser.tsx
@@ -1,27 +1,33 @@
import { ButtonGroup, IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
+import { useAppSelector } from 'app/store/storeHooks';
import { $tool } from 'features/regionalPrompts/store/regionalPromptsSlice';
import { useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
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 = () => {
const { t } = useTranslation();
+ const isDisabled = useAppSelector((s) => s.regionalPrompts.present.layers.length === 0);
const tool = useStore($tool);
const setToolToBrush = useCallback(() => {
$tool.set('brush');
}, []);
- useHotkeys('b', setToolToBrush, []);
+ useHotkeys('b', setToolToBrush, { enabled: !isDisabled }, [isDisabled]);
const setToolToEraser = useCallback(() => {
$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(() => {
$tool.set('move');
}, []);
- useHotkeys('v', setToolToMove, []);
+ useHotkeys('v', setToolToMove, { enabled: !isDisabled }, [isDisabled]);
return (
@@ -31,6 +37,7 @@ export const ToolChooser: React.FC = () => {
icon={}
variant={tool === 'brush' ? 'solid' : 'outline'}
onClick={setToolToBrush}
+ isDisabled={isDisabled}
/>
{
icon={}
variant={tool === 'eraser' ? 'solid' : 'outline'}
onClick={setToolToEraser}
+ isDisabled={isDisabled}
+ />
+ }
+ variant={tool === 'rect' ? 'solid' : 'outline'}
+ onClick={setToolToRect}
+ isDisabled={isDisabled}
/>
{
icon={}
variant={tool === 'move' ? 'solid' : 'outline'}
onClick={setToolToMove}
+ isDisabled={isDisabled}
/>
);
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/UndoRedoButtonGroup.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/UndoRedoButtonGroup.tsx
index caabe4aad9..1243796662 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/components/UndoRedoButtonGroup.tsx
+++ b/invokeai/frontend/web/src/features/regionalPrompts/components/UndoRedoButtonGroup.tsx
@@ -1,7 +1,7 @@
/* eslint-disable i18next/no-literal-string */
import { ButtonGroup, IconButton } from '@invoke-ai/ui-library';
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 { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
@@ -12,30 +12,30 @@ export const UndoRedoButtonGroup = memo(() => {
const dispatch = useAppDispatch();
const mayUndo = useAppSelector((s) => s.regionalPrompts.past.length > 0);
- const undo = useCallback(() => {
- dispatch(undoRegionalPrompts());
+ const handleUndo = useCallback(() => {
+ dispatch(undo());
}, [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 redo = useCallback(() => {
- dispatch(redoRegionalPrompts());
+ const handleRedo = useCallback(() => {
+ dispatch(redo());
}, [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 (
}
isDisabled={!mayUndo}
/>
}
isDisabled={!mayRedo}
/>
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts
index f511656a67..7ce11ccf28 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts
+++ b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts
@@ -4,9 +4,11 @@ import {
$cursorPosition,
$isMouseDown,
$isMouseOver,
+ $lastMouseDownPos,
$tool,
maskLayerLineAdded,
maskLayerPointsAdded,
+ maskLayerRectAdded,
} from 'features/regionalPrompts/store/regionalPromptsSlice';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
@@ -58,6 +60,7 @@ export const useMouseEvents = () => {
return;
}
$isMouseDown.set(true);
+ $lastMouseDownPos.set(pos);
if (!selectedLayerId) {
return;
}
@@ -81,12 +84,26 @@ export const useMouseEvents = () => {
if (!stage) {
return;
}
- // const tool = getTool();
- if ((tool === 'brush' || tool === 'eraser') && $isMouseDown.get()) {
- $isMouseDown.set(false);
+ $isMouseDown.set(false);
+ const pos = $cursorPosition.get();
+ 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(
@@ -99,7 +116,6 @@ export const useMouseEvents = () => {
if (!pos || !selectedLayerId) {
return;
}
- // const tool = getTool();
if (getIsFocused(stage) && $isMouseOver.get() && $isMouseDown.get() && (tool === 'brush' || tool === 'eraser')) {
dispatch(maskLayerPointsAdded({ layerId: selectedLayerId, point: [Math.floor(pos.x), Math.floor(pos.y)] }));
}
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts
index 0c2de490c3..ee773e0858 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts
+++ b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts
@@ -1,5 +1,5 @@
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 { moveBackward, moveForward, moveToBack, moveToFront } from 'common/util/arrayUtils';
import type { ParameterAutoNegative } from 'features/parameters/types/parameterSchemas';
@@ -13,7 +13,7 @@ import { v4 as uuidv4 } from 'uuid';
type DrawingTool = 'brush' | 'eraser';
-export type RPTool = DrawingTool | 'move';
+export type Tool = DrawingTool | 'move' | 'rect';
type VectorMaskLine = {
id: string;
@@ -81,7 +81,7 @@ export const initialRegionalPromptsState: RegionalPromptsState = {
brushSize: 100,
brushColor: { r: 255, g: 0, b: 0, a: 1 },
layers: [],
- globalMaskLayerOpacity: 0.5, // This currently doesn't work
+ globalMaskLayerOpacity: 0.5, // this globally changes all mask layers' opacity
isEnabled: false,
};
@@ -92,7 +92,7 @@ export const regionalPromptsSlice = createSlice({
name: 'regionalPrompts',
initialState: initialRegionalPromptsState,
reducers: {
- //#region Any Layers
+ //#region All Layers
layerAdded: {
reducer: (state, action: PayloadAction) => {
const kind = action.payload;
@@ -189,6 +189,7 @@ export const regionalPromptsSlice = createSlice({
state.selectedLayerId = null;
},
//#endregion
+
//#region Mask Layers
maskLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => {
const { layerId, prompt } = action.payload;
@@ -258,6 +259,29 @@ export const regionalPromptsSlice = createSlice({
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: (
state,
action: PayloadAction<{ layerId: string; autoNegative: ParameterAutoNegative }>
@@ -269,6 +293,7 @@ export const regionalPromptsSlice = createSlice({
}
},
//#endregion
+
//#region General
brushSizeChanged: (state, action: PayloadAction) => {
state.brushSize = action.payload;
@@ -282,6 +307,18 @@ export const regionalPromptsSlice = createSlice({
isEnabledChanged: (state, action: PayloadAction) => {
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
},
});
@@ -318,7 +355,7 @@ class LayerColors {
}
export const {
- // Any layer actions
+ // All layer actions
layerAdded,
layerDeleted,
layerMovedBackward,
@@ -331,17 +368,20 @@ export const {
layerBboxChanged,
layerVisibilityToggled,
allLayersDeleted,
- // Vector mask layer actions
+ // Mask layer actions
maskLayerAutoNegativeChanged,
maskLayerPreviewColorChanged,
maskLayerLineAdded,
maskLayerNegativePromptChanged,
maskLayerPointsAdded,
maskLayerPositivePromptChanged,
+ maskLayerRectAdded,
// General actions
isEnabledChanged,
brushSizeChanged,
globalMaskLayerOpacityChanged,
+ undo,
+ redo,
} = regionalPromptsSlice.actions;
export const selectRegionalPromptsSlice = (state: RootState) => state.regionalPrompts;
@@ -353,24 +393,29 @@ const migrateRegionalPromptsState = (state: any): any => {
export const $isMouseDown = atom(false);
export const $isMouseOver = atom(false);
-export const $tool = atom('brush');
+export const $lastMouseDownPos = atom(null);
+export const $tool = atom('brush');
export const $cursorPosition = atom(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 BRUSH_FILL_ID = 'brush_fill';
-export const BRUSH_BORDER_INNER_ID = 'brush_border_inner';
-export const BRUSH_BORDER_OUTER_ID = 'brush_border_outer';
+export const TOOL_PREVIEW_BRUSH_GROUP_ID = 'tool_preview_layer.brush_group';
+export const TOOL_PREVIEW_BRUSH_FILL_ID = 'tool_preview_layer.brush_fill';
+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
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_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';
// Getters for non-singleton layer and object IDs
const getVectorMaskLayerId = (layerId: string) => `${VECTOR_MASK_LAYER_NAME}_${layerId}`;
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) =>
`${layerId}.objectGroup_${groupId}`;
export const getLayerBboxId = (layerId: string) => `${layerId}.bbox`;
@@ -382,28 +427,25 @@ export const regionalPromptsPersistConfig: PersistConfig =
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
const undoableGroupByMatcher = isAnyOf(
+ layerTranslated,
brushSizeChanged,
globalMaskLayerOpacityChanged,
isEnabledChanged,
maskLayerPositivePromptChanged,
maskLayerNegativePromptChanged,
- layerTranslated,
maskLayerPreviewColorChanged
);
+// These are used to group actions into logical lines below (hate typos)
const LINE_1 = 'LINE_1';
const LINE_2 = 'LINE_2';
export const regionalPromptsUndoableConfig: UndoableOptions = {
limit: 64,
- undoType: undoRegionalPrompts.type,
- redoType: redoRegionalPrompts.type,
+ undoType: regionalPromptsSlice.actions.undo.type,
+ redoType: regionalPromptsSlice.actions.redo.type,
groupBy: (action, state, history) => {
// 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
@@ -423,7 +465,7 @@ export const regionalPromptsUndoableConfig: UndoableOptions {
// Ignore all actions from other slices
- if (!action.type.startsWith('regionalPrompts/')) {
+ if (!action.type.startsWith(regionalPromptsSlice.name)) {
return false;
}
// This action is triggered on state changes, including when we undo. If we do not ignore this action, when we
diff --git a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts
index 31d9948dd8..af00af8c85 100644
--- a/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts
+++ b/invokeai/frontend/web/src/features/regionalPrompts/util/renderers.ts
@@ -1,21 +1,24 @@
import { getStore } from 'app/store/nanostores/store';
import { rgbColorToString } from 'features/canvas/util/colorToString';
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 {
$isMouseOver,
$tool,
- BRUSH_BORDER_INNER_ID,
- BRUSH_BORDER_OUTER_ID,
- BRUSH_FILL_ID,
getLayerBboxId,
getVectorMaskLayerObjectGroupId,
isVectorMaskLayer,
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_RECT_ID,
VECTOR_MASK_LAYER_LINE_NAME,
VECTOR_MASK_LAYER_NAME,
VECTOR_MASK_LAYER_OBJECT_GROUP_NAME,
+ VECTOR_MASK_LAYER_RECT_NAME,
} from 'features/regionalPrompts/store/regionalPromptsSlice';
import { getKonvaLayerBbox } from 'features/regionalPrompts/util/bbox';
import Konva from 'konva';
@@ -41,125 +44,163 @@ const getIsSelected = (layerId?: string | null) => {
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.
* @param stage The konva stage to render on.
* @param tool The selected tool.
* @param color The selected layer's color.
* @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.
*/
export const renderToolPreview = (
stage: Konva.Stage,
- tool: RPTool,
+ tool: Tool,
color: RgbColor | null,
cursorPos: Vector2d | null,
+ lastMouseDownPos: Vector2d | null,
brushSize: number
) => {
const layerCount = stage.find(`.${VECTOR_MASK_LAYER_NAME}`).length;
// Update the stage's pointer style
- if (tool === 'move') {
- stage.container().style.cursor = 'default';
- } else if (layerCount === 0) {
+ if (layerCount === 0) {
// We have no layers, so we should not render any tool
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 we use the brush preview
stage.container().style.cursor = 'none';
}
+ let toolPreviewLayer = stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`);
+
// Create the layer if it doesn't exist
- let layer = stage.findOne(`#${TOOL_PREVIEW_LAYER_ID}`);
- if (!layer) {
+ if (!toolPreviewLayer) {
// Initialize the brush preview layer & add to the stage
- layer = new Konva.Layer({ id: TOOL_PREVIEW_LAYER_ID, visible: tool !== 'move', listening: false });
- stage.add(layer);
- // The brush preview is hidden and shown as the mouse leaves and enters the stage
+ toolPreviewLayer = new Konva.Layer({ id: TOOL_PREVIEW_LAYER_ID, visible: tool !== 'move', listening: false });
+ stage.add(toolPreviewLayer);
+
+ // Add handlers to show/hide the brush preview layer
stage.on('mousemove', (e) => {
+ const tool = $tool.get();
e.target
.getStage()
?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)
- ?.visible($tool.get() !== 'move');
+ ?.visible(tool === 'brush' || tool === 'eraser');
});
stage.on('mouseleave', (e) => {
e.target.getStage()?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(false);
});
stage.on('mouseenter', (e) => {
+ const tool = $tool.get();
e.target
.getStage()
?.findOne(`#${TOOL_PREVIEW_LAYER_ID}`)
- ?.visible($tool.get() !== 'move');
+ ?.visible(tool === 'brush' || tool === 'eraser');
});
- }
- if (!$isMouseOver.get()) {
- layer.visible(false);
- return;
- }
-
- // ...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(`#${BRUSH_FILL_ID}`);
- if (!fill) {
- fill = new Konva.Circle({
- id: BRUSH_FILL_ID,
+ // Create the brush preview group & circles
+ const brushPreviewGroup = new Konva.Group({ id: TOOL_PREVIEW_BRUSH_GROUP_ID });
+ const brushPreviewFill = new Konva.Circle({
+ id: TOOL_PREVIEW_BRUSH_FILL_ID,
listening: false,
strokeEnabled: false,
});
- layer.add(fill);
- }
- fill.setAttrs({
- 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(`#${BRUSH_BORDER_INNER_ID}`);
- if (!borderInner) {
- borderInner = new Konva.Circle({
- id: BRUSH_BORDER_INNER_ID,
+ brushPreviewGroup.add(brushPreviewFill);
+ const brushPreviewBorderInner = new Konva.Circle({
+ id: TOOL_PREVIEW_BRUSH_BORDER_INNER_ID,
listening: false,
stroke: BRUSH_BORDER_INNER_COLOR,
strokeWidth: 1,
strokeEnabled: true,
});
- layer.add(borderInner);
- }
- borderInner.setAttrs({ x: cursorPos.x, y: cursorPos.y, radius: brushSize / 2 });
-
- // Create and/or update the outer border of the brush preview
- let borderOuter = layer.findOne(`#${BRUSH_BORDER_OUTER_ID}`);
- if (!borderOuter) {
- borderOuter = new Konva.Circle({
- id: BRUSH_BORDER_OUTER_ID,
+ brushPreviewGroup.add(brushPreviewBorderInner);
+ const brushPreviewBorderOuter = new Konva.Circle({
+ id: TOOL_PREVIEW_BRUSH_BORDER_OUTER_ID,
listening: false,
stroke: BRUSH_BORDER_OUTER_COLOR,
strokeWidth: 1,
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(`#${TOOL_PREVIEW_BRUSH_GROUP_ID}`);
+ assert(brushPreviewGroup, 'Brush preview group not found');
+
+ const rectPreview = stage.findOne(`#${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(`#${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(`#${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(`#${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(`#${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 = (
stage: Konva.Stage,
vmLayer: VectorMaskLayer,
vmLayerIndex: number,
- tool: RPTool,
+ tool: Tool,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void
) => {
let konvaLayer = stage.findOne(`#${vmLayer.id}`);
@@ -233,7 +274,7 @@ const renderVectorMaskLayer = (
let groupNeedsCache = false;
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())) {
objectNode.destroy();
groupNeedsCache = true;
@@ -272,6 +313,26 @@ const renderVectorMaskLayer = (
vectorMaskLine.stroke(rgbColor);
groupNeedsCache = true;
}
+ } else if (reduxObject.kind === 'vector_mask_rect') {
+ let konvaObject = stage.findOne(`#${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 = (
stage: Konva.Stage,
reduxLayers: Layer[],
- tool: RPTool,
+ tool: Tool,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void
) => {
const reduxLayerIds = reduxLayers.map(mapId);
@@ -342,16 +403,17 @@ export const renderBbox = (
stage: Konva.Stage,
reduxLayers: Layer[],
selectedLayerId: string | null,
- tool: RPTool,
+ tool: Tool,
onBboxChanged: (layerId: string, bbox: IRect | null) => void,
onBboxMouseDown: (layerId: string) => void
) => {
+ // Hide all bboxes so they don't interfere with getClientRect
+ for (const bboxRect of stage.find(`.${LAYER_BBOX_NAME}`)) {
+ bboxRect.visible(false);
+ bboxRect.listening(false);
+ }
// No selected layer or not using the move tool - nothing more to do here
if (tool !== 'move') {
- for (const bboxRect of stage.find(`.${LAYER_BBOX_NAME}`)) {
- bboxRect.visible(false);
- bboxRect.listening(false);
- }
return;
}
@@ -406,10 +468,10 @@ export const renderBbox = (
rect.setAttrs({
visible: true,
listening: true,
- x: bbox.x - 1,
- y: bbox.y - 1,
- width: bbox.width + 2,
- height: bbox.height + 2,
+ x: bbox.x,
+ y: bbox.y,
+ width: bbox.width,
+ height: bbox.height,
stroke: reduxLayer.id === selectedLayerId ? BBOX_SELECTED_STROKE : BBOX_NOT_SELECTED_STROKE,
});
}