Rebases against development

This commit is contained in:
psychedelicious
2022-11-11 06:54:06 +11:00
committed by blessedcoolant
parent 248068fe5d
commit 6c7191712f
233 changed files with 7487 additions and 4316 deletions

View File

@ -5,7 +5,7 @@
- `python scripts/dream.py --web` serves both frontend and backend at
http://localhost:9090
## Environment
## Evironment
Install [node](https://nodejs.org/en/download/) (includes npm) and optionally
[yarn](https://yarnpkg.com/getting-started/install).
@ -15,7 +15,7 @@ packages.
## Dev
1. From `frontend/`, run `npm run dev` / `yarn dev` to start the dev server.
1. From `frontend/`, run `npm dev` / `yarn dev` to start the dev server.
2. Run `python scripts/dream.py --web`.
3. Navigate to the dev server address e.g. `http://localhost:5173/`.

View File

@ -0,0 +1,23 @@
{
"eslintConfig": {
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended"
],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "eslint-plugin-react-hooks"],
"root": true,
"settings": {
"import/resolver": {
"node": {
"paths": ["src"],
"extensions": [".js", ".jsx", ".ts", ".tsx"]
}
}
},
"rules": {
"react/jsx-filename-extension": [1, { "extensions": [".tsx", ".ts"] }]
}
}
}

View File

@ -12,6 +12,7 @@
"dependencies": {
"@chakra-ui/icons": "^2.0.10",
"@chakra-ui/react": "^2.3.1",
"@emotion/cache": "^11.10.5",
"@emotion/react": "^11.10.4",
"@emotion/styled": "^11.10.4",
"@radix-ui/react-context-menu": "^2.0.1",
@ -29,14 +30,17 @@
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.2",
"react-hotkeys-hook": "^3.4.7",
"react-hotkeys-hook": "4",
"react-icons": "^4.4.0",
"react-image-pan-zoom-rotate": "^1.6.0",
"react-konva": "^18.2.3",
"react-redux": "^8.0.2",
"react-transition-group": "^4.4.5",
"redux-deep-persist": "^1.0.6",
"redux-persist": "^6.0.0",
"socket.io": "^4.5.2",
"socket.io-client": "^4.5.2",
"use-image": "^1.1.0",
"uuid": "^9.0.0",
"yarn": "^1.22.19"
},
@ -55,6 +59,7 @@
"tsc-watch": "^5.0.3",
"typescript": "^4.6.4",
"vite": "^3.0.7",
"vite-plugin-eslint": "^1.8.1"
"vite-plugin-eslint": "^1.8.1",
"vite-tsconfig-paths": "^3.5.2"
}
}

View File

