diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/imperative/konvaApiDraft.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/imperative/konvaApiDraft.tsx index 47460ecef6..5ec3cf0e76 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/imperative/konvaApiDraft.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/imperative/konvaApiDraft.tsx @@ -6,6 +6,11 @@ import { rgbColorToString } from 'features/canvas/util/colorToString'; import getScaledCursorPosition from 'features/canvas/util/getScaledCursorPosition'; import { $cursorPosition, + BRUSH_PREVIEW_BORDER_INNER_ID, + BRUSH_PREVIEW_BORDER_OUTER_ID, + BRUSH_PREVIEW_FILL_ID, + BRUSH_PREVIEW_LAYER_ID, + getPromptRegionLayerObjectGroupId, layerBboxChanged, layerSelected, layerTranslated, @@ -17,6 +22,7 @@ import Konva from 'konva'; import type { Node, NodeConfig } from 'konva/lib/Node'; import { atom } from 'nanostores'; import { useLayoutEffect } from 'react'; +import { v4 as uuidv4 } from 'uuid'; import { useMouseDown, useMouseEnter, useMouseLeave, useMouseMove, useMouseUp } from './mouseEventHooks'; @@ -29,10 +35,6 @@ type Props = { export const selectPromptLayerObjectGroup = (item: Node) => item.name() !== REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME; -const BRUSH_PREVIEW_FILL = 'brushPreviewFill'; -const BRUSH_PREVIEW_OUTLINE_INNER = 'brushPreviewOutlineInner'; -const BRUSH_PREVIEW_OUTLINE_OUTER = 'brushPreviewOutlineOuter'; - const isKonvaLayer = (node: Node): node is Konva.Layer => node.nodeType === 'Layer'; const isKonvaLine = (node: Node): node is Konva.Line => node.nodeType === 'Line'; const isKonvaGroup = (node: Node): node is Konva.Group => node.nodeType === 'Group'; @@ -41,8 +43,8 @@ const isKonvaRect = (node: Node): node is Konva.Rect => node.nodeTyp const $brushPreviewNodes = atom<{ layer: Konva.Layer; fill: Konva.Circle; - outlineInner: Konva.Circle; - outlineOuter: Konva.Circle; + borderInner: Konva.Circle; + borderOuter: Konva.Circle; } | null>(null); export const LogicalStage = (props: Props) => { @@ -68,32 +70,32 @@ export const LogicalStage = (props: Props) => { container: props.container, }); - const brushPreviewLayer = new Konva.Layer({ id: 'brushPreviewLayer' }); + const brushPreviewLayer = new Konva.Layer({ id: BRUSH_PREVIEW_LAYER_ID }); stage.add(brushPreviewLayer); const fill = new Konva.Circle({ - id: BRUSH_PREVIEW_FILL, + id: BRUSH_PREVIEW_FILL_ID, listening: false, strokeEnabled: false, strokeHitEnabled: false, }); - const outlineInner = new Konva.Circle({ - id: BRUSH_PREVIEW_OUTLINE_INNER, + const borderInner = new Konva.Circle({ + id: BRUSH_PREVIEW_BORDER_INNER_ID, listening: false, stroke: 'rgba(0,0,0,1)', strokeWidth: 1, strokeEnabled: true, }); - const outlineOuter = new Konva.Circle({ - id: BRUSH_PREVIEW_OUTLINE_OUTER, + const borderOuter = new Konva.Circle({ + id: BRUSH_PREVIEW_BORDER_OUTER_ID, listening: false, stroke: 'rgba(255,255,255,0.8)', strokeWidth: 1, strokeEnabled: true, }); brushPreviewLayer.add(fill); - brushPreviewLayer.add(outlineInner); - brushPreviewLayer.add(outlineOuter); - $brushPreviewNodes.set({ layer: brushPreviewLayer, fill, outlineInner, outlineOuter }); + brushPreviewLayer.add(borderInner); + brushPreviewLayer.add(borderOuter); + $brushPreviewNodes.set({ layer: brushPreviewLayer, fill, borderInner: borderInner, borderOuter: borderOuter }); $stage.set(stage); @@ -156,8 +158,8 @@ export const LogicalStage = (props: Props) => { fill, globalCompositeOperation: state.tool === 'brush' ? 'source-over' : 'destination-out', }); - brushPreviewNodes.outlineInner.setAttrs({ x: cursorPosition.x, y: cursorPosition.y, radius: state.brushSize / 2 }); - brushPreviewNodes.outlineOuter.setAttrs({ + brushPreviewNodes.borderInner.setAttrs({ x: cursorPosition.x, y: cursorPosition.y, radius: state.brushSize / 2 }); + brushPreviewNodes.borderOuter.setAttrs({ x: cursorPosition.x, y: cursorPosition.y, radius: state.brushSize / 2 + 1, @@ -189,6 +191,8 @@ export const LogicalStage = (props: Props) => { name: REGIONAL_PROMPT_LAYER_NAME, draggable: true, listening: reduxLayer.id === state.selectedLayer, + x: reduxLayer.x, + y: reduxLayer.y, }); konvaLayer.on('dragmove', function (e) { dispatch( @@ -212,12 +216,22 @@ export const LogicalStage = (props: Props) => { return pos; }); stage.add(konvaLayer); + konvaLayer.add( + new Konva.Group({ + id: getPromptRegionLayerObjectGroupId(reduxLayer.id, uuidv4()), + name: REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME, + listening: false, + }) + ); $brushPreviewNodes.get()?.layer.moveToTop(); } else { konvaLayer.listening(reduxLayer.id === state.selectedLayer); + konvaLayer.x(reduxLayer.x); + konvaLayer.y(reduxLayer.y); } const color = rgbColorToString(reduxLayer.color); + const konvaObjectGroup = konvaLayer.findOne(`.${REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME}`) as Konva.Group; // Remove deleted objects const objectIds = reduxLayer.objects.map((o) => o.id); @@ -228,35 +242,41 @@ export const LogicalStage = (props: Props) => { } for (const reduxObject of reduxLayer.objects) { + // TODO: Handle rects, images, etc if (reduxObject.kind !== 'line') { return; } - let konvaObject = stage.findOne(`#${reduxObject.id}`) as Konva.Line | undefined; + const konvaObject = stage.findOne(`#${reduxObject.id}`) as Konva.Line | undefined; if (!konvaObject) { - konvaObject = new Konva.Line({ - id: reduxObject.id, - key: reduxObject.id, - name: `${reduxLayer.id}-object`, - points: reduxObject.points, - strokeWidth: reduxObject.strokeWidth, - stroke: color, - tension: 0, - lineCap: 'round', - lineJoin: 'round', - shadowForStrokeEnabled: false, - globalCompositeOperation: reduxObject.tool === 'brush' ? 'source-over' : 'destination-out', - listening: false, - visible: reduxLayer.isVisible, - }); - konvaLayer.add(konvaObject); + // This object hasn't been added to the konva state yet. + konvaObjectGroup.add( + new Konva.Line({ + id: reduxObject.id, + key: reduxObject.id, + name: `${reduxLayer.id}-object`, + points: reduxObject.points, + strokeWidth: reduxObject.strokeWidth, + stroke: color, + tension: 0, + lineCap: 'round', + lineJoin: 'round', + shadowForStrokeEnabled: false, + globalCompositeOperation: reduxObject.tool === 'brush' ? 'source-over' : 'destination-out', + listening: false, + visible: reduxLayer.isVisible, + }) + ); } else { + // Only update the points if they have changed. The point values are never mutated, they are only added to the array. if (konvaObject.points().length !== reduxObject.points.length) { konvaObject.points(reduxObject.points); } + // Only update the color if it has changed. if (konvaObject.stroke() !== color) { konvaObject.stroke(color); } + // Only update layer visibility if it has changed. if (konvaObject.visible() !== reduxLayer.isVisible) { konvaObject.visible(reduxLayer.isVisible); } @@ -269,6 +289,14 @@ export const LogicalStage = (props: Props) => { if (!stage) { return; } + stage.container().style.cursor = state.tool === 'move' ? 'default' : 'none'; + }, [stage, state.tool]); + + useLayoutEffect(() => { + console.log('bbox effect'); + if (!stage) { + return; + } if (state.tool !== 'move') { // Tool was just changed to something other than move - hide all layer bounding boxes diff --git a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts index 88dfb013e8..eedd3096ea 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts @@ -83,12 +83,22 @@ export const regionalPromptsSlice = createSlice({ initialState: initialRegionalPromptsState, reducers: { layerAdded: { - reducer: (state, action: PayloadAction) => { - const newLayer = buildLayer(action.meta.id, action.payload, state.layers.length); - state.layers.push(newLayer); - state.selectedLayer = newLayer.id; + reducer: (state, action: PayloadAction) => { + const layer: PromptRegionLayer = { + id: getPromptRegionLayerId(action.meta.uuid), + isVisible: true, + bbox: null, + kind: action.payload, + prompt: '', + objects: [], + color: action.meta.color, + x: 0, + y: 0, + }; + state.layers.push(layer); + state.selectedLayer = layer.id; }, - prepare: (payload: Layer['kind']) => ({ payload, meta: { id: uuidv4() } }), + prepare: (payload: Layer['kind']) => ({ payload, meta: { uuid: uuidv4(), color: LayerColors.next() } }), }, layerSelected: (state, action: PayloadAction) => { state.selectedLayer = action.payload; @@ -166,20 +176,21 @@ export const regionalPromptsSlice = createSlice({ layer.color = color; }, lineAdded: { - reducer: (state, action: PayloadAction<[number, number], string, { id: string }>) => { + reducer: (state, action: PayloadAction<[number, number], string, { uuid: string }>) => { const layer = state.layers.find((l) => l.id === state.selectedLayer); if (!layer || layer.kind !== 'promptRegionLayer') { return; } + const lineId = getPromptRegionLayerLineId(layer.id, action.meta.uuid); layer.objects.push({ kind: 'line', tool: state.tool, - id: action.meta.id, + id: lineId, points: [action.payload[0] - layer.x, action.payload[1] - layer.y], strokeWidth: state.brushSize, }); }, - prepare: (payload: [number, number]) => ({ payload, meta: { id: uuidv4() } }), + prepare: (payload: [number, number]) => ({ payload, meta: { uuid: uuidv4() } }), }, pointsAdded: (state, action: PayloadAction<[number, number]>) => { const layer = state.layers.find((l) => l.id === state.selectedLayer); @@ -204,33 +215,29 @@ export const regionalPromptsSlice = createSlice({ }, }); -const DEFAULT_COLORS = [ - { r: 200, g: 0, b: 0 }, - { r: 0, g: 200, b: 0 }, - { r: 0, g: 0, b: 200 }, - { r: 200, g: 200, b: 0 }, - { r: 0, g: 200, b: 200 }, - { r: 200, g: 0, b: 200 }, -]; - -const buildLayer = (id: string, kind: Layer['kind'], layerCount: number): Layer => { - if (kind === 'promptRegionLayer') { - const color = DEFAULT_COLORS[layerCount % DEFAULT_COLORS.length]; - assert(color, 'Color not found'); - return { - id, - isVisible: true, - bbox: null, - kind, - prompt: '', - objects: [], - color, - x: 0, - y: 0, - }; +/** + * This class is used to cycle through a set of colors for the prompt region layers. + */ +class LayerColors { + static COLORS: RgbColor[] = [ + { r: 200, g: 0, b: 0 }, + { r: 0, g: 200, b: 0 }, + { r: 0, g: 0, b: 200 }, + { r: 200, g: 200, b: 0 }, + { r: 0, g: 200, b: 200 }, + { r: 200, g: 0, b: 200 }, + ]; + static i = this.COLORS.length - 1; + /** + * Get the next color in the sequence. + */ + static next(): RgbColor { + this.i = (this.i + 1) % this.COLORS.length; + const color = this.COLORS[this.i]; + assert(color); + return color; } - assert(false, `Unknown layer kind: ${kind}`); -}; +} export const { layerAdded, @@ -276,5 +283,12 @@ export const getStage = (): Stage => { assert(stage); return stage; }; +export const BRUSH_PREVIEW_LAYER_ID = 'brushPreviewLayer'; +export const BRUSH_PREVIEW_FILL_ID = 'brushPreviewFill'; +export const BRUSH_PREVIEW_BORDER_INNER_ID = 'brushPreviewBorderInner'; +export const BRUSH_PREVIEW_BORDER_OUTER_ID = 'brushPreviewBorderOuter'; export const REGIONAL_PROMPT_LAYER_NAME = 'regionalPromptLayer'; -export const REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME = 'regionalPromptLayerObjectGroup'; \ No newline at end of file +export const REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME = 'regionalPromptLayerObjectGroup'; +export const getPromptRegionLayerId = (layerId: string) => `layer_${layerId}`; +export const getPromptRegionLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`; +export const getPromptRegionLayerObjectGroupId = (layerId: string, groupId: string) => `${layerId}.objectGroup_${groupId}`;