feat(ui): wip regional prompting UI

- Arrange layers
- Layer visibility
- Layered brush preview
- Cleanup
This commit is contained in:
psychedelicious 2024-04-09 19:09:34 +10:00 committed by Kent Keirsey
parent 83d359b681
commit 822dfa77fc
19 changed files with 433 additions and 111 deletions

View File

@ -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"
}
}

View 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);
});
});
});

View 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;
};

View File

@ -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>;
};

View File

@ -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>
);
};

View File

@ -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>

View File

@ -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>;
};

View File

@ -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';

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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}
/>
);
};

View File

@ -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>

View File

@ -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}

View File

@ -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';

View File

@ -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>;
};

View File

@ -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;
};

View File

@ -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) {

View File

@ -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;
};

View File

@ -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);