Final WebUI build for Release 2.1

- squashed commit of 52 commits from PR #1327

don't log base64 progress images

Fresh Build For WebUI

[WebUI] Loopback Default False

Fixes bugs/styling

- Fixes missing web app state on new version:
Adds stateReconciler to redux-persist.

When we add more values to the state and then release the update app, they will be automatically merged in.

Reseting web UI will be needed far less.
7159ec

- Fixes console z-index
- Moves reset web UI button to visible area

Decreases gallery width on inpainting

Increases workarea split padding to 1rem

Adds missing tooltips to site header

Changes inpainting controls settings to hover

Fixes hotkeys and settings buttons not working

Improves bounding box interactions

- Bounding box can now be moved by dragging any of its edges
- Bounding box does not affect drawing if already drawing a stroke
- Can lock bounding box to draw directly on the bounding box edges
- Removes spacebar-hold behaviour due to technical issues

Fixes silent crash when init image too large

To send the mask to the server, the UI rendered the mask onto the init image and sent the whole image. The mask was then cropped by the server.

If the image was too large, the app silently failed. Maybe it exceeds the websocket size limit.

Fixed by cropping the mask in the UI layer, sending only bounding-box-sized mask image data.

Disabled bounding box settings when locked

Styles image uploader

Builds fresh bundle

Improves bounding box interaction

Added spacebar-hold-to-transform back.

Address bounding box feedback

- Adds back toggle to hide bounding box
- Box quick toggle = q, normal toggle = shift + q
- Styles canvas alert icons

Adds hints when unable to invoke

- Popover on Invoke button indicates why exactly it is disabled, e.g. prompt is empty, something else is processing, etc.
- There may be more than one reason; all are displayed.

Fix Inpainting Alerts Styling

Preventing unnecessary re-renders across the app

Code Split Inpaint Options

Isolate features to their own components so they dont re-render the other stuff each time.

[TESTING] Remove  global isReady checking

I dont believe this is need at all because the isready state is constantly updated when needed and tracked real time in the Redux store. This causes massive re-renders. @psychedelicious If this is absolutely essential for a reason that I do not see, please hit me up on Discord.

Fresh Bundle

Fix Bounding Box Settings re-rendering on brush stroke

[Code Splitting] Bounding Box Options

Isolated all bounding box components to trigger unnecessary re-renders. Still need to fix  bounding box  triggering re-renders on the control panel inside the canvas itself. But the options panel should be a good to go with this change.

Inpainting Controls Code Spitting and Performance

Codesplit the entirety of the inpainting controls. Created new selectors for each and every component to ensure there are no unnecessary re-renders. App feels a lot smoother.

Fixes rerenders on ClearBrushHistory

Fixes crash when requesting post-generation upscale/face restoration

- Moves the inpainting paste to before the postprocessing.

Removes unused isReady state

Changes Report Bug icon to a bug

Restores shift+q bounding box shortcut

Adds alert for bounding box size to status icons

Adds asCheckbox to IAIIconButton

Rough draft of this. Not happy with the styling but it's clearer than having them look just like buttons.

Fixes crash related to old value of progress_latents in state

Styling changes and settings modal minor refactor

Fixes: uploaded JPG images not loading

Reworks CurrentImageButtons.tsx

- Change all icons to FA iconset for consistency
- Refactors IAIIconButton, IAIButton, IAIPopover to handle ref forwarding
- Redesigns buttons into group

Only generate 1 iteration when seed fixed & variations disabled

Fixes progress images select

Fixes edge case: upload over gets stuck while alt tabbing

- Press esc to close it now

Fixes display progress images select typing

Fixes current image button rerenders

Adds min width to ImageUploader

Makes fast-latents in progress default

Update Icon Button Checkbox Style Styling

Fixes next/prev image buttons

Refactor canvas buttons + more

Add Save Intermediates Step Count

For accurate mode only.

Co-Authored-By: Richard Macarthy <richardmacarthy@protonmail.com>

Restores "initial image" text

Address feedback

- moves mask clear button
- fixes intermediates
- shrinks inpainting icons by 10%

Fix Loopback Styling

Adds escape hotkey to close floating panels

Readd Hotkey for Dual Display

Updated Current Image Button Styling
This commit is contained in:
damian0815 2022-11-02 09:08:11 +01:00 committed by Lincoln Stein
parent 5e87062cf8
commit 96b34c0f85
105 changed files with 5022 additions and 3163 deletions

View File

@ -187,7 +187,10 @@ class InvokeAIWebServer:
base_path = (
self.result_path if category == "result" else self.init_image_path
)
paths = glob.glob(os.path.join(base_path, "*.png"))
paths = []
for ext in ("*.png", "*.jpg", "*.jpeg"):
paths.extend(glob.glob(os.path.join(base_path, ext)))
image_paths = sorted(
paths, key=lambda x: os.path.getmtime(x), reverse=True
@ -203,13 +206,19 @@ class InvokeAIWebServer:
image_array = []
for path in image_paths:
if os.path.splitext(path)[1] == ".png":
metadata = retrieve_metadata(path)
sd_metadata = metadata["sd-metadata"]
else:
sd_metadata = {}
(width, height) = Image.open(path).size
image_array.append(
{
"url": self.get_url_from_image_path(path),
"mtime": os.path.getmtime(path),
"metadata": metadata["sd-metadata"],
"metadata": sd_metadata,
"width": width,
"height": height,
"category": category,
@ -236,7 +245,9 @@ class InvokeAIWebServer:
self.result_path if category == "result" else self.init_image_path
)
paths = glob.glob(os.path.join(base_path, "*.png"))
paths = []
for ext in ("*.png", "*.jpg", "*.jpeg"):
paths.extend(glob.glob(os.path.join(base_path, ext)))
image_paths = sorted(
paths, key=lambda x: os.path.getmtime(x), reverse=True
@ -254,9 +265,12 @@ class InvokeAIWebServer:
image_paths = image_paths[slice(0, page_size)]
image_array = []
for path in image_paths:
if os.path.splitext(path)[1] == ".png":
metadata = retrieve_metadata(path)
sd_metadata = metadata["sd-metadata"]
else:
sd_metadata = {}
(width, height) = Image.open(path).size
@ -264,7 +278,7 @@ class InvokeAIWebServer:
{
"url": self.get_url_from_image_path(path),
"mtime": os.path.getmtime(path),
"metadata": metadata["sd-metadata"],
"metadata": sd_metadata,
"width": width,
"height": height,
"category": category,
@ -573,11 +587,7 @@ class InvokeAIWebServer:
)
)
)
# crop the mask image
cropped_mask_image = copy_image_from_bounding_box(
mask_image, **generation_parameters["bounding_box"]
)
generation_parameters["init_mask"] = cropped_mask_image
generation_parameters["init_mask"] = mask_image
totalSteps = self.calculate_real_steps(
steps=generation_parameters["steps"],
@ -605,8 +615,9 @@ class InvokeAIWebServer:
progress.set_current_status_has_steps(True)
if (
generation_parameters['progress_images'] and step % 5 == 0 \
and step < generation_parameters['steps'] - 1
generation_parameters["progress_images"]
and step % generation_parameters['save_intermediates'] == 0
and step < generation_parameters["steps"] - 1
):
image = self.generate.sample_to_image(sample)
metadata = self.parameters_to_generated_image_metadata(
@ -637,14 +648,16 @@ class InvokeAIWebServer:
},
)
if generation_parameters['progress_latents']:
if generation_parameters["progress_latents"]:
image = self.generate.sample_to_lowres_estimated_image(sample)
(width, height) = image.size
width *= 8
height *= 8
buffered = io.BytesIO()
image.save(buffered, format="PNG")
img_base64 = "data:image/png;base64," + base64.b64encode(buffered.getvalue()).decode('UTF-8')
img_base64 = "data:image/png;base64," + base64.b64encode(
buffered.getvalue()
).decode("UTF-8")
self.socketio.emit(
"intermediateResult",
{
@ -654,7 +667,7 @@ class InvokeAIWebServer:
"metadata": {},
"width": width,
"height": height,
}
},
)
self.socketio.emit("progressUpdate", progress.to_formatted_dict())
@ -672,6 +685,14 @@ class InvokeAIWebServer:
step_index = 1
nonlocal prior_variations
# paste the inpainting image back onto the original
if "init_mask" in generation_parameters:
image = paste_image_into_bounding_box(
Image.open(init_img_path),
image,
**generation_parameters["bounding_box"],
)
progress.set_current_status("Generation Complete")
self.socketio.emit("progressUpdate", progress.to_formatted_dict())
@ -760,14 +781,6 @@ class InvokeAIWebServer:
self.socketio.emit("progressUpdate", progress.to_formatted_dict())
eventlet.sleep(0)
# paste the inpainting image back onto the original
if "init_mask" in generation_parameters:
image = paste_image_into_bounding_box(
Image.open(init_img_path),
image,
**generation_parameters["bounding_box"],
)
# restore the stashed URLS and discard the paths, we are about to send the result to client
if "init_img" in all_parameters:
all_parameters["init_img"] = init_img_url

File diff suppressed because one or more lines are too long

501
frontend/dist/assets/index.8eb7dfe4.js vendored Normal file

File diff suppressed because one or more lines are too long

517
frontend/dist/assets/index.bf9dd1fc.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>InvokeAI - A Stable Diffusion Toolkit</title>
<link rel="shortcut icon" type="icon" href="./assets/favicon.0d253ced.ico" />
<script type="module" crossorigin src="./assets/index.cc049b93.js"></script>
<link rel="stylesheet" href="./assets/index.52c8231e.css">
<script type="module" crossorigin src="./assets/index.bf9dd1fc.js"></script>
<link rel="stylesheet" href="./assets/index.f9f4c989.css">
</head>
<body>

View File

