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_hidden: bool
ui_type: Optional[UIType] ui_type: Optional[UIType]
ui_component: Optional[UIComponent] ui_component: Optional[UIComponent]
ui_order: Optional[int]
class _OutputField(BaseModel): class _OutputField(BaseModel):
@ -182,6 +183,7 @@ class _OutputField(BaseModel):
ui_hidden: bool ui_hidden: bool
ui_type: Optional[UIType] ui_type: Optional[UIType]
ui_order: Optional[int]
def InputField( def InputField(
@ -215,6 +217,7 @@ def InputField(
ui_type: Optional[UIType] = None, ui_type: Optional[UIType] = None,
ui_component: Optional[UIComponent] = None, ui_component: Optional[UIComponent] = None,
ui_hidden: bool = False, ui_hidden: bool = False,
ui_order: Optional[int] = None,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
""" """
@ -273,6 +276,7 @@ def InputField(
ui_type=ui_type, ui_type=ui_type,
ui_component=ui_component, ui_component=ui_component,
ui_hidden=ui_hidden, ui_hidden=ui_hidden,
ui_order=ui_order,
**kwargs, **kwargs,
) )
@ -306,6 +310,7 @@ def OutputField(
repr: bool = True, repr: bool = True,
ui_type: Optional[UIType] = None, ui_type: Optional[UIType] = None,
ui_hidden: bool = False, ui_hidden: bool = False,
ui_order: Optional[int] = None,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
""" """
@ -352,6 +357,7 @@ def OutputField(
repr=repr, repr=repr,
ui_type=ui_type, ui_type=ui_type,
ui_hidden=ui_hidden, ui_hidden=ui_hidden,
ui_order=ui_order,
**kwargs, **kwargs,
) )
@ -380,7 +386,7 @@ class BaseInvocationOutput(BaseModel):
"""Base class for all invocation outputs""" """Base class for all invocation outputs"""
# All outputs must include a type name like this: # All outputs must include a type name like this:
# type: Literal['your_output_name'] # type: Literal['your_output_name'] # noqa f821
@classmethod @classmethod
def get_all_subclasses_tuple(cls): def get_all_subclasses_tuple(cls):
@ -421,7 +427,7 @@ class BaseInvocation(ABC, BaseModel):
""" """
# All invocations must include a type name like this: # All invocations must include a type name like this:
# type: Literal['your_output_name'] # type: Literal['your_output_name'] # noqa f821
@classmethod @classmethod
def get_all_subclasses(cls): def get_all_subclasses(cls):
@ -499,7 +505,7 @@ class BaseInvocation(ABC, BaseModel):
raise MissingInputException(self.__fields__["type"].default, field_name) raise MissingInputException(self.__fields__["type"].default, field_name)
return self.invoke(context) 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( is_intermediate: bool = InputField(
default=False, description="Whether or not this node is an intermediate node.", input=Input.Direct 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 PIL import Image, ImageChops, ImageFilter, ImageOps
from invokeai.app.invocations.metadata import CoreMetadata 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.invisible_watermark import InvisibleWatermark
from invokeai.backend.image_util.safety_checker import SafetyChecker 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") @title("Crop Image")
@tags("image", "crop") @tags("image", "crop")
class ImageCropInvocation(BaseInvocation): class ImageCropInvocation(BaseInvocation):

View File

@ -107,12 +107,12 @@ class DenoiseLatentsInvocation(BaseInvocation):
# Inputs # Inputs
positive_conditioning: ConditioningField = InputField( 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( 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) steps: int = InputField(default=10, gt=0, description=FieldDescriptions.steps)
cfg_scale: Union[float, List[float]] = InputField( cfg_scale: Union[float, List[float]] = InputField(
default=7.5, ge=1, description=FieldDescriptions.cfg_scale, ui_type=UIType.Float, title="CFG Scale" 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( scheduler: SAMPLER_NAME_VALUES = InputField(
default="euler", description=FieldDescriptions.scheduler, ui_type=UIType.Scheduler 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( 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( mask: Optional[ImageField] = InputField(
default=None, default=None,
description=FieldDescriptions.mask, description=FieldDescriptions.mask,

View File

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

View File

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

View File

@ -19,7 +19,7 @@
"toggleAutoscroll": "Toggle autoscroll", "toggleAutoscroll": "Toggle autoscroll",
"toggleLogViewer": "Toggle Log Viewer", "toggleLogViewer": "Toggle Log Viewer",
"showGallery": "Show Gallery", "showGallery": "Show Gallery",
"showOptionsPanel": "Show Options Panel", "showOptionsPanel": "Show Side Panel",
"menu": "Menu" "menu": "Menu"
}, },
"common": { "common": {
@ -95,7 +95,6 @@
"statusModelConverted": "Model Converted", "statusModelConverted": "Model Converted",
"statusMergingModels": "Merging Models", "statusMergingModels": "Merging Models",
"statusMergedModels": "Models Merged", "statusMergedModels": "Models Merged",
"pinOptionsPanel": "Pin Options Panel",
"loading": "Loading", "loading": "Loading",
"loadingInvokeAI": "Loading Invoke AI", "loadingInvokeAI": "Loading Invoke AI",
"random": "Random", "random": "Random",
@ -116,7 +115,6 @@
"maintainAspectRatio": "Maintain Aspect Ratio", "maintainAspectRatio": "Maintain Aspect Ratio",
"autoSwitchNewImages": "Auto-Switch to New Images", "autoSwitchNewImages": "Auto-Switch to New Images",
"singleColumnLayout": "Single Column Layout", "singleColumnLayout": "Single Column Layout",
"pinGallery": "Pin Gallery",
"allImagesLoaded": "All Images Loaded", "allImagesLoaded": "All Images Loaded",
"loadMore": "Load More", "loadMore": "Load More",
"noImagesInGallery": "No Images to Display", "noImagesInGallery": "No Images to Display",
@ -577,7 +575,7 @@
"resetWebUI": "Reset Web UI", "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.", "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.", "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", "consoleLogLevel": "Log Level",
"shouldLogToConsole": "Console Logging", "shouldLogToConsole": "Console Logging",
"developer": "Developer", "developer": "Developer",
@ -720,11 +718,12 @@
"swapSizes": "Swap Sizes" "swapSizes": "Swap Sizes"
}, },
"nodes": { "nodes": {
"reloadSchema": "Reload Schema", "reloadNodeTemplates": "Reload Node Templates",
"saveGraph": "Save Graph", "saveWorkflow": "Save Workflow",
"loadGraph": "Load Graph (saved from Node Editor) (Do not copy-paste metadata)", "loadWorkflow": "Load Workflow",
"clearGraph": "Clear Graph", "resetWorkflow": "Reset Workflow",
"clearGraphDesc": "Are you sure you want to clear all nodes?", "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", "zoomInNodes": "Zoom In",
"zoomOutNodes": "Zoom Out", "zoomOutNodes": "Zoom Out",
"fitViewportNodes": "Fit View", "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. * field accepts connection input. If it does, we can make the field optional.
*/ */
// Check if we are generating types for an invocation if ('class' in schemaObject && schemaObject.class === '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) {
// We only want to make fields optional if they are required // We only want to make fields optional if they are required
if (!Array.isArray(schemaObject?.required)) { if (!Array.isArray(schemaObject?.required)) {
schemaObject.required = ['id', 'type']; schemaObject.required = [];
} }
schemaObject.required.forEach((prop) => { schemaObject.required.forEach((prop) => {
@ -60,32 +49,12 @@ async function main() {
); );
} }
}); });
schemaObject.required = [
...new Set(schemaObject.required.concat(['id', 'type'])),
];
return; return;
} }
// Check if we are generating types for an invocation output // Check if we are generating types for an invocation output
const isInvocationOutputPath = metadata.path.match( if ('class' in schemaObject && schemaObject.class === 'output') {
/^#\/components\/schemas\/\w*Output$/ // modify output types
);
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}`
);
} }
}, },
}); });

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

View File

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

View File

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

View File

@ -1,12 +1,17 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { createLogWriter } from '@roarr/browser-log-writer'; import { createLogWriter } from '@roarr/browser-log-writer';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { systemSelector } from 'features/system/store/systemSelectors'; import { systemSelector } from 'features/system/store/systemSelectors';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { useEffect } from 'react'; import { useEffect, useMemo } from 'react';
import { ROARR, Roarr } from 'roarr'; 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( const selector = createSelector(
systemSelector, systemSelector,
@ -25,7 +30,7 @@ const selector = createSelector(
} }
); );
export const useLogger = () => { export const useLogger = (namespace: LoggerNamespace) => {
const { consoleLogLevel, shouldLogToConsole } = useAppSelector(selector); const { consoleLogLevel, shouldLogToConsole } = useAppSelector(selector);
// The provided Roarr browser log writer uses localStorage to config logging to console // 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)); $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 { receivedOpenAPISchema } from 'services/api/thunks/schema';
import { appSocketConnected, socketConnected } from 'services/events/actions'; import { appSocketConnected, socketConnected } from 'services/events/actions';
import { startAppListening } from '../..'; import { startAppListening } from '../..';
import { size } from 'lodash-es';
export const addSocketConnectedEventListener = () => { export const addSocketConnectedEventListener = () => {
startAppListening({ startAppListening({
@ -18,7 +19,7 @@ export const addSocketConnectedEventListener = () => {
const { disabledTabs } = config; const { disabledTabs } = config;
if (!nodes.schema && !disabledTabs.includes('nodes')) { if (!size(nodes.nodeTemplates) && !disabledTabs.includes('nodes')) {
dispatch(receivedOpenAPISchema()); dispatch(receivedOpenAPISchema());
} }

View File

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

View File

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

View File

@ -32,6 +32,10 @@ const selector = createSelector(
} }
if (activeTabName === 'nodes' && nodes.shouldValidateGraph) { if (activeTabName === 'nodes' && nodes.shouldValidateGraph) {
if (!nodes.nodes.length) {
reasons.push('No nodes in graph');
}
nodes.nodes.forEach((node) => { nodes.nodes.forEach((node) => {
if (!isInvocationNode(node)) { if (!isInvocationNode(node)) {
return; 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 { Box, chakra, Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; 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 { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { import {
canvasSelector, canvasSelector,
@ -9,7 +9,7 @@ import {
import Konva from 'konva'; import Konva from 'konva';
import { KonvaEventObject } from 'konva/lib/Node'; import { KonvaEventObject } from 'konva/lib/Node';
import { Vector2d } from 'konva/lib/types'; 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 { Layer, Stage } from 'react-konva';
import useCanvasDragMove from '../hooks/useCanvasDragMove'; import useCanvasDragMove from '../hooks/useCanvasDragMove';
import useCanvasHotkeys from '../hooks/useCanvasHotkeys'; import useCanvasHotkeys from '../hooks/useCanvasHotkeys';
@ -18,6 +18,7 @@ import useCanvasMouseMove from '../hooks/useCanvasMouseMove';
import useCanvasMouseOut from '../hooks/useCanvasMouseOut'; import useCanvasMouseOut from '../hooks/useCanvasMouseOut';
import useCanvasMouseUp from '../hooks/useCanvasMouseUp'; import useCanvasMouseUp from '../hooks/useCanvasMouseUp';
import useCanvasWheel from '../hooks/useCanvasZoom'; import useCanvasWheel from '../hooks/useCanvasZoom';
import { canvasResized } from '../store/canvasSlice';
import { import {
setCanvasBaseLayer, setCanvasBaseLayer,
setCanvasStage, setCanvasStage,
@ -106,7 +107,8 @@ const IAICanvas = () => {
shouldAntialias, shouldAntialias,
} = useAppSelector(selector); } = useAppSelector(selector);
useCanvasHotkeys(); useCanvasHotkeys();
const dispatch = useAppDispatch();
const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage | null>(null); const stageRef = useRef<Konva.Stage | null>(null);
const canvasBaseLayerRef = useRef<Konva.Layer | null>(null); const canvasBaseLayerRef = useRef<Konva.Layer | null>(null);
@ -137,8 +139,30 @@ const IAICanvas = () => {
const { handleDragStart, handleDragMove, handleDragEnd } = const { handleDragStart, handleDragMove, handleDragEnd } =
useCanvasDragMove(); 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 ( return (
<Flex <Flex
id="canvas-container"
ref={containerRef}
sx={{ sx={{
position: 'relative', position: 'relative',
height: '100%', height: '100%',
@ -146,13 +170,18 @@ const IAICanvas = () => {
borderRadius: 'base', borderRadius: 'base',
}} }}
> >
<Box sx={{ position: 'relative' }}> <Box
sx={{
position: 'absolute',
// top: 0,
// insetInlineStart: 0,
}}
>
<ChakraStage <ChakraStage
tabIndex={-1} tabIndex={-1}
ref={canvasStageRefCallback} ref={canvasStageRefCallback}
sx={{ sx={{
outline: 'none', outline: 'none',
// boxShadow: '0px 0px 0px 1px var(--border-color-light)',
overflow: 'hidden', overflow: 'hidden',
cursor: stageCursor ? stageCursor : undefined, cursor: stageCursor ? stageCursor : undefined,
canvas: { canvas: {
@ -213,9 +242,9 @@ const IAICanvas = () => {
/> />
</Layer> </Layer>
</ChakraStage> </ChakraStage>
</Box>
<IAICanvasStatusText /> <IAICanvasStatusText />
<IAICanvasStagingAreaToolbar /> <IAICanvasStagingAreaToolbar />
</Box>
</Flex> </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%" w="100%"
align="center" align="center"
justify="center" justify="center"
filter="drop-shadow(0 0.5rem 1rem rgba(0,0,0))"
onMouseOver={handleMouseOver} onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut} onMouseOut={handleMouseOut}
> >
<ButtonGroup isAttached> <ButtonGroup isAttached borderRadius="base" shadow="dark-lg">
<IAIIconButton <IAIIconButton
tooltip={`${t('unifiedCanvas.previous')} (Left)`} tooltip={`${t('unifiedCanvas.previous')} (Left)`}
aria-label={`${t('unifiedCanvas.previous')} (Left)`} aria-label={`${t('unifiedCanvas.previous')} (Left)`}

View File

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

View File

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

View File

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

View File

@ -126,12 +126,9 @@ export interface CanvasState {
boundingBoxScaleMethod: BoundingBoxScale; boundingBoxScaleMethod: BoundingBoxScale;
brushColor: RgbaColor; brushColor: RgbaColor;
brushSize: number; brushSize: number;
canvasContainerDimensions: Dimensions;
colorPickerColor: RgbaColor; colorPickerColor: RgbaColor;
cursorPosition: Vector2d | null; cursorPosition: Vector2d | null;
doesCanvasNeedScaling: boolean;
futureLayerStates: CanvasLayerState[]; futureLayerStates: CanvasLayerState[];
isCanvasInitialized: boolean;
isDrawing: boolean; isDrawing: boolean;
isMaskEnabled: boolean; isMaskEnabled: boolean;
isMouseOverBoundingBox: 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={{ sx={{
flexDir: 'column', flexDir: 'column',
gap: 3, gap: 3,
p: 3, p: 2,
borderRadius: 'base', borderRadius: 'base',
position: 'relative', position: 'relative',
bg: 'base.200', bg: 'base.250',
_dark: { _dark: {
bg: 'base.850', bg: 'base.750',
}, },
}} }}
> >
@ -194,7 +194,7 @@ const ControlNet = (props: ControlNetProps) => {
aspectRatio: '1/1', aspectRatio: '1/1',
}} }}
> >
<ControlNetImagePreview controlNet={controlNet} height={28} /> <ControlNetImagePreview controlNet={controlNet} isSmall />
</Flex> </Flex>
)} )}
</Flex> </Flex>
@ -207,7 +207,7 @@ const ControlNet = (props: ControlNetProps) => {
{isExpanded && ( {isExpanded && (
<> <>
<ControlNetImagePreview controlNet={controlNet} height="392px" /> <ControlNetImagePreview controlNet={controlNet} />
<ParamControlNetShouldAutoConfig controlNet={controlNet} /> <ParamControlNetShouldAutoConfig controlNet={controlNet} />
<ControlNetProcessorComponent 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 { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/dist/query'; import { skipToken } from '@reduxjs/toolkit/dist/query';
import {
TypesafeDraggableData,
TypesafeDroppableData,
} from 'features/dnd/types';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImage from 'common/components/IAIDndImage';
import {
TypesafeDraggableData,
TypesafeDroppableData,
} from 'features/dnd/types';
import { memo, useCallback, useMemo, useState } from 'react'; import { memo, useCallback, useMemo, useState } from 'react';
import { FaUndo } from 'react-icons/fa'; import { FaUndo } from 'react-icons/fa';
import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useGetImageDTOQuery } from 'services/api/endpoints/images';
@ -21,7 +21,7 @@ import {
type Props = { type Props = {
controlNet: ControlNetConfig; controlNet: ControlNetConfig;
height: SystemStyleObject['h']; isSmall?: boolean;
}; };
const selector = createSelector( const selector = createSelector(
@ -36,15 +36,14 @@ const selector = createSelector(
defaultSelectorOptions defaultSelectorOptions
); );
const ControlNetImagePreview = (props: Props) => { const ControlNetImagePreview = ({ isSmall, controlNet }: Props) => {
const { height } = props;
const { const {
controlImage: controlImageName, controlImage: controlImageName,
processedControlImage: processedControlImageName, processedControlImage: processedControlImageName,
processorType, processorType,
isEnabled, isEnabled,
controlNetId, controlNetId,
} = props.controlNet; } = controlNet;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -109,7 +108,7 @@ const ControlNetImagePreview = (props: Props) => {
sx={{ sx={{
position: 'relative', position: 'relative',
w: 'full', w: 'full',
h: height, h: isSmall ? 28 : 366, // magic no touch
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
pointerEvents: isEnabled ? 'auto' : 'none', 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 { skipToken } from '@reduxjs/toolkit/dist/query';
import { useAppToaster } from 'app/components/Toaster'; import { useAppToaster } from 'app/components/Toaster';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import { import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
resizeAndScaleCanvas,
setInitialCanvasImage,
} from 'features/canvas/store/canvasSlice';
import { import {
imagesToChangeSelected, imagesToChangeSelected,
isModalOpenChanged, isModalOpenChanged,
@ -29,6 +26,7 @@ import {
FaShare, FaShare,
FaTrash, FaTrash,
} from 'react-icons/fa'; } from 'react-icons/fa';
import { MdStar, MdStarBorder } from 'react-icons/md';
import { import {
useGetImageMetadataQuery, useGetImageMetadataQuery,
useStarImagesMutation, useStarImagesMutation,
@ -37,7 +35,6 @@ import {
import { ImageDTO } from 'services/api/types'; import { ImageDTO } from 'services/api/types';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions'; import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions';
import { MdStar, MdStarBorder } from 'react-icons/md';
type SingleSelectionMenuItemsProps = { type SingleSelectionMenuItemsProps = {
imageDTO: ImageDTO; imageDTO: ImageDTO;
@ -110,7 +107,6 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
const handleSendToCanvas = useCallback(() => { const handleSendToCanvas = useCallback(() => {
dispatch(sentImageToCanvas()); dispatch(sentImageToCanvas());
dispatch(setInitialCanvasImage(imageDTO)); dispatch(setInitialCanvasImage(imageDTO));
dispatch(resizeAndScaleCanvas());
dispatch(setActiveTab('unifiedCanvas')); dispatch(setActiveTab('unifiedCanvas'));
toaster({ toaster({

View File

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

View File

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

View File

@ -1,38 +1,21 @@
import { Flex } from '@chakra-ui/react'; import { Flex } from '@chakra-ui/react';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; 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 { 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 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 NodeEditor = () => {
const [isPanelCollapsed, setIsPanelCollapsed] = useState(false);
const isReady = useAppSelector((state) => state.nodes.isReady); const isReady = useAppSelector((state) => state.nodes.isReady);
return ( 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 <Flex
layerStyle="first" layerStyle="first"
sx={{ sx={{
@ -62,6 +45,11 @@ const NodeEditor = () => {
> >
<Flow /> <Flow />
<AddNodePopover /> <AddNodePopover />
<TopLeftPanel />
<TopCenterPanel />
<TopRightPanel />
<BottomLeftPanel />
<MinimapPanel />
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@ -102,8 +90,6 @@ const NodeEditor = () => {
)} )}
</AnimatePresence> </AnimatePresence>
</Flex> </Flex>
</Panel>
</PanelGroup>
); );
}; };

View File

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

View File

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

View File

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

View File

@ -1,7 +1,8 @@
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; 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'; import { isInvocationNode } from 'features/nodes/types/types';
export const makeEdgeSelector = ( 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 { memo } from 'react';
import InvocationNodeFooter from './InvocationNodeFooter'; import InvocationNodeFooter from './InvocationNodeFooter';
import InvocationNodeHeader from './InvocationNodeHeader'; import InvocationNodeHeader from './InvocationNodeHeader';
import NodeWrapper from '../common/NodeWrapper'; import NodeWrapper from '../common/NodeWrapper';
import OutputField from './fields/OutputField'; import OutputField from './fields/OutputField';
import InputField from './fields/InputField'; 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 { useWithFooter } from 'features/nodes/hooks/useWithFooter';
import { useConnectionInputFieldNames } from 'features/nodes/hooks/useConnectionInputFieldNames';
import { useAnyOrDirectInputFieldNames } from 'features/nodes/hooks/useAnyOrDirectInputFieldNames';
type Props = { type Props = {
nodeId: string; nodeId: string;
@ -17,8 +19,9 @@ type Props = {
}; };
const InvocationNode = ({ nodeId, isOpen, label, type, selected }: Props) => { const InvocationNode = ({ nodeId, isOpen, label, type, selected }: Props) => {
const inputFieldNames = useFieldNames(nodeId, 'input'); const inputConnectionFieldNames = useConnectionInputFieldNames(nodeId);
const outputFieldNames = useFieldNames(nodeId, 'output'); const inputAnyOrDirectFieldNames = useAnyOrDirectInputFieldNames(nodeId);
const outputFieldNames = useOutputFieldNames(nodeId);
const withFooter = useWithFooter(nodeId); const withFooter = useWithFooter(nodeId);
return ( return (
@ -44,14 +47,27 @@ const InvocationNode = ({ nodeId, isOpen, label, type, selected }: Props) => {
}} }}
> >
<Flex sx={{ flexDir: 'column', px: 2, w: 'full', h: 'full' }}> <Flex sx={{ flexDir: 'column', px: 2, w: 'full', h: 'full' }}>
{outputFieldNames.map((fieldName) => ( <Grid gridTemplateColumns="1fr auto" gridAutoRows="1fr">
<OutputField {inputConnectionFieldNames.map((fieldName, i) => (
key={`${nodeId}.${fieldName}.output-field`} <GridItem
nodeId={nodeId} gridColumnStart={1}
fieldName={fieldName} 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 <InputField
key={`${nodeId}.${fieldName}.input-field`} key={`${nodeId}.${fieldName}.input-field`}
nodeId={nodeId} nodeId={nodeId}

View File

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

View File

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

View File

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

View File

@ -4,13 +4,16 @@ import {
useColorModeValue, useColorModeValue,
useToken, useToken,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { import {
DRAG_HANDLE_CLASSNAME, DRAG_HANDLE_CLASSNAME,
NODE_WIDTH, NODE_WIDTH,
} from 'features/nodes/types/constants'; } from 'features/nodes/types/constants';
import { NodeStatus } from 'features/nodes/types/types';
import { contextMenusClosed } from 'features/ui/store/uiSlice'; import { contextMenusClosed } from 'features/ui/store/uiSlice';
import { PropsWithChildren, memo, useCallback } from 'react'; import { PropsWithChildren, memo, useCallback, useMemo } from 'react';
type NodeWrapperProps = PropsWithChildren & { type NodeWrapperProps = PropsWithChildren & {
nodeId: string; nodeId: string;
@ -19,25 +22,42 @@ type NodeWrapperProps = PropsWithChildren & {
}; };
const NodeWrapper = (props: NodeWrapperProps) => { 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 [ const [
nodeSelectedOutlineLight, nodeSelectedLight,
nodeSelectedOutlineDark, nodeSelectedDark,
nodeInProgressLight,
nodeInProgressDark,
shadowsXl, shadowsXl,
shadowsBase, shadowsBase,
] = useToken('shadows', [ ] = useToken('shadows', [
'nodeSelectedOutline.light', 'nodeSelected.light',
'nodeSelectedOutline.dark', 'nodeSelected.dark',
'nodeInProgress.light',
'nodeInProgress.dark',
'shadows.xl', 'shadows.xl',
'shadows.base', 'shadows.base',
]); ]);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const shadow = useColorModeValue( const selectedShadow = useColorModeValue(nodeSelectedLight, nodeSelectedDark);
nodeSelectedOutlineLight, const inProgressShadow = useColorModeValue(
nodeSelectedOutlineDark nodeInProgressLight,
nodeInProgressDark
); );
const opacity = useAppSelector((state) => state.nodes.nodeOpacity); const opacity = useAppSelector((state) => state.nodes.nodeOpacity);
@ -57,7 +77,11 @@ const NodeWrapper = (props: NodeWrapperProps) => {
w: width ?? NODE_WIDTH, w: width ?? NODE_WIDTH,
transitionProperty: 'common', transitionProperty: 'common',
transitionDuration: '0.1s', transitionDuration: '0.1s',
shadow: selected ? shadow : undefined, shadow: selected
? isInProgress
? undefined
: selectedShadow
: undefined,
cursor: 'grab', cursor: 'grab',
opacity, opacity,
}} }}
@ -75,6 +99,22 @@ const NodeWrapper = (props: NodeWrapperProps) => {
zIndex: -1, 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} {children}
</Box> </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 { Flex } from '@chakra-ui/react';
import { memo } from 'react';
import NodeOpacitySlider from './NodeOpacitySlider';
import ViewportControls from './ViewportControls';
const BottomLeftPanel = () => ( const BottomLeftPanel = () => (
<Panel position="bottom-left"> <Flex sx={{ gap: 2, position: 'absolute', bottom: 2, insetInlineStart: 2 }}>
<Flex sx={{ gap: 2 }}>
<ViewportControls /> <ViewportControls />
<NodeOpacitySlider /> <NodeOpacitySlider />
</Flex> </Flex>
</Panel>
); );
export default memo(BottomLeftPanel); 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 { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIIconButton from 'common/components/IAIIconButton';
import {
// shouldShowFieldTypeLegendChanged,
shouldShowMinimapPanelChanged,
} from 'features/nodes/store/nodesSlice';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
FaExpand, FaExpand,
// FaInfo, // FaInfo,
FaMapMarkerAlt, FaMapMarkerAlt,
FaMinus,
FaPlus,
} from 'react-icons/fa'; } from 'react-icons/fa';
import { FaMagnifyingGlassMinus, FaMagnifyingGlassPlus } from 'react-icons/fa6';
import { useReactFlow } from 'reactflow'; import { useReactFlow } from 'reactflow';
import {
// shouldShowFieldTypeLegendChanged,
shouldShowMinimapPanelChanged,
} from 'features/nodes/store/nodesSlice';
const ViewportControls = () => { const ViewportControls = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -49,27 +48,24 @@ const ViewportControls = () => {
return ( return (
<ButtonGroup isAttached orientation="vertical"> <ButtonGroup isAttached orientation="vertical">
<Tooltip label={t('nodes.zoomInNodes')}>
<IAIIconButton <IAIIconButton
aria-label="Zoom in " tooltip={t('nodes.zoomInNodes')}
aria-label={t('nodes.zoomInNodes')}
onClick={handleClickedZoomIn} onClick={handleClickedZoomIn}
icon={<FaPlus />} icon={<FaMagnifyingGlassPlus />}
/> />
</Tooltip>
<Tooltip label={t('nodes.zoomOutNodes')}>
<IAIIconButton <IAIIconButton
aria-label="Zoom out" tooltip={t('nodes.zoomOutNodes')}
aria-label={t('nodes.zoomOutNodes')}
onClick={handleClickedZoomOut} onClick={handleClickedZoomOut}
icon={<FaMinus />} icon={<FaMagnifyingGlassMinus />}
/> />
</Tooltip>
<Tooltip label={t('nodes.fitViewportNodes')}>
<IAIIconButton <IAIIconButton
aria-label="Fit viewport" tooltip={t('nodes.fitViewportNodes')}
aria-label={t('nodes.fitViewportNodes')}
onClick={handleClickedFitView} onClick={handleClickedFitView}
icon={<FaExpand />} icon={<FaExpand />}
/> />
</Tooltip>
{/* <Tooltip {/* <Tooltip
label={ label={
shouldShowFieldTypeLegend shouldShowFieldTypeLegend
@ -84,20 +80,21 @@ const ViewportControls = () => {
icon={<FaInfo />} icon={<FaInfo />}
/> />
</Tooltip> */} </Tooltip> */}
<Tooltip <IAIIconButton
label={ tooltip={
shouldShowMinimapPanel
? t('nodes.hideMinimapnodes')
: t('nodes.showMinimapnodes')
}
aria-label={
shouldShowMinimapPanel shouldShowMinimapPanel
? t('nodes.hideMinimapnodes') ? t('nodes.hideMinimapnodes')
: t('nodes.showMinimapnodes') : t('nodes.showMinimapnodes')
} }
>
<IAIIconButton
aria-label="Toggle minimap"
isChecked={shouldShowMinimapPanel} isChecked={shouldShowMinimapPanel}
onClick={handleClickedToggleMiniMapPanel} onClick={handleClickedToggleMiniMapPanel}
icon={<FaMapMarkerAlt />} icon={<FaMapMarkerAlt />}
/> />
</Tooltip>
</ButtonGroup> </ButtonGroup>
); );
}; };

View File

@ -1,19 +1,12 @@
import { Flex, chakra, useColorModeValue } from '@chakra-ui/react';
import { RootState } from 'app/store/store'; import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { useColorModeValue } from '@chakra-ui/react';
import { memo } from 'react'; import { memo } from 'react';
import { MiniMap } from 'reactflow'; import { MiniMap } from 'reactflow';
const MinimapPanel = () => { const ChakraMiniMap = chakra(MiniMap);
const miniMapStyle = useColorModeValue(
{
background: 'var(--invokeai-colors-base-200)',
},
{
background: 'var(--invokeai-colors-base-500)',
}
);
const MinimapPanel = () => {
const shouldShowMinimapPanel = useAppSelector( const shouldShowMinimapPanel = useAppSelector(
(state: RootState) => state.nodes.shouldShowMinimapPanel (state: RootState) => state.nodes.shouldShowMinimapPanel
); );
@ -29,18 +22,28 @@ const MinimapPanel = () => {
); );
return ( return (
<> <Flex sx={{ gap: 2, position: 'absolute', bottom: 2, insetInlineEnd: 2 }}>
{shouldShowMinimapPanel && ( {shouldShowMinimapPanel && (
<MiniMap <ChakraMiniMap
pannable pannable
zoomable zoomable
nodeBorderRadius={15} nodeBorderRadius={15}
style={miniMapStyle} sx={{
m: '0 !important',
backgroundColor: 'base.200 !important',
borderRadius: 'base',
_dark: {
backgroundColor: 'base.500 !important',
},
svg: {
borderRadius: 'inherit',
},
}}
nodeColor={nodeColor} nodeColor={nodeColor}
maskColor={maskColor} 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 { useAppDispatch } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIButton from 'common/components/IAIButton';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaSyncAlt } from 'react-icons/fa'; import { FaSyncAlt } from 'react-icons/fa';
import { receivedOpenAPISchema } from 'services/api/thunks/schema'; import { receivedOpenAPISchema } from 'services/api/thunks/schema';
const ReloadSchemaButton = () => { const ReloadNodeTemplatesButton = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -14,13 +14,15 @@ const ReloadSchemaButton = () => {
}, [dispatch]); }, [dispatch]);
return ( return (
<IAIIconButton <IAIButton
icon={<FaSyncAlt />} leftIcon={<FaSyncAlt />}
tooltip={t('nodes.reloadSchema')} tooltip={t('nodes.reloadNodeTemplates')}
aria-label={t('nodes.reloadSchema')} aria-label={t('nodes.reloadNodeTemplates')}
onClick={handleReloadSchema} onClick={handleReloadSchema}
/> >
{t('nodes.reloadNodeTemplates')}
</IAIButton>
); );
}; };
export default memo(ReloadSchemaButton); export default memo(ReloadNodeTemplatesButton);

View File

@ -6,6 +6,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogOverlay, AlertDialogOverlay,
Button, Button,
Flex,
Text, Text,
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
@ -19,7 +20,7 @@ import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaTrash } from 'react-icons/fa'; import { FaTrash } from 'react-icons/fa';
const ClearGraphButton = () => { const ResetWorkflowButton = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
@ -48,10 +49,11 @@ const ClearGraphButton = () => {
<> <>
<IAIIconButton <IAIIconButton
icon={<FaTrash />} icon={<FaTrash />}
tooltip={t('nodes.clearGraph')} tooltip={t('nodes.resetWorkflow')}
aria-label={t('nodes.clearGraph')} aria-label={t('nodes.resetWorkflow')}
onClick={onOpen} onClick={onOpen}
isDisabled={!nodesCount} isDisabled={!nodesCount}
colorScheme="error"
/> />
<AlertDialog <AlertDialog
@ -64,18 +66,21 @@ const ClearGraphButton = () => {
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold"> <AlertDialogHeader fontSize="lg" fontWeight="bold">
{t('nodes.clearGraph')} {t('nodes.resetWorkflow')}
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogBody> <AlertDialogBody py={4}>
<Text>{t('nodes.clearGraphDesc')}</Text> <Flex flexDir="column" gap={2}>
<Text>{t('nodes.resetWorkflowDesc')}</Text>
<Text variant="subtext">{t('nodes.resetWorkflowDesc2')}</Text>
</Flex>
</AlertDialogBody> </AlertDialogBody>
<AlertDialogFooter> <AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose}> <Button ref={cancelRef} onClick={onClose}>
{t('common.cancel')} {t('common.cancel')}
</Button> </Button>
<Button colorScheme="red" ml={3} onClick={handleConfirmClear}> <Button colorScheme="error" ml={3} onClick={handleConfirmClear}>
{t('common.accept')} {t('common.accept')}
</Button> </Button>
</AlertDialogFooter> </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 { Flex } from '@chakra-ui/layout';
import CancelButton from 'features/parameters/components/ProcessButtons/CancelButton';
import { memo } from 'react'; import { memo } from 'react';
import { Panel } from 'reactflow'; import LoadWorkflowButton from './LoadWorkflowButton';
import NodeEditorSettings from './NodeEditorSettings'; import ResetWorkflowButton from './ResetWorkflowButton';
import ClearGraphButton from './ClearGraphButton'; import SaveWorkflowButton from './SaveWorkflowButton';
import NodeInvokeButton from './NodeInvokeButton';
import ReloadSchemaButton from './ReloadSchemaButton';
const TopCenterPanel = () => { const TopCenterPanel = () => {
return ( return (
<Panel position="top-center"> <Flex
<HStack> sx={{
<NodeInvokeButton /> gap: 2,
<CancelButton /> position: 'absolute',
<ReloadSchemaButton /> top: 2,
<ClearGraphButton /> insetInlineStart: '50%',
<NodeEditorSettings /> transform: 'translate(-50%)',
</HStack> }}
</Panel> >
<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 { 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 { addNodePopoverOpened } from 'features/nodes/store/nodesSlice';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { FaPlus } from 'react-icons/fa';
import { Panel } from 'reactflow';
const TopLeftPanel = () => { const TopLeftPanel = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -12,16 +12,15 @@ const TopLeftPanel = () => {
dispatch(addNodePopoverOpened()); dispatch(addNodePopoverOpened());
}, [dispatch]); }, [dispatch]);
useHotkeys(['shift+a'], () => {
handleOpenAddNodePopover();
});
return ( return (
<Panel position="top-left"> <Flex sx={{ gap: 2, position: 'absolute', top: 2, insetInlineStart: 2 }}>
<IAIButton aria-label="Add Node" onClick={handleOpenAddNodePopover}> <IAIIconButton
Add Node tooltip="Add Node (Shift+A, Space)"
</IAIButton> aria-label="Add Node"
</Panel> icon={<FaPlus />}
onClick={handleOpenAddNodePopover}
/>
</Flex>
); );
}; };

View File

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

View File

@ -1,6 +1,7 @@
import { import {
Divider, Divider,
Flex, Flex,
FormLabelProps,
Heading, Heading,
Modal, Modal,
ModalBody, ModalBody,
@ -8,22 +9,30 @@ import {
ModalContent, ModalContent,
ModalHeader, ModalHeader,
ModalOverlay, ModalOverlay,
forwardRef,
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIIconButton from 'common/components/IAIIconButton';
import IAISwitch from 'common/components/IAISwitch'; import IAISwitch from 'common/components/IAISwitch';
import { ChangeEvent, memo, useCallback } from 'react';
import { FaCog } from 'react-icons/fa';
import { import {
selectionModeChanged,
shouldAnimateEdgesChanged, shouldAnimateEdgesChanged,
shouldColorEdgesChanged, shouldColorEdgesChanged,
shouldSnapToGridChanged, shouldSnapToGridChanged,
shouldValidateGraphChanged, shouldValidateGraphChanged,
} from 'features/nodes/store/nodesSlice'; } 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( const selector = createSelector(
stateSelector, stateSelector,
@ -33,18 +42,20 @@ const selector = createSelector(
shouldValidateGraph, shouldValidateGraph,
shouldSnapToGrid, shouldSnapToGrid,
shouldColorEdges, shouldColorEdges,
selectionMode,
} = nodes; } = nodes;
return { return {
shouldAnimateEdges, shouldAnimateEdges,
shouldValidateGraph, shouldValidateGraph,
shouldSnapToGrid, shouldSnapToGrid,
shouldColorEdges, shouldColorEdges,
selectionModeIsChecked: selectionMode === SelectionMode.Full,
}; };
}, },
defaultSelectorOptions defaultSelectorOptions
); );
const NodeEditorSettings = () => { const WorkflowEditorSettings = forwardRef((_, ref) => {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { const {
@ -52,6 +63,7 @@ const NodeEditorSettings = () => {
shouldValidateGraph, shouldValidateGraph,
shouldSnapToGrid, shouldSnapToGrid,
shouldColorEdges, shouldColorEdges,
selectionModeIsChecked,
} = useAppSelector(selector); } = useAppSelector(selector);
const handleChangeShouldValidate = useCallback( const handleChangeShouldValidate = useCallback(
@ -82,10 +94,19 @@ const NodeEditorSettings = () => {
[dispatch] [dispatch]
); );
const handleChangeSelectionMode = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
dispatch(selectionModeChanged(e.target.checked));
},
[dispatch]
);
return ( return (
<> <>
<IAIIconButton <IAIIconButton
aria-label="Node Editor Settings" ref={ref}
aria-label="Workflow Editor Settings"
tooltip="Workflow Editor Settings"
icon={<FaCog />} icon={<FaCog />}
onClick={onOpen} onClick={onOpen}
/> />
@ -93,7 +114,7 @@ const NodeEditorSettings = () => {
<Modal isOpen={isOpen} onClose={onClose} size="2xl" isCentered> <Modal isOpen={isOpen} onClose={onClose} size="2xl" isCentered>
<ModalOverlay /> <ModalOverlay />
<ModalContent> <ModalContent>
<ModalHeader>Node Editor Settings</ModalHeader> <ModalHeader>Workflow Editor Settings</ModalHeader>
<ModalCloseButton /> <ModalCloseButton />
<ModalBody> <ModalBody>
<Flex <Flex
@ -105,6 +126,7 @@ const NodeEditorSettings = () => {
> >
<Heading size="sm">General</Heading> <Heading size="sm">General</Heading>
<IAISwitch <IAISwitch
formLabelProps={formLabelProps}
onChange={handleChangeShouldAnimate} onChange={handleChangeShouldAnimate}
isChecked={shouldAnimateEdges} isChecked={shouldAnimateEdges}
label="Animated Edges" label="Animated Edges"
@ -112,6 +134,7 @@ const NodeEditorSettings = () => {
/> />
<Divider /> <Divider />
<IAISwitch <IAISwitch
formLabelProps={formLabelProps}
isChecked={shouldSnapToGrid} isChecked={shouldSnapToGrid}
onChange={handleChangeShouldSnap} onChange={handleChangeShouldSnap}
label="Snap to Grid" label="Snap to Grid"
@ -119,26 +142,36 @@ const NodeEditorSettings = () => {
/> />
<Divider /> <Divider />
<IAISwitch <IAISwitch
formLabelProps={formLabelProps}
isChecked={shouldColorEdges} isChecked={shouldColorEdges}
onChange={handleChangeShouldColor} onChange={handleChangeShouldColor}
label="Color-Code Edges" label="Color-Code Edges"
helperText="Color-code edges according to their connected fields" 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}> <Heading size="sm" pt={4}>
Advanced Advanced
</Heading> </Heading>
<IAISwitch <IAISwitch
formLabelProps={formLabelProps}
isChecked={shouldValidateGraph} isChecked={shouldValidateGraph}
onChange={handleChangeShouldValidate} onChange={handleChangeShouldValidate}
label="Validate Connections and Graph" label="Validate Connections and Graph"
helperText="Prevent invalid connections from being made, and invalid graphs from being invoked" helperText="Prevent invalid connections from being made, and invalid graphs from being invoked"
/> />
<ReloadNodeTemplatesButton />
</Flex> </Flex>
</ModalBody> </ModalBody>
</ModalContent> </ModalContent>
</Modal> </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 ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
import { memo, useState } from 'react'; import { usePanelStorage } from 'features/ui/hooks/usePanelStorage';
import { Panel, PanelGroup } from 'react-resizable-panels'; import { memo, useCallback, useRef, useState } from 'react';
import {
ImperativePanelGroupHandle,
Panel,
PanelGroup,
} from 'react-resizable-panels';
import 'reactflow/dist/style.css'; import 'reactflow/dist/style.css';
import WorkflowPanel from './workflow/WorkflowPanel';
import InspectorPanel from './inspector/InspectorPanel'; import InspectorPanel from './inspector/InspectorPanel';
import WorkflowPanel from './workflow/WorkflowPanel';
const NodeEditorPanelGroup = () => { const NodeEditorPanelGroup = () => {
const [isTopPanelCollapsed, setIsTopPanelCollapsed] = useState(false); const [isTopPanelCollapsed, setIsTopPanelCollapsed] = useState(false);
const [isBottomPanelCollapsed, setIsBottomPanelCollapsed] = 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 ( return (
<Flex sx={{ flexDir: 'column', gap: 2, height: '100%', width: '100%' }}>
<ProcessButtons />
<PanelGroup <PanelGroup
id="node-editor-panel_group" ref={panelGroupRef}
autoSaveId="node-editor-panel_group" id="workflow-panel-group"
direction="vertical" direction="vertical"
style={{ height: '100%', width: '100%' }} style={{ height: '100%', width: '100%' }}
storage={panelStorage}
> >
<Panel <Panel
id="node-editor-panel_group_workflow" id="workflow"
collapsible collapsible
onCollapse={setIsTopPanelCollapsed} onCollapse={setIsTopPanelCollapsed}
minSize={25} minSize={25}
@ -26,6 +44,7 @@ const NodeEditorPanelGroup = () => {
</Panel> </Panel>
<ResizeHandle <ResizeHandle
direction="vertical" direction="vertical"
onDoubleClick={handleDoubleClickHandle}
collapsedDirection={ collapsedDirection={
isTopPanelCollapsed isTopPanelCollapsed
? 'top' ? 'top'
@ -35,7 +54,7 @@ const NodeEditorPanelGroup = () => {
} }
/> />
<Panel <Panel
id="node-editor-panel_group_inspector" id="inspector"
collapsible collapsible
onCollapse={setIsBottomPanelCollapsed} onCollapse={setIsBottomPanelCollapsed}
minSize={25} minSize={25}
@ -43,6 +62,7 @@ const NodeEditorPanelGroup = () => {
<InspectorPanel /> <InspectorPanel />
</Panel> </Panel>
</PanelGroup> </PanelGroup>
</Flex>
); );
}; };

View File

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

View File

@ -1,26 +1,10 @@
import { Flex } from '@chakra-ui/react'; 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 DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer';
import { buildWorkflow } from 'features/nodes/util/buildWorkflow'; import { useWorkflow } from 'features/nodes/hooks/useWorkflow';
import { memo, useMemo } from 'react'; import { memo } 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,
};
};
const WorkflowJSONTab = () => { const WorkflowJSONTab = () => {
const { workflow } = useWatchWorkflow(); const workflow = useWorkflow();
return ( return (
<Flex <Flex
@ -31,7 +15,7 @@ const WorkflowJSONTab = () => {
h: 'full', h: 'full',
}} }}
> >
<DataViewer data={workflow} label="Workflow" fileName={workflow.name} /> <DataViewer data={workflow} label="Workflow" />
</Flex> </Flex>
); );
}; };

View File

@ -8,8 +8,8 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { memo } from 'react'; import { memo } from 'react';
import WorkflowGeneralTab from './WorkflowGeneralTab'; import WorkflowGeneralTab from './WorkflowGeneralTab';
import WorkflowLinearTab from './WorkflowLinearTab';
import WorkflowJSONTab from './WorkflowJSONTab'; import WorkflowJSONTab from './WorkflowJSONTab';
import WorkflowLinearTab from './WorkflowLinearTab';
const WorkflowPanel = () => { const WorkflowPanel = () => {
return ( return (
@ -20,7 +20,8 @@ const WorkflowPanel = () => {
w: 'full', w: 'full',
h: 'full', h: 'full',
borderRadius: 'base', borderRadius: 'base',
p: 4, p: 2,
gap: 2,
}} }}
> >
<Tabs <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 { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { map } from 'lodash-es'; import { map } from 'lodash-es';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { KIND_MAP } from '../types/constants';
import { isInvocationNode } from '../types/types'; import { isInvocationNode } from '../types/types';
export const useFieldNames = (nodeId: string, kind: 'input' | 'output') => { export const useOutputFieldNames = (nodeId: string) => {
const selector = useMemo( const selector = useMemo(
() => () =>
createSelector( createSelector(
@ -17,13 +16,18 @@ export const useFieldNames = (nodeId: string, kind: 'input' | 'output') => {
if (!isInvocationNode(node)) { if (!isInvocationNode(node)) {
return []; return [];
} }
return map(node.data[KIND_MAP[kind]], (field) => field.name).filter( const nodeTemplate = nodes.nodeTemplates[node.data.type];
(fieldName) => fieldName !== 'is_intermediate' 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 defaultSelectorOptions
), ),
[kind, nodeId] [nodeId]
); );
const fieldNames = useAppSelector(selector); 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 * Nodes slice persist denylist
*/ */
export const nodesPersistDenylist: (keyof NodesState)[] = [ export const nodesPersistDenylist: (keyof NodesState)[] = [
'schema',
'nodeTemplates', 'nodeTemplates',
'connectionStartParams', 'connectionStartParams',
'currentConnectionFieldType', 'currentConnectionFieldType',

View File

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

View File

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

View File

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

View File

@ -1,23 +1,13 @@
import { import {
ControlNetModelParam,
LoRAModelParam,
MainModelParam,
OnnxModelParam,
SchedulerParam, SchedulerParam,
VaeModelParam, zBaseModel,
zMainOrOnnxModel,
zScheduler,
} from 'features/parameters/types/parameterSchemas'; } from 'features/parameters/types/parameterSchemas';
import { OpenAPIV3 } from 'openapi-types'; import { OpenAPIV3 } from 'openapi-types';
import { RgbaColor } from 'react-colorful'; import { RgbaColor } from 'react-colorful';
import { Edge, Node } from 'reactflow'; import { Node } from 'reactflow';
import { components } from 'services/api/schema'; import { Graph, ImageDTO, _InputField, _OutputField } from 'services/api/types';
import {
Graph,
GraphExecutionState,
ImageDTO,
ImageField,
_InputField,
_OutputField,
} from 'services/api/types';
import { import {
AnyInvocationType, AnyInvocationType,
AnyResult, AnyResult,
@ -53,6 +43,10 @@ export type InvocationTemplate = {
* Array of the invocation outputs * Array of the invocation outputs
*/ */
outputs: Record<string, OutputFieldTemplate>; outputs: Record<string, OutputFieldTemplate>;
/**
* The type of this node's output
*/
outputType: string; // TODO: generate a union of output types
}; };
export type FieldUIConfig = { export type FieldUIConfig = {
@ -114,40 +108,6 @@ export type FieldType = z.infer<typeof zFieldType>;
export const isFieldType = (value: unknown): value is FieldType => export const isFieldType = (value: unknown): value is FieldType =>
zFieldType.safeParse(value).success; 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. * An input field template is generated on each page load from the OpenAPI schema.
* *
@ -179,6 +139,19 @@ export type InputFieldTemplate =
| ImageCollectionInputFieldTemplate | ImageCollectionInputFieldTemplate
| SchedulerInputFieldTemplate; | 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. * 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 * - `id` a unique identifier
* - `name` the name of the field, which comes from the python dataclass * - `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. * An output field template is generated on each page load from the OpenAPI schema.
@ -199,143 +176,309 @@ export type OutputFieldTemplate = {
type: FieldType; type: FieldType;
title: string; title: string;
description: string; description: string;
}; } & _OutputField;
/** export const zInputFieldValueBase = zFieldValueBase.extend({
* Indicates the kind of input(s) this field may have. fieldKind: z.literal('input'),
*/ label: z.string(),
export type InputKind = 'connection' | 'direct' | 'any'; });
export type InputFieldValueBase = z.infer<typeof zInputFieldValueBase>;
export type FieldValueBase = { export const zModelIdentifier = z.object({
id: string; model_name: z.string().trim().min(1),
name: string; base_model: zBaseModel,
type: FieldType; });
};
export type InputFieldValueBase = FieldValueBase & { export const zImageField = z.object({
fieldKind: 'input'; image_name: z.string().trim().min(1),
label: string; });
}; export type ImageField = z.infer<typeof zImageField>;
export type IntegerInputFieldValue = InputFieldValueBase & { export const zLatentsField = z.object({
type: 'integer'; latents_name: z.string().trim().min(1),
value?: number; seed: z.number().int().optional(),
}; });
export type LatentsField = z.infer<typeof zLatentsField>;
export type FloatInputFieldValue = InputFieldValueBase & { export const zConditioningField = z.object({
type: 'float'; conditioning_name: z.string().trim().min(1),
value?: number; });
}; export type ConditioningField = z.infer<typeof zConditioningField>;
export type SeedInputFieldValue = InputFieldValueBase & { export const zIntegerInputFieldValue = zInputFieldValueBase.extend({
type: 'Seed'; type: z.literal('integer'),
value?: number; value: z.number().optional(),
}; });
export type IntegerInputFieldValue = z.infer<typeof zIntegerInputFieldValue>;
export type StringInputFieldValue = InputFieldValueBase & { export const zFloatInputFieldValue = zInputFieldValueBase.extend({
type: 'string'; type: z.literal('float'),
value?: string; value: z.number().optional(),
}; });
export type FloatInputFieldValue = z.infer<typeof zFloatInputFieldValue>;
export type BooleanInputFieldValue = InputFieldValueBase & { export const zStringInputFieldValue = zInputFieldValueBase.extend({
type: 'boolean'; type: z.literal('string'),
value?: boolean; value: z.string().optional(),
}; });
export type StringInputFieldValue = z.infer<typeof zStringInputFieldValue>;
export type EnumInputFieldValue = InputFieldValueBase & { export const zBooleanInputFieldValue = zInputFieldValueBase.extend({
type: 'enum'; type: z.literal('boolean'),
value?: number | string; value: z.boolean().optional(),
}; });
export type BooleanInputFieldValue = z.infer<typeof zBooleanInputFieldValue>;
export type LatentsInputFieldValue = InputFieldValueBase & { export const zEnumInputFieldValue = zInputFieldValueBase.extend({
type: 'LatentsField'; type: z.literal('enum'),
value?: undefined; value: z.union([z.string(), z.number()]).optional(),
}; });
export type EnumInputFieldValue = z.infer<typeof zEnumInputFieldValue>;
export type ConditioningInputFieldValue = InputFieldValueBase & { export const zLatentsInputFieldValue = zInputFieldValueBase.extend({
type: 'ConditioningField'; type: z.literal('LatentsField'),
value?: string; value: zLatentsField.optional(),
}; });
export type LatentsInputFieldValue = z.infer<typeof zLatentsInputFieldValue>;
export type ControlInputFieldValue = InputFieldValueBase & { export const zConditioningInputFieldValue = zInputFieldValueBase.extend({
type: 'ControlField'; type: z.literal('ConditioningField'),
value?: undefined; value: zConditioningField.optional(),
}; });
export type ConditioningInputFieldValue = z.infer<
typeof zConditioningInputFieldValue
>;
export type UNetInputFieldValue = InputFieldValueBase & { export const zControlNetModel = zModelIdentifier;
type: 'UNetField'; export type ControlNetModel = z.infer<typeof zControlNetModel>;
value?: undefined;
};
export type ClipInputFieldValue = InputFieldValueBase & { export const zControlField = zInputFieldValueBase.extend({
type: 'ClipField'; image: zImageField,
value?: undefined; 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 & { export const zControlInputFieldTemplate = zInputFieldValueBase.extend({
type: 'VaeField'; type: z.literal('ControlField'),
value?: undefined; value: zControlField.optional(),
}; });
export type ControlInputFieldValue = z.infer<typeof zControlInputFieldTemplate>;
export type ImageInputFieldValue = InputFieldValueBase & { export const zModelType = z.enum([
type: 'ImageField'; 'onnx',
value?: ImageField; 'main',
}; 'vae',
'lora',
'controlnet',
'embedding',
]);
export type ModelType = z.infer<typeof zModelType>;
export type ImageCollectionInputFieldValue = InputFieldValueBase & { export const zSubModelType = z.enum([
type: 'ImageCollection'; 'unet',
value?: ImageField[]; '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 & { export const zModelInfo = zModelIdentifier.extend({
type: 'MainModelField'; model_type: zModelType,
value?: MainModelParam | OnnxModelParam; submodel: zSubModelType.optional(),
}; });
export type ModelInfo = z.infer<typeof zModelInfo>;
export type SDXLMainModelInputFieldValue = InputFieldValueBase & { export const zLoraInfo = zModelInfo.extend({
type: 'SDXLMainModelField'; weight: z.number().optional(),
value?: MainModelParam | OnnxModelParam; });
}; export type LoraInfo = z.infer<typeof zLoraInfo>;
export type SDXLRefinerModelInputFieldValue = InputFieldValueBase & { export const zUNetField = z.object({
type: 'SDXLRefinerModelField'; unet: zModelInfo,
value?: MainModelParam | OnnxModelParam; scheduler: zModelInfo,
}; loras: z.array(zLoraInfo),
});
export type UNetField = z.infer<typeof zUNetField>;
export type VaeModelInputFieldValue = InputFieldValueBase & { export const zUNetInputFieldValue = zInputFieldValueBase.extend({
type: 'VaeModelField'; type: z.literal('UNetField'),
value?: VaeModelParam; value: zUNetField.optional(),
}; });
export type UNetInputFieldValue = z.infer<typeof zUNetInputFieldValue>;
export type LoRAModelInputFieldValue = InputFieldValueBase & { export const zClipField = z.object({
type: 'LoRAModelField'; tokenizer: zModelInfo,
value?: LoRAModelParam; text_encoder: zModelInfo,
}; skipped_layers: z.number(),
loras: z.array(zLoraInfo),
});
export type ClipField = z.infer<typeof zClipField>;
export type ControlNetModelInputFieldValue = InputFieldValueBase & { export const zClipInputFieldValue = zInputFieldValueBase.extend({
type: 'ControlNetModelField'; type: z.literal('ClipField'),
value?: ControlNetModelParam; value: zClipField.optional(),
}; });
export type ClipInputFieldValue = z.infer<typeof zClipInputFieldValue>;
export type CollectionInputFieldValue = InputFieldValueBase & { export const zVaeField = z.object({
type: 'Collection'; vae: zModelInfo,
value?: (string | number)[]; });
}; export type VaeField = z.infer<typeof zVaeField>;
export type CollectionItemInputFieldValue = InputFieldValueBase & { export const zVaeInputFieldValue = zInputFieldValueBase.extend({
type: 'CollectionItem'; type: z.literal('VaeField'),
value?: undefined; value: zVaeField.optional(),
}; });
export type VaeInputFieldValue = z.infer<typeof zVaeInputFieldValue>;
export type ColorInputFieldValue = InputFieldValueBase & { export const zImageInputFieldValue = zInputFieldValueBase.extend({
type: 'ColorField'; type: z.literal('ImageField'),
value?: RgbaColor; value: zImageField.optional(),
}; });
export type ImageInputFieldValue = z.infer<typeof zImageInputFieldValue>;
export type SchedulerInputFieldValue = InputFieldValueBase & { export const zImageCollectionInputFieldValue = zInputFieldValueBase.extend({
type: 'Scheduler'; type: z.literal('ImageCollection'),
value?: SchedulerParam; 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 = { export type InputFieldTemplateBase = {
name: string; name: string;
@ -521,14 +664,15 @@ export type InvocationBaseSchemaObject = Omit<
export type InvocationOutputSchemaObject = Omit< export type InvocationOutputSchemaObject = Omit<
OpenAPIV3.SchemaObject, OpenAPIV3.SchemaObject,
'properties' 'properties'
> & > & {
OpenAPIV3.SchemaObject['properties'] & { properties: OpenAPIV3.SchemaObject['properties'] & {
type: Omit<OpenAPIV3.SchemaObject, 'default'> & { type: Omit<OpenAPIV3.SchemaObject, 'default'> & {
default: AnyInvocationType; default: string;
}; };
} & { } & {
class: 'output'; class: 'output';
}; };
};
export type InvocationFieldSchema = OpenAPIV3.SchemaObject & _InputField; export type InvocationFieldSchema = OpenAPIV3.SchemaObject & _InputField;
@ -571,24 +715,26 @@ export const isInvocationFieldSchema = (
export type InvocationEdgeExtra = { type: 'default' | 'collapsed' }; 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({ export const zInvocationNodeData = z.object({
id: z.string().trim().min(1), 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), type: z.string().trim().min(1),
inputs: z.record(z.any()), inputs: z.record(zInputFieldValue),
outputs: z.record(z.any()), outputs: z.record(zOutputFieldValue),
label: z.string(), label: z.string(),
isOpen: z.boolean(), isOpen: z.boolean(),
notes: z.string(), 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({ export const zNotesNodeData = z.object({
id: z.string().trim().min(1), id: z.string().trim().min(1),
type: z.literal('notes'), type: z.literal('notes'),
@ -597,75 +743,83 @@ export const zNotesNodeData = z.object({
notes: z.string(), notes: z.string(),
}); });
export const zWorkflow = z.object({ export type NotesNodeData = z.infer<typeof zNotesNodeData>;
name: z.string().trim().min(1),
author: z.string(), export const zWorkflowInvocationNode = z.object({
description: z.string(),
version: z.string(),
contact: z.string(),
tags: z.string(),
notes: z.string(),
nodes: z.array(
z.object({
id: z.string().trim().min(1), id: z.string().trim().min(1),
type: z.string().trim().min(1), type: z.literal('invocation'),
data: z.union([zInvocationNodeData, zNotesNodeData]), data: zInvocationNodeData,
width: z.number().gt(0), width: z.number().gt(0),
height: z.number().gt(0), height: z.number().gt(0),
position: z.object({ position: z.object({
x: z.number(), x: z.number(),
y: z.number(), y: z.number(),
}), }),
}) });
),
edges: z.array( export const zWorkflowNotesNode = z.object({
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), source: z.string().trim().min(1),
sourceHandle: z.string().trim().min(1), sourceHandle: z.string().trim().min(1),
target: z.string().trim().min(1), target: z.string().trim().min(1),
targetHandle: z.string().trim().min(1), targetHandle: z.string().trim().min(1),
id: 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 = { export const zFieldIdentifier = z.object({
name: string; nodeId: z.string().trim().min(1),
author: string; fieldName: z.string().trim().min(1),
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 type InvocationNodeData = { export type FieldIdentifier = z.infer<typeof zFieldIdentifier>;
id: string;
type: AnyInvocationType;
inputs: Record<string, InputFieldValue>;
outputs: Record<string, OutputFieldValue>;
label: string;
isOpen: boolean;
notes: string;
};
export type NotesNodeData = { export const zSemVer = z.string().refine((val) => {
id: string; const [major, minor, patch] = val.split('.');
type: 'notes'; return (
label: string; major !== undefined &&
notes: string; minor !== undefined &&
isOpen: boolean; 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 = { export type CurrentImageNodeData = {
id: string; id: string;
@ -705,25 +859,6 @@ export enum NodeStatus {
FAILED, 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 = { export type NodeExecutionState = {
nodeId: string; nodeId: string;
status: NodeStatus; status: NodeStatus;
@ -733,14 +868,9 @@ export type NodeExecutionState = {
outputs: AnyResult[]; outputs: AnyResult[];
}; };
export type FieldIdentifier = {
nodeId: string;
fieldName: string;
};
export type FieldComponentProps< export type FieldComponentProps<
V extends InputFieldValue, V extends InputFieldValue,
T extends InputFieldTemplate T extends InputFieldTemplate,
> = { > = {
nodeId: string; nodeId: string;
field: V; field: V;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -210,7 +210,7 @@ export type HeightParam = z.infer<typeof zHeight>;
export const isValidHeight = (val: unknown): val is HeightParam => export const isValidHeight = (val: unknown): val is HeightParam =>
zHeight.safeParse(val).success; 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>; export type BaseModelParam = z.infer<typeof zBaseModel>;

View File

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

View File

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

View File

@ -2,17 +2,15 @@ import ParamDynamicPromptsCollapse from 'features/dynamicPrompts/components/Para
import ParamLoraCollapse from 'features/lora/components/ParamLoraCollapse'; import ParamLoraCollapse from 'features/lora/components/ParamLoraCollapse';
import ParamControlNetCollapse from 'features/parameters/components/Parameters/ControlNet/ParamControlNetCollapse'; import ParamControlNetCollapse from 'features/parameters/components/Parameters/ControlNet/ParamControlNetCollapse';
import ParamNoiseCollapse from 'features/parameters/components/Parameters/Noise/ParamNoiseCollapse'; 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 TextToImageTabCoreParameters from 'features/ui/components/tabs/TextToImage/TextToImageTabCoreParameters';
import { memo } from 'react';
import ParamSDXLPromptArea from './ParamSDXLPromptArea'; import ParamSDXLPromptArea from './ParamSDXLPromptArea';
import ParamSDXLRefinerCollapse from './ParamSDXLRefinerCollapse'; import ParamSDXLRefinerCollapse from './ParamSDXLRefinerCollapse';
import { memo } from 'react';
const SDXLTextToImageTabParameters = () => { const SDXLTextToImageTabParameters = () => {
return ( return (
<> <>
<ParamSDXLPromptArea /> <ParamSDXLPromptArea />
<ProcessButtons />
<TextToImageTabCoreParameters /> <TextToImageTabCoreParameters />
<ParamSDXLRefinerCollapse /> <ParamSDXLRefinerCollapse />
<ParamControlNetCollapse /> <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 ParamSeamPaintingCollapse from 'features/parameters/components/Parameters/Canvas/SeamPainting/ParamSeamPaintingCollapse';
import ParamControlNetCollapse from 'features/parameters/components/Parameters/ControlNet/ParamControlNetCollapse'; import ParamControlNetCollapse from 'features/parameters/components/Parameters/ControlNet/ParamControlNetCollapse';
import ParamNoiseCollapse from 'features/parameters/components/Parameters/Noise/ParamNoiseCollapse'; import ParamNoiseCollapse from 'features/parameters/components/Parameters/Noise/ParamNoiseCollapse';
import ProcessButtons from 'features/parameters/components/ProcessButtons/ProcessButtons';
import ParamSDXLPromptArea from './ParamSDXLPromptArea'; import ParamSDXLPromptArea from './ParamSDXLPromptArea';
import ParamSDXLRefinerCollapse from './ParamSDXLRefinerCollapse'; import ParamSDXLRefinerCollapse from './ParamSDXLRefinerCollapse';
import SDXLUnifiedCanvasTabCoreParameters from './SDXLUnifiedCanvasTabCoreParameters'; import SDXLUnifiedCanvasTabCoreParameters from './SDXLUnifiedCanvasTabCoreParameters';
@ -14,7 +13,6 @@ export default function SDXLUnifiedCanvasTabParameters() {
return ( return (
<> <>
<ParamSDXLPromptArea /> <ParamSDXLPromptArea />
<ProcessButtons />
<SDXLUnifiedCanvasTabCoreParameters /> <SDXLUnifiedCanvasTabCoreParameters />
<ParamSDXLRefinerCollapse /> <ParamSDXLRefinerCollapse />
<ParamControlNetCollapse /> <ParamControlNetCollapse />

View File

@ -60,11 +60,6 @@ export default function HotkeysModal({ children }: HotkeysModalProps) {
desc: t('hotkeys.toggleOptions.desc'), desc: t('hotkeys.toggleOptions.desc'),
hotkey: 'O', hotkey: 'O',
}, },
{
title: t('hotkeys.pinOptions.title'),
desc: t('hotkeys.pinOptions.desc'),
hotkey: 'Shift+O',
},
{ {
title: t('hotkeys.toggleGallery.title'), title: t('hotkeys.toggleGallery.title'),
desc: t('hotkeys.toggleGallery.desc'), desc: t('hotkeys.toggleGallery.desc'),
@ -136,11 +131,6 @@ export default function HotkeysModal({ children }: HotkeysModalProps) {
desc: t('hotkeys.nextImage.desc'), desc: t('hotkeys.nextImage.desc'),
hotkey: 'Arrow Right', hotkey: 'Arrow Right',
}, },
{
title: t('hotkeys.toggleGalleryPin.title'),
desc: t('hotkeys.toggleGalleryPin.desc'),
hotkey: 'Shift+G',
},
{ {
title: t('hotkeys.increaseGalleryThumbSize.title'), title: t('hotkeys.increaseGalleryThumbSize.title'),
desc: t('hotkeys.increaseGalleryThumbSize.desc'), desc: t('hotkeys.increaseGalleryThumbSize.desc'),
@ -290,7 +280,7 @@ export default function HotkeysModal({ children }: HotkeysModalProps) {
{ {
title: t('hotkeys.addNodes.title'), title: t('hotkeys.addNodes.title'),
desc: t('hotkeys.addNodes.desc'), 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); const isHovered = useHoverDirty(ref);
return ( return (
<Flex alignItems="center" gap={3} ps={1} ref={ref}> <Flex alignItems="center" gap={5} ps={1} ref={ref}>
<Image <Image
src={InvokeAILogoImage} src={InvokeAILogoImage}
alt="invoke-ai-logo" 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'; } from 'features/system/store/systemSlice';
import { import {
setShouldShowProgressInViewer, setShouldShowProgressInViewer,
setShouldUseCanvasBetaLayout,
setShouldUseSliders, setShouldUseSliders,
} from 'features/ui/store/uiSlice'; } from 'features/ui/store/uiSlice';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
@ -42,6 +41,7 @@ import {
memo, memo,
useCallback, useCallback,
useEffect, useEffect,
useState,
} from 'react'; } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LogLevelName } from 'roarr'; import { LogLevelName } from 'roarr';
@ -68,18 +68,13 @@ const selector = createSelector(
shouldUseWatermarker, shouldUseWatermarker,
} = system; } = system;
const { const { shouldUseSliders, shouldShowProgressInViewer } = ui;
shouldUseCanvasBetaLayout,
shouldUseSliders,
shouldShowProgressInViewer,
} = ui;
const { shouldShowAdvancedOptions } = generation; const { shouldShowAdvancedOptions } = generation;
return { return {
shouldConfirmOnDelete, shouldConfirmOnDelete,
enableImageDebugging, enableImageDebugging,
shouldUseCanvasBetaLayout,
shouldUseSliders, shouldUseSliders,
shouldShowProgressInViewer, shouldShowProgressInViewer,
consoleLogLevel, consoleLogLevel,
@ -98,7 +93,6 @@ const selector = createSelector(
type ConfigOptions = { type ConfigOptions = {
shouldShowDeveloperSettings: boolean; shouldShowDeveloperSettings: boolean;
shouldShowResetWebUiText: boolean; shouldShowResetWebUiText: boolean;
shouldShowBetaLayout: boolean;
shouldShowAdvancedOptionsSettings: boolean; shouldShowAdvancedOptionsSettings: boolean;
shouldShowClearIntermediates: boolean; shouldShowClearIntermediates: boolean;
shouldShowLocalizationToggle: boolean; shouldShowLocalizationToggle: boolean;
@ -113,8 +107,8 @@ type SettingsModalProps = {
const SettingsModal = ({ children, config }: SettingsModalProps) => { const SettingsModal = ({ children, config }: SettingsModalProps) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t } = useTranslation(); const { t } = useTranslation();
const [countdown, setCountdown] = useState(3);
const shouldShowBetaLayout = config?.shouldShowBetaLayout ?? true;
const shouldShowDeveloperSettings = const shouldShowDeveloperSettings =
config?.shouldShowDeveloperSettings ?? true; config?.shouldShowDeveloperSettings ?? true;
const shouldShowResetWebUiText = config?.shouldShowResetWebUiText ?? true; const shouldShowResetWebUiText = config?.shouldShowResetWebUiText ?? true;
@ -156,7 +150,6 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
const { const {
shouldConfirmOnDelete, shouldConfirmOnDelete,
enableImageDebugging, enableImageDebugging,
shouldUseCanvasBetaLayout,
shouldUseSliders, shouldUseSliders,
shouldShowProgressInViewer, shouldShowProgressInViewer,
consoleLogLevel, consoleLogLevel,
@ -179,8 +172,15 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
}); });
onSettingsModalClose(); onSettingsModalClose();
onRefreshModalOpen(); onRefreshModalOpen();
setInterval(() => setCountdown((prev) => prev - 1), 1000);
}, [onSettingsModalClose, onRefreshModalOpen]); }, [onSettingsModalClose, onRefreshModalOpen]);
useEffect(() => {
if (countdown <= 0) {
window.location.reload();
}
}, [countdown]);
const handleLogLevelChanged = useCallback( const handleLogLevelChanged = useCallback(
(v: string) => { (v: string) => {
dispatch(consoleLogLevelChanged(v as LogLevelName)); 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 && ( {shouldShowLocalizationToggle && (
<IAIMantineSelect <IAIMantineSelect
disabled={!isLocalizationEnabled} disabled={!isLocalizationEnabled}
@ -381,6 +370,7 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
isOpen={isRefreshModalOpen} isOpen={isRefreshModalOpen}
onClose={onRefreshModalClose} onClose={onRefreshModalClose}
isCentered isCentered
closeOnEsc={false}
> >
<ModalOverlay backdropFilter="blur(40px)" /> <ModalOverlay backdropFilter="blur(40px)" />
<ModalContent> <ModalContent>
@ -388,7 +378,9 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
<ModalBody> <ModalBody>
<Flex justifyContent="center"> <Flex justifyContent="center">
<Text fontSize="lg"> <Text fontSize="lg">
<Text>{t('settings.resetComplete')}</Text> <Text>
{t('settings.resetComplete')} Reloading in {countdown}...
</Text>
</Text> </Text>
</Flex> </Flex>
</ModalBody> </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', 'isCancelScheduled',
'progressImage', 'progressImage',
'wereModelsReceived', 'wereModelsReceived',
'wasSchemaParsed',
'isPersisted', 'isPersisted',
'isUploading', 'isUploading',
]; ];

View File

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

View File

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

View File

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

View File

@ -11,12 +11,12 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import AuxiliaryProgressIndicator from 'app/components/AuxiliaryProgressIndicator'; 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 { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import ImageGalleryContent from 'features/gallery/components/ImageGalleryContent'; import ImageGalleryContent from 'features/gallery/components/ImageGalleryContent';
import NodeEditorPanelGroup from 'features/nodes/components/sidePanel/NodeEditorPanelGroup';
import { InvokeTabName, tabMap } from 'features/ui/store/tabMap'; 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 { ResourceKey } from 'i18next';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { MouseEvent, ReactNode, memo, useCallback, useMemo } from 'react'; 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 { FaCube, FaFont, FaImage } from 'react-icons/fa';
import { MdDeviceHub, MdGridOn } from 'react-icons/md'; import { MdDeviceHub, MdGridOn } from 'react-icons/md';
import { Panel, PanelGroup } from 'react-resizable-panels'; import { Panel, PanelGroup } from 'react-resizable-panels';
import { useMinimumPanelSize } from '../hooks/useMinimumPanelSize'; import { usePanel } from '../hooks/usePanel';
import { usePanelStorage } from '../hooks/usePanelStorage';
import { import {
activeTabIndexSelector, activeTabIndexSelector,
activeTabNameSelector, activeTabNameSelector,
} from '../store/uiSelectors'; } from '../store/uiSelectors';
import FloatingGalleryButton from './FloatingGalleryButton';
import FloatingSidePanelButtons from './FloatingParametersPanelButtons';
import ParametersPanel from './ParametersPanel';
import ImageTab from './tabs/ImageToImage/ImageToImageTab'; import ImageTab from './tabs/ImageToImage/ImageToImageTab';
import ModelManagerTab from './tabs/ModelManager/ModelManagerTab'; import ModelManagerTab from './tabs/ModelManager/ModelManagerTab';
import NodesTab from './tabs/Nodes/NodesTab'; import NodesTab from './tabs/Nodes/NodesTab';
@ -89,38 +93,20 @@ const enabledTabsSelector = createSelector(
} }
); );
const MIN_GALLERY_WIDTH = 350; const SIDE_PANEL_MIN_SIZE_PX = 448;
const DEFAULT_GALLERY_PCT = 20; const MAIN_PANEL_MIN_SIZE_PX = 448;
const GALLERY_PANEL_MIN_SIZE_PX = 360;
export const NO_GALLERY_TABS: InvokeTabName[] = ['modelManager']; export const NO_GALLERY_TABS: InvokeTabName[] = ['modelManager'];
export const NO_SIDE_PANEL_TABS: InvokeTabName[] = ['modelManager'];
const InvokeTabs = () => { const InvokeTabs = () => {
const activeTab = useAppSelector(activeTabIndexSelector); const activeTab = useAppSelector(activeTabIndexSelector);
const activeTabName = useAppSelector(activeTabNameSelector); const activeTabName = useAppSelector(activeTabNameSelector);
const enabledTabs = useAppSelector(enabledTabsSelector); const enabledTabs = useAppSelector(enabledTabsSelector);
const { shouldPinGallery, shouldPinParametersPanel, shouldShowGallery } =
useAppSelector((state: RootState) => state.ui);
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); 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>) => { const handleClickTab = useCallback((e: MouseEvent<HTMLElement>) => {
if (e.target instanceof HTMLElement) { if (e.target instanceof HTMLElement) {
e.target.blur(); e.target.blur();
@ -153,9 +139,6 @@ const InvokeTabs = () => {
[enabledTabs] [enabledTabs]
); );
const { ref: galleryPanelRef, minSizePct: galleryMinSizePct } =
useMinimumPanelSize(MIN_GALLERY_WIDTH, DEFAULT_GALLERY_PCT, 'app');
const handleTabChange = useCallback( const handleTabChange = useCallback(
(index: number) => { (index: number) => {
const activeTabName = tabMap[index]; const activeTabName = tabMap[index];
@ -167,6 +150,60 @@ const InvokeTabs = () => {
[dispatch] [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 ( return (
<Tabs <Tabs
variant="appTabs" variant="appTabs"
@ -195,33 +232,64 @@ const InvokeTabs = () => {
autoSaveId="app" autoSaveId="app"
direction="horizontal" direction="horizontal"
style={{ height: '100%', width: '100%' }} 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 style={{ height: '100%', width: '100%' }}>
{tabPanels} {tabPanels}
</TabPanels> </TabPanels>
</Panel> </Panel>
{shouldPinGallery && {!NO_GALLERY_TABS.includes(activeTabName) && (
shouldShowGallery &&
!NO_GALLERY_TABS.includes(activeTabName) && (
<> <>
<ResizeHandle /> <ResizeHandle
onDoubleClick={resetGalleryPanel}
// isCollapsed={isGalleryPanelCollapsed}
collapsedDirection={isGalleryPanelCollapsed ? 'right' : undefined}
/>
<Panel <Panel
ref={galleryPanelRef}
onResize={handleResizeGallery}
id="gallery" id="gallery"
order={3} ref={galleryPanelRef}
defaultSize={ order={2}
galleryMinSizePct > DEFAULT_GALLERY_PCT && defaultSize={galleryPanelMinSize}
galleryMinSizePct < 100 // prevent this error https://github.com/bvaughn/react-resizable-panels/blob/main/packages/react-resizable-panels/src/Panel.ts#L96 minSize={galleryPanelMinSize}
? galleryMinSizePct onCollapse={setIsGalleryPanelCollapsed}
: DEFAULT_GALLERY_PCT collapsible
}
minSize={galleryMinSizePct}
maxSize={50}
> >
<ImageGalleryContent /> <ImageGalleryContent />
</Panel> </Panel>
<FloatingGalleryButton
isGalleryCollapsed={isGalleryPanelCollapsed}
galleryPanelRef={galleryPanelRef}
/>
</> </>
)} )}
</PanelGroup> </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 { Box } 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 InitialImageDisplay from 'features/parameters/components/Parameters/ImageToImage/InitialImageDisplay'; 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 { memo, useCallback, useRef } from 'react';
import { import {
ImperativePanelGroupHandle, ImperativePanelGroupHandle,
Panel, Panel,
PanelGroup, PanelGroup,
} from 'react-resizable-panels'; } from 'react-resizable-panels';
import ParametersPinnedWrapper from '../../ParametersPinnedWrapper';
import ResizeHandle from '../ResizeHandle'; import ResizeHandle from '../ResizeHandle';
import TextToImageTabMain from '../TextToImage/TextToImageTabMain'; import TextToImageTabMain from '../TextToImage/TextToImageTabMain';
import ImageToImageTabParameters from './ImageToImageTabParameters';
const ImageToImageTab = () => { const ImageToImageTab = () => {
const dispatch = useAppDispatch();
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null); const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
const model = useAppSelector((state: RootState) => state.generation.model);
const handleDoubleClickHandle = useCallback(() => { const handleDoubleClickHandle = useCallback(() => {
if (!panelGroupRef.current) { if (!panelGroupRef.current) {
return; return;
} }
panelGroupRef.current.setLayout([50, 50]); panelGroupRef.current.setLayout([50, 50]);
}, []); }, []);
const panelStorage = usePanelStorage();
return ( return (
<Flex sx={{ gap: 4, w: 'full', h: 'full' }}>
<ParametersPinnedWrapper>
{model && model.base_model === 'sdxl' ? (
<SDXLImageToImageTabParameters />
) : (
<ImageToImageTabParameters />
)}
</ParametersPinnedWrapper>
<Box sx={{ w: 'full', h: 'full' }}> <Box sx={{ w: 'full', h: 'full' }}>
<PanelGroup <PanelGroup
ref={panelGroupRef} ref={panelGroupRef}
autoSaveId="imageTab.content" autoSaveId="imageTab.content"
direction="horizontal" direction="horizontal"
style={{ height: '100%', width: '100%' }} style={{ height: '100%', width: '100%' }}
storage={panelStorage}
units="percentages"
> >
<Panel <Panel
id="imageTab.content.initImage" id="imageTab.content.initImage"
@ -59,15 +47,11 @@ const ImageToImageTab = () => {
order={1} order={1}
defaultSize={50} defaultSize={50}
minSize={25} minSize={25}
onResize={() => {
dispatch(requestCanvasRescale());
}}
> >
<TextToImageTabMain /> <TextToImageTabMain />
</Panel> </Panel>
</PanelGroup> </PanelGroup>
</Box> </Box>
</Flex>
); );
}; };

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