mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): rects on regional prompt UI
This commit is contained in:
parent
cfddbda578
commit
4895875ded
@ -1522,6 +1522,7 @@
|
||||
"autoNegative": "Auto Negative",
|
||||
"toggleVisibility": "Toggle Layer Visibility",
|
||||
"resetRegion": "Reset Region",
|
||||
"debugLayers": "Debug Layers"
|
||||
"debugLayers": "Debug Layers",
|
||||
"rectangle": "Rectangle"
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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)] }));
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user