Merge branch 'main' into feat/dev_reload

This commit is contained in:
Kevin Turner 2023-08-23 09:47:24 -07:00 committed by GitHub
commit 54e844f7da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
124 changed files with 4825 additions and 5218 deletions

View File

@ -170,6 +170,7 @@ class _InputField(BaseModel):
ui_hidden: bool
ui_type: Optional[UIType]
ui_component: Optional[UIComponent]
ui_order: Optional[int]
class _OutputField(BaseModel):
@ -182,6 +183,7 @@ class _OutputField(BaseModel):
ui_hidden: bool
ui_type: Optional[UIType]
ui_order: Optional[int]
def InputField(
@ -215,6 +217,7 @@ def InputField(
ui_type: Optional[UIType] = None,
ui_component: Optional[UIComponent] = None,
ui_hidden: bool = False,
ui_order: Optional[int] = None,
**kwargs: Any,
) -> Any:
"""
@ -273,6 +276,7 @@ def InputField(
ui_type=ui_type,
ui_component=ui_component,
ui_hidden=ui_hidden,
ui_order=ui_order,
**kwargs,
)
@ -306,6 +310,7 @@ def OutputField(
repr: bool = True,
ui_type: Optional[UIType] = None,
ui_hidden: bool = False,
ui_order: Optional[int] = None,
**kwargs: Any,
) -> Any:
"""
@ -352,6 +357,7 @@ def OutputField(
repr=repr,
ui_type=ui_type,
ui_hidden=ui_hidden,
ui_order=ui_order,
**kwargs,
)
@ -380,7 +386,7 @@ class BaseInvocationOutput(BaseModel):
"""Base class for all invocation outputs"""
# All outputs must include a type name like this:
# type: Literal['your_output_name']
# type: Literal['your_output_name'] # noqa f821
@classmethod
def get_all_subclasses_tuple(cls):
@ -421,7 +427,7 @@ class BaseInvocation(ABC, BaseModel):
"""
# All invocations must include a type name like this:
# type: Literal['your_output_name']
# type: Literal['your_output_name'] # noqa f821
@classmethod
def get_all_subclasses(cls):
@ -499,7 +505,7 @@ class BaseInvocation(ABC, BaseModel):
raise MissingInputException(self.__fields__["type"].default, field_name)
return self.invoke(context)
id: str = InputField(description="The id of this node. Must be unique among all nodes.")
id: str = Field(description="The id of this node. Must be unique among all nodes.")
is_intermediate: bool = InputField(
default=False, description="Whether or not this node is an intermediate node.", input=Input.Direct
)

View File

@ -8,7 +8,7 @@ import numpy
from PIL import Image, ImageChops, ImageFilter, ImageOps
from invokeai.app.invocations.metadata import CoreMetadata
from invokeai.app.invocations.primitives import ImageField, ImageOutput
from invokeai.app.invocations.primitives import ColorField, ImageField, ImageOutput
from invokeai.backend.image_util.invisible_watermark import InvisibleWatermark
from invokeai.backend.image_util.safety_checker import SafetyChecker
@ -41,6 +41,39 @@ class ShowImageInvocation(BaseInvocation):
)
@title("Blank Image")
@tags("image")
class BlankImageInvocation(BaseInvocation):
"""Creates a blank image and forwards it to the pipeline"""
# Metadata
type: Literal["blank_image"] = "blank_image"
# Inputs
width: int = InputField(default=512, description="The width of the image")
height: int = InputField(default=512, description="The height of the image")
mode: Literal["RGB", "RGBA"] = InputField(default="RGB", description="The mode of the image")
color: ColorField = InputField(default=ColorField(r=0, g=0, b=0, a=255), description="The color of the image")
def invoke(self, context: InvocationContext) -> ImageOutput:
image = Image.new(mode=self.mode, size=(self.width, self.height), color=self.color.tuple())
image_dto = context.services.images.create(
image=image,
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
node_id=self.id,
session_id=context.graph_execution_state_id,
is_intermediate=self.is_intermediate,
)
return ImageOutput(
image=ImageField(image_name=image_dto.image_name),
width=image_dto.width,
height=image_dto.height,
)
@title("Crop Image")
@tags("image", "crop")
class ImageCropInvocation(BaseInvocation):

View File

@ -107,12 +107,12 @@ class DenoiseLatentsInvocation(BaseInvocation):
# Inputs
positive_conditioning: ConditioningField = InputField(
description=FieldDescriptions.positive_cond, input=Input.Connection
description=FieldDescriptions.positive_cond, input=Input.Connection, ui_order=0
)
negative_conditioning: ConditioningField = InputField(
description=FieldDescriptions.negative_cond, input=Input.Connection
description=FieldDescriptions.negative_cond, input=Input.Connection, ui_order=1
)
noise: Optional[LatentsField] = InputField(description=FieldDescriptions.noise, input=Input.Connection)
noise: Optional[LatentsField] = InputField(description=FieldDescriptions.noise, input=Input.Connection, ui_order=3)
steps: int = InputField(default=10, gt=0, description=FieldDescriptions.steps)
cfg_scale: Union[float, List[float]] = InputField(
default=7.5, ge=1, description=FieldDescriptions.cfg_scale, ui_type=UIType.Float, title="CFG Scale"
@ -122,11 +122,13 @@ class DenoiseLatentsInvocation(BaseInvocation):
scheduler: SAMPLER_NAME_VALUES = InputField(
default="euler", description=FieldDescriptions.scheduler, ui_type=UIType.Scheduler
)
unet: UNetField = InputField(description=FieldDescriptions.unet, input=Input.Connection, title="UNet")
unet: UNetField = InputField(description=FieldDescriptions.unet, input=Input.Connection, title="UNet", ui_order=2)
control: Union[ControlField, list[ControlField]] = InputField(
default=None, description=FieldDescriptions.control, input=Input.Connection
default=None, description=FieldDescriptions.control, input=Input.Connection, ui_order=5
)
latents: Optional[LatentsField] = InputField(
description=FieldDescriptions.latents, input=Input.Connection, ui_order=4
)
latents: Optional[LatentsField] = InputField(description=FieldDescriptions.latents, input=Input.Connection)
mask: Optional[ImageField] = InputField(
default=None,
description=FieldDescriptions.mask,

View File

@ -9,8 +9,8 @@ module.exports = {
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:prettier/recommended',
'plugin:react/jsx-runtime',
'prettier',
],
parser: '@typescript-eslint/parser',
parserOptions: {
@ -39,7 +39,6 @@ module.exports = {
'warn',
{ varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
],
'prettier/prettier': ['error', { endOfLine: 'auto' }],
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-empty-interface': [

View File

@ -29,12 +29,13 @@
"lint:eslint": "eslint --max-warnings=0 .",
"lint:prettier": "prettier --check .",
"lint:tsc": "tsc --noEmit",
"lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:tsc && yarn run lint:madge",
"lint": "concurrently -g -n eslint,prettier,tsc,madge -c cyan,green,magenta,yellow \"yarn run lint:eslint\" \"yarn run lint:prettier\" \"yarn run lint:tsc\" \"yarn run lint:madge\"",
"fix": "eslint --fix . && prettier --loglevel warn --write . && tsc --noEmit",
"lint-staged": "lint-staged",
"postinstall": "patch-package && yarn run theme",
"theme": "chakra-cli tokens src/theme/theme.ts",
"theme:watch": "chakra-cli tokens src/theme/theme.ts --watch"
"theme:watch": "chakra-cli tokens src/theme/theme.ts --watch",
"up": "yarn upgrade-interactive --latest"
},
"madge": {
"detectiveOptions": {
@ -54,7 +55,7 @@
},
"dependencies": {
"@chakra-ui/anatomy": "^2.2.0",
"@chakra-ui/icons": "^2.0.19",
"@chakra-ui/icons": "^2.1.0",
"@chakra-ui/react": "^2.8.0",
"@chakra-ui/styled-system": "^2.9.1",
"@chakra-ui/theme-tools": "^2.1.0",
@ -65,56 +66,55 @@
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@floating-ui/react-dom": "^2.0.1",
"@fontsource-variable/inter": "^5.0.3",
"@fontsource/inter": "^5.0.3",
"@mantine/core": "^6.0.14",
"@mantine/form": "^6.0.15",
"@mantine/hooks": "^6.0.14",
"@fontsource-variable/inter": "^5.0.8",
"@fontsource/inter": "^5.0.8",
"@mantine/core": "^6.0.19",
"@mantine/form": "^6.0.19",
"@mantine/hooks": "^6.0.19",
"@nanostores/react": "^0.7.1",
"@reduxjs/toolkit": "^1.9.5",
"@roarr/browser-log-writer": "^1.1.5",
"dateformat": "^5.0.3",
"downshift": "^7.6.0",
"formik": "^2.4.2",
"framer-motion": "^10.12.17",
"formik": "^2.4.3",
"framer-motion": "^10.16.1",
"fuse.js": "^6.6.2",
"i18next": "^23.2.3",
"i18next": "^23.4.4",
"i18next-browser-languagedetector": "^7.0.2",
"i18next-http-backend": "^2.2.1",
"konva": "^9.2.0",
"lodash-es": "^4.17.21",
"nanostores": "^0.9.2",
"new-github-issue-url": "^1.0.0",
"openapi-fetch": "^0.6.1",
"openapi-fetch": "^0.7.4",
"overlayscrollbars": "^2.2.0",
"overlayscrollbars-react": "^0.5.0",
"patch-package": "^7.0.0",
"patch-package": "^8.0.0",
"query-string": "^8.1.0",
"re-resizable": "^6.9.9",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-error-boundary": "^4.0.11",
"react-hotkeys-hook": "4.4.0",
"react-i18next": "^13.0.1",
"react-hotkeys-hook": "4.4.1",
"react-i18next": "^13.1.2",
"react-icons": "^4.10.1",
"react-konva": "^18.2.10",
"react-redux": "^8.1.1",
"react-resizable-panels": "^0.0.52",
"react-redux": "^8.1.2",
"react-resizable-panels": "^0.0.55",
"react-use": "^17.4.0",
"react-virtuoso": "^4.3.11",
"react-virtuoso": "^4.5.0",
"react-zoom-pan-pinch": "^3.0.8",
"reactflow": "^11.7.4",
"reactflow": "^11.8.3",
"redux-dynamic-middlewares": "^2.2.0",
"redux-remember": "^3.3.1",
"roarr": "^7.15.0",
"serialize-error": "^11.0.0",
"socket.io-client": "^4.7.0",
"redux-remember": "^4.0.1",
"roarr": "^7.15.1",
"serialize-error": "^11.0.1",
"socket.io-client": "^4.7.2",
"use-debounce": "^9.0.4",
"use-image": "^1.1.1",
"uuid": "^9.0.0",
"zod": "^3.21.4"
"zod": "^3.22.2",
"zod-validation-error": "^1.5.0"
},
"peerDependencies": {
"@chakra-ui/cli": "^2.4.0",
@ -127,38 +127,36 @@
"@chakra-ui/cli": "^2.4.1",
"@types/dateformat": "^5.0.0",
"@types/lodash-es": "^4.14.194",
"@types/node": "^20.3.1",
"@types/react": "^18.2.14",
"@types/node": "^20.5.1",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.6",
"@types/react-redux": "^7.1.25",
"@types/react-transition-group": "^4.4.6",
"@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^5.60.0",
"@typescript-eslint/parser": "^5.60.0",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"@vitejs/plugin-react-swc": "^3.3.2",
"axios": "^1.4.0",
"babel-plugin-transform-imports": "^2.0.0",
"concurrently": "^8.2.0",
"eslint": "^8.43.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2",
"eslint": "^8.47.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"form-data": "^4.0.0",
"husky": "^8.0.3",
"lint-staged": "^13.2.2",
"lint-staged": "^14.0.1",
"madge": "^6.1.0",
"openapi-types": "^12.1.3",
"openapi-typescript": "^6.2.8",
"openapi-typescript-codegen": "^0.24.0",
"openapi-typescript": "^6.5.2",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.8.8",
"prettier": "^3.0.2",
"rollup-plugin-visualizer": "^5.9.2",
"terser": "^5.18.1",
"ts-toolbelt": "^9.6.0",
"vite": "^4.3.9",
"vite-plugin-css-injected-by-js": "^3.1.1",
"vite-plugin-dts": "^2.3.0",
"vite": "^4.4.9",
"vite-plugin-css-injected-by-js": "^3.3.0",
"vite-plugin-dts": "^3.5.2",
"vite-plugin-eslint": "^1.8.1",
"vite-tsconfig-paths": "^4.2.0",
"yarn": "^1.22.19"

View File

@ -19,7 +19,7 @@
"toggleAutoscroll": "Toggle autoscroll",
"toggleLogViewer": "Toggle Log Viewer",
"showGallery": "Show Gallery",
"showOptionsPanel": "Show Options Panel",
"showOptionsPanel": "Show Side Panel",
"menu": "Menu"
},
"common": {
@ -95,7 +95,6 @@
"statusModelConverted": "Model Converted",
"statusMergingModels": "Merging Models",
"statusMergedModels": "Models Merged",
"pinOptionsPanel": "Pin Options Panel",
"loading": "Loading",
"loadingInvokeAI": "Loading Invoke AI",
"random": "Random",
@ -116,7 +115,6 @@
"maintainAspectRatio": "Maintain Aspect Ratio",
"autoSwitchNewImages": "Auto-Switch to New Images",
"singleColumnLayout": "Single Column Layout",
"pinGallery": "Pin Gallery",
"allImagesLoaded": "All Images Loaded",
"loadMore": "Load More",
"noImagesInGallery": "No Images to Display",
@ -577,7 +575,7 @@
"resetWebUI": "Reset Web UI",
"resetWebUIDesc1": "Resetting the web UI only resets the browser's local cache of your images and remembered settings. It does not delete any images from disk.",
"resetWebUIDesc2": "If images aren't showing up in the gallery or something else isn't working, please try resetting before submitting an issue on GitHub.",
"resetComplete": "Web UI has been reset. Refresh the page to reload.",
"resetComplete": "Web UI has been reset.",
"consoleLogLevel": "Log Level",
"shouldLogToConsole": "Console Logging",
"developer": "Developer",
@ -720,11 +718,12 @@
"swapSizes": "Swap Sizes"
},
"nodes": {
"reloadSchema": "Reload Schema",
"saveGraph": "Save Graph",
"loadGraph": "Load Graph (saved from Node Editor) (Do not copy-paste metadata)",
"clearGraph": "Clear Graph",
"clearGraphDesc": "Are you sure you want to clear all nodes?",
"reloadNodeTemplates": "Reload Node Templates",
"saveWorkflow": "Save Workflow",
"loadWorkflow": "Load Workflow",
"resetWorkflow": "Reset Workflow",
"resetWorkflowDesc": "Are you sure you want to reset this workflow?",
"resetWorkflowDesc2": "Resetting the workflow will clear all nodes, edges and workflow details.",
"zoomInNodes": "Zoom In",
"zoomOutNodes": "Zoom Out",
"fitViewportNodes": "Fit View",

View File

@ -27,21 +27,10 @@ async function main() {
* field accepts connection input. If it does, we can make the field optional.
*/
// Check if we are generating types for an invocation
const isInvocationPath = metadata.path.match(
/^#\/components\/schemas\/\w*Invocation$/
);
const hasInvocationProperties =
schemaObject.properties &&
['id', 'is_intermediate', 'type'].every(
(prop) => prop in schemaObject.properties
);
if (isInvocationPath && hasInvocationProperties) {
if ('class' in schemaObject && schemaObject.class === 'invocation') {
// We only want to make fields optional if they are required
if (!Array.isArray(schemaObject?.required)) {
schemaObject.required = ['id', 'type'];
schemaObject.required = [];
}
schemaObject.required.forEach((prop) => {
@ -60,32 +49,12 @@ async function main() {
);
}
});
schemaObject.required = [
...new Set(schemaObject.required.concat(['id', 'type'])),
];
return;
}
// Check if we are generating types for an invocation output
const isInvocationOutputPath = metadata.path.match(
/^#\/components\/schemas\/\w*Output$/
);
const hasOutputProperties =
schemaObject.properties && 'type' in schemaObject.properties;
if (isInvocationOutputPath && hasOutputProperties) {
if (!Array.isArray(schemaObject?.required)) {
schemaObject.required = ['type'];
}
schemaObject.required = [
...new Set(schemaObject.required.concat(['type'])),
];
console.log(
`Making output's "type" required: ${COLORS.fg.yellow}${schemaObject.title}${COLORS.reset}`
);
if ('class' in schemaObject && schemaObject.class === 'output') {
// modify output types
}
},
});

View File

@ -1,4 +1,4 @@
import { Flex, Grid, Portal } from '@chakra-ui/react';
import { Flex, Grid } from '@chakra-ui/react';
import { useLogger } from 'app/logging/useLogger';
import { appStarted } from 'app/store/middleware/listenerMiddleware/listeners/appStarted';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
@ -6,21 +6,17 @@ import { PartialAppConfig } from 'app/types/invokeai';
import ImageUploader from 'common/components/ImageUploader';
import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal';
import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal';
import GalleryDrawer from 'features/gallery/components/GalleryPanel';
import SiteHeader from 'features/system/components/SiteHeader';
import { configChanged } from 'features/system/store/configSlice';
import { languageSelector } from 'features/system/store/systemSelectors';
import FloatingGalleryButton from 'features/ui/components/FloatingGalleryButton';
import FloatingParametersPanelButtons from 'features/ui/components/FloatingParametersPanelButtons';
import InvokeTabs from 'features/ui/components/InvokeTabs';
import ParametersDrawer from 'features/ui/components/ParametersDrawer';
import i18n from 'i18n';
import { size } from 'lodash-es';
import { ReactNode, memo, useCallback, useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import AppErrorBoundaryFallback from './AppErrorBoundaryFallback';
import GlobalHotkeys from './GlobalHotkeys';
import Toaster from './Toaster';
import AppErrorBoundaryFallback from './AppErrorBoundaryFallback';
const DEFAULT_CONFIG = {};
@ -32,7 +28,7 @@ interface Props {
const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => {
const language = useAppSelector(languageSelector);
const logger = useLogger();
const logger = useLogger('system');
const dispatch = useAppDispatch();
const handleReset = useCallback(() => {
localStorage.clear();
@ -46,7 +42,7 @@ const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => {
useEffect(() => {
if (size(config)) {
logger.info({ namespace: 'App', config }, 'Received config');
logger.info({ config }, 'Received config');
dispatch(configChanged(config));
}
}, [dispatch, config, logger]);
@ -83,15 +79,6 @@ const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => {
</Flex>
</Grid>
</ImageUploader>
<GalleryDrawer />
<ParametersDrawer />
<Portal>
<FloatingParametersPanelButtons />
</Portal>
<Portal>
<FloatingGalleryButton />
</Portal>
</Grid>
<DeleteImageModal />
<ChangeBoardModal />

View File

@ -1,30 +1,21 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import {
ctrlKeyPressed,
metaKeyPressed,
shiftKeyPressed,
} from 'features/ui/store/hotkeysSlice';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import {
setActiveTab,
toggleGalleryPanel,
toggleParametersPanel,
togglePinGalleryPanel,
togglePinParametersPanel,
} from 'features/ui/store/uiSlice';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { isEqual } from 'lodash-es';
import React, { memo } from 'react';
import { isHotkeyPressed, useHotkeys } from 'react-hotkeys-hook';
const globalHotkeysSelector = createSelector(
[stateSelector],
({ hotkeys, ui }) => {
({ hotkeys }) => {
const { shift, ctrl, meta } = hotkeys;
const { shouldPinParametersPanel, shouldPinGallery } = ui;
return { shift, ctrl, meta, shouldPinGallery, shouldPinParametersPanel };
return { shift, ctrl, meta };
},
{
memoizeOptions: {
@ -41,9 +32,7 @@ const globalHotkeysSelector = createSelector(
*/
const GlobalHotkeys: React.FC = () => {
const dispatch = useAppDispatch();
const { shift, ctrl, meta, shouldPinParametersPanel, shouldPinGallery } =
useAppSelector(globalHotkeysSelector);
const activeTabName = useAppSelector(activeTabNameSelector);
const { shift, ctrl, meta } = useAppSelector(globalHotkeysSelector);
useHotkeys(
'*',
@ -68,34 +57,6 @@ const GlobalHotkeys: React.FC = () => {
[shift, ctrl, meta]
);
useHotkeys('o', () => {
dispatch(toggleParametersPanel());
if (activeTabName === 'unifiedCanvas' && shouldPinParametersPanel) {
dispatch(requestCanvasRescale());
}
});
useHotkeys(['shift+o'], () => {
dispatch(togglePinParametersPanel());
if (activeTabName === 'unifiedCanvas') {
dispatch(requestCanvasRescale());
}
});
useHotkeys('g', () => {
dispatch(toggleGalleryPanel());
if (activeTabName === 'unifiedCanvas' && shouldPinGallery) {
dispatch(requestCanvasRescale());
}
});
useHotkeys(['shift+g'], () => {
dispatch(togglePinGalleryPanel());
if (activeTabName === 'unifiedCanvas') {
dispatch(requestCanvasRescale());
}
});
useHotkeys('1', () => {
dispatch(setActiveTab('txt2img'));
});
@ -112,6 +73,10 @@ const GlobalHotkeys: React.FC = () => {
dispatch(setActiveTab('nodes'));
});
useHotkeys('5', () => {
dispatch(setActiveTab('modelManager'));
});
return null;
};

View File

@ -9,7 +9,7 @@ export const log = Roarr.child(BASE_CONTEXT);
export const $logger = atom<Logger>(Roarr.child(BASE_CONTEXT));
type LoggerNamespace =
export type LoggerNamespace =
| 'images'
| 'models'
| 'config'

View File

@ -1,12 +1,17 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { createLogWriter } from '@roarr/browser-log-writer';
import { useAppSelector } from 'app/store/storeHooks';
import { systemSelector } from 'features/system/store/systemSelectors';
import { isEqual } from 'lodash-es';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { ROARR, Roarr } from 'roarr';
import { $logger, BASE_CONTEXT, LOG_LEVEL_MAP } from './logger';
import {
$logger,
BASE_CONTEXT,
LOG_LEVEL_MAP,
LoggerNamespace,
logger,
} from './logger';
const selector = createSelector(
systemSelector,
@ -25,7 +30,7 @@ const selector = createSelector(
}
);
export const useLogger = () => {
export const useLogger = (namespace: LoggerNamespace) => {
const { consoleLogLevel, shouldLogToConsole } = useAppSelector(selector);
// The provided Roarr browser log writer uses localStorage to config logging to console
@ -57,7 +62,7 @@ export const useLogger = () => {
$logger.set(Roarr.child(newContext));
}, []);
const logger = useStore($logger);
const log = useMemo(() => logger(namespace), [namespace]);
return logger;
return log;
};

View File

@ -5,6 +5,7 @@ import { modelsApi } from 'services/api/endpoints/models';
import { receivedOpenAPISchema } from 'services/api/thunks/schema';
import { appSocketConnected, socketConnected } from 'services/events/actions';
import { startAppListening } from '../..';
import { size } from 'lodash-es';
export const addSocketConnectedEventListener = () => {
startAppListening({
@ -18,7 +19,7 @@ export const addSocketConnectedEventListener = () => {
const { disabledTabs } = config;
if (!nodes.schema && !disabledTabs.includes('nodes')) {
if (!size(nodes.nodeTemplates) && !disabledTabs.includes('nodes')) {
dispatch(receivedOpenAPISchema());
}

View File

@ -34,14 +34,10 @@ const IAICollapse = (props: IAIToggleCollapseProps) => {
gap: 2,
borderTopRadius: 'base',
borderBottomRadius: isOpen ? 0 : 'base',
bg: isOpen
? mode('base.200', 'base.750')(colorMode)
: mode('base.150', 'base.800')(colorMode),
bg: mode('base.250', 'base.750')(colorMode),
color: mode('base.900', 'base.100')(colorMode),
_hover: {
bg: isOpen
? mode('base.250', 'base.700')(colorMode)
: mode('base.200', 'base.750')(colorMode),
bg: mode('base.300', 'base.700')(colorMode),
},
fontSize: 'sm',
fontWeight: 600,
@ -90,9 +86,10 @@ const IAICollapse = (props: IAIToggleCollapseProps) => {
<Collapse in={isOpen} animateOpacity style={{ overflow: 'unset' }}>
<Box
sx={{
p: 4,
p: 2,
pt: 3,
borderBottomRadius: 'base',
bg: 'base.100',
bg: 'base.150',
_dark: {
bg: 'base.800',
},

View File

@ -126,7 +126,7 @@ const IAIDndImage = (props: IAIDndImageProps) => {
? {}
: {
cursor: 'pointer',
bg: mode('base.200', 'base.800')(colorMode),
bg: mode('base.200', 'base.700')(colorMode),
_hover: {
bg: mode('base.300', 'base.650')(colorMode),
color: mode('base.500', 'base.300')(colorMode),

View File

@ -32,6 +32,10 @@ const selector = createSelector(
}
if (activeTabName === 'nodes' && nodes.shouldValidateGraph) {
if (!nodes.nodes.length) {
reasons.push('No nodes in graph');
}
nodes.nodes.forEach((node) => {
if (!isInvocationNode(node)) {
return;

View File

@ -0,0 +1,2 @@
export const colorTokenToCssVar = (colorToken: string) =>
`var(--invokeai-colors-${colorToken.split('.').join('-')}`;

View File

@ -1,6 +1,6 @@
import { Box, chakra, Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import {
canvasSelector,
@ -9,7 +9,7 @@ import {
import Konva from 'konva';
import { KonvaEventObject } from 'konva/lib/Node';
import { Vector2d } from 'konva/lib/types';
import { memo, useCallback, useRef } from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import { Layer, Stage } from 'react-konva';
import useCanvasDragMove from '../hooks/useCanvasDragMove';
import useCanvasHotkeys from '../hooks/useCanvasHotkeys';
@ -18,6 +18,7 @@ import useCanvasMouseMove from '../hooks/useCanvasMouseMove';
import useCanvasMouseOut from '../hooks/useCanvasMouseOut';
import useCanvasMouseUp from '../hooks/useCanvasMouseUp';
import useCanvasWheel from '../hooks/useCanvasZoom';
import { canvasResized } from '../store/canvasSlice';
import {
setCanvasBaseLayer,
setCanvasStage,
@ -106,7 +107,8 @@ const IAICanvas = () => {
shouldAntialias,
} = useAppSelector(selector);
useCanvasHotkeys();
const dispatch = useAppDispatch();
const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage | null>(null);
const canvasBaseLayerRef = useRef<Konva.Layer | null>(null);
@ -137,8 +139,30 @@ const IAICanvas = () => {
const { handleDragStart, handleDragMove, handleDragEnd } =
useCanvasDragMove();
useEffect(() => {
if (!containerRef.current) {
return;
}
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentBoxSize) {
const { width, height } = entry.contentRect;
dispatch(canvasResized({ width, height }));
}
}
});
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
}, [dispatch]);
return (
<Flex
id="canvas-container"
ref={containerRef}
sx={{
position: 'relative',
height: '100%',
@ -146,13 +170,18 @@ const IAICanvas = () => {
borderRadius: 'base',
}}
>
<Box sx={{ position: 'relative' }}>
<Box
sx={{
position: 'absolute',
// top: 0,
// insetInlineStart: 0,
}}
>
<ChakraStage
tabIndex={-1}
ref={canvasStageRefCallback}
sx={{
outline: 'none',
// boxShadow: '0px 0px 0px 1px var(--border-color-light)',
overflow: 'hidden',
cursor: stageCursor ? stageCursor : undefined,
canvas: {
@ -213,9 +242,9 @@ const IAICanvas = () => {
/>
</Layer>
</ChakraStage>
</Box>
<IAICanvasStatusText />
<IAICanvasStagingAreaToolbar />
</Box>
</Flex>
);
};

View File

@ -1,91 +0,0 @@
import { Flex, Spinner } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
canvasSelector,
initialCanvasImageSelector,
} from 'features/canvas/store/canvasSelectors';
import {
resizeAndScaleCanvas,
resizeCanvas,
setCanvasContainerDimensions,
setDoesCanvasNeedScaling,
} from 'features/canvas/store/canvasSlice';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useLayoutEffect, useRef } from 'react';
const canvasResizerSelector = createSelector(
canvasSelector,
initialCanvasImageSelector,
activeTabNameSelector,
(canvas, initialCanvasImage, activeTabName) => {
const { doesCanvasNeedScaling, isCanvasInitialized } = canvas;
return {
doesCanvasNeedScaling,
activeTabName,
initialCanvasImage,
isCanvasInitialized,
};
}
);
const IAICanvasResizer = () => {
const dispatch = useAppDispatch();
const {
doesCanvasNeedScaling,
activeTabName,
initialCanvasImage,
isCanvasInitialized,
} = useAppSelector(canvasResizerSelector);
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
window.setTimeout(() => {
if (!ref.current) {
return;
}
const { clientWidth, clientHeight } = ref.current;
dispatch(
setCanvasContainerDimensions({
width: clientWidth,
height: clientHeight,
})
);
if (!isCanvasInitialized) {
dispatch(resizeAndScaleCanvas());
} else {
dispatch(resizeCanvas());
}
dispatch(setDoesCanvasNeedScaling(false));
}, 0);
}, [
dispatch,
initialCanvasImage,
doesCanvasNeedScaling,
activeTabName,
isCanvasInitialized,
]);
return (
<Flex
ref={ref}
sx={{
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
width: '100%',
height: '100%',
}}
>
<Spinner thickness="2px" size="xl" />
</Flex>
);
};
export default memo(IAICanvasResizer);

View File

@ -140,11 +140,10 @@ const IAICanvasStagingAreaToolbar = () => {
w="100%"
align="center"
justify="center"
filter="drop-shadow(0 0.5rem 1rem rgba(0,0,0))"
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
>
<ButtonGroup isAttached>
<ButtonGroup isAttached borderRadius="base" shadow="dark-lg">
<IAIIconButton
tooltip={`${t('unifiedCanvas.previous')} (Left)`}
aria-label={`${t('unifiedCanvas.previous')} (Left)`}

View File

@ -18,7 +18,6 @@ import {
import {
resetCanvas,
resetCanvasView,
resizeAndScaleCanvas,
setIsMaskEnabled,
setLayer,
setTool,
@ -183,7 +182,6 @@ const IAICanvasToolbar = () => {
const handleResetCanvas = () => {
dispatch(resetCanvas());
dispatch(resizeAndScaleCanvas());
};
const handleMergeVisible = () => {

View File

@ -3,8 +3,4 @@ import { CanvasState } from './canvasTypes';
/**
* Canvas slice persist denylist
*/
export const canvasPersistDenylist: (keyof CanvasState)[] = [
'cursorPosition',
'isCanvasInitialized',
'doesCanvasNeedScaling',
];
export const canvasPersistDenylist: (keyof CanvasState)[] = ['cursorPosition'];

View File

@ -5,10 +5,6 @@ import {
roundToMultiple,
} from 'common/util/roundDownToMultiple';
import { setAspectRatio } from 'features/parameters/store/generationSlice';
import {
setActiveTab,
setShouldUseCanvasBetaLayout,
} from 'features/ui/store/uiSlice';
import { IRect, Vector2d } from 'konva/lib/types';
import { clamp, cloneDeep } from 'lodash-es';
import { RgbaColor } from 'react-colorful';
@ -50,12 +46,9 @@ export const initialCanvasState: CanvasState = {
boundingBoxScaleMethod: 'none',
brushColor: { r: 90, g: 90, b: 255, a: 1 },
brushSize: 50,
canvasContainerDimensions: { width: 0, height: 0 },
colorPickerColor: { r: 90, g: 90, b: 255, a: 1 },
cursorPosition: null,
doesCanvasNeedScaling: false,
futureLayerStates: [],
isCanvasInitialized: false,
isDrawing: false,
isMaskEnabled: true,
isMouseOverBoundingBox: false,
@ -208,7 +201,6 @@ export const canvasSlice = createSlice({
};
state.futureLayerStates = [];
state.isCanvasInitialized = false;
const newScale = calculateScale(
stageDimensions.width,
stageDimensions.height,
@ -228,7 +220,6 @@ export const canvasSlice = createSlice({
);
state.stageScale = newScale;
state.stageCoordinates = newCoordinates;
state.doesCanvasNeedScaling = true;
},
setBoundingBoxDimensions: (state, action: PayloadAction<Dimensions>) => {
const newDimensions = roundDimensionsTo64(action.payload);
@ -258,9 +249,6 @@ export const canvasSlice = createSlice({
setBoundingBoxPreviewFill: (state, action: PayloadAction<RgbaColor>) => {
state.boundingBoxPreviewFill = action.payload;
},
setDoesCanvasNeedScaling: (state, action: PayloadAction<boolean>) => {
state.doesCanvasNeedScaling = action.payload;
},
setStageScale: (state, action: PayloadAction<number>) => {
state.stageScale = action.payload;
},
@ -493,97 +481,14 @@ export const canvasSlice = createSlice({
state.layerState = initialLayerState;
state.futureLayerStates = [];
},
setCanvasContainerDimensions: (
canvasResized: (
state,
action: PayloadAction<Dimensions>
action: PayloadAction<{ width: number; height: number }>
) => {
state.canvasContainerDimensions = action.payload;
},
resizeAndScaleCanvas: (state) => {
const { width: containerWidth, height: containerHeight } =
state.canvasContainerDimensions;
const initialCanvasImage =
state.layerState.objects.find(isCanvasBaseImage);
const { width, height } = action.payload;
const newStageDimensions = {
width: Math.floor(containerWidth),
height: Math.floor(containerHeight),
};
if (!initialCanvasImage) {
const newScale = calculateScale(
newStageDimensions.width,
newStageDimensions.height,
512,
512,
STAGE_PADDING_PERCENTAGE
);
const newCoordinates = calculateCoordinates(
newStageDimensions.width,
newStageDimensions.height,
0,
0,
512,
512,
newScale
);
const newBoundingBoxDimensions = { width: 512, height: 512 };
state.stageScale = newScale;
state.stageCoordinates = newCoordinates;
state.stageDimensions = newStageDimensions;
state.boundingBoxCoordinates = { x: 0, y: 0 };
state.boundingBoxDimensions = newBoundingBoxDimensions;
if (state.boundingBoxScaleMethod === 'auto') {
const scaledDimensions = getScaledBoundingBoxDimensions(
newBoundingBoxDimensions
);
state.scaledBoundingBoxDimensions = scaledDimensions;
}
return;
}
const { width: imageWidth, height: imageHeight } = initialCanvasImage;
const padding = 0.95;
const newScale = calculateScale(
containerWidth,
containerHeight,
imageWidth,
imageHeight,
padding
);
const newCoordinates = calculateCoordinates(
newStageDimensions.width,
newStageDimensions.height,
0,
0,
imageWidth,
imageHeight,
newScale
);
state.minimumStageScale = newScale;
state.stageScale = newScale;
state.stageCoordinates = floorCoordinates(newCoordinates);
state.stageDimensions = newStageDimensions;
state.isCanvasInitialized = true;
},
resizeCanvas: (state) => {
const { width: containerWidth, height: containerHeight } =
state.canvasContainerDimensions;
const newStageDimensions = {
width: Math.floor(containerWidth),
height: Math.floor(containerHeight),
width: Math.floor(width),
height: Math.floor(height),
};
state.stageDimensions = newStageDimensions;
@ -876,14 +781,6 @@ export const canvasSlice = createSlice({
state.layerState.stagingArea = initialLayerState.stagingArea;
}
});
builder.addCase(setShouldUseCanvasBetaLayout, (state) => {
state.doesCanvasNeedScaling = true;
});
builder.addCase(setActiveTab, (state) => {
state.doesCanvasNeedScaling = true;
});
builder.addCase(setAspectRatio, (state, action) => {
const ratio = action.payload;
if (ratio) {
@ -915,8 +812,6 @@ export const {
resetCanvas,
resetCanvasInteractionState,
resetCanvasView,
resizeAndScaleCanvas,
resizeCanvas,
setBoundingBoxCoordinates,
setBoundingBoxDimensions,
setBoundingBoxPreviewFill,
@ -924,10 +819,8 @@ export const {
flipBoundingBoxAxes,
setBrushColor,
setBrushSize,
setCanvasContainerDimensions,
setColorPickerColor,
setCursorPosition,
setDoesCanvasNeedScaling,
setInitialCanvasImage,
setIsDrawing,
setIsMaskEnabled,
@ -966,6 +859,7 @@ export const {
stagingAreaInitialized,
canvasSessionIdChanged,
setShouldAntialias,
canvasResized,
} = canvasSlice.actions;
export default canvasSlice.reducer;

View File

@ -126,12 +126,9 @@ export interface CanvasState {
boundingBoxScaleMethod: BoundingBoxScale;
brushColor: RgbaColor;
brushSize: number;
canvasContainerDimensions: Dimensions;
colorPickerColor: RgbaColor;
cursorPosition: Vector2d | null;
doesCanvasNeedScaling: boolean;
futureLayerStates: CanvasLayerState[];
isCanvasInitialized: boolean;
isDrawing: boolean;
isMaskEnabled: boolean;
isMouseOverBoundingBox: boolean;

View File

@ -1,16 +0,0 @@
import { AppDispatch, AppGetState } from 'app/store/store';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { debounce } from 'lodash-es';
import { setDoesCanvasNeedScaling } from '../canvasSlice';
const debouncedCanvasScale = debounce((dispatch: AppDispatch) => {
dispatch(setDoesCanvasNeedScaling(true));
}, 300);
export const requestCanvasRescale =
() => (dispatch: AppDispatch, getState: AppGetState) => {
const activeTabName = activeTabNameSelector(getState());
if (activeTabName === 'unifiedCanvas') {
debouncedCanvasScale(dispatch);
}
};

View File

@ -80,12 +80,12 @@ const ControlNet = (props: ControlNetProps) => {
sx={{
flexDir: 'column',
gap: 3,
p: 3,
p: 2,
borderRadius: 'base',
position: 'relative',
bg: 'base.200',
bg: 'base.250',
_dark: {
bg: 'base.850',
bg: 'base.750',
},
}}
>
@ -194,7 +194,7 @@ const ControlNet = (props: ControlNetProps) => {
aspectRatio: '1/1',
}}
>
<ControlNetImagePreview controlNet={controlNet} height={28} />
<ControlNetImagePreview controlNet={controlNet} isSmall />
</Flex>
)}
</Flex>
@ -207,7 +207,7 @@ const ControlNet = (props: ControlNetProps) => {
{isExpanded && (
<>
<ControlNetImagePreview controlNet={controlNet} height="392px" />
<ControlNetImagePreview controlNet={controlNet} />
<ParamControlNetShouldAutoConfig controlNet={controlNet} />
<ControlNetProcessorComponent controlNet={controlNet} />
</>

View File

@ -1,14 +1,14 @@
import { Box, Flex, Spinner, SystemStyleObject } from '@chakra-ui/react';
import { Box, Flex, Spinner } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/dist/query';
import {
TypesafeDraggableData,
TypesafeDroppableData,
} from 'features/dnd/types';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage';
import {
TypesafeDraggableData,
TypesafeDroppableData,
} from 'features/dnd/types';
import { memo, useCallback, useMemo, useState } from 'react';
import { FaUndo } from 'react-icons/fa';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
@ -21,7 +21,7 @@ import {
type Props = {
controlNet: ControlNetConfig;
height: SystemStyleObject['h'];
isSmall?: boolean;
};
const selector = createSelector(
@ -36,15 +36,14 @@ const selector = createSelector(
defaultSelectorOptions
);
const ControlNetImagePreview = (props: Props) => {
const { height } = props;
const ControlNetImagePreview = ({ isSmall, controlNet }: Props) => {
const {
controlImage: controlImageName,
processedControlImage: processedControlImageName,
processorType,
isEnabled,
controlNetId,
} = props.controlNet;
} = controlNet;
const dispatch = useAppDispatch();
@ -109,7 +108,7 @@ const ControlNetImagePreview = (props: Props) => {
sx={{
position: 'relative',
w: 'full',
h: height,
h: isSmall ? 28 : 366, // magic no touch
alignItems: 'center',
justifyContent: 'center',
pointerEvents: isEnabled ? 'auto' : 'none',

View File

@ -1,119 +0,0 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { gallerySelector } from 'features/gallery/store/gallerySelectors';
import { setGalleryImageMinimumWidth } from 'features/gallery/store/gallerySlice';
import { clamp, isEqual } from 'lodash-es';
import { useHotkeys } from 'react-hotkeys-hook';
import { createSelector } from '@reduxjs/toolkit';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import ResizableDrawer from 'features/ui/components/common/ResizableDrawer/ResizableDrawer';
import {
activeTabNameSelector,
uiSelector,
} from 'features/ui/store/uiSelectors';
import { setShouldShowGallery } from 'features/ui/store/uiSlice';
import { memo } from 'react';
import ImageGalleryContent from './ImageGalleryContent';
const selector = createSelector(
[activeTabNameSelector, uiSelector, gallerySelector, isStagingSelector],
(activeTabName, ui, gallery, isStaging) => {
const { shouldPinGallery, shouldShowGallery } = ui;
const { galleryImageMinimumWidth } = gallery;
return {
activeTabName,
isStaging,
shouldPinGallery,
shouldShowGallery,
galleryImageMinimumWidth,
isResizable: activeTabName !== 'unifiedCanvas',
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
},
}
);
const GalleryDrawer = () => {
const dispatch = useAppDispatch();
const {
shouldPinGallery,
shouldShowGallery,
galleryImageMinimumWidth,
// activeTabName,
// isStaging,
// isResizable,
} = useAppSelector(selector);
const handleCloseGallery = () => {
dispatch(setShouldShowGallery(false));
shouldPinGallery && dispatch(requestCanvasRescale());
};
useHotkeys(
'esc',
() => {
dispatch(setShouldShowGallery(false));
},
{
enabled: () => !shouldPinGallery,
preventDefault: true,
},
[shouldPinGallery]
);
const IMAGE_SIZE_STEP = 32;
useHotkeys(
'shift+up',
() => {
if (galleryImageMinimumWidth < 256) {
const newMinWidth = clamp(
galleryImageMinimumWidth + IMAGE_SIZE_STEP,
32,
256
);
dispatch(setGalleryImageMinimumWidth(newMinWidth));
}
},
[galleryImageMinimumWidth]
);
useHotkeys(
'shift+down',
() => {
if (galleryImageMinimumWidth > 32) {
const newMinWidth = clamp(
galleryImageMinimumWidth - IMAGE_SIZE_STEP,
32,
256
);
dispatch(setGalleryImageMinimumWidth(newMinWidth));
}
},
[galleryImageMinimumWidth]
);
if (shouldPinGallery) {
return null;
}
return (
<ResizableDrawer
direction="right"
isResizable={true}
isOpen={shouldShowGallery}
onClose={handleCloseGallery}
minWidth={400}
>
<ImageGalleryContent />
</ResizableDrawer>
);
};
export default memo(GalleryDrawer);

View File

@ -1,45 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIIconButton from 'common/components/IAIIconButton';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { togglePinGalleryPanel } from 'features/ui/store/uiSlice';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
const selector = createSelector(
[stateSelector],
(state) => {
const { shouldPinGallery } = state.ui;
return {
shouldPinGallery,
};
},
defaultSelectorOptions
);
const GalleryPinButton = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { shouldPinGallery } = useAppSelector(selector);
const handleSetShouldPinGallery = () => {
dispatch(togglePinGalleryPanel());
dispatch(requestCanvasRescale());
};
return (
<IAIIconButton
size="sm"
aria-label={t('gallery.pinGallery')}
tooltip={`${t('gallery.pinGallery')} (Shift+G)`}
onClick={handleSetShouldPinGallery}
icon={shouldPinGallery ? <BsPinAngleFill /> : <BsPinAngle />}
/>
);
};
export default memo(GalleryPinButton);

View File

@ -2,10 +2,7 @@ import { MenuItem } from '@chakra-ui/react';
import { skipToken } from '@reduxjs/toolkit/dist/query';
import { useAppToaster } from 'app/components/Toaster';
import { useAppDispatch } from 'app/store/storeHooks';
import {
resizeAndScaleCanvas,
setInitialCanvasImage,
} from 'features/canvas/store/canvasSlice';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import {
imagesToChangeSelected,
isModalOpenChanged,
@ -29,6 +26,7 @@ import {
FaShare,
FaTrash,
} from 'react-icons/fa';
import { MdStar, MdStarBorder } from 'react-icons/md';
import {
useGetImageMetadataQuery,
useStarImagesMutation,
@ -37,7 +35,6 @@ import {
import { ImageDTO } from 'services/api/types';
import { useDebounce } from 'use-debounce';
import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions';
import { MdStar, MdStarBorder } from 'react-icons/md';
type SingleSelectionMenuItemsProps = {
imageDTO: ImageDTO;
@ -110,7 +107,6 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
const handleSendToCanvas = useCallback(() => {
dispatch(sentImageToCanvas());
dispatch(setInitialCanvasImage(imageDTO));
dispatch(resizeAndScaleCanvas());
dispatch(setActiveTab('unifiedCanvas'));
toaster({

View File

@ -18,7 +18,6 @@ import { FaImages, FaServer } from 'react-icons/fa';
import { galleryViewChanged } from '../store/gallerySlice';
import BoardsList from './Boards/BoardsList/BoardsList';
import GalleryBoardName from './GalleryBoardName';
import GalleryPinButton from './GalleryPinButton';
import GallerySettingsPopover from './GallerySettingsPopover';
import GalleryImageGrid from './ImageGrid/GalleryImageGrid';
@ -75,7 +74,6 @@ const ImageGalleryContent = () => {
onToggle={onToggleBoardList}
/>
<GallerySettingsPopover />
<GalleryPinButton />
</Flex>
<Box>
<BoardsList isOpen={isBoardListOpen} />

View File

@ -8,10 +8,12 @@ type Props = {
label: string;
data: object | string;
fileName?: string;
withDownload?: boolean;
withCopy?: boolean;
};
const DataViewer = (props: Props) => {
const { label, data, fileName } = props;
const { label, data, fileName, withDownload = true, withCopy = true } = props;
const dataString = useMemo(
() => (isString(data) ? data : JSON.stringify(data, null, 2)),
[data]
@ -70,6 +72,7 @@ const DataViewer = (props: Props) => {
</OverlayScrollbarsComponent>
</Box>
<Flex sx={{ position: 'absolute', top: 0, insetInlineEnd: 0, p: 2 }}>
{withDownload && (
<Tooltip label={`Save ${label} JSON`}>
<IconButton
aria-label={`Save ${label} JSON`}
@ -79,6 +82,8 @@ const DataViewer = (props: Props) => {
onClick={handleSave}
/>
</Tooltip>
)}
{withCopy && (
<Tooltip label={`Copy ${label} JSON`}>
<IconButton
aria-label={`Copy ${label} JSON`}
@ -88,6 +93,7 @@ const DataViewer = (props: Props) => {
onClick={handleCopy}
/>
</Tooltip>
)}
</Flex>
</Flex>
);

View File

@ -1,38 +1,21 @@
import { Flex } from '@chakra-ui/react';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
import { memo, useState } from 'react';
import { MdDeviceHub } from 'react-icons/md';
import { Panel, PanelGroup } from 'react-resizable-panels';
import 'reactflow/dist/style.css';
import NodeEditorPanelGroup from './sidePanel/NodeEditorPanelGroup';
import { Flow } from './flow/Flow';
import { AnimatePresence, motion } from 'framer-motion';
import { memo } from 'react';
import { MdDeviceHub } from 'react-icons/md';
import 'reactflow/dist/style.css';
import AddNodePopover from './flow/AddNodePopover/AddNodePopover';
import { Flow } from './flow/Flow';
import TopLeftPanel from './flow/panels/TopLeftPanel/TopLeftPanel';
import TopCenterPanel from './flow/panels/TopCenterPanel/TopCenterPanel';
import TopRightPanel from './flow/panels/TopRightPanel/TopRightPanel';
import BottomLeftPanel from './flow/panels/BottomLeftPanel/BottomLeftPanel';
import MinimapPanel from './flow/panels/MinimapPanel/MinimapPanel';
const NodeEditor = () => {
const [isPanelCollapsed, setIsPanelCollapsed] = useState(false);
const isReady = useAppSelector((state) => state.nodes.isReady);
return (
<PanelGroup
id="node-editor"
autoSaveId="node-editor"
direction="horizontal"
style={{ height: '100%', width: '100%' }}
>
<Panel
id="node-editor-panel-group"
collapsible
onCollapse={setIsPanelCollapsed}
minSize={25}
>
<NodeEditorPanelGroup />
</Panel>
<ResizeHandle
collapsedDirection={isPanelCollapsed ? 'left' : undefined}
/>
<Panel id="node-editor-content">
<Flex
layerStyle="first"
sx={{
@ -62,6 +45,11 @@ const NodeEditor = () => {
>
<Flow />
<AddNodePopover />
<TopLeftPanel />
<TopCenterPanel />
<TopRightPanel />
<BottomLeftPanel />
<MinimapPanel />
</motion.div>
)}
</AnimatePresence>
@ -102,8 +90,6 @@ const NodeEditor = () => {
)}
</AnimatePresence>
</Flex>
</Panel>
</PanelGroup>
);
};

View File

@ -140,7 +140,7 @@ const AddNodePopover = () => {
onClose();
}, [onClose]);
useHotkeys(['space', '/'], handleHotkeyOpen);
useHotkeys(['shift+a', 'space'], handleHotkeyOpen);
useHotkeys(['escape'], handleHotkeyClose);
return (

View File

@ -1,5 +1,8 @@
import { useToken } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { contextMenusClosed } from 'features/ui/store/uiSlice';
import { useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
@ -39,11 +42,6 @@ import InvocationDefaultEdge from './edges/InvocationDefaultEdge';
import CurrentImageNode from './nodes/CurrentImage/CurrentImageNode';
import InvocationNodeWrapper from './nodes/Invocation/InvocationNodeWrapper';
import NotesNode from './nodes/Notes/NotesNode';
import BottomLeftPanel from './panels/BottomLeftPanel/BottomLeftPanel';
import MinimapPanel from './panels/MinimapPanel/MinimapPanel';
import TopCenterPanel from './panels/TopCenterPanel/TopCenterPanel';
import TopLeftPanel from './panels/TopLeftPanel/TopLeftPanel';
import TopRightPanel from './panels/TopRightPanel/TopRightPanel';
const DELETE_KEYS = ['Delete', 'Backspace'];
@ -61,14 +59,24 @@ const nodeTypes = {
// TODO: can we support reactflow? if not, we could style the attribution so it matches the app
const proOptions: ProOptions = { hideAttribution: true };
const selector = createSelector(
stateSelector,
({ nodes }) => {
const { shouldSnapToGrid, selectionMode } = nodes;
return {
shouldSnapToGrid,
selectionMode,
};
},
defaultSelectorOptions
);
export const Flow = () => {
const dispatch = useAppDispatch();
const nodes = useAppSelector((state) => state.nodes.nodes);
const edges = useAppSelector((state) => state.nodes.edges);
const viewport = useAppSelector((state) => state.nodes.viewport);
const shouldSnapToGrid = useAppSelector(
(state) => state.nodes.shouldSnapToGrid
);
const { shouldSnapToGrid, selectionMode } = useAppSelector(selector);
const isValidConnection = useIsValidConnection();
@ -181,12 +189,8 @@ export const Flow = () => {
style={{ borderRadius }}
onPaneClick={handlePaneClick}
deleteKeyCode={DELETE_KEYS}
selectionMode={selectionMode}
>
<TopLeftPanel />
<TopCenterPanel />
<TopRightPanel />
<BottomLeftPanel />
<MinimapPanel />
<Background />
</ReactFlow>
);

View File

@ -1,9 +1,10 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { ConnectionLineComponentProps, getBezierPath } from 'reactflow';
import { FIELDS, colorTokenToCssVar } from '../../../types/constants';
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
import { FIELDS } from 'features/nodes/types/constants';
import { memo } from 'react';
import { ConnectionLineComponentProps, getBezierPath } from 'reactflow';
const selector = createSelector(stateSelector, ({ nodes }) => {
const { shouldAnimateEdges, currentConnectionFieldType, shouldColorEdges } =

View File

@ -1,7 +1,8 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { FIELDS, colorTokenToCssVar } from 'features/nodes/types/constants';
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
import { FIELDS } from 'features/nodes/types/constants';
import { isInvocationNode } from 'features/nodes/types/types';
export const makeEdgeSelector = (

View File

@ -1,12 +1,14 @@
import { Flex } from '@chakra-ui/react';
import { Flex, Grid, GridItem } from '@chakra-ui/react';
import { memo } from 'react';
import InvocationNodeFooter from './InvocationNodeFooter';
import InvocationNodeHeader from './InvocationNodeHeader';
import NodeWrapper from '../common/NodeWrapper';
import OutputField from './fields/OutputField';
import InputField from './fields/InputField';
import { useFieldNames } from 'features/nodes/hooks/useFieldNames';
import { useOutputFieldNames } from 'features/nodes/hooks/useOutputFieldNames';
import { useWithFooter } from 'features/nodes/hooks/useWithFooter';
import { useConnectionInputFieldNames } from 'features/nodes/hooks/useConnectionInputFieldNames';
import { useAnyOrDirectInputFieldNames } from 'features/nodes/hooks/useAnyOrDirectInputFieldNames';
type Props = {
nodeId: string;
@ -17,8 +19,9 @@ type Props = {
};
const InvocationNode = ({ nodeId, isOpen, label, type, selected }: Props) => {
const inputFieldNames = useFieldNames(nodeId, 'input');
const outputFieldNames = useFieldNames(nodeId, 'output');
const inputConnectionFieldNames = useConnectionInputFieldNames(nodeId);
const inputAnyOrDirectFieldNames = useAnyOrDirectInputFieldNames(nodeId);
const outputFieldNames = useOutputFieldNames(nodeId);
const withFooter = useWithFooter(nodeId);
return (
@ -44,14 +47,27 @@ const InvocationNode = ({ nodeId, isOpen, label, type, selected }: Props) => {
}}
>
<Flex sx={{ flexDir: 'column', px: 2, w: 'full', h: 'full' }}>
{outputFieldNames.map((fieldName) => (
<OutputField
key={`${nodeId}.${fieldName}.output-field`}
nodeId={nodeId}
fieldName={fieldName}
/>
<Grid gridTemplateColumns="1fr auto" gridAutoRows="1fr">
{inputConnectionFieldNames.map((fieldName, i) => (
<GridItem
gridColumnStart={1}
gridRowStart={i + 1}
key={`${nodeId}.${fieldName}.input-field`}
>
<InputField nodeId={nodeId} fieldName={fieldName} />
</GridItem>
))}
{inputFieldNames.map((fieldName) => (
{outputFieldNames.map((fieldName, i) => (
<GridItem
gridColumnStart={2}
gridRowStart={i + 1}
key={`${nodeId}.${fieldName}.output-field`}
>
<OutputField nodeId={nodeId} fieldName={fieldName} />
</GridItem>
))}
</Grid>
{inputAnyOrDirectFieldNames.map((fieldName) => (
<InputField
key={`${nodeId}.${fieldName}.input-field`}
nodeId={nodeId}

View File

@ -1,15 +1,15 @@
import { Tooltip } from '@chakra-ui/react';
import { CSSProperties, memo, useMemo } from 'react';
import { Handle, HandleType, Position } from 'reactflow';
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
import {
FIELDS,
HANDLE_TOOLTIP_OPEN_DELAY,
colorTokenToCssVar,
} from '../../../../../types/constants';
} from 'features/nodes/types/constants';
import {
InputFieldTemplate,
OutputFieldTemplate,
} from '../../../../../types/types';
} from 'features/nodes/types/types';
import { CSSProperties, memo, useMemo } from 'react';
import { Handle, HandleType, Position } from 'reactflow';
export const handleBaseStyles: CSSProperties = {
position: 'absolute',

View File

@ -31,7 +31,7 @@ const FieldTitle = forwardRef((props: Props, ref) => {
const handleSubmit = useCallback(
async (newTitle: string) => {
if (newTitle === label || newTitle === fieldTemplateTitle) {
if (newTitle && (newTitle === label || newTitle === fieldTemplateTitle)) {
return;
}
setLocalTitle(newTitle || fieldTemplateTitle || 'Unknown Field');

View File

@ -1,10 +1,4 @@
import {
Flex,
FormControl,
FormLabel,
Spacer,
Tooltip,
} from '@chakra-ui/react';
import { Flex, FormControl, FormLabel, Tooltip } from '@chakra-ui/react';
import { useConnectionState } from 'features/nodes/hooks/useConnectionState';
import { useFieldTemplate } from 'features/nodes/hooks/useFieldTemplate';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
@ -42,7 +36,6 @@ const OutputField = ({ nodeId, fieldName }: Props) => {
return (
<OutputFieldWrapper shouldDim={shouldDim}>
<Spacer />
<Tooltip
label={
<FieldTooltipContent
@ -90,6 +83,7 @@ const OutputFieldWrapper = memo(
opacity: shouldDim ? 0.5 : 1,
transitionProperty: 'opacity',
transitionDuration: '0.1s',
justifyContent: 'flex-end',
}}
>
{children}

View File

@ -4,13 +4,16 @@ import {
useColorModeValue,
useToken,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
DRAG_HANDLE_CLASSNAME,
NODE_WIDTH,
} from 'features/nodes/types/constants';
import { NodeStatus } from 'features/nodes/types/types';
import { contextMenusClosed } from 'features/ui/store/uiSlice';
import { PropsWithChildren, memo, useCallback } from 'react';
import { PropsWithChildren, memo, useCallback, useMemo } from 'react';
type NodeWrapperProps = PropsWithChildren & {
nodeId: string;
@ -19,25 +22,42 @@ type NodeWrapperProps = PropsWithChildren & {
};
const NodeWrapper = (props: NodeWrapperProps) => {
const { width, children, selected } = props;
const { nodeId, width, children, selected } = props;
const selectIsInProgress = useMemo(
() =>
createSelector(
stateSelector,
({ nodes }) =>
nodes.nodeExecutionStates[nodeId]?.status === NodeStatus.IN_PROGRESS
),
[nodeId]
);
const isInProgress = useAppSelector(selectIsInProgress);
const [
nodeSelectedOutlineLight,
nodeSelectedOutlineDark,
nodeSelectedLight,
nodeSelectedDark,
nodeInProgressLight,
nodeInProgressDark,
shadowsXl,
shadowsBase,
] = useToken('shadows', [
'nodeSelectedOutline.light',
'nodeSelectedOutline.dark',
'nodeSelected.light',
'nodeSelected.dark',
'nodeInProgress.light',
'nodeInProgress.dark',
'shadows.xl',
'shadows.base',
]);
const dispatch = useAppDispatch();
const shadow = useColorModeValue(
nodeSelectedOutlineLight,
nodeSelectedOutlineDark
const selectedShadow = useColorModeValue(nodeSelectedLight, nodeSelectedDark);
const inProgressShadow = useColorModeValue(
nodeInProgressLight,
nodeInProgressDark
);
const opacity = useAppSelector((state) => state.nodes.nodeOpacity);
@ -57,7 +77,11 @@ const NodeWrapper = (props: NodeWrapperProps) => {
w: width ?? NODE_WIDTH,
transitionProperty: 'common',
transitionDuration: '0.1s',
shadow: selected ? shadow : undefined,
shadow: selected
? isInProgress
? undefined
: selectedShadow
: undefined,
cursor: 'grab',
opacity,
}}
@ -75,6 +99,22 @@ const NodeWrapper = (props: NodeWrapperProps) => {
zIndex: -1,
}}
/>
<Box
sx={{
position: 'absolute',
top: 0,
insetInlineEnd: 0,
bottom: 0,
insetInlineStart: 0,
borderRadius: 'md',
pointerEvents: 'none',
transitionProperty: 'common',
transitionDuration: 'normal',
opacity: 0.7,
shadow: isInProgress ? inProgressShadow : undefined,
zIndex: -1,
}}
/>
{children}
</Box>
);

View File

@ -1,16 +1,13 @@
import { memo } from 'react';
import { Panel } from 'reactflow';
import ViewportControls from './ViewportControls';
import NodeOpacitySlider from './NodeOpacitySlider';
import { Flex } from '@chakra-ui/react';
import { memo } from 'react';
import NodeOpacitySlider from './NodeOpacitySlider';
import ViewportControls from './ViewportControls';
const BottomLeftPanel = () => (
<Panel position="bottom-left">
<Flex sx={{ gap: 2 }}>
<Flex sx={{ gap: 2, position: 'absolute', bottom: 2, insetInlineStart: 2 }}>
<ViewportControls />
<NodeOpacitySlider />
</Flex>
</Panel>
);
export default memo(BottomLeftPanel);

View File

@ -1,20 +1,19 @@
import { ButtonGroup, Tooltip } from '@chakra-ui/react';
import { ButtonGroup } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import {
// shouldShowFieldTypeLegendChanged,
shouldShowMinimapPanelChanged,
} from 'features/nodes/store/nodesSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
FaExpand,
// FaInfo,
FaMapMarkerAlt,
FaMinus,
FaPlus,
} from 'react-icons/fa';
import { FaMagnifyingGlassMinus, FaMagnifyingGlassPlus } from 'react-icons/fa6';
import { useReactFlow } from 'reactflow';
import {
// shouldShowFieldTypeLegendChanged,
shouldShowMinimapPanelChanged,
} from 'features/nodes/store/nodesSlice';
const ViewportControls = () => {
const { t } = useTranslation();
@ -49,27 +48,24 @@ const ViewportControls = () => {
return (
<ButtonGroup isAttached orientation="vertical">
<Tooltip label={t('nodes.zoomInNodes')}>
<IAIIconButton
aria-label="Zoom in "
tooltip={t('nodes.zoomInNodes')}
aria-label={t('nodes.zoomInNodes')}
onClick={handleClickedZoomIn}
icon={<FaPlus />}
icon={<FaMagnifyingGlassPlus />}
/>
</Tooltip>
<Tooltip label={t('nodes.zoomOutNodes')}>
<IAIIconButton
aria-label="Zoom out"
tooltip={t('nodes.zoomOutNodes')}
aria-label={t('nodes.zoomOutNodes')}
onClick={handleClickedZoomOut}
icon={<FaMinus />}
icon={<FaMagnifyingGlassMinus />}
/>
</Tooltip>
<Tooltip label={t('nodes.fitViewportNodes')}>
<IAIIconButton
aria-label="Fit viewport"
tooltip={t('nodes.fitViewportNodes')}
aria-label={t('nodes.fitViewportNodes')}
onClick={handleClickedFitView}
icon={<FaExpand />}
/>
</Tooltip>
{/* <Tooltip
label={
shouldShowFieldTypeLegend
@ -84,20 +80,21 @@ const ViewportControls = () => {
icon={<FaInfo />}
/>
</Tooltip> */}
<Tooltip
label={
<IAIIconButton
tooltip={
shouldShowMinimapPanel
? t('nodes.hideMinimapnodes')
: t('nodes.showMinimapnodes')
}
aria-label={
shouldShowMinimapPanel
? t('nodes.hideMinimapnodes')
: t('nodes.showMinimapnodes')
}
>
<IAIIconButton
aria-label="Toggle minimap"
isChecked={shouldShowMinimapPanel}
onClick={handleClickedToggleMiniMapPanel}
icon={<FaMapMarkerAlt />}
/>
</Tooltip>
</ButtonGroup>
);
};

View File

@ -1,19 +1,12 @@
import { Flex, chakra, useColorModeValue } from '@chakra-ui/react';
import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { useColorModeValue } from '@chakra-ui/react';
import { memo } from 'react';
import { MiniMap } from 'reactflow';
const MinimapPanel = () => {
const miniMapStyle = useColorModeValue(
{
background: 'var(--invokeai-colors-base-200)',
},
{
background: 'var(--invokeai-colors-base-500)',
}
);
const ChakraMiniMap = chakra(MiniMap);
const MinimapPanel = () => {
const shouldShowMinimapPanel = useAppSelector(
(state: RootState) => state.nodes.shouldShowMinimapPanel
);
@ -29,18 +22,28 @@ const MinimapPanel = () => {
);
return (
<>
<Flex sx={{ gap: 2, position: 'absolute', bottom: 2, insetInlineEnd: 2 }}>
{shouldShowMinimapPanel && (
<MiniMap
<ChakraMiniMap
pannable
zoomable
nodeBorderRadius={15}
style={miniMapStyle}
sx={{
m: '0 !important',
backgroundColor: 'base.200 !important',
borderRadius: 'base',
_dark: {
backgroundColor: 'base.500 !important',
},
svg: {
borderRadius: 'inherit',
},
}}
nodeColor={nodeColor}
maskColor={maskColor}
/>
)}
</>
</Flex>
);
};

View File

@ -0,0 +1,30 @@
import { FileButton } from '@mantine/core';
import IAIIconButton from 'common/components/IAIIconButton';
import { useLoadWorkflowFromFile } from 'features/nodes/hooks/useLoadWorkflowFromFile';
import { memo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { FaUpload } from 'react-icons/fa';
const LoadWorkflowButton = () => {
const { t } = useTranslation();
const resetRef = useRef<() => void>(null);
const loadWorkflowFromFile = useLoadWorkflowFromFile();
return (
<FileButton
resetRef={resetRef}
accept="application/json"
onChange={loadWorkflowFromFile}
>
{(props) => (
<IAIIconButton
icon={<FaUpload />}
tooltip={t('nodes.loadWorkflow')}
aria-label={t('nodes.loadWorkflow')}
{...props}
/>
)}
</FileButton>
);
};
export default memo(LoadWorkflowButton);

View File

@ -1,81 +0,0 @@
import { Box } from '@chakra-ui/react';
import { userInvoked } from 'app/store/actions';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIButton, { IAIButtonProps } from 'common/components/IAIButton';
import { IAIIconButtonProps } from 'common/components/IAIIconButton';
import { useIsReadyToInvoke } from 'common/hooks/useIsReadyToInvoke';
import { InvokeButtonTooltipContent } from 'features/parameters/components/ProcessButtons/InvokeButton';
import ProgressBar from 'features/system/components/ProgressBar';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { FaPlay } from 'react-icons/fa';
interface InvokeButton
extends Omit<IAIButtonProps | IAIIconButtonProps, 'aria-label'> {}
const NodeInvokeButton = (props: InvokeButton) => {
const { ...rest } = props;
const dispatch = useAppDispatch();
const activeTabName = useAppSelector(activeTabNameSelector);
const { isReady, isProcessing } = useIsReadyToInvoke();
const handleInvoke = useCallback(() => {
dispatch(userInvoked('nodes'));
}, [dispatch]);
const { t } = useTranslation();
useHotkeys(
['ctrl+enter', 'meta+enter'],
handleInvoke,
{
enabled: () => isReady,
preventDefault: true,
enableOnFormTags: ['input', 'textarea', 'select'],
},
[isReady, activeTabName]
);
return (
<Box style={{ flexGrow: 4 }} position="relative">
<Box style={{ position: 'relative' }}>
{!isReady && (
<Box
borderRadius="base"
style={{
position: 'absolute',
bottom: '0',
left: '0',
right: '0',
height: '100%',
overflow: 'clip',
}}
>
<ProgressBar />
</Box>
)}
<IAIButton
tooltip={<InvokeButtonTooltipContent />}
aria-label={t('parameters.invoke')}
type="submit"
isDisabled={!isReady}
onClick={handleInvoke}
flexGrow={1}
w="100%"
colorScheme="accent"
id="invoke-button"
leftIcon={isProcessing ? undefined : <FaPlay />}
fontWeight={700}
isLoading={isProcessing}
loadingText={t('parameters.invoke')}
{...rest}
>
Invoke
</IAIButton>
</Box>
</Box>
);
};
export default memo(NodeInvokeButton);

View File

@ -1,11 +1,11 @@
import { useAppDispatch } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIButton from 'common/components/IAIButton';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { FaSyncAlt } from 'react-icons/fa';
import { receivedOpenAPISchema } from 'services/api/thunks/schema';
const ReloadSchemaButton = () => {
const ReloadNodeTemplatesButton = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
@ -14,13 +14,15 @@ const ReloadSchemaButton = () => {
}, [dispatch]);
return (
<IAIIconButton
icon={<FaSyncAlt />}
tooltip={t('nodes.reloadSchema')}
aria-label={t('nodes.reloadSchema')}
<IAIButton
leftIcon={<FaSyncAlt />}
tooltip={t('nodes.reloadNodeTemplates')}
aria-label={t('nodes.reloadNodeTemplates')}
onClick={handleReloadSchema}
/>
>
{t('nodes.reloadNodeTemplates')}
</IAIButton>
);
};
export default memo(ReloadSchemaButton);
export default memo(ReloadNodeTemplatesButton);

View File

@ -6,6 +6,7 @@ import {
AlertDialogHeader,
AlertDialogOverlay,
Button,
Flex,
Text,
useDisclosure,
} from '@chakra-ui/react';
@ -19,7 +20,7 @@ import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { FaTrash } from 'react-icons/fa';
const ClearGraphButton = () => {
const ResetWorkflowButton = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { isOpen, onOpen, onClose } = useDisclosure();
@ -48,10 +49,11 @@ const ClearGraphButton = () => {
<>
<IAIIconButton
icon={<FaTrash />}
tooltip={t('nodes.clearGraph')}
aria-label={t('nodes.clearGraph')}
tooltip={t('nodes.resetWorkflow')}
aria-label={t('nodes.resetWorkflow')}
onClick={onOpen}
isDisabled={!nodesCount}
colorScheme="error"
/>
<AlertDialog
@ -64,18 +66,21 @@ const ClearGraphButton = () => {
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{t('nodes.clearGraph')}
{t('nodes.resetWorkflow')}
</AlertDialogHeader>
<AlertDialogBody>
<Text>{t('nodes.clearGraphDesc')}</Text>
<AlertDialogBody py={4}>
<Flex flexDir="column" gap={2}>
<Text>{t('nodes.resetWorkflowDesc')}</Text>
<Text variant="subtext">{t('nodes.resetWorkflowDesc2')}</Text>
</Flex>
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose}>
{t('common.cancel')}
</Button>
<Button colorScheme="red" ml={3} onClick={handleConfirmClear}>
<Button colorScheme="error" ml={3} onClick={handleConfirmClear}>
{t('common.accept')}
</Button>
</AlertDialogFooter>
@ -85,4 +90,4 @@ const ClearGraphButton = () => {
);
};
export default memo(ClearGraphButton);
export default memo(ResetWorkflowButton);

View File

@ -0,0 +1,29 @@
import IAIIconButton from 'common/components/IAIIconButton';
import { useWorkflow } from 'features/nodes/hooks/useWorkflow';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { FaSave } from 'react-icons/fa';
const SaveWorkflowButton = () => {
const { t } = useTranslation();
const workflow = useWorkflow();
const handleSave = useCallback(() => {
const blob = new Blob([JSON.stringify(workflow, null, 2)]);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${workflow.name || 'My Workflow'}.json`;
document.body.appendChild(a);
a.click();
a.remove();
}, [workflow]);
return (
<IAIIconButton
icon={<FaSave />}
tooltip={t('nodes.saveWorkflow')}
aria-label={t('nodes.saveWorkflow')}
onClick={handleSave}
/>
);
};
export default memo(SaveWorkflowButton);

View File

@ -1,23 +1,24 @@
import { HStack } from '@chakra-ui/react';
import CancelButton from 'features/parameters/components/ProcessButtons/CancelButton';
import { Flex } from '@chakra-ui/layout';
import { memo } from 'react';
import { Panel } from 'reactflow';
import NodeEditorSettings from './NodeEditorSettings';
import ClearGraphButton from './ClearGraphButton';
import NodeInvokeButton from './NodeInvokeButton';
import ReloadSchemaButton from './ReloadSchemaButton';
import LoadWorkflowButton from './LoadWorkflowButton';
import ResetWorkflowButton from './ResetWorkflowButton';
import SaveWorkflowButton from './SaveWorkflowButton';
const TopCenterPanel = () => {
return (
<Panel position="top-center">
<HStack>
<NodeInvokeButton />
<CancelButton />
<ReloadSchemaButton />
<ClearGraphButton />
<NodeEditorSettings />
</HStack>
</Panel>
<Flex
sx={{
gap: 2,
position: 'absolute',
top: 2,
insetInlineStart: '50%',
transform: 'translate(-50%)',
}}
>
<SaveWorkflowButton />
<LoadWorkflowButton />
<ResetWorkflowButton />
</Flex>
);
};

View File

@ -0,0 +1,15 @@
import { Flex } from '@chakra-ui/react';
import CancelButton from 'features/parameters/components/ProcessButtons/CancelButton';
import InvokeButton from 'features/parameters/components/ProcessButtons/InvokeButton';
import { memo } from 'react';
const WorkflowEditorControls = () => {
return (
<Flex layerStyle="first" sx={{ gap: 2, borderRadius: 'base', p: 2 }}>
<InvokeButton />
<CancelButton />
</Flex>
);
};
export default memo(WorkflowEditorControls);

View File

@ -1,9 +1,9 @@
import { Flex } from '@chakra-ui/layout';
import { useAppDispatch } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton';
import IAIIconButton from 'common/components/IAIIconButton';
import { addNodePopoverOpened } from 'features/nodes/store/nodesSlice';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { Panel } from 'reactflow';
import { FaPlus } from 'react-icons/fa';
const TopLeftPanel = () => {
const dispatch = useAppDispatch();
@ -12,16 +12,15 @@ const TopLeftPanel = () => {
dispatch(addNodePopoverOpened());
}, [dispatch]);
useHotkeys(['shift+a'], () => {
handleOpenAddNodePopover();
});
return (
<Panel position="top-left">
<IAIButton aria-label="Add Node" onClick={handleOpenAddNodePopover}>
Add Node
</IAIButton>
</Panel>
<Flex sx={{ gap: 2, position: 'absolute', top: 2, insetInlineStart: 2 }}>
<IAIIconButton
tooltip="Add Node (Shift+A, Space)"
aria-label="Add Node"
icon={<FaPlus />}
onClick={handleOpenAddNodePopover}
/>
</Flex>
);
};

View File

@ -1,7 +1,8 @@
import { Flex } from '@chakra-ui/layout';
import { useAppSelector } from 'app/store/storeHooks';
import { memo } from 'react';
import { Panel } from 'reactflow';
import FieldTypeLegend from './FieldTypeLegend';
import WorkflowEditorSettings from './WorkflowEditorSettings';
const TopRightPanel = () => {
const shouldShowFieldTypeLegend = useAppSelector(
@ -9,9 +10,10 @@ const TopRightPanel = () => {
);
return (
<Panel position="top-right">
<Flex sx={{ gap: 2, position: 'absolute', top: 2, insetInlineEnd: 2 }}>
<WorkflowEditorSettings />
{shouldShowFieldTypeLegend && <FieldTypeLegend />}
</Panel>
</Flex>
);
};

View File

@ -1,6 +1,7 @@
import {
Divider,
Flex,
FormLabelProps,
Heading,
Modal,
ModalBody,
@ -8,22 +9,30 @@ import {
ModalContent,
ModalHeader,
ModalOverlay,
forwardRef,
useDisclosure,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIIconButton from 'common/components/IAIIconButton';
import IAISwitch from 'common/components/IAISwitch';
import { ChangeEvent, memo, useCallback } from 'react';
import { FaCog } from 'react-icons/fa';
import {
selectionModeChanged,
shouldAnimateEdgesChanged,
shouldColorEdgesChanged,
shouldSnapToGridChanged,
shouldValidateGraphChanged,
} from 'features/nodes/store/nodesSlice';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { ChangeEvent, memo, useCallback } from 'react';
import { FaCog } from 'react-icons/fa';
import { SelectionMode } from 'reactflow';
import ReloadNodeTemplatesButton from '../TopCenterPanel/ReloadSchemaButton';
const formLabelProps: FormLabelProps = {
fontWeight: 600,
};
const selector = createSelector(
stateSelector,
@ -33,18 +42,20 @@ const selector = createSelector(
shouldValidateGraph,
shouldSnapToGrid,
shouldColorEdges,
selectionMode,
} = nodes;
return {
shouldAnimateEdges,
shouldValidateGraph,
shouldSnapToGrid,
shouldColorEdges,
selectionModeIsChecked: selectionMode === SelectionMode.Full,
};
},
defaultSelectorOptions
);
const NodeEditorSettings = () => {
const WorkflowEditorSettings = forwardRef((_, ref) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const dispatch = useAppDispatch();
const {
@ -52,6 +63,7 @@ const NodeEditorSettings = () => {
shouldValidateGraph,
shouldSnapToGrid,
shouldColorEdges,
selectionModeIsChecked,
} = useAppSelector(selector);
const handleChangeShouldValidate = useCallback(
@ -82,10 +94,19 @@ const NodeEditorSettings = () => {
[dispatch]
);
const handleChangeSelectionMode = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
dispatch(selectionModeChanged(e.target.checked));
},
[dispatch]
);
return (
<>
<IAIIconButton
aria-label="Node Editor Settings"
ref={ref}
aria-label="Workflow Editor Settings"
tooltip="Workflow Editor Settings"
icon={<FaCog />}
onClick={onOpen}
/>
@ -93,7 +114,7 @@ const NodeEditorSettings = () => {
<Modal isOpen={isOpen} onClose={onClose} size="2xl" isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>Node Editor Settings</ModalHeader>
<ModalHeader>Workflow Editor Settings</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Flex
@ -105,6 +126,7 @@ const NodeEditorSettings = () => {
>
<Heading size="sm">General</Heading>
<IAISwitch
formLabelProps={formLabelProps}
onChange={handleChangeShouldAnimate}
isChecked={shouldAnimateEdges}
label="Animated Edges"
@ -112,6 +134,7 @@ const NodeEditorSettings = () => {
/>
<Divider />
<IAISwitch
formLabelProps={formLabelProps}
isChecked={shouldSnapToGrid}
onChange={handleChangeShouldSnap}
label="Snap to Grid"
@ -119,26 +142,36 @@ const NodeEditorSettings = () => {
/>
<Divider />
<IAISwitch
formLabelProps={formLabelProps}
isChecked={shouldColorEdges}
onChange={handleChangeShouldColor}
label="Color-Code Edges"
helperText="Color-code edges according to their connected fields"
/>
<IAISwitch
formLabelProps={formLabelProps}
isChecked={selectionModeIsChecked}
onChange={handleChangeSelectionMode}
label="Fully Contain Nodes to Select"
helperText="Nodes must be fully inside the selection box to be selected"
/>
<Heading size="sm" pt={4}>
Advanced
</Heading>
<IAISwitch
formLabelProps={formLabelProps}
isChecked={shouldValidateGraph}
onChange={handleChangeShouldValidate}
label="Validate Connections and Graph"
helperText="Prevent invalid connections from being made, and invalid graphs from being invoked"
/>
<ReloadNodeTemplatesButton />
</Flex>
</ModalBody>
</ModalContent>
</Modal>
</>
);
};
});
export default memo(NodeEditorSettings);
export default memo(WorkflowEditorSettings);

View File

@ -1,23 +1,41 @@
import { Flex } from '@chakra-ui/react';
import ProcessButtons from 'features/parameters/components/ProcessButtons/ProcessButtons';
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
import { memo, useState } from 'react';
import { Panel, PanelGroup } from 'react-resizable-panels';
import { usePanelStorage } from 'features/ui/hooks/usePanelStorage';
import { memo, useCallback, useRef, useState } from 'react';
import {
ImperativePanelGroupHandle,
Panel,
PanelGroup,
} from 'react-resizable-panels';
import 'reactflow/dist/style.css';
import WorkflowPanel from './workflow/WorkflowPanel';
import InspectorPanel from './inspector/InspectorPanel';
import WorkflowPanel from './workflow/WorkflowPanel';
const NodeEditorPanelGroup = () => {
const [isTopPanelCollapsed, setIsTopPanelCollapsed] = useState(false);
const [isBottomPanelCollapsed, setIsBottomPanelCollapsed] = useState(false);
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
const panelStorage = usePanelStorage();
const handleDoubleClickHandle = useCallback(() => {
if (!panelGroupRef.current) {
return;
}
panelGroupRef.current.setLayout([50, 50]);
}, []);
return (
<Flex sx={{ flexDir: 'column', gap: 2, height: '100%', width: '100%' }}>
<ProcessButtons />
<PanelGroup
id="node-editor-panel_group"
autoSaveId="node-editor-panel_group"
ref={panelGroupRef}
id="workflow-panel-group"
direction="vertical"
style={{ height: '100%', width: '100%' }}
storage={panelStorage}
>
<Panel
id="node-editor-panel_group_workflow"
id="workflow"
collapsible
onCollapse={setIsTopPanelCollapsed}
minSize={25}
@ -26,6 +44,7 @@ const NodeEditorPanelGroup = () => {
</Panel>
<ResizeHandle
direction="vertical"
onDoubleClick={handleDoubleClickHandle}
collapsedDirection={
isTopPanelCollapsed
? 'top'
@ -35,7 +54,7 @@ const NodeEditorPanelGroup = () => {
}
/>
<Panel
id="node-editor-panel_group_inspector"
id="inspector"
collapsible
onCollapse={setIsBottomPanelCollapsed}
minSize={25}
@ -43,6 +62,7 @@ const NodeEditorPanelGroup = () => {
<InspectorPanel />
</Panel>
</PanelGroup>
</Flex>
);
};

View File

@ -20,7 +20,7 @@ const InspectorPanel = () => {
w: 'full',
h: 'full',
borderRadius: 'base',
p: 4,
p: 2,
gap: 2,
}}
>

View File

@ -1,26 +1,10 @@
import { Flex } from '@chakra-ui/react';
import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer';
import { buildWorkflow } from 'features/nodes/util/buildWorkflow';
import { memo, useMemo } from 'react';
import { useDebounce } from 'use-debounce';
const useWatchWorkflow = () => {
const nodes = useAppSelector((state: RootState) => state.nodes);
const [debouncedNodes] = useDebounce(nodes, 300);
const workflow = useMemo(
() => buildWorkflow(debouncedNodes),
[debouncedNodes]
);
return {
workflow,
};
};
import { useWorkflow } from 'features/nodes/hooks/useWorkflow';
import { memo } from 'react';
const WorkflowJSONTab = () => {
const { workflow } = useWatchWorkflow();
const workflow = useWorkflow();
return (
<Flex
@ -31,7 +15,7 @@ const WorkflowJSONTab = () => {
h: 'full',
}}
>
<DataViewer data={workflow} label="Workflow" fileName={workflow.name} />
<DataViewer data={workflow} label="Workflow" />
</Flex>
);
};

View File

@ -8,8 +8,8 @@ import {
} from '@chakra-ui/react';
import { memo } from 'react';
import WorkflowGeneralTab from './WorkflowGeneralTab';
import WorkflowLinearTab from './WorkflowLinearTab';
import WorkflowJSONTab from './WorkflowJSONTab';
import WorkflowLinearTab from './WorkflowLinearTab';
const WorkflowPanel = () => {
return (
@ -20,7 +20,8 @@ const WorkflowPanel = () => {
w: 'full',
h: 'full',
borderRadius: 'base',
p: 4,
p: 2,
gap: 2,
}}
>
<Tabs

View File

@ -0,0 +1,36 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { map } from 'lodash-es';
import { useMemo } from 'react';
import { isInvocationNode } from '../types/types';
export const useAnyOrDirectInputFieldNames = (nodeId: string) => {
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ nodes }) => {
const node = nodes.nodes.find((node) => node.id === nodeId);
if (!isInvocationNode(node)) {
return [];
}
const nodeTemplate = nodes.nodeTemplates[node.data.type];
if (!nodeTemplate) {
return [];
}
return map(nodeTemplate.inputs)
.filter((field) => ['any', 'direct'].includes(field.input))
.sort((a, b) => (a.ui_order ?? 0) - (b.ui_order ?? 0))
.map((field) => field.name)
.filter((fieldName) => fieldName !== 'is_intermediate');
},
defaultSelectorOptions
),
[nodeId]
);
const fieldNames = useAppSelector(selector);
return fieldNames;
};

View File

@ -0,0 +1,36 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { map } from 'lodash-es';
import { useMemo } from 'react';
import { isInvocationNode } from '../types/types';
export const useConnectionInputFieldNames = (nodeId: string) => {
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ nodes }) => {
const node = nodes.nodes.find((node) => node.id === nodeId);
if (!isInvocationNode(node)) {
return [];
}
const nodeTemplate = nodes.nodeTemplates[node.data.type];
if (!nodeTemplate) {
return [];
}
return map(nodeTemplate.inputs)
.filter((field) => field.input === 'connection')
.sort((a, b) => (a.ui_order ?? 0) - (b.ui_order ?? 0))
.map((field) => field.name)
.filter((fieldName) => fieldName !== 'is_intermediate');
},
defaultSelectorOptions
),
[nodeId]
);
const fieldNames = useAppSelector(selector);
return fieldNames;
};

View File

@ -0,0 +1,103 @@
import { ListItem, Text, UnorderedList } from '@chakra-ui/react';
import { useLogger } from 'app/logging/useLogger';
import { useAppDispatch } from 'app/store/storeHooks';
import { parseify } from 'common/util/serialize';
import { workflowLoaded } from 'features/nodes/store/nodesSlice';
import { zWorkflow } from 'features/nodes/types/types';
import { addToast } from 'features/system/store/systemSlice';
import { makeToast } from 'features/system/util/makeToast';
import { memo, useCallback } from 'react';
import { ZodError } from 'zod';
import { fromZodError, fromZodIssue } from 'zod-validation-error';
export const useLoadWorkflowFromFile = () => {
const dispatch = useAppDispatch();
const logger = useLogger('nodes');
const loadWorkflowFromFile = useCallback(
(file: File | null) => {
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = async () => {
const rawJSON = reader.result;
try {
const parsedJSON = JSON.parse(String(rawJSON));
const result = zWorkflow.safeParse(parsedJSON);
if (!result.success) {
const message = fromZodError(result.error, {
prefix: 'Workflow Validation Error',
}).toString();
logger.error({ error: parseify(result.error) }, message);
dispatch(
addToast(
makeToast({
title: 'Unable to Validate Workflow',
description: (
<WorkflowValidationErrorContent error={result.error} />
),
status: 'error',
duration: 5000,
})
)
);
return;
}
dispatch(workflowLoaded(result.data));
dispatch(
addToast(
makeToast({
title: 'Workflow Loaded',
status: 'success',
})
)
);
reader.abort();
} catch (error) {
// file reader error
if (error) {
dispatch(
addToast(
makeToast({
title: 'Unable to Load Workflow',
status: 'error',
})
)
);
}
}
};
reader.readAsText(file);
},
[dispatch, logger]
);
return loadWorkflowFromFile;
};
const WorkflowValidationErrorContent = memo((props: { error: ZodError }) => {
if (props.error.issues[0]) {
return (
<Text>
{fromZodIssue(props.error.issues[0], { prefix: null }).toString()}
</Text>
);
}
return (
<UnorderedList>
{props.error.issues.map((issue, i) => (
<ListItem key={i}>
<Text>{fromZodIssue(issue, { prefix: null }).toString()}</Text>
</ListItem>
))}
</UnorderedList>
);
});
WorkflowValidationErrorContent.displayName = 'WorkflowValidationErrorContent';

View File

@ -4,10 +4,9 @@ import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { map } from 'lodash-es';
import { useMemo } from 'react';
import { KIND_MAP } from '../types/constants';
import { isInvocationNode } from '../types/types';
export const useFieldNames = (nodeId: string, kind: 'input' | 'output') => {
export const useOutputFieldNames = (nodeId: string) => {
const selector = useMemo(
() =>
createSelector(
@ -17,13 +16,18 @@ export const useFieldNames = (nodeId: string, kind: 'input' | 'output') => {
if (!isInvocationNode(node)) {
return [];
}
return map(node.data[KIND_MAP[kind]], (field) => field.name).filter(
(fieldName) => fieldName !== 'is_intermediate'
);
const nodeTemplate = nodes.nodeTemplates[node.data.type];
if (!nodeTemplate) {
return [];
}
return map(nodeTemplate.outputs)
.sort((a, b) => (a.ui_order ?? 0) - (b.ui_order ?? 0))
.map((field) => field.name)
.filter((fieldName) => fieldName !== 'is_intermediate');
},
defaultSelectorOptions
),
[kind, nodeId]
[nodeId]
);
const fieldNames = useAppSelector(selector);

View File

@ -0,0 +1,16 @@
import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { buildWorkflow } from 'features/nodes/util/buildWorkflow';
import { useMemo } from 'react';
import { useDebounce } from 'use-debounce';
export const useWorkflow = () => {
const nodes = useAppSelector((state: RootState) => state.nodes);
const [debouncedNodes] = useDebounce(nodes, 300);
const workflow = useMemo(
() => buildWorkflow(debouncedNodes),
[debouncedNodes]
);
return workflow;
};

View File

@ -4,7 +4,6 @@ import { NodesState } from './types';
* Nodes slice persist denylist
*/
export const nodesPersistDenylist: (keyof NodesState)[] = [
'schema',
'nodeTemplates',
'connectionStartParams',
'currentConnectionFieldType',

View File

@ -14,6 +14,7 @@ import {
Node,
NodeChange,
OnConnectStartParams,
SelectionMode,
Viewport,
} from 'reactflow';
import { receivedOpenAPISchema } from 'services/api/thunks/schema';
@ -56,6 +57,8 @@ import {
import { NodesState } from './types';
import { findUnoccupiedPosition } from './util/findUnoccupiedPosition';
export const WORKFLOW_FORMAT_VERSION = '1.0.0';
const initialNodeExecutionState: Omit<NodeExecutionState, 'nodeId'> = {
status: NodeStatus.PENDING,
error: null,
@ -64,10 +67,23 @@ const initialNodeExecutionState: Omit<NodeExecutionState, 'nodeId'> = {
outputs: [],
};
export const initialWorkflow = {
meta: {
version: WORKFLOW_FORMAT_VERSION,
},
name: '',
author: '',
description: '',
notes: '',
tags: '',
contact: '',
version: '',
exposedFields: [],
};
export const initialNodesState: NodesState = {
nodes: [],
edges: [],
schema: null,
nodeTemplates: {},
isReady: false,
connectionStartParams: null,
@ -82,21 +98,13 @@ export const initialNodesState: NodesState = {
nodeOpacity: 1,
selectedNodes: [],
selectedEdges: [],
workflow: {
name: '',
author: '',
description: '',
notes: '',
tags: '',
contact: '',
version: '',
exposedFields: [],
},
workflow: initialWorkflow,
nodeExecutionStates: {},
viewport: { x: 0, y: 0, zoom: 1 },
mouseOverField: null,
nodesToCopy: [],
edgesToCopy: [],
selectionMode: SelectionMode.Partial,
};
type FieldValueAction<T extends InputFieldValue> = PayloadAction<{
@ -570,15 +578,6 @@ const nodesSlice = createSlice({
nodeOpacityChanged: (state, action: PayloadAction<number>) => {
state.nodeOpacity = action.payload;
},
loadFileNodes: (
state,
action: PayloadAction<Node<InvocationNodeData>[]>
) => {
state.nodes = action.payload;
},
loadFileEdges: (state, action: PayloadAction<Edge[]>) => {
state.edges = action.payload;
},
workflowNameChanged: (state, action: PayloadAction<string>) => {
state.workflow.name = action.payload;
},
@ -601,9 +600,9 @@ const nodesSlice = createSlice({
state.workflow.contact = action.payload;
},
workflowLoaded: (state, action: PayloadAction<Workflow>) => {
// TODO: validation
const { nodes, edges, ...workflow } = action.payload;
state.workflow = workflow;
state.nodes = applyNodeChanges(
nodes.map((node) => ({
item: { ...node, dragHandle: `.${DRAG_HANDLE_CLASSNAME}` },
@ -615,6 +614,19 @@ const nodesSlice = createSlice({
edges.map((edge) => ({ item: edge, type: 'add' })),
[]
);
state.nodeExecutionStates = nodes.reduce<
Record<string, NodeExecutionState>
>((acc, node) => {
acc[node.id] = {
nodeId: node.id,
...initialNodeExecutionState,
};
return acc;
}, {});
},
workflowReset: (state) => {
state.workflow = cloneDeep(initialWorkflow);
},
viewportChanged: (state, action: PayloadAction<Viewport>) => {
state.viewport = action.payload;
@ -721,14 +733,16 @@ const nodesSlice = createSlice({
addNodePopoverToggled: (state) => {
state.isAddNodePopoverOpen = !state.isAddNodePopoverOpen;
},
selectionModeChanged: (state, action: PayloadAction<boolean>) => {
state.selectionMode = action.payload
? SelectionMode.Full
: SelectionMode.Partial;
},
},
extraReducers: (builder) => {
builder.addCase(receivedOpenAPISchema.pending, (state) => {
state.isReady = false;
});
builder.addCase(receivedOpenAPISchema.fulfilled, (state, action) => {
state.schema = action.payload;
});
builder.addCase(appSocketInvocationStarted, (state, action) => {
const { source_node_id } = action.payload.data;
const node = state.nodeExecutionStates[source_node_id];
@ -792,8 +806,6 @@ export const {
nodeTemplatesBuilt,
nodeEditorReset,
imageCollectionFieldValueChanged,
loadFileNodes,
loadFileEdges,
fieldStringValueChanged,
fieldNumberValueChanged,
fieldBooleanValueChanged,
@ -837,6 +849,7 @@ export const {
addNodePopoverOpened,
addNodePopoverClosed,
addNodePopoverToggled,
selectionModeChanged,
} = nodesSlice.actions;
export default nodesSlice.reducer;

View File

@ -1,5 +1,10 @@
import { OpenAPIV3 } from 'openapi-types';
import { Edge, Node, OnConnectStartParams, Viewport } from 'reactflow';
import {
Edge,
Node,
OnConnectStartParams,
SelectionMode,
Viewport,
} from 'reactflow';
import {
FieldIdentifier,
FieldType,
@ -13,7 +18,6 @@ import {
export type NodesState = {
nodes: Node<NodeData>[];
edges: Edge<InvocationEdgeExtra>[];
schema: OpenAPIV3.Document | null;
nodeTemplates: Record<string, InvocationTemplate>;
connectionStartParams: OnConnectStartParams | null;
currentConnectionFieldType: FieldType | null;
@ -34,4 +38,5 @@ export type NodesState = {
nodesToCopy: Node<NodeData>[];
edgesToCopy: Edge<InvocationEdgeExtra>[];
isAddNodePopoverOpen: boolean;
selectionMode: SelectionMode;
};

View File

@ -23,9 +23,6 @@ export const COLLECTION_TYPES: FieldType[] = [
'ImageCollection',
];
export const colorTokenToCssVar = (colorToken: string) =>
`var(--invokeai-colors-${colorToken.split('.').join('-')}`;
export const FIELDS: Record<FieldType, FieldUIConfig> = {
integer: {
title: 'Integer',

View File

@ -1,23 +1,13 @@
import {
ControlNetModelParam,
LoRAModelParam,
MainModelParam,
OnnxModelParam,
SchedulerParam,
VaeModelParam,
zBaseModel,
zMainOrOnnxModel,
zScheduler,
} from 'features/parameters/types/parameterSchemas';
import { OpenAPIV3 } from 'openapi-types';
import { RgbaColor } from 'react-colorful';
import { Edge, Node } from 'reactflow';
import { components } from 'services/api/schema';
import {
Graph,
GraphExecutionState,
ImageDTO,
ImageField,
_InputField,
_OutputField,
} from 'services/api/types';
import { Node } from 'reactflow';
import { Graph, ImageDTO, _InputField, _OutputField } from 'services/api/types';
import {
AnyInvocationType,
AnyResult,
@ -53,6 +43,10 @@ export type InvocationTemplate = {
* Array of the invocation outputs
*/
outputs: Record<string, OutputFieldTemplate>;
/**
* The type of this node's output
*/
outputType: string; // TODO: generate a union of output types
};
export type FieldUIConfig = {
@ -114,40 +108,6 @@ export type FieldType = z.infer<typeof zFieldType>;
export const isFieldType = (value: unknown): value is FieldType =>
zFieldType.safeParse(value).success;
/**
* An input field is persisted across reloads as part of the user's local state.
*
* An input field has three properties:
* - `id` a unique identifier
* - `name` the name of the field, which comes from the python dataclass
* - `value` the field's value
*/
export type InputFieldValue =
| IntegerInputFieldValue
| SeedInputFieldValue
| FloatInputFieldValue
| StringInputFieldValue
| BooleanInputFieldValue
| ImageInputFieldValue
| LatentsInputFieldValue
| ConditioningInputFieldValue
| UNetInputFieldValue
| ClipInputFieldValue
| VaeInputFieldValue
| ControlInputFieldValue
| EnumInputFieldValue
| MainModelInputFieldValue
| SDXLMainModelInputFieldValue
| SDXLRefinerModelInputFieldValue
| VaeModelInputFieldValue
| LoRAModelInputFieldValue
| ControlNetModelInputFieldValue
| CollectionInputFieldValue
| CollectionItemInputFieldValue
| ColorInputFieldValue
| ImageCollectionInputFieldValue
| SchedulerInputFieldValue;
/**
* An input field template is generated on each page load from the OpenAPI schema.
*
@ -179,6 +139,19 @@ export type InputFieldTemplate =
| ImageCollectionInputFieldTemplate
| SchedulerInputFieldTemplate;
/**
* Indicates the kind of input(s) this field may have.
*/
export const zInputKind = z.enum(['connection', 'direct', 'any']);
export type InputKind = z.infer<typeof zInputKind>;
export const zFieldValueBase = z.object({
id: z.string().trim().min(1),
name: z.string().trim().min(1),
type: zFieldType,
});
export type FieldValueBase = z.infer<typeof zFieldValueBase>;
/**
* An output field is persisted across as part of the user's local state.
*
@ -186,7 +159,11 @@ export type InputFieldTemplate =
* - `id` a unique identifier
* - `name` the name of the field, which comes from the python dataclass
*/
export type OutputFieldValue = FieldValueBase & { fieldKind: 'output' };
export const zOutputFieldValue = zFieldValueBase.extend({
fieldKind: z.literal('output'),
});
export type OutputFieldValue = z.infer<typeof zOutputFieldValue>;
/**
* An output field template is generated on each page load from the OpenAPI schema.
@ -199,143 +176,309 @@ export type OutputFieldTemplate = {
type: FieldType;
title: string;
description: string;
};
} & _OutputField;
/**
* Indicates the kind of input(s) this field may have.
*/
export type InputKind = 'connection' | 'direct' | 'any';
export const zInputFieldValueBase = zFieldValueBase.extend({
fieldKind: z.literal('input'),
label: z.string(),
});
export type InputFieldValueBase = z.infer<typeof zInputFieldValueBase>;
export type FieldValueBase = {
id: string;
name: string;
type: FieldType;
};
export const zModelIdentifier = z.object({
model_name: z.string().trim().min(1),
base_model: zBaseModel,
});
export type InputFieldValueBase = FieldValueBase & {
fieldKind: 'input';
label: string;
};
export const zImageField = z.object({
image_name: z.string().trim().min(1),
});
export type ImageField = z.infer<typeof zImageField>;
export type IntegerInputFieldValue = InputFieldValueBase & {
type: 'integer';
value?: number;
};
export const zLatentsField = z.object({
latents_name: z.string().trim().min(1),
seed: z.number().int().optional(),
});
export type LatentsField = z.infer<typeof zLatentsField>;
export type FloatInputFieldValue = InputFieldValueBase & {
type: 'float';
value?: number;
};
export const zConditioningField = z.object({
conditioning_name: z.string().trim().min(1),
});
export type ConditioningField = z.infer<typeof zConditioningField>;
export type SeedInputFieldValue = InputFieldValueBase & {
type: 'Seed';
value?: number;
};
export const zIntegerInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('integer'),
value: z.number().optional(),
});
export type IntegerInputFieldValue = z.infer<typeof zIntegerInputFieldValue>;
export type StringInputFieldValue = InputFieldValueBase & {
type: 'string';
value?: string;
};
export const zFloatInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('float'),
value: z.number().optional(),
});
export type FloatInputFieldValue = z.infer<typeof zFloatInputFieldValue>;
export type BooleanInputFieldValue = InputFieldValueBase & {
type: 'boolean';
value?: boolean;
};
export const zStringInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('string'),
value: z.string().optional(),
});
export type StringInputFieldValue = z.infer<typeof zStringInputFieldValue>;
export type EnumInputFieldValue = InputFieldValueBase & {
type: 'enum';
value?: number | string;
};
export const zBooleanInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('boolean'),
value: z.boolean().optional(),
});
export type BooleanInputFieldValue = z.infer<typeof zBooleanInputFieldValue>;
export type LatentsInputFieldValue = InputFieldValueBase & {
type: 'LatentsField';
value?: undefined;
};
export const zEnumInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('enum'),
value: z.union([z.string(), z.number()]).optional(),
});
export type EnumInputFieldValue = z.infer<typeof zEnumInputFieldValue>;
export type ConditioningInputFieldValue = InputFieldValueBase & {
type: 'ConditioningField';
value?: string;
};
export const zLatentsInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('LatentsField'),
value: zLatentsField.optional(),
});
export type LatentsInputFieldValue = z.infer<typeof zLatentsInputFieldValue>;
export type ControlInputFieldValue = InputFieldValueBase & {
type: 'ControlField';
value?: undefined;
};
export const zConditioningInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('ConditioningField'),
value: zConditioningField.optional(),
});
export type ConditioningInputFieldValue = z.infer<
typeof zConditioningInputFieldValue
>;
export type UNetInputFieldValue = InputFieldValueBase & {
type: 'UNetField';
value?: undefined;
};
export const zControlNetModel = zModelIdentifier;
export type ControlNetModel = z.infer<typeof zControlNetModel>;
export type ClipInputFieldValue = InputFieldValueBase & {
type: 'ClipField';
value?: undefined;
};
export const zControlField = zInputFieldValueBase.extend({
image: zImageField,
control_model: zControlNetModel,
control_weight: z.union([z.number(), z.array(z.number())]).optional(),
begin_step_percent: z.number().optional(),
end_step_percent: z.number().optional(),
control_mode: z
.enum(['balanced', 'more_prompt', 'more_control', 'unbalanced'])
.optional(),
resize_mode: z
.enum(['just_resize', 'crop_resize', 'fill_resize', 'just_resize_simple'])
.optional(),
});
export type ControlField = z.infer<typeof zControlField>;
export type VaeInputFieldValue = InputFieldValueBase & {
type: 'VaeField';
value?: undefined;
};
export const zControlInputFieldTemplate = zInputFieldValueBase.extend({
type: z.literal('ControlField'),
value: zControlField.optional(),
});
export type ControlInputFieldValue = z.infer<typeof zControlInputFieldTemplate>;
export type ImageInputFieldValue = InputFieldValueBase & {
type: 'ImageField';
value?: ImageField;
};
export const zModelType = z.enum([
'onnx',
'main',
'vae',
'lora',
'controlnet',
'embedding',
]);
export type ModelType = z.infer<typeof zModelType>;
export type ImageCollectionInputFieldValue = InputFieldValueBase & {
type: 'ImageCollection';
value?: ImageField[];
};
export const zSubModelType = z.enum([
'unet',
'text_encoder',
'text_encoder_2',
'tokenizer',
'tokenizer_2',
'vae',
'vae_decoder',
'vae_encoder',
'scheduler',
'safety_checker',
]);
export type SubModelType = z.infer<typeof zSubModelType>;
export type MainModelInputFieldValue = InputFieldValueBase & {
type: 'MainModelField';
value?: MainModelParam | OnnxModelParam;
};
export const zModelInfo = zModelIdentifier.extend({
model_type: zModelType,
submodel: zSubModelType.optional(),
});
export type ModelInfo = z.infer<typeof zModelInfo>;
export type SDXLMainModelInputFieldValue = InputFieldValueBase & {
type: 'SDXLMainModelField';
value?: MainModelParam | OnnxModelParam;
};
export const zLoraInfo = zModelInfo.extend({
weight: z.number().optional(),
});
export type LoraInfo = z.infer<typeof zLoraInfo>;
export type SDXLRefinerModelInputFieldValue = InputFieldValueBase & {
type: 'SDXLRefinerModelField';
value?: MainModelParam | OnnxModelParam;
};
export const zUNetField = z.object({
unet: zModelInfo,
scheduler: zModelInfo,
loras: z.array(zLoraInfo),
});
export type UNetField = z.infer<typeof zUNetField>;
export type VaeModelInputFieldValue = InputFieldValueBase & {
type: 'VaeModelField';
value?: VaeModelParam;
};
export const zUNetInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('UNetField'),
value: zUNetField.optional(),
});
export type UNetInputFieldValue = z.infer<typeof zUNetInputFieldValue>;
export type LoRAModelInputFieldValue = InputFieldValueBase & {
type: 'LoRAModelField';
value?: LoRAModelParam;
};
export const zClipField = z.object({
tokenizer: zModelInfo,
text_encoder: zModelInfo,
skipped_layers: z.number(),
loras: z.array(zLoraInfo),
});
export type ClipField = z.infer<typeof zClipField>;
export type ControlNetModelInputFieldValue = InputFieldValueBase & {
type: 'ControlNetModelField';
value?: ControlNetModelParam;
};
export const zClipInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('ClipField'),
value: zClipField.optional(),
});
export type ClipInputFieldValue = z.infer<typeof zClipInputFieldValue>;
export type CollectionInputFieldValue = InputFieldValueBase & {
type: 'Collection';
value?: (string | number)[];
};
export const zVaeField = z.object({
vae: zModelInfo,
});
export type VaeField = z.infer<typeof zVaeField>;
export type CollectionItemInputFieldValue = InputFieldValueBase & {
type: 'CollectionItem';
value?: undefined;
};
export const zVaeInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('VaeField'),
value: zVaeField.optional(),
});
export type VaeInputFieldValue = z.infer<typeof zVaeInputFieldValue>;
export type ColorInputFieldValue = InputFieldValueBase & {
type: 'ColorField';
value?: RgbaColor;
};
export const zImageInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('ImageField'),
value: zImageField.optional(),
});
export type ImageInputFieldValue = z.infer<typeof zImageInputFieldValue>;
export type SchedulerInputFieldValue = InputFieldValueBase & {
type: 'Scheduler';
value?: SchedulerParam;
};
export const zImageCollectionInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('ImageCollection'),
value: z.array(zImageField).optional(),
});
export type ImageCollectionInputFieldValue = z.infer<
typeof zImageCollectionInputFieldValue
>;
export const zMainModelInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('MainModelField'),
value: zMainOrOnnxModel.optional(),
});
export type MainModelInputFieldValue = z.infer<
typeof zMainModelInputFieldValue
>;
export const zSDXLMainModelInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('SDXLMainModelField'),
value: zMainOrOnnxModel.optional(),
});
export type SDXLMainModelInputFieldValue = z.infer<
typeof zSDXLMainModelInputFieldValue
>;
export const zSDXLRefinerModelInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('SDXLRefinerModelField'),
value: zMainOrOnnxModel.optional(), // TODO: should narrow this down to a refiner model
});
export type SDXLRefinerModelInputFieldValue = z.infer<
typeof zSDXLRefinerModelInputFieldValue
>;
export const zVaeModelField = zModelIdentifier;
export const zVaeModelInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('VaeModelField'),
value: zVaeModelField.optional(),
});
export type VaeModelInputFieldValue = z.infer<typeof zVaeModelInputFieldValue>;
export const zLoRAModelField = zModelIdentifier;
export type LoRAModelField = z.infer<typeof zLoRAModelField>;
export const zLoRAModelInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('LoRAModelField'),
value: zLoRAModelField.optional(),
});
export type LoRAModelInputFieldValue = z.infer<
typeof zLoRAModelInputFieldValue
>;
export const zControlNetModelField = zModelIdentifier;
export type ControlNetModelField = z.infer<typeof zControlNetModelField>;
export const zControlNetModelInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('ControlNetModelField'),
value: zControlNetModelField.optional(),
});
export type ControlNetModelInputFieldValue = z.infer<
typeof zControlNetModelInputFieldValue
>;
export const zCollectionInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('Collection'),
value: z.array(z.any()).optional(), // TODO: should this field ever have a value?
});
export type CollectionInputFieldValue = z.infer<
typeof zCollectionInputFieldValue
>;
export const zCollectionItemInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('CollectionItem'),
value: z.any().optional(), // TODO: should this field ever have a value?
});
export type CollectionItemInputFieldValue = z.infer<
typeof zCollectionItemInputFieldValue
>;
export const zColorField = z.object({
r: z.number().int().min(0).max(255),
g: z.number().int().min(0).max(255),
b: z.number().int().min(0).max(255),
a: z.number().int().min(0).max(255),
});
export type ColorField = z.infer<typeof zColorField>;
export const zColorInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('ColorField'),
value: zColorField.optional(),
});
export type ColorInputFieldValue = z.infer<typeof zColorInputFieldValue>;
export const zSchedulerInputFieldValue = zInputFieldValueBase.extend({
type: z.literal('Scheduler'),
value: zScheduler.optional(),
});
export type SchedulerInputFieldValue = z.infer<
typeof zSchedulerInputFieldValue
>;
export const zInputFieldValue = z.discriminatedUnion('type', [
zIntegerInputFieldValue,
zFloatInputFieldValue,
zStringInputFieldValue,
zBooleanInputFieldValue,
zImageInputFieldValue,
zLatentsInputFieldValue,
zConditioningInputFieldValue,
zUNetInputFieldValue,
zClipInputFieldValue,
zVaeInputFieldValue,
zControlInputFieldTemplate,
zEnumInputFieldValue,
zMainModelInputFieldValue,
zSDXLMainModelInputFieldValue,
zSDXLRefinerModelInputFieldValue,
zVaeModelInputFieldValue,
zLoRAModelInputFieldValue,
zControlNetModelInputFieldValue,
zCollectionInputFieldValue,
zCollectionItemInputFieldValue,
zColorInputFieldValue,
zImageCollectionInputFieldValue,
zSchedulerInputFieldValue,
]);
export type InputFieldValue = z.infer<typeof zInputFieldValue>;
export type InputFieldTemplateBase = {
name: string;
@ -521,14 +664,15 @@ export type InvocationBaseSchemaObject = Omit<
export type InvocationOutputSchemaObject = Omit<
OpenAPIV3.SchemaObject,
'properties'
> &
OpenAPIV3.SchemaObject['properties'] & {
> & {
properties: OpenAPIV3.SchemaObject['properties'] & {
type: Omit<OpenAPIV3.SchemaObject, 'default'> & {
default: AnyInvocationType;
default: string;
};
} & {
class: 'output';
};
};
export type InvocationFieldSchema = OpenAPIV3.SchemaObject & _InputField;
@ -571,24 +715,26 @@ export const isInvocationFieldSchema = (
export type InvocationEdgeExtra = { type: 'default' | 'collapsed' };
export const zInputFieldValue = z.object({
id: z.string().trim().min(1),
name: z.string().trim().min(1),
type: zFieldType,
label: z.string(),
isExposed: z.boolean(),
});
export const zInvocationNodeData = z.object({
id: z.string().trim().min(1),
// no easy way to build this dynamically, and we don't want to anyways, because this will be used
// to validate incoming workflows, and we want to allow community nodes.
type: z.string().trim().min(1),
inputs: z.record(z.any()),
outputs: z.record(z.any()),
inputs: z.record(zInputFieldValue),
outputs: z.record(zOutputFieldValue),
label: z.string(),
isOpen: z.boolean(),
notes: z.string(),
});
// Massage this to get better type safety while developing
export type InvocationNodeData = Omit<
z.infer<typeof zInvocationNodeData>,
'type'
> & {
type: AnyInvocationType;
};
export const zNotesNodeData = z.object({
id: z.string().trim().min(1),
type: z.literal('notes'),
@ -597,75 +743,83 @@ export const zNotesNodeData = z.object({
notes: z.string(),
});
export const zWorkflow = z.object({
name: z.string().trim().min(1),
author: z.string(),
description: z.string(),
version: z.string(),
contact: z.string(),
tags: z.string(),
notes: z.string(),
nodes: z.array(
z.object({
export type NotesNodeData = z.infer<typeof zNotesNodeData>;
export const zWorkflowInvocationNode = z.object({
id: z.string().trim().min(1),
type: z.string().trim().min(1),
data: z.union([zInvocationNodeData, zNotesNodeData]),
type: z.literal('invocation'),
data: zInvocationNodeData,
width: z.number().gt(0),
height: z.number().gt(0),
position: z.object({
x: z.number(),
y: z.number(),
}),
})
),
edges: z.array(
z.object({
});
export const zWorkflowNotesNode = z.object({
id: z.string().trim().min(1),
type: z.literal('notes'),
data: zNotesNodeData,
width: z.number().gt(0),
height: z.number().gt(0),
position: z.object({
x: z.number(),
y: z.number(),
}),
});
export const zWorkflowNode = z.discriminatedUnion('type', [
zWorkflowInvocationNode,
zWorkflowNotesNode,
]);
export type WorkflowNode = z.infer<typeof zWorkflowNode>;
export const zWorkflowEdge = z.object({
source: z.string().trim().min(1),
sourceHandle: z.string().trim().min(1),
target: z.string().trim().min(1),
targetHandle: z.string().trim().min(1),
id: z.string().trim().min(1),
type: z.string().trim().min(1),
})
),
type: z.enum(['default', 'collapsed']),
});
export type Workflow = {
name: string;
author: string;
description: string;
version: string;
contact: string;
tags: string;
notes: string;
nodes: Pick<
Node<InvocationNodeData | NotesNodeData>,
'id' | 'type' | 'data' | 'width' | 'height' | 'position'
>[];
edges: Pick<
Edge<InvocationEdgeExtra>,
'source' | 'sourceHandle' | 'target' | 'targetHandle' | 'id' | 'type'
>[];
exposedFields: FieldIdentifier[];
};
export const zFieldIdentifier = z.object({
nodeId: z.string().trim().min(1),
fieldName: z.string().trim().min(1),
});
export type InvocationNodeData = {
id: string;
type: AnyInvocationType;
inputs: Record<string, InputFieldValue>;
outputs: Record<string, OutputFieldValue>;
label: string;
isOpen: boolean;
notes: string;
};
export type FieldIdentifier = z.infer<typeof zFieldIdentifier>;
export type NotesNodeData = {
id: string;
type: 'notes';
label: string;
notes: string;
isOpen: boolean;
};
export const zSemVer = z.string().refine((val) => {
const [major, minor, patch] = val.split('.');
return (
major !== undefined &&
minor !== undefined &&
patch !== undefined &&
Number.isInteger(Number(major)) &&
Number.isInteger(Number(minor)) &&
Number.isInteger(Number(patch))
);
});
export type SemVer = z.infer<typeof zSemVer>;
export const zWorkflow = z.object({
name: z.string(),
author: z.string(),
description: z.string(),
version: z.string(),
contact: z.string(),
tags: z.string(),
notes: z.string(),
nodes: z.array(zWorkflowNode),
edges: z.array(zWorkflowEdge),
exposedFields: z.array(zFieldIdentifier),
});
export type Workflow = z.infer<typeof zWorkflow>;
export type CurrentImageNodeData = {
id: string;
@ -705,25 +859,6 @@ export enum NodeStatus {
FAILED,
}
type SavedOutput =
| components['schemas']['StringOutput']
| components['schemas']['IntegerOutput']
| components['schemas']['FloatOutput']
| components['schemas']['ImageOutput'];
export const isSavedOutput = (
output: GraphExecutionState['results'][string]
): output is SavedOutput =>
Boolean(
output &&
[
'string_output',
'integer_output',
'float_output',
'image_output',
].includes(output?.type)
);
export type NodeExecutionState = {
nodeId: string;
status: NodeStatus;
@ -733,14 +868,9 @@ export type NodeExecutionState = {
outputs: AnyResult[];
};
export type FieldIdentifier = {
nodeId: string;
fieldName: string;
};
export type FieldComponentProps<
V extends InputFieldValue,
T extends InputFieldTemplate
T extends InputFieldTemplate,
> = {
nodeId: string;
field: V;

View File

@ -1,8 +1,7 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { pick } from 'lodash-es';
import { NodesState } from '../store/types';
import { Workflow, isInvocationNode, isNotesNode } from '../types/types';
import { Workflow, zWorkflowEdge, zWorkflowNode } from '../types/types';
export const buildWorkflow = (nodesState: NodesState): Workflow => {
const { workflow: workflowMeta, nodes, edges } = nodesState;
@ -13,25 +12,19 @@ export const buildWorkflow = (nodesState: NodesState): Workflow => {
};
nodes.forEach((node) => {
if (!isInvocationNode(node) && !isNotesNode(node)) {
const result = zWorkflowNode.safeParse(node);
if (!result.success) {
return;
}
workflow.nodes.push(
pick(node, ['id', 'type', 'position', 'width', 'height', 'data'])
);
workflow.nodes.push(result.data);
});
edges.forEach((edge) => {
workflow.edges.push(
pick(edge, [
'source',
'sourceHandle',
'target',
'targetHandle',
'id',
'type',
])
);
const result = zWorkflowEdge.safeParse(edge);
if (!result.success) {
return;
}
workflow.edges.push(result.data);
});
return workflow;

View File

@ -467,7 +467,7 @@ export const buildInputFieldTemplate = (
const fieldType = getFieldType(fieldSchema);
// console.log('input fieldType', fieldType);
const { input, ui_hidden, ui_component, ui_type } = fieldSchema;
const { input, ui_hidden, ui_component, ui_type, ui_order } = fieldSchema;
const extra = {
input,
@ -475,6 +475,7 @@ export const buildInputFieldTemplate = (
ui_component,
ui_type,
required: nodeSchema.required?.includes(name) ?? false,
ui_order,
};
const baseField = {

View File

@ -14,13 +14,34 @@ import {
} from '../types/types';
import { buildInputFieldTemplate, getFieldType } from './fieldTemplateBuilders';
const RESERVED_FIELD_NAMES = ['id', 'type', 'metadata'];
const RESERVED_INPUT_FIELD_NAMES = ['id', 'type', 'metadata'];
const RESERVED_OUTPUT_FIELD_NAMES = ['type'];
const invocationDenylist: AnyInvocationType[] = [
'graph',
'metadata_accumulator',
];
const isAllowedInputField = (nodeType: string, fieldName: string) => {
if (RESERVED_INPUT_FIELD_NAMES.includes(fieldName)) {
return false;
}
if (nodeType === 'collect' && fieldName === 'collection') {
return false;
}
if (nodeType === 'iterate' && fieldName === 'index') {
return false;
}
return true;
};
const isAllowedOutputField = (nodeType: string, fieldName: string) => {
if (RESERVED_OUTPUT_FIELD_NAMES.includes(fieldName)) {
return false;
}
return true;
};
const isNotInDenylist = (schema: InvocationSchemaObject) =>
!invocationDenylist.includes(schema.properties.type.default);
@ -42,17 +63,28 @@ export const parseSchema = (
const inputs = reduce(
schema.properties,
(inputsAccumulator, property, propertyName) => {
if (
!RESERVED_FIELD_NAMES.includes(propertyName) &&
isInvocationFieldSchema(property) &&
!property.ui_hidden
) {
if (!isAllowedInputField(type, propertyName)) {
logger('nodes').trace(
{ type, propertyName, property: parseify(property) },
'Skipped reserved input field'
);
return inputsAccumulator;
}
if (!isInvocationFieldSchema(property)) {
logger('nodes').warn(
{ type, propertyName, property: parseify(property) },
'Unhandled input property'
);
return inputsAccumulator;
}
const field = buildInputFieldTemplate(schema, property, propertyName);
if (field) {
inputsAccumulator[propertyName] = field;
}
}
return inputsAccumulator;
},
{} as Record<string, InputFieldTemplate>
@ -82,14 +114,27 @@ export const parseSchema = (
throw 'Invalid output schema';
}
const outputType = outputSchema.properties.type.default;
const outputs = reduce(
outputSchema.properties as OpenAPIV3.SchemaObject,
outputSchema.properties,
(outputsAccumulator, property, propertyName) => {
if (
!['type', 'id'].includes(propertyName) &&
!['object'].includes(property.type) && // TODO: handle objects?
isInvocationFieldSchema(property)
) {
if (!isAllowedOutputField(type, propertyName)) {
logger('nodes').trace(
{ type, propertyName, property: parseify(property) },
'Skipped reserved output field'
);
return outputsAccumulator;
}
if (!isInvocationFieldSchema(property)) {
logger('nodes').warn(
{ type, propertyName, property: parseify(property) },
'Unhandled output property'
);
return outputsAccumulator;
}
const fieldType = getFieldType(property);
outputsAccumulator[propertyName] = {
fieldKind: 'output',
@ -97,10 +142,10 @@ export const parseSchema = (
title: property.title ?? '',
description: property.description ?? '',
type: fieldType,
ui_hidden: property.ui_hidden ?? false,
ui_type: property.ui_type,
ui_order: property.ui_order,
};
} else {
logger('nodes').warn({ property }, 'Unhandled output property');
}
return outputsAccumulator;
},
@ -114,6 +159,7 @@ export const parseSchema = (
description,
inputs,
outputs,
outputType,
};
Object.assign(acc, { [type]: invocation });

View File

@ -69,31 +69,29 @@ const ParamControlNetCollapse = () => {
return (
<IAICollapse label="ControlNet" activeLabel={activeLabel}>
<Flex sx={{ flexDir: 'column', gap: 3 }}>
<Flex gap={2} alignItems="center">
<Flex sx={{ flexDir: 'column', gap: 2 }}>
<Flex
sx={{
flexDirection: 'column',
w: '100%',
gap: 2,
px: 4,
py: 2,
borderRadius: 4,
bg: 'base.200',
p: 2,
ps: 3,
borderRadius: 'base',
alignItems: 'center',
bg: 'base.250',
_dark: {
bg: 'base.850',
bg: 'base.750',
},
}}
>
<ParamControlNetFeatureToggle />
</Flex>
<IAIIconButton
tooltip="Add ControlNet"
aria-label="Add ControlNet"
icon={<FaPlus />}
isDisabled={!firstModel}
flexGrow={1}
size="md"
size="sm"
onClick={handleClickedAddControlNet}
/>
</Flex>

View File

@ -23,9 +23,8 @@ import { useFeatureStatus } from '../../../../system/hooks/useFeatureStatus';
const promptInputSelector = createSelector(
[stateSelector, activeTabNameSelector],
({ generation, ui }, activeTabName) => {
({ generation }, activeTabName) => {
return {
shouldPinParametersPanel: ui.shouldPinParametersPanel,
prompt: generation.positivePrompt,
activeTabName,
};
@ -42,8 +41,7 @@ const promptInputSelector = createSelector(
*/
const ParamPositiveConditioning = () => {
const dispatch = useAppDispatch();
const { prompt, shouldPinParametersPanel, activeTabName } =
useAppSelector(promptInputSelector);
const { prompt, activeTabName } = useAppSelector(promptInputSelector);
const isReady = useIsReadyToInvoke();
const promptRef = useRef<HTMLTextAreaElement>(null);
const { isOpen, onClose, onOpen } = useDisclosure();
@ -148,7 +146,7 @@ const ParamPositiveConditioning = () => {
<Box
sx={{
position: 'absolute',
top: shouldPinParametersPanel ? 5 : 0,
top: 0,
insetInlineEnd: 0,
}}
>

View File

@ -49,7 +49,7 @@ const InitialImageDisplay = () => {
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'base',
p: 4,
p: 2,
gap: 4,
}}
>
@ -64,6 +64,7 @@ const InitialImageDisplay = () => {
>
<Text
sx={{
ps: 2,
fontWeight: 600,
userSelect: 'none',
color: 'base.700',

View File

@ -27,6 +27,7 @@ import { MdCancel, MdCancelScheduleSend } from 'react-icons/md';
import { ChevronDownIcon } from '@chakra-ui/icons';
import { sessionCanceled } from 'services/api/thunks/session';
import IAIButton from 'common/components/IAIButton';
const cancelButtonSelector = createSelector(
systemSelector,
@ -49,15 +50,14 @@ const cancelButtonSelector = createSelector(
}
);
interface CancelButtonProps {
type Props = Omit<ButtonProps, 'aria-label'> & {
btnGroupWidth?: string | number;
}
asIconButton?: boolean;
};
const CancelButton = (
props: CancelButtonProps & Omit<ButtonProps, 'aria-label'>
) => {
const CancelButton = (props: Props) => {
const dispatch = useAppDispatch();
const { btnGroupWidth = 'auto', ...rest } = props;
const { btnGroupWidth = 'auto', asIconButton = false, ...rest } = props;
const {
isProcessing,
isConnected,
@ -124,6 +124,7 @@ const CancelButton = (
return (
<ButtonGroup isAttached width={btnGroupWidth}>
{asIconButton ? (
<IAIIconButton
icon={cancelIcon}
tooltip={cancelLabel}
@ -134,6 +135,20 @@ const CancelButton = (
id="cancel-button"
{...rest}
/>
) : (
<IAIButton
leftIcon={cancelIcon}
tooltip={cancelLabel}
aria-label={cancelLabel}
isDisabled={!isConnected || !isProcessing || !isCancelable}
onClick={handleClickCancel}
colorScheme="error"
id="cancel-button"
{...rest}
>
Cancel
</IAIButton>
)}
<Menu closeOnSelect={false}>
<MenuButton
as={IAIIconButton}

View File

@ -1,6 +1,5 @@
import {
Box,
ChakraProps,
Divider,
Flex,
ListItem,
@ -19,7 +18,6 @@ import IAIIconButton, {
import { useIsReadyToInvoke } from 'common/hooks/useIsReadyToInvoke';
import { clampSymmetrySteps } from 'features/parameters/store/generationSlice';
import ProgressBar from 'features/system/components/ProgressBar';
import { selectIsBusy } from 'features/system/store/systemSelectors';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
@ -27,42 +25,16 @@ import { useTranslation } from 'react-i18next';
import { FaPlay } from 'react-icons/fa';
import { useBoardName } from 'services/api/hooks/useBoardName';
const IN_PROGRESS_STYLES: ChakraProps['sx'] = {
_disabled: {
bg: 'none',
color: 'base.600',
cursor: 'not-allowed',
_hover: {
color: 'base.600',
bg: 'none',
},
},
};
const selector = createSelector(
[stateSelector, activeTabNameSelector, selectIsBusy],
({ gallery }, activeTabName, isBusy) => {
const { autoAddBoardId } = gallery;
return {
isBusy,
autoAddBoardId,
activeTabName,
};
},
defaultSelectorOptions
);
interface InvokeButton
extends Omit<IAIButtonProps | IAIIconButtonProps, 'aria-label'> {
iconButton?: boolean;
asIconButton?: boolean;
}
export default function InvokeButton(props: InvokeButton) {
const { iconButton = false, ...rest } = props;
const { asIconButton = false, sx, ...rest } = props;
const dispatch = useAppDispatch();
const { isReady, isProcessing } = useIsReadyToInvoke();
const { activeTabName } = useAppSelector(selector);
const activeTabName = useAppSelector(activeTabNameSelector);
const handleInvoke = useCallback(() => {
dispatch(clampSymmetrySteps());
@ -87,21 +59,22 @@ export default function InvokeButton(props: InvokeButton) {
<Box style={{ position: 'relative' }}>
{!isReady && (
<Box
borderRadius="base"
style={{
sx={{
position: 'absolute',
bottom: '0',
left: '0',
right: '0',
height: '100%',
overflow: 'clip',
borderRadius: 'base',
...sx,
}}
{...rest}
>
<ProgressBar />
</Box>
)}
{iconButton ? (
{asIconButton ? (
<IAIIconButton
aria-label={t('parameters.invoke')}
type="submit"
@ -112,18 +85,20 @@ export default function InvokeButton(props: InvokeButton) {
colorScheme="accent"
isLoading={isProcessing}
id="invoke-button"
{...rest}
data-progress={isProcessing}
sx={{
w: 'full',
flexGrow: 1,
...(isProcessing ? IN_PROGRESS_STYLES : {}),
...sx,
}}
{...rest}
/>
) : (
<IAIButton
tooltip={<InvokeButtonTooltipContent />}
aria-label={t('parameters.invoke')}
type="submit"
data-progress={isProcessing}
isDisabled={!isReady}
onClick={handleInvoke}
colorScheme="accent"
@ -131,13 +106,13 @@ export default function InvokeButton(props: InvokeButton) {
leftIcon={isProcessing ? undefined : <FaPlay />}
isLoading={isProcessing}
loadingText={t('parameters.invoke')}
{...rest}
sx={{
w: 'full',
flexGrow: 1,
fontWeight: 700,
...(isProcessing ? IN_PROGRESS_STYLES : {}),
...sx,
}}
{...rest}
>
Invoke
</IAIButton>
@ -147,9 +122,21 @@ export default function InvokeButton(props: InvokeButton) {
);
}
const tooltipSelector = createSelector(
[stateSelector],
({ gallery }) => {
const { autoAddBoardId } = gallery;
return {
autoAddBoardId,
};
},
defaultSelectorOptions
);
export const InvokeButtonTooltipContent = memo(() => {
const { isReady, reasons } = useIsReadyToInvoke();
const { autoAddBoardId } = useAppSelector(selector);
const { autoAddBoardId } = useAppSelector(tooltipSelector);
const autoAddBoardName = useBoardName(autoAddBoardId);
return (
@ -166,7 +153,11 @@ export const InvokeButtonTooltipContent = memo(() => {
))}
</UnorderedList>
)}
<Divider opacity={0.2} />
<Divider
opacity={0.2}
borderColor="base.50"
_dark={{ borderColor: 'base.900' }}
/>
<Text fontWeight={400} fontStyle="oblique 10deg">
Adding images to{' '}
<Text as="span" fontWeight={600}>

View File

@ -1,14 +1,14 @@
import { Flex } from '@chakra-ui/react';
import { memo } from 'react';
import CancelButton from './CancelButton';
import InvokeButton from './InvokeButton';
import { memo } from 'react';
/**
* Buttons to start and cancel image generation.
*/
const ProcessButtons = () => {
return (
<Flex gap={2}>
<Flex layerStyle="first" sx={{ gap: 2, borderRadius: 'base', p: 2 }}>
<InvokeButton />
<CancelButton />
</Flex>

View File

@ -210,7 +210,7 @@ export type HeightParam = z.infer<typeof zHeight>;
export const isValidHeight = (val: unknown): val is HeightParam =>
zHeight.safeParse(val).success;
const zBaseModel = z.enum(['sd-1', 'sd-2', 'sdxl', 'sdxl-refiner']);
export const zBaseModel = z.enum(['sd-1', 'sd-2', 'sdxl', 'sdxl-refiner']);
export type BaseModelParam = z.infer<typeof zBaseModel>;

View File

@ -9,10 +9,6 @@ export default function ParamSDXLConcatButton() {
(state: RootState) => state.sdxl.shouldConcatSDXLStylePrompt
);
const shouldPinParametersPanel = useAppSelector(
(state: RootState) => state.ui.shouldPinParametersPanel
);
const dispatch = useAppDispatch();
const handleShouldConcatPromptChange = () => {
@ -31,7 +27,7 @@ export default function ParamSDXLConcatButton() {
sx={{
position: 'absolute',
insetInlineEnd: 1,
top: shouldPinParametersPanel ? 12 : 20,
top: 6,
border: 'none',
color: shouldConcatSDXLStylePrompt ? 'accent.500' : 'base.500',
_hover: {

View File

@ -2,17 +2,15 @@ import ParamDynamicPromptsCollapse from 'features/dynamicPrompts/components/Para
import ParamLoraCollapse from 'features/lora/components/ParamLoraCollapse';
import ParamControlNetCollapse from 'features/parameters/components/Parameters/ControlNet/ParamControlNetCollapse';
import ParamNoiseCollapse from 'features/parameters/components/Parameters/Noise/ParamNoiseCollapse';
import ProcessButtons from 'features/parameters/components/ProcessButtons/ProcessButtons';
import { memo } from 'react';
import ParamSDXLPromptArea from './ParamSDXLPromptArea';
import ParamSDXLRefinerCollapse from './ParamSDXLRefinerCollapse';
import SDXLImageToImageTabCoreParameters from './SDXLImageToImageTabCoreParameters';
import { memo } from 'react';
const SDXLImageToImageTabParameters = () => {
return (
<>
<ParamSDXLPromptArea />
<ProcessButtons />
<SDXLImageToImageTabCoreParameters />
<ParamSDXLRefinerCollapse />
<ParamControlNetCollapse />

View File

@ -2,17 +2,15 @@ import ParamDynamicPromptsCollapse from 'features/dynamicPrompts/components/Para
import ParamLoraCollapse from 'features/lora/components/ParamLoraCollapse';
import ParamControlNetCollapse from 'features/parameters/components/Parameters/ControlNet/ParamControlNetCollapse';
import ParamNoiseCollapse from 'features/parameters/components/Parameters/Noise/ParamNoiseCollapse';
import ProcessButtons from 'features/parameters/components/ProcessButtons/ProcessButtons';
import TextToImageTabCoreParameters from 'features/ui/components/tabs/TextToImage/TextToImageTabCoreParameters';
import { memo } from 'react';
import ParamSDXLPromptArea from './ParamSDXLPromptArea';
import ParamSDXLRefinerCollapse from './ParamSDXLRefinerCollapse';
import { memo } from 'react';
const SDXLTextToImageTabParameters = () => {
return (
<>
<ParamSDXLPromptArea />
<ProcessButtons />
<TextToImageTabCoreParameters />
<ParamSDXLRefinerCollapse />
<ParamControlNetCollapse />

View File

@ -5,7 +5,6 @@ import ParamMaskAdjustmentCollapse from 'features/parameters/components/Paramete
import ParamSeamPaintingCollapse from 'features/parameters/components/Parameters/Canvas/SeamPainting/ParamSeamPaintingCollapse';
import ParamControlNetCollapse from 'features/parameters/components/Parameters/ControlNet/ParamControlNetCollapse';
import ParamNoiseCollapse from 'features/parameters/components/Parameters/Noise/ParamNoiseCollapse';
import ProcessButtons from 'features/parameters/components/ProcessButtons/ProcessButtons';
import ParamSDXLPromptArea from './ParamSDXLPromptArea';
import ParamSDXLRefinerCollapse from './ParamSDXLRefinerCollapse';
import SDXLUnifiedCanvasTabCoreParameters from './SDXLUnifiedCanvasTabCoreParameters';
@ -14,7 +13,6 @@ export default function SDXLUnifiedCanvasTabParameters() {
return (
<>
<ParamSDXLPromptArea />
<ProcessButtons />
<SDXLUnifiedCanvasTabCoreParameters />
<ParamSDXLRefinerCollapse />
<ParamControlNetCollapse />

View File

@ -60,11 +60,6 @@ export default function HotkeysModal({ children }: HotkeysModalProps) {
desc: t('hotkeys.toggleOptions.desc'),
hotkey: 'O',
},
{
title: t('hotkeys.pinOptions.title'),
desc: t('hotkeys.pinOptions.desc'),
hotkey: 'Shift+O',
},
{
title: t('hotkeys.toggleGallery.title'),
desc: t('hotkeys.toggleGallery.desc'),
@ -136,11 +131,6 @@ export default function HotkeysModal({ children }: HotkeysModalProps) {
desc: t('hotkeys.nextImage.desc'),
hotkey: 'Arrow Right',
},
{
title: t('hotkeys.toggleGalleryPin.title'),
desc: t('hotkeys.toggleGalleryPin.desc'),
hotkey: 'Shift+G',
},
{
title: t('hotkeys.increaseGalleryThumbSize.title'),
desc: t('hotkeys.increaseGalleryThumbSize.desc'),
@ -290,7 +280,7 @@ export default function HotkeysModal({ children }: HotkeysModalProps) {
{
title: t('hotkeys.addNodes.title'),
desc: t('hotkeys.addNodes.desc'),
hotkey: 'Shift + A',
hotkey: 'Shift + A / Space',
},
];

View File

@ -15,7 +15,7 @@ const InvokeAILogoComponent = ({ showVersion = true }: Props) => {
const isHovered = useHoverDirty(ref);
return (
<Flex alignItems="center" gap={3} ps={1} ref={ref}>
<Flex alignItems="center" gap={5} ps={1} ref={ref}>
<Image
src={InvokeAILogoImage}
alt="invoke-ai-logo"

View File

@ -0,0 +1,82 @@
import {
Flex,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
useDisclosure,
} from '@chakra-ui/react';
import { LOCALSTORAGE_KEYS, LOCALSTORAGE_PREFIX } from 'app/store/constants';
import IAIButton from 'common/components/IAIButton';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
onSettingsModalClose: () => void;
};
const ResetWebUIButton = ({ onSettingsModalClose }: Props) => {
const { t } = useTranslation();
const [countdown, setCountdown] = useState(5);
const {
isOpen: isRefreshModalOpen,
onOpen: onRefreshModalOpen,
onClose: onRefreshModalClose,
} = useDisclosure();
const handleClickResetWebUI = useCallback(() => {
// Only remove our keys
Object.keys(window.localStorage).forEach((key) => {
if (
LOCALSTORAGE_KEYS.includes(key) ||
key.startsWith(LOCALSTORAGE_PREFIX)
) {
localStorage.removeItem(key);
}
});
onSettingsModalClose();
onRefreshModalOpen();
setInterval(() => setCountdown((prev) => prev - 1), 1000);
}, [onSettingsModalClose, onRefreshModalOpen]);
useEffect(() => {
if (countdown <= 0) {
window.location.reload();
}
}, [countdown]);
return (
<>
<IAIButton colorScheme="error" onClick={handleClickResetWebUI}>
{t('settings.resetWebUI')}
</IAIButton>
<Modal
closeOnOverlayClick={false}
isOpen={isRefreshModalOpen}
onClose={onRefreshModalClose}
isCentered
closeOnEsc={false}
>
<ModalOverlay backdropFilter="blur(40px)" />
<ModalContent>
<ModalHeader />
<ModalBody>
<Flex justifyContent="center">
<Text fontSize="lg">
<Text>{t('settings.resetComplete')}</Text>
<Text>Reloading in {countdown}...</Text>
</Text>
</Flex>
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
</>
);
};
export default memo(ResetWebUIButton);

View File

@ -31,7 +31,6 @@ import {
} from 'features/system/store/systemSlice';
import {
setShouldShowProgressInViewer,
setShouldUseCanvasBetaLayout,
setShouldUseSliders,
} from 'features/ui/store/uiSlice';
import { isEqual } from 'lodash-es';
@ -42,6 +41,7 @@ import {
memo,
useCallback,
useEffect,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { LogLevelName } from 'roarr';
@ -68,18 +68,13 @@ const selector = createSelector(
shouldUseWatermarker,
} = system;
const {
shouldUseCanvasBetaLayout,
shouldUseSliders,
shouldShowProgressInViewer,
} = ui;
const { shouldUseSliders, shouldShowProgressInViewer } = ui;
const { shouldShowAdvancedOptions } = generation;
return {
shouldConfirmOnDelete,
enableImageDebugging,
shouldUseCanvasBetaLayout,
shouldUseSliders,
shouldShowProgressInViewer,
consoleLogLevel,
@ -98,7 +93,6 @@ const selector = createSelector(
type ConfigOptions = {
shouldShowDeveloperSettings: boolean;
shouldShowResetWebUiText: boolean;
shouldShowBetaLayout: boolean;
shouldShowAdvancedOptionsSettings: boolean;
shouldShowClearIntermediates: boolean;
shouldShowLocalizationToggle: boolean;
@ -113,8 +107,8 @@ type SettingsModalProps = {
const SettingsModal = ({ children, config }: SettingsModalProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const [countdown, setCountdown] = useState(3);
const shouldShowBetaLayout = config?.shouldShowBetaLayout ?? true;
const shouldShowDeveloperSettings =
config?.shouldShowDeveloperSettings ?? true;
const shouldShowResetWebUiText = config?.shouldShowResetWebUiText ?? true;
@ -156,7 +150,6 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
const {
shouldConfirmOnDelete,
enableImageDebugging,
shouldUseCanvasBetaLayout,
shouldUseSliders,
shouldShowProgressInViewer,
consoleLogLevel,
@ -179,8 +172,15 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
});
onSettingsModalClose();
onRefreshModalOpen();
setInterval(() => setCountdown((prev) => prev - 1), 1000);
}, [onSettingsModalClose, onRefreshModalOpen]);
useEffect(() => {
if (countdown <= 0) {
window.location.reload();
}
}, [countdown]);
const handleLogLevelChanged = useCallback(
(v: string) => {
dispatch(consoleLogLevelChanged(v as LogLevelName));
@ -297,17 +297,6 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
)
}
/>
{shouldShowBetaLayout && (
<SettingSwitch
label={t('settings.alternateCanvasLayout')}
useBadge
badgeLabel={t('settings.beta')}
isChecked={shouldUseCanvasBetaLayout}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
dispatch(setShouldUseCanvasBetaLayout(e.target.checked))
}
/>
)}
{shouldShowLocalizationToggle && (
<IAIMantineSelect
disabled={!isLocalizationEnabled}
@ -381,6 +370,7 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
isOpen={isRefreshModalOpen}
onClose={onRefreshModalClose}
isCentered
closeOnEsc={false}
>
<ModalOverlay backdropFilter="blur(40px)" />
<ModalContent>
@ -388,7 +378,9 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
<ModalBody>
<Flex justifyContent="center">
<Text fontSize="lg">
<Text>{t('settings.resetComplete')}</Text>
<Text>
{t('settings.resetComplete')} Reloading in {countdown}...
</Text>
</Text>
</Flex>
</ModalBody>

View File

@ -1,38 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { useMemo } from 'react';
import { configSelector } from '../store/configSelectors';
import { systemSelector } from '../store/systemSelectors';
const isApplicationReadySelector = createSelector(
[systemSelector, configSelector],
(system, config) => {
const { wasSchemaParsed } = system;
const { disabledTabs } = config;
return {
disabledTabs,
wasSchemaParsed,
};
}
);
/**
* Checks if the application is ready to be used, i.e. if the initial startup process is finished.
*/
export const useIsApplicationReady = () => {
const { disabledTabs, wasSchemaParsed } = useAppSelector(
isApplicationReadySelector
);
const isApplicationReady = useMemo(() => {
if (!disabledTabs.includes('nodes') && !wasSchemaParsed) {
return false;
}
return true;
}, [disabledTabs, wasSchemaParsed]);
return isApplicationReady;
};

View File

@ -16,7 +16,6 @@ export const systemPersistDenylist: (keyof SystemState)[] = [
'isCancelScheduled',
'progressImage',
'wereModelsReceived',
'wasSchemaParsed',
'isPersisted',
'isUploading',
];

View File

@ -2,9 +2,8 @@ import { UseToastOptions } from '@chakra-ui/react';
import { PayloadAction, createSlice, isAnyOf } from '@reduxjs/toolkit';
import { InvokeLogLevel } from 'app/logging/logger';
import { userInvoked } from 'app/store/actions';
import { nodeTemplatesBuilt } from 'features/nodes/store/nodesSlice';
import { t } from 'i18next';
import { startCase, upperFirst } from 'lodash-es';
import { get, startCase, upperFirst } from 'lodash-es';
import { LogLevelName } from 'roarr';
import {
isAnySessionRejected,
@ -68,10 +67,6 @@ export interface SystemState {
* Whether or not the available models were received
*/
wereModelsReceived: boolean;
/**
* Whether or not the OpenAPI schema was received and parsed
*/
wasSchemaParsed: boolean;
/**
* The console output logging level
*/
@ -112,7 +107,6 @@ export const initialSystemState: SystemState = {
isCancelScheduled: false,
subscribedNodeIds: [],
wereModelsReceived: false,
wasSchemaParsed: false,
consoleLogLevel: 'debug',
shouldLogToConsole: true,
statusTranslationKey: 'common.statusDisconnected',
@ -339,13 +333,6 @@ export const systemSlice = createSlice({
);
});
/**
* OpenAPI schema was parsed
*/
builder.addCase(nodeTemplatesBuilt, (state) => {
state.wasSchemaParsed = true;
});
// *** Matchers - must be after all cases ***
/**
@ -381,14 +368,14 @@ export const systemSlice = createSlice({
return;
}
} else if (action.payload?.error) {
errorDescription = action.payload?.error as string;
errorDescription = action.payload?.error;
}
state.toastQueue.push(
makeToast({
title: t('toast.serverError'),
status: 'error',
description: errorDescription,
description: get(errorDescription, 'detail', 'Unknown Error'),
duration,
})
);

View File

@ -1,65 +1,57 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { Flex } from '@chakra-ui/layout';
import { Portal } from '@chakra-ui/portal';
import IAIIconButton from 'common/components/IAIIconButton';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { setShouldShowGallery } from 'features/ui/store/uiSlice';
import { isEqual } from 'lodash-es';
import { memo } from 'react';
import { RefObject, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { MdPhotoLibrary } from 'react-icons/md';
import { activeTabNameSelector, uiSelector } from '../store/uiSelectors';
import { NO_GALLERY_TABS } from './InvokeTabs';
import { ImperativePanelHandle } from 'react-resizable-panels';
const floatingGalleryButtonSelector = createSelector(
[activeTabNameSelector, uiSelector],
(activeTabName, ui) => {
const { shouldPinGallery, shouldShowGallery } = ui;
return {
shouldPinGallery,
shouldShowGalleryButton: NO_GALLERY_TABS.includes(activeTabName)
? false
: !shouldShowGallery,
type Props = {
isGalleryCollapsed: boolean;
galleryPanelRef: RefObject<ImperativePanelHandle>;
};
},
{ memoizeOptions: { resultEqualityCheck: isEqual } }
);
const FloatingGalleryButton = () => {
const FloatingGalleryButton = ({
isGalleryCollapsed,
galleryPanelRef,
}: Props) => {
const { t } = useTranslation();
const { shouldPinGallery, shouldShowGalleryButton } = useAppSelector(
floatingGalleryButtonSelector
);
const dispatch = useAppDispatch();
const handleShowGallery = () => {
dispatch(setShouldShowGallery(true));
shouldPinGallery && dispatch(requestCanvasRescale());
galleryPanelRef.current?.expand();
};
return shouldShowGalleryButton ? (
if (!isGalleryCollapsed) {
return null;
}
return (
<Portal>
<Flex
pos="absolute"
transform="translate(0, -50%)"
minW={8}
top="50%"
insetInlineEnd="1.63rem"
>
<IAIIconButton
tooltip="Show Gallery (G)"
tooltipProps={{ placement: 'top' }}
aria-label={t('accessibility.showGallery')}
aria-label={t('common.showGalleryPanel')}
onClick={handleShowGallery}
icon={<MdPhotoLibrary />}
sx={{
pos: 'absolute',
top: '50%',
transform: 'translate(0, -50%)',
p: 0,
insetInlineEnd: 0,
px: 3,
h: 48,
w: 8,
borderStartEndRadius: 0,
borderEndEndRadius: 0,
shadow: '2xl',
}}
>
<MdPhotoLibrary />
</IAIIconButton>
) : null;
/>
</Flex>
</Portal>
);
};
export default memo(FloatingGalleryButton);

View File

@ -1,20 +1,12 @@
import { ChakraProps, Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { ChakraProps, Flex, Portal } from '@chakra-ui/react';
import IAIIconButton from 'common/components/IAIIconButton';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import CancelButton from 'features/parameters/components/ProcessButtons/CancelButton';
import InvokeButton from 'features/parameters/components/ProcessButtons/InvokeButton';
import {
activeTabNameSelector,
uiSelector,
} from 'features/ui/store/uiSelectors';
import { setShouldShowParametersPanel } from 'features/ui/store/uiSlice';
import { isEqual } from 'lodash-es';
import { memo } from 'react';
import { RefObject, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaSlidersH } from 'react-icons/fa';
import { ImperativePanelHandle } from 'react-resizable-panels';
const floatingButtonStyles: ChakraProps['sx'] = {
borderStartStartRadius: 0,
@ -22,81 +14,48 @@ const floatingButtonStyles: ChakraProps['sx'] = {
shadow: '2xl',
};
export const floatingParametersPanelButtonSelector = createSelector(
[uiSelector, activeTabNameSelector],
(ui, activeTabName) => {
const {
shouldPinParametersPanel,
shouldUseCanvasBetaLayout,
shouldShowParametersPanel,
} = ui;
const canvasBetaLayoutCheck =
shouldUseCanvasBetaLayout && activeTabName === 'unifiedCanvas';
const shouldShowProcessButtons =
!canvasBetaLayoutCheck &&
(!shouldPinParametersPanel || !shouldShowParametersPanel);
const shouldShowParametersPanelButton =
!canvasBetaLayoutCheck &&
!shouldShowParametersPanel &&
['txt2img', 'img2img', 'unifiedCanvas'].includes(activeTabName);
return {
shouldPinParametersPanel,
shouldShowParametersPanelButton,
shouldShowProcessButtons,
type Props = {
isSidePanelCollapsed: boolean;
sidePanelRef: RefObject<ImperativePanelHandle>;
};
},
{ memoizeOptions: { resultEqualityCheck: isEqual } }
);
const FloatingParametersPanelButtons = () => {
const dispatch = useAppDispatch();
const FloatingSidePanelButtons = ({
isSidePanelCollapsed,
sidePanelRef,
}: Props) => {
const { t } = useTranslation();
const {
shouldShowProcessButtons,
shouldShowParametersPanelButton,
shouldPinParametersPanel,
} = useAppSelector(floatingParametersPanelButtonSelector);
const handleShowOptionsPanel = () => {
dispatch(setShouldShowParametersPanel(true));
shouldPinParametersPanel && dispatch(requestCanvasRescale());
const handleShowSidePanel = () => {
sidePanelRef.current?.expand();
};
if (!shouldShowParametersPanelButton) {
if (!isSidePanelCollapsed) {
return null;
}
return (
<Portal>
<Flex
pos="absolute"
transform="translate(0, -50%)"
minW={8}
top="50%"
insetInlineStart="4.5rem"
insetInlineStart="5.13rem"
direction="column"
gap={2}
>
<IAIIconButton
tooltip="Show Options Panel (O)"
tooltipProps={{ placement: 'top' }}
aria-label={t('accessibility.showOptionsPanel')}
onClick={handleShowOptionsPanel}
tooltip="Show Side Panel (O, T)"
aria-label={t('common.showOptionsPanel')}
onClick={handleShowSidePanel}
sx={floatingButtonStyles}
>
<FaSlidersH />
</IAIIconButton>
{shouldShowProcessButtons && (
<>
<InvokeButton iconButton sx={floatingButtonStyles} />
<CancelButton sx={floatingButtonStyles} />
</>
)}
icon={<FaSlidersH />}
/>
<InvokeButton asIconButton sx={floatingButtonStyles} />
<CancelButton sx={floatingButtonStyles} asIconButton />
</Flex>
</Portal>
);
};
export default memo(FloatingParametersPanelButtons);
export default memo(FloatingSidePanelButtons);

View File

@ -11,12 +11,12 @@ import {
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import AuxiliaryProgressIndicator from 'app/components/AuxiliaryProgressIndicator';
import { RootState, stateSelector } from 'app/store/store';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import ImageGalleryContent from 'features/gallery/components/ImageGalleryContent';
import NodeEditorPanelGroup from 'features/nodes/components/sidePanel/NodeEditorPanelGroup';
import { InvokeTabName, tabMap } from 'features/ui/store/tabMap';
import { setActiveTab, togglePanels } from 'features/ui/store/uiSlice';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { ResourceKey } from 'i18next';
import { isEqual } from 'lodash-es';
import { MouseEvent, ReactNode, memo, useCallback, useMemo } from 'react';
@ -25,11 +25,15 @@ import { useTranslation } from 'react-i18next';
import { FaCube, FaFont, FaImage } from 'react-icons/fa';
import { MdDeviceHub, MdGridOn } from 'react-icons/md';
import { Panel, PanelGroup } from 'react-resizable-panels';
import { useMinimumPanelSize } from '../hooks/useMinimumPanelSize';
import { usePanel } from '../hooks/usePanel';
import { usePanelStorage } from '../hooks/usePanelStorage';
import {
activeTabIndexSelector,
activeTabNameSelector,
} from '../store/uiSelectors';
import FloatingGalleryButton from './FloatingGalleryButton';
import FloatingSidePanelButtons from './FloatingParametersPanelButtons';
import ParametersPanel from './ParametersPanel';
import ImageTab from './tabs/ImageToImage/ImageToImageTab';
import ModelManagerTab from './tabs/ModelManager/ModelManagerTab';
import NodesTab from './tabs/Nodes/NodesTab';
@ -89,38 +93,20 @@ const enabledTabsSelector = createSelector(
}
);
const MIN_GALLERY_WIDTH = 350;
const DEFAULT_GALLERY_PCT = 20;
const SIDE_PANEL_MIN_SIZE_PX = 448;
const MAIN_PANEL_MIN_SIZE_PX = 448;
const GALLERY_PANEL_MIN_SIZE_PX = 360;
export const NO_GALLERY_TABS: InvokeTabName[] = ['modelManager'];
export const NO_SIDE_PANEL_TABS: InvokeTabName[] = ['modelManager'];
const InvokeTabs = () => {
const activeTab = useAppSelector(activeTabIndexSelector);
const activeTabName = useAppSelector(activeTabNameSelector);
const enabledTabs = useAppSelector(enabledTabsSelector);
const { shouldPinGallery, shouldPinParametersPanel, shouldShowGallery } =
useAppSelector((state: RootState) => state.ui);
const { t } = useTranslation();
const dispatch = useAppDispatch();
useHotkeys(
'f',
() => {
dispatch(togglePanels());
(shouldPinGallery || shouldPinParametersPanel) &&
dispatch(requestCanvasRescale());
},
[shouldPinGallery, shouldPinParametersPanel]
);
const handleResizeGallery = useCallback(() => {
if (activeTabName === 'unifiedCanvas') {
dispatch(requestCanvasRescale());
}
}, [dispatch, activeTabName]);
const handleClickTab = useCallback((e: MouseEvent<HTMLElement>) => {
if (e.target instanceof HTMLElement) {
e.target.blur();
@ -153,9 +139,6 @@ const InvokeTabs = () => {
[enabledTabs]
);
const { ref: galleryPanelRef, minSizePct: galleryMinSizePct } =
useMinimumPanelSize(MIN_GALLERY_WIDTH, DEFAULT_GALLERY_PCT, 'app');
const handleTabChange = useCallback(
(index: number) => {
const activeTabName = tabMap[index];
@ -167,6 +150,60 @@ const InvokeTabs = () => {
[dispatch]
);
const {
minSize: sidePanelMinSize,
isCollapsed: isSidePanelCollapsed,
setIsCollapsed: setIsSidePanelCollapsed,
ref: sidePanelRef,
reset: resetSidePanel,
expand: expandSidePanel,
collapse: collapseSidePanel,
toggle: toggleSidePanel,
} = usePanel(SIDE_PANEL_MIN_SIZE_PX, 'pixels');
const {
ref: galleryPanelRef,
minSize: galleryPanelMinSize,
isCollapsed: isGalleryPanelCollapsed,
setIsCollapsed: setIsGalleryPanelCollapsed,
reset: resetGalleryPanel,
expand: expandGalleryPanel,
collapse: collapseGalleryPanel,
toggle: toggleGalleryPanel,
} = usePanel(GALLERY_PANEL_MIN_SIZE_PX, 'pixels');
useHotkeys(
'f',
() => {
if (isGalleryPanelCollapsed || isSidePanelCollapsed) {
expandGalleryPanel();
expandSidePanel();
} else {
collapseSidePanel();
collapseGalleryPanel();
}
},
[dispatch, isGalleryPanelCollapsed, isSidePanelCollapsed]
);
useHotkeys(
['t', 'o'],
() => {
toggleSidePanel();
},
[dispatch]
);
useHotkeys(
'g',
() => {
toggleGalleryPanel();
},
[dispatch]
);
const panelStorage = usePanelStorage();
return (
<Tabs
variant="appTabs"
@ -195,33 +232,64 @@ const InvokeTabs = () => {
autoSaveId="app"
direction="horizontal"
style={{ height: '100%', width: '100%' }}
storage={panelStorage}
units="pixels"
>
<Panel id="main">
{!NO_SIDE_PANEL_TABS.includes(activeTabName) && (
<>
<Panel
order={0}
id="side"
ref={sidePanelRef}
defaultSize={sidePanelMinSize}
minSize={sidePanelMinSize}
onCollapse={setIsSidePanelCollapsed}
collapsible
>
{activeTabName === 'nodes' ? (
<NodeEditorPanelGroup />
) : (
<ParametersPanel />
)}
</Panel>
<ResizeHandle
onDoubleClick={resetSidePanel}
// isCollapsed={isSidePanelCollapsed}
collapsedDirection={isSidePanelCollapsed ? 'left' : undefined}
/>
<FloatingSidePanelButtons
isSidePanelCollapsed={isSidePanelCollapsed}
sidePanelRef={sidePanelRef}
/>
</>
)}
<Panel id="main" order={1} minSize={MAIN_PANEL_MIN_SIZE_PX}>
<TabPanels style={{ height: '100%', width: '100%' }}>
{tabPanels}
</TabPanels>
</Panel>
{shouldPinGallery &&
shouldShowGallery &&
!NO_GALLERY_TABS.includes(activeTabName) && (
{!NO_GALLERY_TABS.includes(activeTabName) && (
<>
<ResizeHandle />
<ResizeHandle
onDoubleClick={resetGalleryPanel}
// isCollapsed={isGalleryPanelCollapsed}
collapsedDirection={isGalleryPanelCollapsed ? 'right' : undefined}
/>
<Panel
ref={galleryPanelRef}
onResize={handleResizeGallery}
id="gallery"
order={3}
defaultSize={
galleryMinSizePct > DEFAULT_GALLERY_PCT &&
galleryMinSizePct < 100 // prevent this error https://github.com/bvaughn/react-resizable-panels/blob/main/packages/react-resizable-panels/src/Panel.ts#L96
? galleryMinSizePct
: DEFAULT_GALLERY_PCT
}
minSize={galleryMinSizePct}
maxSize={50}
ref={galleryPanelRef}
order={2}
defaultSize={galleryPanelMinSize}
minSize={galleryPanelMinSize}
onCollapse={setIsGalleryPanelCollapsed}
collapsible
>
<ImageGalleryContent />
</Panel>
<FloatingGalleryButton
isGalleryCollapsed={isGalleryPanelCollapsed}
galleryPanelRef={galleryPanelRef}
/>
</>
)}
</PanelGroup>

View File

@ -1,116 +0,0 @@
import { Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import SDXLImageToImageTabParameters from 'features/sdxl/components/SDXLImageToImageTabParameters';
import SDXLTextToImageTabParameters from 'features/sdxl/components/SDXLTextToImageTabParameters';
import InvokeAILogoComponent from 'features/system/components/InvokeAILogoComponent';
import {
activeTabNameSelector,
uiSelector,
} from 'features/ui/store/uiSelectors';
import { setShouldShowParametersPanel } from 'features/ui/store/uiSlice';
import { memo, useMemo } from 'react';
import { PARAMETERS_PANEL_WIDTH } from 'theme/util/constants';
import PinParametersPanelButton from './PinParametersPanelButton';
import ResizableDrawer from './common/ResizableDrawer/ResizableDrawer';
import ImageToImageTabParameters from './tabs/ImageToImage/ImageToImageTabParameters';
import TextToImageTabParameters from './tabs/TextToImage/TextToImageTabParameters';
import UnifiedCanvasParameters from './tabs/UnifiedCanvas/UnifiedCanvasParameters';
const selector = createSelector(
[uiSelector, activeTabNameSelector],
(ui, activeTabName) => {
const { shouldPinParametersPanel, shouldShowParametersPanel } = ui;
return {
activeTabName,
shouldPinParametersPanel,
shouldShowParametersPanel,
};
},
defaultSelectorOptions
);
const ParametersDrawer = () => {
const dispatch = useAppDispatch();
const { shouldPinParametersPanel, shouldShowParametersPanel, activeTabName } =
useAppSelector(selector);
const handleClosePanel = () => {
dispatch(setShouldShowParametersPanel(false));
};
const model = useAppSelector((state: RootState) => state.generation.model);
const drawerContent = useMemo(() => {
if (activeTabName === 'txt2img') {
return model && model.base_model === 'sdxl' ? (
<SDXLTextToImageTabParameters />
) : (
<TextToImageTabParameters />
);
}
if (activeTabName === 'img2img') {
return model && model.base_model === 'sdxl' ? (
<SDXLImageToImageTabParameters />
) : (
<ImageToImageTabParameters />
);
}
if (activeTabName === 'unifiedCanvas') {
return <UnifiedCanvasParameters />;
}
return null;
}, [activeTabName, model]);
if (shouldPinParametersPanel) {
return null;
}
return (
<ResizableDrawer
direction="left"
isResizable={false}
isOpen={shouldShowParametersPanel}
onClose={handleClosePanel}
>
<Flex
sx={{
flexDir: 'column',
h: 'full',
w: PARAMETERS_PANEL_WIDTH,
gap: 2,
position: 'relative',
flexShrink: 0,
overflowY: 'auto',
}}
>
<Flex
paddingBottom={4}
justifyContent="space-between"
alignItems="center"
>
<InvokeAILogoComponent />
<PinParametersPanelButton />
</Flex>
<Flex
sx={{
gap: 2,
flexDirection: 'column',
h: 'full',
w: 'full',
}}
>
{drawerContent}
</Flex>
</Flex>
</ResizableDrawer>
);
};
export default memo(ParametersDrawer);

View File

@ -0,0 +1,130 @@
import { Box, Flex } from '@chakra-ui/react';
import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import ProcessButtons from 'features/parameters/components/ProcessButtons/ProcessButtons';
import SDXLImageToImageTabParameters from 'features/sdxl/components/SDXLImageToImageTabParameters';
import SDXLTextToImageTabParameters from 'features/sdxl/components/SDXLTextToImageTabParameters';
import SDXLUnifiedCanvasTabParameters from 'features/sdxl/components/SDXLUnifiedCanvasTabParameters';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import { PropsWithChildren, memo } from 'react';
import { activeTabNameSelector } from '../store/uiSelectors';
import ImageToImageTabParameters from './tabs/ImageToImage/ImageToImageTabParameters';
import TextToImageTabParameters from './tabs/TextToImage/TextToImageTabParameters';
import UnifiedCanvasParameters from './tabs/UnifiedCanvas/UnifiedCanvasParameters';
const ParametersPanel = () => {
const activeTabName = useAppSelector(activeTabNameSelector);
const model = useAppSelector((state: RootState) => state.generation.model);
if (activeTabName === 'txt2img') {
return (
<ParametersPanelWrapper>
{model && model.base_model === 'sdxl' ? (
<SDXLTextToImageTabParameters />
) : (
<TextToImageTabParameters />
)}
</ParametersPanelWrapper>
);
}
if (activeTabName === 'img2img') {
return (
<ParametersPanelWrapper>
{model && model.base_model === 'sdxl' ? (
<SDXLImageToImageTabParameters />
) : (
<ImageToImageTabParameters />
)}
</ParametersPanelWrapper>
);
}
if (activeTabName === 'unifiedCanvas') {
return (
<ParametersPanelWrapper>
{model && model.base_model === 'sdxl' ? (
<SDXLUnifiedCanvasTabParameters />
) : (
<UnifiedCanvasParameters />
)}
</ParametersPanelWrapper>
);
}
return null;
};
export default memo(ParametersPanel);
const ParametersPanelWrapper = memo((props: PropsWithChildren) => {
return (
<Flex
sx={{
w: 'full',
h: 'full',
flexDir: 'column',
gap: 2,
}}
>
<ProcessButtons />
<Flex
layerStyle="first"
sx={{
w: 'full',
h: 'full',
position: 'relative',
borderRadius: 'base',
p: 2,
}}
>
<Flex
sx={{
w: 'full',
h: 'full',
position: 'relative',
}}
>
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
<OverlayScrollbarsComponent
defer
style={{ height: '100%', width: '100%' }}
options={{
scrollbars: {
visibility: 'auto',
autoHide: 'scroll',
autoHideDelay: 800,
theme: 'os-theme-dark',
},
overflow: {
x: 'hidden',
},
}}
>
<Flex
sx={{
gap: 2,
flexDirection: 'column',
h: 'full',
w: 'full',
}}
>
{props.children}
</Flex>
</OverlayScrollbarsComponent>
</Box>
</Flex>
</Flex>
</Flex>
);
});
ParametersPanelWrapper.displayName = 'ParametersPanelWrapper';

View File

@ -1,57 +0,0 @@
import { Box, Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { PropsWithChildren, memo } from 'react';
import { PARAMETERS_PANEL_WIDTH } from 'theme/util/constants';
import { uiSelector } from '../store/uiSelectors';
import PinParametersPanelButton from './PinParametersPanelButton';
const selector = createSelector(uiSelector, (ui) => {
const { shouldPinParametersPanel, shouldShowParametersPanel } = ui;
return {
shouldPinParametersPanel,
shouldShowParametersPanel,
};
});
type ParametersPinnedWrapperProps = PropsWithChildren;
const ParametersPinnedWrapper = (props: ParametersPinnedWrapperProps) => {
const { shouldPinParametersPanel, shouldShowParametersPanel } =
useAppSelector(selector);
if (!(shouldPinParametersPanel && shouldShowParametersPanel)) {
return null;
}
return (
<Box
sx={{
position: 'relative',
h: 'full',
w: PARAMETERS_PANEL_WIDTH,
flexShrink: 0,
}}
>
<Flex
sx={{
gap: 2,
flexDirection: 'column',
h: 'full',
w: 'full',
position: 'absolute',
overflowY: 'auto',
}}
>
{props.children}
</Flex>
<PinParametersPanelButton
sx={{ position: 'absolute', top: 0, insetInlineEnd: 0 }}
/>
</Box>
);
};
export default memo(ParametersPinnedWrapper);

View File

@ -1,59 +0,0 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton, {
IAIIconButtonProps,
} from 'common/components/IAIIconButton';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { useTranslation } from 'react-i18next';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
import { setShouldPinParametersPanel } from '../store/uiSlice';
import { memo } from 'react';
type PinParametersPanelButtonProps = Omit<IAIIconButtonProps, 'aria-label'>;
const PinParametersPanelButton = (props: PinParametersPanelButtonProps) => {
const { sx } = props;
const dispatch = useAppDispatch();
const shouldPinParametersPanel = useAppSelector(
(state) => state.ui.shouldPinParametersPanel
);
const { t } = useTranslation();
const handleClickPinOptionsPanel = () => {
dispatch(setShouldPinParametersPanel(!shouldPinParametersPanel));
dispatch(requestCanvasRescale());
};
return (
<IAIIconButton
{...props}
tooltip={t('common.pinOptionsPanel')}
aria-label={t('common.pinOptionsPanel')}
onClick={handleClickPinOptionsPanel}
icon={shouldPinParametersPanel ? <BsPinAngleFill /> : <BsPinAngle />}
variant="ghost"
size="sm"
sx={{
color: 'base.500',
_hover: {
color: 'base.600',
},
_active: {
color: 'base.700',
},
_dark: {
color: 'base.500',
_hover: {
color: 'base.400',
},
_active: {
color: 'base.300',
},
},
...sx,
}}
/>
);
};
export default memo(PinParametersPanelButton);

View File

@ -1,196 +0,0 @@
import {
Box,
chakra,
ChakraProps,
Slide,
useOutsideClick,
useTheme,
SlideDirection,
useColorMode,
} from '@chakra-ui/react';
import {
Resizable,
ResizableProps,
ResizeCallback,
ResizeStartCallback,
} from 're-resizable';
import { ReactNode, memo, useEffect, useMemo, useRef, useState } from 'react';
import { LangDirection } from './types';
import {
getHandleEnables,
getMinMaxDimensions,
getSlideDirection,
getStyles,
} from './util';
import { mode } from 'theme/util/mode';
type ResizableDrawerProps = ResizableProps & {
children: ReactNode;
isResizable: boolean;
isOpen: boolean;
onClose: () => void;
direction?: SlideDirection;
initialWidth?: number;
minWidth?: number;
maxWidth?: number;
initialHeight?: number;
minHeight?: number;
maxHeight?: number;
onResizeStart?: ResizeStartCallback;
onResizeStop?: ResizeCallback;
onResize?: ResizeCallback;
handleWidth?: string | number;
handleInteractWidth?: string | number;
sx?: ChakraProps['sx'];
};
const ChakraResizeable = chakra(Resizable, {
shouldForwardProp: (prop) => !['sx'].includes(prop),
});
const ResizableDrawer = ({
direction = 'left',
isResizable,
isOpen,
onClose,
children,
initialWidth,
minWidth,
maxWidth,
initialHeight,
minHeight,
maxHeight,
onResizeStart,
onResizeStop,
onResize,
sx = {},
}: ResizableDrawerProps) => {
const langDirection = useTheme().direction as LangDirection;
const { colorMode } = useColorMode();
const outsideClickRef = useRef<HTMLDivElement>(null);
const defaultWidth = useMemo(
() =>
initialWidth ??
minWidth ??
(['left', 'right'].includes(direction) ? 'auto' : '100%'),
[initialWidth, minWidth, direction]
);
const defaultHeight = useMemo(
() =>
initialHeight ??
minHeight ??
(['top', 'bottom'].includes(direction) ? 'auto' : '100%'),
[initialHeight, minHeight, direction]
);
const [width, setWidth] = useState<number | string>(defaultWidth);
const [height, setHeight] = useState<number | string>(defaultHeight);
useOutsideClick({
ref: outsideClickRef,
handler: () => {
onClose();
},
enabled: isOpen,
});
const handleEnables = useMemo(
() => (isResizable ? getHandleEnables({ direction, langDirection }) : {}),
[isResizable, langDirection, direction]
);
const minMaxDimensions = useMemo(
() =>
getMinMaxDimensions({
direction,
minWidth,
maxWidth,
minHeight,
maxHeight,
}),
[minWidth, maxWidth, minHeight, maxHeight, direction]
);
const { containerStyles, handleStyles } = useMemo(
() =>
getStyles({
isResizable,
direction,
}),
[isResizable, direction]
);
const slideDirection = useMemo(
() => getSlideDirection(direction, langDirection),
[direction, langDirection]
);
useEffect(() => {
if (['left', 'right'].includes(direction)) {
setHeight('100vh');
// setHeight(isPinned ? '100%' : '100vh');
}
if (['top', 'bottom'].includes(direction)) {
setWidth('100vw');
// setWidth(isPinned ? '100%' : '100vw');
}
}, [direction]);
return (
<Slide
direction={slideDirection}
in={isOpen}
motionProps={{ initial: false }}
style={{ width: 'full' }}
>
<Box
ref={outsideClickRef}
sx={{
width: 'full',
height: 'full',
}}
>
<ChakraResizeable
size={{
width: isResizable ? width : defaultWidth,
height: isResizable ? height : defaultHeight,
}}
enable={handleEnables}
handleStyles={handleStyles}
{...minMaxDimensions}
sx={{
borderColor: mode('base.200', 'base.800')(colorMode),
p: 4,
bg: mode('base.50', 'base.900')(colorMode),
height: 'full',
shadow: isOpen ? 'dark-lg' : undefined,
...containerStyles,
...sx,
}}
onResizeStart={(event, direction, elementRef) => {
onResizeStart && onResizeStart(event, direction, elementRef);
}}
onResize={(event, direction, elementRef, delta) => {
onResize && onResize(event, direction, elementRef, delta);
}}
onResizeStop={(event, direction, elementRef, delta) => {
if (['left', 'right'].includes(direction)) {
setWidth(Number(width) + delta.width);
}
if (['top', 'bottom'].includes(direction)) {
setHeight(Number(height) + delta.height);
}
onResizeStop && onResizeStop(event, direction, elementRef, delta);
}}
>
{children}
</ChakraResizeable>
</Box>
</Slide>
);
};
export default memo(ResizableDrawer);

View File

@ -1,2 +0,0 @@
export type Placement = 'top' | 'right' | 'bottom' | 'left';
export type LangDirection = 'ltr' | 'rtl' | undefined;

View File

@ -1,283 +0,0 @@
import { SlideDirection } from '@chakra-ui/react';
import { AnimationProps } from 'framer-motion';
import { HandleStyles } from 're-resizable';
import { CSSProperties } from 'react';
import { LangDirection } from './types';
export type GetHandleEnablesOptions = {
direction: SlideDirection;
langDirection: LangDirection;
};
/**
* Determine handles to enable. `re-resizable` doesn't handle RTL, so we have to do that here.
*/
export const getHandleEnables = ({
direction,
langDirection,
}: GetHandleEnablesOptions) => {
const top = direction === 'bottom';
const right =
(langDirection !== 'rtl' && direction === 'left') ||
(langDirection === 'rtl' && direction === 'right');
const bottom = direction === 'top';
const left =
(langDirection !== 'rtl' && direction === 'right') ||
(langDirection === 'rtl' && direction === 'left');
return {
top,
right,
bottom,
left,
};
};
export type GetDefaultSizeOptions = {
initialWidth?: string | number;
initialHeight?: string | number;
direction: SlideDirection;
};
// Get default sizes based on direction and initial values
export const getDefaultSize = ({
initialWidth,
initialHeight,
direction,
}: GetDefaultSizeOptions) => {
const width =
initialWidth ?? (['left', 'right'].includes(direction) ? 500 : '100vw');
const height =
initialHeight ?? (['top', 'bottom'].includes(direction) ? 500 : '100vh');
return { width, height };
};
export type GetMinMaxDimensionsOptions = {
direction: SlideDirection;
minWidth?: number;
maxWidth?: number;
minHeight?: number;
maxHeight?: number;
};
// Get the min/max width/height based on direction and provided values
export const getMinMaxDimensions = ({
direction,
minWidth,
maxWidth,
minHeight,
maxHeight,
}: GetMinMaxDimensionsOptions) => {
const minW =
minWidth ?? (['left', 'right'].includes(direction) ? 10 : undefined);
const maxW =
maxWidth ?? (['left', 'right'].includes(direction) ? '95vw' : undefined);
const minH =
minHeight ?? (['top', 'bottom'].includes(direction) ? 10 : undefined);
const maxH =
maxHeight ?? (['top', 'bottom'].includes(direction) ? '95vh' : undefined);
return {
...(minW ? { minWidth: minW } : {}),
...(maxW ? { maxWidth: maxW } : {}),
...(minH ? { minHeight: minH } : {}),
...(maxH ? { maxHeight: maxH } : {}),
};
};
export type GetAnimationsOptions = {
direction: SlideDirection;
langDirection: LangDirection;
};
// Get the framer-motion animation props, taking into account language direction
export const getAnimations = ({
direction,
langDirection,
}: GetAnimationsOptions): AnimationProps => {
const baseAnimation = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
// chakra consumes the transition prop, which, for it, is a string.
// however we know the transition prop will make it to framer motion,
// which wants it as an object. cast as string to satisfy TS.
transition: { duration: 0.2, ease: 'easeInOut' },
};
const langDirectionFactor = langDirection === 'rtl' ? -1 : 1;
if (direction === 'top') {
return {
...baseAnimation,
initial: { y: -999 },
animate: { y: 0 },
exit: { y: -999 },
};
}
if (direction === 'right') {
return {
...baseAnimation,
initial: { x: 999 * langDirectionFactor },
animate: { x: 0 },
exit: { x: 999 * langDirectionFactor },
};
}
if (direction === 'bottom') {
return {
...baseAnimation,
initial: { y: 999 },
animate: { y: 0 },
exit: { y: 999 },
};
}
if (direction === 'left') {
return {
...baseAnimation,
initial: { x: -999 * langDirectionFactor },
animate: { x: 0 },
exit: { x: -999 * langDirectionFactor },
};
}
return {};
};
export type GetResizableStylesProps = {
isResizable: boolean;
direction: SlideDirection;
};
// Expand the handle hitbox
const HANDLE_INTERACT_PADDING = '0.75rem';
// Visible padding around handle
const HANDLE_PADDING = '1rem';
const HANDLE_WIDTH = '5px';
// Get the styles for the container and handle. Do not need to handle langDirection here bc we use direction-agnostic CSS
export const getStyles = ({
isResizable,
direction,
}: GetResizableStylesProps): {
containerStyles: CSSProperties; // technically this could be ChakraProps['sx'], but we cannot use this for HandleStyles so leave it as CSSProperties to be consistent
handleStyles: HandleStyles;
} => {
// if (!isResizable) {
// return { containerStyles: {}, handleStyles: {} };
// }
// Calculate the positioning offset of the handle hitbox so it is centered over the handle
const handleOffset = `calc((2 * ${HANDLE_INTERACT_PADDING} + ${HANDLE_WIDTH}) / -2)`;
if (direction === 'top') {
return {
containerStyles: {
borderBottomWidth: HANDLE_WIDTH,
paddingBottom: HANDLE_PADDING,
},
handleStyles: isResizable
? {
top: {
paddingTop: HANDLE_INTERACT_PADDING,
paddingBottom: HANDLE_INTERACT_PADDING,
bottom: handleOffset,
},
}
: {},
};
}
if (direction === 'left') {
return {
containerStyles: {
borderInlineEndWidth: HANDLE_WIDTH,
paddingInlineEnd: HANDLE_PADDING,
},
handleStyles: isResizable
? {
right: {
paddingInlineStart: HANDLE_INTERACT_PADDING,
paddingInlineEnd: HANDLE_INTERACT_PADDING,
insetInlineEnd: handleOffset,
},
}
: {},
};
}
if (direction === 'bottom') {
return {
containerStyles: {
borderTopWidth: HANDLE_WIDTH,
paddingTop: HANDLE_PADDING,
},
handleStyles: isResizable
? {
bottom: {
paddingTop: HANDLE_INTERACT_PADDING,
paddingBottom: HANDLE_INTERACT_PADDING,
top: handleOffset,
},
}
: {},
};
}
if (direction === 'right') {
return {
containerStyles: {
borderInlineStartWidth: HANDLE_WIDTH,
paddingInlineStart: HANDLE_PADDING,
},
handleStyles: isResizable
? {
left: {
paddingInlineStart: HANDLE_INTERACT_PADDING,
paddingInlineEnd: HANDLE_INTERACT_PADDING,
insetInlineStart: handleOffset,
},
}
: {},
};
}
return { containerStyles: {}, handleStyles: {} };
};
// Chakra's Slide does not handle langDirection, so we need to do it here
export const getSlideDirection = (
direction: SlideDirection,
langDirection: LangDirection
) => {
if (['top', 'bottom'].includes(direction)) {
return direction;
}
if (direction === 'left') {
if (langDirection === 'rtl') {
return 'right';
}
return 'left';
}
if (direction === 'right') {
if (langDirection === 'rtl') {
return 'left';
}
return 'right';
}
return 'left';
};

View File

@ -1,48 +1,36 @@
import { Box, Flex } from '@chakra-ui/react';
import { RootState } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { Box } from '@chakra-ui/react';
import InitialImageDisplay from 'features/parameters/components/Parameters/ImageToImage/InitialImageDisplay';
import SDXLImageToImageTabParameters from 'features/sdxl/components/SDXLImageToImageTabParameters';
import { usePanelStorage } from 'features/ui/hooks/usePanelStorage';
import { memo, useCallback, useRef } from 'react';
import {
ImperativePanelGroupHandle,
Panel,
PanelGroup,
} from 'react-resizable-panels';
import ParametersPinnedWrapper from '../../ParametersPinnedWrapper';
import ResizeHandle from '../ResizeHandle';
import TextToImageTabMain from '../TextToImage/TextToImageTabMain';
import ImageToImageTabParameters from './ImageToImageTabParameters';
const ImageToImageTab = () => {
const dispatch = useAppDispatch();
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
const model = useAppSelector((state: RootState) => state.generation.model);
const handleDoubleClickHandle = useCallback(() => {
if (!panelGroupRef.current) {
return;
}
panelGroupRef.current.setLayout([50, 50]);
}, []);
const panelStorage = usePanelStorage();
return (
<Flex sx={{ gap: 4, w: 'full', h: 'full' }}>
<ParametersPinnedWrapper>
{model && model.base_model === 'sdxl' ? (
<SDXLImageToImageTabParameters />
) : (
<ImageToImageTabParameters />
)}
</ParametersPinnedWrapper>
<Box sx={{ w: 'full', h: 'full' }}>
<PanelGroup
ref={panelGroupRef}
autoSaveId="imageTab.content"
direction="horizontal"
style={{ height: '100%', width: '100%' }}
storage={panelStorage}
units="percentages"
>
<Panel
id="imageTab.content.initImage"
@ -59,15 +47,11 @@ const ImageToImageTab = () => {
order={1}
defaultSize={50}
minSize={25}
onResize={() => {
dispatch(requestCanvasRescale());
}}
>
<TextToImageTabMain />
</Panel>
</PanelGroup>
</Box>
</Flex>
);
};

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