mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): wip regional prompting UI
- Arrange layers - Layer visibility - Layered brush preview - Cleanup
This commit is contained in:
parent
83d359b681
commit
822dfa77fc
@ -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"
|
||||
}
|
||||
}
|
||||
|
85
invokeai/frontend/web/src/common/util/arrayUtils.test.ts
Normal file
85
invokeai/frontend/web/src/common/util/arrayUtils.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
37
invokeai/frontend/web/src/common/util/arrayUtils.ts
Normal file
37
invokeai/frontend/web/src/common/util/arrayUtils.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export const moveForward = <T>(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 = <T>(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 = <T>(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 = <T>(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;
|
||||
};
|
@ -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 <Button onClick={onClick}>Add Layer</Button>;
|
||||
return <Button onClick={onClick}>{t('regionalPrompts.addLayer')}</Button>;
|
||||
};
|
||||
|
@ -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 <Circle x={pos.x} y={pos.y} radius={brushSize / 2} fill={color} />;
|
||||
};
|
||||
|
||||
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 (
|
||||
<Group>
|
||||
<Circle
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
radius={brushSize / 2 + 1}
|
||||
stroke="rgba(255,255,255,0.8)"
|
||||
strokeWidth={1}
|
||||
strokeEnabled={true}
|
||||
listening={false}
|
||||
/>
|
||||
<Circle
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
radius={brushSize / 2}
|
||||
stroke="rgba(0,0,0,1)"
|
||||
strokeWidth={1}
|
||||
strokeEnabled={true}
|
||||
listening={false}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
@ -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 (
|
||||
<FormControl orientation="vertical">
|
||||
<FormLabel>Brush Size</FormLabel>
|
||||
<FormLabel>{t('regionalPrompts.brushSize')}</FormLabel>
|
||||
<CompositeSlider min={1} max={100} value={brushSize} onChange={onChange} />
|
||||
<CompositeNumberInput min={1} max={500} value={brushSize} onChange={onChange} />
|
||||
</FormControl>
|
||||
|
@ -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 <Button onClick={onClick} flexShrink={0}>Delete</Button>;
|
||||
};
|
@ -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';
|
||||
|
@ -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 (
|
||||
<Flex flexDir="column" onClickCapture={onClickCapture} border={border}>
|
||||
<Flex gap={2}>
|
||||
<ResetLayerButton id={id} />
|
||||
<DeleteLayerButton id={id} />
|
||||
<LayerColorPicker id={id} />
|
||||
<Flex gap={2} onClickCapture={onClickCapture}>
|
||||
<Flex w={2} borderRadius="base" bg={bg} flexShrink={0} py={4} />
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<Flex gap={2}>
|
||||
<LayerColorPicker id={id} />
|
||||
<LayerVisibilityToggle id={id} />
|
||||
<LayerMenu id={id} />
|
||||
</Flex>
|
||||
<RegionalPromptsPrompt layerId={id} />
|
||||
</Flex>
|
||||
<RegionalPromptsPrompt layerId={id} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
@ -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<Props> = ({ 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 (
|
||||
<Menu>
|
||||
<MenuButton as={IconButton} aria-label="Layer menu" size="sm" icon={<PiDotsThreeVerticalBold />} />
|
||||
<MenuList>
|
||||
<MenuItem onClick={moveToFront} isDisabled={!validActions.canMoveToFront} icon={<PiArrowLineUpBold />}>
|
||||
{t('regionalPrompts.moveToFront')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={moveForward} isDisabled={!validActions.canMoveForward} icon={<PiArrowUpBold />}>
|
||||
{t('regionalPrompts.moveForward')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={moveBackward} isDisabled={!validActions.canMoveBackward} icon={<PiArrowDownBold />}>
|
||||
{t('regionalPrompts.moveBackward')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={moveToBack} isDisabled={!validActions.canMoveToBack} icon={<PiArrowLineDownBold />}>
|
||||
{t('regionalPrompts.moveToBack')}
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuItem onClick={resetLayer} icon={<PiArrowCounterClockwiseBold />}>
|
||||
{t('accessibility.reset')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={deleteLayer} icon={<PiTrashSimpleBold />} color="error.300">
|
||||
{t('common.delete')}
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
};
|
@ -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 (
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label="Toggle layer visibility"
|
||||
variant={isVisible ? 'outline' : 'ghost'}
|
||||
icon={isVisible ? <PiEyeBold /> : <PiEyeClosedBold />}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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 (
|
||||
<Flex gap={4}>
|
||||
<Flex flexDir="column" w={200}>
|
||||
<Flex flexDir="column" w={200} gap={4}>
|
||||
<AddLayerButton />
|
||||
<BrushSize />
|
||||
{layerIds.map((id) => (
|
||||
{layerIdsReversed.map((id) => (
|
||||
<LayerListItem key={id} id={id} />
|
||||
))}
|
||||
</Flex>
|
||||
|
@ -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<HTMLTextAreaElement>(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}
|
||||
|
@ -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<Konva.Stage | null>(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 key={layer.id}>
|
||||
{layer.objects.map((obj) => {
|
||||
if (obj.kind === 'line') {
|
||||
return <LineComponent key={obj.id} line={obj} color={layer.color} />;
|
||||
}
|
||||
if (obj.kind === 'fillRect') {
|
||||
return <RectComponent key={obj.id} rect={obj} color={layer.color} />;
|
||||
}
|
||||
})}
|
||||
{layer.id === selectedLayer && <BrushPreviewFill />}
|
||||
</Layer>
|
||||
))}
|
||||
<Layer>
|
||||
{layers.map((layer) => (
|
||||
<Group key={layer.id}>
|
||||
{layer.objects.map((obj) => {
|
||||
if (obj.kind === 'line') {
|
||||
return <LineComponent key={obj.id} line={obj} color={layer.color} />;
|
||||
}
|
||||
if (obj.kind === 'fillRect') {
|
||||
return <RectComponent key={obj.id} rect={obj} color={layer.color} />;
|
||||
}
|
||||
})}
|
||||
</Group>
|
||||
))}
|
||||
<BrushPreview />
|
||||
<BrushPreviewOutline />
|
||||
</Layer>
|
||||
</ChakraStage>
|
||||
);
|
||||
});
|
||||
|
||||
RegionalPromptsStage.displayName = 'RegionalPromptingEditor';
|
||||
RegionalPromptsStage.displayName = 'RegionalPromptsStage';
|
||||
|
@ -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 <Button onClick={onClick} flexShrink={0}>Reset</Button>;
|
||||
};
|
@ -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;
|
||||
};
|
@ -33,7 +33,6 @@ export const useMouseDown = (stageRef: MutableRefObject<Konva.Stage | null>) =>
|
||||
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<Konva.Stage | null>) => {
|
||||
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<Konva.Stage | null>) =>
|
||||
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<Konva.Stage | null>) =>
|
||||
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<Konva.Stage | null>) =>
|
||||
if (!stageRef.current) {
|
||||
return;
|
||||
}
|
||||
console.log('Mouse enter');
|
||||
$isMouseOver.set(true);
|
||||
const pos = syncCursorPos(stageRef.current);
|
||||
if (!pos) {
|
@ -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;
|
||||
};
|
@ -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<Layer['kind'], string, { id: string }>) => {
|
||||
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<string>) => {
|
||||
state.selectedLayer = action.payload;
|
||||
},
|
||||
layerIsVisibleToggled: (state, action: PayloadAction<string>) => {
|
||||
const layer = state.layers.find((l) => l.id === action.payload);
|
||||
if (!layer) {
|
||||
return;
|
||||
}
|
||||
layer.isVisible = !layer.isVisible;
|
||||
},
|
||||
layerReset: (state, action: PayloadAction<string>) => {
|
||||
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<string>) => {
|
||||
const cb = (l: Layer) => l.id === action.payload;
|
||||
moveForward(state.layers, cb);
|
||||
},
|
||||
layerMovedToFront: (state, action: PayloadAction<string>) => {
|
||||
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<string>) => {
|
||||
const cb = (l: Layer) => l.id === action.payload;
|
||||
moveBackward(state.layers, cb);
|
||||
},
|
||||
layerMovedToBack: (state, action: PayloadAction<string>) => {
|
||||
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<RegionalPromptsState> =
|
||||
|
||||
export const $isMouseDown = atom(false);
|
||||
export const $isMouseOver = atom(false);
|
||||
export const $isFocused = atom(false);
|
||||
export const $cursorPosition = atom<Vector2d | null>(null);
|
||||
export const $tool = atom<Tool>('brush');
|
||||
export const $stage = atom<Konva.Stage | null>(null);
|
||||
|
Loading…
Reference in New Issue
Block a user