@ -1,5 +1,9 @@
@use '../styles/Mixins/' as *;
svg {
fill: var(--svg-color);
}
.App {
display: grid;
width: 100vw;

View File

@ -1,21 +1,21 @@
import { useEffect } from 'react';
import ProgressBar from '../features/system/ProgressBar';
import SiteHeader from '../features/system/SiteHeader';
import Console from '../features/system/Console';
import ProgressBar from 'features/system/ProgressBar';
import SiteHeader from 'features/system/SiteHeader';
import Console from 'features/system/Console';
import { useAppDispatch } from './store';
import { requestSystemConfig } from './socketio/actions';
import { keepGUIAlive } from './utils';
import InvokeTabs from '../features/tabs/InvokeTabs';
import ImageUploader from '../common/components/ImageUploader';
import { RootState, useAppSelector } from '../app/store';
import InvokeTabs from 'features/tabs/InvokeTabs';
import ImageUploader from 'common/components/ImageUploader';
import { RootState, useAppSelector } from 'app/store';
import FloatingGalleryButton from '../features/tabs/FloatingGalleryButton';
import FloatingOptionsPanelButtons from '../features/tabs/FloatingOptionsPanelButtons';
import FloatingGalleryButton from 'features/tabs/FloatingGalleryButton';
import FloatingOptionsPanelButtons from 'features/tabs/FloatingOptionsPanelButtons';
import { createSelector } from '@reduxjs/toolkit';
import { GalleryState } from '../features/gallery/gallerySlice';
import { OptionsState } from '../features/options/optionsSlice';
import { activeTabNameSelector } from '../features/options/optionsSelectors';
import { SystemState } from '../features/system/systemSlice';
import { GalleryState } from 'features/gallery/gallerySlice';
import { OptionsState } from 'features/options/optionsSlice';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { SystemState } from 'features/system/systemSlice';
import _ from 'lodash';
import { Model } from './invokeai';
@ -51,16 +51,20 @@ const appSelector = createSelector(
''
);
const shouldShowGalleryButton = !(
shouldShowGallery ||
(shouldHoldGalleryOpen && !shouldPinGallery)
);
const shouldShowGalleryButton =
!(shouldShowGallery || (shouldHoldGalleryOpen && !shouldPinGallery)) &&
['txt2img', 'img2img', 'inpainting', 'outpainting'].includes(
activeTabName
);
const shouldShowOptionsPanelButton =
!(
shouldShowOptionsPanel ||
(shouldHoldOptionsPanelOpen && !shouldPinOptionsPanel)
) && ['txt2img', 'img2img', 'inpainting'].includes(activeTabName);
) &&
['txt2img', 'img2img', 'inpainting', 'outpainting'].includes(
activeTabName
);
return {
modelStatusText,
@ -81,10 +85,6 @@ const App = () => {
const { shouldShowGalleryButton, shouldShowOptionsPanelButton } =
useAppSelector(appSelector);
useEffect(() => {
dispatch(requestSystemConfig());
}, [dispatch]);
return (
<div className="App">
<ImageUploader>

View File

@ -1,6 +1,6 @@
// TODO: use Enums?
import { InProgressImageType } from '../features/system/systemSlice';
import { InProgressImageType } from 'features/system/systemSlice';
// Valid samplers
export const SAMPLERS: Array<string> = [

View File

@ -12,7 +12,9 @@
* 'gfpgan'.
*/
import { Category as GalleryCategory } from '../features/gallery/gallerySlice';
import { Category as GalleryCategory } from 'features/gallery/gallerySlice';
import { InvokeTabName } from 'features/tabs/InvokeTabs';
import { IRect } from 'konva/lib/types';
/**
* TODO:
@ -115,8 +117,8 @@ export declare type Image = {
metadata?: Metadata;
width: number;
height: number;
category: GalleryCategory;
isBase64: boolean;
category: GalleryCategory;
isBase64: boolean;
};
// GalleryImages is an array of Image.
@ -171,10 +173,13 @@ export declare type SystemStatusResponse = SystemStatus;
export declare type SystemConfigResponse = SystemConfig;
export declare type ImageResultResponse = Omit<Image, 'uuid'>;
export declare type ImageResultResponse = Omit<Image, 'uuid'> & {
boundingBox?: IRect;
generationMode: InvokeTabName;
};
export declare type ImageUploadResponse = Omit<Image, 'uuid' | 'metadata'> & {
destination: 'img2img' | 'inpainting';
destination: 'img2img' | 'inpainting' | 'outpainting' | 'outpainting_merge';
};
export declare type ErrorResponse = {
@ -198,9 +203,17 @@ export declare type ImageUrlResponse = {
url: string;
};
export declare type ImageUploadDestination = 'img2img' | 'inpainting';
export declare type ImageUploadDestination =
| 'img2img'
| 'inpainting'
| 'outpainting_merge';
export declare type UploadImagePayload = {
file: File;
destination?: ImageUploadDestination;
};
export declare type UploadOutpaintingMergeImagePayload = {
dataURL: string;
name: string;
};

View File

@ -1,39 +1,35 @@
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { RootState } from '../store';
import { activeTabNameSelector } from '../../features/options/optionsSelectors';
import { OptionsState } from '../../features/options/optionsSlice';
import { SystemState } from '../../features/system/systemSlice';
import { InpaintingState } from '../../features/tabs/Inpainting/inpaintingSlice';
import { validateSeedWeights } from '../../common/util/seedWeightPairs';
import { RootState } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { OptionsState } from 'features/options/optionsSlice';
import { SystemState } from 'features/system/systemSlice';
import { baseCanvasImageSelector } from 'features/canvas/canvasSlice';
import { validateSeedWeights } from 'common/util/seedWeightPairs';
export const readinessSelector = createSelector(
[
(state: RootState) => state.options,
(state: RootState) => state.system,
(state: RootState) => state.inpainting,
baseCanvasImageSelector,
activeTabNameSelector,
],
(
options: OptionsState,
system: SystemState,
inpainting: InpaintingState,
baseCanvasImage,
activeTabName
) => {
const {
prompt,
shouldGenerateVariations,
seedWeights,
// maskPath,
initialImage,
seed,
} = options;
const { isProcessing, isConnected } = system;
const { imageToInpaint } = inpainting;
let isReady = true;
const reasonsWhyNotReady: string[] = [];
@ -48,20 +44,11 @@ export const readinessSelector = createSelector(
reasonsWhyNotReady.push('No initial image selected');
}
if (activeTabName === 'inpainting' && !imageToInpaint) {
if (activeTabName === 'inpainting' && !baseCanvasImage) {
isReady = false;
reasonsWhyNotReady.push('No inpainting image selected');
}
// // 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) {

View File

@ -1,7 +1,7 @@
import { createAction } from '@reduxjs/toolkit';
import { GalleryCategory } from '../../features/gallery/gallerySlice';
import { InvokeTabName } from '../../features/tabs/InvokeTabs';
import * as InvokeAI from '../invokeai';
import { GalleryCategory } from 'features/gallery/gallerySlice';
import { InvokeTabName } from 'features/tabs/InvokeTabs';
import * as InvokeAI from 'app/invokeai';
/**

View File

@ -4,23 +4,24 @@ import { Socket } from 'socket.io-client';
import {
frontendToBackendParameters,
FrontendToBackendParametersConfig,
} from '../../common/util/parameterTranslation';
} from 'common/util/parameterTranslation';
import {
GalleryCategory,
GalleryState,
removeImage,
} from '../../features/gallery/gallerySlice';
import { OptionsState } from '../../features/options/optionsSlice';
} from 'features/gallery/gallerySlice';
import { OptionsState } from 'features/options/optionsSlice';
import {
addLogEntry,
errorOccurred,
modelChangeRequested,
setIsProcessing,
} from '../../features/system/systemSlice';
import { inpaintingImageElementRef } from '../../features/tabs/Inpainting/InpaintingCanvas';
import { InvokeTabName } from '../../features/tabs/InvokeTabs';
import * as InvokeAI from '../invokeai';
import { RootState } from '../store';
} from 'features/system/systemSlice';
import { inpaintingImageElementRef } from 'features/canvas/IAICanvas';
import { InvokeTabName } from 'features/tabs/InvokeTabs';
import * as InvokeAI from 'app/invokeai';
import { RootState } from 'app/store';
import { baseCanvasImageSelector } from 'features/canvas/canvasSlice';
/**
* Returns an object containing all functions which use `socketio.emit()`.
@ -42,7 +43,7 @@ const makeSocketIOEmitters = (
const {
options: optionsState,
system: systemState,
inpainting: inpaintingState,
canvas: canvasState,
gallery: galleryState,
} = state;
@ -50,15 +51,15 @@ const makeSocketIOEmitters = (
{
generationMode,
optionsState,
inpaintingState,
canvasState,
systemState,
};
if (generationMode === 'inpainting') {
if (
!inpaintingImageElementRef.current ||
!inpaintingState.imageToInpaint?.url
) {
if (['inpainting', 'outpainting'].includes(generationMode)) {
const baseCanvasImage = baseCanvasImageSelector(getState());
const imageUrl = baseCanvasImage?.url;
if (!inpaintingImageElementRef.current || !imageUrl) {
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
@ -70,11 +71,10 @@ const makeSocketIOEmitters = (
return;
}
frontendToBackendParametersConfig.imageToProcessUrl =
inpaintingState.imageToInpaint.url;
frontendToBackendParametersConfig.imageToProcessUrl = imageUrl;
frontendToBackendParametersConfig.maskImageElement =
inpaintingImageElementRef.current;
// frontendToBackendParametersConfig.maskImageElement =
// inpaintingImageElementRef.current;
} else if (!['txt2img', 'img2img'].includes(generationMode)) {
if (!galleryState.currentImage?.url) return;
@ -96,7 +96,12 @@ const makeSocketIOEmitters = (
// TODO: handle maintaining masks for reproducibility in future
if (generationParameters.init_mask) {
generationParameters.init_mask = generationParameters.init_mask
.substr(0, 20)
.substr(0, 64)
.concat('...');
}
if (generationParameters.init_img) {
generationParameters.init_img = generationParameters.init_img
.substr(0, 64)
.concat('...');
}

View File

@ -2,7 +2,7 @@ import { AnyAction, MiddlewareAPI, Dispatch } from '@reduxjs/toolkit';
import { v4 as uuidv4 } from 'uuid';
import dateFormat from 'dateformat';
import * as InvokeAI from '../invokeai';
import * as InvokeAI from 'app/invokeai';
import {
addLogEntry,
@ -15,7 +15,7 @@ import {
errorOccurred,
setModelList,
setIsCancelable,
} from '../../features/system/systemSlice';
} from 'features/system/systemSlice';
import {
addGalleryImages,
@ -25,19 +25,21 @@ import {
removeImage,
setCurrentImage,
setIntermediateImage,
} from '../../features/gallery/gallerySlice';
} from 'features/gallery/gallerySlice';
import {
clearInitialImage,
setInitialImage,
setMaskPath,
} from '../../features/options/optionsSlice';
import { requestImages, requestNewImages } from './actions';
} from 'features/options/optionsSlice';
import { requestImages, requestNewImages, requestSystemConfig } from './actions';
import {
addImageToOutpaintingSesion,
clearImageToInpaint,
setImageToInpaint,
} from '../../features/tabs/Inpainting/inpaintingSlice';
import { tabMap } from '../../features/tabs/InvokeTabs';
setImageToOutpaint,
} from 'features/canvas/canvasSlice';
import { tabMap } from 'features/tabs/InvokeTabs';
/**
* Returns an object containing listener callbacks for socketio events.
@ -56,6 +58,7 @@ const makeSocketIOListeners = (
try {
dispatch(setIsConnected(true));
dispatch(setCurrentStatus('Connected'));
dispatch(requestSystemConfig());
const gallery: GalleryState = getState().gallery;
if (gallery.categories.user.latest_mtime) {
@ -111,6 +114,16 @@ const makeSocketIOListeners = (
})
);
if (data.generationMode === 'outpainting' && data.boundingBox) {
const { boundingBox } = data;
dispatch(
addImageToOutpaintingSesion({
image: newImage,
boundingBox,
})
);
}
if (shouldLoopback) {
const activeTabName = tabMap[activeTab];
switch (activeTabName) {
@ -299,15 +312,15 @@ const makeSocketIOListeners = (
// remove references to image in options
const { initialImage, maskPath } = getState().options;
const { imageToInpaint } = getState().inpainting;
const { inpainting, outpainting } = getState().canvas;
if (initialImage?.url === url || initialImage === url) {
dispatch(clearInitialImage());
}
if (imageToInpaint?.url === url) {
dispatch(clearImageToInpaint());
}
// if (imageToInpaint?.url === url) {
// dispatch(clearImageToInpaint());
// }
if (maskPath === url) {
dispatch(setMaskPath(''));

View File

@ -4,7 +4,7 @@ import { io } from 'socket.io-client';
import makeSocketIOListeners from './listeners';
import makeSocketIOEmitters from './emitters';
import * as InvokeAI from '../invokeai';
import * as InvokeAI from 'app/invokeai';
/**
* Creates a socketio middleware to handle communication with server.
@ -104,12 +104,9 @@ export const socketioMiddleware = () => {
onImageDeleted(data);
});
socketio.on(
'imageUploaded',
(data: InvokeAI.ImageUploadResponse) => {
onImageUploaded(data);
}
);
socketio.on('imageUploaded', (data: InvokeAI.ImageUploadResponse) => {
onImageUploaded(data);
});
socketio.on('maskImageUploaded', (data: InvokeAI.ImageUrlResponse) => {
onMaskImageUploaded(data);

View File

@ -5,16 +5,14 @@ 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, { OptionsState } from '../features/options/optionsSlice';
import galleryReducer, { GalleryState } from '../features/gallery/gallerySlice';
import inpaintingReducer, {
InpaintingState,
} from '../features/tabs/Inpainting/inpaintingSlice';
import { getPersistConfig } from 'redux-deep-persist';
import optionsReducer from 'features/options/optionsSlice';
import galleryReducer from 'features/gallery/gallerySlice';
import systemReducer from 'features/system/systemSlice';
import canvasReducer from 'features/canvas/canvasSlice';
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.
@ -28,87 +26,82 @@ import { PersistPartial } from 'redux-persist/es/persistReducer';
* These can be blacklisted in redux-persist.
*
* The necesssary nested persistors with blacklists are configured below.
*
* TODO: Do we blacklist initialImagePath? If the image is deleted from disk we get an
* ugly 404. But if we blacklist it, then this is a valuable parameter that is lost
* on reload. Need to figure out a good way to handle this.
*/
const rootPersistConfig = {
key: 'root',
storage,
stateReconciler: autoMergeLevel2,
blacklist: ['gallery', 'system', 'inpainting'],
};
const genericCanvasBlacklist = [
'pastObjects',
'futureObjects',
'stageScale',
'stageDimensions',
'stageCoordinates',
'cursorPosition',
];
const systemPersistConfig = {
key: 'system',
storage,
stateReconciler: autoMergeLevel2,
blacklist: [
'isCancelable',
'isConnected',
'isProcessing',
'currentStep',
'socketId',
'isESRGANAvailable',
'isGFPGANAvailable',
'currentStep',
'totalSteps',
'currentIteration',
'totalIterations',
'currentStatus',
],
};
const inpaintingCanvasBlacklist = genericCanvasBlacklist.map(
(blacklistItem) => `canvas.inpainting.${blacklistItem}`
);
const galleryPersistConfig = {
key: 'gallery',
storage,
stateReconciler: autoMergeLevel2,
whitelist: [
'galleryWidth',
'shouldPinGallery',
'shouldShowGallery',
'galleryScrollPosition',
'galleryImageMinimumWidth',
'galleryImageObjectFit',
],
};
const outpaintingCanvasBlacklist = genericCanvasBlacklist.map(
(blacklistItem) => `canvas.outpainting.${blacklistItem}`
);
const inpaintingPersistConfig = {
key: 'inpainting',
storage,
stateReconciler: autoMergeLevel2,
blacklist: ['pastLines', 'futuresLines', 'cursorPosition'],
};
const systemBlacklist = [
'currentIteration',
'currentStatus',
'currentStep',
'isCancelable',
'isConnected',
'isESRGANAvailable',
'isGFPGANAvailable',
'isProcessing',
'socketId',
'totalIterations',
'totalSteps',
].map((blacklistItem) => `system.${blacklistItem}`);
const reducers = combineReducers({
const galleryBlacklist = [
'categories',
'currentCategory',
'currentImage',
'currentImageUuid',
'shouldAutoSwitchToNewImages',
'shouldHoldGalleryOpen',
'intermediateImage',
].map((blacklistItem) => `gallery.${blacklistItem}`);
const rootReducer = combineReducers({
options: optionsReducer,
gallery: persistReducer<GalleryState>(galleryPersistConfig, galleryReducer),
system: persistReducer<SystemState>(systemPersistConfig, systemReducer),
inpainting: persistReducer<InpaintingState>(
inpaintingPersistConfig,
inpaintingReducer
),
gallery: galleryReducer,
system: systemReducer,
canvas: canvasReducer,
});
const persistedReducer = persistReducer<{
options: OptionsState;
gallery: GalleryState & PersistPartial;
system: SystemState & PersistPartial;
inpainting: InpaintingState & PersistPartial;
}>(rootPersistConfig, reducers);
const rootPersistConfig = getPersistConfig({
key: 'root',
storage,
rootReducer,
blacklist: [
...inpaintingCanvasBlacklist,
...outpaintingCanvasBlacklist,
...systemBlacklist,
...galleryBlacklist,
],
throttle: 500,
});
const persistedReducer = persistReducer(rootPersistConfig, rootReducer);
// Continue with store setup
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
// redux-persist sometimes needs to temporarily put a function in redux state, need to disable this check
immutableCheck: false,
serializableCheck: false,
}).concat(socketioMiddleware()),
});
export type AppGetState = typeof store.getState;
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@ -1,7 +1,7 @@
import { Box, forwardRef, Icon } from '@chakra-ui/react';
import { IconType } from 'react-icons';
import { MdHelp } from 'react-icons/md';
import { Feature } from '../../app/features';
import { Feature } from 'app/features';
import GuidePopover from './GuidePopover';
type GuideIconProps = {

View File

@ -1,11 +1,11 @@
.guide-popover-arrow {
background-color: var(--tab-panel-bg) !important;
box-shadow: none !important;
background-color: var(--tab-panel-bg);
box-shadow: none;
}
.guide-popover-content {
background-color: var(--background-color-secondary) !important;
border: none !important;
background-color: var(--background-color-secondary);
border: none;
}
.guide-popover-guide-content {

View File

@ -5,12 +5,12 @@ import {
PopoverTrigger,
Box,
} from '@chakra-ui/react';
import { SystemState } from '../../features/system/systemSlice';
import { useAppSelector } from '../../app/store';
import { RootState } from '../../app/store';
import { SystemState } from 'features/system/systemSlice';
import { useAppSelector } from 'app/store';
import { RootState } from 'app/store';
import { createSelector } from '@reduxjs/toolkit';
import { ReactElement } from 'react';
import { Feature, FEATURES } from '../../app/features';
import { Feature, FEATURES } from 'app/features';
type GuideProps = {
children: ReactElement;

View File

@ -1,3 +1,8 @@
.invokeai__button {
justify-content: space-between;
background-color: var(--btn-base-color);
place-content: center;
&:hover {
background-color: var(--btn-base-color-hover);
}
}

View File

@ -15,7 +15,7 @@
svg {
width: 0.6rem;
height: 0.6rem;
stroke-width: 3px !important;
stroke-width: 3px;
}
&[data-checked] {

View File

@ -1,11 +1,11 @@
@use '../../styles/Mixins/' as *;
.invokeai__icon-button {
background-color: var(--btn-grey);
background: var(--btn-base-color);
cursor: pointer;
&:hover {
background-color: var(--btn-grey-hover);
background-color: var(--btn-base-color-hover);
}
&[data-selected='true'] {
@ -20,16 +20,39 @@
}
&[data-variant='link'] {
background: none !important;
background: none;
&:hover {
background: none !important;
background: none;
}
}
&[data-selected='true'] {
border-color: var(--accent-color);
// Check Box Style
&[data-as-checkbox='true'] {
background-color: var(--btn-base-color);
border: 3px solid var(--btn-base-color);
svg {
fill: var(--text-color);
}
&:hover {
border-color: var(--accent-color-hover);
background-color: var(--btn-base-color);
border-color: var(--btn-checkbox-border-hover);
svg {
fill: var(--text-color);
}
}
&[data-selected='true'] {
border-color: var(--accent-color);
svg {
fill: var(--accent-color-hover);
}
&:hover {
svg {
fill: var(--accent-color-hover);
}
}
}
}
@ -38,28 +61,12 @@
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 {

View File

@ -25,13 +25,23 @@ const IAIIconButton = forwardRef((props: IAIIconButtonProps, forwardedRef) => {
} = props;
return (
<Tooltip label={tooltip} hasArrow {...tooltipProps}>
<Tooltip
label={tooltip}
hasArrow
{...tooltipProps}
{...(tooltipProps?.placement
? { placement: tooltipProps.placement }
: { placement: 'top' })}
>
<IconButton
ref={forwardedRef}
className={`invokeai__icon-button ${styleClass}`}
className={
styleClass
? `invokeai__icon-button ${styleClass}`
: `invokeai__icon-button`
}
data-as-checkbox={asCheckbox}
data-selected={isChecked !== undefined ? isChecked : undefined}
style={props.onClick ? { cursor: 'pointer' } : {}}
{...rest}
/>
</Tooltip>

View File

@ -1,16 +1,14 @@
.invokeai__number-input-form-control {
display: grid;
grid-template-columns: max-content auto;
display: flex;
align-items: center;
column-gap: 1rem;
.invokeai__number-input-form-label {
color: var(--text-color-secondary);
margin-right: 0;
font-size: 1rem;
margin-bottom: 0;
flex-grow: 2;
white-space: nowrap;
padding-right: 1rem;
&[data-focus] + .invokeai__number-input-root {
outline: none;
@ -33,7 +31,7 @@
align-items: center;
background-color: var(--background-color-secondary);
border: 2px solid var(--border-color);
border-radius: 0.2rem;
border-radius: 0.3rem;
}
.invokeai__number-input-field {
@ -41,10 +39,8 @@
font-weight: bold;
width: 100%;
height: auto;
padding: 0;
font-size: 0.9rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
padding: 0 0.5rem;
&:focus {
outline: none;

View File

@ -21,6 +21,7 @@ const numberStringRegex = /^-?(0\.)?\.?$/;
interface Props extends Omit<NumberInputProps, 'onChange'> {
styleClass?: string;
label?: string;
labelFontSize?: string | number;
width?: string | number;
showStepper?: boolean;
value: number;
@ -43,6 +44,7 @@ interface Props extends Omit<NumberInputProps, 'onChange'> {
const IAINumberInput = (props: Props) => {
const {
label,
labelFontSize = '1rem',
styleClass,
isDisabled = false,
showStepper = true,
@ -127,6 +129,7 @@ const IAINumberInput = (props: Props) => {
<FormLabel
className="invokeai__number-input-form-label"
style={{ display: label ? 'block' : 'none' }}
fontSize={labelFontSize}
{...formLabelProps}
>
{label}

View File

@ -1,10 +1,10 @@
.invokeai__popover-content {
min-width: unset;
width: unset !important;
width: unset;
padding: 1rem;
border-radius: 0.5rem !important;
background-color: var(--background-color) !important;
border: 2px solid var(--border-color) !important;
border-radius: 0.5rem;
background-color: var(--background-color);
border: 2px solid var(--border-color);
.invokeai__popover-arrow {
background-color: var(--background-color) !important;

View File

@ -29,7 +29,7 @@ const IAIPopover = (props: IAIPopoverProps) => {
<Popover {...rest}>
<PopoverTrigger>{triggerComponent}</PopoverTrigger>
<PopoverContent className={`invokeai__popover-content ${styleClass}`}>
{hasArrow && <PopoverArrow className={'invokeai__popover-arrow'} />}
{hasArrow && <PopoverArrow className="invokeai__popover-arrow" />}
{children}
</PopoverContent>
</Popover>

View File

@ -27,5 +27,6 @@
.invokeai__select-option {
background-color: var(--background-color-secondary);
color: var(--text-color-secondary);
}
}

View File

@ -2,7 +2,7 @@ import { FormControl, FormLabel, Select, SelectProps } from '@chakra-ui/react';
import { MouseEvent } from 'react';
type IAISelectProps = SelectProps & {
label: string;
label?: string;
styleClass?: string;
validValues:
| Array<number | string>
@ -32,15 +32,18 @@ const IAISelect = (props: IAISelectProps) => {
e.nativeEvent.cancelBubble = true;
}}
>
<FormLabel
className="invokeai__select-label"
fontSize={fontSize}
marginBottom={1}
flexGrow={2}
whiteSpace="nowrap"
>
{label}
</FormLabel>
{label && (
<FormLabel
className="invokeai__select-label"
fontSize={fontSize}
marginBottom={1}
flexGrow={2}
whiteSpace="nowrap"
>
{label}
</FormLabel>
)}
<Select
className="invokeai__select-picker"
fontSize={fontSize}

View File

@ -1,40 +1,62 @@
@use '../../styles/Mixins/' as *;
.invokeai__slider-form-control {
.invokeai__slider-component {
display: flex;
column-gap: 1rem;
justify-content: space-between;
gap: 1rem;
align-items: center;
width: max-content;
padding-right: 0.25rem;
.invokeai__slider-inner-container {
display: flex;
column-gap: 0.5rem;
.invokeai__slider-component-label {
min-width: max-content;
margin: 0;
font-weight: bold;
font-size: 0.9rem;
color: var(--text-color-secondary);
}
.invokeai__slider-form-label {
color: var(--text-color-secondary);
margin: 0;
margin-right: 0.5rem;
margin-bottom: 0.1rem;
.invokeai__slider_track {
background-color: var(--tab-color);
}
.invokeai__slider_track-filled {
background-color: var(--slider-color);
}
.invokeai__slider-thumb {
width: 4px;
}
.invokeai__slider-mark {
font-size: 0.75rem;
font-weight: bold;
color: var(--slider-color);
margin-top: 0.3rem;
}
.invokeai__slider-number-input {
border: none;
font-size: 0.9rem;
font-weight: bold;
height: 2rem;
background-color: var(--background-color-secondary);
border: 2px solid var(--border-color);
&:focus {
outline: none;
box-shadow: none;
border: 2px solid var(--input-border-color);
box-shadow: 0 0 10px 0 var(--input-box-shadow-color);
}
.invokeai__slider-root {
.invokeai__slider-filled-track {
background-color: var(--accent-color-hover);
}
&:disabled {
opacity: 0.2;
}
}
.invokeai__slider-track {
background-color: var(--text-color-secondary);
height: 5px;
border-radius: 9999px;
}
.invokeai__slider-number-stepper {
border: none;
}
.invokeai__slider-thumb {
}
&[data-markers='true'] {
.invokeai__slider_container {
margin-top: -1rem;
}
}
}
.invokeai__slider-thumb-tooltip {
}

View File

@ -1,87 +1,241 @@
import {
Slider,
SliderTrack,
SliderFilledTrack,
SliderThumb,
FormControl,
FormLabel,
Tooltip,
SliderProps,
FormControlProps,
FormLabel,
FormLabelProps,
SliderTrackProps,
HStack,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputFieldProps,
NumberInputProps,
NumberInputStepper,
NumberInputStepperProps,
Slider,
SliderFilledTrack,
SliderMark,
SliderMarkProps,
SliderThumb,
SliderThumbProps,
SliderTrack,
SliderTrackProps,
Tooltip,
TooltipProps,
SliderInnerTrackProps,
} from '@chakra-ui/react';
import React, { FocusEvent, useEffect, useState } from 'react';
import { BiReset } from 'react-icons/bi';
import IAIIconButton, { IAIIconButtonProps } from './IAIIconButton';
import _ from 'lodash';
type IAISliderProps = SliderProps & {
label?: string;
export type IAIFullSliderProps = {
label: string;
value: number;
min?: number;
max?: number;
step?: number;
onChange: (v: number) => void;
withSliderMarks?: boolean;
sliderMarkLeftOffset?: number;
sliderMarkRightOffset?: number;
withInput?: boolean;
isInteger?: boolean;
inputWidth?: string | number;
inputReadOnly?: boolean;
withReset?: boolean;
handleReset?: () => void;
isResetDisabled?: boolean;
isSliderDisabled?: boolean;
isInputDisabled?: boolean;
tooltipSuffix?: string;
hideTooltip?: boolean;
styleClass?: string;
formControlProps?: FormControlProps;
formLabelProps?: FormLabelProps;
sliderFormControlProps?: FormControlProps;
sliderFormLabelProps?: FormLabelProps;
sliderMarkProps?: Omit<SliderMarkProps, 'value'>;
sliderTrackProps?: SliderTrackProps;
sliderInnerTrackProps?: SliderInnerTrackProps;
sliderThumbProps?: SliderThumbProps;
sliderThumbTooltipProps?: Omit<TooltipProps, 'children'>;
sliderNumberInputProps?: NumberInputProps;
sliderNumberInputFieldProps?: NumberInputFieldProps;
sliderNumberInputStepperProps?: NumberInputStepperProps;
sliderTooltipProps?: Omit<TooltipProps, 'children'>;
sliderIAIIconButtonProps?: IAIIconButtonProps;
};
const IAISlider = (props: IAISliderProps) => {
export default function IAISlider(props: IAIFullSliderProps) {
const [showTooltip, setShowTooltip] = useState(false);
const {
label,
value,
min = 1,
max = 100,
step = 1,
onChange,
tooltipSuffix = '',
withSliderMarks = false,
sliderMarkLeftOffset = 0,
sliderMarkRightOffset = -7,
withInput = false,
isInteger = false,
inputWidth = '5rem',
inputReadOnly = true,
withReset = false,
hideTooltip = false,
handleReset,
isResetDisabled,
isSliderDisabled,
isInputDisabled,
styleClass,
formControlProps,
formLabelProps,
sliderFormControlProps,
sliderFormLabelProps,
sliderMarkProps,
sliderTrackProps,
sliderInnerTrackProps,
sliderThumbProps,
sliderThumbTooltipProps,
sliderNumberInputProps,
sliderNumberInputFieldProps,
sliderNumberInputStepperProps,
sliderTooltipProps,
sliderIAIIconButtonProps,
...rest
} = props;
const [localInputValue, setLocalInputValue] = useState<string>(String(value));
useEffect(() => {
if (String(value) !== localInputValue && localInputValue !== '') {
setLocalInputValue(String(value));
}
}, [value, localInputValue, setLocalInputValue]);
const handleInputBlur = (e: FocusEvent<HTMLInputElement>) => {
const clamped = _.clamp(
isInteger ? Math.floor(Number(e.target.value)) : Number(e.target.value),
min,
max
);
setLocalInputValue(String(clamped));
onChange(clamped);
};
const handleInputChange = (v: any) => {
setLocalInputValue(v);
onChange(Number(v));
};
const handleResetDisable = () => {
if (!handleReset) return;
handleReset();
};
return (
<FormControl
className={`invokeai__slider-form-control ${styleClass}`}
{...formControlProps}
className={
styleClass
? `invokeai__slider-component ${styleClass}`
: `invokeai__slider-component`
}
data-markers={withSliderMarks}
{...sliderFormControlProps}
>
<div className="invokeai__slider-inner-container">
<FormLabel
className={`invokeai__slider-form-label`}
whiteSpace="nowrap"
{...formLabelProps}
>
{label}
</FormLabel>
<FormLabel
className="invokeai__slider-component-label"
{...sliderFormLabelProps}
>
{label}
</FormLabel>
<HStack w={'100%'} gap={2}>
<Slider
className={`invokeai__slider-root`}
aria-label={label}
value={value}
min={min}
max={max}
step={step}
onChange={handleInputChange}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
focusThumbOnChange={false}
isDisabled={isSliderDisabled}
{...rest}
>
<SliderTrack
className={`invokeai__slider-track`}
{...sliderTrackProps}
>
<SliderFilledTrack
className={`invokeai__slider-filled-track`}
{...sliderInnerTrackProps}
/>
{withSliderMarks && (
<>
<SliderMark
value={min}
className="invokeai__slider-mark invokeai__slider-mark-start"
ml={sliderMarkLeftOffset}
{...sliderMarkProps}
>
{min}
</SliderMark>
<SliderMark
value={max}
className="invokeai__slider-mark invokeai__slider-mark-end"
ml={sliderMarkRightOffset}
{...sliderMarkProps}
>
{max}
</SliderMark>
</>
)}
<SliderTrack className="invokeai__slider_track" {...sliderTrackProps}>
<SliderFilledTrack className="invokeai__slider_track-filled" />
</SliderTrack>
<Tooltip
className={`invokeai__slider-thumb-tooltip`}
placement="top"
hasArrow
{...sliderThumbTooltipProps}
className="invokeai__slider-component-tooltip"
placement="top"
isOpen={showTooltip}
label={`${value}${tooltipSuffix}`}
hidden={hideTooltip}
{...sliderTooltipProps}
>
<SliderThumb
className={`invokeai__slider-thumb`}
className="invokeai__slider-thumb"
{...sliderThumbProps}
/>
</Tooltip>
</Slider>
</div>
{withInput && (
<NumberInput
min={min}
max={max}
step={step}
value={localInputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
className="invokeai__slider-number-field"
isDisabled={isInputDisabled}
{...sliderNumberInputProps}
>
<NumberInputField
className="invokeai__slider-number-input"
width={inputWidth}
readOnly={inputReadOnly}
{...sliderNumberInputFieldProps}
/>
<NumberInputStepper {...sliderNumberInputStepperProps}>
<NumberIncrementStepper className="invokeai__slider-number-stepper" />
<NumberDecrementStepper className="invokeai__slider-number-stepper" />
</NumberInputStepper>
</NumberInput>
)}
{withReset && (
<IAIIconButton
size={'sm'}
aria-label={'Reset'}
tooltip={'Reset'}
icon={<BiReset />}
onClick={handleResetDisable}
isDisabled={isResetDisabled}
{...sliderIAIIconButtonProps}
/>
)}
</HStack>
</FormControl>
);
};
export default IAISlider;
}

View File

@ -1,4 +1,5 @@
import { Heading } from '@chakra-ui/react';
import { KeyboardEvent } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
type ImageUploadOverlayProps = {

View File

@ -33,7 +33,6 @@
}
.image-uploader-button-outer {
min-width: 20rem;
width: 100%;
height: 100%;
display: flex;
@ -42,10 +41,10 @@
cursor: pointer;
border-radius: 0.5rem;
color: var(--tab-list-text-inactive);
background-color: var(--btn-grey);
background-color: var(--background-color);
&:hover {
background-color: var(--btn-grey-hover);
background-color: var(--background-color-light);
}
}
@ -66,10 +65,10 @@
text-align: center;
svg {
width: 4rem !important;
height: 4rem !important;
width: 4rem;
height: 4rem;
}
h2 {
font-size: 1.2rem !important;
font-size: 1.2rem;
}
}

View File

@ -1,12 +1,18 @@
import { useCallback, ReactNode, useState, useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../app/store';
import {
useCallback,
ReactNode,
useState,
useEffect,
KeyboardEvent,
} from 'react';
import { useAppDispatch, useAppSelector } from 'app/store';
import { FileRejection, useDropzone } from 'react-dropzone';
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 { 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 = {
@ -41,7 +47,7 @@ const ImageUploader = (props: ImageUploaderProps) => {
(file: File) => {
setIsHandlingUpload(true);
const payload: UploadImagePayload = { file };
if (['img2img', 'inpainting'].includes(activeTabName)) {
if (['img2img', 'inpainting', 'outpainting'].includes(activeTabName)) {
payload.destination = activeTabName as ImageUploadDestination;
}
dispatch(uploadImage(payload));
@ -137,7 +143,13 @@ const ImageUploader = (props: ImageUploaderProps) => {
return (
<ImageUploaderTriggerContext.Provider value={open}>
<div {...getRootProps({ style: {} })}>
<div
{...getRootProps({ style: {} })}
onKeyDown={(e: KeyboardEvent) => {
// Bail out if user hits spacebar - do not open the uploader
if (e.key === ' ') return;
}}
>
<input {...getInputProps()} />
{children}
{isDragActive && isHandlingUpload && (

View File

@ -1,7 +1,7 @@
import { Heading } from '@chakra-ui/react';
import { useContext } from 'react';
import { FaUpload } from 'react-icons/fa';
import { ImageUploaderTriggerContext } from '../../app/contexts/ImageUploaderTriggerContext';
import { ImageUploaderTriggerContext } from 'app/contexts/ImageUploaderTriggerContext';
type ImageUploaderButtonProps = {
styleClass?: string;

View File

@ -1,6 +1,6 @@
import { useContext } from 'react';
import { FaUpload } from 'react-icons/fa';
import { ImageUploaderTriggerContext } from '../../app/contexts/ImageUploaderTriggerContext';
import { ImageUploaderTriggerContext } from 'app/contexts/ImageUploaderTriggerContext';
import IAIIconButton from './IAIIconButton';
const ImageUploaderIconButton = () => {

View File

@ -1,5 +1,5 @@
import React from 'react';
import Img2ImgPlaceHolder from '../../../assets/images/image2img.png';
import Img2ImgPlaceHolder from 'assets/images/image2img.png';
export const ImageToImageWIP = () => {
return (

View File

@ -0,0 +1,21 @@
type Base64AndCaption = {
base64: string;
caption: string;
};
const openBase64ImageInTab = (images: Base64AndCaption[]) => {
const w = window.open('');
if (!w) return;
images.forEach((i) => {
const image = new Image();
image.src = i.base64;
w.document.write(i.caption);
w.document.write('</br>');
w.document.write(image.outerHTML);
w.document.write('</br></br>');
});
};
export default openBase64ImageInTab;

View File

@ -1,20 +1,21 @@
import { NUMPY_RAND_MAX, NUMPY_RAND_MIN } from '../../app/constants';
import { OptionsState } from '../../features/options/optionsSlice';
import { SystemState } from '../../features/system/systemSlice';
import { NUMPY_RAND_MAX, NUMPY_RAND_MIN } from 'app/constants';
import { OptionsState } from 'features/options/optionsSlice';
import { SystemState } from 'features/system/systemSlice';
import { stringToSeedWeightsArray } from './seedWeightPairs';
import randomInt from './randomInt';
import { InvokeTabName } from '../../features/tabs/InvokeTabs';
import { InpaintingState } from '../../features/tabs/Inpainting/inpaintingSlice';
import generateMask from '../../features/tabs/Inpainting/util/generateMask';
import { InvokeTabName } from 'features/tabs/InvokeTabs';
import { CanvasState, isCanvasMaskLine } from 'features/canvas/canvasSlice';
import generateMask from 'features/canvas/util/generateMask';
import { canvasImageLayerRef } from 'features/canvas/IAICanvas';
import openBase64ImageInTab from './openBase64ImageInTab';
export type FrontendToBackendParametersConfig = {
generationMode: InvokeTabName;
optionsState: OptionsState;
inpaintingState: InpaintingState;
canvasState: CanvasState;
systemState: SystemState;
imageToProcessUrl?: string;
maskImageElement?: HTMLImageElement;
};
/**
@ -27,10 +28,9 @@ export const frontendToBackendParameters = (
const {
generationMode,
optionsState,
inpaintingState,
canvasState,
systemState,
imageToProcessUrl,
maskImageElement,
} = config;
const {
@ -62,8 +62,11 @@ export const frontendToBackendParameters = (
shouldRandomizeSeed,
} = optionsState;
const { shouldDisplayInProgressType, saveIntermediatesInterval } =
systemState;
const {
shouldDisplayInProgressType,
saveIntermediatesInterval,
enableImageDebugging,
} = systemState;
const generationParameters: { [k: string]: any } = {
prompt,
@ -80,6 +83,8 @@ export const frontendToBackendParameters = (
progress_images: shouldDisplayInProgressType === 'full-res',
progress_latents: shouldDisplayInProgressType === 'latents',
save_intermediates: saveIntermediatesInterval,
generation_mode: generationMode,
init_mask: '',
};
generationParameters.seed = shouldRandomizeSeed
@ -101,35 +106,36 @@ export const frontendToBackendParameters = (
}
// inpainting exclusive parameters
if (generationMode === 'inpainting' && maskImageElement) {
if (
['inpainting', 'outpainting'].includes(generationMode) &&
canvasImageLayerRef.current
) {
const {
lines,
boundingBoxCoordinate,
objects,
boundingBoxCoordinates,
boundingBoxDimensions,
inpaintReplace,
shouldUseInpaintReplace,
} = inpaintingState;
stageScale,
isMaskEnabled,
} = canvasState[canvasState.currentCanvas];
const boundingBox = {
...boundingBoxCoordinate,
...boundingBoxCoordinates,
...boundingBoxDimensions,
};
generationParameters.init_img = imageToProcessUrl;
generationParameters.strength = img2imgStrength;
generationParameters.fit = false;
const { maskDataURL, isMaskEmpty } = generateMask(
maskImageElement,
lines,
const maskDataURL = generateMask(
isMaskEnabled ? objects.filter(isCanvasMaskLine) : [],
boundingBox
);
generationParameters.is_mask_empty = isMaskEmpty;
generationParameters.init_mask = maskDataURL;
generationParameters.init_mask = maskDataURL.split(
'data:image/png;base64,'
)[1];
generationParameters.fit = false;
generationParameters.init_img = imageToProcessUrl;
generationParameters.strength = img2imgStrength;
if (shouldUseInpaintReplace) {
generationParameters.inpaint_replace = inpaintReplace;
@ -137,8 +143,44 @@ export const frontendToBackendParameters = (
generationParameters.bounding_box = boundingBox;
// TODO: The server metadata generation needs to be changed to fix this.
generationParameters.progress_images = false;
if (generationMode === 'outpainting') {
const tempScale = canvasImageLayerRef.current.scale();
canvasImageLayerRef.current.scale({
x: 1 / stageScale,
y: 1 / stageScale,
});
const absPos = canvasImageLayerRef.current.getAbsolutePosition();
const imageDataURL = canvasImageLayerRef.current.toDataURL({
x: boundingBox.x + absPos.x,
y: boundingBox.y + absPos.y,
width: boundingBox.width,
height: boundingBox.height,
});
if (enableImageDebugging) {
openBase64ImageInTab([
{ base64: maskDataURL, caption: 'mask sent as init_mask' },
{ base64: imageDataURL, caption: 'image sent as init_img' },
]);
}
canvasImageLayerRef.current.scale(tempScale);
generationParameters.init_img = imageDataURL;
// TODO: The server metadata generation needs to be changed to fix this.
generationParameters.progress_images = false;
generationParameters.seam_size = 96;
generationParameters.seam_blur = 16;
generationParameters.seam_strength = 0.7;
generationParameters.seam_steps = 10;
generationParameters.tile_size = 32;
generationParameters.force_outpaint = false;
}
}
if (shouldGenerateVariations) {
@ -171,6 +213,10 @@ export const frontendToBackendParameters = (
}
}
if (enableImageDebugging) {
generationParameters.enable_image_debugging = enableImageDebugging;
}
return {
generationParameters,
esrganParameters,

View File

@ -1,4 +1,4 @@
import * as InvokeAI from '../../app/invokeai';
import * as InvokeAI from 'app/invokeai';
const promptToString = (prompt: InvokeAI.Prompt): string => {
if (prompt.length === 1) {

View File

@ -1,4 +1,4 @@
import * as InvokeAI from '../../app/invokeai';
import * as InvokeAI from 'app/invokeai';
export const stringToSeedWeights = (
string: string

View File

@ -0,0 +1,275 @@
// lib
import { MutableRefObject, useEffect, useRef, useState } from 'react';
import Konva from 'konva';
import { Layer, Stage } from 'react-konva';
import { Image as KonvaImage } from 'react-konva';
import { Stage as StageType } from 'konva/lib/Stage';
// app
import { useAppDispatch, useAppSelector } from 'app/store';
import {
baseCanvasImageSelector,
clearImageToInpaint,
currentCanvasSelector,
outpaintingCanvasSelector,
} from 'features/canvas/canvasSlice';
// component
import IAICanvasMaskLines from './IAICanvasMaskLines';
import IAICanvasBrushPreview from './IAICanvasBrushPreview';
import { Vector2d } from 'konva/lib/types';
import IAICanvasBoundingBoxPreview from './IAICanvasBoundingBoxPreview';
import { useToast } from '@chakra-ui/react';
import useCanvasHotkeys from './hooks/useCanvasHotkeys';
import _ from 'lodash';
import { createSelector } from '@reduxjs/toolkit';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import IAICanvasMaskCompositer from './IAICanvasMaskCompositer';
import useCanvasWheel from './hooks/useCanvasZoom';
import useCanvasMouseDown from './hooks/useCanvasMouseDown';
import useCanvasMouseUp from './hooks/useCanvasMouseUp';
import useCanvasMouseMove from './hooks/useCanvasMouseMove';
import useCanvasMouseEnter from './hooks/useCanvasMouseEnter';
import useCanvasMouseOut from './hooks/useCanvasMouseOut';
import useCanvasDragMove from './hooks/useCanvasDragMove';
import IAICanvasOutpaintingObjects from './IAICanvasOutpaintingObjects';
import IAICanvasGrid from './IAICanvasGrid';
import IAICanvasIntermediateImage from './IAICanvasIntermediateImage';
import IAICanvasStatusText from './IAICanvasStatusText';
const canvasSelector = createSelector(
[
currentCanvasSelector,
outpaintingCanvasSelector,
baseCanvasImageSelector,
activeTabNameSelector,
],
(currentCanvas, outpaintingCanvas, baseCanvasImage, activeTabName) => {
const {
shouldInvertMask,
isMaskEnabled,
shouldShowCheckboardTransparency,
stageScale,
shouldShowBoundingBox,
shouldLockBoundingBox,
isTransformingBoundingBox,
isMouseOverBoundingBox,
isMovingBoundingBox,
stageDimensions,
stageCoordinates,
isMoveStageKeyHeld,
tool,
isMovingStage,
} = currentCanvas;
const { shouldShowGrid } = outpaintingCanvas;
let stageCursor: string | undefined = '';
if (tool === 'move') {
if (isTransformingBoundingBox) {
stageCursor = undefined;
} else if (isMouseOverBoundingBox) {
stageCursor = 'move';
} else {
if (isMovingStage) {
stageCursor = 'grabbing';
} else {
stageCursor = 'grab';
}
}
} else {
stageCursor = 'none';
}
return {
shouldInvertMask,
isMaskEnabled,
shouldShowCheckboardTransparency,
stageScale,
shouldShowBoundingBox,
shouldLockBoundingBox,
shouldShowGrid,
isTransformingBoundingBox,
isModifyingBoundingBox: isTransformingBoundingBox || isMovingBoundingBox,
stageCursor,
isMouseOverBoundingBox,
stageDimensions,
stageCoordinates,
isMoveStageKeyHeld,
activeTabName,
baseCanvasImage,
tool,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
// Use a closure allow other components to use these things... not ideal...
export let stageRef: MutableRefObject<StageType | null>;
export let canvasImageLayerRef: MutableRefObject<Konva.Layer | null>;
export let inpaintingImageElementRef: MutableRefObject<HTMLImageElement | null>;
const IAICanvas = () => {
const dispatch = useAppDispatch();
const {
shouldInvertMask,
isMaskEnabled,
shouldShowCheckboardTransparency,
stageScale,
shouldShowBoundingBox,
isModifyingBoundingBox,
stageCursor,
stageDimensions,
stageCoordinates,
shouldShowGrid,
activeTabName,
baseCanvasImage,
tool,
} = useAppSelector(canvasSelector);
useCanvasHotkeys();
const toast = useToast();
// set the closure'd refs
stageRef = useRef<StageType>(null);
canvasImageLayerRef = useRef<Konva.Layer>(null);
inpaintingImageElementRef = useRef<HTMLImageElement>(null);
const lastCursorPositionRef = useRef<Vector2d>({ x: 0, y: 0 });
// Use refs for values that do not affect rendering, other values in redux
const didMouseMoveRef = useRef<boolean>(false);
// Load the image into this
const [canvasBgImage, setCanvasBgImage] = useState<HTMLImageElement | null>(
null
);
const handleWheel = useCanvasWheel(stageRef);
const handleMouseDown = useCanvasMouseDown(stageRef);
const handleMouseUp = useCanvasMouseUp(stageRef, didMouseMoveRef);
const handleMouseMove = useCanvasMouseMove(
stageRef,
didMouseMoveRef,
lastCursorPositionRef
);
const handleMouseEnter = useCanvasMouseEnter(stageRef);
const handleMouseOut = useCanvasMouseOut();
const { handleDragStart, handleDragMove, handleDragEnd } =
useCanvasDragMove();
// Load the image and set the options panel width & height
useEffect(() => {
if (baseCanvasImage) {
const image = new Image();
image.onload = () => {
inpaintingImageElementRef.current = image;
setCanvasBgImage(image);
};
image.onerror = () => {
toast({
title: 'Unable to Load Image',
description: `Image ${baseCanvasImage.url} failed to load`,
status: 'error',
isClosable: true,
});
dispatch(clearImageToInpaint());
};
image.src = baseCanvasImage.url;
} else {
setCanvasBgImage(null);
}
}, [baseCanvasImage, dispatch, stageScale, toast]);
return (
<div className="inpainting-canvas-container">
<div className="inpainting-canvas-wrapper">
<Stage
ref={stageRef}
style={{ ...(stageCursor ? { cursor: stageCursor } : {}) }}
className="inpainting-canvas-stage checkerboard"
x={stageCoordinates.x}
y={stageCoordinates.y}
width={stageDimensions.width}
height={stageDimensions.height}
scale={{ x: stageScale, y: stageScale }}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseOut}
onMouseMove={handleMouseMove}
onMouseOut={handleMouseOut}
onMouseUp={handleMouseUp}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
onWheel={handleWheel}
listening={
tool === 'move' &&
!isModifyingBoundingBox &&
activeTabName === 'outpainting'
}
draggable={
tool === 'move' &&
!isModifyingBoundingBox &&
activeTabName === 'outpainting'
}
>
<Layer visible={shouldShowGrid}>
<IAICanvasGrid />
</Layer>
<Layer
id={'image-layer'}
ref={canvasImageLayerRef}
listening={false}
imageSmoothingEnabled={false}
>
<IAICanvasOutpaintingObjects />
<IAICanvasIntermediateImage />
</Layer>
<Layer id={'mask-layer'} visible={isMaskEnabled} listening={false}>
<IAICanvasMaskLines visible={true} listening={false} />
<IAICanvasMaskCompositer listening={false} />
{canvasBgImage && (
<>
<KonvaImage
image={canvasBgImage}
listening={false}
globalCompositeOperation="source-in"
visible={shouldInvertMask}
/>
<KonvaImage
image={canvasBgImage}
listening={false}
globalCompositeOperation="source-out"
visible={
!shouldInvertMask && shouldShowCheckboardTransparency
}
/>
</>
)}
</Layer>
<Layer id={'preview-layer'}>
<IAICanvasBoundingBoxPreview visible={shouldShowBoundingBox} />
<IAICanvasBrushPreview
visible={tool !== 'move'}
listening={false}
/>
</Layer>
</Stage>
<IAICanvasStatusText />
</div>
</div>
);
};
export default IAICanvas;

View File

@ -7,58 +7,60 @@ import { Vector2d } from 'konva/lib/types';
import _ from 'lodash';
import { useCallback, useEffect, useRef } from 'react';
import { Group, Rect, Transformer } from 'react-konva';
import { useAppDispatch, useAppSelector } from 'app/store';
import { roundToMultiple } from 'common/util/roundDownToMultiple';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../app/store';
import { roundToMultiple } from '../../../../common/util/roundDownToMultiple';
import {
InpaintingState,
setBoundingBoxCoordinate,
baseCanvasImageSelector,
currentCanvasSelector,
outpaintingCanvasSelector,
setBoundingBoxCoordinates,
setBoundingBoxDimensions,
setIsMouseOverBoundingBox,
setIsMovingBoundingBox,
setIsTransformingBoundingBox,
} from '../inpaintingSlice';
import { rgbaColorToString } from '../util/colorToString';
import {
DASH_WIDTH,
// MARCHING_ANTS_SPEED,
} from '../util/constants';
} from 'features/canvas/canvasSlice';
import { GroupConfig } from 'konva/lib/Group';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
const boundingBoxPreviewSelector = createSelector(
(state: RootState) => state.inpainting,
(inpainting: InpaintingState) => {
currentCanvasSelector,
outpaintingCanvasSelector,
baseCanvasImageSelector,
activeTabNameSelector,
(currentCanvas, outpaintingCanvas, baseCanvasImage, activeTabName) => {
const {
boundingBoxCoordinate,
boundingBoxCoordinates,
boundingBoxDimensions,
boundingBoxPreviewFill,
canvasDimensions,
stageDimensions,
stageScale,
imageToInpaint,
shouldLockBoundingBox,
isDrawing,
isTransformingBoundingBox,
isMovingBoundingBox,
isMouseOverBoundingBox,
isSpacebarHeld,
} = inpainting;
shouldDarkenOutsideBoundingBox,
tool,
stageCoordinates,
} = currentCanvas;
const { shouldSnapToGrid } = outpaintingCanvas;
return {
boundingBoxCoordinate,
boundingBoxCoordinates,
boundingBoxDimensions,
boundingBoxPreviewFillString: rgbaColorToString(boundingBoxPreviewFill),
canvasDimensions,
stageScale,
imageToInpaint,
dash: DASH_WIDTH / stageScale, // scale dash lengths
strokeWidth: 1 / stageScale, // scale stroke thickness
shouldLockBoundingBox,
isDrawing,
isTransformingBoundingBox,
isMouseOverBoundingBox,
shouldDarkenOutsideBoundingBox,
isMovingBoundingBox,
isSpacebarHeld,
isTransformingBoundingBox,
shouldLockBoundingBox,
stageDimensions,
stageScale,
baseCanvasImage,
activeTabName,
shouldSnapToGrid,
tool,
stageCoordinates,
boundingBoxStrokeWidth: (isMouseOverBoundingBox ? 8 : 1) / stageScale,
};
},
{
@ -68,52 +70,31 @@ const boundingBoxPreviewSelector = createSelector(
}
);
/**
* Shades the area around the mask.
*/
export const InpaintingBoundingBoxPreviewOverlay = () => {
const {
boundingBoxCoordinate,
boundingBoxDimensions,
boundingBoxPreviewFillString,
canvasDimensions,
} = useAppSelector(boundingBoxPreviewSelector);
type IAICanvasBoundingBoxPreviewProps = GroupConfig;
return (
<Group>
<Rect
x={0}
y={0}
height={canvasDimensions.height}
width={canvasDimensions.width}
fill={boundingBoxPreviewFillString}
/>
<Rect
x={boundingBoxCoordinate.x}
y={boundingBoxCoordinate.y}
width={boundingBoxDimensions.width}
height={boundingBoxDimensions.height}
fill={'rgb(255,255,255)'}
listening={false}
globalCompositeOperation={'destination-out'}
/>
</Group>
);
};
const IAICanvasBoundingBoxPreview = (
props: IAICanvasBoundingBoxPreviewProps
) => {
const { ...rest } = props;
const InpaintingBoundingBoxPreview = () => {
const dispatch = useAppDispatch();
const {
boundingBoxCoordinate,
boundingBoxCoordinates,
boundingBoxDimensions,
stageScale,
imageToInpaint,
shouldLockBoundingBox,
isDrawing,
isTransformingBoundingBox,
isMovingBoundingBox,
isMouseOverBoundingBox,
isSpacebarHeld,
shouldDarkenOutsideBoundingBox,
isMovingBoundingBox,
isTransformingBoundingBox,
shouldLockBoundingBox,
stageCoordinates,
stageDimensions,
stageScale,
baseCanvasImage,
activeTabName,
shouldSnapToGrid,
tool,
boundingBoxStrokeWidth,
} = useAppSelector(boundingBoxPreviewSelector);
const transformerRef = useRef<Konva.Transformer>(null);
@ -129,31 +110,60 @@ const InpaintingBoundingBoxPreview = () => {
const handleOnDragMove = useCallback(
(e: KonvaEventObject<DragEvent>) => {
if (activeTabName === 'inpainting' || !shouldSnapToGrid) {
dispatch(
setBoundingBoxCoordinates({
x: e.target.x(),
y: e.target.y(),
})
);
return;
}
const dragX = e.target.x();
const dragY = e.target.y();
const newX = roundToMultiple(dragX, 64);
const newY = roundToMultiple(dragY, 64);
e.target.x(newX);
e.target.y(newY);
dispatch(
setBoundingBoxCoordinate({
x: Math.floor(e.target.x()),
y: Math.floor(e.target.y()),
setBoundingBoxCoordinates({
x: newX,
y: newY,
})
);
},
[dispatch]
[activeTabName, dispatch, shouldSnapToGrid]
);
const dragBoundFunc = useCallback(
(position: Vector2d) => {
if (!imageToInpaint) return boundingBoxCoordinate;
if (!baseCanvasImage) return boundingBoxCoordinates;
const { x, y } = position;
const maxX = imageToInpaint.width - boundingBoxDimensions.width;
const maxY = imageToInpaint.height - boundingBoxDimensions.height;
const maxX =
stageDimensions.width - boundingBoxDimensions.width * stageScale;
const maxY =
stageDimensions.height - boundingBoxDimensions.height * stageScale;
const clampedX = Math.floor(_.clamp(x, 0, maxX * stageScale));
const clampedY = Math.floor(_.clamp(y, 0, maxY * stageScale));
const clampedX = Math.floor(_.clamp(x, 0, maxX));
const clampedY = Math.floor(_.clamp(y, 0, maxY));
return { x: clampedX, y: clampedY };
},
[boundingBoxCoordinate, boundingBoxDimensions, imageToInpaint, stageScale]
[
baseCanvasImage,
boundingBoxCoordinates,
stageDimensions.width,
stageDimensions.height,
boundingBoxDimensions.width,
boundingBoxDimensions.height,
stageScale,
]
);
const handleOnTransform = useCallback(() => {
@ -184,7 +194,7 @@ const InpaintingBoundingBoxPreview = () => {
);
dispatch(
setBoundingBoxCoordinate({
setBoundingBoxCoordinates({
x,
y,
})
@ -195,6 +205,7 @@ const InpaintingBoundingBoxPreview = () => {
rect.scaleY(1);
}, [dispatch]);
// OK
const anchorDragBoundFunc = useCallback(
(
oldPos: Vector2d, // old absolute position of anchor point
@ -253,6 +264,7 @@ const InpaintingBoundingBoxPreview = () => {
[scaledStep]
);
// OK
const boundBoxFunc = useCallback(
(oldBoundBox: Box, newBoundBox: Box) => {
/**
@ -260,12 +272,10 @@ const InpaintingBoundingBoxPreview = () => {
* Unlike anchorDragBoundFunc, it does get a width and height, so
* the logic to constrain the size of the bounding box is very simple.
*/
if (!imageToInpaint) return oldBoundBox;
if (!baseCanvasImage) return oldBoundBox;
if (
newBoundBox.width + newBoundBox.x > imageToInpaint.width * stageScale ||
newBoundBox.height + newBoundBox.y >
imageToInpaint.height * stageScale ||
newBoundBox.width + newBoundBox.x > stageDimensions.width ||
newBoundBox.height + newBoundBox.y > stageDimensions.height ||
newBoundBox.x < 0 ||
newBoundBox.y < 0
) {
@ -274,101 +284,107 @@ const InpaintingBoundingBoxPreview = () => {
return newBoundBox;
},
[imageToInpaint, stageScale]
[baseCanvasImage, stageDimensions]
);
const handleStartedTransforming = (e: KonvaEventObject<MouseEvent>) => {
e.cancelBubble = true;
e.evt.stopImmediatePropagation();
console.log("Started transform")
const handleStartedTransforming = () => {
dispatch(setIsTransformingBoundingBox(true));
};
const handleEndedTransforming = (e: KonvaEventObject<MouseEvent>) => {
const handleEndedTransforming = () => {
dispatch(setIsTransformingBoundingBox(false));
dispatch(setIsMouseOverBoundingBox(false));
};
const handleStartedMoving = (e: KonvaEventObject<MouseEvent>) => {
e.cancelBubble = true;
e.evt.stopImmediatePropagation();
const handleStartedMoving = () => {
dispatch(setIsMovingBoundingBox(true));
};
const handleEndedModifying = (e: KonvaEventObject<MouseEvent>) => {
const handleEndedModifying = () => {
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);
const handleMouseOver = () => {
dispatch(setIsMouseOverBoundingBox(true));
};
const handleMouseOut = () => {
!isTransformingBoundingBox &&
!isMovingBoundingBox &&
dispatch(setIsMouseOverBoundingBox(false));
};
return (
<>
<Group {...rest}>
<Rect
x={boundingBoxCoordinate.x}
y={boundingBoxCoordinate.y}
offsetX={stageCoordinates.x / stageScale}
offsetY={stageCoordinates.y / stageScale}
height={stageDimensions.height / stageScale}
width={stageDimensions.width / stageScale}
fill={'rgba(0,0,0,0.4)'}
listening={false}
visible={shouldDarkenOutsideBoundingBox}
/>
<Rect
x={boundingBoxCoordinates.x}
y={boundingBoxCoordinates.y}
width={boundingBoxDimensions.width}
height={boundingBoxDimensions.height}
ref={shapeRef}
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));
}}
onMouseOut={() => {
!isTransformingBoundingBox &&
!isMovingBoundingBox &&
dispatch(setIsMouseOverBoundingBox(false));
}}
onMouseDown={handleStartedMoving}
onMouseUp={handleEndedModifying}
fill={'rgb(255,255,255)'}
listening={false}
visible={shouldDarkenOutsideBoundingBox}
globalCompositeOperation={'destination-out'}
/>
<Rect
dragBoundFunc={
activeTabName === 'inpainting' ? dragBoundFunc : undefined
}
draggable={true}
onDragMove={handleOnDragMove}
dragBoundFunc={dragBoundFunc}
onTransform={handleOnTransform}
fillEnabled={tool === 'move'}
height={boundingBoxDimensions.height}
listening={!isDrawing && tool === 'move'}
onDragEnd={handleEndedModifying}
onDragMove={handleOnDragMove}
onMouseDown={handleStartedMoving}
onMouseOut={handleMouseOut}
onMouseOver={handleMouseOver}
onMouseUp={handleEndedModifying}
onTransform={handleOnTransform}
onTransformEnd={handleEndedTransforming}
ref={shapeRef}
stroke={isMouseOverBoundingBox ? 'rgba(255,255,255,0.7)' : 'white'}
strokeWidth={boundingBoxStrokeWidth}
width={boundingBoxDimensions.width}
x={boundingBoxCoordinates.x}
y={boundingBoxCoordinates.y}
/>
<Transformer
ref={transformerRef}
anchorCornerRadius={3}
anchorDragBoundFunc={anchorDragBoundFunc}
anchorFill={'rgba(212,216,234,1)'}
anchorSize={15}
anchorStroke={'rgb(42,42,42)'}
borderDash={[4, 4]}
borderStroke={'black'}
rotateEnabled={false}
borderEnabled={true}
borderStroke={'black'}
boundBoxFunc={boundBoxFunc}
draggable={false}
enabledAnchors={tool === 'move' ? undefined : []}
flipEnabled={false}
ignoreStroke={true}
keepRatio={false}
listening={!isDrawing && !shouldLockBoundingBox}
listening={!isDrawing && tool === 'move'}
onDragEnd={handleEndedModifying}
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));
}}
ref={transformerRef}
rotateEnabled={false}
/>
</>
</Group>
);
};
export default InpaintingBoundingBoxPreview;
export default IAICanvasBoundingBoxPreview;

View File

@ -0,0 +1,95 @@
import { createSelector } from '@reduxjs/toolkit';
import {
currentCanvasSelector,
outpaintingCanvasSelector,
setBrushColor,
setBrushSize,
setTool,
} from './canvasSlice';
import { useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import _ from 'lodash';
import IAIIconButton from 'common/components/IAIIconButton';
import { FaPaintBrush } from 'react-icons/fa';
import IAIPopover from 'common/components/IAIPopover';
import IAIColorPicker from 'common/components/IAIColorPicker';
import IAISlider from 'common/components/IAISlider';
import { Flex } from '@chakra-ui/react';
import IAINumberInput from 'common/components/IAINumberInput';
export const selector = createSelector(
[currentCanvasSelector, outpaintingCanvasSelector, activeTabNameSelector],
(currentCanvas, outpaintingCanvas, activeTabName) => {
const {
layer,
maskColor,
brushColor,
brushSize,
eraserSize,
tool,
shouldDarkenOutsideBoundingBox,
shouldShowIntermediates,
} = currentCanvas;
const { shouldShowGrid, shouldSnapToGrid, shouldAutoSave } =
outpaintingCanvas;
return {
layer,
tool,
maskColor,
brushColor,
brushSize,
eraserSize,
activeTabName,
shouldShowGrid,
shouldSnapToGrid,
shouldAutoSave,
shouldDarkenOutsideBoundingBox,
shouldShowIntermediates,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasBrushButtonPopover = () => {
const dispatch = useAppDispatch();
const { tool, brushColor, brushSize } = useAppSelector(selector);
return (
<IAIPopover
trigger="hover"
triggerComponent={
<IAIIconButton
aria-label="Brush (B)"
tooltip="Brush (B)"
icon={<FaPaintBrush />}
data-selected={tool === 'brush'}
onClick={() => dispatch(setTool('brush'))}
/>
}
>
<Flex minWidth={'15rem'} direction={'column'} gap={'1rem'} width={'100%'}>
<Flex gap={'1rem'} justifyContent="space-between">
<IAISlider
label="Size"
value={brushSize}
withInput
onChange={(newSize) => dispatch(setBrushSize(newSize))}
/>
</Flex>
<IAIColorPicker
style={{ width: '100%' }}
color={brushColor}
onChange={(newColor) => dispatch(setBrushColor(newColor))}
/>
</Flex>
</IAIPopover>
);
};
export default IAICanvasBrushButtonPopover;

View File

@ -0,0 +1,121 @@
import { createSelector } from '@reduxjs/toolkit';
import { GroupConfig } from 'konva/lib/Group';
import _ from 'lodash';
import { Circle, Group } from 'react-konva';
import { useAppSelector } from 'app/store';
import { currentCanvasSelector } from 'features/canvas/canvasSlice';
import { rgbaColorToString } from './util/colorToString';
const canvasBrushPreviewSelector = createSelector(
currentCanvasSelector,
(currentCanvas) => {
const {
cursorPosition,
stageDimensions: { width, height },
brushSize,
eraserSize,
maskColor,
brushColor,
tool,
layer,
shouldShowBrush,
isMovingBoundingBox,
isTransformingBoundingBox,
stageScale,
} = currentCanvas;
return {
cursorPosition,
width,
height,
radius: tool === 'brush' ? brushSize / 2 : eraserSize / 2,
brushColorString: rgbaColorToString(
layer === 'mask' ? maskColor : brushColor
),
tool,
shouldShowBrush,
shouldDrawBrushPreview:
!(
isMovingBoundingBox ||
isTransformingBoundingBox ||
!cursorPosition
) && shouldShowBrush,
strokeWidth: 1.5 / stageScale,
dotRadius: 1.5 / stageScale,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
/**
* Draws a black circle around the canvas brush preview.
*/
const IAICanvasBrushPreview = (props: GroupConfig) => {
const { ...rest } = props;
const {
cursorPosition,
width,
height,
radius,
brushColorString,
tool,
shouldDrawBrushPreview,
dotRadius,
strokeWidth,
} = useAppSelector(canvasBrushPreviewSelector);
if (!shouldDrawBrushPreview) return null;
return (
<Group listening={false} {...rest}>
<Circle
x={cursorPosition ? cursorPosition.x : width / 2}
y={cursorPosition ? cursorPosition.y : height / 2}
radius={radius}
fill={brushColorString}
listening={false}
globalCompositeOperation={
tool === 'eraser' ? 'destination-out' : 'source-over'
}
/>
<Circle
x={cursorPosition ? cursorPosition.x : width / 2}
y={cursorPosition ? cursorPosition.y : height / 2}
radius={radius}
stroke={'rgba(255,255,255,0.4)'}
strokeWidth={strokeWidth * 2}
strokeEnabled={true}
listening={false}
/>
<Circle
x={cursorPosition ? cursorPosition.x : width / 2}
y={cursorPosition ? cursorPosition.y : height / 2}
radius={radius}
stroke={'rgba(0,0,0,1)'}
strokeWidth={strokeWidth}
strokeEnabled={true}
listening={false}
/>
<Circle
x={cursorPosition ? cursorPosition.x : width / 2}
y={cursorPosition ? cursorPosition.y : height / 2}
radius={dotRadius * 2}
fill={'rgba(255,255,255,0.4)'}
listening={false}
/>
<Circle
x={cursorPosition ? cursorPosition.x : width / 2}
y={cursorPosition ? cursorPosition.y : height / 2}
radius={dotRadius}
fill={'rgba(0,0,0,1)'}
listening={false}
/>
</Group>
);
};
export default IAICanvasBrushPreview;

View File

@ -0,0 +1,100 @@
import IAICanvasBrushControl from './IAICanvasControls/IAICanvasBrushControl';
import IAICanvasEraserControl from './IAICanvasControls/IAICanvasEraserControl';
import IAICanvasUndoControl from './IAICanvasControls/IAICanvasUndoButton';
import IAICanvasRedoControl from './IAICanvasControls/IAICanvasRedoButton';
import { Button, ButtonGroup } from '@chakra-ui/react';
import IAICanvasMaskClear from './IAICanvasControls/IAICanvasMaskControls/IAICanvasMaskClear';
import IAICanvasMaskVisibilityControl from './IAICanvasControls/IAICanvasMaskControls/IAICanvasMaskVisibilityControl';
import IAICanvasMaskInvertControl from './IAICanvasControls/IAICanvasMaskControls/IAICanvasMaskInvertControl';
import IAICanvasLockBoundingBoxControl from './IAICanvasControls/IAICanvasLockBoundingBoxControl';
import IAICanvasShowHideBoundingBoxControl from './IAICanvasControls/IAICanvasShowHideBoundingBoxControl';
import ImageUploaderIconButton from 'common/components/ImageUploaderIconButton';
import { createSelector } from '@reduxjs/toolkit';
import {
currentCanvasSelector,
GenericCanvasState,
outpaintingCanvasSelector,
OutpaintingCanvasState,
uploadOutpaintingMergedImage,
} from './canvasSlice';
import { RootState, useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { OptionsState } from 'features/options/optionsSlice';
import _ from 'lodash';
import IAICanvasImageEraserControl from './IAICanvasControls/IAICanvasImageEraserControl';
import { canvasImageLayerRef } from './IAICanvas';
import { uploadImage } from 'app/socketio/actions';
import IAIIconButton from 'common/components/IAIIconButton';
import { FaSave } from 'react-icons/fa';
export const canvasControlsSelector = createSelector(
[
outpaintingCanvasSelector,
(state: RootState) => state.options,
activeTabNameSelector,
],
(
outpaintingCanvas: OutpaintingCanvasState,
options: OptionsState,
activeTabName
) => {
const { stageScale, boundingBoxCoordinates, boundingBoxDimensions } =
outpaintingCanvas;
return {
activeTabName,
stageScale,
boundingBoxCoordinates,
boundingBoxDimensions,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasControls = () => {
const dispatch = useAppDispatch();
const {
activeTabName,
boundingBoxCoordinates,
boundingBoxDimensions,
stageScale,
} = useAppSelector(canvasControlsSelector);
return (
<div className="inpainting-settings">
<ButtonGroup isAttached={true}>
<IAICanvasBrushControl />
<IAICanvasEraserControl />
{activeTabName === 'outpainting' && <IAICanvasImageEraserControl />}
</ButtonGroup>
<ButtonGroup isAttached={true}>
<IAICanvasMaskVisibilityControl />
<IAICanvasMaskInvertControl />
<IAICanvasLockBoundingBoxControl />
<IAICanvasShowHideBoundingBoxControl />
<IAICanvasMaskClear />
</ButtonGroup>
<IAIIconButton
aria-label="Save"
tooltip="Save"
icon={<FaSave />}
onClick={() => {
dispatch(uploadOutpaintingMergedImage(canvasImageLayerRef));
}}
fontSize={20}
/>
<ButtonGroup isAttached={true}>
<IAICanvasUndoControl />
<IAICanvasRedoControl />
</ButtonGroup>
<ButtonGroup isAttached={true}>
<ImageUploaderIconButton />
</ButtonGroup>
</div>
);
};
export default IAICanvasControls;

View File

@ -1,37 +1,31 @@
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 { 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 'features/options/optionsSelectors';
import {
InpaintingState,
currentCanvasSelector,
setBrushSize,
setShouldShowBrushPreview,
setTool,
} from '../inpaintingSlice';
} from 'features/canvas/canvasSlice';
import _ from 'lodash';
import InpaintingMaskColorPicker from './InpaintingMaskControls/InpaintingMaskColorPicker';
import IAICanvasMaskColorPicker from './IAICanvasMaskControls/IAICanvasMaskColorPicker';
const inpaintingBrushSelector = createSelector(
[(state: RootState) => state.inpainting, activeTabNameSelector],
(inpainting: InpaintingState, activeTabName) => {
const { tool, brushSize, shouldShowMask } = inpainting;
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas, activeTabName) => {
const { tool, brushSize } = currentCanvas;
return {
tool,
brushSize,
shouldShowMask,
activeTabName,
};
},
@ -42,9 +36,9 @@ const inpaintingBrushSelector = createSelector(
}
);
export default function InpaintingBrushControl() {
export default function IAICanvasBrushControl() {
const dispatch = useAppDispatch();
const { tool, brushSize, shouldShowMask, activeTabName } = useAppSelector(
const { tool, brushSize, activeTabName } = useAppSelector(
inpaintingBrushSelector
);
@ -63,9 +57,6 @@ export default function InpaintingBrushControl() {
dispatch(setBrushSize(v));
};
// Hotkeys
// Decrease brush size
useHotkeys(
'[',
(e: KeyboardEvent) => {
@ -77,9 +68,9 @@ export default function InpaintingBrushControl() {
}
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
enabled: true,
},
[activeTabName, shouldShowMask, brushSize]
[activeTabName, brushSize]
);
// Increase brush size
@ -90,9 +81,9 @@ export default function InpaintingBrushControl() {
handleChangeBrushSize(brushSize + 5);
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
enabled: true,
},
[activeTabName, shouldShowMask, brushSize]
[activeTabName, brushSize]
);
// Set tool to brush
@ -103,9 +94,9 @@ export default function InpaintingBrushControl() {
handleSelectBrushTool();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
enabled: true,
},
[activeTabName, shouldShowMask]
[activeTabName]
);
return (
@ -120,7 +111,6 @@ export default function InpaintingBrushControl() {
icon={<FaPaintBrush />}
onClick={handleSelectBrushTool}
data-selected={tool === 'brush'}
isDisabled={!shouldShowMask}
/>
}
>
@ -131,9 +121,6 @@ export default function InpaintingBrushControl() {
onChange={handleChangeBrushSize}
min={1}
max={200}
width="100px"
focusThumbOnChange={false}
isDisabled={!shouldShowMask}
/>
<IAINumberInput
value={brushSize}
@ -141,9 +128,8 @@ export default function InpaintingBrushControl() {
width={'80px'}
min={1}
max={999}
isDisabled={!shouldShowMask}
/>
<InpaintingMaskColorPicker />
<IAICanvasMaskColorPicker />
</div>
</IAIPopover>
);

View File

@ -1,10 +1,9 @@
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';
import { useAppDispatch } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import { clearImageToInpaint } from 'features/canvas/canvasSlice';
export default function InpaintingClearImageControl() {
export default function IAICanvasClearImageControl() {
const dispatch = useAppDispatch();
const handleClearImage = () => {

View File

@ -0,0 +1,61 @@
import { createSelector } from '@reduxjs/toolkit';
import { useHotkeys } from 'react-hotkeys-hook';
import { FaEraser } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import { currentCanvasSelector, setTool } from 'features/canvas/canvasSlice';
import _ from 'lodash';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
const eraserSelector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas, activeTabName) => {
const { tool, isMaskEnabled } = currentCanvas;
return {
tool,
isMaskEnabled,
activeTabName,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function IAICanvasEraserControl() {
const { tool, isMaskEnabled, activeTabName } = useAppSelector(eraserSelector);
const dispatch = useAppDispatch();
const handleSelectEraserTool = () => dispatch(setTool('eraser'));
// Hotkeys
// Set tool to maskEraser
useHotkeys(
'e',
(e: KeyboardEvent) => {
e.preventDefault();
handleSelectEraserTool();
},
{
enabled: true,
},
[activeTabName]
);
return (
<IAIIconButton
aria-label={
activeTabName === 'inpainting' ? 'Eraser (E)' : 'Erase Mask (E)'
}
tooltip={activeTabName === 'inpainting' ? 'Eraser (E)' : 'Erase Mask (E)'}
icon={<FaEraser />}
onClick={handleSelectEraserTool}
data-selected={tool === 'eraser'}
isDisabled={!isMaskEnabled}
/>
);
}

View File

@ -0,0 +1,60 @@
import { createSelector } from '@reduxjs/toolkit';
import { useHotkeys } from 'react-hotkeys-hook';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import { currentCanvasSelector, setTool } from 'features/canvas/canvasSlice';
import _ from 'lodash';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { BsEraser } from 'react-icons/bs';
const imageEraserSelector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas, activeTabName) => {
const { tool, isMaskEnabled } = currentCanvas;
return {
tool,
isMaskEnabled,
activeTabName,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function IAICanvasImageEraserControl() {
const { tool, isMaskEnabled, activeTabName } =
useAppSelector(imageEraserSelector);
const dispatch = useAppDispatch();
const handleSelectEraserTool = () => dispatch(setTool('eraser'));
// Hotkeys
useHotkeys(
'shift+e',
(e: KeyboardEvent) => {
e.preventDefault();
handleSelectEraserTool();
},
{
enabled: true,
},
[activeTabName, isMaskEnabled]
);
return (
<IAIIconButton
aria-label="Erase Canvas (Shift+E)"
tooltip="Erase Canvas (Shift+E)"
icon={<BsEraser />}
fontSize={18}
onClick={handleSelectEraserTool}
data-selected={tool === 'eraser'}
isDisabled={!isMaskEnabled}
/>
);
}

View File

@ -0,0 +1,47 @@
import { FaLock, FaUnlock } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import {
currentCanvasSelector,
GenericCanvasState,
setShouldLockBoundingBox,
} from 'features/canvas/canvasSlice';
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
const canvasLockBoundingBoxSelector = createSelector(
currentCanvasSelector,
(currentCanvas: GenericCanvasState) => {
const { shouldLockBoundingBox } = currentCanvas;
return {
shouldLockBoundingBox,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasLockBoundingBoxControl = () => {
const dispatch = useAppDispatch();
const { shouldLockBoundingBox } = useAppSelector(
canvasLockBoundingBoxSelector
);
return (
<IAIIconButton
aria-label="Lock Inpainting Box"
tooltip="Lock Inpainting Box"
icon={shouldLockBoundingBox ? <FaLock /> : <FaUnlock />}
data-selected={shouldLockBoundingBox}
onClick={() => {
dispatch(setShouldLockBoundingBox(!shouldLockBoundingBox));
}}
/>
);
};
export default IAICanvasLockBoundingBoxControl;

View File

@ -0,0 +1,38 @@
import { useState } from 'react';
import { FaMask } from 'react-icons/fa';
import IAIPopover from 'common/components/IAIPopover';
import IAIIconButton from 'common/components/IAIIconButton';
import IAICanvasMaskInvertControl from './IAICanvasMaskControls/IAICanvasMaskInvertControl';
import IAICanvasMaskVisibilityControl from './IAICanvasMaskControls/IAICanvasMaskVisibilityControl';
import IAICanvasMaskColorPicker from './IAICanvasMaskControls/IAICanvasMaskColorPicker';
export default function IAICanvasMaskControl() {
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">
<IAICanvasMaskVisibilityControl />
<IAICanvasMaskInvertControl />
<IAICanvasMaskColorPicker />
</div>
</IAIPopover>
</>
);
}

View File

@ -0,0 +1,75 @@
import { RgbaColor } from 'react-colorful';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIColorPicker from 'common/components/IAIColorPicker';
import {
currentCanvasSelector,
GenericCanvasState,
setMaskColor,
} from 'features/canvas/canvasSlice';
import _ from 'lodash';
import { createSelector } from '@reduxjs/toolkit';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { useHotkeys } from 'react-hotkeys-hook';
const selector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas: GenericCanvasState, activeTabName) => {
const { brushColor } = currentCanvas;
return {
brushColor,
activeTabName,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function IAICanvasBrushColorPicker() {
const dispatch = useAppDispatch();
const { brushColor, activeTabName } = useAppSelector(selector);
const handleChangeBrushColor = (newColor: RgbaColor) => {
dispatch(setMaskColor(newColor));
};
// Decrease brush opacity
useHotkeys(
'shift+[',
(e: KeyboardEvent) => {
e.preventDefault();
handleChangeBrushColor({
...brushColor,
a: Math.max(brushColor.a - 0.05, 0),
});
},
{
enabled: true,
},
[activeTabName, brushColor.a]
);
// Increase brush opacity
useHotkeys(
'shift+]',
(e: KeyboardEvent) => {
e.preventDefault();
handleChangeBrushColor({
...brushColor,
a: Math.min(brushColor.a + 0.05, 1),
});
},
{
enabled: true,
},
[activeTabName, brushColor.a]
);
return (
<IAIColorPicker color={brushColor} onChange={handleChangeBrushColor} />
);
}

View File

@ -0,0 +1,77 @@
import { createSelector } from '@reduxjs/toolkit';
import { FaPlus } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import {
clearMask,
currentCanvasSelector,
InpaintingCanvasState,
isCanvasMaskLine,
OutpaintingCanvasState,
} from 'features/canvas/canvasSlice';
import _ from 'lodash';
import { useHotkeys } from 'react-hotkeys-hook';
import { useToast } from '@chakra-ui/react';
const canvasMaskClearSelector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas, activeTabName) => {
const { isMaskEnabled, objects } = currentCanvas as
| InpaintingCanvasState
| OutpaintingCanvasState;
return {
isMaskEnabled,
activeTabName,
isMaskEmpty: objects.filter(isCanvasMaskLine).length === 0,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function IAICanvasMaskClear() {
const { isMaskEnabled, activeTabName, isMaskEmpty } = useAppSelector(
canvasMaskClearSelector
);
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: !isMaskEmpty,
},
[activeTabName, isMaskEmpty, isMaskEnabled]
);
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 || !isMaskEnabled}
/>
);
}

View File

@ -1,27 +1,31 @@
import React from 'react';
import { RgbaColor } from 'react-colorful';
import { FaPalette } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIColorPicker from 'common/components/IAIColorPicker';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
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';
currentCanvasSelector,
GenericCanvasState,
setMaskColor,
} from 'features/canvas/canvasSlice';
import _ from 'lodash';
import { createSelector } from '@reduxjs/toolkit';
import { activeTabNameSelector } from '../../../../options/optionsSelectors';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { useHotkeys } from 'react-hotkeys-hook';
const inpaintingMaskColorPickerSelector = createSelector(
[(state: RootState) => state.inpainting, activeTabNameSelector],
(inpainting: InpaintingState, activeTabName) => {
const { shouldShowMask, maskColor } = inpainting;
const maskColorPickerSelector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas: GenericCanvasState, activeTabName) => {
const { isMaskEnabled, maskColor } = currentCanvas;
return { shouldShowMask, maskColor, activeTabName };
return {
isMaskEnabled,
maskColor,
activeTabName,
};
},
{
memoizeOptions: {
@ -30,9 +34,9 @@ const inpaintingMaskColorPickerSelector = createSelector(
}
);
export default function InpaintingMaskColorPicker() {
const { shouldShowMask, maskColor, activeTabName } = useAppSelector(
inpaintingMaskColorPickerSelector
export default function IAICanvasMaskColorPicker() {
const { isMaskEnabled, maskColor, activeTabName } = useAppSelector(
maskColorPickerSelector
);
const dispatch = useAppDispatch();
const handleChangeMaskColor = (newColor: RgbaColor) => {
@ -51,9 +55,9 @@ export default function InpaintingMaskColorPicker() {
});
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
enabled: true,
},
[activeTabName, shouldShowMask, maskColor.a]
[activeTabName, isMaskEnabled, maskColor.a]
);
// Increase mask opacity
@ -63,13 +67,13 @@ export default function InpaintingMaskColorPicker() {
e.preventDefault();
handleChangeMaskColor({
...maskColor,
a: Math.min(maskColor.a + 0.05, 100),
a: Math.min(maskColor.a + 0.05, 1),
});
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
enabled: true,
},
[activeTabName, shouldShowMask, maskColor.a]
[activeTabName, isMaskEnabled, maskColor.a]
);
return (
@ -80,7 +84,7 @@ export default function InpaintingMaskColorPicker() {
<IAIIconButton
aria-label="Mask Color"
icon={<FaPalette />}
isDisabled={!shouldShowMask}
isDisabled={!isMaskEnabled}
cursor={'pointer'}
/>
}

View File

@ -1,24 +1,27 @@
import { createSelector } from '@reduxjs/toolkit';
import React from 'react';
import { MdInvertColors, MdInvertColorsOff } from 'react-icons/md';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import {
RootState,
useAppDispatch,
useAppSelector,
} from '../../../../../app/store';
import IAIIconButton from '../../../../../common/components/IAIIconButton';
import { InpaintingState, setShouldInvertMask } from '../../inpaintingSlice';
currentCanvasSelector,
GenericCanvasState,
setShouldInvertMask,
} from 'features/canvas/canvasSlice';
import _ from 'lodash';
import { activeTabNameSelector } from '../../../../options/optionsSelectors';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { useHotkeys } from 'react-hotkeys-hook';
const inpaintingMaskInvertSelector = createSelector(
[(state: RootState) => state.inpainting, activeTabNameSelector],
(inpainting: InpaintingState, activeTabName) => {
const { shouldShowMask, shouldInvertMask } = inpainting;
const canvasMaskInvertSelector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas: GenericCanvasState, activeTabName) => {
const { isMaskEnabled, shouldInvertMask } = currentCanvas;
return { shouldInvertMask, shouldShowMask, activeTabName };
return {
shouldInvertMask,
isMaskEnabled,
activeTabName,
};
},
{
memoizeOptions: {
@ -27,9 +30,9 @@ const inpaintingMaskInvertSelector = createSelector(
}
);
export default function InpaintingMaskInvertControl() {
const { shouldInvertMask, shouldShowMask, activeTabName } = useAppSelector(
inpaintingMaskInvertSelector
export default function IAICanvasMaskInvertControl() {
const { shouldInvertMask, isMaskEnabled, activeTabName } = useAppSelector(
canvasMaskInvertSelector
);
const dispatch = useAppDispatch();
@ -44,9 +47,9 @@ export default function InpaintingMaskInvertControl() {
handleToggleShouldInvertMask();
},
{
enabled: activeTabName === 'inpainting' && shouldShowMask,
enabled: true,
},
[activeTabName, shouldInvertMask, shouldShowMask]
[activeTabName, shouldInvertMask, isMaskEnabled]
);
return (
@ -62,7 +65,7 @@ export default function InpaintingMaskInvertControl() {
)
}
onClick={handleToggleShouldInvertMask}
isDisabled={!shouldShowMask}
isDisabled={!isMaskEnabled}
/>
);
}

View File

@ -0,0 +1,60 @@
import { useHotkeys } from 'react-hotkeys-hook';
import { BiHide, BiShow } from 'react-icons/bi';
import { createSelector } from 'reselect';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import {
currentCanvasSelector,
GenericCanvasState,
setIsMaskEnabled,
} from 'features/canvas/canvasSlice';
import _ from 'lodash';
const canvasMaskVisibilitySelector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas: GenericCanvasState, activeTabName) => {
const { isMaskEnabled } = currentCanvas;
return { isMaskEnabled, activeTabName };
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function IAICanvasMaskVisibilityControl() {
const dispatch = useAppDispatch();
const { isMaskEnabled, activeTabName } = useAppSelector(
canvasMaskVisibilitySelector
);
const handleToggleShouldShowMask = () =>
dispatch(setIsMaskEnabled(!isMaskEnabled));
// Hotkeys
// Show/hide mask
useHotkeys(
'h',
(e: KeyboardEvent) => {
e.preventDefault();
handleToggleShouldShowMask();
},
{
enabled: activeTabName === 'inpainting' || activeTabName == 'outpainting',
},
[activeTabName, isMaskEnabled]
);
return (
<IAIIconButton
aria-label="Hide Mask (H)"
tooltip="Hide Mask (H)"
data-alert={!isMaskEnabled}
icon={isMaskEnabled ? <BiShow size={22} /> : <BiHide size={22} />}
onClick={handleToggleShouldShowMask}
/>
);
}

View File

@ -0,0 +1,57 @@
import { createSelector } from '@reduxjs/toolkit';
import { useHotkeys } from 'react-hotkeys-hook';
import { FaRedo } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { currentCanvasSelector, redo } from 'features/canvas/canvasSlice';
import _ from 'lodash';
const canvasRedoSelector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(currentCanvas, activeTabName) => {
const { futureObjects } = currentCanvas;
return {
canRedo: futureObjects.length > 0,
activeTabName,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function IAICanvasRedoButton() {
const dispatch = useAppDispatch();
const { canRedo, activeTabName } = useAppSelector(canvasRedoSelector);
const handleRedo = () => {
dispatch(redo());
};
useHotkeys(
['meta+shift+z', 'control+shift+z', 'control+y', 'meta+y'],
(e: KeyboardEvent) => {
e.preventDefault();
handleRedo();
},
{
enabled: canRedo,
},
[activeTabName, canRedo]
);
return (
<IAIIconButton
aria-label="Redo"
tooltip="Redo"
icon={<FaRedo />}
onClick={handleRedo}
isDisabled={!canRedo}
/>
);
}

View File

@ -0,0 +1,46 @@
import { FaVectorSquare } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import {
currentCanvasSelector,
GenericCanvasState,
setShouldShowBoundingBox,
} from 'features/canvas/canvasSlice';
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
const canvasShowHideBoundingBoxControlSelector = createSelector(
currentCanvasSelector,
(currentCanvas: GenericCanvasState) => {
const { shouldShowBoundingBox } = currentCanvas;
return {
shouldShowBoundingBox,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasShowHideBoundingBoxControl = () => {
const dispatch = useAppDispatch();
const { shouldShowBoundingBox } = useAppSelector(
canvasShowHideBoundingBoxControlSelector
);
return (
<IAIIconButton
aria-label="Hide Inpainting Box (Shift+H)"
tooltip="Hide Inpainting Box (Shift+H)"
icon={<FaVectorSquare />}
data-alert={!shouldShowBoundingBox}
onClick={() => {
dispatch(setShouldShowBoundingBox(!shouldShowBoundingBox));
}}
/>
);
};
export default IAICanvasShowHideBoundingBoxControl;

View File

@ -1,16 +1,11 @@
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';
import { RootState, useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import { setShowDualDisplay } from 'features/options/optionsSlice';
import { setDoesCanvasNeedScaling } from 'features/canvas/canvasSlice';
export default function InpaintingSplitLayoutControl() {
export default function IAICanvasSplitLayoutControl() {
const dispatch = useAppDispatch();
const showDualDisplay = useAppSelector(
(state: RootState) => state.options.showDualDisplay
@ -18,7 +13,7 @@ export default function InpaintingSplitLayoutControl() {
const handleDualDisplay = () => {
dispatch(setShowDualDisplay(!showDualDisplay));
dispatch(setNeedsCache(true));
dispatch(setDoesCanvasNeedScaling(true));
};
// Hotkeys

View File

@ -0,0 +1,58 @@
import { createSelector } from '@reduxjs/toolkit';
import { useHotkeys } from 'react-hotkeys-hook';
import { FaUndo } from 'react-icons/fa';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import { currentCanvasSelector, undo } from 'features/canvas/canvasSlice';
import _ from 'lodash';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
const canvasUndoSelector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(canvas, activeTabName) => {
const { pastObjects } = canvas;
return {
canUndo: pastObjects.length > 0,
activeTabName,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
export default function IAICanvasUndoButton() {
const dispatch = useAppDispatch();
const { canUndo, activeTabName } = useAppSelector(canvasUndoSelector);
const handleUndo = () => {
dispatch(undo());
};
useHotkeys(
['meta+z', 'control+z'],
(e: KeyboardEvent) => {
e.preventDefault();
handleUndo();
},
{
enabled: canUndo,
},
[activeTabName, canUndo]
);
return (
<IAIIconButton
aria-label="Undo"
tooltip="Undo"
icon={<FaUndo />}
onClick={handleUndo}
isDisabled={!canUndo}
/>
);
}

View File

@ -0,0 +1,71 @@
import { createSelector } from '@reduxjs/toolkit';
import { currentCanvasSelector, setEraserSize, setTool } from './canvasSlice';
import { useAppDispatch, useAppSelector } from 'app/store';
import _ from 'lodash';
import IAIIconButton from 'common/components/IAIIconButton';
import { FaEraser } from 'react-icons/fa';
import IAIPopover from 'common/components/IAIPopover';
import IAISlider from 'common/components/IAISlider';
import { Flex } from '@chakra-ui/react';
import { useHotkeys } from 'react-hotkeys-hook';
export const selector = createSelector(
[currentCanvasSelector],
(currentCanvas) => {
const { eraserSize, tool } = currentCanvas;
return {
tool,
eraserSize,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasEraserButtonPopover = () => {
const dispatch = useAppDispatch();
const { tool, eraserSize } = useAppSelector(selector);
const handleSelectEraserTool = () => dispatch(setTool('eraser'));
useHotkeys(
'e',
(e: KeyboardEvent) => {
e.preventDefault();
handleSelectEraserTool();
},
{
enabled: true,
},
[tool]
);
return (
<IAIPopover
trigger="hover"
triggerComponent={
<IAIIconButton
aria-label="Eraser (E)"
tooltip="Eraser (E)"
icon={<FaEraser />}
data-selected={tool === 'eraser'}
onClick={() => dispatch(setTool('eraser'))}
/>
}
>
<Flex minWidth={'15rem'} direction={'column'} gap={'1rem'}>
<IAISlider
label="Size"
value={eraserSize}
withInput
onChange={(newSize) => dispatch(setEraserSize(newSize))}
/>
</Flex>
</IAIPopover>
);
};
export default IAICanvasEraserButtonPopover;

View File

@ -0,0 +1,49 @@
// import { GroupConfig } from 'konva/lib/Group';
// import { Group, Line } from 'react-konva';
// import { RootState, useAppSelector } from 'app/store';
// import { createSelector } from '@reduxjs/toolkit';
// import { OutpaintingCanvasState } from './canvasSlice';
// export const canvasEraserLinesSelector = createSelector(
// (state: RootState) => state.canvas.outpainting,
// (outpainting: OutpaintingCanvasState) => {
// const { eraserLines } = outpainting;
// return {
// eraserLines,
// };
// }
// );
// type IAICanvasEraserLinesProps = GroupConfig;
// /**
// * Draws the lines which comprise the mask.
// *
// * Uses globalCompositeOperation to handle the brush and eraser tools.
// */
// const IAICanvasEraserLines = (props: IAICanvasEraserLinesProps) => {
// const { ...rest } = props;
// const { eraserLines } = useAppSelector(canvasEraserLinesSelector);
// return (
// <Group {...rest} globalCompositeOperation={'destination-out'}>
// {eraserLines.map((line, i) => (
// <Line
// key={i}
// points={line.points}
// stroke={'rgb(0,0,0)'} // The lines can be any color, just need alpha > 0
// strokeWidth={line.strokeWidth * 2}
// tension={0}
// lineCap="round"
// lineJoin="round"
// shadowForStrokeEnabled={false}
// listening={false}
// globalCompositeOperation={'source-over'}
// />
// ))}
// </Group>
// );
// };
// export default IAICanvasEraserLines;
export default {}

View File

@ -0,0 +1,88 @@
// Grid drawing adapted from https://longviewcoder.com/2021/12/08/konva-a-better-grid/
import { useColorMode } from '@chakra-ui/react';
import _ from 'lodash';
import { Group, Line as KonvaLine } from 'react-konva';
import useUnscaleCanvasValue from './hooks/useUnscaleCanvasValue';
import { stageRef } from './IAICanvas';
const IAICanvasGrid = () => {
const { colorMode } = useColorMode();
const unscale = useUnscaleCanvasValue();
if (!stageRef.current) return null;
const gridLineColor =
colorMode === 'light' ? 'rgba(0,0,0,0.3)' : 'rgba(255,255,255,0.3)';
const stage = stageRef.current;
const width = stage.width();
const height = stage.height();
const x = stage.x();
const y = stage.y();
const stageRect = {
x1: 0,
y1: 0,
x2: width,
y2: height,
offset: {
x: unscale(x),
y: unscale(y),
},
};
const gridOffset = {
x: Math.ceil(unscale(x) / 64) * 64,
y: Math.ceil(unscale(y) / 64) * 64,
};
const gridRect = {
x1: -gridOffset.x,
y1: -gridOffset.y,
x2: unscale(width) - gridOffset.x + 64,
y2: unscale(height) - gridOffset.y + 64,
};
const gridFullRect = {
x1: Math.min(stageRect.x1, gridRect.x1),
y1: Math.min(stageRect.y1, gridRect.y1),
x2: Math.max(stageRect.x2, gridRect.x2),
y2: Math.max(stageRect.y2, gridRect.y2),
};
const fullRect = gridFullRect;
const // find the x & y size of the grid
xSize = fullRect.x2 - fullRect.x1,
ySize = fullRect.y2 - fullRect.y1,
// compute the number of steps required on each axis.
xSteps = Math.round(xSize / 64) + 1,
ySteps = Math.round(ySize / 64) + 1;
return (
<Group>
{_.range(0, xSteps).map((i) => (
<KonvaLine
key={`x_${i}`}
x={fullRect.x1 + i * 64}
y={fullRect.y1}
points={[0, 0, 0, ySize]}
stroke={gridLineColor}
strokeWidth={1}
/>
))}
{_.range(0, ySteps).map((i) => (
<KonvaLine
key={`y_${i}`}
x={fullRect.x1}
y={fullRect.y1 + i * 64}
points={[0, 0, xSize, 0]}
stroke={gridLineColor}
strokeWidth={1}
/>
))}
</Group>
);
};
export default IAICanvasGrid;

View File

@ -0,0 +1,15 @@
import { Image } from 'react-konva';
import useImage from 'use-image';
type IAICanvasImageProps = {
url: string;
x: number;
y: number;
};
const IAICanvasImage = (props: IAICanvasImageProps) => {
const { url, x, y } = props;
const [image] = useImage(url);
return <Image x={x} y={y} image={image} listening={false} />;
};
export default IAICanvasImage;

View File

@ -0,0 +1,59 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState, useAppSelector } from 'app/store';
import { GalleryState } from 'features/gallery/gallerySlice';
import { ImageConfig } from 'konva/lib/shapes/Image';
import _ from 'lodash';
import { useEffect, useState } from 'react';
import { Image as KonvaImage } from 'react-konva';
const selector = createSelector(
[(state: RootState) => state.gallery],
(gallery: GalleryState) => {
return gallery.intermediateImage ? gallery.intermediateImage : null;
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
type Props = Omit<ImageConfig, 'image'>;
const IAICanvasIntermediateImage = (props: Props) => {
const { ...rest } = props;
const intermediateImage = useAppSelector(selector);
const [loadedImageElement, setLoadedImageElement] =
useState<HTMLImageElement | null>(null);
useEffect(() => {
if (!intermediateImage) return;
const tempImage = new Image();
tempImage.onload = () => {
setLoadedImageElement(tempImage);
};
tempImage.src = intermediateImage.url;
}, [intermediateImage]);
if (!intermediateImage?.boundingBox) return null;
const {
boundingBox: { x, y, width, height },
} = intermediateImage;
return loadedImageElement ? (
<KonvaImage
x={x}
y={y}
width={width}
height={height}
image={loadedImageElement}
listening={false}
{...rest}
/>
) : null;
};
export default IAICanvasIntermediateImage;

View File

@ -0,0 +1,69 @@
import { Button, Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import {
clearMask,
currentCanvasSelector,
setIsMaskEnabled,
setLayer,
setMaskColor,
} from './canvasSlice';
import { useAppDispatch, useAppSelector } from 'app/store';
import _ from 'lodash';
import IAIIconButton from 'common/components/IAIIconButton';
import { FaMask } from 'react-icons/fa';
import IAIPopover from 'common/components/IAIPopover';
import IAICheckbox from 'common/components/IAICheckbox';
import IAIColorPicker from 'common/components/IAIColorPicker';
export const selector = createSelector(
[currentCanvasSelector],
(currentCanvas) => {
const { maskColor, layer, isMaskEnabled } = currentCanvas;
return {
layer,
maskColor,
isMaskEnabled,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasMaskButtonPopover = () => {
const dispatch = useAppDispatch();
const { layer, maskColor, isMaskEnabled } = useAppSelector(selector);
return (
<IAIPopover
trigger="hover"
triggerComponent={
<IAIIconButton
aria-label="Select Mask Layer"
tooltip="Select Mask Layer"
data-alert={layer === 'mask'}
onClick={() => dispatch(setLayer(layer === 'mask' ? 'base' : 'mask'))}
icon={<FaMask />}
/>
}
>
<Flex direction={'column'} gap={'0.5rem'}>
<Button onClick={() => dispatch(clearMask())}>Clear Mask</Button>
<IAICheckbox
label="Enable Mask"
isChecked={isMaskEnabled}
onChange={(e) => dispatch(setIsMaskEnabled(e.target.checked))}
/>
<IAICheckbox label="Invert Mask" />
<IAIColorPicker
color={maskColor}
onChange={(newColor) => dispatch(setMaskColor(newColor))}
/>
</Flex>
</IAIPopover>
);
};
export default IAICanvasMaskButtonPopover;

View File

@ -0,0 +1,49 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store';
import { RectConfig } from 'konva/lib/shapes/Rect';
import { Rect } from 'react-konva';
import {
currentCanvasSelector,
InpaintingCanvasState,
OutpaintingCanvasState,
} from './canvasSlice';
import { rgbaColorToString } from './util/colorToString';
export const canvasMaskCompositerSelector = createSelector(
currentCanvasSelector,
(currentCanvas) => {
const { maskColor, stageCoordinates, stageDimensions, stageScale } =
currentCanvas as InpaintingCanvasState | OutpaintingCanvasState;
return {
stageCoordinates,
stageDimensions,
stageScale,
maskColorString: rgbaColorToString(maskColor),
};
}
);
type IAICanvasMaskCompositerProps = RectConfig;
const IAICanvasMaskCompositer = (props: IAICanvasMaskCompositerProps) => {
const { ...rest } = props;
const { maskColorString, stageCoordinates, stageDimensions, stageScale } =
useAppSelector(canvasMaskCompositerSelector);
return (
<Rect
offsetX={stageCoordinates.x / stageScale}
offsetY={stageCoordinates.y / stageScale}
height={stageDimensions.height / stageScale}
width={stageDimensions.width / stageScale}
fill={maskColorString}
globalCompositeOperation={'source-in'}
listening={false}
{...rest}
/>
);
};
export default IAICanvasMaskCompositer;

View File

@ -0,0 +1,64 @@
import { GroupConfig } from 'konva/lib/Group';
import { Group, Line } from 'react-konva';
import { useAppSelector } from 'app/store';
import { createSelector } from '@reduxjs/toolkit';
import {
currentCanvasSelector,
GenericCanvasState,
InpaintingCanvasState,
isCanvasMaskLine,
OutpaintingCanvasState,
} from './canvasSlice';
import _ from 'lodash';
export const canvasLinesSelector = createSelector(
currentCanvasSelector,
(currentCanvas: GenericCanvasState) => {
const { objects } = currentCanvas as
| InpaintingCanvasState
| OutpaintingCanvasState;
return {
objects,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
type InpaintingCanvasLinesProps = GroupConfig;
/**
* Draws the lines which comprise the mask.
*
* Uses globalCompositeOperation to handle the brush and eraser tools.
*/
const IAICanvasLines = (props: InpaintingCanvasLinesProps) => {
const { ...rest } = props;
const { objects } = useAppSelector(canvasLinesSelector);
return (
<Group listening={false} {...rest}>
{objects.filter(isCanvasMaskLine).map((line, i) => (
<Line
key={i}
points={line.points}
stroke={'rgb(0,0,0)'} // The lines can be any color, just need alpha > 0
strokeWidth={line.strokeWidth * 2}
tension={0}
lineCap="round"
lineJoin="round"
shadowForStrokeEnabled={false}
listening={false}
globalCompositeOperation={
line.tool === 'brush' ? 'source-over' : 'destination-out'
}
/>
))}
</Group>
);
};
export default IAICanvasLines;

View File

@ -0,0 +1,112 @@
import { ButtonGroup } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import {
currentCanvasSelector,
resetCanvas,
setTool,
uploadOutpaintingMergedImage,
} from './canvasSlice';
import { useAppDispatch, useAppSelector } from 'app/store';
import _ from 'lodash';
import { canvasImageLayerRef } from './IAICanvas';
import IAIIconButton from 'common/components/IAIIconButton';
import {
FaArrowsAlt,
FaCopy,
FaDownload,
FaLayerGroup,
FaSave,
FaTrash,
FaUpload,
} from 'react-icons/fa';
import IAICanvasUndoButton from './IAICanvasControls/IAICanvasUndoButton';
import IAICanvasRedoButton from './IAICanvasControls/IAICanvasRedoButton';
import IAICanvasSettingsButtonPopover from './IAICanvasSettingsButtonPopover';
import IAICanvasEraserButtonPopover from './IAICanvasEraserButtonPopover';
import IAICanvasBrushButtonPopover from './IAICanvasBrushButtonPopover';
import IAICanvasMaskButtonPopover from './IAICanvasMaskButtonPopover';
export const canvasControlsSelector = createSelector(
[currentCanvasSelector],
(currentCanvas) => {
const { tool } = currentCanvas;
return {
tool,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasOutpaintingControls = () => {
const dispatch = useAppDispatch();
const { tool } = useAppSelector(canvasControlsSelector);
return (
<div className="inpainting-settings">
<IAICanvasMaskButtonPopover />
<ButtonGroup isAttached>
<IAICanvasBrushButtonPopover />
<IAICanvasEraserButtonPopover />
<IAIIconButton
aria-label="Move (M)"
tooltip="Move (M)"
icon={<FaArrowsAlt />}
data-selected={tool === 'move'}
onClick={() => dispatch(setTool('move'))}
/>
</ButtonGroup>
<ButtonGroup isAttached>
<IAIIconButton
aria-label="Merge Visible"
tooltip="Merge Visible"
icon={<FaLayerGroup />}
onClick={() => {
dispatch(uploadOutpaintingMergedImage(canvasImageLayerRef));
}}
/>
<IAIIconButton
aria-label="Save Selection to Gallery"
tooltip="Save Selection to Gallery"
icon={<FaSave />}
/>
<IAIIconButton
aria-label="Copy Selection"
tooltip="Copy Selection"
icon={<FaCopy />}
/>
<IAIIconButton
aria-label="Download Selection"
tooltip="Download Selection"
icon={<FaDownload />}
/>
</ButtonGroup>
<ButtonGroup isAttached>
<IAICanvasUndoButton />
<IAICanvasRedoButton />
</ButtonGroup>
<ButtonGroup isAttached>
<IAICanvasSettingsButtonPopover />
</ButtonGroup>
<ButtonGroup isAttached>
<IAIIconButton
aria-label="Upload"
tooltip="Upload"
icon={<FaUpload />}
/>
<IAIIconButton
aria-label="Reset Canvas"
tooltip="Reset Canvas"
icon={<FaTrash />}
onClick={() => dispatch(resetCanvas())}
/>
</ButtonGroup>
</div>
);
};
export default IAICanvasOutpaintingControls;

View File

@ -0,0 +1,65 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState, useAppSelector } from 'app/store';
import _ from 'lodash';
import { Group, Line } from 'react-konva';
import {
CanvasState,
isCanvasBaseImage,
isCanvasBaseLine,
} from './canvasSlice';
import IAICanvasImage from './IAICanvasImage';
import { rgbaColorToString } from './util/colorToString';
const selector = createSelector(
[(state: RootState) => state.canvas],
(canvas: CanvasState) => {
return {
objects:
canvas.currentCanvas === 'outpainting'
? canvas.outpainting.objects
: undefined,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasOutpaintingObjects = () => {
const { objects } = useAppSelector(selector);
if (!objects) return null;
return (
<Group name="outpainting-objects" listening={false}>
{objects.map((obj, i) => {
if (isCanvasBaseImage(obj)) {
return (
<IAICanvasImage key={i} x={obj.x} y={obj.y} url={obj.image.url} />
);
} else if (isCanvasBaseLine(obj)) {
return (
<Line
key={i}
points={obj.points}
stroke={obj.color ? rgbaColorToString(obj.color) : 'rgb(0,0,0)'} // The lines can be any color, just need alpha > 0
strokeWidth={obj.strokeWidth * 2}
tension={0}
lineCap="round"
lineJoin="round"
shadowForStrokeEnabled={false}
listening={false}
globalCompositeOperation={
obj.tool === 'brush' ? 'source-over' : 'destination-out'
}
/>
);
}
})}
</Group>
);
};
export default IAICanvasOutpaintingObjects;

View File

@ -0,0 +1,78 @@
import { Spinner } from '@chakra-ui/react';
import { useLayoutEffect, useRef } from 'react';
import { RootState, useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import {
baseCanvasImageSelector,
CanvasState,
currentCanvasSelector,
GenericCanvasState,
setStageDimensions,
setStageScale,
} from 'features/canvas/canvasSlice';
import { createSelector } from '@reduxjs/toolkit';
import * as InvokeAI from 'app/invokeai';
import { first } from 'lodash';
const canvasResizerSelector = createSelector(
(state: RootState) => state.canvas,
baseCanvasImageSelector,
activeTabNameSelector,
(canvas: CanvasState, baseCanvasImage, activeTabName) => {
const { doesCanvasNeedScaling } = canvas;
return {
doesCanvasNeedScaling,
activeTabName,
baseCanvasImage,
};
}
);
const IAICanvasResizer = () => {
const dispatch = useAppDispatch();
const { doesCanvasNeedScaling, activeTabName, baseCanvasImage } =
useAppSelector(canvasResizerSelector);
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
window.setTimeout(() => {
if (!ref.current || !baseCanvasImage) return;
const width = ref.current.clientWidth;
const height = ref.current.clientHeight;
const scale = Math.min(
1,
Math.min(width / baseCanvasImage.width, height / baseCanvasImage.height)
);
dispatch(setStageScale(scale));
if (activeTabName === 'inpainting') {
dispatch(
setStageDimensions({
width: Math.floor(baseCanvasImage.width * scale),
height: Math.floor(baseCanvasImage.height * scale),
})
);
} else if (activeTabName === 'outpainting') {
dispatch(
setStageDimensions({
width: Math.floor(width),
height: Math.floor(height),
})
);
}
}, 0);
}, [dispatch, baseCanvasImage, doesCanvasNeedScaling, activeTabName]);
return (
<div ref={ref} className="inpainting-canvas-area">
<Spinner thickness="2px" speed="1s" size="xl" />
</div>
);
};
export default IAICanvasResizer;

View File

@ -0,0 +1,101 @@
import { Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import {
currentCanvasSelector,
outpaintingCanvasSelector,
setShouldAutoSave,
setShouldDarkenOutsideBoundingBox,
setShouldShowGrid,
setShouldShowIntermediates,
setShouldSnapToGrid,
} from './canvasSlice';
import { useAppDispatch, useAppSelector } from 'app/store';
import _ from 'lodash';
import IAIIconButton from 'common/components/IAIIconButton';
import { FaWrench } from 'react-icons/fa';
import IAIPopover from 'common/components/IAIPopover';
import IAICheckbox from 'common/components/IAICheckbox';
export const canvasControlsSelector = createSelector(
[currentCanvasSelector, outpaintingCanvasSelector],
(currentCanvas, outpaintingCanvas) => {
const { shouldDarkenOutsideBoundingBox, shouldShowIntermediates } =
currentCanvas;
const { shouldShowGrid, shouldSnapToGrid, shouldAutoSave } =
outpaintingCanvas;
return {
shouldShowGrid,
shouldSnapToGrid,
shouldAutoSave,
shouldDarkenOutsideBoundingBox,
shouldShowIntermediates,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasSettingsButtonPopover = () => {
const dispatch = useAppDispatch();
const {
shouldShowIntermediates,
shouldShowGrid,
shouldSnapToGrid,
shouldAutoSave,
shouldDarkenOutsideBoundingBox,
} = useAppSelector(canvasControlsSelector);
return (
<IAIPopover
trigger="hover"
triggerComponent={
<IAIIconButton
variant="link"
data-variant="link"
tooltip="Canvas Settings"
aria-label="Canvas Settings"
icon={<FaWrench />}
/>
}
>
<Flex direction={'column'} gap={'0.5rem'}>
<IAICheckbox
label="Show Intermediates"
isChecked={shouldShowIntermediates}
onChange={(e) =>
dispatch(setShouldShowIntermediates(e.target.checked))
}
/>
<IAICheckbox
label="Show Grid"
isChecked={shouldShowGrid}
onChange={(e) => dispatch(setShouldShowGrid(e.target.checked))}
/>
<IAICheckbox
label="Snap to Grid"
isChecked={shouldSnapToGrid}
onChange={(e) => dispatch(setShouldSnapToGrid(e.target.checked))}
/>
<IAICheckbox
label="Darken Outside Selection"
isChecked={shouldDarkenOutsideBoundingBox}
onChange={(e) =>
dispatch(setShouldDarkenOutsideBoundingBox(e.target.checked))
}
/>
<IAICheckbox
label="Auto Save to Gallery"
isChecked={shouldAutoSave}
onChange={(e) => dispatch(setShouldAutoSave(e.target.checked))}
/>
</Flex>
</IAIPopover>
);
};
export default IAICanvasSettingsButtonPopover;

View File

@ -0,0 +1,74 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store';
import _ from 'lodash';
import { currentCanvasSelector } from './canvasSlice';
const roundToHundreth = (val: number): number => {
return Math.round(val * 100) / 100;
};
const selector = createSelector(
[currentCanvasSelector],
(currentCanvas) => {
const {
stageDimensions: { width: stageWidth, height: stageHeight },
stageCoordinates: { x: stageX, y: stageY },
boundingBoxDimensions: { width: boxWidth, height: boxHeight },
boundingBoxCoordinates: { x: boxX, y: boxY },
cursorPosition,
stageScale,
} = currentCanvas;
const position = cursorPosition
? { cursorX: cursorPosition.x, cursorY: cursorPosition.y }
: { cursorX: -1, cursorY: -1 };
return {
stageWidth,
stageHeight,
stageX,
stageY,
boxWidth,
boxHeight,
boxX,
boxY,
stageScale,
...position,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const IAICanvasStatusText = () => {
const {
stageWidth,
stageHeight,
stageX,
stageY,
boxWidth,
boxHeight,
boxX,
boxY,
cursorX,
cursorY,
stageScale,
} = useAppSelector(selector);
return (
<div className="canvas-status-text">
<div>{`Stage: ${stageWidth} x ${stageHeight}`}</div>
<div>{`Stage: ${roundToHundreth(stageX)}, ${roundToHundreth(
stageY
)}`}</div>
<div>{`Scale: ${roundToHundreth(stageScale)}`}</div>
<div>{`Box: ${boxWidth} x ${boxHeight}`}</div>
<div>{`Box: ${roundToHundreth(boxX)}, ${roundToHundreth(boxY)}`}</div>
<div>{`Cursor: ${cursorX}, ${cursorY}`}</div>
</div>
);
};
export default IAICanvasStatusText;

View File

@ -0,0 +1,764 @@
import {
createAsyncThunk,
createSelector,
createSlice,
} from '@reduxjs/toolkit';
import { v4 as uuidv4 } from 'uuid';
import type { PayloadAction } from '@reduxjs/toolkit';
import { IRect, Vector2d } from 'konva/lib/types';
import { RgbaColor } from 'react-colorful';
import * as InvokeAI from 'app/invokeai';
import _ from 'lodash';
import { roundDownToMultiple } from 'common/util/roundDownToMultiple';
import { RootState } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { MutableRefObject } from 'react';
import Konva from 'konva';
export interface GenericCanvasState {
tool: CanvasTool;
brushSize: number;
brushColor: RgbaColor;
eraserSize: number;
maskColor: RgbaColor;
cursorPosition: Vector2d | null;
stageDimensions: Dimensions;
stageCoordinates: Vector2d;
boundingBoxDimensions: Dimensions;
boundingBoxCoordinates: Vector2d;
boundingBoxPreviewFill: RgbaColor;
shouldShowBoundingBox: boolean;
shouldDarkenOutsideBoundingBox: boolean;
isMaskEnabled: boolean;
shouldInvertMask: boolean;
shouldShowCheckboardTransparency: boolean;
shouldShowBrush: boolean;
shouldShowBrushPreview: boolean;
stageScale: number;
isDrawing: boolean;
isTransformingBoundingBox: boolean;
isMouseOverBoundingBox: boolean;
isMovingBoundingBox: boolean;
isMovingStage: boolean;
shouldUseInpaintReplace: boolean;
inpaintReplace: number;
shouldLockBoundingBox: boolean;
isMoveBoundingBoxKeyHeld: boolean;
isMoveStageKeyHeld: boolean;
intermediateImage?: InvokeAI.Image;
shouldShowIntermediates: boolean;
}
export type CanvasLayer = 'base' | 'mask';
export type CanvasDrawingTool = 'brush' | 'eraser';
export type CanvasTool = CanvasDrawingTool | 'move';
export type Dimensions = {
width: number;
height: number;
};
export type CanvasAnyLine = {
kind: 'line';
tool: CanvasDrawingTool;
strokeWidth: number;
points: number[];
};
export type CanvasImage = {
kind: 'image';
layer: 'base';
x: number;
y: number;
image: InvokeAI.Image;
};
export type CanvasMaskLine = CanvasAnyLine & {
layer: 'mask';
};
export type CanvasLine = CanvasAnyLine & {
layer: 'base';
color?: RgbaColor;
};
type CanvasObject = CanvasImage | CanvasLine | CanvasMaskLine;
// type guards
export const isCanvasMaskLine = (obj: CanvasObject): obj is CanvasMaskLine =>
obj.kind === 'line' && obj.layer === 'mask';
export const isCanvasBaseLine = (obj: CanvasObject): obj is CanvasLine =>
obj.kind === 'line' && obj.layer === 'base';
export const isCanvasBaseImage = (obj: CanvasObject): obj is CanvasImage =>
obj.kind === 'image' && obj.layer === 'base';
export const isCanvasAnyLine = (
obj: CanvasObject
): obj is CanvasMaskLine | CanvasLine => obj.kind === 'line';
export type OutpaintingCanvasState = GenericCanvasState & {
layer: CanvasLayer;
objects: CanvasObject[];
pastObjects: CanvasObject[][];
futureObjects: CanvasObject[][];
shouldShowGrid: boolean;
shouldSnapToGrid: boolean;
shouldAutoSave: boolean;
stagingArea: {
images: CanvasImage[];
selectedImageIndex: number;
};
};
export type InpaintingCanvasState = GenericCanvasState & {
layer: 'mask';
objects: CanvasObject[];
pastObjects: CanvasObject[][];
futureObjects: CanvasObject[][];
imageToInpaint?: InvokeAI.Image;
};
export type BaseCanvasState = InpaintingCanvasState | OutpaintingCanvasState;
export type ValidCanvasName = 'inpainting' | 'outpainting';
export interface CanvasState {
doesCanvasNeedScaling: boolean;
currentCanvas: ValidCanvasName;
inpainting: InpaintingCanvasState;
outpainting: OutpaintingCanvasState;
}
const initialGenericCanvasState: GenericCanvasState = {
tool: 'brush',
brushColor: { r: 90, g: 90, b: 255, a: 1 },
brushSize: 50,
maskColor: { r: 255, g: 90, b: 90, a: 0.5 },
eraserSize: 50,
stageDimensions: { width: 0, height: 0 },
stageCoordinates: { x: 0, y: 0 },
boundingBoxDimensions: { width: 512, height: 512 },
boundingBoxCoordinates: { x: 0, y: 0 },
boundingBoxPreviewFill: { r: 0, g: 0, b: 0, a: 0.5 },
shouldShowBoundingBox: true,
shouldDarkenOutsideBoundingBox: false,
cursorPosition: null,
isMaskEnabled: true,
shouldInvertMask: false,
shouldShowCheckboardTransparency: false,
shouldShowBrush: true,
shouldShowBrushPreview: false,
isDrawing: false,
isTransformingBoundingBox: false,
isMouseOverBoundingBox: false,
isMovingBoundingBox: false,
stageScale: 1,
shouldUseInpaintReplace: false,
inpaintReplace: 0.1,
shouldLockBoundingBox: false,
isMoveBoundingBoxKeyHeld: false,
isMoveStageKeyHeld: false,
shouldShowIntermediates: true,
isMovingStage: false,
};
const initialCanvasState: CanvasState = {
currentCanvas: 'inpainting',
doesCanvasNeedScaling: false,
inpainting: {
layer: 'mask',
objects: [],
pastObjects: [],
futureObjects: [],
...initialGenericCanvasState,
},
outpainting: {
layer: 'base',
objects: [],
pastObjects: [],
futureObjects: [],
stagingArea: {
images: [],
selectedImageIndex: 0,
},
shouldShowGrid: true,
shouldSnapToGrid: true,
shouldAutoSave: false,
...initialGenericCanvasState,
},
};
export const canvasSlice = createSlice({
name: 'canvas',
initialState: initialCanvasState,
reducers: {
setTool: (state, action: PayloadAction<CanvasTool>) => {
const tool = action.payload;
state[state.currentCanvas].tool = action.payload;
if (tool !== 'move') {
state[state.currentCanvas].isTransformingBoundingBox = false;
state[state.currentCanvas].isMouseOverBoundingBox = false;
state[state.currentCanvas].isMovingBoundingBox = false;
state[state.currentCanvas].isMovingStage = false;
}
},
setLayer: (state, action: PayloadAction<CanvasLayer>) => {
state[state.currentCanvas].layer = action.payload;
},
toggleTool: (state) => {
const currentTool = state[state.currentCanvas].tool;
if (currentTool !== 'move') {
state[state.currentCanvas].tool =
currentTool === 'brush' ? 'eraser' : 'brush';
}
},
setMaskColor: (state, action: PayloadAction<RgbaColor>) => {
state[state.currentCanvas].maskColor = action.payload;
},
setBrushColor: (state, action: PayloadAction<RgbaColor>) => {
state[state.currentCanvas].brushColor = action.payload;
},
setBrushSize: (state, action: PayloadAction<number>) => {
state[state.currentCanvas].brushSize = action.payload;
},
setEraserSize: (state, action: PayloadAction<number>) => {
state[state.currentCanvas].eraserSize = action.payload;
},
clearMask: (state) => {
state[state.currentCanvas].pastObjects.push(
state[state.currentCanvas].objects
);
state[state.currentCanvas].objects = state[
state.currentCanvas
].objects.filter((obj) => !isCanvasMaskLine(obj));
state[state.currentCanvas].futureObjects = [];
state[state.currentCanvas].shouldInvertMask = false;
},
toggleShouldInvertMask: (state) => {
state[state.currentCanvas].shouldInvertMask =
!state[state.currentCanvas].shouldInvertMask;
},
toggleShouldShowMask: (state) => {
state[state.currentCanvas].isMaskEnabled =
!state[state.currentCanvas].isMaskEnabled;
},
setShouldInvertMask: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].shouldInvertMask = action.payload;
},
setIsMaskEnabled: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].isMaskEnabled = action.payload;
state[state.currentCanvas].layer = action.payload ? 'mask' : 'base';
},
setShouldShowCheckboardTransparency: (
state,
action: PayloadAction<boolean>
) => {
state[state.currentCanvas].shouldShowCheckboardTransparency =
action.payload;
},
setShouldShowBrushPreview: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].shouldShowBrushPreview = action.payload;
},
setShouldShowBrush: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].shouldShowBrush = action.payload;
},
setCursorPosition: (state, action: PayloadAction<Vector2d | null>) => {
state[state.currentCanvas].cursorPosition = action.payload;
},
clearImageToInpaint: (state) => {
state.inpainting.imageToInpaint = undefined;
},
setImageToOutpaint: (state, action: PayloadAction<InvokeAI.Image>) => {
const { width: canvasWidth, height: canvasHeight } =
state.outpainting.stageDimensions;
const { width, height } = state.outpainting.boundingBoxDimensions;
const { x, y } = state.outpainting.boundingBoxCoordinates;
const maxWidth = Math.min(action.payload.width, canvasWidth);
const maxHeight = Math.min(action.payload.height, canvasHeight);
const newCoordinates: Vector2d = { x, y };
const newDimensions: Dimensions = { width, height };
if (width + x > maxWidth) {
// Bounding box at least needs to be translated
if (width > maxWidth) {
// Bounding box also needs to be resized
newDimensions.width = roundDownToMultiple(maxWidth, 64);
}
newCoordinates.x = maxWidth - newDimensions.width;
}
if (height + y > maxHeight) {
// Bounding box at least needs to be translated
if (height > maxHeight) {
// Bounding box also needs to be resized
newDimensions.height = roundDownToMultiple(maxHeight, 64);
}
newCoordinates.y = maxHeight - newDimensions.height;
}
state.outpainting.boundingBoxDimensions = newDimensions;
state.outpainting.boundingBoxCoordinates = newCoordinates;
// state.outpainting.imageToInpaint = action.payload;
state.outpainting.objects = [
{
kind: 'image',
layer: 'base',
x: 0,
y: 0,
image: action.payload,
},
];
state.doesCanvasNeedScaling = true;
},
setImageToInpaint: (state, action: PayloadAction<InvokeAI.Image>) => {
const { width: canvasWidth, height: canvasHeight } =
state.inpainting.stageDimensions;
const { width, height } = state.inpainting.boundingBoxDimensions;
const { x, y } = state.inpainting.boundingBoxCoordinates;
const maxWidth = Math.min(action.payload.width, canvasWidth);
const maxHeight = Math.min(action.payload.height, canvasHeight);
const newCoordinates: Vector2d = { x, y };
const newDimensions: Dimensions = { width, height };
if (width + x > maxWidth) {
// Bounding box at least needs to be translated
if (width > maxWidth) {
// Bounding box also needs to be resized
newDimensions.width = roundDownToMultiple(maxWidth, 64);
}
newCoordinates.x = maxWidth - newDimensions.width;
}
if (height + y > maxHeight) {
// Bounding box at least needs to be translated
if (height > maxHeight) {
// Bounding box also needs to be resized
newDimensions.height = roundDownToMultiple(maxHeight, 64);
}
newCoordinates.y = maxHeight - newDimensions.height;
}
state.inpainting.boundingBoxDimensions = newDimensions;
state.inpainting.boundingBoxCoordinates = newCoordinates;
state.inpainting.imageToInpaint = action.payload;
state.doesCanvasNeedScaling = true;
},
setStageDimensions: (state, action: PayloadAction<Dimensions>) => {
state[state.currentCanvas].stageDimensions = action.payload;
const { width: canvasWidth, height: canvasHeight } = action.payload;
const { width: boundingBoxWidth, height: boundingBoxHeight } =
state[state.currentCanvas].boundingBoxDimensions;
const newBoundingBoxWidth = roundDownToMultiple(
_.clamp(
boundingBoxWidth,
64,
canvasWidth / state[state.currentCanvas].stageScale
),
64
);
const newBoundingBoxHeight = roundDownToMultiple(
_.clamp(
boundingBoxHeight,
64,
canvasHeight / state[state.currentCanvas].stageScale
),
64
);
state[state.currentCanvas].boundingBoxDimensions = {
width: newBoundingBoxWidth,
height: newBoundingBoxHeight,
};
},
setBoundingBoxDimensions: (state, action: PayloadAction<Dimensions>) => {
state[state.currentCanvas].boundingBoxDimensions = action.payload;
const { width: boundingBoxWidth, height: boundingBoxHeight } =
action.payload;
const { x: boundingBoxX, y: boundingBoxY } =
state[state.currentCanvas].boundingBoxCoordinates;
const { width: canvasWidth, height: canvasHeight } =
state[state.currentCanvas].stageDimensions;
const scaledCanvasWidth =
canvasWidth / state[state.currentCanvas].stageScale;
const scaledCanvasHeight =
canvasHeight / state[state.currentCanvas].stageScale;
const roundedCanvasWidth = roundDownToMultiple(scaledCanvasWidth, 64);
const roundedCanvasHeight = roundDownToMultiple(scaledCanvasHeight, 64);
const roundedBoundingBoxWidth = roundDownToMultiple(boundingBoxWidth, 64);
const roundedBoundingBoxHeight = roundDownToMultiple(
boundingBoxHeight,
64
);
const overflowX = boundingBoxX + boundingBoxWidth - scaledCanvasWidth;
const overflowY = boundingBoxY + boundingBoxHeight - scaledCanvasHeight;
const newBoundingBoxWidth = _.clamp(
roundedBoundingBoxWidth,
64,
roundedCanvasWidth
);
const newBoundingBoxHeight = _.clamp(
roundedBoundingBoxHeight,
64,
roundedCanvasHeight
);
const overflowCorrectedX =
overflowX > 0 ? boundingBoxX - overflowX : boundingBoxX;
const overflowCorrectedY =
overflowY > 0 ? boundingBoxY - overflowY : boundingBoxY;
const clampedX = _.clamp(
overflowCorrectedX,
state[state.currentCanvas].stageCoordinates.x,
roundedCanvasWidth - newBoundingBoxWidth
);
const clampedY = _.clamp(
overflowCorrectedY,
state[state.currentCanvas].stageCoordinates.y,
roundedCanvasHeight - newBoundingBoxHeight
);
state[state.currentCanvas].boundingBoxDimensions = {
width: newBoundingBoxWidth,
height: newBoundingBoxHeight,
};
state[state.currentCanvas].boundingBoxCoordinates = {
x: clampedX,
y: clampedY,
};
},
setBoundingBoxCoordinates: (state, action: PayloadAction<Vector2d>) => {
state[state.currentCanvas].boundingBoxCoordinates = action.payload;
},
setStageCoordinates: (state, action: PayloadAction<Vector2d>) => {
state[state.currentCanvas].stageCoordinates = action.payload;
},
setBoundingBoxPreviewFill: (state, action: PayloadAction<RgbaColor>) => {
state[state.currentCanvas].boundingBoxPreviewFill = action.payload;
},
setDoesCanvasNeedScaling: (state, action: PayloadAction<boolean>) => {
state.doesCanvasNeedScaling = action.payload;
},
setStageScale: (state, action: PayloadAction<number>) => {
state[state.currentCanvas].stageScale = action.payload;
state.doesCanvasNeedScaling = false;
},
setShouldDarkenOutsideBoundingBox: (
state,
action: PayloadAction<boolean>
) => {
state[state.currentCanvas].shouldDarkenOutsideBoundingBox =
action.payload;
},
setIsDrawing: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].isDrawing = action.payload;
},
setClearBrushHistory: (state) => {
state[state.currentCanvas].pastObjects = [];
state[state.currentCanvas].futureObjects = [];
},
setShouldUseInpaintReplace: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].shouldUseInpaintReplace = action.payload;
},
setInpaintReplace: (state, action: PayloadAction<number>) => {
state[state.currentCanvas].inpaintReplace = action.payload;
},
setShouldLockBoundingBox: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].shouldLockBoundingBox = action.payload;
},
toggleShouldLockBoundingBox: (state) => {
state[state.currentCanvas].shouldLockBoundingBox =
!state[state.currentCanvas].shouldLockBoundingBox;
},
setShouldShowBoundingBox: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].shouldShowBoundingBox = action.payload;
},
setIsTransformingBoundingBox: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].isTransformingBoundingBox = action.payload;
},
setIsMovingBoundingBox: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].isMovingBoundingBox = action.payload;
},
setIsMouseOverBoundingBox: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].isMouseOverBoundingBox = action.payload;
},
setIsMoveBoundingBoxKeyHeld: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].isMoveBoundingBoxKeyHeld = action.payload;
},
setIsMoveStageKeyHeld: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].isMoveStageKeyHeld = action.payload;
},
setCurrentCanvas: (state, action: PayloadAction<ValidCanvasName>) => {
state.currentCanvas = action.payload;
},
addImageToOutpaintingSesion: (
state,
action: PayloadAction<{
boundingBox: IRect;
image: InvokeAI.Image;
}>
) => {
const { boundingBox, image } = action.payload;
if (!boundingBox || !image) return;
const { x, y } = boundingBox;
state.outpainting.pastObjects.push([...state.outpainting.objects]);
state.outpainting.futureObjects = [];
state.outpainting.objects.push({
kind: 'image',
layer: 'base',
x,
y,
image,
});
},
addLine: (state, action: PayloadAction<number[]>) => {
const { tool, layer, brushColor, brushSize, eraserSize } =
state[state.currentCanvas];
if (tool === 'move') return;
state[state.currentCanvas].pastObjects.push(
state[state.currentCanvas].objects
);
state[state.currentCanvas].objects.push({
kind: 'line',
layer,
tool,
strokeWidth: tool === 'brush' ? brushSize / 2 : eraserSize / 2,
points: action.payload,
...(layer === 'base' && tool === 'brush' && { color: brushColor }),
});
state[state.currentCanvas].futureObjects = [];
},
addPointToCurrentLine: (state, action: PayloadAction<number[]>) => {
const lastLine =
state[state.currentCanvas].objects.findLast(isCanvasAnyLine);
if (!lastLine) return;
lastLine.points.push(...action.payload);
},
undo: (state) => {
if (state.outpainting.objects.length === 0) return;
const newObjects = state.outpainting.pastObjects.pop();
if (!newObjects) return;
state.outpainting.futureObjects.unshift(state.outpainting.objects);
state.outpainting.objects = newObjects;
},
redo: (state) => {
if (state.outpainting.futureObjects.length === 0) return;
const newObjects = state.outpainting.futureObjects.shift();
if (!newObjects) return;
state.outpainting.pastObjects.push(state.outpainting.objects);
state.outpainting.objects = newObjects;
},
setShouldShowGrid: (state, action: PayloadAction<boolean>) => {
state.outpainting.shouldShowGrid = action.payload;
},
setIsMovingStage: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].isMovingStage = action.payload;
},
setShouldSnapToGrid: (state, action: PayloadAction<boolean>) => {
state.outpainting.shouldSnapToGrid = action.payload;
},
setShouldAutoSave: (state, action: PayloadAction<boolean>) => {
state.outpainting.shouldAutoSave = action.payload;
},
setShouldShowIntermediates: (state, action: PayloadAction<boolean>) => {
state[state.currentCanvas].shouldShowIntermediates = action.payload;
},
resetCanvas: (state) => {
state[state.currentCanvas].pastObjects.push(
state[state.currentCanvas].objects
);
state[state.currentCanvas].objects = [];
state[state.currentCanvas].futureObjects = [];
},
},
extraReducers: (builder) => {
builder.addCase(uploadOutpaintingMergedImage.fulfilled, (state, action) => {
if (!action.payload) return;
state.outpainting.pastObjects.push([...state.outpainting.objects]);
state.outpainting.futureObjects = [];
state.outpainting.objects = [
{
kind: 'image',
layer: 'base',
...action.payload,
},
];
});
},
});
export const {
setTool,
setLayer,
setBrushColor,
setBrushSize,
setEraserSize,
addLine,
addPointToCurrentLine,
setShouldInvertMask,
setIsMaskEnabled,
setShouldShowCheckboardTransparency,
setShouldShowBrushPreview,
setMaskColor,
clearMask,
clearImageToInpaint,
undo,
redo,
setCursorPosition,
setStageDimensions,
setImageToInpaint,
setImageToOutpaint,
setBoundingBoxDimensions,
setBoundingBoxCoordinates,
setBoundingBoxPreviewFill,
setDoesCanvasNeedScaling,
setStageScale,
toggleTool,
setShouldShowBoundingBox,
setShouldDarkenOutsideBoundingBox,
setIsDrawing,
setShouldShowBrush,
setClearBrushHistory,
setShouldUseInpaintReplace,
setInpaintReplace,
setShouldLockBoundingBox,
toggleShouldLockBoundingBox,
setIsMovingBoundingBox,
setIsTransformingBoundingBox,
setIsMouseOverBoundingBox,
setIsMoveBoundingBoxKeyHeld,
setIsMoveStageKeyHeld,
setStageCoordinates,
setCurrentCanvas,
addImageToOutpaintingSesion,
resetCanvas,
setShouldShowGrid,
setShouldSnapToGrid,
setShouldAutoSave,
setShouldShowIntermediates,
setIsMovingStage,
} = canvasSlice.actions;
export default canvasSlice.reducer;
export const uploadOutpaintingMergedImage = createAsyncThunk(
'canvas/uploadOutpaintingMergedImage',
async (
canvasImageLayerRef: MutableRefObject<Konva.Layer | null>,
thunkAPI
) => {
const { getState } = thunkAPI;
const state = getState() as RootState;
const stageScale = state.canvas.outpainting.stageScale;
if (!canvasImageLayerRef.current) return;
const tempScale = canvasImageLayerRef.current.scale();
const { x: relativeX, y: relativeY } =
canvasImageLayerRef.current.getClientRect({
relativeTo: canvasImageLayerRef.current.getParent(),
});
canvasImageLayerRef.current.scale({
x: 1 / stageScale,
y: 1 / stageScale,
});
const clientRect = canvasImageLayerRef.current.getClientRect();
const imageDataURL = canvasImageLayerRef.current.toDataURL(clientRect);
canvasImageLayerRef.current.scale(tempScale);
if (!imageDataURL) return;
const response = await fetch(window.location.origin + '/upload', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
dataURL: imageDataURL,
name: 'outpaintingmerge.png',
}),
});
const data = (await response.json()) as InvokeAI.ImageUploadResponse;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { destination, ...rest } = data;
const image = {
uuid: uuidv4(),
...rest,
};
return {
image,
x: relativeX,
y: relativeY,
};
}
);
export const currentCanvasSelector = (state: RootState): BaseCanvasState =>
state.canvas[state.canvas.currentCanvas];
export const outpaintingCanvasSelector = (
state: RootState
): OutpaintingCanvasState => state.canvas.outpainting;
export const inpaintingCanvasSelector = (
state: RootState
): InpaintingCanvasState => state.canvas.inpainting;
export const baseCanvasImageSelector = createSelector(
[(state: RootState) => state.canvas, activeTabNameSelector],
(canvas: CanvasState, activeTabName) => {
if (activeTabName === 'inpainting') {
return canvas.inpainting.imageToInpaint;
} else if (activeTabName === 'outpainting') {
const firstImageObject = canvas.outpainting.objects.find(
(obj) => obj.kind === 'image'
);
if (firstImageObject && firstImageObject.kind === 'image') {
return firstImageObject.image;
}
}
}
);

View File

@ -0,0 +1,49 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { KonvaEventObject } from 'konva/lib/Node';
import _ from 'lodash';
import { useCallback } from 'react';
import {
currentCanvasSelector,
setIsMovingStage,
setStageCoordinates,
} from '../canvasSlice';
const selector = createSelector(
[currentCanvasSelector, activeTabNameSelector],
(canvas, activeTabName) => {
const { tool } = canvas;
return {
tool,
activeTabName,
};
},
{ memoizeOptions: { resultEqualityCheck: _.isEqual } }
);
const useCanvasDrag = () => {
const dispatch = useAppDispatch();
const { tool, activeTabName } = useAppSelector(selector);
return {
handleDragStart: useCallback(() => {
if (tool !== 'move' || activeTabName !== 'outpainting') return;
dispatch(setIsMovingStage(true));
}, [activeTabName, dispatch, tool]),
handleDragMove: useCallback(
(e: KonvaEventObject<MouseEvent>) => {
if (tool !== 'move' || activeTabName !== 'outpainting') return;
dispatch(setStageCoordinates(e.target.getPosition()));
},
[activeTabName, dispatch, tool]
),
handleDragEnd: useCallback(() => {
if (tool !== 'move' || activeTabName !== 'outpainting') return;
dispatch(setIsMovingStage(false));
}, [activeTabName, dispatch, tool]),
};
};
export default useCanvasDrag;

View File

@ -0,0 +1,111 @@
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { useHotkeys } from 'react-hotkeys-hook';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { OptionsState } from 'features/options/optionsSlice';
import {
CanvasTool,
setShouldShowBoundingBox,
setTool,
toggleShouldLockBoundingBox,
} from 'features/canvas/canvasSlice';
import { RootState, useAppDispatch, useAppSelector } from 'app/store';
import { currentCanvasSelector, GenericCanvasState } from '../canvasSlice';
import { useRef } from 'react';
const inpaintingCanvasHotkeysSelector = createSelector(
[
(state: RootState) => state.options,
currentCanvasSelector,
activeTabNameSelector,
],
(options: OptionsState, currentCanvas: GenericCanvasState, activeTabName) => {
const {
isMaskEnabled,
cursorPosition,
shouldLockBoundingBox,
shouldShowBoundingBox,
tool,
} = currentCanvas;
return {
activeTabName,
isMaskEnabled,
isCursorOnCanvas: Boolean(cursorPosition),
shouldLockBoundingBox,
shouldShowBoundingBox,
tool,
};
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const useInpaintingCanvasHotkeys = () => {
const dispatch = useAppDispatch();
const { isMaskEnabled, activeTabName, shouldShowBoundingBox, tool } =
useAppSelector(inpaintingCanvasHotkeysSelector);
const previousToolRef = useRef<CanvasTool | null>(null);
// Toggle lock bounding box
useHotkeys(
'shift+w',
(e: KeyboardEvent) => {
e.preventDefault();
dispatch(toggleShouldLockBoundingBox());
},
{
enabled: true,
},
[activeTabName]
);
useHotkeys(
'shift+h',
(e: KeyboardEvent) => {
e.preventDefault();
dispatch(setShouldShowBoundingBox(!shouldShowBoundingBox));
},
{
enabled: true,
},
[activeTabName, shouldShowBoundingBox]
);
useHotkeys(
['space'],
(e: KeyboardEvent) => {
if (e.repeat) return;
if (tool !== 'move') {
previousToolRef.current = tool;
dispatch(setTool('move'));
}
},
{ keyup: false, keydown: true },
[tool, previousToolRef]
);
useHotkeys(
['space'],
(e: KeyboardEvent) => {
if (e.repeat) return;
if (
tool === 'move' &&
previousToolRef.current &&
previousToolRef.current !== 'move'
) {
dispatch(setTool(previousToolRef.current));
previousToolRef.current = 'move';
}
},
{ keyup: true, keydown: false },
[tool, previousToolRef]
);
};
export default useInpaintingCanvasHotkeys;

View File

@ -0,0 +1,56 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import Konva from 'konva';
import { KonvaEventObject } from 'konva/lib/Node';
import _ from 'lodash';
import { MutableRefObject, useCallback } from 'react';
import {
addLine,
currentCanvasSelector,
setIsDrawing,
setIsMovingStage,
} from '../canvasSlice';
import getScaledCursorPosition from '../util/getScaledCursorPosition';
const selector = createSelector(
[activeTabNameSelector, currentCanvasSelector],
(activeTabName, currentCanvas) => {
const { tool } = currentCanvas;
return {
tool,
activeTabName,
};
},
{ memoizeOptions: { resultEqualityCheck: _.isEqual } }
);
const useCanvasMouseDown = (stageRef: MutableRefObject<Konva.Stage | null>) => {
const dispatch = useAppDispatch();
const { tool } = useAppSelector(selector);
return useCallback(
(e: KonvaEventObject<MouseEvent>) => {
if (!stageRef.current) return;
if (tool === 'move') {
dispatch(setIsMovingStage(true));
return;
}
const scaledCursorPosition = getScaledCursorPosition(stageRef.current);
if (!scaledCursorPosition) return;
e.evt.preventDefault();
dispatch(setIsDrawing(true));
// Add a new line starting from the current cursor position.
dispatch(addLine([scaledCursorPosition.x, scaledCursorPosition.y]));
},
[stageRef, dispatch, tool]
);
};
export default useCanvasMouseDown;

View File

@ -0,0 +1,48 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import Konva from 'konva';
import { KonvaEventObject } from 'konva/lib/Node';
import _ from 'lodash';
import { MutableRefObject, useCallback } from 'react';
import { addLine, currentCanvasSelector, setIsDrawing } from '../canvasSlice';
import getScaledCursorPosition from '../util/getScaledCursorPosition';
const selector = createSelector(
[activeTabNameSelector, currentCanvasSelector],
(activeTabName, currentCanvas) => {
const { tool } = currentCanvas;
return {
tool,
activeTabName,
};
},
{ memoizeOptions: { resultEqualityCheck: _.isEqual } }
);
const useCanvasMouseEnter = (
stageRef: MutableRefObject<Konva.Stage | null>
) => {
const dispatch = useAppDispatch();
const { tool } = useAppSelector(selector);
return useCallback(
(e: KonvaEventObject<MouseEvent>) => {
if (e.evt.buttons !== 1) return;
if (!stageRef.current) return;
const scaledCursorPosition = getScaledCursorPosition(stageRef.current);
if (!scaledCursorPosition || tool === 'move') return;
dispatch(setIsDrawing(true));
// Add a new line starting from the current cursor position.
dispatch(addLine([scaledCursorPosition.x, scaledCursorPosition.y]));
},
[stageRef, tool, dispatch]
);
};
export default useCanvasMouseEnter;

View File

@ -0,0 +1,64 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import Konva from 'konva';
import { Vector2d } from 'konva/lib/types';
import _ from 'lodash';
import { MutableRefObject, useCallback } from 'react';
import {
addPointToCurrentLine,
currentCanvasSelector,
GenericCanvasState,
setCursorPosition,
} from '../canvasSlice';
import getScaledCursorPosition from '../util/getScaledCursorPosition';
const selector = createSelector(
[activeTabNameSelector, currentCanvasSelector],
(activeTabName, canvas: GenericCanvasState) => {
const { tool, isDrawing } = canvas;
return {
tool,
isDrawing,
activeTabName,
};
},
{ memoizeOptions: { resultEqualityCheck: _.isEqual } }
);
const useCanvasMouseMove = (
stageRef: MutableRefObject<Konva.Stage | null>,
didMouseMoveRef: MutableRefObject<boolean>,
lastCursorPositionRef: MutableRefObject<Vector2d>
) => {
const dispatch = useAppDispatch();
const { isDrawing, tool } = useAppSelector(selector);
return useCallback(() => {
if (!stageRef.current) return;
const scaledCursorPosition = getScaledCursorPosition(stageRef.current);
if (!scaledCursorPosition) return;
dispatch(setCursorPosition(scaledCursorPosition));
lastCursorPositionRef.current = scaledCursorPosition;
if (!isDrawing || tool === 'move') return;
didMouseMoveRef.current = true;
dispatch(
addPointToCurrentLine([scaledCursorPosition.x, scaledCursorPosition.y])
);
}, [
didMouseMoveRef,
dispatch,
isDrawing,
lastCursorPositionRef,
stageRef,
tool,
]);
};
export default useCanvasMouseMove;

View File

@ -0,0 +1,15 @@
import { useAppDispatch } from 'app/store';
import _ from 'lodash';
import { useCallback } from 'react';
import { setCursorPosition, setIsDrawing } from '../canvasSlice';
const useCanvasMouseOut = () => {
const dispatch = useAppDispatch();
return useCallback(() => {
dispatch(setCursorPosition(null));
dispatch(setIsDrawing(false));
}, [dispatch]);
};
export default useCanvasMouseOut;

View File

@ -0,0 +1,64 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import Konva from 'konva';
import _ from 'lodash';
import { MutableRefObject, useCallback } from 'react';
import {
// addPointToCurrentEraserLine,
addPointToCurrentLine,
currentCanvasSelector,
GenericCanvasState,
setIsDrawing,
setIsMovingStage,
} from '../canvasSlice';
import getScaledCursorPosition from '../util/getScaledCursorPosition';
const selector = createSelector(
[activeTabNameSelector, currentCanvasSelector],
(activeTabName, canvas: GenericCanvasState) => {
const { tool, isDrawing } = canvas;
return {
tool,
isDrawing,
activeTabName,
};
},
{ memoizeOptions: { resultEqualityCheck: _.isEqual } }
);
const useCanvasMouseUp = (
stageRef: MutableRefObject<Konva.Stage | null>,
didMouseMoveRef: MutableRefObject<boolean>
) => {
const dispatch = useAppDispatch();
const { tool, isDrawing } = useAppSelector(selector);
return useCallback(() => {
if (tool === 'move') {
dispatch(setIsMovingStage(false));
return;
}
if (!didMouseMoveRef.current && isDrawing && stageRef.current) {
const scaledCursorPosition = getScaledCursorPosition(stageRef.current);
if (!scaledCursorPosition) return;
/**
* Extend the current line.
* In this case, the mouse didn't move, so we append the same point to
* the line's existing points. This allows the line to render as a circle
* centered on that point.
*/
dispatch(
addPointToCurrentLine([scaledCursorPosition.x, scaledCursorPosition.y])
);
} else {
didMouseMoveRef.current = false;
}
dispatch(setIsDrawing(false));
}, [didMouseMoveRef, dispatch, isDrawing, stageRef, tool]);
};
export default useCanvasMouseUp;

View File

@ -0,0 +1,83 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import Konva from 'konva';
import { KonvaEventObject } from 'konva/lib/Node';
import _ from 'lodash';
import { MutableRefObject, useCallback } from 'react';
import {
currentCanvasSelector,
GenericCanvasState,
setStageCoordinates,
setStageScale,
} from '../canvasSlice';
import {
CANVAS_SCALE_BY,
MAX_CANVAS_SCALE,
MIN_CANVAS_SCALE,
} from '../util/constants';
const selector = createSelector(
[activeTabNameSelector, currentCanvasSelector],
(activeTabName, canvas: GenericCanvasState) => {
const { isMoveStageKeyHeld, stageScale } = canvas;
return {
isMoveStageKeyHeld,
stageScale,
activeTabName,
};
},
{ memoizeOptions: { resultEqualityCheck: _.isEqual } }
);
const useCanvasWheel = (stageRef: MutableRefObject<Konva.Stage | null>) => {
const dispatch = useAppDispatch();
const { isMoveStageKeyHeld, stageScale, activeTabName } =
useAppSelector(selector);
return useCallback(
(e: KonvaEventObject<WheelEvent>) => {
// stop default scrolling
if (activeTabName !== 'outpainting') return;
e.evt.preventDefault();
// const oldScale = stageRef.current.scaleX();
if (!stageRef.current || isMoveStageKeyHeld) return;
const cursorPos = stageRef.current.getPointerPosition();
if (!cursorPos) return;
const mousePointTo = {
x: (cursorPos.x - stageRef.current.x()) / stageScale,
y: (cursorPos.y - stageRef.current.y()) / stageScale,
};
let delta = e.evt.deltaY;
// when we zoom on trackpad, e.evt.ctrlKey is true
// in that case lets revert direction
if (e.evt.ctrlKey) {
delta = -delta;
}
const newScale = _.clamp(
stageScale * CANVAS_SCALE_BY ** delta,
MIN_CANVAS_SCALE,
MAX_CANVAS_SCALE
);
const newPos = {
x: cursorPos.x - mousePointTo.x * newScale,
y: cursorPos.y - mousePointTo.y * newScale,
};
dispatch(setStageScale(newScale));
dispatch(setStageCoordinates(newPos));
},
[activeTabName, dispatch, isMoveStageKeyHeld, stageRef, stageScale]
);
};
export default useCanvasWheel;

View File

@ -0,0 +1,23 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store';
import _ from 'lodash';
import { currentCanvasSelector, GenericCanvasState } from '../canvasSlice';
const selector = createSelector(
[currentCanvasSelector],
(currentCanvas: GenericCanvasState) => {
return currentCanvas.stageScale;
},
{
memoizeOptions: {
resultEqualityCheck: _.isEqual,
},
}
);
const useUnscaleCanvasValue = () => {
const stageScale = useAppSelector(selector);
return (value: number) => value / stageScale;
};
export default useUnscaleCanvasValue;

View File

@ -7,4 +7,8 @@ export const MARCHING_ANTS_SPEED = 30;
// bounding box anchor size
export const TRANSFORMER_ANCHOR_SIZE = 15;
export const CANVAS_SCALE_BY = 0.999;
export const MIN_CANVAS_SCALE = 0.1
export const MAX_CANVAS_SCALE = 20

View File

@ -0,0 +1,64 @@
import Konva from 'konva';
import { IRect } from 'konva/lib/types';
import { CanvasMaskLine } from 'features/canvas/canvasSlice';
/**
* Generating a mask image from InpaintingCanvas.tsx is not as simple
* as calling toDataURL() on the canvas, because the mask may be represented
* by colored lines or transparency, or the user may have inverted the mask
* display.
*
* So we need to regenerate the mask image by creating an offscreen canvas,
* drawing the mask and compositing everything correctly to output a valid
* mask image.
*/
const generateMask = (lines: CanvasMaskLine[], boundingBox: IRect): string => {
// create an offscreen canvas and add the mask to it
const { width, height } = boundingBox;
const offscreenContainer = document.createElement('div');
const stage = new Konva.Stage({
container: offscreenContainer,
width: width,
height: height,
});
const baseLayer = new Konva.Layer();
const maskLayer = new Konva.Layer();
// composite the image onto the mask layer
baseLayer.add(
new Konva.Rect({
...boundingBox,
fill: 'white',
})
);
lines.forEach((line) =>
maskLayer.add(
new Konva.Line({
points: line.points,
stroke: 'black',
strokeWidth: line.strokeWidth * 2,
tension: 0,
lineCap: 'round',
lineJoin: 'round',
shadowForStrokeEnabled: false,
globalCompositeOperation:
line.tool === 'brush' ? 'source-over' : 'destination-out',
})
)
);
stage.add(baseLayer);
stage.add(maskLayer);
const dataURL = stage.toDataURL({ ...boundingBox });
offscreenContainer.remove();
return dataURL;
};
export default generateMask;

View File

@ -13,11 +13,18 @@
max-width: 25rem;
}
.current-image-send-to-popover {
.invokeai__button {
place-content: start;
}
}
.chakra-popover__popper {
z-index: 11;
}
.delete-image-btn {
background-color: var(--btn-base-color);
svg {
fill: var(--btn-delete-image);
}

View File

@ -1,24 +1,25 @@
import { createSelector } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import { useAppDispatch, useAppSelector } from '../../app/store';
import { RootState } from '../../app/store';
import { useAppDispatch, useAppSelector } from 'app/store';
import { RootState } from 'app/store';
import {
OptionsState,
setActiveTab,
setAllParameters,
setInitialImage,
setIsLightBoxOpen,
setPrompt,
setSeed,
setShouldShowImageDetails,
} from '../options/optionsSlice';
} from 'features/options/optionsSlice';
import DeleteImageModal from './DeleteImageModal';
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 UpscaleOptions from '../options/AdvancedOptions/Upscale/UpscaleOptions';
import FaceRestoreOptions from '../options/AdvancedOptions/FaceRestore/FaceRestoreOptions';
import { SystemState } from 'features/system/systemSlice';
import IAIButton from 'common/components/IAIButton';
import { runESRGAN, runFacetool } from 'app/socketio/actions';
import IAIIconButton from 'common/components/IAIIconButton';
import UpscaleOptions from 'features/options/AdvancedOptions/Upscale/UpscaleOptions';
import FaceRestoreOptions from 'features/options/AdvancedOptions/FaceRestore/FaceRestoreOptions';
import { useHotkeys } from 'react-hotkeys-hook';
import { ButtonGroup, Link, useClipboard, useToast } from '@chakra-ui/react';
import {
@ -36,11 +37,12 @@ import {
} from 'react-icons/fa';
import {
setImageToInpaint,
setNeedsCache,
} from '../tabs/Inpainting/inpaintingSlice';
setDoesCanvasNeedScaling,
setImageToOutpaint,
} from 'features/canvas/canvasSlice';
import { GalleryState } from './gallerySlice';
import { activeTabNameSelector } from '../options/optionsSelectors';
import IAIPopover from '../../common/components/IAIPopover';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import IAIPopover from 'common/components/IAIPopover';
const systemSelector = createSelector(
[
@ -58,8 +60,12 @@ const systemSelector = createSelector(
const { isProcessing, isConnected, isGFPGANAvailable, isESRGANAvailable } =
system;
const { upscalingLevel, facetoolStrength, shouldShowImageDetails } =
options;
const {
upscalingLevel,
facetoolStrength,
shouldShowImageDetails,
isLightBoxOpen,
} = options;
const { intermediateImage, currentImage } = gallery;
@ -74,6 +80,7 @@ const systemSelector = createSelector(
currentImage,
shouldShowImageDetails,
activeTabName,
isLightBoxOpen,
};
},
{
@ -99,28 +106,31 @@ const CurrentImageButtons = () => {
shouldDisableToolbarButtons,
shouldShowImageDetails,
currentImage,
isLightBoxOpen,
} = useAppSelector(systemSelector);
const { onCopy } = useClipboard(
currentImage ? window.location.toString() + currentImage.url : ''
);
const toast = useToast();
const handleClickUseAsInitialImage = () => {
if (!currentImage) return;
if (isLightBoxOpen) dispatch(setIsLightBoxOpen(false));
dispatch(setInitialImage(currentImage));
dispatch(setActiveTab('img2img'));
};
const handleCopyImageLink = () => {
onCopy();
toast({
title: 'Image Link Copied',
status: 'success',
duration: 2500,
isClosable: true,
});
navigator.clipboard
.writeText(
currentImage ? window.location.toString() + currentImage.url : ''
)
.then(() => {
toast({
title: 'Image Link Copied',
status: 'success',
duration: 2500,
isClosable: true,
});
});
};
useHotkeys(
@ -308,11 +318,27 @@ const CurrentImageButtons = () => {
const handleSendToInpainting = () => {
if (!currentImage) return;
if (isLightBoxOpen) dispatch(setIsLightBoxOpen(false));
dispatch(setImageToInpaint(currentImage));
dispatch(setActiveTab('inpainting'));
dispatch(setNeedsCache(true));
dispatch(setDoesCanvasNeedScaling(true));
toast({
title: 'Sent to Inpainting',
status: 'success',
duration: 2500,
isClosable: true,
});
};
const handleSendToOutpainting = () => {
if (!currentImage) return;
if (isLightBoxOpen) dispatch(setIsLightBoxOpen(false));
dispatch(setImageToOutpaint(currentImage));
dispatch(setActiveTab('outpainting'));
dispatch(setDoesCanvasNeedScaling(true));
toast({
title: 'Sent to Inpainting',
@ -363,6 +389,13 @@ const CurrentImageButtons = () => {
>
Send to Inpainting
</IAIButton>
<IAIButton
size={'sm'}
onClick={handleSendToOutpainting}
leftIcon={<FaShare />}
>
Send to Outpainting
</IAIButton>
<IAIButton
size={'sm'}
onClick={handleCopyImageLink}

View File

@ -25,6 +25,7 @@
max-height: 100%;
height: auto;
position: absolute;
cursor: pointer;
}
}

View File

@ -1,12 +1,12 @@
import { RootState, useAppSelector } from '../../app/store';
import { RootState, useAppSelector } from 'app/store';
import CurrentImageButtons from './CurrentImageButtons';
import { MdPhoto } from 'react-icons/md';
import CurrentImagePreview from './CurrentImagePreview';
import { GalleryState } from './gallerySlice';
import { OptionsState } from '../options/optionsSlice';
import { OptionsState } from 'features/options/optionsSlice';
import _ from 'lodash';
import { createSelector } from '@reduxjs/toolkit';
import { activeTabNameSelector } from '../options/optionsSelectors';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
export const currentImageDisplaySelector = createSelector(
[

View File

@ -1,7 +1,7 @@
import { IconButton, Image, Spinner } from '@chakra-ui/react';
import { useState } from 'react';
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';
import { RootState, useAppDispatch, useAppSelector } from '../../app/store';
import { RootState, useAppDispatch, useAppSelector } from 'app/store';
import {
GalleryCategory,
GalleryState,
@ -10,7 +10,7 @@ import {
} from './gallerySlice';
import { createSelector } from '@reduxjs/toolkit';
import _ from 'lodash';
import { OptionsState } from '../options/optionsSlice';
import { OptionsState, setIsLightBoxOpen } from 'features/options/optionsSlice';
import ImageMetadataViewer from './ImageMetaDataViewer/ImageMetadataViewer';
export const imagesSelector = createSelector(
@ -76,6 +76,10 @@ export default function CurrentImagePreview() {
dispatch(selectNextImage());
};
const handleLightBox = () => {
dispatch(setIsLightBoxOpen(true));
};
return (
<div className={'current-image-preview'}>
{imageToDisplay && (
@ -83,6 +87,7 @@ export default function CurrentImagePreview() {
src={imageToDisplay.url}
width={imageToDisplay.width}
height={imageToDisplay.height}
onClick={handleLightBox}
/>
)}
{!shouldShowImageDetails && (

View File

@ -22,11 +22,11 @@ import {
SyntheticEvent,
useRef,
} from 'react';
import { useAppDispatch, useAppSelector } from '../../app/store';
import { deleteImage } from '../../app/socketio/actions';
import { RootState } from '../../app/store';
import { setShouldConfirmOnDelete, SystemState } from '../system/systemSlice';
import * as InvokeAI from '../../app/invokeai';
import { useAppDispatch, useAppSelector } from 'app/store';
import { deleteImage } from 'app/socketio/actions';
import { RootState } from 'app/store';
import { setShouldConfirmOnDelete, SystemState } from 'features/system/systemSlice';
import * as InvokeAI from 'app/invokeai';
import { useHotkeys } from 'react-hotkeys-hook';
import _ from 'lodash';

View File

@ -6,7 +6,7 @@ import {
Tooltip,
useToast,
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from '../../app/store';
import { useAppDispatch, useAppSelector } from 'app/store';
import { setCurrentImage } from './gallerySlice';
import { FaCheck, FaTrashAlt } from 'react-icons/fa';
import DeleteImageModal from './DeleteImageModal';
@ -16,12 +16,16 @@ import {
setAllImageToImageParameters,
setAllTextToImageParameters,
setInitialImage,
setIsLightBoxOpen,
setPrompt,
setSeed,
} from '../options/optionsSlice';
import * as InvokeAI from '../../app/invokeai';
} from 'features/options/optionsSlice';
import * as InvokeAI from 'app/invokeai';
import * as ContextMenu from '@radix-ui/react-context-menu';
import { setImageToInpaint } from '../tabs/Inpainting/inpaintingSlice';
import {
setImageToInpaint,
setImageToOutpaint,
} from 'features/canvas/canvasSlice';
import { hoverableImageSelector } from './gallerySliceSelectors';
interface HoverableImageProps {
@ -44,6 +48,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
galleryImageObjectFit,
galleryImageMinimumWidth,
mayDeleteImage,
isLightBoxOpen,
} = useAppSelector(hoverableImageSelector);
const { image, isSelected } = props;
const { url, uuid, metadata } = image;
@ -77,6 +82,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
};
const handleSendToImageToImage = () => {
if (isLightBoxOpen) dispatch(setIsLightBoxOpen(false));
dispatch(setInitialImage(image));
if (activeTabName !== 'img2img') {
dispatch(setActiveTab('img2img'));
@ -90,6 +96,7 @@ const HoverableImage = memo((props: HoverableImageProps) => {
};
const handleSendToInpainting = () => {
if (isLightBoxOpen) dispatch(setIsLightBoxOpen(false));
dispatch(setImageToInpaint(image));
if (activeTabName !== 'inpainting') {
dispatch(setActiveTab('inpainting'));
@ -102,6 +109,20 @@ const HoverableImage = memo((props: HoverableImageProps) => {
});
};
const handleSendToOutpainting = () => {
if (isLightBoxOpen) dispatch(setIsLightBoxOpen(false));
dispatch(setImageToOutpaint(image));
if (activeTabName !== 'outpainting') {
dispatch(setActiveTab('outpainting'));
}
toast({
title: 'Sent to Outpainting',
status: 'success',
duration: 2500,
isClosable: true,
});
};
const handleUseAllParameters = () => {
metadata && dispatch(setAllTextToImageParameters(metadata));
toast({
@ -228,6 +249,9 @@ const HoverableImage = memo((props: HoverableImageProps) => {
<ContextMenu.Item onClickCapture={handleSendToInpainting}>
Send to Inpainting
</ContextMenu.Item>
<ContextMenu.Item onClickCapture={handleSendToOutpainting}>
Send to Outpainting
</ContextMenu.Item>
<DeleteImageModal image={image}>
<ContextMenu.Item data-warning>Delete Image</ContextMenu.Item>
</DeleteImageModal>

View File

@ -35,7 +35,7 @@
}
.image-gallery-popup {
background-color: var(--tab-color);
background-color: var(--background-color-secondary);
padding: 1rem;
display: flex;
flex-direction: column;
@ -55,16 +55,16 @@
column-gap: 0.5rem;
justify-content: space-between;
div {
.image-gallery-header-right-icons {
display: flex;
column-gap: 0.5rem;
flex-direction: row;
column-gap: 0.5rem;
}
.image-gallery-icon-btn {
background-color: var(--btn-load-more) !important;
background-color: var(--btn-load-more);
&:hover {
background-color: var(--btn-load-more-hover) !important;
background-color: var(--btn-load-more-hover);
}
}
@ -96,7 +96,8 @@
.image-gallery-container-placeholder {
display: flex;
flex-direction: column;
background-color: var(--background-color-secondary);
row-gap: 0.5rem;
background-color: var(--background-color);
border-radius: 0.5rem;
place-items: center;
padding: 2rem;
@ -108,26 +109,26 @@
}
svg {
width: 5rem;
height: 5rem;
width: 4rem;
height: 4rem;
color: var(--svg-color);
}
}
.image-gallery-load-more-btn {
background-color: var(--btn-load-more) !important;
font-size: 0.85rem !important;
background-color: var(--btn-load-more);
font-size: 0.85rem;
padding: 0.5rem;
margin-top: 1rem;
&:disabled {
&:hover {
background-color: var(--btn-load-more) !important;
background-color: var(--btn-load-more);
}
}
&:hover {
background-color: var(--btn-load-more-hover) !important;
background-color: var(--btn-load-more-hover);
}
}
}
@ -135,11 +136,15 @@
}
.image-gallery-category-btn-group {
width: 100% !important;
column-gap: 0 !important;
justify-content: stretch !important;
width: max-content;
column-gap: 0;
justify-content: stretch;
button {
background-color: var(--btn-base-color);
&:hover {
background-color: var(--btn-base-color-hover);
}
flex-grow: 1;
&[data-selected='true'] {
background-color: var(--accent-color);

View File

@ -5,9 +5,9 @@ import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { MdPhotoLibrary } from 'react-icons/md';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
import { requestImages } from '../../app/socketio/actions';
import { useAppDispatch, useAppSelector } from '../../app/store';
import IAIIconButton from '../../common/components/IAIIconButton';
import { requestImages } from 'app/socketio/actions';
import { useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import {
selectNextImage,
selectPrevImage,
@ -21,19 +21,19 @@ import {
setShouldPinGallery,
} from './gallerySlice';
import HoverableImage from './HoverableImage';
import { setShouldShowGallery } from '../gallery/gallerySlice';
import { setShouldShowGallery } from 'features/gallery/gallerySlice';
import { ButtonGroup, useToast } from '@chakra-ui/react';
import { CSSTransition } from 'react-transition-group';
import { Direction } from 're-resizable/lib/resizer';
import { imageGallerySelector } from './gallerySliceSelectors';
import { FaImage, FaUser, FaWrench } from 'react-icons/fa';
import IAIPopover from '../../common/components/IAIPopover';
import IAISlider from '../../common/components/IAISlider';
import IAIPopover from 'common/components/IAIPopover';
import IAISlider from 'common/components/IAISlider';
import { BiReset } from 'react-icons/bi';
import IAICheckbox from '../../common/components/IAICheckbox';
import { setNeedsCache } from '../tabs/Inpainting/inpaintingSlice';
import IAICheckbox from 'common/components/IAICheckbox';
import { setDoesCanvasNeedScaling } from 'features/canvas/canvasSlice';
import _ from 'lodash';
import useClickOutsideWatcher from '../../common/hooks/useClickOutsideWatcher';
import useClickOutsideWatcher from 'common/hooks/useClickOutsideWatcher';
const GALLERY_SHOW_BUTTONS_MIN_WIDTH = 320;
@ -56,6 +56,7 @@ export default function ImageGallery() {
shouldAutoSwitchToNewImages,
areMoreImagesAvailable,
galleryWidth,
isLightBoxOpen,
} = useAppSelector(imageGallerySelector);
const [galleryMinWidth, setGalleryMinWidth] = useState<number>(300);
@ -68,6 +69,13 @@ export default function ImageGallery() {
useEffect(() => {
if (!shouldPinGallery) return;
if (isLightBoxOpen) {
dispatch(setGalleryWidth(400));
setGalleryMinWidth(400);
setGalleryMaxWidth(400);
return;
}
if (activeTabName === 'inpainting') {
dispatch(setGalleryWidth(190));
setGalleryMinWidth(190);
@ -83,14 +91,14 @@ export default function ImageGallery() {
);
setGalleryMaxWidth(590);
}
dispatch(setNeedsCache(true));
}, [dispatch, activeTabName, shouldPinGallery, galleryWidth]);
dispatch(setDoesCanvasNeedScaling(true));
}, [dispatch, activeTabName, shouldPinGallery, galleryWidth, isLightBoxOpen]);
useEffect(() => {
if (!shouldPinGallery) {
setGalleryMaxWidth(window.innerWidth);
}
}, [shouldPinGallery]);
}, [shouldPinGallery, isLightBoxOpen]);
const galleryRef = useRef<HTMLDivElement>(null);
const galleryContainerRef = useRef<HTMLDivElement>(null);
@ -98,7 +106,7 @@ export default function ImageGallery() {
const handleSetShouldPinGallery = () => {
dispatch(setShouldPinGallery(!shouldPinGallery));
dispatch(setNeedsCache(true));
dispatch(setDoesCanvasNeedScaling(true));
};
const handleToggleGallery = () => {
@ -107,7 +115,7 @@ export default function ImageGallery() {
const handleOpenGallery = () => {
dispatch(setShouldShowGallery(true));
shouldPinGallery && dispatch(setNeedsCache(true));
shouldPinGallery && dispatch(setDoesCanvasNeedScaling(true));
};
const handleCloseGallery = () => {
@ -119,7 +127,7 @@ export default function ImageGallery() {
)
);
dispatch(setShouldHoldGalleryOpen(false));
// dispatch(setNeedsCache(true));
// dispatch(setDoesCanvasNeedScaling(true));
};
const handleClickLoadMore = () => {
@ -128,7 +136,7 @@ export default function ImageGallery() {
const handleChangeGalleryImageMinimumWidth = (v: number) => {
dispatch(setGalleryImageMinimumWidth(v));
dispatch(setNeedsCache(true));
dispatch(setDoesCanvasNeedScaling(true));
};
const setCloseGalleryTimer = () => {
@ -143,8 +151,10 @@ export default function ImageGallery() {
'g',
() => {
handleToggleGallery();
shouldPinGallery &&
setTimeout(() => dispatch(setDoesCanvasNeedScaling(true)), 400);
},
[shouldShowGallery]
[shouldShowGallery, shouldPinGallery]
);
useHotkeys('left', () => {
@ -159,6 +169,7 @@ export default function ImageGallery() {
'shift+g',
() => {
handleSetShouldPinGallery();
dispatch(setDoesCanvasNeedScaling(true));
},
[shouldPinGallery]
);
@ -168,6 +179,7 @@ export default function ImageGallery() {
() => {
if (shouldPinGallery) return;
dispatch(setShouldShowGallery(false));
dispatch(setDoesCanvasNeedScaling(true));
},
[shouldPinGallery]
);
@ -339,49 +351,48 @@ export default function ImageGallery() {
}}
>
<div className="image-gallery-header">
<div>
<ButtonGroup
size="sm"
isAttached
variant="solid"
className="image-gallery-category-btn-group"
>
{shouldShowButtons ? (
<>
<Button
data-selected={currentCategory === 'result'}
onClick={() => dispatch(setCurrentCategory('result'))}
>
Invocations
</Button>
<Button
data-selected={currentCategory === 'user'}
onClick={() => dispatch(setCurrentCategory('user'))}
>
User
</Button>
</>
) : (
<>
<IAIIconButton
aria-label="Show Invocations"
tooltip="Show Invocations"
data-selected={currentCategory === 'result'}
icon={<FaImage />}
onClick={() => dispatch(setCurrentCategory('result'))}
/>
<IAIIconButton
aria-label="Show Uploads"
tooltip="Show Uploads"
data-selected={currentCategory === 'user'}
icon={<FaUser />}
onClick={() => dispatch(setCurrentCategory('user'))}
/>
</>
)}
</ButtonGroup>
</div>
<div>
<ButtonGroup
size="sm"
isAttached
variant="solid"
className="image-gallery-category-btn-group"
>
{shouldShowButtons ? (
<>
<Button
data-selected={currentCategory === 'result'}
onClick={() => dispatch(setCurrentCategory('result'))}
>
Invocations
</Button>
<Button
data-selected={currentCategory === 'user'}
onClick={() => dispatch(setCurrentCategory('user'))}
>
User
</Button>
</>
) : (
<>
<IAIIconButton
aria-label="Show Invocations"
tooltip="Show Invocations"
data-selected={currentCategory === 'result'}
icon={<FaImage />}
onClick={() => dispatch(setCurrentCategory('result'))}
/>
<IAIIconButton
aria-label="Show Uploads"
tooltip="Show Uploads"
data-selected={currentCategory === 'user'}
icon={<FaUser />}
onClick={() => dispatch(setCurrentCategory('user'))}
/>
</>
)}
</ButtonGroup>
<div className="image-gallery-header-right-icons">
<IAIPopover
isLazy
trigger="hover"
@ -403,12 +414,12 @@ export default function ImageGallery() {
onChange={handleChangeGalleryImageMinimumWidth}
min={32}
max={256}
width={100}
// width={100}
label={'Image Size'}
formLabelProps={{ style: { fontSize: '0.9rem' } }}
sliderThumbTooltipProps={{
label: `${galleryImageMinimumWidth}px`,
}}
// formLabelProps={{ style: { fontSize: '0.9rem' } }}
// sliderThumbTooltipProps={{
// label: `${galleryImageMinimumWidth}px`,
// }}
/>
<IAIIconButton
size={'sm'}

View File

@ -10,8 +10,8 @@ import {
import { ExternalLinkIcon } from '@chakra-ui/icons';
import { memo } from 'react';
import { IoArrowUndoCircleOutline } from 'react-icons/io5';
import { useAppDispatch } from '../../../app/store';
import * as InvokeAI from '../../../app/invokeai';
import { useAppDispatch } from 'app/store';
import * as InvokeAI from 'app/invokeai';
import {
setCfgScale,
setFacetoolStrength,
@ -33,9 +33,9 @@ import {
setWidth,
setInitialImage,
setShouldShowImageDetails,
} from '../../options/optionsSlice';
import promptToString from '../../../common/util/promptToString';
import { seedWeightsToString } from '../../../common/util/seedWeightPairs';
} from 'features/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';

View File

@ -1,7 +1,9 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import _, { clamp } from 'lodash';
import * as InvokeAI from '../../app/invokeai';
import * as InvokeAI from 'app/invokeai';
import { IRect } from 'konva/lib/types';
import { InvokeTabName } from 'features/tabs/InvokeTabs';
export type GalleryCategory = 'user' | 'result';
@ -23,7 +25,7 @@ export type Gallery = {
export interface GalleryState {
currentImage?: InvokeAI.Image;
currentImageUuid: string;
intermediateImage?: InvokeAI.Image;
intermediateImage?: InvokeAI.Image & { boundingBox?: IRect; generationMode?: InvokeTabName };
shouldPinGallery: boolean;
shouldShowGallery: boolean;
galleryScrollPosition: number;
@ -148,7 +150,12 @@ export const gallerySlice = createSlice({
state.intermediateImage = undefined;
tempCategory.latest_mtime = mtime;
},
setIntermediateImage: (state, action: PayloadAction<InvokeAI.Image>) => {
setIntermediateImage: (
state,
action: PayloadAction<
InvokeAI.Image & { boundingBox?: IRect; generationMode?: InvokeTabName }
>
) => {
state.intermediateImage = action.payload;
},
clearIntermediateImage: (state) => {

View File

@ -1,8 +1,8 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from '../../app/store';
import { activeTabNameSelector } from '../options/optionsSelectors';
import { OptionsState } from '../options/optionsSlice';
import { SystemState } from '../system/systemSlice';
import { RootState } from 'app/store';
import { activeTabNameSelector } from 'features/options/optionsSelectors';
import { OptionsState } from 'features/options/optionsSlice';
import { SystemState } from 'features/system/systemSlice';
import { GalleryState } from './gallerySlice';
import _ from 'lodash';
@ -27,6 +27,8 @@ export const imageGallerySelector = createSelector(
galleryWidth,
} = gallery;
const { isLightBoxOpen } = options;
return {
currentImageUuid,
shouldPinGallery,
@ -43,6 +45,7 @@ export const imageGallerySelector = createSelector(
categories[currentCategory].areMoreImagesAvailable,
currentCategory,
galleryWidth,
isLightBoxOpen,
};
},
{
@ -70,6 +73,7 @@ export const hoverableImageSelector = createSelector(
galleryImageObjectFit: gallery.galleryImageObjectFit,
galleryImageMinimumWidth: gallery.galleryImageMinimumWidth,
activeTabName,
isLightBoxOpen: options.isLightBoxOpen,
};
},
{

View File

@ -0,0 +1,74 @@
@use '../../styles/Mixins/' as *;
.lightbox-container {
width: 100%;
height: 100%;
color: var(--text-color);
overflow: hidden;
position: absolute;
left: 0;
top: 0;
background-color: var(--background-color-secondary);
z-index: 30;
.image-gallery-wrapper {
max-height: 100% !important;
.image-gallery-container {
max-height: calc(100vh - 5rem);
}
}
.current-image-options {
z-index: 2;
position: absolute;
top: 1rem;
}
}
.lightbox-close-btn {
z-index: 3;
position: absolute;
left: 1rem;
top: 1rem;
@include BaseButton;
}
.lightbox-display-container {
display: flex;
flex-direction: row;
}
.lightbox-preview-wrapper {
overflow: hidden;
background-color: red;
background-color: var(--background-color-secondary);
display: grid;
grid-template-columns: auto max-content;
place-items: center;
width: 100vw;
height: 100vh;
.current-image-next-prev-buttons {
position: absolute;
}
.lightbox-image {
grid-area: lightbox-content;
border-radius: 0.5rem;
}
.lightbox-image-options {
position: absolute;
z-index: 2;
left: 1rem;
top: 4.5rem;
user-select: none;
border-radius: 0.5rem;
display: flex;
flex-direction: column;
row-gap: 0.5rem;
}
}

View File

@ -0,0 +1,116 @@
import { IconButton } from '@chakra-ui/react';
import { RootState, useAppDispatch, useAppSelector } from 'app/store';
import IAIIconButton from 'common/components/IAIIconButton';
import CurrentImageButtons from 'features/gallery/CurrentImageButtons';
import { imagesSelector } from 'features/gallery/CurrentImagePreview';
import {
selectNextImage,
selectPrevImage,
} from 'features/gallery/gallerySlice';
import ImageGallery from 'features/gallery/ImageGallery';
import { setIsLightBoxOpen } from 'features/options/optionsSlice';
import React, { useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { BiExit } from 'react-icons/bi';
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';
import ReactPanZoom from './ReactPanZoom';
export default function Lightbox() {
const dispatch = useAppDispatch();
const isLightBoxOpen = useAppSelector(
(state: RootState) => state.options.isLightBoxOpen
);
const {
imageToDisplay,
shouldShowImageDetails,
isOnFirstImage,
isOnLastImage,
} = useAppSelector(imagesSelector);
const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] =
useState<boolean>(false);
const handleCurrentImagePreviewMouseOver = () => {
setShouldShowNextPrevButtons(true);
};
const handleCurrentImagePreviewMouseOut = () => {
setShouldShowNextPrevButtons(false);
};
const handleClickPrevButton = () => {
dispatch(selectPrevImage());
};
const handleClickNextButton = () => {
dispatch(selectNextImage());
};
useHotkeys(
'Esc',
() => {
if (isLightBoxOpen) dispatch(setIsLightBoxOpen(false));
},
[isLightBoxOpen]
);
return (
<div className="lightbox-container">
<IAIIconButton
icon={<BiExit />}
aria-label="Exit Viewer"
className="lightbox-close-btn"
onClick={() => {
dispatch(setIsLightBoxOpen(false));
}}
fontSize={20}
/>
<div className="lightbox-display-container">
<div className="lightbox-preview-wrapper">
<CurrentImageButtons />
{!shouldShowImageDetails && (
<div className="current-image-next-prev-buttons">
<div
className="next-prev-button-trigger-area prev-button-trigger-area"
onMouseOver={handleCurrentImagePreviewMouseOver}
onMouseOut={handleCurrentImagePreviewMouseOut}
>
{shouldShowNextPrevButtons && !isOnFirstImage && (
<IconButton
aria-label="Previous image"
icon={<FaAngleLeft className="next-prev-button" />}
variant="unstyled"
onClick={handleClickPrevButton}
/>
)}
</div>
<div
className="next-prev-button-trigger-area next-button-trigger-area"
onMouseOver={handleCurrentImagePreviewMouseOver}
onMouseOut={handleCurrentImagePreviewMouseOut}
>
{shouldShowNextPrevButtons && !isOnLastImage && (
<IconButton
aria-label="Next image"
icon={<FaAngleRight className="next-prev-button" />}
variant="unstyled"
onClick={handleClickNextButton}
/>
)}
</div>
</div>
)}
{imageToDisplay && (
<ReactPanZoom
image={imageToDisplay.url}
styleClass="lightbox-image"
/>
)}
</div>
<ImageGallery />
</div>
</div>
);
}

View File

@ -0,0 +1,155 @@
import IAIIconButton from 'common/components/IAIIconButton';
import * as React from 'react';
import {
BiReset,
BiRotateLeft,
BiRotateRight,
BiZoomIn,
BiZoomOut,
} from 'react-icons/bi';
import { MdFlip } from 'react-icons/md';
import { PanViewer } from 'react-image-pan-zoom-rotate';
type ReactPanZoomProps = {
image: string;
styleClass?: string;
alt?: string;
ref?: any;
};
export default function ReactPanZoom({
image,
alt,
ref,
styleClass,
}: ReactPanZoomProps) {
const [dx, setDx] = React.useState(0);
const [dy, setDy] = React.useState(0);
const [zoom, setZoom] = React.useState(1);
const [rotation, setRotation] = React.useState(0);
const [flip, setFlip] = React.useState(false);
const resetAll = () => {
setDx(0);
setDy(0);
setZoom(1);
setRotation(0);
setFlip(false);
};
const zoomIn = () => {
setZoom(zoom + 0.2);
};
const zoomOut = () => {
if (zoom >= 0.5) {
setZoom(zoom - 0.2);
}
};
const rotateLeft = () => {
if (rotation === -3) {
setRotation(0);
} else {
setRotation(rotation - 1);
}
};
const rotateRight = () => {
if (rotation === 3) {
setRotation(0);
} else {
setRotation(rotation + 1);
}
};
const flipImage = () => {
setFlip(!flip);
};
const onPan = (dx: number, dy: number) => {
setDx(dx);
setDy(dy);
};
return (
<div>
<div className="lightbox-image-options">
<IAIIconButton
icon={<BiZoomIn />}
aria-label="Zoom In"
tooltip="Zoom In"
onClick={zoomIn}
fontSize={20}
/>
<IAIIconButton
icon={<BiZoomOut />}
aria-label="Zoom Out"
tooltip="Zoom Out"
onClick={zoomOut}
fontSize={20}
/>
<IAIIconButton
icon={<BiRotateLeft />}
aria-label="Rotate Left"
tooltip="Rotate Left"
onClick={rotateLeft}
fontSize={20}
/>
<IAIIconButton
icon={<BiRotateRight />}
aria-label="Rotate Right"
tooltip="Rotate Right"
onClick={rotateRight}
fontSize={20}
/>
<IAIIconButton
icon={<MdFlip />}
aria-label="Flip Image"
tooltip="Flip Image"
onClick={flipImage}
fontSize={20}
/>
<IAIIconButton
icon={<BiReset />}
aria-label="Reset"
tooltip="Reset"
onClick={resetAll}
fontSize={20}
/>
</div>
<PanViewer
style={{
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1,
}}
zoom={zoom}
setZoom={setZoom}
pandx={dx}
pandy={dy}
onPan={onPan}
rotation={rotation}
key={dx}
>
<img
style={{
transform: `rotate(${rotation * 90}deg) scaleX(${flip ? -1 : 1})`,
width: '100%',
}}
src={image}
alt={alt}
ref={ref}
className={styleClass ? styleClass : ''}
/>
</PanViewer>
</div>
);
}

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