From 642a0de3dd1ca271554b832e4215cace074e7503 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 19 Apr 2024 15:18:26 +1000 Subject: [PATCH] feat(ui): organize layer naming prep for non-rp layer types --- .../util/graph/addRegionalPromptsToGraph.ts | 3 +- .../components/AddLayerButton.tsx | 2 +- .../components/LayerAutoNegativeCombobox.tsx | 7 +- .../components/LayerColorPicker.tsx | 7 +- .../components/LayerListItem.tsx | 13 +- .../regionalPrompts/components/LayerMenu.tsx | 4 +- .../components/LayerVisibilityToggle.tsx | 4 +- .../RegionalPromptsNegativePrompt.tsx | 4 +- .../RegionalPromptsPositivePrompt.tsx | 4 +- .../components/StageComponent.tsx | 17 +- .../regionalPrompts/hooks/layerStateHooks.ts | 32 +- .../regionalPrompts/hooks/mouseEventHooks.ts | 10 +- .../store/regionalPromptsSlice.ts | 267 ++++++++--------- .../regionalPrompts/util/renderers.ts | 278 +++++++++--------- 14 files changed, 340 insertions(+), 312 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/addRegionalPromptsToGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/addRegionalPromptsToGraph.ts index 03e88bcd5c..263dcde55e 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/addRegionalPromptsToGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/addRegionalPromptsToGraph.ts @@ -11,6 +11,7 @@ import { PROMPT_REGION_POSITIVE_COND_INVERTED_PREFIX, PROMPT_REGION_POSITIVE_COND_PREFIX, } from 'features/nodes/util/graph/constants'; +import { isRegionalPromptLayer } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { getRegionalPromptLayerBlobs } from 'features/regionalPrompts/util/getLayerBlobs'; import { size } from 'lodash-es'; import { imagesApi } from 'services/api/endpoints/images'; @@ -22,7 +23,7 @@ export const addRegionalPromptsToGraph = async (state: RootState, graph: NonNull // TODO: Handle non-SDXL // const isSDXL = state.generation.model?.base === 'sdxl'; const layers = state.regionalPrompts.present.layers - .filter((l) => l.kind === 'promptRegionLayer') // We only want the prompt region layers + .filter(isRegionalPromptLayer) // We only want the prompt region layers .filter((l) => l.isVisible) // Only visible layers are rendered on the canvas .filter((l) => l.negativePrompt || l.positivePrompt); // Only layers with prompts get added to the graph diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/AddLayerButton.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/AddLayerButton.tsx index 01107489b7..1791f73cce 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/AddLayerButton.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/AddLayerButton.tsx @@ -8,7 +8,7 @@ export const AddLayerButton = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const onClick = useCallback(() => { - dispatch(layerAdded('promptRegionLayer')); + dispatch(layerAdded('regionalPromptLayer')); }, [dispatch]); return ; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/LayerAutoNegativeCombobox.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerAutoNegativeCombobox.tsx index 0f29b41287..cf98e639e5 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/LayerAutoNegativeCombobox.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerAutoNegativeCombobox.tsx @@ -4,7 +4,8 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { isParameterAutoNegative } from 'features/parameters/types/parameterSchemas'; import { - layerAutoNegativeChanged, + isRegionalPromptLayer, + rpLayerAutoNegativeChanged, selectRegionalPromptsSlice, } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback, useMemo } from 'react'; @@ -25,7 +26,7 @@ const useAutoNegative = (layerId: string) => { () => createSelector(selectRegionalPromptsSlice, (regionalPrompts) => { const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); - assert(layer, `Layer ${layerId} not found`); + assert(isRegionalPromptLayer(layer), `Layer ${layerId} not found or not an RP layer`); return layer.autoNegative; }), [layerId] @@ -44,7 +45,7 @@ const AutoNegativeCombobox = ({ layerId }: Props) => { if (!isParameterAutoNegative(v?.value)) { return; } - dispatch(layerAutoNegativeChanged({ layerId, autoNegative: v.value })); + dispatch(rpLayerAutoNegativeChanged({ layerId, autoNegative: v.value })); }, [dispatch, layerId] ); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/LayerColorPicker.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerColorPicker.tsx index 17012623ae..1ac6706d04 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/LayerColorPicker.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerColorPicker.tsx @@ -3,7 +3,8 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import RgbColorPicker from 'common/components/RgbColorPicker'; import { - promptRegionLayerColorChanged, + isRegionalPromptLayer, + rpLayerColorChanged, selectRegionalPromptsSlice, } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback, useMemo } from 'react'; @@ -20,7 +21,7 @@ export const LayerColorPicker = memo(({ id }: Props) => { () => createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => { const layer = regionalPrompts.present.layers.find((l) => l.id === id); - assert(layer); + assert(isRegionalPromptLayer(layer), `Layer ${id} not found or not an RP layer`); return layer.color; }), [id] @@ -29,7 +30,7 @@ export const LayerColorPicker = memo(({ id }: Props) => { const dispatch = useAppDispatch(); const onColorChange = useCallback( (color: RgbColor) => { - dispatch(promptRegionLayerColorChanged({ layerId: id, color })); + dispatch(rpLayerColorChanged({ layerId: id, color })); }, [dispatch, id] ); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/LayerListItem.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerListItem.tsx index e9ae02bd34..c383e35314 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/LayerListItem.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerListItem.tsx @@ -7,8 +7,9 @@ import { LayerMenu } from 'features/regionalPrompts/components/LayerMenu'; import { LayerVisibilityToggle } from 'features/regionalPrompts/components/LayerVisibilityToggle'; import { RegionalPromptsNegativePrompt } from 'features/regionalPrompts/components/RegionalPromptsNegativePrompt'; import { RegionalPromptsPositivePrompt } from 'features/regionalPrompts/components/RegionalPromptsPositivePrompt'; -import { layerSelected } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { isRegionalPromptLayer, rpLayerSelected } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback } from 'react'; +import { assert } from 'tsafe'; type Props = { id: string; @@ -18,15 +19,13 @@ export const LayerListItem = memo(({ id }: Props) => { const dispatch = useAppDispatch(); const selectedLayer = useAppSelector((s) => s.regionalPrompts.present.selectedLayer); const color = useAppSelector((s) => { - const color = s.regionalPrompts.present.layers.find((l) => l.id === id)?.color; - if (color) { - return rgbaColorToString({ ...color, a: selectedLayer === id ? 1 : 0.35 }); - } - return 'base.700'; + const layer = s.regionalPrompts.present.layers.find((l) => l.id === id); + assert(isRegionalPromptLayer(layer), `Layer ${id} not found or not an RP layer`); + return rgbaColorToString({ ...layer.color, a: selectedLayer === id ? 1 : 0.35 }); }); const onClickCapture = useCallback(() => { // Must be capture so that the layer is selected before deleting/resetting/etc - dispatch(layerSelected(id)); + dispatch(rpLayerSelected(id)); }, [dispatch, id]); return ( diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/LayerMenu.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerMenu.tsx index 7091cc63ab..c69fff907c 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/LayerMenu.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerMenu.tsx @@ -7,7 +7,7 @@ import { layerMovedForward, layerMovedToBack, layerMovedToFront, - layerReset, + rpLayerReset, selectRegionalPromptsSlice, } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback, useMemo } from 'react'; @@ -55,7 +55,7 @@ export const LayerMenu = memo(({ id }: Props) => { dispatch(layerMovedToBack(id)); }, [dispatch, id]); const resetLayer = useCallback(() => { - dispatch(layerReset(id)); + dispatch(rpLayerReset(id)); }, [dispatch, id]); const deleteLayer = useCallback(() => { dispatch(layerDeleted(id)); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/LayerVisibilityToggle.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerVisibilityToggle.tsx index 2a004f262a..a4c96ddea8 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/LayerVisibilityToggle.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerVisibilityToggle.tsx @@ -1,7 +1,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { useLayerIsVisible } from 'features/regionalPrompts/hooks/layerStateHooks'; -import { layerIsVisibleToggled } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { rpLayerIsVisibleToggled } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback } from 'react'; import { PiEyeBold, PiEyeClosedBold } from 'react-icons/pi'; @@ -13,7 +13,7 @@ export const LayerVisibilityToggle = memo(({ id }: Props) => { const dispatch = useAppDispatch(); const isVisible = useLayerIsVisible(id); const onClick = useCallback(() => { - dispatch(layerIsVisibleToggled(id)); + dispatch(rpLayerIsVisibleToggled(id)); }, [dispatch, id]); return ( diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsNegativePrompt.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsNegativePrompt.tsx index 31863643a0..05e94304a2 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsNegativePrompt.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsNegativePrompt.tsx @@ -5,7 +5,7 @@ import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton'; import { PromptPopover } from 'features/prompt/PromptPopover'; import { usePrompt } from 'features/prompt/usePrompt'; import { useLayerNegativePrompt } from 'features/regionalPrompts/hooks/layerStateHooks'; -import { negativePromptChanged } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { rpLayerNegativePromptChanged } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback, useRef } from 'react'; import type { HotkeyCallback } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -22,7 +22,7 @@ export const RegionalPromptsNegativePrompt = memo((props: Props) => { const { t } = useTranslation(); const _onChange = useCallback( (v: string) => { - dispatch(negativePromptChanged({ layerId: props.layerId, prompt: v })); + dispatch(rpLayerNegativePromptChanged({ layerId: props.layerId, prompt: v })); }, [dispatch, props.layerId] ); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsPositivePrompt.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsPositivePrompt.tsx index 2592edf0e8..84cc6c1f57 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsPositivePrompt.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsPositivePrompt.tsx @@ -5,7 +5,7 @@ import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton'; import { PromptPopover } from 'features/prompt/PromptPopover'; import { usePrompt } from 'features/prompt/usePrompt'; import { useLayerPositivePrompt } from 'features/regionalPrompts/hooks/layerStateHooks'; -import { positivePromptChanged } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { rpLayerPositivePromptChanged } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { memo, useCallback, useRef } from 'react'; import type { HotkeyCallback } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -22,7 +22,7 @@ export const RegionalPromptsPositivePrompt = memo((props: Props) => { const { t } = useTranslation(); const _onChange = useCallback( (v: string) => { - dispatch(positivePromptChanged({ layerId: props.layerId, prompt: v })); + dispatch(rpLayerPositivePromptChanged({ layerId: props.layerId, prompt: v })); }, [dispatch, props.layerId] ); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx index 7003c6bbd0..72d2a60c59 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/StageComponent.tsx @@ -6,8 +6,9 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useMouseEvents } from 'features/regionalPrompts/hooks/mouseEventHooks'; import { $cursorPosition, - layerBboxChanged, - layerTranslated, + isRegionalPromptLayer, + rpLayerBboxChanged, + rpLayerTranslated, selectRegionalPromptsSlice, } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { renderBbox, renderBrushPreview, renderLayers } from 'features/regionalPrompts/util/renderers'; @@ -15,11 +16,17 @@ import Konva from 'konva'; import type { IRect } from 'konva/lib/types'; import { atom } from 'nanostores'; import { useCallback, useLayoutEffect } from 'react'; +import { assert } from 'tsafe'; const log = logger('regionalPrompts'); const $stage = atom(null); const selectSelectedLayerColor = createMemoizedSelector(selectRegionalPromptsSlice, (regionalPrompts) => { - return regionalPrompts.present.layers.find((l) => l.id === regionalPrompts.present.selectedLayer)?.color ?? null; + const layer = regionalPrompts.present.layers.find((l) => l.id === regionalPrompts.present.selectedLayer); + if (!layer) { + return null; + } + assert(isRegionalPromptLayer(layer), `Layer ${regionalPrompts.present.selectedLayer} is not an RP layer`); + return layer.color; }); const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElement | null) => { @@ -34,14 +41,14 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem const onLayerPosChanged = useCallback( (layerId: string, x: number, y: number) => { - dispatch(layerTranslated({ layerId, x, y })); + dispatch(rpLayerTranslated({ layerId, x, y })); }, [dispatch] ); const onBboxChanged = useCallback( (layerId: string, bbox: IRect) => { - dispatch(layerBboxChanged({ layerId, bbox })); + dispatch(rpLayerBboxChanged({ layerId, bbox })); }, [dispatch] ); diff --git a/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts b/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts index 51c9793add..8e82142994 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts @@ -1,47 +1,47 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector } from 'app/store/storeHooks'; -import { selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { isRegionalPromptLayer, selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { useMemo } from 'react'; import { assert } from 'tsafe'; export const useLayerPositivePrompt = (layerId: string) => { const selectLayer = useMemo( () => - createSelector( - selectRegionalPromptsSlice, - (regionalPrompts) => regionalPrompts.present.layers.find((l) => l.id === layerId)?.positivePrompt - ), + createSelector(selectRegionalPromptsSlice, (regionalPrompts) => { + const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); + assert(isRegionalPromptLayer(layer), `Layer ${layerId} not found or not an RP layer`); + return layer.positivePrompt; + }), [layerId] ); const prompt = useAppSelector(selectLayer); - assert(prompt !== undefined, `Layer ${layerId} doesn't exist!`); return prompt; }; export const useLayerNegativePrompt = (layerId: string) => { const selectLayer = useMemo( () => - createSelector( - selectRegionalPromptsSlice, - (regionalPrompts) => regionalPrompts.present.layers.find((l) => l.id === layerId)?.negativePrompt - ), + createSelector(selectRegionalPromptsSlice, (regionalPrompts) => { + const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); + assert(isRegionalPromptLayer(layer), `Layer ${layerId} not found or not an RP layer`); + return layer.negativePrompt; + }), [layerId] ); const prompt = useAppSelector(selectLayer); - assert(prompt !== undefined, `Layer ${layerId} doesn't exist!`); return prompt; }; export const useLayerIsVisible = (layerId: string) => { const selectLayer = useMemo( () => - createSelector( - selectRegionalPromptsSlice, - (regionalPrompts) => regionalPrompts.present.layers.find((l) => l.id === layerId)?.isVisible - ), + createSelector(selectRegionalPromptsSlice, (regionalPrompts) => { + const layer = regionalPrompts.present.layers.find((l) => l.id === layerId); + assert(isRegionalPromptLayer(layer), `Layer ${layerId} not found or not an RP layer`); + return layer.isVisible; + }), [layerId] ); const isVisible = useAppSelector(selectLayer); - assert(isVisible !== undefined, `Layer ${layerId} doesn't exist!`); return isVisible; }; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts index 80a3620b0f..ad08f58e8d 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts @@ -5,8 +5,8 @@ import { $cursorPosition, $isMouseDown, $isMouseOver, - lineAdded, - pointsAdded, + rpLayerLineAdded, + rpLayerPointsAdded, } from 'features/regionalPrompts/store/regionalPromptsSlice'; import type Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; @@ -43,7 +43,7 @@ export const useMouseEvents = () => { $isMouseDown.set(true); const tool = getTool(); if (tool === 'brush' || tool === 'eraser') { - dispatch(lineAdded([pos.x, pos.y, pos.x, pos.y])); + dispatch(rpLayerLineAdded([pos.x, pos.y, pos.x, pos.y])); } }, [dispatch] @@ -75,7 +75,7 @@ export const useMouseEvents = () => { } const tool = getTool(); if (getIsFocused(stage) && $isMouseOver.get() && $isMouseDown.get() && (tool === 'brush' || tool === 'eraser')) { - dispatch(pointsAdded([pos.x, pos.y])); + dispatch(rpLayerPointsAdded([pos.x, pos.y])); } }, [dispatch] @@ -111,7 +111,7 @@ export const useMouseEvents = () => { $isMouseDown.set(true); const tool = getTool(); if (tool === 'brush' || tool === 'eraser') { - dispatch(lineAdded([pos.x, pos.y, pos.x, pos.y])); + dispatch(rpLayerLineAdded([pos.x, pos.y, pos.x, 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 65a9fab5ab..9645a547dd 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts @@ -44,14 +44,14 @@ type LayerObject = ImageObject | LineObject | FillRectObject; type LayerBase = { id: string; +}; + +export type RegionalPromptLayer = LayerBase & { isVisible: boolean; x: number; y: number; bbox: IRect | null; -}; - -type PromptRegionLayer = LayerBase & { - kind: 'promptRegionLayer'; + kind: 'regionalPromptLayer'; objects: LayerObject[]; positivePrompt: string; negativePrompt: string; @@ -59,13 +59,13 @@ type PromptRegionLayer = LayerBase & { autoNegative: ParameterAutoNegative; }; -export type Layer = PromptRegionLayer; +export type Layer = RegionalPromptLayer; type RegionalPromptsState = { _version: 1; tool: Tool; selectedLayer: string | null; - layers: PromptRegionLayer[]; + layers: Layer[]; brushSize: number; promptLayerOpacity: number; }; @@ -80,50 +80,37 @@ export const initialRegionalPromptsState: RegionalPromptsState = { }; const isLine = (obj: LayerObject): obj is LineObject => obj.kind === 'line'; +export const isRegionalPromptLayer = (layer?: Layer): layer is RegionalPromptLayer => + layer?.kind === 'regionalPromptLayer'; export const regionalPromptsSlice = createSlice({ name: 'regionalPrompts', initialState: initialRegionalPromptsState, reducers: { + //#region Meta Layer layerAdded: { reducer: (state, action: PayloadAction) => { - const layer: PromptRegionLayer = { - id: getLayerId(action.meta.uuid), - isVisible: true, - bbox: null, - kind: action.payload, - positivePrompt: '', - negativePrompt: '', - objects: [], - color: action.meta.color, - x: 0, - y: 0, - autoNegative: 'off', - }; - state.layers.push(layer); - state.selectedLayer = layer.id; + if (action.payload === 'regionalPromptLayer') { + const layer: RegionalPromptLayer = { + id: getRPLayerId(action.meta.uuid), + isVisible: true, + bbox: null, + kind: action.payload, + positivePrompt: '', + negativePrompt: '', + objects: [], + color: action.meta.color, + x: 0, + y: 0, + autoNegative: 'off', + }; + state.layers.push(layer); + state.selectedLayer = layer.id; + return; + } }, prepare: (payload: Layer['kind']) => ({ payload, meta: { uuid: uuidv4(), color: LayerColors.next() } }), }, - layerSelected: (state, action: PayloadAction) => { - state.selectedLayer = action.payload; - }, - layerIsVisibleToggled: (state, action: PayloadAction) => { - const layer = state.layers.find((l) => l.id === action.payload); - if (!layer) { - return; - } - layer.isVisible = !layer.isVisible; - }, - layerReset: (state, action: PayloadAction) => { - const layer = state.layers.find((l) => l.id === action.payload); - if (!layer) { - return; - } - layer.objects = []; - layer.bbox = null; - layer.isVisible = true; - }, layerDeleted: (state, action: PayloadAction) => { state.layers = state.layers.filter((l) => l.id !== action.payload); state.selectedLayer = state.layers[0]?.id ?? null; @@ -146,84 +133,111 @@ export const regionalPromptsSlice = createSlice({ // Because the layers are in reverse order, moving to the back is equivalent to moving to the front moveToFront(state.layers, cb); }, - layerTranslated: (state, action: PayloadAction<{ layerId: string; x: number; y: number }>) => { + //#endregion + //#region RP Layers + rpLayerSelected: (state, action: PayloadAction) => { + const layer = state.layers.find((l) => l.id === action.payload); + if (isRegionalPromptLayer(layer)) { + state.selectedLayer = layer.id; + } + }, + rpLayerIsVisibleToggled: (state, action: PayloadAction) => { + const layer = state.layers.find((l) => l.id === action.payload); + if (isRegionalPromptLayer(layer)) { + layer.isVisible = !layer.isVisible; + } + }, + rpLayerReset: (state, action: PayloadAction) => { + const layer = state.layers.find((l) => l.id === action.payload); + if (isRegionalPromptLayer(layer)) { + layer.objects = []; + layer.bbox = null; + layer.isVisible = true; + } + }, + rpLayerTranslated: (state, action: PayloadAction<{ layerId: string; x: number; y: number }>) => { const { layerId, x, y } = action.payload; const layer = state.layers.find((l) => l.id === layerId); - if (!layer) { - return; + if (isRegionalPromptLayer(layer)) { + layer.x = x; + layer.y = y; } - layer.x = x; - layer.y = y; }, - layerBboxChanged: (state, action: PayloadAction<{ layerId: string; bbox: IRect | null }>) => { + rpLayerBboxChanged: (state, action: PayloadAction<{ layerId: string; bbox: IRect | null }>) => { const { layerId, bbox } = action.payload; const layer = state.layers.find((l) => l.id === layerId); - if (!layer) { - return; + if (isRegionalPromptLayer(layer)) { + layer.bbox = bbox; } - layer.bbox = bbox; }, allLayersDeleted: (state) => { state.layers = []; state.selectedLayer = null; }, - positivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => { + rpLayerPositivePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => { const { layerId, prompt } = action.payload; const layer = state.layers.find((l) => l.id === layerId); - if (!layer) { - return; + if (isRegionalPromptLayer(layer)) { + layer.positivePrompt = prompt; } - layer.positivePrompt = prompt; }, - negativePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => { + rpLayerNegativePromptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => { const { layerId, prompt } = action.payload; const layer = state.layers.find((l) => l.id === layerId); - if (!layer) { - return; + if (isRegionalPromptLayer(layer)) { + layer.negativePrompt = prompt; } - layer.negativePrompt = prompt; }, - promptRegionLayerColorChanged: (state, action: PayloadAction<{ layerId: string; color: RgbColor }>) => { + rpLayerColorChanged: (state, action: PayloadAction<{ layerId: string; color: RgbColor }>) => { const { layerId, color } = action.payload; const layer = state.layers.find((l) => l.id === layerId); - if (!layer || layer.kind !== 'promptRegionLayer') { - return; + if (isRegionalPromptLayer(layer)) { + layer.color = color; } - layer.color = color; }, - lineAdded: { + rpLayerLineAdded: { reducer: (state, action: PayloadAction<[number, number, number, number], string, { uuid: string }>) => { const layer = state.layers.find((l) => l.id === state.selectedLayer); - if (!layer || layer.kind !== 'promptRegionLayer') { - return; + if (isRegionalPromptLayer(layer)) { + const lineId = getRPLayerLineId(layer.id, action.meta.uuid); + layer.objects.push({ + kind: 'line', + tool: state.tool, + id: lineId, + points: [ + action.payload[0] - layer.x, + action.payload[1] - layer.y, + action.payload[2] - layer.x, + action.payload[3] - layer.y, + ], + strokeWidth: state.brushSize, + }); } - const lineId = getLayerLineId(layer.id, action.meta.uuid); - layer.objects.push({ - kind: 'line', - tool: state.tool, - id: lineId, - points: [ - action.payload[0] - layer.x, - action.payload[1] - layer.y, - action.payload[2] - layer.x, - action.payload[3] - layer.y, - ], - strokeWidth: state.brushSize, - }); }, prepare: (payload: [number, number, number, number]) => ({ payload, meta: { uuid: uuidv4() } }), }, - pointsAdded: (state, action: PayloadAction<[number, number]>) => { + rpLayerPointsAdded: (state, action: PayloadAction<[number, number]>) => { const layer = state.layers.find((l) => l.id === state.selectedLayer); - if (!layer || layer.kind !== 'promptRegionLayer') { - return; + if (isRegionalPromptLayer(layer)) { + const lastLine = layer.objects.findLast(isLine); + if (!lastLine) { + return; + } + lastLine.points.push(action.payload[0] - layer.x, action.payload[1] - layer.y); } - const lastLine = layer.objects.findLast(isLine); - if (!lastLine) { - return; - } - lastLine.points.push(action.payload[0] - layer.x, action.payload[1] - layer.y); }, + rpLayerAutoNegativeChanged: ( + state, + action: PayloadAction<{ layerId: string; autoNegative: ParameterAutoNegative }> + ) => { + const { layerId, autoNegative } = action.payload; + const layer = state.layers.find((l) => l.id === layerId); + if (isRegionalPromptLayer(layer)) { + layer.autoNegative = autoNegative; + } + }, + //#endregion + //#region General brushSizeChanged: (state, action: PayloadAction) => { state.brushSize = action.payload; }, @@ -233,17 +247,7 @@ export const regionalPromptsSlice = createSlice({ promptLayerOpacityChanged: (state, action: PayloadAction) => { state.promptLayerOpacity = action.payload; }, - layerAutoNegativeChanged: ( - state, - action: PayloadAction<{ layerId: string; autoNegative: ParameterAutoNegative }> - ) => { - const { layerId, autoNegative } = action.payload; - const layer = state.layers.find((l) => l.id === layerId); - if (!layer || layer.kind !== 'promptRegionLayer') { - return; - } - layer.autoNegative = autoNegative; - }, + //#endregion }, }); @@ -272,27 +276,28 @@ class LayerColors { } export const { - layerAdded, - layerSelected, - layerReset, - layerDeleted, - layerIsVisibleToggled, - positivePromptChanged, - negativePromptChanged, - lineAdded, - pointsAdded, - promptRegionLayerColorChanged, - brushSizeChanged, - layerMovedForward, - layerMovedToFront, - layerMovedBackward, - layerMovedToBack, - toolChanged, - layerTranslated, - layerBboxChanged, - promptLayerOpacityChanged, allLayersDeleted, - layerAutoNegativeChanged, + brushSizeChanged, + layerAdded, + layerDeleted, + layerMovedBackward, + layerMovedForward, + layerMovedToBack, + layerMovedToFront, + promptLayerOpacityChanged, + toolChanged, + // Regional Prompt layer actions + rpLayerAutoNegativeChanged, + rpLayerBboxChanged, + rpLayerColorChanged, + rpLayerIsVisibleToggled, + rpLayerLineAdded, + rpLayerNegativePromptChanged, + rpLayerPointsAdded, + rpLayerPositivePromptChanged, + rpLayerReset, + rpLayerSelected, + rpLayerTranslated, } = regionalPromptsSlice.actions; export const selectRegionalPromptsSlice = (state: RootState) => state.regionalPrompts; @@ -319,11 +324,11 @@ export const REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME = 'regionalPromptLayerObjec export const REGIONAL_PROMPT_LAYER_BBOX_NAME = 'regionalPromptLayerBbox'; // Getters for non-singleton layer and object IDs -const getLayerId = (layerId: string) => `layer_${layerId}`; -const getLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`; -export const getLayerObjectGroupId = (layerId: string, groupId: string) => `${layerId}.objectGroup_${groupId}`; -export const getLayerBboxId = (layerId: string) => `${layerId}.bbox`; -export const getLayerTransparencyRectId = (layerId: string) => `${layerId}.transparency_rect`; +const getRPLayerId = (layerId: string) => `rp_layer_${layerId}`; +const getRPLayerLineId = (layerId: string, lineId: string) => `${layerId}.line_${lineId}`; +export const getRPLayerObjectGroupId = (layerId: string, groupId: string) => `${layerId}.objectGroup_${groupId}`; +export const getPRLayerBboxId = (layerId: string) => `${layerId}.bbox`; +export const getRPLayerTransparencyRectId = (layerId: string) => `${layerId}.transparency_rect`; export const regionalPromptsPersistConfig: PersistConfig = { name: regionalPromptsSlice.name, @@ -339,11 +344,11 @@ export const clearHistoryRegionalPrompts = createAction(`${regionalPromptsSlice. // These actions are _individually_ grouped together as single undoable actions const undoableGroupByMatcher = isAnyOf( - positivePromptChanged, - negativePromptChanged, brushSizeChanged, - layerTranslated, - promptLayerOpacityChanged + promptLayerOpacityChanged, + rpLayerPositivePromptChanged, + rpLayerNegativePromptChanged, + rpLayerTranslated ); const LINE_1 = 'LINE_1'; @@ -355,13 +360,13 @@ export const regionalPromptsUndoableConfig: UndoableOptions { - // Lines are started with `lineAdded` and may have any number of subsequent `pointsAdded` events. We can use a - // double-buffering-ish trick to group each logical line as a single undoable action, without grouping separate - // logical lines as a single undo action. - if (lineAdded.match(action)) { + // Lines are started with `rpLayerLineAdded` and may have any number of subsequent `rpLayerPointsAdded` events. + // We can use a double-buffer-esque trick to group each "logical" line as a single undoable action, without grouping + // separate logical lines as a single undo action. + if (rpLayerLineAdded.match(action)) { return history.group === LINE_1 ? LINE_2 : LINE_1; } - if (pointsAdded.match(action)) { + if (rpLayerPointsAdded.match(action)) { if (history.group === LINE_1 || history.group === LINE_2) { return history.group; } @@ -378,7 +383,7 @@ export const regionalPromptsUndoableConfig: UndoableOptions void +) => { + let konvaLayer = stage.findOne(`#${rpLayer.id}`); + + if (!konvaLayer) { + // This layer hasn't been added to the konva state yet + konvaLayer = new Konva.Layer({ + id: rpLayer.id, + name: REGIONAL_PROMPT_LAYER_NAME, + draggable: true, + }); + + // Create a `dragmove` listener for this layer + if (onLayerPosChanged) { + konvaLayer.on('dragend', function (e) { + onLayerPosChanged(rpLayer.id, e.target.x(), e.target.y()); + }); + } + + // The dragBoundFunc limits how far the layer can be dragged + konvaLayer.dragBoundFunc(function (pos) { + const cursorPos = getScaledCursorPosition(stage); + if (!cursorPos) { + return this.getAbsolutePosition(); + } + // Prevent the user from dragging the layer out of the stage bounds. + if ( + cursorPos.x < 0 || + cursorPos.x > stage.width() / stage.scaleX() || + cursorPos.y < 0 || + cursorPos.y > stage.height() / stage.scaleY() + ) { + return this.getAbsolutePosition(); + } + return pos; + }); + + // The object group holds all of the layer's objects (e.g. lines and rects) + const konvaObjectGroup = new Konva.Group({ + id: getRPLayerObjectGroupId(rpLayer.id, uuidv4()), + name: REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME, + listening: false, + }); + konvaLayer.add(konvaObjectGroup); + + // To achieve performant transparency, we use the `source-in` blending mode on a rect that covers the entire layer. + // The brush strokes group functions as a mask for this rect, which has the layer's fill and opacity. The brush + // strokes' color doesn't matter - the only requirement is that they are not transparent. + const transparencyRect = new Konva.Rect({ + id: getRPLayerTransparencyRectId(rpLayer.id), + globalCompositeOperation: 'source-in', + listening: false, + }); + konvaLayer.add(transparencyRect); + + stage.add(konvaLayer); + + // When a layer is added, it ends up on top of the brush preview - we need to move the preview back to the top. + stage.findOne(`#${BRUSH_PREVIEW_LAYER_ID}`)?.moveToTop(); + } + + // Update the layer's position and listening state (only the selected layer is listening) + konvaLayer.setAttrs({ + listening: rpLayer.id === selectedLayerId && tool === 'move', + x: rpLayer.x, + y: rpLayer.y, + // There are rpLayers.length layers, plus a brush preview layer rendered on top of them, so the zIndex works + // out to be the layerIndex. If more layers are added, this may no longer be true. + zIndex: rpLayerIndex, + }); + + const color = rgbColorToString(rpLayer.color); + const konvaObjectGroup = konvaLayer.findOne(`.${REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME}`); + assert(konvaObjectGroup, `Object group not found for layer ${rpLayer.id}`); + const transparencyRect = konvaLayer.findOne(`#${getRPLayerTransparencyRectId(rpLayer.id)}`); + assert(transparencyRect, `Transparency rect not found for layer ${rpLayer.id}`); + + // Remove deleted objects + const objectIds = rpLayer.objects.map(mapId); + for (const objectNode of konvaLayer.find(`.${REGIONAL_PROMPT_LAYER_LINE_NAME}`)) { + if (!objectIds.includes(objectNode.id())) { + objectNode.destroy(); + } + } + + for (const reduxObject of rpLayer.objects) { + // TODO: Handle rects, images, etc + if (reduxObject.kind !== 'line') { + continue; + } + + let konvaObject = stage.findOne(`#${reduxObject.id}`); + + if (!konvaObject) { + // This object hasn't been added to the konva state yet. + konvaObject = new Konva.Line({ + id: reduxObject.id, + key: reduxObject.id, + name: REGIONAL_PROMPT_LAYER_LINE_NAME, + strokeWidth: reduxObject.strokeWidth, + tension: 0, + lineCap: 'round', + lineJoin: 'round', + shadowForStrokeEnabled: false, + globalCompositeOperation: reduxObject.tool === 'brush' ? 'source-over' : 'destination-out', + listening: false, + }); + konvaObjectGroup.add(konvaObject); + } + + // 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() !== rpLayer.isVisible) { + konvaObject.visible(rpLayer.isVisible); + } + } + + // Set the layer opacity - must happen after all objects are added to the layer so the rect is the right size + transparencyRect.setAttrs({ + ...konvaLayer.getClientRect({ skipTransform: true }), + fill: color, + opacity: layerOpacity, + }); +}; + /** * Renders the layers on the stage. * @param stage The konva stage to render on. @@ -154,134 +293,9 @@ export const renderLayers = ( for (let layerIndex = 0; layerIndex < reduxLayers.length; layerIndex++) { const reduxLayer = reduxLayers[layerIndex]; assert(reduxLayer, `Layer at index ${layerIndex} is undefined`); - let konvaLayer = stage.findOne(`#${reduxLayer.id}`); - - if (!konvaLayer) { - // This layer hasn't been added to the konva state yet - konvaLayer = new Konva.Layer({ - id: reduxLayer.id, - name: REGIONAL_PROMPT_LAYER_NAME, - draggable: true, - }); - - // Create a `dragmove` listener for this layer - if (onLayerPosChanged) { - konvaLayer.on('dragend', function (e) { - onLayerPosChanged(reduxLayer.id, e.target.x(), e.target.y()); - }); - } - - // The dragBoundFunc limits how far the layer can be dragged - konvaLayer.dragBoundFunc(function (pos) { - const cursorPos = getScaledCursorPosition(stage); - if (!cursorPos) { - return this.getAbsolutePosition(); - } - // Prevent the user from dragging the layer out of the stage bounds. - if ( - cursorPos.x < 0 || - cursorPos.x > stage.width() / stage.scaleX() || - cursorPos.y < 0 || - cursorPos.y > stage.height() / stage.scaleY() - ) { - return this.getAbsolutePosition(); - } - return pos; - }); - - // The object group holds all of the layer's objects (e.g. lines and rects) - const konvaObjectGroup = new Konva.Group({ - id: getLayerObjectGroupId(reduxLayer.id, uuidv4()), - name: REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME, - listening: false, - }); - konvaLayer.add(konvaObjectGroup); - - // To achieve performant transparency, we use the `source-in` blending mode on a rect that covers the entire layer. - // The brush strokes group functions as a mask for this rect, which has the layer's fill and opacity. The brush - // strokes' color doesn't matter - the only requirement is that they are not transparent. - const transparencyRect = new Konva.Rect({ - id: getLayerTransparencyRectId(reduxLayer.id), - globalCompositeOperation: 'source-in', - listening: false, - }); - konvaLayer.add(transparencyRect); - - stage.add(konvaLayer); - - // When a layer is added, it ends up on top of the brush preview - we need to move the preview back to the top. - stage.findOne(`#${BRUSH_PREVIEW_LAYER_ID}`)?.moveToTop(); + if (reduxLayer.kind === 'regionalPromptLayer') { + renderRPLayer(stage, reduxLayer, layerIndex, selectedLayerId, tool, layerOpacity, onLayerPosChanged); } - - // Update the layer's position and listening state (only the selected layer is listening) - konvaLayer.setAttrs({ - listening: reduxLayer.id === selectedLayerId && tool === 'move', - x: reduxLayer.x, - y: reduxLayer.y, - // There are reduxLayers.length layers, plus a brush preview layer rendered on top of them, so the zIndex works - // out to be the layerIndex. If more layers are added, this may no longer be true. - zIndex: layerIndex, - }); - - const color = rgbColorToString(reduxLayer.color); - const konvaObjectGroup = konvaLayer.findOne(`.${REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME}`); - assert(konvaObjectGroup, `Object group not found for layer ${reduxLayer.id}`); - const transparencyRect = konvaLayer.findOne(`#${getLayerTransparencyRectId(reduxLayer.id)}`); - assert(transparencyRect, `Transparency rect not found for layer ${reduxLayer.id}`); - - // Remove deleted objects - const objectIds = reduxLayer.objects.map(mapId); - for (const objectNode of konvaLayer.find(`.${REGIONAL_PROMPT_LAYER_LINE_NAME}`)) { - if (!objectIds.includes(objectNode.id())) { - objectNode.destroy(); - } - } - - for (const reduxObject of reduxLayer.objects) { - // TODO: Handle rects, images, etc - if (reduxObject.kind !== 'line') { - continue; - } - - let konvaObject = stage.findOne(`#${reduxObject.id}`); - - if (!konvaObject) { - // This object hasn't been added to the konva state yet. - konvaObject = new Konva.Line({ - id: reduxObject.id, - key: reduxObject.id, - name: REGIONAL_PROMPT_LAYER_LINE_NAME, - strokeWidth: reduxObject.strokeWidth, - tension: 0, - lineCap: 'round', - lineJoin: 'round', - shadowForStrokeEnabled: false, - globalCompositeOperation: reduxObject.tool === 'brush' ? 'source-over' : 'destination-out', - listening: false, - }); - konvaObjectGroup.add(konvaObject); - } - - // 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); - } - } - - // Set the layer opacity - must happen after all objects are added to the layer so the rect is the right size - transparencyRect.setAttrs({ - ...konvaLayer.getClientRect({ skipTransform: true }), - fill: color, - opacity: layerOpacity, - }); } }; @@ -322,7 +336,7 @@ export const renderBbox = ( let rect = konvaLayer.findOne(`.${REGIONAL_PROMPT_LAYER_BBOX_NAME}`); if (!rect) { rect = new Konva.Rect({ - id: getLayerBboxId(selectedLayerId), + id: getPRLayerBboxId(selectedLayerId), name: REGIONAL_PROMPT_LAYER_BBOX_NAME, strokeWidth: 1, });