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, }); }