diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 7ac20649b0..7470d77390 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1505,5 +1505,13 @@ }, "app": { "storeNotInitialized": "Store is not initialized" + }, + "regionalPrompts": { + "addLayer": "Add Layer", + "moveToFront": "Move to Front", + "moveToBack": "Move to Back", + "moveForward": "Move Forward", + "moveBackward": "Move Backward", + "brushSize": "Brush Size" } } diff --git a/invokeai/frontend/web/src/common/util/arrayUtils.test.ts b/invokeai/frontend/web/src/common/util/arrayUtils.test.ts new file mode 100644 index 0000000000..5d0fd090f7 --- /dev/null +++ b/invokeai/frontend/web/src/common/util/arrayUtils.test.ts @@ -0,0 +1,85 @@ +import { moveBackward, moveForward, moveToBack, moveToFront } from 'common/util/arrayUtils'; +import { describe, expect, it } from 'vitest'; + +describe('Array Manipulation Functions', () => { + const originalArray = ['a', 'b', 'c', 'd']; + describe('moveForwardOne', () => { + it('should move an item forward by one position', () => { + const array = [...originalArray]; + const result = moveForward(array, (item) => item === 'b'); + expect(result).toEqual(['a', 'c', 'b', 'd']); + }); + + it('should do nothing if the item is at the end', () => { + const array = [...originalArray]; + const result = moveForward(array, (item) => item === 'd'); + expect(result).toEqual(['a', 'b', 'c', 'd']); + }); + + it("should leave the array unchanged if the item isn't in the array", () => { + const array = [...originalArray]; + const result = moveForward(array, (item) => item === 'z'); + expect(result).toEqual(originalArray); + }); + }); + + describe('moveToFront', () => { + it('should move an item to the front', () => { + const array = [...originalArray]; + const result = moveToFront(array, (item) => item === 'c'); + expect(result).toEqual(['c', 'a', 'b', 'd']); + }); + + it('should do nothing if the item is already at the front', () => { + const array = [...originalArray]; + const result = moveToFront(array, (item) => item === 'a'); + expect(result).toEqual(['a', 'b', 'c', 'd']); + }); + + it("should leave the array unchanged if the item isn't in the array", () => { + const array = [...originalArray]; + const result = moveToFront(array, (item) => item === 'z'); + expect(result).toEqual(originalArray); + }); + }); + + describe('moveBackwardsOne', () => { + it('should move an item backward by one position', () => { + const array = [...originalArray]; + const result = moveBackward(array, (item) => item === 'c'); + expect(result).toEqual(['a', 'c', 'b', 'd']); + }); + + it('should do nothing if the item is at the beginning', () => { + const array = [...originalArray]; + const result = moveBackward(array, (item) => item === 'a'); + expect(result).toEqual(['a', 'b', 'c', 'd']); + }); + + it("should leave the array unchanged if the item isn't in the array", () => { + const array = [...originalArray]; + const result = moveBackward(array, (item) => item === 'z'); + expect(result).toEqual(originalArray); + }); + }); + + describe('moveToBack', () => { + it('should move an item to the back', () => { + const array = [...originalArray]; + const result = moveToBack(array, (item) => item === 'b'); + expect(result).toEqual(['a', 'c', 'd', 'b']); + }); + + it('should do nothing if the item is already at the back', () => { + const array = [...originalArray]; + const result = moveToBack(array, (item) => item === 'd'); + expect(result).toEqual(['a', 'b', 'c', 'd']); + }); + + it("should leave the array unchanged if the item isn't in the array", () => { + const array = [...originalArray]; + const result = moveToBack(array, (item) => item === 'z'); + expect(result).toEqual(originalArray); + }); + }); +}); diff --git a/invokeai/frontend/web/src/common/util/arrayUtils.ts b/invokeai/frontend/web/src/common/util/arrayUtils.ts new file mode 100644 index 0000000000..38c99b63ec --- /dev/null +++ b/invokeai/frontend/web/src/common/util/arrayUtils.ts @@ -0,0 +1,37 @@ +export const moveForward = (array: T[], callback: (item: T) => boolean): T[] => { + const index = array.findIndex(callback); + if (index >= 0 && index < array.length - 1) { + //@ts-expect-error - These indicies are safe per the previous check + [array[index], array[index + 1]] = [array[index + 1], array[index]]; + } + return array; +}; + +export const moveToFront = (array: T[], callback: (item: T) => boolean): T[] => { + const index = array.findIndex(callback); + if (index > 0) { + const [item] = array.splice(index, 1); + //@ts-expect-error - These indicies are safe per the previous check + array.unshift(item); + } + return array; +}; + +export const moveBackward = (array: T[], callback: (item: T) => boolean): T[] => { + const index = array.findIndex(callback); + if (index > 0) { + //@ts-expect-error - These indicies are safe per the previous check + [array[index], array[index - 1]] = [array[index - 1], array[index]]; + } + return array; +}; + +export const moveToBack = (array: T[], callback: (item: T) => boolean): T[] => { + const index = array.findIndex(callback); + if (index >= 0 && index < array.length - 1) { + const [item] = array.splice(index, 1); + //@ts-expect-error - These indicies are safe per the previous check + array.push(item); + } + return array; +}; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/AddLayerButton.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/AddLayerButton.tsx index 2b10cb9676..f39db803c7 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/AddLayerButton.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/AddLayerButton.tsx @@ -2,12 +2,14 @@ import { Button } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { layerAdded } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; export const AddLayerButton = () => { + const { t } = useTranslation(); const dispatch = useAppDispatch(); const onClick = useCallback(() => { dispatch(layerAdded('promptRegionLayer')); }, [dispatch]); - return ; + return ; }; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/BrushPreview.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/BrushPreview.tsx index 4796e4a170..759be3f1ce 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/BrushPreview.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/BrushPreview.tsx @@ -2,9 +2,9 @@ import { useStore } from '@nanostores/react'; import { useAppSelector } from 'app/store/storeHooks'; import { rgbColorToString } from 'features/canvas/util/colorToString'; import { $cursorPosition } from 'features/regionalPrompts/store/regionalPromptsSlice'; -import { Circle } from 'react-konva'; +import { Circle, Group } from 'react-konva'; -export const BrushPreview = () => { +export const BrushPreviewFill = () => { const brushSize = useAppSelector((s) => s.regionalPrompts.brushSize); const color = useAppSelector((s) => { const _color = s.regionalPrompts.layers.find((l) => l.id === s.regionalPrompts.selectedLayer)?.color; @@ -21,3 +21,42 @@ export const BrushPreview = () => { return ; }; + +export const BrushPreviewOutline = () => { + const brushSize = useAppSelector((s) => s.regionalPrompts.brushSize); + const color = useAppSelector((s) => { + const _color = s.regionalPrompts.layers.find((l) => l.id === s.regionalPrompts.selectedLayer)?.color; + if (!_color) { + return null; + } + return rgbColorToString(_color); + }); + const pos = useStore($cursorPosition); + + if (!brushSize || !color || !pos) { + return null; + } + + return ( + + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/BrushSize.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/BrushSize.tsx index be9e5a3b27..67871381cb 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/BrushSize.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/BrushSize.tsx @@ -2,9 +2,11 @@ import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { brushSizeChanged } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; export const BrushSize = () => { const dispatch = useAppDispatch(); + const { t } = useTranslation(); const brushSize = useAppSelector((s) => s.regionalPrompts.brushSize); const onChange = useCallback( (v: number) => { @@ -14,7 +16,7 @@ export const BrushSize = () => { ); return ( - Brush Size + {t('regionalPrompts.brushSize')} diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/DeleteLayerButton.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/DeleteLayerButton.tsx deleted file mode 100644 index dd858aba8c..0000000000 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/DeleteLayerButton.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Button } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { layerDeleted } from 'features/regionalPrompts/store/regionalPromptsSlice'; -import { useCallback } from 'react'; - -type Props = { - id: string; -}; - -export const DeleteLayerButton = ({ id }: Props) => { - const dispatch = useAppDispatch(); - const onClick = useCallback(() => { - dispatch(layerDeleted(id)); - }, [dispatch, id]); - - return ; -}; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/LayerColorPicker.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerColorPicker.tsx index 1edab785f1..c0c221c099 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/LayerColorPicker.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerColorPicker.tsx @@ -2,7 +2,7 @@ import { Popover, PopoverBody, PopoverContent, PopoverTrigger } from '@invoke-ai import { useAppDispatch } from 'app/store/storeHooks'; import { ColorPreview } from 'common/components/ColorPreview'; import RgbColorPicker from 'common/components/RgbColorPicker'; -import { useLayer } from 'features/regionalPrompts/hooks/useLayer'; +import { useLayer } from 'features/regionalPrompts/hooks/layerStateHooks'; import { promptRegionLayerColorChanged } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { useCallback } from 'react'; import type { RgbColor } from 'react-colorful'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/LayerListItem.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerListItem.tsx index d1b6aa03be..ce81c64af2 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/LayerListItem.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerListItem.tsx @@ -1,9 +1,9 @@ import { Flex } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { DeleteLayerButton } from 'features/regionalPrompts/components/DeleteLayerButton'; import { LayerColorPicker } from 'features/regionalPrompts/components/LayerColorPicker'; +import { LayerMenu } from 'features/regionalPrompts/components/LayerMenu'; +import { LayerVisibilityToggle } from 'features/regionalPrompts/components/LayerVisibilityToggle'; import { RegionalPromptsPrompt } from 'features/regionalPrompts/components/RegionalPromptsPrompt'; -import { ResetLayerButton } from 'features/regionalPrompts/components/ResetLayerButton'; import { layerSelected } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { useCallback, useMemo } from 'react'; @@ -14,19 +14,22 @@ type Props = { export const LayerListItem = ({ id }: Props) => { const dispatch = useAppDispatch(); const selectedLayer = useAppSelector((s) => s.regionalPrompts.selectedLayer); - const border = useMemo(() => (selectedLayer === id ? '1px solid red' : 'none'), [selectedLayer, id]); + const bg = useMemo(() => (selectedLayer === id ? 'invokeBlue.500' : 'transparent'), [selectedLayer, id]); const onClickCapture = useCallback(() => { // Must be capture so that the layer is selected before deleting/resetting/etc dispatch(layerSelected(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 new file mode 100644 index 0000000000..2317eda26a --- /dev/null +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerMenu.tsx @@ -0,0 +1,90 @@ +import { IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { + layerDeleted, + layerMovedBackward, + layerMovedForward, + layerMovedToBack, + layerMovedToFront, + layerReset, + selectRegionalPromptsSlice, +} from 'features/regionalPrompts/store/regionalPromptsSlice'; +import type React from 'react'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + PiArrowCounterClockwiseBold, + PiArrowDownBold, + PiArrowLineDownBold, + PiArrowLineUpBold, + PiArrowUpBold, + PiDotsThreeVerticalBold, + PiTrashSimpleBold, +} from 'react-icons/pi'; + +type Props = { id: string }; + +export const LayerMenu: React.FC = ({ id }) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const selectValidActions = useMemo( + () => + createSelector(selectRegionalPromptsSlice, (regionalPrompts) => { + const layerIndex = regionalPrompts.layers.findIndex((l) => l.id === id); + const layerCount = regionalPrompts.layers.length; + return { + canMoveForward: layerIndex < layerCount - 1, + canMoveBackward: layerIndex > 0, + canMoveToFront: layerIndex < layerCount - 1, + canMoveToBack: layerIndex > 0, + }; + }), + [id] + ); + const validActions = useAppSelector(selectValidActions); + const moveForward = useCallback(() => { + dispatch(layerMovedForward(id)); + }, [dispatch, id]); + const moveToFront = useCallback(() => { + dispatch(layerMovedToFront(id)); + }, [dispatch, id]); + const moveBackward = useCallback(() => { + dispatch(layerMovedBackward(id)); + }, [dispatch, id]); + const moveToBack = useCallback(() => { + dispatch(layerMovedToBack(id)); + }, [dispatch, id]); + const resetLayer = useCallback(() => { + dispatch(layerReset(id)); + }, [dispatch, id]); + const deleteLayer = useCallback(() => { + dispatch(layerDeleted(id)); + }, [dispatch, id]); + return ( + + } /> + + }> + {t('regionalPrompts.moveToFront')} + + }> + {t('regionalPrompts.moveForward')} + + }> + {t('regionalPrompts.moveBackward')} + + }> + {t('regionalPrompts.moveToBack')} + + + }> + {t('accessibility.reset')} + + } color="error.300"> + {t('common.delete')} + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/LayerVisibilityToggle.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerVisibilityToggle.tsx new file mode 100644 index 0000000000..bb636e8338 --- /dev/null +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/LayerVisibilityToggle.tsx @@ -0,0 +1,28 @@ +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 { useCallback } from 'react'; +import { PiEyeBold, PiEyeClosedBold } from 'react-icons/pi'; + +type Props = { + id: string; +}; + +export const LayerVisibilityToggle = ({ id }: Props) => { + const dispatch = useAppDispatch(); + const isVisible = useLayerIsVisible(id); + const onClick = useCallback(() => { + dispatch(layerIsVisibleToggled(id)); + }, [dispatch, id]); + + return ( + : } + onClick={onClick} + /> + ); +}; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsEditor.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsEditor.tsx index 525aee02d3..fb81151d9f 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsEditor.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsEditor.tsx @@ -7,18 +7,18 @@ import { LayerListItem } from 'features/regionalPrompts/components/LayerListItem import { RegionalPromptsStage } from 'features/regionalPrompts/components/RegionalPromptsStage'; import { selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; -const selectLayerIds = createSelector(selectRegionalPromptsSlice, (regionalPrompts) => - regionalPrompts.layers.map((l) => l.id) +const selectLayerIdsReversed = createSelector(selectRegionalPromptsSlice, (regionalPrompts) => + regionalPrompts.layers.map((l) => l.id).reverse() ); export const RegionalPromptsEditor = () => { - const layerIds = useAppSelector(selectLayerIds); + const layerIdsReversed = useAppSelector(selectLayerIdsReversed); return ( - + - {layerIds.map((id) => ( + {layerIdsReversed.map((id) => ( ))} diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsPrompt.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsPrompt.tsx index 1ae1f55f67..53ff10fe27 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsPrompt.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsPrompt.tsx @@ -5,7 +5,7 @@ import { PromptOverlayButtonWrapper } from 'features/parameters/components/Promp import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton'; import { PromptPopover } from 'features/prompt/PromptPopover'; import { usePrompt } from 'features/prompt/usePrompt'; -import { useLayer } from 'features/regionalPrompts/hooks/useLayer'; +import { useLayerPrompt } from 'features/regionalPrompts/hooks/layerStateHooks'; import { promptChanged } from 'features/regionalPrompts/store/regionalPromptsSlice'; import { SDXLConcatButton } from 'features/sdxl/components/SDXLPrompts/SDXLConcatButton'; import { memo, useCallback, useRef } from 'react'; @@ -18,21 +18,21 @@ type Props = { }; export const RegionalPromptsPrompt = memo((props: Props) => { - const layer = useLayer(props.layerId); + const prompt = useLayerPrompt(props.layerId); const dispatch = useAppDispatch(); const baseModel = useAppSelector((s) => s.generation.model)?.base; const textareaRef = useRef(null); const { t } = useTranslation(); - const handleChange = useCallback( + const _onChange = useCallback( (v: string) => { dispatch(promptChanged({ layerId: props.layerId, prompt: v })); }, [dispatch, props.layerId] ); const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown, onFocus } = usePrompt({ - prompt: layer.prompt, - textareaRef: textareaRef, - onChange: handleChange, + prompt, + textareaRef, + onChange: _onChange, }); const focus: HotkeyCallback = useCallback( (e) => { @@ -51,7 +51,7 @@ export const RegionalPromptsPrompt = memo((props: Props) => { id="prompt" name="prompt" ref={textareaRef} - value={layer.prompt} + value={prompt} placeholder={t('parameters.positivePromptPlaceholder')} onChange={onChange} minH={28} diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsStage.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsStage.tsx index 1b257f7ae1..a8335444d6 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsStage.tsx +++ b/invokeai/frontend/web/src/features/regionalPrompts/components/RegionalPromptsStage.tsx @@ -1,7 +1,7 @@ import { chakra } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector } from 'app/store/storeHooks'; -import { BrushPreview } from 'features/regionalPrompts/components/BrushPreview'; +import { BrushPreviewFill, BrushPreviewOutline } from 'features/regionalPrompts/components/BrushPreview'; import { LineComponent } from 'features/regionalPrompts/components/LineComponent'; import { RectComponent } from 'features/regionalPrompts/components/RectComponent'; import { @@ -10,13 +10,15 @@ import { useMouseLeave, useMouseMove, useMouseUp, -} from 'features/regionalPrompts/hooks/useMouseDown'; +} from 'features/regionalPrompts/hooks/mouseEventHooks'; import { $stage, selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; import type Konva from 'konva'; import { memo, useCallback, useRef } from 'react'; -import { Group, Layer, Stage } from 'react-konva'; +import { Layer, Stage } from 'react-konva'; -const selectLayers = createSelector(selectRegionalPromptsSlice, (regionalPrompts) => regionalPrompts.layers); +const selectVisibleLayers = createSelector(selectRegionalPromptsSlice, (regionalPrompts) => + regionalPrompts.layers.filter((l) => l.isVisible) +); const ChakraStage = chakra(Stage, { shouldForwardProp: (prop) => !['sx'].includes(prop), @@ -27,7 +29,8 @@ const stageSx = { }; export const RegionalPromptsStage: React.FC = memo(() => { - const layers = useAppSelector(selectLayers); + const layers = useAppSelector(selectVisibleLayers); + const selectedLayer = useAppSelector((s) => s.regionalPrompts.selectedLayer); const stageRef = useRef(null); const onMouseDown = useMouseDown(stageRef); const onMouseUp = useMouseUp(stageRef); @@ -52,23 +55,24 @@ export const RegionalPromptsStage: React.FC = memo(() => { tabIndex={-1} sx={stageSx} > + {layers.map((layer) => ( + + {layer.objects.map((obj) => { + if (obj.kind === 'line') { + return ; + } + if (obj.kind === 'fillRect') { + return ; + } + })} + {layer.id === selectedLayer && } + + ))} - {layers.map((layer) => ( - - {layer.objects.map((obj) => { - if (obj.kind === 'line') { - return ; - } - if (obj.kind === 'fillRect') { - return ; - } - })} - - ))} - + ); }); -RegionalPromptsStage.displayName = 'RegionalPromptingEditor'; +RegionalPromptsStage.displayName = 'RegionalPromptsStage'; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/components/ResetLayerButton.tsx b/invokeai/frontend/web/src/features/regionalPrompts/components/ResetLayerButton.tsx deleted file mode 100644 index 8ce44a2e51..0000000000 --- a/invokeai/frontend/web/src/features/regionalPrompts/components/ResetLayerButton.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Button } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { layerReset } from 'features/regionalPrompts/store/regionalPromptsSlice'; -import { useCallback } from 'react'; - -type Props = { - id: string; -}; - -export const ResetLayerButton = ({ id }: Props) => { - const dispatch = useAppDispatch(); - const onClick = useCallback(() => { - dispatch(layerReset(id)); - }, [dispatch, id]); - - return ; -}; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts b/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts new file mode 100644 index 0000000000..067c0b71d6 --- /dev/null +++ b/invokeai/frontend/web/src/features/regionalPrompts/hooks/layerStateHooks.ts @@ -0,0 +1,46 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; +import { useMemo } from 'react'; +import { assert } from 'tsafe'; + +export const useLayer = (layerId: string) => { + const selectLayer = useMemo( + () => + createSelector(selectRegionalPromptsSlice, (regionalPrompts) => + regionalPrompts.layers.find((l) => l.id === layerId) + ), + [layerId] + ); + const layer = useAppSelector(selectLayer); + assert(layer !== undefined, `Layer ${layerId} doesn't exist!`); + return layer; +}; + +export const useLayerPrompt = (layerId: string) => { + const selectLayer = useMemo( + () => + createSelector( + selectRegionalPromptsSlice, + (regionalPrompts) => regionalPrompts.layers.find((l) => l.id === layerId)?.prompt + ), + [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.layers.find((l) => l.id === layerId)?.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/useMouseDown.ts b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts similarity index 95% rename from invokeai/frontend/web/src/features/regionalPrompts/hooks/useMouseDown.ts rename to invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts index 7fec408886..3e3b8f0db5 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/hooks/useMouseDown.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/hooks/mouseEventHooks.ts @@ -33,7 +33,6 @@ export const useMouseDown = (stageRef: MutableRefObject) => if (!stageRef.current) { return; } - console.log('Mouse down'); const pos = syncCursorPos(stageRef.current); if (!pos) { return; @@ -55,7 +54,6 @@ export const useMouseUp = (stageRef: MutableRefObject) => { if (!stageRef.current) { return; } - console.log('Mouse up'); if ($tool.get() === 'brush' && $isMouseDown.get()) { // Add another point to the last line. $isMouseDown.set(false); @@ -78,7 +76,6 @@ export const useMouseMove = (stageRef: MutableRefObject) => if (!stageRef.current) { return; } - console.log('Mouse move'); const pos = syncCursorPos(stageRef.current); if (!pos) { return; @@ -98,7 +95,6 @@ export const useMouseLeave = (stageRef: MutableRefObject) => if (!stageRef.current) { return; } - console.log('Mouse leave'); $isMouseOver.set(false); $isMouseDown.set(false); $cursorPosition.set(null); @@ -115,7 +111,6 @@ export const useMouseEnter = (stageRef: MutableRefObject) => if (!stageRef.current) { return; } - console.log('Mouse enter'); $isMouseOver.set(true); const pos = syncCursorPos(stageRef.current); if (!pos) { diff --git a/invokeai/frontend/web/src/features/regionalPrompts/hooks/useLayer.ts b/invokeai/frontend/web/src/features/regionalPrompts/hooks/useLayer.ts deleted file mode 100644 index d0f72fcb46..0000000000 --- a/invokeai/frontend/web/src/features/regionalPrompts/hooks/useLayer.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { useAppSelector } from 'app/store/storeHooks'; -import { selectRegionalPromptsSlice } from 'features/regionalPrompts/store/regionalPromptsSlice'; -import { useMemo } from 'react'; -import { assert } from 'tsafe'; - -export const useLayer = (layerId: string) => { - const selectLayer = useMemo( - () => - createSelector(selectRegionalPromptsSlice, (regionalPrompts) => - regionalPrompts.layers.find((l) => l.id === layerId) - ), - [layerId] - ); - const layer = useAppSelector(selectLayer); - assert(layer, `Layer ${layerId} doesn't exist!`); - return layer; -}; diff --git a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts index 704f078317..83a87d67d6 100644 --- a/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts +++ b/invokeai/frontend/web/src/features/regionalPrompts/store/regionalPromptsSlice.ts @@ -1,6 +1,7 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; +import { moveBackward, moveForward, moveToBack, moveToFront } from 'common/util/arrayUtils'; import type Konva from 'konva'; import type { Vector2d } from 'konva/lib/types'; import { atom } from 'nanostores'; @@ -13,7 +14,7 @@ type LayerObjectBase = { isSelected: boolean; }; -export type ImageObject = LayerObjectBase & { +type ImageObject = LayerObjectBase & { kind: 'image'; imageName: string; x: number; @@ -38,26 +39,30 @@ export type FillRectObject = LayerObjectBase & { export type LayerObject = ImageObject | LineObject | FillRectObject; -export type PromptRegionLayer = { +type LayerBase = { id: string; + isVisible: boolean; +}; + +type PromptRegionLayer = LayerBase & { kind: 'promptRegionLayer'; objects: LayerObject[]; prompt: string; color: RgbColor; }; -export type Layer = PromptRegionLayer; +type Layer = PromptRegionLayer; -export type Tool = 'brush'; +type Tool = 'brush'; -export type RegionalPromptsState = { +type RegionalPromptsState = { _version: 1; selectedLayer: string | null; layers: PromptRegionLayer[]; brushSize: number; }; -export const initialRegionalPromptsState: RegionalPromptsState = { +const initialRegionalPromptsState: RegionalPromptsState = { _version: 1, selectedLayer: null, brushSize: 40, @@ -73,7 +78,7 @@ export const regionalPromptsSlice = createSlice({ layerAdded: { reducer: (state, action: PayloadAction) => { const newLayer = buildLayer(action.meta.id, action.payload, state.layers.length); - state.layers.push(newLayer); + state.layers.unshift(newLayer); state.selectedLayer = newLayer.id; }, prepare: (payload: Layer['kind']) => ({ payload, meta: { id: uuidv4() } }), @@ -81,6 +86,13 @@ export const regionalPromptsSlice = createSlice({ 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) { @@ -92,6 +104,24 @@ export const regionalPromptsSlice = createSlice({ state.layers = state.layers.filter((l) => l.id !== action.payload); state.selectedLayer = state.layers[0]?.id ?? null; }, + layerMovedForward: (state, action: PayloadAction) => { + const cb = (l: Layer) => l.id === action.payload; + moveForward(state.layers, cb); + }, + layerMovedToFront: (state, action: PayloadAction) => { + const cb = (l: Layer) => l.id === action.payload; + // Because the layers are in reverse order, moving to the front is equivalent to moving to the back + moveToBack(state.layers, cb); + }, + layerMovedBackward: (state, action: PayloadAction) => { + const cb = (l: Layer) => l.id === action.payload; + moveBackward(state.layers, cb); + }, + layerMovedToBack: (state, action: PayloadAction) => { + const cb = (l: Layer) => l.id === action.payload; + // Because the layers are in reverse order, moving to the back is equivalent to moving to the front + moveToFront(state.layers, cb); + }, promptChanged: (state, action: PayloadAction<{ layerId: string; prompt: string }>) => { const { layerId, prompt } = action.payload; const layer = state.layers.find((l) => l.id === layerId); @@ -144,12 +174,13 @@ const DEFAULT_COLORS = [ { r: 200, g: 0, b: 200 }, ]; -const buildLayer = (id: string, kind: Layer['kind'], layerCount: number) => { +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, kind, prompt: '', objects: [], @@ -172,11 +203,16 @@ export const { layerSelected, layerReset, layerDeleted, + layerIsVisibleToggled, promptChanged, lineAdded, pointsAdded, promptRegionLayerColorChanged, brushSizeChanged, + layerMovedForward, + layerMovedToFront, + layerMovedBackward, + layerMovedToBack, } = regionalPromptsSlice.actions; export const selectRegionalPromptsSlice = (state: RootState) => state.regionalPrompts; @@ -195,7 +231,6 @@ export const regionalPromptsPersistConfig: PersistConfig = export const $isMouseDown = atom(false); export const $isMouseOver = atom(false); -export const $isFocused = atom(false); export const $cursorPosition = atom(null); export const $tool = atom('brush'); export const $stage = atom(null);