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",
"toggleVisibility": "Toggle Layer Visibility",
"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 {
$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');

View File

@ -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 (
<ButtonGroup isAttached>
@ -31,6 +37,7 @@ export const ToolChooser: React.FC = () => {
icon={<PiPaintBrushBold />}
variant={tool === 'brush' ? 'solid' : 'outline'}
onClick={setToolToBrush}
isDisabled={isDisabled}
/>
<IconButton
aria-label={`${t('unifiedCanvas.eraser')} (E)`}
@ -38,6 +45,15 @@ export const ToolChooser: React.FC = () => {
icon={<PiEraserBold />}
variant={tool === 'eraser' ? 'solid' : 'outline'}
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
aria-label={`${t('unifiedCanvas.move')} (V)`}
@ -45,6 +61,7 @@ export const ToolChooser: React.FC = () => {
icon={<PiArrowsOutCardinalBold />}
variant={tool === 'move' ? 'solid' : 'outline'}
onClick={setToolToMove}
isDisabled={isDisabled}
/>
</ButtonGroup>
);

View File

@ -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 (
<ButtonGroup>
<IconButton
aria-label={t('unifiedCanvas.undo')}
tooltip={t('unifiedCanvas.undo')}
onClick={undo}
onClick={handleUndo}
icon={<PiArrowCounterClockwiseBold />}
isDisabled={!mayUndo}
/>
<IconButton
aria-label={t('unifiedCanvas.redo')}
tooltip={t('unifiedCanvas.redo')}
onClick={redo}
onClick={handleRedo}
icon={<PiArrowClockwiseBold />}
isDisabled={!mayRedo}
/>

View File

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

View File

@ -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<Layer['kind'], string, { uuid: string }>) => {
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<number>) => {
state.brushSize = action.payload;
@ -282,6 +307,18 @@ export const regionalPromptsSlice = createSlice({
isEnabledChanged: (state, action: PayloadAction<boolean>) => {
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<RPTool>('brush');
export const $lastMouseDownPos = atom<Vector2d | null>(null);
export const $tool = atom<Tool>('brush');
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 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<RegionalPromptsState> =
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<RegionalPromptsState, UnknownAction> = {
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<RegionalPromptsState
},
filter: (action, _state, _history) => {
// 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

View File

@ -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<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`);
// Create the layer if it doesn't exist
let layer = stage.findOne<Konva.Layer>(`#${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<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`)
?.visible($tool.get() !== 'move');
?.visible(tool === 'brush' || tool === 'eraser');
});
stage.on('mouseleave', (e) => {
e.target.getStage()?.findOne<Konva.Layer>(`#${TOOL_PREVIEW_LAYER_ID}`)?.visible(false);
});
stage.on('mouseenter', (e) => {
const tool = $tool.get();
e.target
.getStage()
?.findOne<Konva.Layer>(`#${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<Konva.Circle>(`#${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<Konva.Circle>(`#${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<Konva.Circle>(`#${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<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 = (
stage: Konva.Stage,
vmLayer: VectorMaskLayer,
vmLayerIndex: number,
tool: RPTool,
tool: Tool,
onLayerPosChanged?: (layerId: string, x: number, y: number) => void
) => {
let konvaLayer = stage.findOne<Konva.Layer>(`#${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<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 = (
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<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
if (tool !== 'move') {
for (const bboxRect of stage.find<Konva.Rect>(`.${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,
});
}