@ -1,8 +1,7 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import ProgressBar from '../features/system/ProgressBar';
import SiteHeader from '../features/system/SiteHeader';
import Console from '../features/system/Console';
import Loading from '../Loading';
import { useAppDispatch } from './store';
import { requestSystemConfig } from './socketio/actions';
import { keepGUIAlive } from './utils';
@ -79,17 +78,14 @@ const appSelector = createSelector(
const App = () => {
const dispatch = useAppDispatch();
const [isReady, setIsReady] = useState<boolean>(false);
const { shouldShowGalleryButton, shouldShowOptionsPanelButton } =
useAppSelector(appSelector);
useEffect(() => {
dispatch(requestSystemConfig());
setIsReady(true);
}, [dispatch]);
return isReady ? (
return (
<div className="App">
<ImageUploader>
<ProgressBar />
@ -104,8 +100,6 @@ const App = () => {
{shouldShowOptionsPanelButton && <FloatingOptionsPanelButtons />}
</ImageUploader>
</div>
) : (
<Loading />
);
};

View File

@ -1,5 +1,7 @@
// TODO: use Enums?
import { InProgressImageType } from '../features/system/systemSlice';
// Valid samplers
export const SAMPLERS: Array<string> = [
'ddim',
@ -38,8 +40,11 @@ export const NUMPY_RAND_MAX = 4294967295;
export const FACETOOL_TYPES = ['gfpgan', 'codeformer'] as const;
export const IN_PROGRESS_IMAGE_TYPES: Array<{ key: string; value: string }> = [
{ key: "None", value: 'none'},
{ key: "Fast", value: 'latents' },
{ key: "Accurate", value: 'full-res' }
export const IN_PROGRESS_IMAGE_TYPES: Array<{
key: string;
value: InProgressImageType;
}> = [
{ key: 'None', value: 'none' },
{ key: 'Fast', value: 'latents' },
{ key: 'Accurate', value: 'full-res' },
];

View File

@ -1,6 +1,6 @@
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { RootState } from '../../app/store';
import { RootState } from '../store';
import { activeTabNameSelector } from '../../features/options/optionsSelectors';
import { OptionsState } from '../../features/options/optionsSlice';
@ -25,7 +25,7 @@ export const readinessSelector = createSelector(
prompt,
shouldGenerateVariations,
seedWeights,
maskPath,
// maskPath,
initialImage,
seed,
} = options;
@ -34,33 +34,45 @@ export const readinessSelector = createSelector(
const { imageToInpaint } = inpainting;
let isReady = true;
const reasonsWhyNotReady: string[] = [];
// Cannot generate without a prompt
if (!prompt || Boolean(prompt.match(/^[\s\r\n]+$/))) {
return false;
isReady = false;
reasonsWhyNotReady.push('Missing prompt');
}
if (activeTabName === 'img2img' && !initialImage) {
return false;
isReady = false;
reasonsWhyNotReady.push('No initial image selected');
}
if (activeTabName === 'inpainting' && !imageToInpaint) {
return false;
isReady = false;
reasonsWhyNotReady.push('No inpainting image selected');
}
// Cannot generate with a mask without img2img
if (maskPath && !initialImage) {
return false;
}
// // We don't use mask paths now.
// // Cannot generate with a mask without img2img
// if (maskPath && !initialImage) {
// isReady = false;
// reasonsWhyNotReady.push(
// 'On ImageToImage tab, but no mask is provided.'
// );
// }
// TODO: job queue
// Cannot generate if already processing an image
if (isProcessing) {
return false;
isReady = false;
reasonsWhyNotReady.push('System Busy');
}
// Cannot generate if not connected
if (!isConnected) {
return false;
isReady = false;
reasonsWhyNotReady.push('System Disconnected');
}
// Cannot generate variations without valid seed weights
@ -68,11 +80,12 @@ export const readinessSelector = createSelector(
shouldGenerateVariations &&
(!(validateSeedWeights(seedWeights) || seedWeights === '') || seed === -1)
) {
return false;
isReady = false;
reasonsWhyNotReady.push('Seed-Weights badly formatted.');
}
// All good
return true;
return { isReady, reasonsWhyNotReady };
},
{
memoizeOptions: {

View File

@ -146,12 +146,14 @@ const makeSocketIOListeners = (
...data,
})
);
if (!data.isBase64) {
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `Intermediate image generated: ${data.url}`,
})
);
}
} catch (e) {
console.error(e);
}

View File

@ -5,12 +5,16 @@ import type { TypedUseSelectorHook } from 'react-redux';
import { persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage'; // defaults to localStorage for web
import optionsReducer from '../features/options/optionsSlice';
import galleryReducer from '../features/gallery/gallerySlice';
import inpaintingReducer from '../features/tabs/Inpainting/inpaintingSlice';
import optionsReducer, { OptionsState } from '../features/options/optionsSlice';
import galleryReducer, { GalleryState } from '../features/gallery/gallerySlice';
import inpaintingReducer, {
InpaintingState,
} from '../features/tabs/Inpainting/inpaintingSlice';
import systemReducer from '../features/system/systemSlice';
import systemReducer, { SystemState } from '../features/system/systemSlice';
import { socketioMiddleware } from './socketio/middleware';
import autoMergeLevel2 from 'redux-persist/es/stateReconciler/autoMergeLevel2';
import { PersistPartial } from 'redux-persist/es/persistReducer';
/**
* redux-persist provides an easy and reliable way to persist state across reloads.
@ -33,12 +37,14 @@ import { socketioMiddleware } from './socketio/middleware';
const rootPersistConfig = {
key: 'root',
storage,
stateReconciler: autoMergeLevel2,
blacklist: ['gallery', 'system', 'inpainting'],
};
const systemPersistConfig = {
key: 'system',
storage,
stateReconciler: autoMergeLevel2,
blacklist: [
'isCancelable',
'isConnected',
@ -58,6 +64,7 @@ const systemPersistConfig = {
const galleryPersistConfig = {
key: 'gallery',
storage,
stateReconciler: autoMergeLevel2,
whitelist: [
'galleryWidth',
'shouldPinGallery',
@ -71,17 +78,26 @@ const galleryPersistConfig = {
const inpaintingPersistConfig = {
key: 'inpainting',
storage,
stateReconciler: autoMergeLevel2,
blacklist: ['pastLines', 'futuresLines', 'cursorPosition'],
};
const reducers = combineReducers({
options: optionsReducer,
gallery: persistReducer(galleryPersistConfig, galleryReducer),
system: persistReducer(systemPersistConfig, systemReducer),
inpainting: persistReducer(inpaintingPersistConfig, inpaintingReducer),
gallery: persistReducer<GalleryState>(galleryPersistConfig, galleryReducer),
system: persistReducer<SystemState>(systemPersistConfig, systemReducer),
inpainting: persistReducer<InpaintingState>(
inpaintingPersistConfig,
inpaintingReducer
),
});
const persistedReducer = persistReducer(rootPersistConfig, reducers);
const persistedReducer = persistReducer<{
options: OptionsState;
gallery: GalleryState & PersistPartial;
system: SystemState & PersistPartial;
inpainting: InpaintingState & PersistPartial;
}>(rootPersistConfig, reducers);
// Continue with store setup
export const store = configureStore({

View File

@ -0,0 +1,3 @@
.invokeai__button {
justify-content: space-between;
}

View File

@ -1,23 +1,32 @@
import { Button, ButtonProps, Tooltip } from '@chakra-ui/react';
import {
Button,
ButtonProps,
forwardRef,
Tooltip,
TooltipProps,
} from '@chakra-ui/react';
import { ReactNode } from 'react';
export interface IAIButtonProps extends ButtonProps {
label: string;
tooltip?: string;
tooltipProps?: Omit<TooltipProps, 'children'>;
styleClass?: string;
children: ReactNode;
}
/**
* Reusable customized button component.
*/
const IAIButton = (props: IAIButtonProps) => {
const { label, tooltip = '', styleClass, ...rest } = props;
const IAIButton = forwardRef((props: IAIButtonProps, forwardedRef) => {
const { children, tooltip = '', tooltipProps, styleClass, ...rest } = props;
return (
<Tooltip label={tooltip}>
<Button className={styleClass ? styleClass : ''} {...rest}>
{label}
<Tooltip label={tooltip} {...tooltipProps}>
<Button
ref={forwardedRef}
className={['invokeai__button', styleClass].join(' ')}
{...rest}
>
{children}
</Button>
</Tooltip>
);
};
});
export default IAIButton;

View File

@ -1,6 +1,6 @@
@use '../../styles/Mixins/' as *;
.icon-button {
.invokeai__icon-button {
background-color: var(--btn-grey);
cursor: pointer;
@ -8,13 +8,68 @@
background-color: var(--btn-grey-hover);
}
&[data-selected=true] {
&[data-selected='true'] {
background-color: var(--accent-color);
&:hover {
background-color: var(--accent-color-hover);
}
}
&[disabled] {
cursor: not-allowed;
}
&[data-variant='link'] {
background: none !important;
&:hover {
background: none !important;
}
}
&[data-selected='true'] {
border-color: var(--accent-color);
&:hover {
border-color: var(--accent-color-hover);
}
}
&[data-alert='true'] {
animation-name: pulseColor;
animation-duration: 1s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
&:hover {
animation: none;
background-color: var(--accent-color-hover);
}
}
&[data-as-checkbox='true'] {
background-color: var(--btn-grey);
border: 3px solid var(--btn-grey);
svg {
fill: var(--text-color);
}
&:hover {
background-color: var(--btn-grey);
border-color: var(--btn-checkbox-border-hover);
svg {
fill: var(--text-color);
}
}
}
}
@keyframes pulseColor {
0% {
background-color: var(--accent-color);
}
50% {
background-color: var(--accent-color-dim);
}
100% {
background-color: var(--accent-color);
}
}

View File

@ -2,38 +2,40 @@ import {
IconButtonProps,
IconButton,
Tooltip,
PlacementWithLogical,
TooltipProps,
forwardRef,
} from '@chakra-ui/react';
interface Props extends IconButtonProps {
tooltip?: string;
tooltipPlacement?: PlacementWithLogical | undefined;
export type IAIIconButtonProps = IconButtonProps & {
styleClass?: string;
}
tooltip?: string;
tooltipProps?: Omit<TooltipProps, 'children'>;
asCheckbox?: boolean;
isChecked?: boolean;
};
/**
* Reusable customized button component. Originally was more customized - now probably unecessary.
*/
const IAIIconButton = (props: Props) => {
const IAIIconButton = forwardRef((props: IAIIconButtonProps, forwardedRef) => {
const {
tooltip = '',
tooltipPlacement = 'bottom',
styleClass,
onClick,
cursor,
tooltipProps,
asCheckbox,
isChecked,
...rest
} = props;
return (
<Tooltip label={tooltip} hasArrow placement={tooltipPlacement}>
<Tooltip label={tooltip} hasArrow {...tooltipProps}>
<IconButton
className={`icon-button ${styleClass}`}
ref={forwardedRef}
className={`invokeai__icon-button ${styleClass}`}
data-as-checkbox={asCheckbox}
data-selected={isChecked !== undefined ? isChecked : undefined}
style={props.onClick ? { cursor: 'pointer' } : {}}
{...rest}
cursor={cursor ? cursor : onClick ? 'pointer' : 'unset'}
onClick={onClick}
/>
</Tooltip>
);
};
});
export default IAIIconButton;

View File

@ -1,7 +1,6 @@
.invokeai__number-input-form-control {
display: grid;
grid-template-columns: max-content auto;
column-gap: 1rem;
align-items: center;
.invokeai__number-input-form-label {
@ -11,6 +10,7 @@
margin-bottom: 0;
flex-grow: 2;
white-space: nowrap;
padding-right: 1rem;
&[data-focus] + .invokeai__number-input-root {
outline: none;

View File

@ -123,6 +123,7 @@ const IAINumberInput = (props: Props) => {
}
{...formControlProps}
>
{label && (
<FormLabel
className="invokeai__number-input-form-label"
style={{ display: label ? 'block' : 'none' }}
@ -130,6 +131,7 @@ const IAINumberInput = (props: Props) => {
>
{label}
</FormLabel>
)}
<NumberInput
className="invokeai__number-input-root"
value={valueAsString}
@ -145,10 +147,8 @@ const IAINumberInput = (props: Props) => {
textAlign={textAlign}
{...numberInputFieldProps}
/>
<div
className="invokeai__number-input-stepper"
style={showStepper ? { display: 'block' } : { display: 'none' }}
>
{showStepper && (
<div className="invokeai__number-input-stepper">
<NumberIncrementStepper
{...numberInputStepperProps}
className="invokeai__number-input-stepper-button"
@ -158,6 +158,7 @@ const IAINumberInput = (props: Props) => {
className="invokeai__number-input-stepper-button"
/>
</div>
)}
</NumberInput>
</FormControl>
</Tooltip>

View File

@ -3,13 +3,14 @@ import {
PopoverArrow,
PopoverContent,
PopoverTrigger,
Box,
BoxProps,
} from '@chakra-ui/react';
import { PopoverProps } from '@chakra-ui/react';
import { ReactNode } from 'react';
type IAIPopoverProps = PopoverProps & {
triggerComponent: ReactNode;
triggerContainerProps?: BoxProps;
children: ReactNode;
styleClass?: string;
hasArrow?: boolean;
@ -23,11 +24,10 @@ const IAIPopover = (props: IAIPopoverProps) => {
hasArrow = true,
...rest
} = props;
return (
<Popover {...rest}>
<PopoverTrigger>
<Box>{triggerComponent}</Box>
</PopoverTrigger>
<PopoverTrigger>{triggerComponent}</PopoverTrigger>
<PopoverContent className={`invokeai__popover-content ${styleClass}`}>
{hasArrow && <PopoverArrow className={'invokeai__popover-arrow'} />}
{children}

View File

@ -1,8 +1,7 @@
@use '../../styles/Mixins/' as *;
.invokeai__select {
display: grid;
grid-template-columns: repeat(2, max-content);
display: flex;
column-gap: 1rem;
align-items: center;
width: max-content;

View File

@ -1,17 +1,17 @@
import { FormControl, FormLabel, Select, SelectProps } from '@chakra-ui/react';
import { MouseEvent } from 'react';
interface Props extends SelectProps {
type IAISelectProps = SelectProps & {
label: string;
styleClass?: string;
validValues:
| Array<number | string>
| Array<{ key: string; value: string | number }>;
}
};
/**
* Customized Chakra FormControl + Select multi-part component.
*/
const IAISelect = (props: Props) => {
const IAISelect = (props: IAISelectProps) => {
const {
label,
isDisabled,
@ -33,19 +33,19 @@ const IAISelect = (props: Props) => {
}}
>
<FormLabel
className="invokeai__select-label"
fontSize={fontSize}
marginBottom={1}
flexGrow={2}
whiteSpace="nowrap"
className="invokeai__select-label"
>
{label}
</FormLabel>
<Select
className="invokeai__select-picker"
fontSize={fontSize}
size={size}
{...rest}
className="invokeai__select-picker"
>
{validValues.map((opt) => {
return typeof opt === 'string' || typeof opt === 'number' ? (
@ -53,7 +53,11 @@ const IAISelect = (props: Props) => {
{opt}
</option>
) : (
<option key={opt.value} value={opt.value}>
<option
key={opt.value}
value={opt.value}
className="invokeai__select-option"
>
{opt.key}
</option>
);

View File

@ -22,8 +22,6 @@ const IAISwitch = (props: Props) => {
const {
label,
isDisabled = false,
// fontSize = 'md',
// size = 'md',
width = 'auto',
formControlProps,
formLabelProps,
@ -39,17 +37,11 @@ const IAISwitch = (props: Props) => {
>
<FormLabel
className="invokeai__switch-form-label"
// fontSize={fontSize}
whiteSpace="nowrap"
{...formLabelProps}
>
{label}
<Switch
className="invokeai__switch-root"
// size={size}
// className="switch-button"
{...rest}
/>
<Switch className="invokeai__switch-root" {...rest} />
</FormLabel>
</FormControl>
);

View File

@ -0,0 +1,39 @@
import { Heading } from '@chakra-ui/react';
import { useHotkeys } from 'react-hotkeys-hook';
type ImageUploadOverlayProps = {
isDragAccept: boolean;
isDragReject: boolean;
overlaySecondaryText: string;
setIsHandlingUpload: (isHandlingUpload: boolean) => void;
};
const ImageUploadOverlay = (props: ImageUploadOverlayProps) => {
const {
isDragAccept,
isDragReject,
overlaySecondaryText,
setIsHandlingUpload,
} = props;
useHotkeys('esc', () => {
setIsHandlingUpload(false);
});
return (
<div className="dropzone-container">
{isDragAccept && (
<div className="dropzone-overlay is-drag-accept">
<Heading size={'lg'}>Upload Image{overlaySecondaryText}</Heading>
</div>
)}
{isDragReject && (
<div className="dropzone-overlay is-drag-reject">
<Heading size={'lg'}>Invalid Upload</Heading>
<Heading size={'md'}>Must be single JPEG or PNG image</Heading>
</div>
)}
</div>
);
};
export default ImageUploadOverlay;

View File

@ -16,9 +16,10 @@
row-gap: 1rem;
align-items: center;
justify-content: center;
background-color: var(--background-color);
&.is-drag-accept {
box-shadow: inset 0 0 20rem 1rem var(--status-good-color);
box-shadow: inset 0 0 20rem 1rem var(--accent-color);
}
&.is-drag-reject {
@ -32,6 +33,7 @@
}
.image-uploader-button-outer {
min-width: 20rem;
width: 100%;
height: 100%;
display: flex;

View File

@ -1,11 +1,13 @@
import { useCallback, ReactNode, useState, useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../app/store';
import { FileRejection, useDropzone } from 'react-dropzone';
import { Heading, Spinner, useToast } from '@chakra-ui/react';
import { useToast } from '@chakra-ui/react';
import { uploadImage } from '../../app/socketio/actions';
import { ImageUploadDestination, UploadImagePayload } from '../../app/invokeai';
import { ImageUploaderTriggerContext } from '../../app/contexts/ImageUploaderTriggerContext';
import { activeTabNameSelector } from '../../features/options/optionsSelectors';
import { tabDict } from '../../features/tabs/InvokeTabs';
import ImageUploadOverlay from './ImageUploadOverlay';
type ImageUploaderProps = {
children: ReactNode;
@ -71,6 +73,7 @@ const ImageUploader = (props: ImageUploaderProps) => {
accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] },
noClick: true,
onDrop,
onDragOver: () => setIsHandlingUpload(true),
maxFiles: 1,
});
@ -128,30 +131,22 @@ const ImageUploader = (props: ImageUploaderProps) => {
};
}, [dispatch, toast, activeTabName]);
const overlaySecondaryText = ['img2img', 'inpainting'].includes(activeTabName)
? ` to ${tabDict[activeTabName as keyof typeof tabDict].tooltip}`
: ``;
return (
<ImageUploaderTriggerContext.Provider value={open}>
<div {...getRootProps({ style: {} })}>
<input {...getInputProps()} />
{children}
{isDragActive && (
<div className="dropzone-container">
{isDragAccept && (
<div className="dropzone-overlay is-drag-accept">
<Heading size={'lg'}>Drop Images</Heading>
</div>
)}
{isDragReject && (
<div className="dropzone-overlay is-drag-reject">
<Heading size={'lg'}>Invalid Upload</Heading>
<Heading size={'md'}>Must be single JPEG or PNG image</Heading>
</div>
)}
{isHandlingUpload && (
<div className="dropzone-overlay is-handling-upload">
<Spinner />
</div>
)}
</div>
{isDragActive && isHandlingUpload && (
<ImageUploadOverlay
isDragAccept={isDragAccept}
isDragReject={isDragReject}
overlaySecondaryText={overlaySecondaryText}
setIsHandlingUpload={setIsHandlingUpload}
/>
)}
</div>
</ImageUploaderTriggerContext.Provider>

View File

@ -0,0 +1,19 @@
import { useContext } from 'react';
import { FaUpload } from 'react-icons/fa';
import { ImageUploaderTriggerContext } from '../../app/contexts/ImageUploaderTriggerContext';
import IAIIconButton from './IAIIconButton';
const ImageUploaderIconButton = () => {
const openImageUploader = useContext(ImageUploaderTriggerContext);
return (
<IAIIconButton
aria-label="Upload Image"
tooltip="Upload Image"
icon={<FaUpload />}
onClick={openImageUploader || undefined}
/>
);
};
export default ImageUploaderIconButton;

View File

@ -62,11 +62,13 @@ export const frontendToBackendParameters = (
shouldRandomizeSeed,
} = optionsState;
const { shouldDisplayInProgressType } = systemState;
const { shouldDisplayInProgressType, saveIntermediatesInterval } =
systemState;
const generationParameters: { [k: string]: any } = {
prompt,
iterations,
iterations:
shouldRandomizeSeed || shouldGenerateVariations ? iterations : 1,
steps,
cfg_scale: cfgScale,
threshold,
@ -76,7 +78,8 @@ export const frontendToBackendParameters = (
sampler_name: sampler,
seed,
progress_images: shouldDisplayInProgressType === 'full-res',
progress_latents: shouldDisplayInProgressType === 'latents'
progress_latents: shouldDisplayInProgressType === 'latents',
save_intermediates: saveIntermediatesInterval,
};
generationParameters.seed = shouldRandomizeSeed

View File

@ -0,0 +1,25 @@
.current-image-options {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
column-gap: 0.5em;
.current-image-send-to-popover,
.current-image-postprocessing-popover {
display: flex;
flex-direction: column;
row-gap: 0.5rem;
max-width: 25rem;
}
.chakra-popover__popper {
z-index: 11;
}
.delete-image-btn {
svg {
fill: var(--btn-delete-image);
}
}
}

View File

@ -1,8 +1,6 @@
import { createSelector } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import * as InvokeAI from '../../app/invokeai';
import { useAppDispatch, useAppSelector } from '../../app/store';
import { RootState } from '../../app/store';
import {
@ -10,6 +8,7 @@ import {
setActiveTab,
setAllParameters,
setInitialImage,
setPrompt,
setSeed,
setShouldShowImageDetails,
} from '../options/optionsSlice';
@ -18,27 +17,30 @@ import { SystemState } from '../system/systemSlice';
import IAIButton from '../../common/components/IAIButton';
import { runESRGAN, runFacetool } from '../../app/socketio/actions';
import IAIIconButton from '../../common/components/IAIIconButton';
import { MdDelete, MdFace, MdHd, MdImage, MdInfo } from 'react-icons/md';
import InvokePopover from './InvokePopover';
import UpscaleOptions from '../options/AdvancedOptions/Upscale/UpscaleOptions';
import FaceRestoreOptions from '../options/AdvancedOptions/FaceRestore/FaceRestoreOptions';
import { useHotkeys } from 'react-hotkeys-hook';
import { useToast } from '@chakra-ui/react';
import { FaCopy, FaPaintBrush, FaSeedling } from 'react-icons/fa';
import { setImageToInpaint } from '../tabs/Inpainting/inpaintingSlice';
import { ButtonGroup, Link, useClipboard, useToast } from '@chakra-ui/react';
import {
FaAsterisk,
FaCode,
FaCopy,
FaDownload,
FaExpandArrowsAlt,
FaGrinStars,
FaQuoteRight,
FaSeedling,
FaShare,
FaShareAlt,
FaTrash,
} from 'react-icons/fa';
import {
setImageToInpaint,
setNeedsCache,
} from '../tabs/Inpainting/inpaintingSlice';
import { GalleryState } from './gallerySlice';
import { activeTabNameSelector } from '../options/optionsSelectors';
const intermediateImageSelector = createSelector(
(state: RootState) => state.gallery,
(gallery: GalleryState) => gallery.intermediateImage,
{
memoizeOptions: {
resultEqualityCheck: (a, b) =>
(a === undefined && b === undefined) || a.uuid === b.uuid,
},
}
);
import IAIPopover from '../../common/components/IAIPopover';
const systemSelector = createSelector(
[
@ -59,7 +61,7 @@ const systemSelector = createSelector(
const { upscalingLevel, facetoolStrength, shouldShowImageDetails } =
options;
const { intermediateImage } = gallery;
const { intermediateImage, currentImage } = gallery;
return {
isProcessing,
@ -68,7 +70,8 @@ const systemSelector = createSelector(
isESRGANAvailable,
upscalingLevel,
facetoolStrength,
intermediateImage,
shouldDisableToolbarButtons: Boolean(intermediateImage) || !currentImage,
currentImage,
shouldShowImageDetails,
activeTabName,
};
@ -80,15 +83,11 @@ const systemSelector = createSelector(
}
);
type CurrentImageButtonsProps = {
image: InvokeAI.Image;
};
/**
* Row of buttons for common actions:
* Use as init image, use all params, use seed, upscale, fix faces, details, delete.
*/
const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
const CurrentImageButtons = () => {
const dispatch = useAppDispatch();
const {
isProcessing,
@ -97,22 +96,37 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
isESRGANAvailable,
upscalingLevel,
facetoolStrength,
intermediateImage,
shouldDisableToolbarButtons,
shouldShowImageDetails,
activeTabName,
currentImage,
} = useAppSelector(systemSelector);
const { onCopy } = useClipboard(
currentImage ? window.location.toString() + currentImage.url : ''
);
const toast = useToast();
const handleClickUseAsInitialImage = () => {
dispatch(setInitialImage(image));
dispatch(setActiveTab(1));
if (!currentImage) return;
dispatch(setInitialImage(currentImage));
dispatch(setActiveTab('img2img'));
};
const handleCopyImageLink = () => {
onCopy();
toast({
title: 'Image Link Copied',
status: 'success',
duration: 2500,
isClosable: true,
});
};
useHotkeys(
'shift+i',
() => {
if (image) {
if (currentImage) {
handleClickUseAsInitialImage();
toast({
title: 'Sent To Image To Image',
@ -130,16 +144,20 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
});
}
},
[image]
[currentImage]
);
const handleClickUseAllParameters = () =>
image.metadata && dispatch(setAllParameters(image.metadata));
const handleClickUseAllParameters = () => {
if (!currentImage) return;
currentImage.metadata && dispatch(setAllParameters(currentImage.metadata));
};
useHotkeys(
'a',
() => {
if (['txt2img', 'img2img'].includes(image?.metadata?.image?.type)) {
if (
['txt2img', 'img2img'].includes(currentImage?.metadata?.image?.type)
) {
handleClickUseAllParameters();
toast({
title: 'Parameters Set',
@ -157,15 +175,18 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
});
}
},
[image]
[currentImage]
);
const handleClickUseSeed = () =>
image.metadata && dispatch(setSeed(image.metadata.image.seed));
const handleClickUseSeed = () => {
currentImage?.metadata &&
dispatch(setSeed(currentImage.metadata.image.seed));
};
useHotkeys(
's',
() => {
if (image?.metadata?.image?.seed) {
if (currentImage?.metadata?.image?.seed) {
handleClickUseSeed();
toast({
title: 'Seed Set',
@ -183,16 +204,47 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
});
}
},
[image]
[currentImage]
);
const handleClickUpscale = () => dispatch(runESRGAN(image));
const handleClickUsePrompt = () =>
currentImage?.metadata?.image?.prompt &&
dispatch(setPrompt(currentImage.metadata.image.prompt));
useHotkeys(
'p',
() => {
if (currentImage?.metadata?.image?.prompt) {
handleClickUsePrompt();
toast({
title: 'Prompt Set',
status: 'success',
duration: 2500,
isClosable: true,
});
} else {
toast({
title: 'Prompt Not Set',
description: 'Could not find prompt for this image.',
status: 'error',
duration: 2500,
isClosable: true,
});
}
},
[currentImage]
);
const handleClickUpscale = () => {
currentImage && dispatch(runESRGAN(currentImage));
};
useHotkeys(
'u',
() => {
if (
isESRGANAvailable &&
Boolean(!intermediateImage) &&
!shouldDisableToolbarButtons &&
isConnected &&
!isProcessing &&
upscalingLevel
@ -208,23 +260,25 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
}
},
[
image,
currentImage,
isESRGANAvailable,
intermediateImage,
shouldDisableToolbarButtons,
isConnected,
isProcessing,
upscalingLevel,
]
);
const handleClickFixFaces = () => dispatch(runFacetool(image));
const handleClickFixFaces = () => {
currentImage && dispatch(runFacetool(currentImage));
};
useHotkeys(
'r',
() => {
if (
isGFPGANAvailable &&
Boolean(!intermediateImage) &&
!shouldDisableToolbarButtons &&
isConnected &&
!isProcessing &&
facetoolStrength
@ -240,9 +294,9 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
}
},
[
image,
currentImage,
isGFPGANAvailable,
intermediateImage,
shouldDisableToolbarButtons,
isConnected,
isProcessing,
facetoolStrength,
@ -253,10 +307,13 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
dispatch(setShouldShowImageDetails(!shouldShowImageDetails));
const handleSendToInpainting = () => {
dispatch(setImageToInpaint(image));
if (activeTabName !== 'inpainting') {
if (!currentImage) return;
dispatch(setImageToInpaint(currentImage));
dispatch(setActiveTab('inpainting'));
}
dispatch(setNeedsCache(true));
toast({
title: 'Sent to Inpainting',
status: 'success',
@ -268,7 +325,7 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
useHotkeys(
'i',
() => {
if (image) {
if (currentImage) {
handleClickShowImageDetails();
} else {
toast({
@ -279,111 +336,141 @@ const CurrentImageButtons = ({ image }: CurrentImageButtonsProps) => {
});
}
},
[image, shouldShowImageDetails]
[currentImage, shouldShowImageDetails]
);
return (
<div className="current-image-options">
<IAIIconButton
icon={<MdImage />}
tooltip="Send To Image To Image"
aria-label="Send To Image To Image"
onClick={handleClickUseAsInitialImage}
/>
<IAIIconButton
icon={<FaPaintBrush />}
tooltip="Send To Inpainting"
aria-label="Send To Inpainting"
onClick={handleSendToInpainting}
/>
<IAIIconButton
icon={<FaCopy />}
tooltip="Use All"
aria-label="Use All"
isDisabled={
!['txt2img', 'img2img'].includes(image?.metadata?.image?.type)
<ButtonGroup isAttached={true}>
<IAIPopover
trigger="hover"
triggerComponent={
<IAIIconButton aria-label="Send to..." icon={<FaShareAlt />} />
}
onClick={handleClickUseAllParameters}
>
<div className="current-image-send-to-popover">
<IAIButton
size={'sm'}
onClick={handleClickUseAsInitialImage}
leftIcon={<FaShare />}
>
Send to Image to Image
</IAIButton>
<IAIButton
size={'sm'}
onClick={handleSendToInpainting}
leftIcon={<FaShare />}
>
Send to Inpainting
</IAIButton>
<IAIButton
size={'sm'}
onClick={handleCopyImageLink}
leftIcon={<FaCopy />}
>
Copy Link to Image
</IAIButton>
<IAIButton leftIcon={<FaDownload />} size={'sm'}>
<Link download={true} href={currentImage?.url}>
Download Image
</Link>
</IAIButton>
</div>
</IAIPopover>
</ButtonGroup>
<ButtonGroup isAttached={true}>
<IAIIconButton
icon={<FaQuoteRight />}
tooltip="Use Prompt"
aria-label="Use Prompt"
isDisabled={!currentImage?.metadata?.image?.prompt}
onClick={handleClickUsePrompt}
/>
<IAIIconButton
icon={<FaSeedling />}
tooltip="Use Seed"
aria-label="Use Seed"
isDisabled={!image?.metadata?.image?.seed}
isDisabled={!currentImage?.metadata?.image?.seed}
onClick={handleClickUseSeed}
/>
{/* <IAIButton
label="Use All"
<IAIIconButton
icon={<FaAsterisk />}
tooltip="Use All"
aria-label="Use All"
isDisabled={
!['txt2img', 'img2img'].includes(image?.metadata?.image?.type)
!['txt2img', 'img2img'].includes(
currentImage?.metadata?.image?.type
)
}
onClick={handleClickUseAllParameters}
/>
</ButtonGroup>
<ButtonGroup isAttached={true}>
<IAIPopover
trigger="hover"
triggerComponent={
<IAIIconButton icon={<FaGrinStars />} aria-label="Restore Faces" />
}
>
<div className="current-image-postprocessing-popover">
<FaceRestoreOptions />
<IAIButton
label="Use Seed"
isDisabled={!image?.metadata?.image?.seed}
onClick={handleClickUseSeed}
/> */}
<InvokePopover
title="Restore Faces"
popoverOptions={<FaceRestoreOptions />}
actionButton={
<IAIButton
label={'Restore Faces'}
isDisabled={
!isGFPGANAvailable ||
Boolean(intermediateImage) ||
!currentImage ||
!(isConnected && !isProcessing) ||
!facetoolStrength
}
onClick={handleClickFixFaces}
/>
>
Restore Faces
</IAIButton>
</div>
</IAIPopover>
<IAIPopover
trigger="hover"
triggerComponent={
<IAIIconButton icon={<FaExpandArrowsAlt />} aria-label="Upscale" />
}
>
<IAIIconButton icon={<MdFace />} aria-label="Restore Faces" />
</InvokePopover>
<InvokePopover
title="Upscale"
styleClass="upscale-popover"
popoverOptions={<UpscaleOptions />}
actionButton={
<div className="current-image-postprocessing-popover">
<UpscaleOptions />
<IAIButton
label={'Upscale Image'}
isDisabled={
!isESRGANAvailable ||
Boolean(intermediateImage) ||
!currentImage ||
!(isConnected && !isProcessing) ||
!upscalingLevel
}
onClick={handleClickUpscale}
/>
}
>
<IAIIconButton icon={<MdHd />} aria-label="Upscale" />
</InvokePopover>
Upscale Image
</IAIButton>
</div>
</IAIPopover>
</ButtonGroup>
<IAIIconButton
icon={<MdInfo />}
icon={<FaCode />}
tooltip="Details"
aria-label="Details"
data-selected={shouldShowImageDetails}
onClick={handleClickShowImageDetails}
/>
<DeleteImageModal image={image}>
<DeleteImageModal image={currentImage}>
<IAIIconButton
icon={<MdDelete />}
icon={<FaTrash />}
tooltip="Delete Image"
aria-label="Delete Image"
isDisabled={
Boolean(intermediateImage) || !isConnected || isProcessing
}
isDisabled={!currentImage || !isConnected || isProcessing}
className="delete-image-btn"
/>
</DeleteImageModal>
</div>

View File

@ -9,18 +9,6 @@
border-radius: 0.5rem;
}
.current-image-options {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
column-gap: 0.5rem;
.chakra-popover__popper {
z-index: 11;
}
}
.current-image-preview {
position: relative;
justify-content: center;
@ -50,6 +38,7 @@
justify-content: space-between;
z-index: 1;
height: 100%;
width: 100%;
pointer-events: none;
}

View File

@ -19,10 +19,9 @@ export const currentImageDisplaySelector = createSelector(
const { shouldShowImageDetails } = options;
return {
currentImage,
intermediateImage,
activeTabName,
shouldShowImageDetails,
hasAnImageToDisplay: currentImage || intermediateImage,
};
},
{
@ -36,18 +35,16 @@ export const currentImageDisplaySelector = createSelector(
* Displays the current image if there is one, plus associated actions.
*/
const CurrentImageDisplay = () => {
const { currentImage, intermediateImage, activeTabName } = useAppSelector(
const { hasAnImageToDisplay, activeTabName } = useAppSelector(
currentImageDisplaySelector
);
const imageToDisplay = intermediateImage || currentImage;
return (
<div className="current-image-area" data-tab-name={activeTabName}>
{imageToDisplay ? (
{hasAnImageToDisplay ? (
<>
<CurrentImageButtons image={imageToDisplay} />
<CurrentImagePreview imageToDisplay={imageToDisplay} />
<CurrentImageButtons />
<CurrentImagePreview />
</>
) : (
<div className="current-image-display-placeholder">

View File

@ -2,8 +2,12 @@ import { IconButton, Image } from '@chakra-ui/react';
import { useState } from 'react';
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';
import { RootState, useAppDispatch, useAppSelector } from '../../app/store';
import { GalleryState, selectNextImage, selectPrevImage } from './gallerySlice';
import * as InvokeAI from '../../app/invokeai';
import {
GalleryCategory,
GalleryState,
selectNextImage,
selectPrevImage,
} from './gallerySlice';
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { OptionsState } from '../options/optionsSlice';
@ -12,20 +16,29 @@ import ImageMetadataViewer from './ImageMetaDataViewer/ImageMetadataViewer';
export const imagesSelector = createSelector(
[(state: RootState) => state.gallery, (state: RootState) => state.options],
(gallery: GalleryState, options: OptionsState) => {
const { currentCategory } = gallery;
const { currentCategory, currentImage, intermediateImage } = gallery;
const { shouldShowImageDetails } = options;
const tempImages = gallery.categories[currentCategory].images;
const tempImages =
gallery.categories[
currentImage ? (currentImage.category as GalleryCategory) : 'result'
].images;
const currentImageIndex = tempImages.findIndex(
(i) => i.uuid === gallery?.currentImage?.uuid
);
const imagesLength = tempImages.length;
return {
imageToDisplay: intermediateImage ? intermediateImage : currentImage,
isIntermediate: intermediateImage,
currentCategory,
isOnFirstImage: currentImageIndex === 0,
isOnLastImage:
!isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1,
shouldShowImageDetails,
shouldShowPrevImageButton: currentImageIndex === 0,
shouldShowNextImageButton:
!isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1,
};
},
{
@ -35,16 +48,16 @@ export const imagesSelector = createSelector(
}
);
interface CurrentImagePreviewProps {
imageToDisplay: InvokeAI.Image;
}
export default function CurrentImagePreview(props: CurrentImagePreviewProps) {
const { imageToDisplay } = props;
export default function CurrentImagePreview() {
const dispatch = useAppDispatch();
const { isOnFirstImage, isOnLastImage, shouldShowImageDetails } =
useAppSelector(imagesSelector);
const {
isOnFirstImage,
isOnLastImage,
shouldShowImageDetails,
imageToDisplay,
isIntermediate,
} = useAppSelector(imagesSelector);
const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] =
useState<boolean>(false);
@ -67,11 +80,13 @@ export default function CurrentImagePreview(props: CurrentImagePreviewProps) {
return (
<div className={'current-image-preview'}>
{imageToDisplay && (
<Image
src={imageToDisplay.url}
width={imageToDisplay.width}
height={imageToDisplay.height}
width={isIntermediate ? imageToDisplay.width : undefined}
height={isIntermediate ? imageToDisplay.height : undefined}
/>
)}
{!shouldShowImageDetails && (
<div className="current-image-next-prev-buttons">
<div
@ -104,7 +119,7 @@ export default function CurrentImagePreview(props: CurrentImagePreviewProps) {
</div>
</div>
)}
{shouldShowImageDetails && (
{shouldShowImageDetails && imageToDisplay && (
<ImageMetadataViewer
image={imageToDisplay}
styleClass="current-image-metadata"

View File

@ -28,12 +28,18 @@ import { RootState } from '../../app/store';
import { setShouldConfirmOnDelete, SystemState } from '../system/systemSlice';
import * as InvokeAI from '../../app/invokeai';
import { useHotkeys } from 'react-hotkeys-hook';
import _ from 'lodash';
const systemSelector = createSelector(
(state: RootState) => state.system,
(system: SystemState) => {
const { shouldConfirmOnDelete, isConnected, isProcessing } = system;
return { shouldConfirmOnDelete, isConnected, isProcessing };
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
interface DeleteImageModalProps {
@ -44,7 +50,7 @@ interface DeleteImageModalProps {
/**
* The image to delete.
*/
image: InvokeAI.Image;
image?: InvokeAI.Image;
}
/**
@ -67,7 +73,7 @@ const DeleteImageModal = forwardRef(
};
const handleDelete = () => {
if (isConnected && !isProcessing) {
if (isConnected && !isProcessing && image) {
dispatch(deleteImage(image));
}
onClose();
@ -89,7 +95,7 @@ const DeleteImageModal = forwardRef(
<>
{cloneElement(children, {
// TODO: This feels wrong.
onClick: handleClickDelete,
onClick: image ? handleClickDelete : undefined,
ref: ref,
})}

View File

@ -19,8 +19,6 @@
}
.image-gallery-wrapper {
z-index: 100;
&[data-pinned='false'] {
position: fixed;
height: 100vh;

View File

@ -69,9 +69,9 @@ export default function ImageGallery() {
if (!shouldPinGallery) return;
if (activeTabName === 'inpainting') {
dispatch(setGalleryWidth(220));
setGalleryMinWidth(220);
setGalleryMaxWidth(220);
dispatch(setGalleryWidth(190));
setGalleryMinWidth(190);
setGalleryMaxWidth(190);
} else if (activeTabName === 'img2img') {
dispatch(
setGalleryWidth(Math.min(Math.max(Number(galleryWidth), 0), 490))
@ -163,6 +163,15 @@ export default function ImageGallery() {
[shouldPinGallery]
);
useHotkeys(
'esc',
() => {
if (shouldPinGallery) return;
dispatch(setShouldShowGallery(false));
},
[shouldPinGallery]
);
const IMAGE_SIZE_STEP = 32;
useHotkeys(
@ -261,6 +270,7 @@ export default function ImageGallery() {
>
<div
className="image-gallery-wrapper"
style={{ zIndex: shouldPinGallery ? 1 : 100 }}
data-pinned={shouldPinGallery}
ref={galleryRef}
onMouseLeave={!shouldPinGallery ? setCloseGalleryTimer : undefined}

View File

@ -32,10 +32,12 @@ import {
setUpscalingStrength,
setWidth,
setInitialImage,
setShouldShowImageDetails,
} from '../../options/optionsSlice';
import promptToString from '../../../common/util/promptToString';
import { seedWeightsToString } from '../../../common/util/seedWeightPairs';
import { FaCopy } from 'react-icons/fa';
import { useHotkeys } from 'react-hotkeys-hook';
type MetadataItemProps = {
isLink?: boolean;
@ -107,7 +109,10 @@ const memoEqualityCheck = (
const ImageMetadataViewer = memo(
({ image, styleClass }: ImageMetadataViewerProps) => {
const dispatch = useAppDispatch();
// const jsonBgColor = useColorModeValue('blackAlpha.100', 'whiteAlpha.100');
useHotkeys('esc', () => {
dispatch(setShouldShowImageDetails(false));
});
const metadata = image?.metadata?.image || {};
const {

View File

@ -1,35 +0,0 @@
.popover-content {
background-color: var(--background-color-secondary) !important;
border: none !important;
border-top: 0px;
background-color: var(--tab-hover-color);
border-radius: 0 0 0.4rem 0.4rem;
}
.popover-arrow {
background: var(--tab-hover-color) !important;
box-shadow: none;
}
.popover-options {
background: var(--tab-panel-bg);
border-radius: 0 0 0.4rem 0.4rem;
border: 2px solid var(--tab-hover-color);
padding: 0.75rem 1rem 0.75rem 1rem;
display: grid;
grid-auto-rows: max-content;
grid-row-gap: 0.5rem;
justify-content: space-between;
}
.popover-header {
background: var(--tab-hover-color);
border-radius: 0.4rem 0.4rem 0 0;
font-weight: bold;
border: none;
padding-left: 1rem !important;
}
.upscale-popover {
width: 23rem !important;
}

View File

@ -1,45 +0,0 @@
import {
Box,
Popover,
PopoverArrow,
PopoverContent,
PopoverHeader,
PopoverTrigger,
} from '@chakra-ui/react';
import React, { ReactNode } from 'react';
type PopoverProps = {
title?: string;
delay?: number;
styleClass?: string;
popoverOptions?: ReactNode;
actionButton?: ReactNode;
children: ReactNode;
};
const InvokePopover = ({
title = 'Popup',
styleClass,
delay = 50,
popoverOptions,
actionButton,
children,
}: PopoverProps) => {
return (
<Popover trigger={'hover'} closeDelay={delay}>
<PopoverTrigger>
<Box>{children}</Box>
</PopoverTrigger>
<PopoverContent className={`popover-content ${styleClass}`}>
<PopoverArrow className="popover-arrow" />
<PopoverHeader className="popover-header">{title}</PopoverHeader>
<div className="popover-options">
{popoverOptions ? popoverOptions : null}
{actionButton}
</div>
</PopoverContent>
</Popover>
);
};
export default InvokePopover;

View File

@ -4,6 +4,7 @@ import { activeTabNameSelector } from '../options/optionsSelectors';
import { OptionsState } from '../options/optionsSlice';
import { SystemState } from '../system/systemSlice';
import { GalleryState } from './gallerySlice';
import _ from 'lodash';
export const imageGallerySelector = createSelector(
[
@ -43,6 +44,11 @@ export const imageGallerySelector = createSelector(
currentCategory,
galleryWidth,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
@ -65,5 +71,10 @@ export const hoverableImageSelector = createSelector(
galleryImageMinimumWidth: gallery.galleryImageMinimumWidth,
activeTabName,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);

View File

@ -1,205 +0,0 @@
import { Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { BiHide, BiReset, BiShow } from 'react-icons/bi';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAICheckbox from '../../../../common/components/IAICheckbox';
import IAIIconButton from '../../../../common/components/IAIIconButton';
import IAINumberInput from '../../../../common/components/IAINumberInput';
import IAISlider from '../../../../common/components/IAISlider';
import { roundDownToMultiple } from '../../../../common/util/roundDownToMultiple';
import {
InpaintingState,
setBoundingBoxDimensions,
setShouldLockBoundingBox,
setShouldShowBoundingBox,
setShouldShowBoundingBoxFill,
} from '../../../tabs/Inpainting/inpaintingSlice';
const boundingBoxDimensionsSelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => {
const {
canvasDimensions,
boundingBoxDimensions,
shouldShowBoundingBox,
shouldShowBoundingBoxFill,
pastLines,
futureLines,
shouldLockBoundingBox,
} = inpainting;
return {
canvasDimensions,
boundingBoxDimensions,
shouldShowBoundingBox,
shouldShowBoundingBoxFill,
pastLines,
futureLines,
shouldLockBoundingBox,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const BoundingBoxSettings = () => {
const dispatch = useAppDispatch();
const {
canvasDimensions,
boundingBoxDimensions,
shouldShowBoundingBox,
shouldShowBoundingBoxFill,
shouldLockBoundingBox,
} = useAppSelector(boundingBoxDimensionsSelector);
const handleChangeBoundingBoxWidth = (v: number) => {
dispatch(
setBoundingBoxDimensions({
...boundingBoxDimensions,
width: Math.floor(v),
})
);
};
const handleChangeBoundingBoxHeight = (v: number) => {
dispatch(
setBoundingBoxDimensions({
...boundingBoxDimensions,
height: Math.floor(v),
})
);
};
const handleChangeShouldShowBoundingBoxFill = () => {
dispatch(setShouldShowBoundingBoxFill(!shouldShowBoundingBoxFill));
};
const handleChangeShouldLockBoundingBox = () => {
dispatch(setShouldLockBoundingBox(!shouldLockBoundingBox));
};
const handleResetWidth = () => {
dispatch(
setBoundingBoxDimensions({
...boundingBoxDimensions,
width: Math.floor(canvasDimensions.width),
})
);
};
const handleResetHeight = () => {
dispatch(
setBoundingBoxDimensions({
...boundingBoxDimensions,
height: Math.floor(canvasDimensions.height),
})
);
};
const handleShowBoundingBox = () =>
dispatch(setShouldShowBoundingBox(!shouldShowBoundingBox));
return (
<div className="inpainting-bounding-box-settings">
<div className="inpainting-bounding-box-header">
<p>Inpaint Box</p>
<IAIIconButton
aria-label="Toggle Bounding Box Visibility"
icon={
shouldShowBoundingBox ? <BiShow size={22} /> : <BiHide size={22} />
}
onClick={handleShowBoundingBox}
background={'none'}
padding={0}
/>
</div>
<div className="inpainting-bounding-box-settings-items">
<div className="inpainting-bounding-box-dimensions-slider-numberinput">
<IAISlider
label="Box W"
min={64}
max={roundDownToMultiple(canvasDimensions.width, 64)}
step={64}
value={boundingBoxDimensions.width}
onChange={handleChangeBoundingBoxWidth}
width={'5rem'}
/>
<IAINumberInput
value={boundingBoxDimensions.width}
onChange={handleChangeBoundingBoxWidth}
min={64}
max={roundDownToMultiple(canvasDimensions.width, 64)}
step={64}
width={'5rem'}
/>
<IAIIconButton
size={'sm'}
aria-label={'Reset Width'}
tooltip={'Reset Width'}
onClick={handleResetWidth}
icon={<BiReset />}
styleClass="inpainting-bounding-box-reset-icon-btn"
isDisabled={canvasDimensions.width === boundingBoxDimensions.width}
/>
</div>
<div className="inpainting-bounding-box-dimensions-slider-numberinput">
<IAISlider
label="Box H"
min={64}
max={roundDownToMultiple(canvasDimensions.height, 64)}
step={64}
value={boundingBoxDimensions.height}
onChange={handleChangeBoundingBoxHeight}
width={'5rem'}
/>
<IAINumberInput
value={boundingBoxDimensions.height}
onChange={handleChangeBoundingBoxHeight}
min={64}
max={roundDownToMultiple(canvasDimensions.height, 64)}
step={64}
padding="0"
width={'5rem'}
/>
<IAIIconButton
size={'sm'}
aria-label={'Reset Height'}
tooltip={'Reset Height'}
onClick={handleResetHeight}
icon={<BiReset />}
styleClass="inpainting-bounding-box-reset-icon-btn"
isDisabled={
canvasDimensions.height === boundingBoxDimensions.height
}
/>
</div>
<Flex alignItems={'center'} justifyContent={'space-between'}>
<IAICheckbox
label="Darken Outside Box"
isChecked={shouldShowBoundingBoxFill}
onChange={handleChangeShouldShowBoundingBoxFill}
styleClass="inpainting-bounding-box-darken"
/>
<IAICheckbox
label="Lock Bounding Box"
isChecked={shouldLockBoundingBox}
onChange={handleChangeShouldLockBoundingBox}
styleClass="inpainting-bounding-box-darken"
/>
</Flex>
</div>
</div>
);
};
export default BoundingBoxSettings;

View File

@ -0,0 +1,28 @@
import React from 'react';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../../app/store';
import IAICheckbox from '../../../../../common/components/IAICheckbox';
import { setShouldShowBoundingBoxFill } from '../../../../tabs/Inpainting/inpaintingSlice';
export default function BoundingBoxDarkenOutside() {
const dispatch = useAppDispatch();
const shouldShowBoundingBoxFill = useAppSelector(
(state: RootState) => state.inpainting.shouldShowBoundingBoxFill
);
const handleChangeShouldShowBoundingBoxFill = () => {
dispatch(setShouldShowBoundingBoxFill(!shouldShowBoundingBoxFill));
};
return (
<IAICheckbox
label="Darken Outside Box"
isChecked={shouldShowBoundingBoxFill}
onChange={handleChangeShouldShowBoundingBoxFill}
styleClass="inpainting-bounding-box-darken"
/>
);
}

View File

@ -0,0 +1,128 @@
import React from 'react';
import IAISlider from '../../../../../common/components/IAISlider';
import IAINumberInput from '../../../../../common/components/IAINumberInput';
import IAIIconButton from '../../../../../common/components/IAIIconButton';
import { BiReset } from 'react-icons/bi';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../../app/store';
import { createSelector } from '@reduxjs/toolkit';
import {
InpaintingState,
setBoundingBoxDimensions,
} from '../../../../tabs/Inpainting/inpaintingSlice';
import { roundDownToMultiple } from '../../../../../common/util/roundDownToMultiple';
import _ from 'lodash';
const boundingBoxDimensionsSelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => {
const { canvasDimensions, boundingBoxDimensions, shouldLockBoundingBox } =
inpainting;
return {
canvasDimensions,
boundingBoxDimensions,
shouldLockBoundingBox,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
type BoundingBoxDimensionSlidersType = {
dimension: 'width' | 'height';
};
export default function BoundingBoxDimensionSlider(
props: BoundingBoxDimensionSlidersType
) {
const { dimension } = props;
const dispatch = useAppDispatch();
const { shouldLockBoundingBox, canvasDimensions, boundingBoxDimensions } =
useAppSelector(boundingBoxDimensionsSelector);
const canvasDimension = canvasDimensions[dimension];
const boundingBoxDimension = boundingBoxDimensions[dimension];
const handleBoundingBoxDimension = (v: number) => {
if (dimension == 'width') {
dispatch(
setBoundingBoxDimensions({
...boundingBoxDimensions,
width: Math.floor(v),
})
);
}
if (dimension == 'height') {
dispatch(
setBoundingBoxDimensions({
...boundingBoxDimensions,
height: Math.floor(v),
})
);
}
};
const handleResetDimension = () => {
if (dimension == 'width') {
dispatch(
setBoundingBoxDimensions({
...boundingBoxDimensions,
width: Math.floor(canvasDimension),
})
);
}
if (dimension == 'height') {
dispatch(
setBoundingBoxDimensions({
...boundingBoxDimensions,
height: Math.floor(canvasDimension),
})
);
}
};
return (
<div className="inpainting-bounding-box-dimensions-slider-numberinput">
<IAISlider
isDisabled={shouldLockBoundingBox}
label="Box H"
min={64}
max={roundDownToMultiple(canvasDimension, 64)}
step={64}
value={boundingBoxDimension}
onChange={handleBoundingBoxDimension}
width={'5rem'}
/>
<IAINumberInput
isDisabled={shouldLockBoundingBox}
value={boundingBoxDimension}
onChange={handleBoundingBoxDimension}
min={64}
max={roundDownToMultiple(canvasDimension, 64)}
step={64}
padding="0"
width={'5rem'}
/>
<IAIIconButton
size={'sm'}
aria-label={'Reset Height'}
tooltip={'Reset Height'}
onClick={handleResetDimension}
icon={<BiReset />}
styleClass="inpainting-bounding-box-reset-icon-btn"
isDisabled={
shouldLockBoundingBox || canvasDimension === boundingBoxDimension
}
/>
</div>
);
}

View File

@ -0,0 +1,27 @@
import React from 'react';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../../app/store';
import IAICheckbox from '../../../../../common/components/IAICheckbox';
import { setShouldLockBoundingBox } from '../../../../tabs/Inpainting/inpaintingSlice';
export default function BoundingBoxLock() {
const shouldLockBoundingBox = useAppSelector(
(state: RootState) => state.inpainting.shouldLockBoundingBox
);
const dispatch = useAppDispatch();
const handleChangeShouldLockBoundingBox = () => {
dispatch(setShouldLockBoundingBox(!shouldLockBoundingBox));
};
return (
<IAICheckbox
label="Lock Bounding Box"
isChecked={shouldLockBoundingBox}
onChange={handleChangeShouldLockBoundingBox}
styleClass="inpainting-bounding-box-darken"
/>
);
}

View File

@ -0,0 +1,26 @@
import { Flex } from '@chakra-ui/react';
import BoundingBoxDarkenOutside from './BoundingBoxDarkenOutside';
import BoundingBoxDimensionSlider from './BoundingBoxDimensionSlider';
import BoundingBoxLock from './BoundingBoxLock';
import BoundingBoxVisibility from './BoundingBoxVisibility';
const BoundingBoxSettings = () => {
return (
<div className="inpainting-bounding-box-settings">
<div className="inpainting-bounding-box-header">
<p>Inpaint Box</p>
<BoundingBoxVisibility />
</div>
<div className="inpainting-bounding-box-settings-items">
<BoundingBoxDimensionSlider dimension="width" />
<BoundingBoxDimensionSlider dimension="height" />
<Flex alignItems={'center'} justifyContent={'space-between'}>
<BoundingBoxDarkenOutside />
<BoundingBoxLock />
</Flex>
</div>
</div>
);
};
export default BoundingBoxSettings;

View File

@ -0,0 +1,28 @@
import React from 'react';
import { BiHide, BiShow } from 'react-icons/bi';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../../app/store';
import IAIIconButton from '../../../../../common/components/IAIIconButton';
import { setShouldShowBoundingBox } from '../../../../tabs/Inpainting/inpaintingSlice';
export default function BoundingBoxVisibility() {
const shouldShowBoundingBox = useAppSelector(
(state: RootState) => state.inpainting.shouldShowBoundingBox
);
const dispatch = useAppDispatch();
const handleShowBoundingBox = () =>
dispatch(setShouldShowBoundingBox(!shouldShowBoundingBox));
return (
<IAIIconButton
aria-label="Toggle Bounding Box Visibility"
icon={shouldShowBoundingBox ? <BiShow size={22} /> : <BiHide size={22} />}
onClick={handleShowBoundingBox}
background={'none'}
padding={0}
/>
);
}

View File

@ -0,0 +1,56 @@
import { useToast } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAIButton from '../../../../common/components/IAIButton';
import {
InpaintingState,
setClearBrushHistory,
} from '../../../tabs/Inpainting/inpaintingSlice';
import _ from 'lodash';
const clearBrushHistorySelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => {
const { pastLines, futureLines } = inpainting;
return {
mayClearBrushHistory:
futureLines.length > 0 || pastLines.length > 0 ? false : true,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function ClearBrushHistory() {
const dispatch = useAppDispatch();
const toast = useToast();
const { mayClearBrushHistory } = useAppSelector(clearBrushHistorySelector);
const handleClearBrushHistory = () => {
dispatch(setClearBrushHistory());
toast({
title: 'Brush Stroke History Cleared',
status: 'success',
duration: 2500,
isClosable: true,
});
};
return (
<IAIButton
onClick={handleClearBrushHistory}
tooltip="Clears brush stroke history"
disabled={mayClearBrushHistory}
styleClass="inpainting-options-btn"
>
Clear Brush History
</IAIButton>
);
}

View File

@ -0,0 +1,70 @@
import React, { ChangeEvent } from 'react';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAINumberInput from '../../../../common/components/IAINumberInput';
import _ from 'lodash';
import { createSelector } from '@reduxjs/toolkit';
import {
InpaintingState,
setInpaintReplace,
setShouldUseInpaintReplace,
} from '../../../tabs/Inpainting/inpaintingSlice';
import IAISwitch from '../../../../common/components/IAISwitch';
const inpaintReplaceSelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => {
const { inpaintReplace, shouldUseInpaintReplace } = inpainting;
return {
inpaintReplace,
shouldUseInpaintReplace,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function InpaintReplace() {
const { inpaintReplace, shouldUseInpaintReplace } = useAppSelector(
inpaintReplaceSelector
);
const dispatch = useAppDispatch();
return (
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '0 1rem 0 0.2rem',
}}
>
<IAINumberInput
label="Inpaint Replace"
value={inpaintReplace}
min={0}
max={1.0}
step={0.05}
width={'auto'}
formControlProps={{ style: { paddingRight: '1rem' } }}
isInteger={false}
isDisabled={!shouldUseInpaintReplace}
onChange={(v: number) => {
dispatch(setInpaintReplace(v));
}}
/>
<IAISwitch
isChecked={shouldUseInpaintReplace}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
dispatch(setShouldUseInpaintReplace(e.target.checked))
}
/>
</div>
);
}

View File

@ -1,96 +1,13 @@
import { useToast } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import React, { ChangeEvent } from 'react';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAIButton from '../../../../common/components/IAIButton';
import {
InpaintingState,
setClearBrushHistory,
setInpaintReplace,
setShouldUseInpaintReplace,
} from '../../../tabs/Inpainting/inpaintingSlice';
import BoundingBoxSettings from './BoundingBoxSettings';
import _ from 'lodash';
import IAINumberInput from '../../../../common/components/IAINumberInput';
import IAISwitch from '../../../../common/components/IAISwitch';
const inpaintingSelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => {
const { pastLines, futureLines, inpaintReplace, shouldUseInpaintReplace } =
inpainting;
return {
pastLines,
futureLines,
inpaintReplace,
shouldUseInpaintReplace,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
import BoundingBoxSettings from './BoundingBoxSettings/BoundingBoxSettings';
import InpaintReplace from './InpaintReplace';
import ClearBrushHistory from './ClearBrushHistory';
export default function InpaintingSettings() {
const dispatch = useAppDispatch();
const toast = useToast();
const { pastLines, futureLines, inpaintReplace, shouldUseInpaintReplace } =
useAppSelector(inpaintingSelector);
const handleClearBrushHistory = () => {
dispatch(setClearBrushHistory());
toast({
title: 'Brush Stroke History Cleared',
status: 'success',
duration: 2500,
isClosable: true,
});
};
return (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '0 1rem 0 0.2rem',
}}
>
<IAINumberInput
label="Inpaint Replace"
value={inpaintReplace}
min={0}
max={1.0}
step={0.05}
width={'auto'}
formControlProps={{ style: { paddingRight: '1rem' } }}
isInteger={false}
isDisabled={!shouldUseInpaintReplace}
onChange={(v: number) => {
dispatch(setInpaintReplace(v));
}}
/>
<IAISwitch
isChecked={shouldUseInpaintReplace}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
dispatch(setShouldUseInpaintReplace(e.target.checked))
}
/>
</div>
<InpaintReplace />
<BoundingBoxSettings />
<IAIButton
label="Clear Brush History"
onClick={handleClearBrushHistory}
tooltip="Clears brush stroke history"
disabled={futureLines.length > 0 || pastLines.length > 0 ? false : true}
styleClass="inpainting-options-btn"
/>
<ClearBrushHistory />
</>
);
}

View File

@ -7,7 +7,7 @@ import { setHeight } from '../optionsSlice';
import { fontSize } from './MainOptions';
export default function MainHeight() {
const { height } = useAppSelector((state: RootState) => state.options);
const height = useAppSelector((state: RootState) => state.options.height);
const activeTabName = useAppSelector(activeTabNameSelector);
const dispatch = useAppDispatch();

View File

@ -1,13 +1,33 @@
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import React from 'react';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import IAINumberInput from '../../../common/components/IAINumberInput';
import { setIterations } from '../optionsSlice';
import { mayGenerateMultipleImagesSelector } from '../optionsSelectors';
import { OptionsState, setIterations } from '../optionsSlice';
import { fontSize, inputWidth } from './MainOptions';
const mainIterationsSelector = createSelector(
[(state: RootState) => state.options, mayGenerateMultipleImagesSelector],
(options: OptionsState, mayGenerateMultipleImages) => {
const { iterations } = options;
return {
iterations,
mayGenerateMultipleImages,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function MainIterations() {
const dispatch = useAppDispatch();
const iterations = useAppSelector(
(state: RootState) => state.options.iterations
const { iterations, mayGenerateMultipleImages } = useAppSelector(
mainIterationsSelector
);
const handleChangeIterations = (v: number) => dispatch(setIterations(v));
@ -18,6 +38,7 @@ export default function MainIterations() {
step={1}
min={1}
max={9999}
isDisabled={!mayGenerateMultipleImages}
onChange={handleChangeIterations}
value={iterations}
width={inputWidth}

View File

@ -19,6 +19,7 @@
.main-option-block {
border-radius: 0.5rem;
display: grid !important;
grid-template-columns: auto !important;
row-gap: 0.4rem;

View File

@ -2,14 +2,14 @@ import React, { ChangeEvent } from 'react';
import { WIDTHS } from '../../../app/constants';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import IAISelect from '../../../common/components/IAISelect';
import { tabMap } from '../../tabs/InvokeTabs';
import { activeTabNameSelector } from '../optionsSelectors';
import { setWidth } from '../optionsSlice';
import { fontSize } from './MainOptions';
export default function MainWidth() {
const { width, activeTab } = useAppSelector(
(state: RootState) => state.options
);
const width = useAppSelector((state: RootState) => state.options.width);
const activeTabName = useAppSelector(activeTabNameSelector);
const dispatch = useAppDispatch();
const handleChangeWidth = (e: ChangeEvent<HTMLSelectElement>) =>
@ -17,7 +17,7 @@ export default function MainWidth() {
return (
<IAISelect
isDisabled={tabMap[activeTab] === 'inpainting'}
isDisabled={activeTabName === 'inpainting'}
label="Width"
value={width}
flexGrow={1}

View File

@ -1,12 +1,13 @@
import { MdCancel } from 'react-icons/md';
import { cancelProcessing } from '../../../app/socketio/actions';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import IAIIconButton from '../../../common/components/IAIIconButton';
import IAIIconButton, {
IAIIconButtonProps,
} from '../../../common/components/IAIIconButton';
import { useHotkeys } from 'react-hotkeys-hook';
import { createSelector } from '@reduxjs/toolkit';
import { SystemState } from '../../system/systemSlice';
import _ from 'lodash';
import { IAIButtonProps } from '../../../common/components/IAIButton';
const cancelButtonSelector = createSelector(
(state: RootState) => state.system,
@ -24,7 +25,9 @@ const cancelButtonSelector = createSelector(
}
);
export default function CancelButton(props: Omit<IAIButtonProps, 'label'>) {
export default function CancelButton(
props: Omit<IAIIconButtonProps, 'aria-label'>
) {
const { ...rest } = props;
const dispatch = useAppDispatch();
const { isProcessing, isConnected, isCancelable } =

View File

@ -1,3 +1,4 @@
import { ListItem, UnorderedList } from '@chakra-ui/react';
import { useHotkeys } from 'react-hotkeys-hook';
import { FaPlay } from 'react-icons/fa';
import { readinessSelector } from '../../../app/selectors/readinessSelector';
@ -6,17 +7,21 @@ import { useAppDispatch, useAppSelector } from '../../../app/store';
import IAIButton, {
IAIButtonProps,
} from '../../../common/components/IAIButton';
import IAIIconButton from '../../../common/components/IAIIconButton';
import IAIIconButton, {
IAIIconButtonProps,
} from '../../../common/components/IAIIconButton';
import IAIPopover from '../../../common/components/IAIPopover';
import { activeTabNameSelector } from '../optionsSelectors';
interface InvokeButton extends Omit<IAIButtonProps, 'label'> {
interface InvokeButton
extends Omit<IAIButtonProps | IAIIconButtonProps, 'aria-label'> {
iconButton?: boolean;
}
export default function InvokeButton(props: InvokeButton) {
const { iconButton = false, ...rest } = props;
const dispatch = useAppDispatch();
const isReady = useAppSelector(readinessSelector);
const { isReady, reasonsWhyNotReady } = useAppSelector(readinessSelector);
const activeTabName = useAppSelector(activeTabNameSelector);
const handleClickGenerate = () => {
@ -33,7 +38,9 @@ export default function InvokeButton(props: InvokeButton) {
[isReady, activeTabName]
);
return iconButton ? (
const buttonComponent = (
<div style={{ flexGrow: 4 }}>
{iconButton ? (
<IAIIconButton
aria-label="Invoke"
type="submit"
@ -42,18 +49,51 @@ export default function InvokeButton(props: InvokeButton) {
onClick={handleClickGenerate}
className="invoke-btn invoke"
tooltip="Invoke"
tooltipPlacement="bottom"
tooltipProps={{ placement: 'bottom' }}
{...rest}
/>
) : (
<IAIButton
label="Invoke"
aria-label="Invoke"
type="submit"
isDisabled={!isReady}
onClick={handleClickGenerate}
className="invoke-btn"
{...rest}
/>
>
Invoke
</IAIButton>
)}
</div>
);
return isReady ? (
buttonComponent
) : (
<IAIPopover trigger="hover" triggerComponent={buttonComponent}>
{reasonsWhyNotReady && (
<UnorderedList>
{reasonsWhyNotReady.map((reason, i) => (
<ListItem key={i}>{reason}</ListItem>
))}
</UnorderedList>
)}
</IAIPopover>
);
// return isReady ? (
// buttonComponent
// ) : (
// <IAIPopover trigger="hover" triggerComponent={buttonComponent}>
// {reasonsWhyNotReady ? (
// <UnorderedList>
// {reasonsWhyNotReady.map((reason, i) => (
// <ListItem key={i}>{reason}</ListItem>
// ))}
// </UnorderedList>
// ) : (
// 'test'
// )}
// </IAIPopover>
// );
}

View File

@ -15,9 +15,11 @@ const LoopbackButton = () => {
return (
<IAIIconButton
aria-label="Loopback"
tooltip="Loopback"
data-selected={shouldLoopback}
aria-label="Toggle Loopback"
tooltip="Toggle Loopback"
styleClass="loopback-btn"
asCheckbox={true}
isChecked={shouldLoopback}
icon={<FaRecycle />}
onClick={() => {
dispatch(setShouldLoopback(!shouldLoopback));

View File

@ -7,6 +7,7 @@
.invoke-btn {
flex-grow: 1;
width: 100%;
svg {
width: 18px !important;
height: 18px !important;
@ -25,3 +26,34 @@
// $btn-width: 3rem
);
}
.loopback-btn {
&[data-as-checkbox='true'] {
background-color: var(--btn-grey);
border: 3px solid var(--btn-grey);
svg {
fill: var(--text-color);
}
&:hover {
background-color: var(--btn-grey);
border-color: var(--btn-checkbox-border-hover);
svg {
fill: var(--text-color);
}
}
&[data-selected='true'] {
border-color: var(--accent-color);
background-color: var(--btn-grey);
svg {
fill: var(--text-color);
}
&:hover {
border-color: var(--accent-color);
background-color: var(--btn-grey);
svg {
fill: var(--text-color);
}
}
}
}
}

View File

@ -31,7 +31,7 @@ const promptInputSelector = createSelector(
const PromptInput = () => {
const dispatch = useAppDispatch();
const { prompt, activeTabName } = useAppSelector(promptInputSelector);
const isReady = useAppSelector(readinessSelector);
const { isReady } = useAppSelector(readinessSelector);
const promptRef = useRef<HTMLTextAreaElement>(null);

View File

@ -13,3 +13,17 @@ export const activeTabNameSelector = createSelector(
},
}
);
export const mayGenerateMultipleImagesSelector = createSelector(
(state: RootState) => state.options,
(options: OptionsState) => {
const { shouldRandomizeSeed, shouldGenerateVariations } = options;
return shouldRandomizeSeed || shouldGenerateVariations;
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);

View File

@ -1,10 +1,3 @@
.console-resizable {
display: flex;
position: fixed;
left: 0;
bottom: 0;
}
.console {
width: 100vw;
display: flex;
@ -48,7 +41,7 @@
position: fixed !important;
left: 0.5rem;
bottom: 0.5rem;
z-index: 21;
z-index: 10000;
&:hover {
background: var(--console-icon-button-bg-color-hover) !important;
@ -67,7 +60,7 @@
position: fixed !important;
left: 0.5rem;
bottom: 3rem;
z-index: 21;
z-index: 10000;
&:hover {
background: var(--console-icon-button-bg-color-hover) !important;

View File

@ -75,6 +75,10 @@ const Console = () => {
[shouldShowLogViewer]
);
useHotkeys('esc', () => {
dispatch(setShouldShowLogViewer(false));
});
const handleOnScroll = () => {
if (!viewerRef.current) return;
if (
@ -99,7 +103,7 @@ const Console = () => {
position: 'fixed',
left: 0,
bottom: 0,
zIndex: 20,
zIndex: 9999,
}}
maxHeight={'90vh'}
>

View File

@ -73,15 +73,20 @@ export default function HotkeysModal({ children }: HotkeysModalProps) {
const generalHotkeys = [
{
title: 'Set Parameters',
desc: 'Use all parameters of the current image',
hotkey: 'A',
title: 'Set Prompt',
desc: 'Use the prompt of the current image',
hotkey: 'P',
},
{
title: 'Set Seed',
desc: 'Use the seed of the current image',
hotkey: 'S',
},
{
title: 'Set Parameters',
desc: 'Use all parameters of the current image',
hotkey: 'A',
},
{ title: 'Restore Faces', desc: 'Restore the current image', hotkey: 'R' },
{ title: 'Upscale', desc: 'Upscale the current image', hotkey: 'U' },
{
@ -95,6 +100,7 @@ export default function HotkeysModal({ children }: HotkeysModalProps) {
hotkey: 'Shift+I',
},
{ title: 'Delete Image', desc: 'Delete the current image', hotkey: 'Del' },
{ title: 'Close Panels', desc: 'Closes open panels', hotkey: 'Esc' },
];
const galleryHotkeys = [
@ -194,12 +200,12 @@ export default function HotkeysModal({ children }: HotkeysModalProps) {
{
title: 'Lock Bounding Box',
desc: 'Locks the bounding box',
hotkey: 'M',
hotkey: 'Shift+Q',
},
{
title: 'Quick Toggle Lock Bounding Box',
desc: 'Hold to toggle locking the bounding box',
hotkey: 'Space',
hotkey: 'Q',
},
{
title: 'Expand Inpainting Area',

View File

@ -1,24 +1,37 @@
.model-list {
.chakra-accordion {
display: grid;
row-gap: 0.5rem;
}
// .chakra-accordion {
// display: grid;
// row-gap: 0.5rem;
// }
.chakra-accordion__item {
border: none;
border-radius: 0.3rem;
background-color: var(--tab-hover-color);
}
// .chakra-accordion__item {
// border: none;
// }
// button {
// border-radius: 0.3rem !important;
// &[aria-expanded='true'] {
// // background-color: var(--tab-hover-color);
// border-radius: 0.3rem;
// }
// }
.model-list-accordion {
outline: none;
padding: 0.25rem;
button {
border-radius: 0.3rem !important;
&[aria-expanded='true'] {
background-color: var(--tab-hover-color);
border-radius: 0.3rem;
padding: 0;
margin: 0;
&:hover {
background-color: unset;
}
}
div {
border: none !important;
}
.model-list-button {
display: flex;
flex-direction: row;
@ -64,6 +77,9 @@
}
}
.model-list-item-load-btn {
button {
padding: 0.5rem;
}
}
}
}

View File

@ -73,8 +73,11 @@ const ModelList = () => {
const { models } = useAppSelector(modelListSelector);
return (
<div className="model-list">
<Accordion allowToggle>
<Accordion
allowToggle
className="model-list-accordion"
variant={'unstyled'}
>
<AccordionItem>
<AccordionButton>
<div className="model-list-button">
@ -97,7 +100,6 @@ const ModelList = () => {
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
);
};

View File

@ -14,18 +14,22 @@ import {
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import _, { isEqual } from 'lodash';
import { cloneElement, ReactElement } from 'react';
import { RootState, useAppSelector } from '../../../app/store';
import { ChangeEvent, cloneElement, ReactElement } from 'react';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import { persistor } from '../../../main';
import {
InProgressImageType,
setSaveIntermediatesInterval,
setShouldConfirmOnDelete,
setShouldDisplayGuides,
setShouldDisplayInProgressType,
SystemState,
} from '../systemSlice';
import ModelList from './ModelList';
import { SettingsModalItem, SettingsModalSelectItem } from './SettingsModalItem';
import { IN_PROGRESS_IMAGE_TYPES } from '../../../app/constants';
import IAISwitch from '../../../common/components/IAISwitch';
import IAISelect from '../../../common/components/IAISelect';
import IAINumberInput from '../../../common/components/IAINumberInput';
const systemSelector = createSelector(
(state: RootState) => state.system,
@ -60,6 +64,14 @@ type SettingsModalProps = {
* Secondary post-reset modal is included here.
*/
const SettingsModal = ({ children }: SettingsModalProps) => {
const dispatch = useAppDispatch();
const saveIntermediatesInterval = useAppSelector(
(state: RootState) => state.system.saveIntermediatesInterval
);
const steps = useAppSelector((state: RootState) => state.options.steps);
const {
isOpen: isSettingsModalOpen,
onOpen: onSettingsModalOpen,
@ -89,6 +101,12 @@ const SettingsModal = ({ children }: SettingsModalProps) => {
});
};
const handleChangeIntermediateSteps = (value: number) => {
if (value > steps) value = steps;
if (value < 1) value = 1;
dispatch(setSaveIntermediatesInterval(value));
};
return (
<>
{cloneElement(children, {
@ -101,31 +119,62 @@ const SettingsModal = ({ children }: SettingsModalProps) => {
<ModalHeader className="settings-modal-header">Settings</ModalHeader>
<ModalCloseButton />
<ModalBody className="settings-modal-content">
<ModelList />
<div className="settings-modal-items">
<SettingsModalSelectItem
settingTitle="Display In-Progress Images"
<div className="settings-modal-item">
<ModelList />
</div>
<div
className="settings-modal-item"
style={{ gridAutoFlow: 'row', rowGap: '0.5rem' }}
>
<IAISelect
label={'Display In-Progress Images'}
validValues={IN_PROGRESS_IMAGE_TYPES}
defaultValue={shouldDisplayInProgressType}
dispatcher={setShouldDisplayInProgressType}
value={shouldDisplayInProgressType}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
dispatch(
setShouldDisplayInProgressType(
e.target.value as InProgressImageType
)
)
}
/>
<SettingsModalItem
settingTitle="Confirm on Delete"
{shouldDisplayInProgressType === 'full-res' && (
<IAINumberInput
label="Save images every n steps"
min={1}
max={steps}
step={1}
onChange={handleChangeIntermediateSteps}
value={saveIntermediatesInterval}
width="auto"
textAlign="center"
/>
)}
</div>
<IAISwitch
styleClass="settings-modal-item"
label={'Confirm on Delete'}
isChecked={shouldConfirmOnDelete}
dispatcher={setShouldConfirmOnDelete}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
dispatch(setShouldConfirmOnDelete(e.target.checked))
}
/>
<SettingsModalItem
settingTitle="Display Help Icons"
<IAISwitch
styleClass="settings-modal-item"
label={'Display Help Icons'}
isChecked={shouldDisplayGuides}
dispatcher={setShouldDisplayGuides}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
dispatch(setShouldDisplayGuides(e.target.checked))
}
/>
</div>
<div className="settings-modal-reset">
<Heading size={'md'}>Reset Web UI</Heading>
<Button colorScheme="red" onClick={handleClickResetWebUI}>
Reset Web UI
</Button>
<Text>
Resetting the web UI only resets the browser's local cache of
your images and remembered settings. It does not delete any
@ -136,9 +185,6 @@ const SettingsModal = ({ children }: SettingsModalProps) => {
isn't working, please try resetting before submitting an issue
on GitHub.
</Text>
<Button colorScheme="red" onClick={handleClickResetWebUI}>
Reset Web UI
</Button>
</div>
</ModalBody>

View File

@ -1,50 +0,0 @@
import { useAppDispatch } from '../../../app/store';
import IAISelect from '../../../common/components/IAISelect';
import IAISwitch from '../../../common/components/IAISwitch';
export function SettingsModalItem({
settingTitle,
isChecked,
dispatcher,
}: {
settingTitle: string;
isChecked: boolean;
dispatcher: any;
}) {
const dispatch = useAppDispatch();
return (
<IAISwitch
styleClass="settings-modal-item"
label={settingTitle}
isChecked={isChecked}
onChange={(e) => dispatch(dispatcher(e.target.checked))}
/>
);
}
export function SettingsModalSelectItem({
settingTitle,
validValues,
defaultValue,
dispatcher,
}: {
settingTitle: string;
validValues:
Array<number | string>
| Array<{ key: string; value: string | number }>;
defaultValue: string;
dispatcher: any;
}) {
const dispatch = useAppDispatch();
return (
<IAISelect
styleClass="settings-modal-item"
label={settingTitle}
validValues={validValues}
defaultValue={defaultValue}
onChange={(e) => dispatch(dispatcher(e.target.value))}
/>
);
}

View File

@ -1,11 +1,19 @@
import { IconButton, Link, Tooltip, useColorMode } from '@chakra-ui/react';
import { Link, useColorMode } from '@chakra-ui/react';
import { useHotkeys } from 'react-hotkeys-hook';
import { FaSun, FaMoon, FaGithub, FaDiscord } from 'react-icons/fa';
import { MdHelp, MdKeyboard, MdSettings } from 'react-icons/md';
import {
FaSun,
FaMoon,
FaGithub,
FaDiscord,
FaBug,
FaKeyboard,
FaWrench,
} from 'react-icons/fa';
import InvokeAILogo from '../../assets/images/logo.png';
import IAIIconButton from '../../common/components/IAIIconButton';
import HotkeysModal from './HotkeysModal/HotkeysModal';
@ -26,11 +34,6 @@ const SiteHeader = () => {
[colorMode, toggleColorMode]
);
const colorModeIcon = colorMode == 'light' ? <FaMoon /> : <FaSun />;
// Make FaMoon and FaSun icon apparent size consistent
const colorModeIconFontSize = colorMode == 'light' ? 18 : 20;
return (
<div className="site-header">
<div className="site-header-left-side">
@ -44,47 +47,47 @@ const SiteHeader = () => {
<StatusIndicator />
<HotkeysModal>
<IconButton
<IAIIconButton
aria-label="Hotkeys"
variant="link"
fontSize={24}
tooltip="Hotkeys"
size={'sm'}
icon={<MdKeyboard />}
variant="link"
data-variant="link"
fontSize={20}
icon={<FaKeyboard />}
/>
</HotkeysModal>
<Tooltip hasArrow label="Theme" placement={'bottom'}>
<IconButton
<IAIIconButton
aria-label="Toggle Dark Mode"
tooltip="Dark Mode"
onClick={toggleColorMode}
variant="link"
data-variant="link"
fontSize={20}
size={'sm'}
fontSize={colorModeIconFontSize}
icon={colorModeIcon}
icon={colorMode === 'light' ? <FaMoon /> : <FaSun />}
/>
</Tooltip>
<Tooltip hasArrow label="Report Bug" placement={'bottom'}>
<IconButton
aria-label="Link to Github Issues"
<IAIIconButton
aria-label="Report Bug"
tooltip="Report Bug"
variant="link"
fontSize={23}
data-variant="link"
fontSize={20}
size={'sm'}
icon={
<Link
isExternal
href="http://github.com/invoke-ai/InvokeAI/issues"
>
<MdHelp />
<Link isExternal href="http://github.com/invoke-ai/InvokeAI/issues">
<FaBug />
</Link>
}
/>
</Tooltip>
<Tooltip hasArrow label="Github" placement={'bottom'}>
<IconButton
<IAIIconButton
aria-label="Link to Github Repo"
tooltip="Github"
variant="link"
data-variant="link"
fontSize={20}
size={'sm'}
icon={
@ -93,12 +96,12 @@ const SiteHeader = () => {
</Link>
}
/>
</Tooltip>
<Tooltip hasArrow label="Discord" placement={'bottom'}>
<IconButton
<IAIIconButton
aria-label="Link to Discord Server"
tooltip="Discord"
variant="link"
data-variant="link"
fontSize={20}
size={'sm'}
icon={
@ -107,15 +110,16 @@ const SiteHeader = () => {
</Link>
}
/>
</Tooltip>
<SettingsModal>
<IconButton
<IAIIconButton
aria-label="Settings"
tooltip="Settings"
variant="link"
fontSize={24}
data-variant="link"
fontSize={20}
size={'sm'}
icon={<MdSettings />}
icon={<FaWrench />}
/>
</SettingsModal>
</div>

View File

@ -15,10 +15,17 @@ export interface Log {
[index: number]: LogEntry;
}
export type ReadinessPayload = {
isReady: boolean;
reasonsWhyNotReady: string[];
};
export type InProgressImageType = 'none' | 'full-res' | 'latents';
export interface SystemState
extends InvokeAI.SystemStatus,
InvokeAI.SystemConfig {
shouldDisplayInProgressType: string;
shouldDisplayInProgressType: InProgressImageType;
log: Array<LogEntry>;
shouldShowLogViewer: boolean;
isGFPGANAvailable: boolean;
@ -36,14 +43,15 @@ export interface SystemState
shouldDisplayGuides: boolean;
wasErrorSeen: boolean;
isCancelable: boolean;
saveIntermediatesInterval: number;
}
const initialSystemState = {
const initialSystemState: SystemState = {
isConnected: false,
isProcessing: false,
log: [],
shouldShowLogViewer: false,
shouldDisplayInProgressType: "none",
shouldDisplayInProgressType: 'latents',
shouldDisplayGuides: true,
isGFPGANAvailable: true,
isESRGANAvailable: true,
@ -65,15 +73,17 @@ const initialSystemState = {
hasError: false,
wasErrorSeen: true,
isCancelable: true,
saveIntermediatesInterval: 5,
};
const initialState: SystemState = initialSystemState;
export const systemSlice = createSlice({
name: 'system',
initialState,
initialState: initialSystemState,
reducers: {
setShouldDisplayInProgressType: (state, action: PayloadAction<string>) => {
setShouldDisplayInProgressType: (
state,
action: PayloadAction<InProgressImageType>
) => {
state.shouldDisplayInProgressType = action.payload;
},
setIsProcessing: (state, action: PayloadAction<boolean>) => {
@ -178,6 +188,9 @@ export const systemSlice = createSlice({
state.isProcessing = true;
state.currentStatusHasSteps = false;
},
setSaveIntermediatesInterval: (state, action: PayloadAction<number>) => {
state.saveIntermediatesInterval = action.payload;
},
},
});
@ -200,6 +213,7 @@ export const {
setModelList,
setIsCancelable,
modelChangeRequested,
setSaveIntermediatesInterval,
} = systemSlice.actions;
export default systemSlice.reducer;

View File

@ -13,7 +13,7 @@ const FloatingGalleryButton = () => {
return (
<IAIIconButton
tooltip="Show Gallery (G)"
tooltipPlacement="top"
tooltipProps={{ placement: 'top' }}
aria-label="Show Gallery"
styleClass="floating-show-hide-button right"
onMouseOver={handleShowGallery}

View File

@ -36,7 +36,7 @@ const FloatingOptionsPanelButtons = () => {
<div className="show-hide-button-options">
<IAIIconButton
tooltip="Show Options Panel (O)"
tooltipPlacement="top"
tooltipProps={{ placement: 'top' }}
aria-label="Show Options Panel"
onClick={handleShowOptionsPanel}
>

View File

@ -1,20 +1,22 @@
import { IconButton, Image, useToast } from '@chakra-ui/react';
import React, { SyntheticEvent } from 'react';
import { MdClear } from 'react-icons/md';
import { Image, useToast } from '@chakra-ui/react';
import { SyntheticEvent } from 'react';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import ImageUploaderIconButton from '../../../common/components/ImageUploaderIconButton';
import { clearInitialImage } from '../../options/optionsSlice';
export default function InitImagePreview() {
const { initialImage } = useAppSelector((state: RootState) => state.options);
const initialImage = useAppSelector(
(state: RootState) => state.options.initialImage
);
const dispatch = useAppDispatch();
const toast = useToast();
const handleClickResetInitialImage = (e: SyntheticEvent) => {
e.stopPropagation();
dispatch(clearInitialImage());
};
// const handleClickResetInitialImage = (e: SyntheticEvent) => {
// e.stopPropagation();
// dispatch(clearInitialImage());
// };
const alertMissingInitImage = () => {
toast({
@ -29,13 +31,15 @@ export default function InitImagePreview() {
return (
<>
<div className="init-image-preview-header">
{/* <div className="init-image-preview-header"> */}
<h2>Initial Image</h2>
<IconButton
{/* <IconButton
isDisabled={!initialImage}
aria-label={'Reset Initial Image'}
onClick={handleClickResetInitialImage}
icon={<MdClear />}
/>
/> */}
<ImageUploaderIconButton />
</div>
{initialImage && (
<div className="init-image-preview">

View File

@ -11,7 +11,11 @@
.inpainting-settings {
display: flex;
align-items: center;
column-gap: 1rem;
column-gap: 0.5rem;
svg {
transform: scale(0.9);
}
.inpainting-buttons-group {
display: flex;
@ -29,10 +33,10 @@
margin-left: 1rem !important;
}
.inpainting-slider-numberinput {
.inpainting-brush-options {
display: flex;
column-gap: 1rem;
align-items: center;
column-gap: 1rem;
}
}
@ -45,33 +49,23 @@
height: 100%;
}
.inpainting-canvas-wrapper {
.inpainting-canvas-spiner {
display: flex;
align-items: center;
width: 100%;
height: 100%;
}
.inpainting-canvas-container {
display: flex;
align-items: center;
justify-content: center;
position: relative;
height: 100%;
width: 100%;
border-radius: 0.5rem;
.inpainting-alerts {
position: absolute;
top: 0;
left: 0;
display: flex;
column-gap: 0.5rem;
z-index: 2;
padding: 0.5rem;
pointer-events: none;
font-size: 0.9rem;
font-weight: bold;
div {
background-color: var(--accent-color);
color: var(--text-color);
padding: 0.2rem 0.6rem;
border-radius: 0.25rem;
}
.inpainting-canvas-wrapper {
position: relative;
}
.inpainting-canvas-stage {

View File

@ -33,7 +33,7 @@ import InpaintingBoundingBoxPreview, {
InpaintingBoundingBoxPreviewOverlay,
} from './components/InpaintingBoundingBoxPreview';
import { KonvaEventObject } from 'konva/lib/Node';
import KeyboardEventManager from './components/KeyboardEventManager';
import KeyboardEventManager from './KeyboardEventManager';
import { useToast } from '@chakra-ui/react';
// Use a closure allow other components to use these things... not ideal...
@ -56,8 +56,8 @@ const InpaintingCanvas = () => {
shouldShowBoundingBox,
shouldShowBoundingBoxFill,
isDrawing,
shouldLockBoundingBox,
boundingBoxDimensions,
isModifyingBoundingBox,
stageCursor,
} = useAppSelector(inpaintingCanvasSelector);
const toast = useToast();
@ -113,7 +113,7 @@ const InpaintingCanvas = () => {
if (
!scaledCursorPosition ||
!maskLayerRef.current ||
!shouldLockBoundingBox
isModifyingBoundingBox
)
return;
@ -127,7 +127,7 @@ const InpaintingCanvas = () => {
points: [scaledCursorPosition.x, scaledCursorPosition.y],
})
);
}, [dispatch, brushSize, tool, shouldLockBoundingBox]);
}, [dispatch, brushSize, tool, isModifyingBoundingBox]);
/**
*
@ -143,20 +143,20 @@ const InpaintingCanvas = () => {
dispatch(setCursorPosition(scaledCursorPosition));
if (!maskLayerRef.current || !shouldLockBoundingBox) {
if (!maskLayerRef.current) {
return;
}
lastCursorPosition.current = scaledCursorPosition;
if (!isDrawing) return;
if (!isDrawing || isModifyingBoundingBox) return;
didMouseMoveRef.current = true;
// Extend the current line
dispatch(
addPointToCurrentLine([scaledCursorPosition.x, scaledCursorPosition.y])
);
}, [dispatch, isDrawing, shouldLockBoundingBox]);
}, [dispatch, isDrawing, isModifyingBoundingBox]);
/**
*
@ -170,7 +170,7 @@ const InpaintingCanvas = () => {
if (
!scaledCursorPosition ||
!maskLayerRef.current ||
!shouldLockBoundingBox
isModifyingBoundingBox
)
return;
@ -187,7 +187,7 @@ const InpaintingCanvas = () => {
didMouseMoveRef.current = false;
}
dispatch(setIsDrawing(false));
}, [dispatch, isDrawing, shouldLockBoundingBox]);
}, [dispatch, isDrawing, isModifyingBoundingBox]);
/**
*
@ -214,7 +214,7 @@ const InpaintingCanvas = () => {
if (
!scaledCursorPosition ||
!maskLayerRef.current ||
!shouldLockBoundingBox
isModifyingBoundingBox
)
return;
@ -230,25 +230,12 @@ const InpaintingCanvas = () => {
);
}
},
[dispatch, brushSize, tool, shouldLockBoundingBox]
[dispatch, brushSize, tool, isModifyingBoundingBox]
);
return (
<div className="inpainting-canvas-wrapper" tabIndex={1}>
<div className="inpainting-alerts">
{!shouldShowMask && (
<div style={{ pointerEvents: 'none' }}>Mask Hidden (H)</div>
)}
{shouldInvertMask && (
<div style={{ pointerEvents: 'none' }}>Mask Inverted (Shift+M)</div>
)}
{!shouldLockBoundingBox && (
<div style={{ pointerEvents: 'none' }}>
{`Transforming Bounding Box ${boundingBoxDimensions.width}x${boundingBoxDimensions.height} (M)`}
</div>
)}
</div>
<div className="inpainting-canvas-container">
<div className="inpainting-canvas-wrapper">
{canvasBgImage && (
<Stage
width={Math.floor(canvasBgImage.width * stageScale)}
@ -260,7 +247,7 @@ const InpaintingCanvas = () => {
onMouseUp={handleMouseUp}
onMouseOut={handleMouseOutCanvas}
onMouseLeave={handleMouseOutCanvas}
style={{ cursor: shouldShowMask ? 'none' : 'default' }}
style={{ ...(stageCursor ? { cursor: stageCursor } : {}) }}
className="inpainting-canvas-stage checkerboard"
ref={stageRef}
>
@ -283,7 +270,7 @@ const InpaintingCanvas = () => {
>
<InpaintingCanvasLines />
{shouldLockBoundingBox && <InpaintingCanvasBrushPreview />}
<InpaintingCanvasBrushPreview />
{shouldInvertMask && (
<KonvaImage
@ -300,17 +287,14 @@ const InpaintingCanvas = () => {
/>
)}
</Layer>
{shouldShowMask && (
<Layer>
{shouldShowBoundingBoxFill && shouldShowBoundingBox && (
<InpaintingBoundingBoxPreviewOverlay />
)}
{shouldShowBoundingBox && <InpaintingBoundingBoxPreview />}
{shouldLockBoundingBox && (
<InpaintingCanvasBrushPreviewOutline />
)}
</Layer>
)}
</>
)}
</Stage>
@ -318,6 +302,7 @@ const InpaintingCanvas = () => {
<Cacher />
<KeyboardEventManager />
</div>
</div>
);
};

View File

@ -0,0 +1,29 @@
.inpainting-alerts {
position: absolute;
top: 0;
left: 0;
z-index: 2;
margin: 0.5rem;
button {
background-color: var(--inpainting-alerts-bg);
svg {
fill: var(--inpainting-alerts-icon-color);
}
&[data-selected='true'] {
background-color: var(--inpainting-alerts-bg-active);
svg {
fill: var(--inpainting-alerts-icon-active);
}
}
&[data-alert='true'] {
background-color: var(--inpainting-alerts-bg-alert);
svg {
fill: var(--inpainting-alerts-icon-alert);
}
}
}
}

View File

@ -0,0 +1,126 @@
import _ from 'lodash';
const inpaintingCanvasStatusIconsSelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => {
const {
shouldShowMask,
shouldInvertMask,
shouldLockBoundingBox,
shouldShowBoundingBox,
boundingBoxDimensions,
} = inpainting;
return {
shouldShowMask,
shouldInvertMask,
shouldLockBoundingBox,
shouldShowBoundingBox,
isBoundingBoxTooSmall:
boundingBoxDimensions.width < 512 || boundingBoxDimensions.height < 512,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
import { ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { BiHide, BiShow } from 'react-icons/bi';
import { GiResize } from 'react-icons/gi';
import { BsBoundingBox } from 'react-icons/bs';
import { FaLock, FaUnlock } from 'react-icons/fa';
import { MdInvertColors, MdInvertColorsOff } from 'react-icons/md';
import { RootState, useAppSelector } from '../../../app/store';
import { InpaintingState } from './inpaintingSlice';
import { MouseEvent, useRef, useState } from 'react';
const InpaintingCanvasStatusIcons = () => {
const {
shouldShowMask,
shouldInvertMask,
shouldLockBoundingBox,
shouldShowBoundingBox,
isBoundingBoxTooSmall,
} = useAppSelector(inpaintingCanvasStatusIconsSelector);
const [shouldAcceptPointerEvents, setShouldAcceptPointerEvents] =
useState<boolean>(false);
const timeoutRef = useRef<number>(0);
const handleMouseOver = () => {
if (!shouldAcceptPointerEvents) {
timeoutRef.current = window.setTimeout(
() => setShouldAcceptPointerEvents(true),
1000
);
}
};
const handleMouseOut = () => {
if (!shouldAcceptPointerEvents) {
setShouldAcceptPointerEvents(false);
window.clearTimeout(timeoutRef.current);
}
};
return (
<div
className="inpainting-alerts"
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
onMouseLeave={handleMouseOut}
onBlur={handleMouseOut}
>
<ButtonGroup
isAttached
pointerEvents={shouldAcceptPointerEvents ? 'auto' : 'none'}
>
<Tooltip label="Mask Hidden">
<IconButton
aria-label="Show/HideMask"
size="xs"
variant={'ghost'}
fontSize={'1rem'}
data-selected={!shouldShowMask}
icon={shouldShowMask ? <BiShow /> : <BiHide />}
/>
</Tooltip>
<IconButton
aria-label="Invert Mask"
variant={'ghost'}
size="xs"
fontSize={'1rem'}
data-selected={shouldInvertMask}
icon={shouldInvertMask ? <MdInvertColors /> : <MdInvertColorsOff />}
/>
<IconButton
aria-label="Bounding Box Lock"
size="xs"
variant={'ghost'}
data-selected={shouldLockBoundingBox}
icon={shouldLockBoundingBox ? <FaLock /> : <FaUnlock />}
/>
<IconButton
aria-label="Bounding Box Lock"
size="xs"
variant={'ghost'}
data-alert={!shouldShowBoundingBox}
icon={<BsBoundingBox />}
/>
<IconButton
aria-label="Under 512x512"
size="xs"
variant={'ghost'}
data-alert={isBoundingBoxTooSmall}
icon={<GiResize />}
/>
</ButtonGroup>
</div>
);
};
export default InpaintingCanvasStatusIcons;

View File

@ -1,442 +1,38 @@
import { useToast } from '@chakra-ui/react';
import { useHotkeys } from 'react-hotkeys-hook';
import {
FaEraser,
FaMask,
FaPaintBrush,
FaPalette,
FaPlus,
FaRedo,
FaTrash,
FaUndo,
} from 'react-icons/fa';
import { BiHide, BiShow } from 'react-icons/bi';
import { VscSplitHorizontal } from 'react-icons/vsc';
import { useAppDispatch, useAppSelector } from '../../../app/store';
import IAIIconButton from '../../../common/components/IAIIconButton';
import {
clearMask,
redo,
setMaskColor,
setBrushSize,
setShouldShowBrushPreview,
setTool,
undo,
setShouldShowMask,
setShouldInvertMask,
setNeedsCache,
toggleShouldLockBoundingBox,
clearImageToInpaint,
} from './inpaintingSlice';
import { MdInvertColors, MdInvertColorsOff } from 'react-icons/md';
import IAISlider from '../../../common/components/IAISlider';
import IAINumberInput from '../../../common/components/IAINumberInput';
import { inpaintingControlsSelector } from './inpaintingSliceSelectors';
import IAIPopover from '../../../common/components/IAIPopover';
import IAIColorPicker from '../../../common/components/IAIColorPicker';
import { RgbaColor } from 'react-colorful';
import { setShowDualDisplay } from '../../options/optionsSlice';
import { useState } from 'react';
import InpaintingBrushControl from './InpaintingControls/InpaintingBrushControl';
import InpaintingEraserControl from './InpaintingControls/InpaintingEraserControl';
import InpaintingUndoControl from './InpaintingControls/InpaintingUndoControl';
import InpaintingRedoControl from './InpaintingControls/InpaintingRedoControl';
import { ButtonGroup } from '@chakra-ui/react';
import InpaintingMaskClear from './InpaintingControls/InpaintingMaskControls/InpaintingMaskClear';
import InpaintingMaskVisibilityControl from './InpaintingControls/InpaintingMaskControls/InpaintingMaskVisibilityControl';
import InpaintingMaskInvertControl from './InpaintingControls/InpaintingMaskControls/InpaintingMaskInvertControl';
import InpaintingLockBoundingBoxControl from './InpaintingControls/InpaintingLockBoundingBoxControl';
import InpaintingShowHideBoundingBoxControl from './InpaintingControls/InpaintingShowHideBoundingBoxControl';
import ImageUploaderIconButton from '../../../common/components/ImageUploaderIconButton';
const InpaintingControls = () => {
const {
tool,
brushSize,
maskColor,
shouldInvertMask,
shouldShowMask,
canUndo,
canRedo,
isMaskEmpty,
activeTabName,
showDualDisplay,
} = useAppSelector(inpaintingControlsSelector);
const dispatch = useAppDispatch();
const toast = useToast();
// Button State Controllers
const [maskOptionsOpen, setMaskOptionsOpen] = useState<boolean>(false);
/**
* Hotkeys
*/
// Decrease brush size
useHotkeys(
'[',
(e: KeyboardEvent) => {
e.preventDefault();
if (brushSize - 5 > 0) {
handleChangeBrushSize(brushSize - 5);
} else {
handleChangeBrushSize(1);
}
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask, brushSize]
);
// Increase brush size
useHotkeys(
']',
(e: KeyboardEvent) => {
e.preventDefault();
handleChangeBrushSize(brushSize + 5);
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask, brushSize]
);
// Decrease mask opacity
useHotkeys(
'shift+[',
(e: KeyboardEvent) => {
e.preventDefault();
handleChangeMaskColor({
...maskColor,
a: Math.max(maskColor.a - 0.05, 0),
});
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask, maskColor.a]
);
// Increase mask opacity
useHotkeys(
'shift+]',
(e: KeyboardEvent) => {
e.preventDefault();
handleChangeMaskColor({
...maskColor,
a: Math.min(maskColor.a + 0.05, 100),
});
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask, maskColor.a]
);
// Set tool to eraser
useHotkeys(
'e',
(e: KeyboardEvent) => {
e.preventDefault();
if (activeTabName !== 'inpainting' || !shouldShowMask) return;
handleSelectEraserTool();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask]
);
// Set tool to brush
useHotkeys(
'b',
(e: KeyboardEvent) => {
e.preventDefault();
handleSelectBrushTool();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask]
);
// Toggle lock bounding box
useHotkeys(
'm',
(e: KeyboardEvent) => {
e.preventDefault();
dispatch(toggleShouldLockBoundingBox());
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask]
);
// Undo
useHotkeys(
'cmd+z, control+z',
(e: KeyboardEvent) => {
e.preventDefault();
handleUndo();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask && canUndo,
},
[activeTabName, shouldShowMask, canUndo]
);
// Redo
useHotkeys(
'cmd+shift+z, control+shift+z, control+y, cmd+y',
(e: KeyboardEvent) => {
e.preventDefault();
handleRedo();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask && canRedo,
},
[activeTabName, shouldShowMask, canRedo]
);
// Show/hide mask
useHotkeys(
'h',
(e: KeyboardEvent) => {
e.preventDefault();
handleToggleShouldShowMask();
},
{
enabled: activeTabName === 'inpainting',
},
[activeTabName, shouldShowMask]
);
// Invert mask
useHotkeys(
'shift+m',
(e: KeyboardEvent) => {
e.preventDefault();
handleToggleShouldInvertMask();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldInvertMask, shouldShowMask]
);
// Clear mask
useHotkeys(
'shift+c',
(e: KeyboardEvent) => {
e.preventDefault();
handleClearMask();
toast({
title: 'Mask Cleared',
status: 'success',
duration: 2500,
isClosable: true,
});
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask && !isMaskEmpty,
},
[activeTabName, isMaskEmpty, shouldShowMask]
);
// Toggle split view
useHotkeys(
'shift+j',
() => {
handleDualDisplay();
},
[showDualDisplay]
);
const handleClearMask = () => {
dispatch(clearMask());
};
const handleSelectEraserTool = () => dispatch(setTool('eraser'));
const handleSelectBrushTool = () => dispatch(setTool('brush'));
const handleChangeBrushSize = (v: number) => {
dispatch(setShouldShowBrushPreview(true));
dispatch(setBrushSize(v));
};
const handleToggleShouldShowMask = () =>
dispatch(setShouldShowMask(!shouldShowMask));
const handleToggleShouldInvertMask = () =>
dispatch(setShouldInvertMask(!shouldInvertMask));
const handleShowBrushPreview = () => {
dispatch(setShouldShowBrushPreview(true));
};
const handleHideBrushPreview = () => {
dispatch(setShouldShowBrushPreview(false));
};
const handleChangeMaskColor = (newColor: RgbaColor) => {
dispatch(setMaskColor(newColor));
};
const handleUndo = () => dispatch(undo());
const handleRedo = () => dispatch(redo());
const handleDualDisplay = () => {
dispatch(setShowDualDisplay(!showDualDisplay));
dispatch(setNeedsCache(true));
};
const handleClearImage = () => {
dispatch(clearImageToInpaint());
};
return (
<div className="inpainting-settings">
<div className="inpainting-buttons-group">
<IAIPopover
trigger="hover"
onOpen={handleShowBrushPreview}
onClose={handleHideBrushPreview}
triggerComponent={
<IAIIconButton
aria-label="Brush (B)"
tooltip="Brush (B)"
icon={<FaPaintBrush />}
onClick={handleSelectBrushTool}
data-selected={tool === 'brush'}
isDisabled={!shouldShowMask}
/>
}
>
<div className="inpainting-slider-numberinput">
<IAISlider
label="Brush Size"
value={brushSize}
onChange={handleChangeBrushSize}
min={1}
max={200}
width="100px"
focusThumbOnChange={false}
isDisabled={!shouldShowMask}
/>
<IAINumberInput
value={brushSize}
onChange={handleChangeBrushSize}
width={'80px'}
min={1}
max={999}
isDisabled={!shouldShowMask}
/>
</div>
</IAIPopover>
<IAIIconButton
aria-label="Eraser (E)"
tooltip="Eraser (E)"
icon={<FaEraser />}
onClick={handleSelectEraserTool}
data-selected={tool === 'eraser'}
isDisabled={!shouldShowMask}
/>
</div>
<div className="inpainting-buttons-group">
<IAIPopover
trigger="click"
onOpen={() => setMaskOptionsOpen(true)}
onClose={() => setMaskOptionsOpen(false)}
triggerComponent={
<IAIIconButton
aria-label="Mask Options"
tooltip="Mask Options"
icon={<FaMask />}
cursor={'pointer'}
data-selected={maskOptionsOpen}
/>
}
>
<div className="inpainting-button-dropdown">
<IAIIconButton
aria-label="Hide/Show Mask (H)"
tooltip="Hide/Show Mask (H)"
data-selected={!shouldShowMask}
icon={
shouldShowMask ? <BiShow size={22} /> : <BiHide size={22} />
}
onClick={handleToggleShouldShowMask}
/>
<IAIIconButton
tooltip="Invert Mask Display (Shift+M)"
aria-label="Invert Mask Display (Shift+M)"
data-selected={shouldInvertMask}
icon={
shouldInvertMask ? (
<MdInvertColors size={22} />
) : (
<MdInvertColorsOff size={22} />
)
}
onClick={handleToggleShouldInvertMask}
isDisabled={!shouldShowMask}
/>
<IAIPopover
trigger="hover"
placement="right"
styleClass="inpainting-color-picker"
triggerComponent={
<IAIIconButton
aria-label="Mask Color"
tooltip="Mask Color"
icon={<FaPalette />}
isDisabled={!shouldShowMask}
cursor={'pointer'}
/>
}
>
<IAIColorPicker
color={maskColor}
onChange={handleChangeMaskColor}
/>
</IAIPopover>
</div>
</IAIPopover>
<IAIIconButton
aria-label="Clear Mask (Shift+C)"
tooltip="Clear Mask (Shift+C)"
icon={<FaPlus size={18} style={{ transform: 'rotate(45deg)' }} />}
onClick={handleClearMask}
isDisabled={isMaskEmpty || !shouldShowMask}
/>
</div>
<div className="inpainting-buttons-group">
<IAIIconButton
aria-label="Undo"
tooltip="Undo"
icon={<FaUndo />}
onClick={handleUndo}
isDisabled={!canUndo || !shouldShowMask}
/>
<IAIIconButton
aria-label="Redo"
tooltip="Redo"
icon={<FaRedo />}
onClick={handleRedo}
isDisabled={!canRedo || !shouldShowMask}
/>
</div>
<ButtonGroup isAttached={true}>
<InpaintingBrushControl />
<InpaintingEraserControl />
</ButtonGroup>
<div className="inpainting-buttons-group">
<IAIIconButton
aria-label="Clear Image"
tooltip="Clear Image"
icon={<FaTrash size={16} />}
onClick={handleClearImage}
/>
</div>
<IAIIconButton
aria-label="Split Layout (Shift+J)"
tooltip="Split Layout (Shift+J)"
icon={<VscSplitHorizontal />}
data-selected={showDualDisplay}
onClick={handleDualDisplay}
/>
<ButtonGroup isAttached={true}>
<InpaintingMaskVisibilityControl />
<InpaintingMaskInvertControl />
<InpaintingLockBoundingBoxControl />
<InpaintingShowHideBoundingBoxControl />
<InpaintingMaskClear />
</ButtonGroup>
<ButtonGroup isAttached={true}>
<InpaintingUndoControl />
<InpaintingRedoControl />
</ButtonGroup>
<ButtonGroup isAttached={true}>
<ImageUploaderIconButton />
</ButtonGroup>
</div>
);
};

View File

@ -0,0 +1,150 @@
import { createSelector } from '@reduxjs/toolkit';
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { FaPaintBrush } from 'react-icons/fa';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAIIconButton from '../../../../common/components/IAIIconButton';
import IAINumberInput from '../../../../common/components/IAINumberInput';
import IAIPopover from '../../../../common/components/IAIPopover';
import IAISlider from '../../../../common/components/IAISlider';
import { activeTabNameSelector } from '../../../options/optionsSelectors';
import {
InpaintingState,
setBrushSize,
setShouldShowBrushPreview,
setTool,
} from '../inpaintingSlice';
import _ from 'lodash';
import InpaintingMaskColorPicker from './InpaintingMaskControls/InpaintingMaskColorPicker';
const inpaintingBrushSelector = createSelector(
[(state: RootState) => state.inpainting, activeTabNameSelector],
(inpainting: InpaintingState, activeTabName) => {
const { tool, brushSize, shouldShowMask } = inpainting;
return {
tool,
brushSize,
shouldShowMask,
activeTabName,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function InpaintingBrushControl() {
const dispatch = useAppDispatch();
const { tool, brushSize, shouldShowMask, activeTabName } = useAppSelector(
inpaintingBrushSelector
);
const handleSelectBrushTool = () => dispatch(setTool('brush'));
const handleShowBrushPreview = () => {
dispatch(setShouldShowBrushPreview(true));
};
const handleHideBrushPreview = () => {
dispatch(setShouldShowBrushPreview(false));
};
const handleChangeBrushSize = (v: number) => {
dispatch(setShouldShowBrushPreview(true));
dispatch(setBrushSize(v));
};
// Hotkeys
// Decrease brush size
useHotkeys(
'[',
(e: KeyboardEvent) => {
e.preventDefault();
if (brushSize - 5 > 0) {
handleChangeBrushSize(brushSize - 5);
} else {
handleChangeBrushSize(1);
}
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask, brushSize]
);
// Increase brush size
useHotkeys(
']',
(e: KeyboardEvent) => {
e.preventDefault();
handleChangeBrushSize(brushSize + 5);
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask, brushSize]
);
// Set tool to brush
useHotkeys(
'b',
(e: KeyboardEvent) => {
e.preventDefault();
handleSelectBrushTool();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask]
);
return (
<IAIPopover
trigger="hover"
onOpen={handleShowBrushPreview}
onClose={handleHideBrushPreview}
triggerComponent={
<IAIIconButton
aria-label="Brush (B)"
tooltip="Brush (B)"
icon={<FaPaintBrush />}
onClick={handleSelectBrushTool}
data-selected={tool === 'brush'}
isDisabled={!shouldShowMask}
/>
}
>
<div className="inpainting-brush-options">
<IAISlider
label="Brush Size"
value={brushSize}
onChange={handleChangeBrushSize}
min={1}
max={200}
width="100px"
focusThumbOnChange={false}
isDisabled={!shouldShowMask}
/>
<IAINumberInput
value={brushSize}
onChange={handleChangeBrushSize}
width={'80px'}
min={1}
max={999}
isDisabled={!shouldShowMask}
/>
<InpaintingMaskColorPicker />
</div>
</IAIPopover>
);
}

View File

@ -0,0 +1,22 @@
import React from 'react';
import { FaTrash } from 'react-icons/fa';
import { useAppDispatch } from '../../../../app/store';
import IAIIconButton from '../../../../common/components/IAIIconButton';
import { clearImageToInpaint } from '../inpaintingSlice';
export default function InpaintingClearImageControl() {
const dispatch = useAppDispatch();
const handleClearImage = () => {
dispatch(clearImageToInpaint());
};
return (
<IAIIconButton
aria-label="Clear Image"
tooltip="Clear Image"
icon={<FaTrash size={16} />}
onClick={handleClearImage}
/>
);
}

View File

@ -0,0 +1,67 @@
import { createSelector } from '@reduxjs/toolkit';
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { FaEraser } from 'react-icons/fa';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAIIconButton from '../../../../common/components/IAIIconButton';
import { InpaintingState, setTool } from '../inpaintingSlice';
import _ from 'lodash';
import { activeTabNameSelector } from '../../../options/optionsSelectors';
const inpaintingEraserSelector = createSelector(
[(state: RootState) => state.inpainting, activeTabNameSelector],
(inpainting: InpaintingState, activeTabName) => {
const { tool, shouldShowMask } = inpainting;
return {
tool,
shouldShowMask,
activeTabName,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function InpaintingEraserControl() {
const { tool, shouldShowMask, activeTabName } = useAppSelector(
inpaintingEraserSelector
);
const dispatch = useAppDispatch();
const handleSelectEraserTool = () => dispatch(setTool('eraser'));
// Hotkeys
// Set tool to eraser
useHotkeys(
'e',
(e: KeyboardEvent) => {
e.preventDefault();
if (activeTabName !== 'inpainting' || !shouldShowMask) return;
handleSelectEraserTool();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask]
);
return (
<IAIIconButton
aria-label="Eraser (E)"
tooltip="Eraser (E)"
icon={<FaEraser />}
onClick={handleSelectEraserTool}
data-selected={tool === 'eraser'}
isDisabled={!shouldShowMask}
/>
);
}

View File

@ -0,0 +1,29 @@
import { FaLock, FaUnlock } from 'react-icons/fa';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAIIconButton from '../../../../common/components/IAIIconButton';
import { setShouldLockBoundingBox } from '../inpaintingSlice';
const InpaintingLockBoundingBoxControl = () => {
const dispatch = useAppDispatch();
const shouldLockBoundingBox = useAppSelector(
(state: RootState) => state.inpainting.shouldLockBoundingBox
);
return (
<IAIIconButton
aria-label="Lock Inpainting Box"
tooltip="Lock Inpainting Box"
icon={shouldLockBoundingBox ? <FaLock /> : <FaUnlock />}
data-selected={shouldLockBoundingBox}
onClick={() => {
dispatch(setShouldLockBoundingBox(!shouldLockBoundingBox));
}}
/>
);
};
export default InpaintingLockBoundingBoxControl;

View File

@ -0,0 +1,38 @@
import React, { useState } from 'react';
import { FaMask } from 'react-icons/fa';
import IAIIconButton from '../../../../common/components/IAIIconButton';
import IAIPopover from '../../../../common/components/IAIPopover';
import InpaintingMaskVisibilityControl from './InpaintingMaskControls/InpaintingMaskVisibilityControl';
import InpaintingMaskInvertControl from './InpaintingMaskControls/InpaintingMaskInvertControl';
import InpaintingMaskColorPicker from './InpaintingMaskControls/InpaintingMaskColorPicker';
export default function InpaintingMaskControl() {
const [maskOptionsOpen, setMaskOptionsOpen] = useState<boolean>(false);
return (
<>
<IAIPopover
trigger="hover"
onOpen={() => setMaskOptionsOpen(true)}
onClose={() => setMaskOptionsOpen(false)}
triggerComponent={
<IAIIconButton
aria-label="Mask Options"
tooltip="Mask Options"
icon={<FaMask />}
cursor={'pointer'}
data-selected={maskOptionsOpen}
/>
}
>
<div className="inpainting-button-dropdown">
<InpaintingMaskVisibilityControl />
<InpaintingMaskInvertControl />
<InpaintingMaskColorPicker />
</div>
</IAIPopover>
</>
);
}

View File

@ -0,0 +1,70 @@
import { createSelector } from '@reduxjs/toolkit';
import React from 'react';
import { FaPlus } from 'react-icons/fa';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../../app/store';
import IAIIconButton from '../../../../../common/components/IAIIconButton';
import { activeTabNameSelector } from '../../../../options/optionsSelectors';
import { clearMask, InpaintingState } from '../../inpaintingSlice';
import _ from 'lodash';
import { useHotkeys } from 'react-hotkeys-hook';
import { useToast } from '@chakra-ui/react';
const inpaintingMaskClearSelector = createSelector(
[(state: RootState) => state.inpainting, activeTabNameSelector],
(inpainting: InpaintingState, activeTabName) => {
const { shouldShowMask, lines } = inpainting;
return { shouldShowMask, activeTabName, isMaskEmpty: lines.length === 0 };
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function InpaintingMaskClear() {
const { shouldShowMask, activeTabName, isMaskEmpty } = useAppSelector(
inpaintingMaskClearSelector
);
const dispatch = useAppDispatch();
const toast = useToast();
const handleClearMask = () => {
dispatch(clearMask());
};
// Clear mask
useHotkeys(
'shift+c',
(e: KeyboardEvent) => {
e.preventDefault();
handleClearMask();
toast({
title: 'Mask Cleared',
status: 'success',
duration: 2500,
isClosable: true,
});
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask && !isMaskEmpty,
},
[activeTabName, isMaskEmpty, shouldShowMask]
);
return (
<IAIIconButton
aria-label="Clear Mask (Shift+C)"
tooltip="Clear Mask (Shift+C)"
icon={<FaPlus size={20} style={{ transform: 'rotate(45deg)' }} />}
onClick={handleClearMask}
isDisabled={isMaskEmpty || !shouldShowMask}
/>
);
}

View File

@ -0,0 +1,91 @@
import React from 'react';
import { RgbaColor } from 'react-colorful';
import { FaPalette } from 'react-icons/fa';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../../app/store';
import IAIColorPicker from '../../../../../common/components/IAIColorPicker';
import IAIIconButton from '../../../../../common/components/IAIIconButton';
import IAIPopover from '../../../../../common/components/IAIPopover';
import { InpaintingState, setMaskColor } from '../../inpaintingSlice';
import _ from 'lodash';
import { createSelector } from '@reduxjs/toolkit';
import { activeTabNameSelector } from '../../../../options/optionsSelectors';
import { useHotkeys } from 'react-hotkeys-hook';
const inpaintingMaskColorPickerSelector = createSelector(
[(state: RootState) => state.inpainting, activeTabNameSelector],
(inpainting: InpaintingState, activeTabName) => {
const { shouldShowMask, maskColor } = inpainting;
return { shouldShowMask, maskColor, activeTabName };
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function InpaintingMaskColorPicker() {
const { shouldShowMask, maskColor, activeTabName } = useAppSelector(
inpaintingMaskColorPickerSelector
);
const dispatch = useAppDispatch();
const handleChangeMaskColor = (newColor: RgbaColor) => {
dispatch(setMaskColor(newColor));
};
// Hotkeys
// Decrease mask opacity
useHotkeys(
'shift+[',
(e: KeyboardEvent) => {
e.preventDefault();
handleChangeMaskColor({
...maskColor,
a: Math.max(maskColor.a - 0.05, 0),
});
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask, maskColor.a]
);
// Increase mask opacity
useHotkeys(
'shift+]',
(e: KeyboardEvent) => {
e.preventDefault();
handleChangeMaskColor({
...maskColor,
a: Math.min(maskColor.a + 0.05, 100),
});
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask, maskColor.a]
);
return (
<IAIPopover
trigger="hover"
styleClass="inpainting-color-picker"
triggerComponent={
<IAIIconButton
aria-label="Mask Color"
icon={<FaPalette />}
isDisabled={!shouldShowMask}
cursor={'pointer'}
/>
}
>
<IAIColorPicker color={maskColor} onChange={handleChangeMaskColor} />
</IAIPopover>
);
}

View File

@ -0,0 +1,68 @@
import { createSelector } from '@reduxjs/toolkit';
import React from 'react';
import { MdInvertColors, MdInvertColorsOff } from 'react-icons/md';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../../app/store';
import IAIIconButton from '../../../../../common/components/IAIIconButton';
import { InpaintingState, setShouldInvertMask } from '../../inpaintingSlice';
import _ from 'lodash';
import { activeTabNameSelector } from '../../../../options/optionsSelectors';
import { useHotkeys } from 'react-hotkeys-hook';
const inpaintingMaskInvertSelector = createSelector(
[(state: RootState) => state.inpainting, activeTabNameSelector],
(inpainting: InpaintingState, activeTabName) => {
const { shouldShowMask, shouldInvertMask } = inpainting;
return { shouldInvertMask, shouldShowMask, activeTabName };
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function InpaintingMaskInvertControl() {
const { shouldInvertMask, shouldShowMask, activeTabName } = useAppSelector(
inpaintingMaskInvertSelector
);
const dispatch = useAppDispatch();
const handleToggleShouldInvertMask = () =>
dispatch(setShouldInvertMask(!shouldInvertMask));
// Invert mask
useHotkeys(
'shift+m',
(e: KeyboardEvent) => {
e.preventDefault();
handleToggleShouldInvertMask();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldInvertMask, shouldShowMask]
);
return (
<IAIIconButton
tooltip="Invert Mask Display (Shift+M)"
aria-label="Invert Mask Display (Shift+M)"
data-selected={shouldInvertMask}
icon={
shouldInvertMask ? (
<MdInvertColors size={22} />
) : (
<MdInvertColorsOff size={22} />
)
}
onClick={handleToggleShouldInvertMask}
isDisabled={!shouldShowMask}
/>
);
}

View File

@ -0,0 +1,61 @@
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { BiHide, BiShow } from 'react-icons/bi';
import { createSelector } from 'reselect';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../../app/store';
import IAIIconButton from '../../../../../common/components/IAIIconButton';
import { activeTabNameSelector } from '../../../../options/optionsSelectors';
import { InpaintingState, setShouldShowMask } from '../../inpaintingSlice';
import _ from 'lodash';
const inpaintingMaskVisibilitySelector = createSelector(
[(state: RootState) => state.inpainting, activeTabNameSelector],
(inpainting: InpaintingState, activeTabName) => {
const { shouldShowMask } = inpainting;
return { shouldShowMask, activeTabName };
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function InpaintingMaskVisibilityControl() {
const dispatch = useAppDispatch();
const { shouldShowMask, activeTabName } = useAppSelector(
inpaintingMaskVisibilitySelector
);
const handleToggleShouldShowMask = () =>
dispatch(setShouldShowMask(!shouldShowMask));
// Hotkeys
// Show/hide mask
useHotkeys(
'h',
(e: KeyboardEvent) => {
e.preventDefault();
handleToggleShouldShowMask();
},
{
enabled: activeTabName === 'inpainting',
},
[activeTabName, shouldShowMask]
);
return (
<IAIIconButton
aria-label="Hide Mask (H)"
tooltip="Hide Mask (H)"
data-alert={!shouldShowMask}
icon={shouldShowMask ? <BiShow size={22} /> : <BiHide size={22} />}
onClick={handleToggleShouldShowMask}
/>
);
}

View File

@ -0,0 +1,66 @@
import { createSelector } from '@reduxjs/toolkit';
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { FaRedo } from 'react-icons/fa';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAIIconButton from '../../../../common/components/IAIIconButton';
import { activeTabNameSelector } from '../../../options/optionsSelectors';
import { InpaintingState, redo } from '../inpaintingSlice';
import _ from 'lodash';
const inpaintingRedoSelector = createSelector(
[(state: RootState) => state.inpainting, activeTabNameSelector],
(inpainting: InpaintingState, activeTabName) => {
const { futureLines, shouldShowMask } = inpainting;
return {
canRedo: futureLines.length > 0,
shouldShowMask,
activeTabName,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function InpaintingRedoControl() {
const dispatch = useAppDispatch();
const { canRedo, shouldShowMask, activeTabName } = useAppSelector(
inpaintingRedoSelector
);
const handleRedo = () => dispatch(redo());
// Hotkeys
// Redo
useHotkeys(
'cmd+shift+z, control+shift+z, control+y, cmd+y',
(e: KeyboardEvent) => {
e.preventDefault();
handleRedo();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask && canRedo,
},
[activeTabName, shouldShowMask, canRedo]
);
return (
<IAIIconButton
aria-label="Redo"
tooltip="Redo"
icon={<FaRedo />}
onClick={handleRedo}
isDisabled={!canRedo || !shouldShowMask}
/>
);
}

View File

@ -0,0 +1,29 @@
import { FaVectorSquare } from 'react-icons/fa';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAIIconButton from '../../../../common/components/IAIIconButton';
import { setShouldShowBoundingBox } from '../inpaintingSlice';
const InpaintingShowHideBoundingBoxControl = () => {
const dispatch = useAppDispatch();
const shouldShowBoundingBox = useAppSelector(
(state: RootState) => state.inpainting.shouldShowBoundingBox
);
return (
<IAIIconButton
aria-label="Hide Inpainting Box"
tooltip="Hide Inpainting Box"
icon={<FaVectorSquare />}
data-alert={!shouldShowBoundingBox}
onClick={() => {
dispatch(setShouldShowBoundingBox(!shouldShowBoundingBox));
}}
/>
);
};
export default InpaintingShowHideBoundingBoxControl;

View File

@ -0,0 +1,43 @@
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { VscSplitHorizontal } from 'react-icons/vsc';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAIIconButton from '../../../../common/components/IAIIconButton';
import { setShowDualDisplay } from '../../../options/optionsSlice';
import { setNeedsCache } from '../inpaintingSlice';
export default function InpaintingSplitLayoutControl() {
const dispatch = useAppDispatch();
const showDualDisplay = useAppSelector(
(state: RootState) => state.options.showDualDisplay
);
const handleDualDisplay = () => {
dispatch(setShowDualDisplay(!showDualDisplay));
dispatch(setNeedsCache(true));
};
// Hotkeys
// Toggle split view
useHotkeys(
'shift+j',
() => {
handleDualDisplay();
},
[showDualDisplay]
);
return (
<IAIIconButton
aria-label="Split Layout (Shift+J)"
tooltip="Split Layout (Shift+J)"
icon={<VscSplitHorizontal />}
data-selected={showDualDisplay}
onClick={handleDualDisplay}
/>
);
}

View File

@ -0,0 +1,66 @@
import { createSelector } from '@reduxjs/toolkit';
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { FaUndo } from 'react-icons/fa';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import IAIIconButton from '../../../../common/components/IAIIconButton';
import { InpaintingState, undo } from '../inpaintingSlice';
import _ from 'lodash';
import { activeTabNameSelector } from '../../../options/optionsSelectors';
const inpaintingUndoSelector = createSelector(
[(state: RootState) => state.inpainting, activeTabNameSelector],
(inpainting: InpaintingState, activeTabName) => {
const { pastLines, shouldShowMask } = inpainting;
return {
canUndo: pastLines.length > 0,
shouldShowMask,
activeTabName,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function InpaintingUndoControl() {
const dispatch = useAppDispatch();
const { canUndo, shouldShowMask, activeTabName } = useAppSelector(
inpaintingUndoSelector
);
const handleUndo = () => dispatch(undo());
// Hotkeys
// Undo
useHotkeys(
'cmd+z, control+z',
(e: KeyboardEvent) => {
e.preventDefault();
handleUndo();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask && canUndo,
},
[activeTabName, shouldShowMask, canUndo]
);
return (
<IAIIconButton
aria-label="Undo"
tooltip="Undo"
icon={<FaUndo />}
onClick={handleUndo}
isDisabled={!canUndo || !shouldShowMask}
/>
);
}

View File

@ -1,19 +1,17 @@
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { useEffect, useRef } from 'react';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import { activeTabNameSelector } from '../../../options/optionsSelectors';
import { OptionsState } from '../../../options/optionsSlice';
import { useHotkeys } from 'react-hotkeys-hook';
import { RootState, useAppDispatch, useAppSelector } from '../../../app/store';
import { activeTabNameSelector } from '../../options/optionsSelectors';
import { OptionsState } from '../../options/optionsSlice';
import {
InpaintingState,
setIsDrawing,
setIsSpacebarHeld,
setShouldLockBoundingBox,
toggleShouldLockBoundingBox,
toggleTool,
} from '../inpaintingSlice';
} from './inpaintingSlice';
const keyboardEventManagerSelector = createSelector(
[
@ -22,13 +20,18 @@ const keyboardEventManagerSelector = createSelector(
activeTabNameSelector,
],
(options: OptionsState, inpainting: InpaintingState, activeTabName) => {
const { shouldShowMask, cursorPosition, shouldLockBoundingBox } =
inpainting;
const {
shouldShowMask,
cursorPosition,
shouldLockBoundingBox,
shouldShowBoundingBox,
} = inpainting;
return {
activeTabName,
shouldShowMask,
isCursorOnCanvas: Boolean(cursorPosition),
shouldLockBoundingBox,
shouldShowBoundingBox,
};
},
{
@ -45,15 +48,30 @@ const KeyboardEventManager = () => {
activeTabName,
isCursorOnCanvas,
shouldLockBoundingBox,
shouldShowBoundingBox,
} = useAppSelector(keyboardEventManagerSelector);
const wasLastEventOverCanvas = useRef<boolean>(false);
const lastEvent = useRef<KeyboardEvent | null>(null);
// Toggle lock bounding box
useHotkeys(
'shift+q',
(e: KeyboardEvent) => {
e.preventDefault();
dispatch(toggleShouldLockBoundingBox());
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
},
[activeTabName, shouldShowMask]
);
// Manages hold-style keyboard shortcuts
useEffect(() => {
const listener = (e: KeyboardEvent) => {
if (
!['x', ' '].includes(e.key) ||
!['x', 'q'].includes(e.key) ||
activeTabName !== 'inpainting' ||
!shouldShowMask
) {
@ -91,13 +109,10 @@ const KeyboardEventManager = () => {
dispatch(toggleTool());
break;
}
case ' ': {
if (!shouldShowMask) break;
if (e.type === 'keydown') {
dispatch(setIsDrawing(false));
}
dispatch(setShouldLockBoundingBox(!shouldLockBoundingBox));
case 'q': {
if (!shouldShowMask || !shouldShowBoundingBox) break;
dispatch(setIsSpacebarHeld(e.type === 'keydown'));
dispatch(setShouldLockBoundingBox(e.type !== 'keydown'));
break;
}
}
@ -119,6 +134,7 @@ const KeyboardEventManager = () => {
shouldShowMask,
isCursorOnCanvas,
shouldLockBoundingBox,
shouldShowBoundingBox,
]);
return null;

View File

@ -33,6 +33,9 @@ const Cacher = () => {
futureLines,
needsCache,
isDrawing,
isTransformingBoundingBox,
isMovingBoundingBox,
shouldShowBoundingBox,
} = useAppSelector((state: RootState) => state.inpainting);
useLayoutEffect(() => {
@ -58,12 +61,15 @@ const Cacher = () => {
imageToInpaint,
shouldShowBrush,
shouldShowBoundingBoxFill,
shouldShowBoundingBox,
shouldLockBoundingBox,
stageScale,
pastLines,
futureLines,
needsCache,
isDrawing,
isTransformingBoundingBox,
isMovingBoundingBox,
]);
/**

View File

@ -1,5 +1,6 @@
import { createSelector } from '@reduxjs/toolkit';
import Konva from 'konva';
import { Context } from 'konva/lib/Context';
import { KonvaEventObject } from 'konva/lib/Node';
import { Box } from 'konva/lib/shapes/Transformer';
import { Vector2d } from 'konva/lib/types';
@ -12,11 +13,13 @@ import {
useAppSelector,
} from '../../../../app/store';
import { roundToMultiple } from '../../../../common/util/roundDownToMultiple';
import { stageRef } from '../InpaintingCanvas';
import {
InpaintingState,
setBoundingBoxCoordinate,
setBoundingBoxDimensions,
setIsMouseOverBoundingBox,
setIsMovingBoundingBox,
setIsTransformingBoundingBox,
} from '../inpaintingSlice';
import { rgbaColorToString } from '../util/colorToString';
import {
@ -35,6 +38,11 @@ const boundingBoxPreviewSelector = createSelector(
stageScale,
imageToInpaint,
shouldLockBoundingBox,
isDrawing,
isTransformingBoundingBox,
isMovingBoundingBox,
isMouseOverBoundingBox,
isSpacebarHeld,
} = inpainting;
return {
boundingBoxCoordinate,
@ -46,6 +54,11 @@ const boundingBoxPreviewSelector = createSelector(
dash: DASH_WIDTH / stageScale, // scale dash lengths
strokeWidth: 1 / stageScale, // scale stroke thickness
shouldLockBoundingBox,
isDrawing,
isTransformingBoundingBox,
isMouseOverBoundingBox,
isMovingBoundingBox,
isSpacebarHeld,
};
},
{
@ -93,10 +106,14 @@ const InpaintingBoundingBoxPreview = () => {
const {
boundingBoxCoordinate,
boundingBoxDimensions,
strokeWidth,
stageScale,
imageToInpaint,
shouldLockBoundingBox,
isDrawing,
isTransformingBoundingBox,
isMovingBoundingBox,
isMouseOverBoundingBox,
isSpacebarHeld,
} = useAppSelector(boundingBoxPreviewSelector);
const transformerRef = useRef<Konva.Transformer>(null);
@ -108,15 +125,6 @@ const InpaintingBoundingBoxPreview = () => {
transformerRef.current.getLayer()?.batchDraw();
}, [shouldLockBoundingBox]);
useEffect(
() => () => {
const container = stageRef.current?.container();
if (!container) return;
container.style.cursor = 'unset';
},
[shouldLockBoundingBox]
);
const scaledStep = 64 * stageScale;
const handleOnDragMove = useCallback(
@ -269,6 +277,35 @@ const InpaintingBoundingBoxPreview = () => {
[imageToInpaint, stageScale]
);
const handleStartedTransforming = (e: KonvaEventObject<MouseEvent>) => {
e.cancelBubble = true;
e.evt.stopImmediatePropagation();
console.log("Started transform")
dispatch(setIsTransformingBoundingBox(true));
};
const handleEndedTransforming = (e: KonvaEventObject<MouseEvent>) => {
dispatch(setIsTransformingBoundingBox(false));
dispatch(setIsMouseOverBoundingBox(false));
};
const handleStartedMoving = (e: KonvaEventObject<MouseEvent>) => {
e.cancelBubble = true;
e.evt.stopImmediatePropagation();
dispatch(setIsMovingBoundingBox(true));
};
const handleEndedModifying = (e: KonvaEventObject<MouseEvent>) => {
dispatch(setIsTransformingBoundingBox(false));
dispatch(setIsMovingBoundingBox(false));
dispatch(setIsMouseOverBoundingBox(false));
};
const spacebarHeldHitFunc = (context: Context, shape: Konva.Shape) => {
context.rect(0, 0, imageToInpaint?.width, imageToInpaint?.height);
context.fillShape(shape);
};
return (
<>
<Rect
@ -277,23 +314,28 @@ const InpaintingBoundingBoxPreview = () => {
width={boundingBoxDimensions.width}
height={boundingBoxDimensions.height}
ref={shapeRef}
stroke={'white'}
strokeWidth={strokeWidth}
listening={!shouldLockBoundingBox}
onMouseEnter={(e) => {
const container = e?.target?.getStage()?.container();
if (!container) return;
container.style.cursor = shouldLockBoundingBox ? 'none' : 'move';
stroke={isMouseOverBoundingBox ? 'rgba(255,255,255,0.3)' : 'white'}
strokeWidth={Math.floor((isMouseOverBoundingBox ? 8 : 1) / stageScale)}
fillEnabled={isSpacebarHeld}
hitFunc={isSpacebarHeld ? spacebarHeldHitFunc : undefined}
hitStrokeWidth={Math.floor(13 / stageScale)}
listening={!isDrawing && !shouldLockBoundingBox}
onMouseOver={() => {
dispatch(setIsMouseOverBoundingBox(true));
}}
onMouseLeave={(e) => {
const container = e?.target?.getStage()?.container();
if (!container) return;
container.style.cursor = shouldLockBoundingBox ? 'none' : 'default';
onMouseOut={() => {
!isTransformingBoundingBox &&
!isMovingBoundingBox &&
dispatch(setIsMouseOverBoundingBox(false));
}}
draggable={!shouldLockBoundingBox}
onMouseDown={handleStartedMoving}
onMouseUp={handleEndedModifying}
draggable={true}
onDragMove={handleOnDragMove}
dragBoundFunc={dragBoundFunc}
onTransform={handleOnTransform}
onDragEnd={handleEndedModifying}
onTransformEnd={handleEndedTransforming}
/>
<Transformer
ref={transformerRef}
@ -308,10 +350,22 @@ const InpaintingBoundingBoxPreview = () => {
flipEnabled={false}
ignoreStroke={true}
keepRatio={false}
listening={!shouldLockBoundingBox}
listening={!isDrawing && !shouldLockBoundingBox}
onMouseDown={handleStartedTransforming}
onMouseUp={handleEndedTransforming}
enabledAnchors={shouldLockBoundingBox ? [] : undefined}
boundBoxFunc={boundBoxFunc}
anchorDragBoundFunc={anchorDragBoundFunc}
onDragEnd={handleEndedModifying}
onTransformEnd={handleEndedTransforming}
onMouseOver={() => {
dispatch(setIsMouseOverBoundingBox(true));
}}
onMouseOut={() => {
!isTransformingBoundingBox &&
!isMovingBoundingBox &&
dispatch(setIsMouseOverBoundingBox(false));
}}
/>
</>
);

View File

@ -11,22 +11,28 @@ const inpaintingCanvasBrushPreviewSelector = createSelector(
const {
cursorPosition,
canvasDimensions: { width, height },
shouldShowBrushPreview,
brushSize,
maskColor,
tool,
shouldShowBrush,
isMovingBoundingBox,
isTransformingBoundingBox,
} = inpainting;
return {
cursorPosition,
width,
height,
shouldShowBrushPreview,
brushSize,
maskColorString: rgbaColorToRgbString(maskColor),
tool,
shouldShowBrush,
shouldDrawBrushPreview:
!(
isMovingBoundingBox ||
isTransformingBoundingBox ||
!cursorPosition
) && shouldShowBrush,
};
},
{
@ -44,16 +50,13 @@ const InpaintingCanvasBrushPreview = () => {
cursorPosition,
width,
height,
shouldShowBrushPreview,
brushSize,
maskColorString,
tool,
shouldShowBrush,
shouldDrawBrushPreview,
} = useAppSelector(inpaintingCanvasBrushPreviewSelector);
if (!shouldShowBrush || !(cursorPosition || shouldShowBrushPreview)) {
return null;
}
if (!shouldDrawBrushPreview) return null;
return (
<Circle

View File

@ -4,26 +4,34 @@ import { Circle } from 'react-konva';
import { RootState, useAppSelector } from '../../../../app/store';
import { InpaintingState } from '../inpaintingSlice';
const inpaintingCanvasBrushPreviewSelector = createSelector(
const inpaintingCanvasBrushPrevieOutlineSelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => {
const {
cursorPosition,
canvasDimensions: { width, height },
shouldShowBrushPreview,
brushSize,
stageScale,
tool,
shouldShowBrush,
isMovingBoundingBox,
isTransformingBoundingBox,
stageScale,
} = inpainting;
return {
cursorPosition,
width,
height,
shouldShowBrushPreview,
brushSize,
tool,
strokeWidth: 1 / stageScale, // scale stroke thickness
shouldShowBrush,
radius: 1 / stageScale, // scale stroke thickness
shouldDrawBrushPreview:
!(
isMovingBoundingBox ||
isTransformingBoundingBox ||
!cursorPosition
) && shouldShowBrush,
};
},
{
@ -41,15 +49,13 @@ const InpaintingCanvasBrushPreviewOutline = () => {
cursorPosition,
width,
height,
shouldShowBrushPreview,
brushSize,
shouldDrawBrushPreview,
strokeWidth,
shouldShowBrush,
} = useAppSelector(inpaintingCanvasBrushPreviewSelector);
if (!shouldShowBrush || !(cursorPosition || shouldShowBrushPreview))
return null;
radius,
} = useAppSelector(inpaintingCanvasBrushPrevieOutlineSelector);
if (!shouldDrawBrushPreview) return null;
return (
<>
<Circle
@ -64,7 +70,7 @@ const InpaintingCanvasBrushPreviewOutline = () => {
<Circle
x={cursorPosition ? cursorPosition.x : width / 2}
y={cursorPosition ? cursorPosition.y : height / 2}
radius={1}
radius={radius}
fill={'rgba(0,0,0,1)'}
listening={false}
/>

View File

@ -51,9 +51,13 @@ export interface InpaintingState {
needsCache: boolean;
stageScale: number;
isDrawing: boolean;
isTransformingBoundingBox: boolean;
isMouseOverBoundingBox: boolean;
isMovingBoundingBox: boolean;
shouldUseInpaintReplace: boolean;
inpaintReplace: number;
shouldLockBoundingBox: boolean;
isSpacebarHeld: boolean;
}
const initialInpaintingState: InpaintingState = {
@ -63,7 +67,7 @@ const initialInpaintingState: InpaintingState = {
canvasDimensions: { width: 0, height: 0 },
boundingBoxDimensions: { width: 512, height: 512 },
boundingBoxCoordinate: { x: 0, y: 0 },
boundingBoxPreviewFill: { r: 0, g: 0, b: 0, a: 0.7 },
boundingBoxPreviewFill: { r: 0, g: 0, b: 0, a: 0.5 },
shouldShowBoundingBox: true,
shouldShowBoundingBoxFill: true,
cursorPosition: null,
@ -77,10 +81,14 @@ const initialInpaintingState: InpaintingState = {
shouldShowBrushPreview: false,
needsCache: false,
isDrawing: false,
isTransformingBoundingBox: false,
isMouseOverBoundingBox: false,
isMovingBoundingBox: false,
stageScale: 1,
shouldUseInpaintReplace: false,
inpaintReplace: 1,
inpaintReplace: 0.1,
shouldLockBoundingBox: true,
isSpacebarHeld: false,
};
const initialState: InpaintingState = initialInpaintingState;
@ -319,6 +327,18 @@ export const inpaintingSlice = createSlice({
setShouldShowBoundingBox: (state, action: PayloadAction<boolean>) => {
state.shouldShowBoundingBox = action.payload;
},
setIsTransformingBoundingBox: (state, action: PayloadAction<boolean>) => {
state.isTransformingBoundingBox = action.payload;
},
setIsMovingBoundingBox: (state, action: PayloadAction<boolean>) => {
state.isMovingBoundingBox = action.payload;
},
setIsMouseOverBoundingBox: (state, action: PayloadAction<boolean>) => {
state.isMouseOverBoundingBox = action.payload;
},
setIsSpacebarHeld: (state, action: PayloadAction<boolean>) => {
state.isSpacebarHeld = action.payload;
},
},
});
@ -354,6 +374,10 @@ export const {
setInpaintReplace,
setShouldLockBoundingBox,
toggleShouldLockBoundingBox,
setIsMovingBoundingBox,
setIsTransformingBoundingBox,
setIsMouseOverBoundingBox,
setIsSpacebarHeld,
} = inpaintingSlice.actions;
export default inpaintingSlice.reducer;

View File

@ -78,7 +78,23 @@ export const inpaintingCanvasSelector = createSelector(
isDrawing,
shouldLockBoundingBox,
boundingBoxDimensions,
isTransformingBoundingBox,
isMouseOverBoundingBox,
isMovingBoundingBox,
} = inpainting;
let stageCursor: string | undefined = '';
if (isTransformingBoundingBox) {
stageCursor = undefined;
} else if (isMovingBoundingBox || isMouseOverBoundingBox) {
stageCursor = 'move';
} else if (shouldShowMask) {
stageCursor = 'none';
} else {
stageCursor = 'default';
}
return {
tool,
brushSize,
@ -93,6 +109,10 @@ export const inpaintingCanvasSelector = createSelector(
isDrawing,
shouldLockBoundingBox,
boundingBoxDimensions,
isTransformingBoundingBox,
isModifyingBoundingBox: isTransformingBoundingBox || isMovingBoundingBox,
stageCursor,
isMouseOverBoundingBox,
};
},
{

View File

@ -101,7 +101,7 @@ const generateMask = (
new Konva.Image({ image: image, globalCompositeOperation: 'source-out' })
);
const maskDataURL = stage.toDataURL();
const maskDataURL = stage.toDataURL({ ...boundingBox });
return { maskDataURL, isMaskEmpty };
};

View File

@ -67,6 +67,15 @@ const InvokeOptionsPanel = (props: Props) => {
[shouldShowOptionsPanel]
);
useHotkeys(
'esc',
() => {
if (shouldPinOptionsPanel) return;
dispatch(setShouldShowOptionsPanel(false));
},
[shouldPinOptionsPanel]
);
useHotkeys(
'shift+o',
() => {
@ -74,7 +83,6 @@ const InvokeOptionsPanel = (props: Props) => {
},
[shouldPinOptionsPanel]
);
//
const handleCloseOptionsPanel = useCallback(() => {
if (shouldPinOptionsPanel) return;
@ -112,12 +120,6 @@ const InvokeOptionsPanel = (props: Props) => {
dispatch(setNeedsCache(true));
};
// // set gallery scroll position
// useEffect(() => {
// if (!optionsPanelContainerRef.current) return;
// optionsPanelContainerRef.current.scrollTop = optionsPanelScrollPosition;
// }, [optionsPanelScrollPosition, shouldShowOptionsPanel]);
return (
<CSSTransition
nodeRef={optionsPanelRef}
@ -170,7 +172,6 @@ const InvokeOptionsPanel = (props: Props) => {
</h1>
</div>
)}
{children}
</div>
</div>

View File

@ -15,9 +15,10 @@ import TextToImageIcon from '../../common/icons/TextToImageIcon';
import { setActiveTab } from '../options/optionsSlice';
import ImageToImageWorkarea from './ImageToImage';
import InpaintingWorkarea from './Inpainting';
import { setNeedsCache } from './Inpainting/inpaintingSlice';
import TextToImageWorkarea from './TextToImage';
export const tab_dict = {
export const tabDict = {
txt2img: {
title: <TextToImageIcon fill={'black'} boxSize={'2.5rem'} />,
workarea: <TextToImageWorkarea />,
@ -50,8 +51,8 @@ export const tab_dict = {
},
};
// Array where index maps to the key of tab_dict
export const tabMap = _.map(tab_dict, (tab, key) => key);
// Array where index maps to the key of tabDict
export const tabMap = _.map(tabDict, (tab, key) => key);
// Use tabMap to generate a union type of tab names
const tabMapTypes = [...tabMap] as const;
@ -73,6 +74,7 @@ export default function InvokeTabs() {
useHotkeys('3', () => {
dispatch(setActiveTab(2));
dispatch(setNeedsCache(true));
});
useHotkeys('4', () => {
@ -89,15 +91,15 @@ export default function InvokeTabs() {
const renderTabs = () => {
const tabsToRender: ReactElement[] = [];
Object.keys(tab_dict).forEach((key) => {
Object.keys(tabDict).forEach((key) => {
tabsToRender.push(
<Tooltip
key={key}
hasArrow
label={tab_dict[key as keyof typeof tab_dict].tooltip}
label={tabDict[key as keyof typeof tabDict].tooltip}
placement={'right'}
>
<Tab>{tab_dict[key as keyof typeof tab_dict].title}</Tab>
<Tab>{tabDict[key as keyof typeof tabDict].title}</Tab>
</Tooltip>
);
});
@ -106,10 +108,10 @@ export default function InvokeTabs() {
const renderTabPanels = () => {
const tabPanelsToRender: ReactElement[] = [];
Object.keys(tab_dict).forEach((key) => {
Object.keys(tabDict).forEach((key) => {
tabPanelsToRender.push(
<TabPanel className="app-tabs-panel" key={key}>
{tab_dict[key as keyof typeof tab_dict].workarea}
{tabDict[key as keyof typeof tabDict].workarea}
</TabPanel>
);
});
@ -125,6 +127,7 @@ export default function InvokeTabs() {
index={activeTab}
onChange={(index: number) => {
dispatch(setActiveTab(index));
dispatch(setNeedsCache(true));
}}
>
<div className="app-tabs-list">{renderTabs()}</div>

View File

@ -7,21 +7,26 @@
.workarea-main {
display: flex;
column-gap: 0.5rem;
column-gap: 1rem;
height: 100%;
.workarea-children-wrapper {
position: relative;
width: 100%;
height: 100%;
}
.workarea-split-view {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
// height: $app-content-height;
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
}
.workarea-single-view {
width: 100%;
// height: $app-content-height;
height: 100%;
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
}
@ -45,3 +50,22 @@
}
}
}
.workarea-split-button {
position: absolute;
cursor: pointer;
padding: 0.5rem;
top: 0;
right: 0;
z-index: 20;
&[data-selected='true'] {
top: 0;
right: 0;
svg {
opacity: 1;
}
}
svg {
opacity: 0.5;
}
}

Some files were not shown because too many files have changed in this diff Show More