mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-22 02:18:03 -05:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cde1a2967 | ||
|
|
8a6a88742c | ||
|
|
12d9862c4e | ||
|
|
a3614b73b5 | ||
|
|
8f2af4aedd | ||
|
|
de231d4e0f | ||
|
|
a5218d1a74 | ||
|
|
546cb23071 | ||
|
|
b297892734 | ||
|
|
1e16b92cf6 | ||
|
|
019a7ebc66 | ||
|
|
c310ccdbae | ||
|
|
b1d181b74f | ||
|
|
4ea3ddaf16 | ||
|
|
4e5eacedce | ||
|
|
3aa0c500ec | ||
|
|
e329f5ad43 | ||
|
|
e6ad91bf89 | ||
|
|
2f586416a5 | ||
|
|
33b56f421c | ||
|
|
e58ee4c492 | ||
|
|
49691aa07e | ||
|
|
56570f235f | ||
|
|
a2d95cf5b6 | ||
|
|
704dbfd04a | ||
|
|
5d9e078043 | ||
|
|
875cde13ae | ||
|
|
77655aed86 | ||
|
|
0628b92d63 | ||
|
|
9e526d00c2 | ||
|
|
1a24396be8 | ||
|
|
d97e73a565 | ||
|
|
55b14c8aaf | ||
|
|
79f65e57eb | ||
|
|
b4c8950278 | ||
|
|
400b2e9a55 | ||
|
|
3a687c583a | ||
|
|
833950078d | ||
|
|
e698dcb148 | ||
|
|
218386e077 | ||
|
|
4426be9e64 | ||
|
|
86f4cf7857 | ||
|
|
49ae66d94a | ||
|
|
c10865c7ef | ||
|
|
f3478a189a | ||
|
|
43db29176a | ||
|
|
f38922929c | ||
|
|
7d02c58f86 | ||
|
|
6edce8be87 | ||
|
|
31f63e38bd | ||
|
|
78a68ac3a7 | ||
|
|
8cd3bcd1c0 | ||
|
|
264cc5ef46 | ||
|
|
8bfbea5ed3 | ||
|
|
f06a66da07 | ||
|
|
337cae9b22 | ||
|
|
bf926bb7d5 | ||
|
|
18ad9a6af3 | ||
|
|
b6ed31c222 | ||
|
|
200beb5af5 | ||
|
|
f82a948bdd | ||
|
|
dd03e3ddcd | ||
|
|
7561b73e8f | ||
|
|
caa97608c7 | ||
|
|
72a6d1edc1 | ||
|
|
b8bf89c2f1 | ||
|
|
a1ade2b8c0 | ||
|
|
4bdcae1f8f | ||
|
|
4b22c84407 | ||
|
|
c9daf1db30 | ||
|
|
06d3cfbe97 | ||
|
|
71e4901313 |
26
.github/ISSUE_TEMPLATE/BUG_REPORT.yml
vendored
26
.github/ISSUE_TEMPLATE/BUG_REPORT.yml
vendored
@@ -21,6 +21,20 @@ body:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: install_method
|
||||
attributes:
|
||||
label: Install method
|
||||
description: How did you install Invoke?
|
||||
multiple: false
|
||||
options:
|
||||
- "Invoke's Launcher"
|
||||
- 'Stability Matrix'
|
||||
- 'Pinokio'
|
||||
- 'Manual'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: __Describe your environment__
|
||||
@@ -76,8 +90,8 @@ body:
|
||||
attributes:
|
||||
label: Version number
|
||||
description: |
|
||||
The version of Invoke you have installed. If it is not the latest version, please update and try again to confirm the issue still exists. If you are testing main, please include the commit hash instead.
|
||||
placeholder: ex. 3.6.1
|
||||
The version of Invoke you have installed. If it is not the [latest version](https://github.com/invoke-ai/InvokeAI/releases/latest), please update and try again to confirm the issue still exists. If you are testing main, please include the commit hash instead.
|
||||
placeholder: ex. v6.0.2
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -85,17 +99,17 @@ body:
|
||||
id: browser-version
|
||||
attributes:
|
||||
label: Browser
|
||||
description: Your web browser and version.
|
||||
description: Your web browser and version, if you do not use the Launcher's provided GUI.
|
||||
placeholder: ex. Firefox 123.0b3
|
||||
validations:
|
||||
required: true
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: python-deps
|
||||
attributes:
|
||||
label: Python dependencies
|
||||
label: System Information
|
||||
description: |
|
||||
If the problem occurred during image generation, click the gear icon at the bottom left corner, click "About", click the copy button and then paste here.
|
||||
Click the gear icon at the bottom left corner, then click "About". Click the copy button and then paste here.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -190,3 +190,5 @@ installer/update.bat
|
||||
installer/update.sh
|
||||
installer/InvokeAI-Installer/
|
||||
.aider*
|
||||
|
||||
.claude/
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
FROM docker.io/node:22-slim AS web-builder
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack use pnpm@8.x
|
||||
RUN corepack enable
|
||||
RUN corepack use pnpm@10.x && corepack enable
|
||||
|
||||
WORKDIR /build
|
||||
COPY invokeai/frontend/web/ ./
|
||||
|
||||
@@ -41,7 +41,7 @@ If you just want to use Invoke, you should use the [launcher][launcher link].
|
||||
With the modifications made, the install command should look something like this:
|
||||
|
||||
```sh
|
||||
uv pip install -e ".[dev,test,docs,xformers]" --python 3.12 --python-preference only-managed --index=https://download.pytorch.org/whl/cu126 --reinstall
|
||||
uv pip install -e ".[dev,test,docs,xformers]" --python 3.12 --python-preference only-managed --index=https://download.pytorch.org/whl/cu128 --reinstall
|
||||
```
|
||||
|
||||
6. At this point, you should have Invoke installed, a venv set up and activated, and the server running. But you will see a warning in the terminal that no UI was found. If you go to the URL for the server, you won't get a UI.
|
||||
@@ -50,11 +50,11 @@ If you just want to use Invoke, you should use the [launcher][launcher link].
|
||||
|
||||
If you only want to edit the docs, you can stop here and skip to the **Documentation** section below.
|
||||
|
||||
7. Install the frontend dev toolchain:
|
||||
7. Install the frontend dev toolchain, paying attention to versions:
|
||||
|
||||
- [`nodejs`](https://nodejs.org/) (v20+)
|
||||
- [`nodejs`](https://nodejs.org/) (tested on LTS, v22)
|
||||
|
||||
- [`pnpm`](https://pnpm.io/8.x/installation) (must be v8 - not v9!)
|
||||
- [`pnpm`](https://pnpm.io/installation) (tested on v10)
|
||||
|
||||
8. Do a production build of the frontend:
|
||||
|
||||
|
||||
@@ -297,7 +297,7 @@ Migration logic is in [migrations.ts].
|
||||
<!-- links -->
|
||||
|
||||
[pydantic]: https://github.com/pydantic/pydantic 'pydantic'
|
||||
[zod]: https://github.com/colinhacks/zod 'zod/v4'
|
||||
[zod]: https://github.com/colinhacks/zod 'zod'
|
||||
[openapi-types]: https://github.com/kogosoftwarellc/open-api/tree/main/packages/openapi-types 'openapi-types'
|
||||
[reactflow]: https://github.com/xyflow/xyflow 'reactflow'
|
||||
[reactflow-concepts]: https://reactflow.dev/learn/concepts/terms-and-definitions
|
||||
|
||||
@@ -430,6 +430,15 @@ class FluxConditioningOutput(BaseInvocationOutput):
|
||||
return cls(conditioning=FluxConditioningField(conditioning_name=conditioning_name))
|
||||
|
||||
|
||||
@invocation_output("flux_conditioning_collection_output")
|
||||
class FluxConditioningCollectionOutput(BaseInvocationOutput):
|
||||
"""Base class for nodes that output a collection of conditioning tensors"""
|
||||
|
||||
collection: list[FluxConditioningField] = OutputField(
|
||||
description="The output conditioning tensors",
|
||||
)
|
||||
|
||||
|
||||
@invocation_output("sd3_conditioning_output")
|
||||
class SD3ConditioningOutput(BaseInvocationOutput):
|
||||
"""Base class for nodes that output a single SD3 conditioning tensor"""
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
dist/
|
||||
static/
|
||||
.husky/
|
||||
node_modules/
|
||||
patches/
|
||||
stats.html
|
||||
index.html
|
||||
.yarn/
|
||||
*.scss
|
||||
src/services/api/schema.ts
|
||||
@@ -1,88 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ['@invoke-ai/eslint-config-react'],
|
||||
plugins: ['path', 'i18next'],
|
||||
rules: {
|
||||
// TODO(psyche): Enable this rule. Requires no default exports in components - many changes.
|
||||
'react-refresh/only-export-components': 'off',
|
||||
// TODO(psyche): Enable this rule. Requires a lot of eslint-disable-next-line comments.
|
||||
'@typescript-eslint/consistent-type-assertions': 'off',
|
||||
// https://github.com/qdanik/eslint-plugin-path
|
||||
'path/no-relative-imports': ['error', { maxDepth: 0 }],
|
||||
// https://github.com/edvardchen/eslint-plugin-i18next/blob/HEAD/docs/rules/no-literal-string.md
|
||||
// TODO: ENABLE THIS RULE BEFORE v6.0.0
|
||||
// 'i18next/no-literal-string': 'error',
|
||||
// https://eslint.org/docs/latest/rules/no-console
|
||||
'no-console': 'warn',
|
||||
// https://eslint.org/docs/latest/rules/no-promise-executor-return
|
||||
'no-promise-executor-return': 'error',
|
||||
// https://eslint.org/docs/latest/rules/require-await
|
||||
'require-await': 'error',
|
||||
// Restrict setActiveTab calls to only use-navigation-api.tsx
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'CallExpression[callee.name="setActiveTab"]',
|
||||
message:
|
||||
'setActiveTab() can only be called from use-navigation-api.tsx. Use navigationApi.switchToTab() instead.',
|
||||
},
|
||||
],
|
||||
// TODO: ENABLE THIS RULE BEFORE v6.0.0
|
||||
'react/display-name': 'off',
|
||||
'no-restricted-properties': [
|
||||
'error',
|
||||
{
|
||||
object: 'crypto',
|
||||
property: 'randomUUID',
|
||||
message: 'Use of crypto.randomUUID is not allowed as it is not available in all browsers.',
|
||||
},
|
||||
{
|
||||
object: 'navigator',
|
||||
property: 'clipboard',
|
||||
message:
|
||||
'The Clipboard API is not available by default in Firefox. Use the `useClipboard` hook instead, which wraps clipboard access to prevent errors.',
|
||||
},
|
||||
],
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: 'lodash-es',
|
||||
importNames: ['isEqual'],
|
||||
message: 'Please use objectEquals from @observ33r/object-equals instead.',
|
||||
},
|
||||
{
|
||||
name: 'lodash-es',
|
||||
message: 'Please use es-toolkit instead.',
|
||||
},
|
||||
{
|
||||
name: 'es-toolkit',
|
||||
importNames: ['isEqual'],
|
||||
message: 'Please use objectEquals from @observ33r/object-equals instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
/**
|
||||
* Allow setActiveTab calls only in use-navigation-api.tsx
|
||||
*/
|
||||
{
|
||||
files: ['**/use-navigation-api.tsx'],
|
||||
rules: {
|
||||
'no-restricted-syntax': 'off',
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Overrides for stories
|
||||
*/
|
||||
{
|
||||
files: ['*.stories.tsx'],
|
||||
rules: {
|
||||
// We may not have i18n available in stories.
|
||||
'i18next/no-literal-string': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -14,3 +14,4 @@ static/
|
||||
src/theme/css/overlayscrollbars.css
|
||||
src/theme_/css/overlayscrollbars.css
|
||||
pnpm-lock.yaml
|
||||
.claude
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
module.exports = {
|
||||
...require('@invoke-ai/prettier-config-react'),
|
||||
overrides: [
|
||||
{
|
||||
files: ['public/locales/*.json'],
|
||||
options: {
|
||||
tabWidth: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
17
invokeai/frontend/web/.prettierrc.json
Normal file
17
invokeai/frontend/web/.prettierrc.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/prettierrc",
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"endOfLine": "auto",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["public/locales/*.json"],
|
||||
"options": {
|
||||
"tabWidth": 4
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,21 +1,23 @@
|
||||
import { PropsWithChildren, memo, useEffect } from 'react';
|
||||
import { modelChanged } from '../src/features/controlLayers/store/paramsSlice';
|
||||
import { useAppDispatch } from '../src/app/store/storeHooks';
|
||||
import { useGlobalModifiersInit } from '@invoke-ai/ui-library';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { memo, useEffect } from 'react';
|
||||
|
||||
import { useAppDispatch } from '../src/app/store/storeHooks';
|
||||
import { modelChanged } from '../src/features/controlLayers/store/paramsSlice';
|
||||
/**
|
||||
* Initializes some state for storybook. Must be in a different component
|
||||
* so that it is run inside the redux context.
|
||||
*/
|
||||
export const ReduxInit = memo((props: PropsWithChildren) => {
|
||||
export const ReduxInit = memo(({ children }: PropsWithChildren) => {
|
||||
const dispatch = useAppDispatch();
|
||||
useGlobalModifiersInit();
|
||||
useEffect(() => {
|
||||
dispatch(
|
||||
modelChanged({ model: { key: 'test_model', hash: 'some_hash', name: 'some name', base: 'sd-1', type: 'main' } })
|
||||
);
|
||||
}, []);
|
||||
}, [dispatch]);
|
||||
|
||||
return props.children;
|
||||
return children;
|
||||
});
|
||||
|
||||
ReduxInit.displayName = 'ReduxInit';
|
||||
|
||||
@@ -2,19 +2,13 @@ import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-interactions',
|
||||
'@storybook/addon-storysource',
|
||||
],
|
||||
addons: ['@storybook/addon-links', '@storybook/addon-docs'],
|
||||
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: 'tag',
|
||||
},
|
||||
|
||||
core: {
|
||||
disableTelemetry: true,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { addons } from '@storybook/manager-api';
|
||||
import { themes } from '@storybook/theming';
|
||||
import { addons } from 'storybook/manager-api';
|
||||
import { themes } from 'storybook/theming';
|
||||
|
||||
addons.setConfig({
|
||||
theme: themes.dark,
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Preview } from '@storybook/react';
|
||||
import { themes } from '@storybook/theming';
|
||||
import type { Preview } from '@storybook/react-vite';
|
||||
import { themes } from 'storybook/theming';
|
||||
import { $store } from 'app/store/nanostores/store';
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import { Provider } from 'react-redux';
|
||||
import ThemeLocaleProvider from '../src/app/components/ThemeLocaleProvider';
|
||||
import { $baseUrl } from '../src/app/store/nanostores/baseUrl';
|
||||
import { createStore } from '../src/app/store/store';
|
||||
|
||||
// TODO: Disabled for IDE performance issues with our translation JSON
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import translationEN from '../public/locales/en.json';
|
||||
import ThemeLocaleProvider from '../src/app/components/ThemeLocaleProvider';
|
||||
import { $baseUrl } from '../src/app/store/nanostores/baseUrl';
|
||||
import { createStore } from '../src/app/store/store';
|
||||
import { ReduxInit } from './ReduxInit';
|
||||
import { $store } from 'app/store/nanostores/store';
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
lng: 'en',
|
||||
@@ -46,6 +47,7 @@ const preview: Preview = {
|
||||
parameters: {
|
||||
docs: {
|
||||
theme: themes.dark,
|
||||
codePanel: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
242
invokeai/frontend/web/eslint.config.mjs
Normal file
242
invokeai/frontend/web/eslint.config.mjs
Normal file
@@ -0,0 +1,242 @@
|
||||
import js from '@eslint/js';
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
||||
import typescriptParser from '@typescript-eslint/parser';
|
||||
import pluginI18Next from 'eslint-plugin-i18next';
|
||||
import pluginImport from 'eslint-plugin-import';
|
||||
import pluginPath from 'eslint-plugin-path';
|
||||
import pluginReact from 'eslint-plugin-react';
|
||||
import pluginReactHooks from 'eslint-plugin-react-hooks';
|
||||
import pluginReactRefresh from 'eslint-plugin-react-refresh';
|
||||
import pluginSimpleImportSort from 'eslint-plugin-simple-import-sort';
|
||||
import pluginStorybook from 'eslint-plugin-storybook';
|
||||
import pluginUnusedImports from 'eslint-plugin-unused-imports';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
GlobalCompositeOperation: 'readonly',
|
||||
RequestInit: 'readonly',
|
||||
},
|
||||
},
|
||||
|
||||
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
||||
|
||||
plugins: {
|
||||
react: pluginReact,
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
'react-hooks': pluginReactHooks,
|
||||
import: pluginImport,
|
||||
'unused-imports': pluginUnusedImports,
|
||||
'simple-import-sort': pluginSimpleImportSort,
|
||||
'react-refresh': pluginReactRefresh.configs.vite,
|
||||
path: pluginPath,
|
||||
i18next: pluginI18Next,
|
||||
storybook: pluginStorybook,
|
||||
},
|
||||
|
||||
rules: {
|
||||
...typescriptEslint.configs.recommended.rules,
|
||||
...pluginReact.configs.recommended.rules,
|
||||
...pluginReact.configs['jsx-runtime'].rules,
|
||||
...pluginReactHooks.configs.recommended.rules,
|
||||
...pluginStorybook.configs.recommended.rules,
|
||||
|
||||
'react/jsx-no-bind': [
|
||||
'error',
|
||||
{
|
||||
allowBind: true,
|
||||
},
|
||||
],
|
||||
|
||||
'react/jsx-curly-brace-presence': [
|
||||
'error',
|
||||
{
|
||||
props: 'never',
|
||||
children: 'never',
|
||||
},
|
||||
],
|
||||
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
|
||||
curly: 'error',
|
||||
'no-var': 'error',
|
||||
'brace-style': 'error',
|
||||
'prefer-template': 'error',
|
||||
radix: 'error',
|
||||
'space-before-blocks': 'error',
|
||||
eqeqeq: 'error',
|
||||
'one-var': ['error', 'never'],
|
||||
'no-eval': 'error',
|
||||
'no-extend-native': 'error',
|
||||
'no-implied-eval': 'error',
|
||||
'no-label-var': 'error',
|
||||
'no-return-assign': 'error',
|
||||
'no-sequences': 'error',
|
||||
'no-template-curly-in-string': 'error',
|
||||
'no-throw-literal': 'error',
|
||||
'no-unmodified-loop-condition': 'error',
|
||||
'import/no-duplicates': 'error',
|
||||
'import/prefer-default-export': 'off',
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
|
||||
'unused-imports/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
vars: 'all',
|
||||
varsIgnorePattern: '^_',
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
|
||||
'simple-import-sort/imports': 'error',
|
||||
'simple-import-sort/exports': 'error',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
|
||||
'@typescript-eslint/ban-ts-comment': [
|
||||
'error',
|
||||
{
|
||||
'ts-expect-error': 'allow-with-description',
|
||||
'ts-ignore': true,
|
||||
'ts-nocheck': true,
|
||||
'ts-check': false,
|
||||
minimumDescriptionLength: 10,
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/no-empty-interface': [
|
||||
'error',
|
||||
{
|
||||
allowSingleExtends: true,
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{
|
||||
prefer: 'type-imports',
|
||||
fixStyle: 'separate-type-imports',
|
||||
disallowTypeAnnotations: true,
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/no-import-type-side-effects': 'error',
|
||||
|
||||
'@typescript-eslint/consistent-type-assertions': [
|
||||
'error',
|
||||
{
|
||||
assertionStyle: 'as',
|
||||
},
|
||||
],
|
||||
|
||||
'path/no-relative-imports': [
|
||||
'error',
|
||||
{
|
||||
maxDepth: 0,
|
||||
},
|
||||
],
|
||||
|
||||
'no-console': 'warn',
|
||||
'no-promise-executor-return': 'error',
|
||||
'require-await': 'error',
|
||||
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'CallExpression[callee.name="setActiveTab"]',
|
||||
message:
|
||||
'setActiveTab() can only be called from use-navigation-api.tsx. Use navigationApi.switchToTab() instead.',
|
||||
},
|
||||
],
|
||||
|
||||
'no-restricted-properties': [
|
||||
'error',
|
||||
{
|
||||
object: 'crypto',
|
||||
property: 'randomUUID',
|
||||
message: 'Use of crypto.randomUUID is not allowed as it is not available in all browsers.',
|
||||
},
|
||||
{
|
||||
object: 'navigator',
|
||||
property: 'clipboard',
|
||||
message:
|
||||
'The Clipboard API is not available by default in Firefox. Use the `useClipboard` hook instead, which wraps clipboard access to prevent errors.',
|
||||
},
|
||||
],
|
||||
|
||||
// Typescript handles this for us: https://eslint.org/docs/latest/rules/no-redeclare#handled_by_typescript
|
||||
'no-redeclare': 'off',
|
||||
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: 'lodash-es',
|
||||
importNames: ['isEqual'],
|
||||
message: 'Please use objectEquals from @observ33r/object-equals instead.',
|
||||
},
|
||||
{
|
||||
name: 'lodash-es',
|
||||
message: 'Please use es-toolkit instead.',
|
||||
},
|
||||
{
|
||||
name: 'es-toolkit',
|
||||
importNames: ['isEqual'],
|
||||
message: 'Please use objectEquals from @observ33r/object-equals instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
files: ['**/use-navigation-api.tsx'],
|
||||
rules: {
|
||||
'no-restricted-syntax': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
files: ['**/*.stories.tsx'],
|
||||
rules: {
|
||||
'i18next/no-literal-string': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
ignores: [
|
||||
'**/dist/',
|
||||
'**/static/',
|
||||
'**/.husky/',
|
||||
'**/node_modules/',
|
||||
'**/patches/',
|
||||
'**/stats.html',
|
||||
'**/index.html',
|
||||
'**/.yarn/',
|
||||
'**/*.scss',
|
||||
'src/services/api/schema.ts',
|
||||
'.prettierrc.js',
|
||||
'.storybook',
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -14,6 +14,7 @@ const config: KnipConfig = {
|
||||
'src/features/controlLayers/konva/util.ts',
|
||||
// Will be using this
|
||||
'src/common/hooks/useAsyncState.ts',
|
||||
'src/app/store/use-debounced-app-selector.ts',
|
||||
],
|
||||
ignoreBinaries: ['only-allow'],
|
||||
paths: {
|
||||
|
||||
@@ -47,25 +47,25 @@
|
||||
"@fontsource-variable/inter": "^5.2.6",
|
||||
"@invoke-ai/ui-library": "^0.0.46",
|
||||
"@nanostores/react": "^1.0.0",
|
||||
"@observ33r/object-equals": "^1.1.4",
|
||||
"@observ33r/object-equals": "^1.1.5",
|
||||
"@reduxjs/toolkit": "2.8.2",
|
||||
"@roarr/browser-log-writer": "^1.3.0",
|
||||
"@xyflow/react": "^12.7.1",
|
||||
"ag-psd": "^28.2.1",
|
||||
"@xyflow/react": "^12.8.2",
|
||||
"ag-psd": "^28.2.2",
|
||||
"async-mutex": "^0.5.0",
|
||||
"chakra-react-select": "^4.9.2",
|
||||
"cmdk": "^1.1.1",
|
||||
"compare-versions": "^6.1.1",
|
||||
"dockview": "^4.4.0",
|
||||
"es-toolkit": "^1.39.5",
|
||||
"dockview": "^4.4.1",
|
||||
"es-toolkit": "^1.39.7",
|
||||
"filesize": "^10.1.6",
|
||||
"fracturedjsonjs": "^4.1.0",
|
||||
"framer-motion": "^11.10.0",
|
||||
"i18next": "^25.2.1",
|
||||
"i18next": "^25.3.2",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"idb-keyval": "6.2.1",
|
||||
"idb-keyval": "6.2.2",
|
||||
"jsondiffpatch": "^0.7.3",
|
||||
"konva": "^9.3.20",
|
||||
"konva": "^9.3.22",
|
||||
"linkify-react": "^4.3.1",
|
||||
"linkifyjs": "^4.3.1",
|
||||
"lru-cache": "^11.1.0",
|
||||
@@ -83,7 +83,7 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-hook-form": "^7.58.1",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-hotkeys-hook": "4.5.0",
|
||||
"react-i18next": "^15.5.3",
|
||||
"react-icons": "^5.5.0",
|
||||
@@ -103,7 +103,7 @@
|
||||
"use-debounce": "^10.0.5",
|
||||
"use-device-pixel-ratio": "^1.1.2",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.25.67",
|
||||
"zod": "^4.0.5",
|
||||
"zod-validation-error": "^3.5.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -111,39 +111,43 @@
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@invoke-ai/eslint-config-react": "^0.0.14",
|
||||
"@invoke-ai/prettier-config-react": "^0.0.7",
|
||||
"@storybook/addon-essentials": "^8.6.12",
|
||||
"@storybook/addon-interactions": "^8.6.12",
|
||||
"@storybook/addon-links": "^8.6.12",
|
||||
"@storybook/addon-storysource": "^8.6.12",
|
||||
"@storybook/manager-api": "^8.6.12",
|
||||
"@storybook/react": "^8.6.12",
|
||||
"@storybook/react-vite": "^8.6.12",
|
||||
"@storybook/theming": "^8.6.12",
|
||||
"@eslint/js": "^9.31.0",
|
||||
"@storybook/addon-docs": "^9.0.17",
|
||||
"@storybook/addon-links": "^9.0.17",
|
||||
"@storybook/react-vite": "^9.0.17",
|
||||
"@types/node": "^22.15.1",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
||||
"@typescript-eslint/parser": "^8.37.0",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"@vitest/coverage-v8": "^3.1.2",
|
||||
"@vitest/ui": "^3.1.2",
|
||||
"concurrently": "^9.1.2",
|
||||
"csstype": "^3.1.3",
|
||||
"dpdm": "^3.14.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-i18next": "^6.1.1",
|
||||
"eslint-plugin-path": "^1.3.0",
|
||||
"eslint": "^9.31.0",
|
||||
"eslint-plugin-i18next": "^6.1.2",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-path": "^2.0.3",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"eslint-plugin-storybook": "^9.0.17",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"globals": "^16.3.0",
|
||||
"knip": "^5.61.3",
|
||||
"openapi-types": "^12.1.3",
|
||||
"openapi-typescript": "^7.6.1",
|
||||
"prettier": "^3.5.3",
|
||||
"rollup-plugin-visualizer": "^5.14.0",
|
||||
"storybook": "^8.6.12",
|
||||
"rollup-plugin-visualizer": "^6.0.3",
|
||||
"storybook": "^9.0.17",
|
||||
"tsafe": "^1.8.5",
|
||||
"type-fest": "^4.40.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.2",
|
||||
"vite": "^7.0.5",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2",
|
||||
"vite-plugin-dts": "^4.5.3",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
|
||||
2182
invokeai/frontend/web/pnpm-lock.yaml
generated
2182
invokeai/frontend/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1775,6 +1775,20 @@
|
||||
"Structure controls how closely the output image will keep to the layout of the original. Low structure allows major changes, while high structure strictly maintains the original composition and layout."
|
||||
]
|
||||
},
|
||||
"tileSize": {
|
||||
"heading": "Tile Size",
|
||||
"paragraphs": [
|
||||
"Controls the size of tiles used during the upscaling process. Larger tiles use more memory but may produce better results.",
|
||||
"SD1.5 models default to 768, while SDXL models default to 1024. Reduce tile size if you encounter memory issues."
|
||||
]
|
||||
},
|
||||
"tileOverlap": {
|
||||
"heading": "Tile Overlap",
|
||||
"paragraphs": [
|
||||
"Controls the overlap between adjacent tiles during upscaling. Higher overlap values help reduce visible seams between tiles but use more memory.",
|
||||
"The default value of 128 works well for most cases, but you can adjust based on your specific needs and memory constraints."
|
||||
]
|
||||
},
|
||||
"fluxDevLicense": {
|
||||
"heading": "Non-Commercial License",
|
||||
"paragraphs": [
|
||||
@@ -1962,7 +1976,6 @@
|
||||
"recalculateRects": "Recalculate Rects",
|
||||
"clipToBbox": "Clip Strokes to Bbox",
|
||||
"outputOnlyMaskedRegions": "Output Only Generated Regions",
|
||||
"saveAllImagesToGallery": "Save All Images to Gallery",
|
||||
"addLayer": "Add Layer",
|
||||
"duplicate": "Duplicate",
|
||||
"moveToFront": "Move to Front",
|
||||
@@ -2087,9 +2100,9 @@
|
||||
"resetCanvasLayers": "Reset Canvas Layers",
|
||||
"resetGenerationSettings": "Reset Generation Settings",
|
||||
"replaceCurrent": "Replace Current",
|
||||
"controlLayerEmptyState": "<UploadButton>Upload an image</UploadButton>, drag an image from the <GalleryButton>gallery</GalleryButton> onto this layer, <PullBboxButton>pull the bounding box into this layer</PullBboxButton>, or draw on the canvas to get started.",
|
||||
"referenceImageEmptyStateWithCanvasOptions": "<UploadButton>Upload an image</UploadButton>, drag an image from the <GalleryButton>gallery</GalleryButton> onto this Reference Image or <PullBboxButton>pull the bounding box into this Reference Image</PullBboxButton> to get started.",
|
||||
"referenceImageEmptyState": "<UploadButton>Upload an image</UploadButton> or drag an image from the <GalleryButton>gallery</GalleryButton> onto this Reference Image to get started.",
|
||||
"controlLayerEmptyState": "<UploadButton>Upload an image</UploadButton>, drag an image from the gallery onto this layer, <PullBboxButton>pull the bounding box into this layer</PullBboxButton>, or draw on the canvas to get started.",
|
||||
"referenceImageEmptyStateWithCanvasOptions": "<UploadButton>Upload an image</UploadButton>, drag an image from the gallery onto this Reference Image or <PullBboxButton>pull the bounding box into this Reference Image</PullBboxButton> to get started.",
|
||||
"referenceImageEmptyState": "<UploadButton>Upload an image</UploadButton> or drag an image from the gallery onto this Reference Image to get started.",
|
||||
"uploadOrDragAnImage": "Drag an image from the gallery or <UploadButton>upload an image</UploadButton>.",
|
||||
"imageNoise": "Image Noise",
|
||||
"denoiseLimit": "Denoise Limit",
|
||||
@@ -2332,7 +2345,8 @@
|
||||
"alert": "Preserving Masked Region"
|
||||
},
|
||||
"saveAllImagesToGallery": {
|
||||
"alert": "Saving All Images to Gallery"
|
||||
"label": "Send New Generations to Gallery",
|
||||
"alert": "Sending new generations to Gallery, bypassing Canvas"
|
||||
},
|
||||
"isolatedStagingPreview": "Isolated Staging Preview",
|
||||
"isolatedPreview": "Isolated Preview",
|
||||
@@ -2396,6 +2410,9 @@
|
||||
"upscaleModel": "Upscale Model",
|
||||
"postProcessingModel": "Post-Processing Model",
|
||||
"scale": "Scale",
|
||||
"tileControl": "Tile Control",
|
||||
"tileSize": "Tile Size",
|
||||
"tileOverlap": "Tile Overlap",
|
||||
"postProcessingMissingModelWarning": "Visit the <LinkComponent>Model Manager</LinkComponent> to install a post-processing (image to image) model.",
|
||||
"missingModelsWarning": "Visit the <LinkComponent>Model Manager</LinkComponent> to install the required models:",
|
||||
"mainModelDesc": "Main model (SD1.5 or SDXL architecture)",
|
||||
|
||||
@@ -30,16 +30,16 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
|
||||
}, [clearStorage]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary onReset={handleReset} FallbackComponent={AppErrorBoundaryFallback}>
|
||||
<ThemeLocaleProvider>
|
||||
<ThemeLocaleProvider>
|
||||
<ErrorBoundary onReset={handleReset} FallbackComponent={AppErrorBoundaryFallback}>
|
||||
<Box id="invoke-app-wrapper" w="100dvw" h="100dvh" position="relative" overflow="hidden">
|
||||
<AppContent />
|
||||
{!didStudioInit && <Loading />}
|
||||
</Box>
|
||||
<GlobalHookIsolator config={config} studioInitAction={studioInitAction} />
|
||||
<GlobalModalIsolator />
|
||||
</ThemeLocaleProvider>
|
||||
</ErrorBoundary>
|
||||
</ErrorBoundary>
|
||||
</ThemeLocaleProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useGlobalModifiersInit } from '@invoke-ai/ui-library';
|
||||
import { setupListeners } from '@reduxjs/toolkit/query';
|
||||
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
|
||||
import { useStudioInitAction } from 'app/hooks/useStudioInitAction';
|
||||
import { useSyncLangDirection } from 'app/hooks/useSyncLangDirection';
|
||||
import { useSyncQueueStatus } from 'app/hooks/useSyncQueueStatus';
|
||||
import { useLogger } from 'app/logging/useLogger';
|
||||
import { useSyncLoggingConfig } from 'app/logging/useSyncLoggingConfig';
|
||||
@@ -15,6 +16,8 @@ import { useDndMonitor } from 'features/dnd/useDndMonitor';
|
||||
import { useDynamicPromptsWatcher } from 'features/dynamicPrompts/hooks/useDynamicPromptsWatcher';
|
||||
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
|
||||
import { useWorkflowBuilderWatcher } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
|
||||
import { useSyncExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
|
||||
import { useSyncNodeErrors } from 'features/nodes/store/util/fieldValidators';
|
||||
import { useReadinessWatcher } from 'features/queue/store/readiness';
|
||||
import { configChanged } from 'features/system/store/configSlice';
|
||||
import { selectLanguage } from 'features/system/store/systemSelectors';
|
||||
@@ -47,10 +50,13 @@ export const GlobalHookIsolator = memo(
|
||||
useCloseChakraTooltipsOnDragFix();
|
||||
useNavigationApi();
|
||||
useDndMonitor();
|
||||
useSyncNodeErrors();
|
||||
useSyncLangDirection();
|
||||
|
||||
// Persistent subscription to the queue counts query - canvas relies on this to know if there are pending
|
||||
// and/or in progress canvas sessions.
|
||||
useGetQueueCountsByDestinationQuery(queueCountArg);
|
||||
useSyncExecutionState();
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language);
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { GlobalImageHotkeys } from 'app/components/GlobalImageHotkeys';
|
||||
import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal';
|
||||
import { CanvasPasteModal } from 'features/controlLayers/components/CanvasPasteModal';
|
||||
import {
|
||||
NewCanvasSessionDialog,
|
||||
NewGallerySessionDialog,
|
||||
} from 'features/controlLayers/components/NewSessionConfirmationAlertDialog';
|
||||
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { DeleteImageModal } from 'features/deleteImageModal/components/DeleteImageModal';
|
||||
import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone';
|
||||
@@ -50,8 +46,6 @@ export const GlobalModalIsolator = memo(() => {
|
||||
<RefreshAfterResetModal />
|
||||
<DeleteBoardModal />
|
||||
<GlobalImageHotkeys />
|
||||
<NewGallerySessionDialog />
|
||||
<NewCanvasSessionDialog />
|
||||
<ImageContextMenu />
|
||||
<FullscreenDropzone />
|
||||
<VideosModal />
|
||||
|
||||
@@ -317,7 +317,7 @@ const InvokeAIUI = ({
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
window.$store = $store;
|
||||
}
|
||||
() => {
|
||||
return () => {
|
||||
$store.set(undefined);
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
window.$store = undefined;
|
||||
|
||||
@@ -3,43 +3,39 @@ import 'overlayscrollbars/overlayscrollbars.css';
|
||||
import '@xyflow/react/dist/base.css';
|
||||
import 'common/components/OverlayScrollbars/overlayscrollbars.css';
|
||||
|
||||
import { ChakraProvider, DarkMode, extendTheme, theme as _theme, TOAST_OPTIONS } from '@invoke-ai/ui-library';
|
||||
import { ChakraProvider, DarkMode, extendTheme, theme as baseTheme, TOAST_OPTIONS } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $direction } from 'app/hooks/useSyncLangDirection';
|
||||
import type { ReactNode } from 'react';
|
||||
import { memo, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
type ThemeLocaleProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const buildTheme = (direction: 'ltr' | 'rtl') => {
|
||||
return extendTheme({
|
||||
...baseTheme,
|
||||
direction,
|
||||
shadows: {
|
||||
...baseTheme.shadows,
|
||||
selected:
|
||||
'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
|
||||
hoverSelected:
|
||||
'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
|
||||
hoverUnselected:
|
||||
'inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-800)',
|
||||
selectedForCompare:
|
||||
'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
|
||||
hoverSelectedForCompare:
|
||||
'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
function ThemeLocaleProvider({ children }: ThemeLocaleProviderProps) {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const direction = i18n.dir();
|
||||
|
||||
const theme = useMemo(() => {
|
||||
return extendTheme({
|
||||
..._theme,
|
||||
direction,
|
||||
shadows: {
|
||||
..._theme.shadows,
|
||||
selected:
|
||||
'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
|
||||
hoverSelected:
|
||||
'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
|
||||
hoverUnselected:
|
||||
'inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-800)',
|
||||
selectedForCompare:
|
||||
'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
|
||||
hoverSelectedForCompare:
|
||||
'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
|
||||
},
|
||||
});
|
||||
}, [direction]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.dir = direction;
|
||||
}, [direction]);
|
||||
const direction = useStore($direction);
|
||||
const theme = useMemo(() => buildTheme(direction), [direction]);
|
||||
|
||||
return (
|
||||
<ChakraProvider theme={theme} toastOptions={TOAST_OPTIONS}>
|
||||
|
||||
@@ -21,7 +21,6 @@ import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/st
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { navigationApi } from 'features/ui/layouts/navigation-api';
|
||||
import { LAUNCHPAD_PANEL_ID, WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
|
||||
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
|
||||
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
|
||||
import { atom } from 'nanostores';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
@@ -165,7 +164,6 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
|
||||
// Go to the generate tab, open the launchpad
|
||||
await navigationApi.focusPanel('generate', LAUNCHPAD_PANEL_ID);
|
||||
store.dispatch(paramsReset());
|
||||
store.dispatch(activeTabCanvasRightPanelChanged('gallery'));
|
||||
break;
|
||||
case 'canvas':
|
||||
// Go to the canvas tab, open the launchpad
|
||||
|
||||
36
invokeai/frontend/web/src/app/hooks/useSyncLangDirection.ts
Normal file
36
invokeai/frontend/web/src/app/hooks/useSyncLangDirection.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
|
||||
import { atom } from 'nanostores';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Global atom storing the language direction, to be consumed by the Chakra theme.
|
||||
*
|
||||
* Why do we need this? We have a kind of catch-22:
|
||||
* - The Chakra theme needs to know the language direction to apply the correct styles.
|
||||
* - The language direction is determined by i18n and the language selection.
|
||||
* - We want our error boundary to be themed.
|
||||
* - It's possible that i18n can throw if the language selection is invalid or not supported.
|
||||
*
|
||||
* Previously, we had the logic in this file in the theme provider, which wrapped the error boundary. The error
|
||||
* was properly themed. But then, if i18n threw in the theme provider, the error boundary does not catch the
|
||||
* error. The app would crash to a white screen.
|
||||
*
|
||||
* We tried swapping the component hierarchy so that the error boundary wraps the theme provider, but then the
|
||||
* error boundary isn't themed!
|
||||
*
|
||||
* The solution is to move this i18n direction logic out of the theme provider and into a hook that we can use
|
||||
* within the error boundary. The error boundary will be themed, _and_ catch any i18n errors.
|
||||
*/
|
||||
export const $direction = atom<'ltr' | 'rtl'>('ltr');
|
||||
|
||||
export const useSyncLangDirection = () => {
|
||||
useAssertSingleton('useSyncLangDirection');
|
||||
const { i18n, t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const direction = i18n.dir();
|
||||
$direction.set(direction);
|
||||
document.body.dir = direction;
|
||||
}, [i18n, t]);
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { createLogWriter } from '@roarr/browser-log-writer';
|
||||
import { atom } from 'nanostores';
|
||||
import type { Logger, MessageSerializer } from 'roarr';
|
||||
import { ROARR, Roarr } from 'roarr';
|
||||
import { z } from 'zod/v4';
|
||||
import { z } from 'zod';
|
||||
|
||||
const serializeMessage: MessageSerializer = (message) => {
|
||||
return JSON.stringify(message);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import { bboxSyncedToOptimalDimension, rgRefImageModelChanged } from 'features/controlLayers/store/canvasSlice';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { buildSelectIsStaging, selectCanvasSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { loraDeleted } from 'features/controlLayers/store/lorasSlice';
|
||||
import { modelChanged, syncedToOptimalDimension, vaeSelected } from 'features/controlLayers/store/paramsSlice';
|
||||
import { refImageModelChanged, selectReferenceImageEntities } from 'features/controlLayers/store/refImagesSlice';
|
||||
@@ -152,7 +152,8 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
|
||||
if (modelBase !== state.params.model?.base) {
|
||||
// Sync generate tab settings whenever the model base changes
|
||||
dispatch(syncedToOptimalDimension());
|
||||
if (!selectIsStaging(state)) {
|
||||
const isStaging = buildSelectIsStaging(selectCanvasSessionId(state))(state);
|
||||
if (!isStaging) {
|
||||
// Canvas tab only syncs if not staging
|
||||
dispatch(bboxSyncedToOptimalDimension());
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ import { refImageModelChanged, selectRefImagesSlice } from 'features/controlLaye
|
||||
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
|
||||
import { getEntityIdentifier, isFLUXReduxConfig, isIPAdapterConfig } from 'features/controlLayers/store/types';
|
||||
import { modelSelected } from 'features/parameters/store/actions';
|
||||
import { postProcessingModelChanged, upscaleModelChanged } from 'features/parameters/store/upscaleSlice';
|
||||
import {
|
||||
postProcessingModelChanged,
|
||||
tileControlnetModelChanged,
|
||||
upscaleModelChanged,
|
||||
} from 'features/parameters/store/upscaleSlice';
|
||||
import {
|
||||
zParameterCLIPEmbedModel,
|
||||
zParameterSpandrelImageToImageModel,
|
||||
@@ -28,6 +32,7 @@ import type { AnyModelConfig } from 'services/api/types';
|
||||
import {
|
||||
isCLIPEmbedModelConfig,
|
||||
isControlLayerModelConfig,
|
||||
isControlNetModelConfig,
|
||||
isFluxReduxModelConfig,
|
||||
isFluxVAEModelConfig,
|
||||
isIPAdapterModelConfig,
|
||||
@@ -71,6 +76,7 @@ export const addModelsLoadedListener = (startAppListening: AppStartListening) =>
|
||||
handleControlAdapterModels(models, state, dispatch, log);
|
||||
handlePostProcessingModel(models, state, dispatch, log);
|
||||
handleUpscaleModel(models, state, dispatch, log);
|
||||
handleTileControlNetModel(models, state, dispatch, log);
|
||||
handleIPAdapterModels(models, state, dispatch, log);
|
||||
handleT5EncoderModels(models, state, dispatch, log);
|
||||
handleCLIPEmbedModels(models, state, dispatch, log);
|
||||
@@ -345,6 +351,46 @@ const handleUpscaleModel: ModelHandler = (models, state, dispatch, log) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTileControlNetModel: ModelHandler = (models, state, dispatch, log) => {
|
||||
const selectedTileControlNetModel = state.upscale.tileControlnetModel;
|
||||
const controlNetModels = models.filter(isControlNetModelConfig);
|
||||
|
||||
// If the currently selected model is available, we don't need to do anything
|
||||
if (selectedTileControlNetModel && controlNetModels.some((m) => m.key === selectedTileControlNetModel.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The only way we have to identify a model as a tile model is by its name containing 'tile' :)
|
||||
const tileModel = controlNetModels.find((m) => m.name.toLowerCase().includes('tile'));
|
||||
|
||||
// If we have a tile model, select it
|
||||
if (tileModel) {
|
||||
log.debug(
|
||||
{ selectedTileControlNetModel, tileModel },
|
||||
'No selected tile ControlNet model or selected model is not available, selecting tile model'
|
||||
);
|
||||
dispatch(tileControlnetModelChanged(tileModel));
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, select the first available ControlNet model
|
||||
const firstModel = controlNetModels[0] || null;
|
||||
if (firstModel) {
|
||||
log.debug(
|
||||
{ selectedTileControlNetModel, firstModel },
|
||||
'No tile ControlNet model found, selecting first available ControlNet model'
|
||||
);
|
||||
dispatch(tileControlnetModelChanged(firstModel));
|
||||
return;
|
||||
}
|
||||
|
||||
// No available models, we should clear the selected model - but only if we have one selected
|
||||
if (selectedTileControlNetModel) {
|
||||
log.debug({ selectedTileControlNetModel }, 'Selected tile ControlNet model is not available, clearing');
|
||||
dispatch(tileControlnetModelChanged(null));
|
||||
}
|
||||
};
|
||||
|
||||
const handleT5EncoderModels: ModelHandler = (models, state, dispatch, log) => {
|
||||
const selectedT5EncoderModel = state.params.t5EncoderModel;
|
||||
const t5EncoderModels = models.filter((m) => isT5EncoderModelConfig(m));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import { isNil } from 'es-toolkit';
|
||||
import { bboxHeightChanged, bboxWidthChanged } from 'features/controlLayers/store/canvasSlice';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { buildSelectIsStaging, selectCanvasSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import {
|
||||
heightChanged,
|
||||
setCfgRescaleMultiplier,
|
||||
@@ -115,7 +115,8 @@ export const addSetDefaultSettingsListener = (startAppListening: AppStartListeni
|
||||
}
|
||||
const setSizeOptions = { updateAspectRatio: true, clamp: true };
|
||||
|
||||
const isStaging = selectIsStaging(getState());
|
||||
const isStaging = buildSelectIsStaging(selectCanvasSessionId(state))(state);
|
||||
|
||||
const activeTab = selectActiveTab(getState());
|
||||
if (activeTab === 'generate') {
|
||||
if (isParameterWidth(width)) {
|
||||
|
||||
@@ -67,6 +67,8 @@ export type Feature =
|
||||
| 'scale'
|
||||
| 'creativity'
|
||||
| 'structure'
|
||||
| 'tileSize'
|
||||
| 'tileOverlap'
|
||||
| 'optimizedDenoising'
|
||||
| 'fluxDevLicense';
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
|
||||
import { selectIsClientSideUploadEnabled } from 'features/system/store/configSlice';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { memo, useCallback } from 'react';
|
||||
import type { FileRejection } from 'react-dropzone';
|
||||
import type { Accept, FileRejection } from 'react-dropzone';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiUploadBold } from 'react-icons/pi';
|
||||
@@ -15,6 +15,18 @@ import type { ImageDTO } from 'services/api/types';
|
||||
import { assert } from 'tsafe';
|
||||
import type { SetOptional } from 'type-fest';
|
||||
|
||||
const addUpperCaseReducer = (acc: string[], ext: string) => {
|
||||
acc.push(ext);
|
||||
acc.push(ext.toUpperCase());
|
||||
return acc;
|
||||
};
|
||||
|
||||
export const dropzoneAccept: Accept = {
|
||||
'image/png': ['.png'].reduce(addUpperCaseReducer, [] as string[]),
|
||||
'image/jpeg': ['.jpg', '.jpeg', '.png'].reduce(addUpperCaseReducer, [] as string[]),
|
||||
'image/webp': ['.webp'].reduce(addUpperCaseReducer, [] as string[]),
|
||||
};
|
||||
|
||||
import { useClientSideUpload } from './useClientSideUpload';
|
||||
type UseImageUploadButtonArgs =
|
||||
| {
|
||||
@@ -164,11 +176,7 @@ export const useImageUploadButton = ({
|
||||
getInputProps: getUploadInputProps,
|
||||
open: openUploader,
|
||||
} = useDropzone({
|
||||
accept: {
|
||||
'image/png': ['.png'],
|
||||
'image/jpeg': ['.jpg', '.jpeg', '.png'],
|
||||
'image/webp': ['.webp'],
|
||||
},
|
||||
accept: dropzoneAccept,
|
||||
onDropAccepted,
|
||||
onDropRejected,
|
||||
disabled: isDisabled,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const preventDefault = (e: React.MouseEvent) => {
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
export const preventDefault = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type React from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
/**
|
||||
* A typed version of React.memo, useful for components that take generics.
|
||||
*/
|
||||
export const typedMemo: <T extends keyof JSX.IntrinsicElements | React.JSXElementConstructor<any>>(
|
||||
export const typedMemo: <T extends keyof React.JSX.IntrinsicElements | React.JSXElementConstructor<any>>(
|
||||
component: T,
|
||||
propsAreEqual?: (prevProps: React.ComponentProps<T>, nextProps: React.ComponentProps<T>) => boolean
|
||||
) => T & { displayName?: string } = memo;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { z } from 'zod/v4';
|
||||
import type { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Helper to create a type guard from a zod schema. The type guard will infer the schema's TS type.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
|
||||
import { Combobox, ConfirmationAlertDialog, Flex, FormControl, Text } from '@invoke-ai/ui-library';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
|
||||
import {
|
||||
@@ -14,7 +13,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
|
||||
import { useAddImagesToBoardMutation, useRemoveImagesFromBoardMutation } from 'services/api/endpoints/images';
|
||||
|
||||
const selectImagesToChange = createMemoizedSelector(
|
||||
const selectImagesToChange = createSelector(
|
||||
selectChangeBoardModalSlice,
|
||||
(changeBoardModal) => changeBoardModal.image_names
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ export const CanvasAlertsSaveAllImagesToGallery = memo(() => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert status="info" borderRadius="base" fontSize="sm" shadow="md" w="fit-content">
|
||||
<Alert status="warning" borderRadius="base" fontSize="sm" shadow="md" w="fit-content">
|
||||
<AlertIcon />
|
||||
<AlertTitle>{t('controlLayers.settings.saveAllImagesToGallery.alert')}</AlertTitle>
|
||||
</Alert>
|
||||
|
||||
@@ -57,21 +57,21 @@ const CanvasAlertsSelectedEntityStatusContent = memo(({ entityIdentifier, adapte
|
||||
const alert = useMemo<AlertData | null>(() => {
|
||||
if (isFiltering) {
|
||||
return {
|
||||
status: 'info',
|
||||
status: 'warning',
|
||||
title: t('controlLayers.HUD.entityStatus.isFiltering', { title }),
|
||||
};
|
||||
}
|
||||
|
||||
if (isTransforming) {
|
||||
return {
|
||||
status: 'info',
|
||||
status: 'warning',
|
||||
title: t('controlLayers.HUD.entityStatus.isTransforming', { title }),
|
||||
};
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return {
|
||||
status: 'info',
|
||||
status: 'warning',
|
||||
title: t('controlLayers.HUD.entityStatus.isEmpty', { title }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useEntityIdentifierContext } from 'features/controlLayers/contexts/Enti
|
||||
import { usePullBboxIntoLayer } from 'features/controlLayers/hooks/saveCanvasHooks';
|
||||
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
|
||||
import { replaceCanvasEntityObjectsWithImage } from 'features/imageActions/actions';
|
||||
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
@@ -21,9 +20,6 @@ export const ControlLayerSettingsEmptyState = memo(() => {
|
||||
[dispatch, entityIdentifier, getState]
|
||||
);
|
||||
const uploadApi = useImageUploadButton({ onUpload, allowMultiple: false });
|
||||
const onClickGalleryButton = useCallback(() => {
|
||||
dispatch(activeTabCanvasRightPanelChanged('gallery'));
|
||||
}, [dispatch]);
|
||||
const pullBboxIntoLayer = usePullBboxIntoLayer(entityIdentifier);
|
||||
|
||||
const components = useMemo(
|
||||
@@ -31,14 +27,11 @@ export const ControlLayerSettingsEmptyState = memo(() => {
|
||||
UploadButton: (
|
||||
<Button isDisabled={isBusy} size="sm" variant="link" color="base.300" {...uploadApi.getUploadButtonProps()} />
|
||||
),
|
||||
GalleryButton: (
|
||||
<Button onClick={onClickGalleryButton} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
|
||||
),
|
||||
PullBboxButton: (
|
||||
<Button onClick={pullBboxIntoLayer} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
|
||||
),
|
||||
}),
|
||||
[isBusy, onClickGalleryButton, pullBboxIntoLayer, uploadApi]
|
||||
[isBusy, pullBboxIntoLayer, uploadApi]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { Checkbox, ConfirmationAlertDialog, Flex, FormControl, FormLabel, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
|
||||
import { buildUseBoolean } from 'common/hooks/useBoolean';
|
||||
import { canvasSessionReset, generateSessionReset } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import {
|
||||
selectSystemShouldConfirmOnNewSession,
|
||||
shouldConfirmOnNewSessionToggled,
|
||||
} from 'features/system/store/systemSlice';
|
||||
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const [useNewGallerySessionDialog] = buildUseBoolean(false);
|
||||
const [useNewCanvasSessionDialog] = buildUseBoolean(false);
|
||||
|
||||
const useNewGallerySession = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession);
|
||||
const newSessionDialog = useNewGallerySessionDialog();
|
||||
|
||||
const newGallerySessionImmediate = useCallback(() => {
|
||||
dispatch(generateSessionReset());
|
||||
dispatch(activeTabCanvasRightPanelChanged('gallery'));
|
||||
}, [dispatch]);
|
||||
|
||||
const newGallerySessionWithDialog = useCallback(() => {
|
||||
if (shouldConfirmOnNewSession) {
|
||||
newSessionDialog.setTrue();
|
||||
return;
|
||||
}
|
||||
newGallerySessionImmediate();
|
||||
}, [newGallerySessionImmediate, newSessionDialog, shouldConfirmOnNewSession]);
|
||||
|
||||
return { newGallerySessionImmediate, newGallerySessionWithDialog };
|
||||
};
|
||||
|
||||
const useNewCanvasSession = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession);
|
||||
const newSessionDialog = useNewCanvasSessionDialog();
|
||||
|
||||
const newCanvasSessionImmediate = useCallback(() => {
|
||||
dispatch(canvasSessionReset());
|
||||
dispatch(activeTabCanvasRightPanelChanged('layers'));
|
||||
}, [dispatch]);
|
||||
|
||||
const newCanvasSessionWithDialog = useCallback(() => {
|
||||
if (shouldConfirmOnNewSession) {
|
||||
newSessionDialog.setTrue();
|
||||
return;
|
||||
}
|
||||
|
||||
newCanvasSessionImmediate();
|
||||
}, [newCanvasSessionImmediate, newSessionDialog, shouldConfirmOnNewSession]);
|
||||
|
||||
return { newCanvasSessionImmediate, newCanvasSessionWithDialog };
|
||||
};
|
||||
|
||||
export const NewGallerySessionDialog = memo(() => {
|
||||
useAssertSingleton('NewGallerySessionDialog');
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const dialog = useNewGallerySessionDialog();
|
||||
const { newGallerySessionImmediate } = useNewGallerySession();
|
||||
|
||||
const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession);
|
||||
const onToggleConfirm = useCallback(() => {
|
||||
dispatch(shouldConfirmOnNewSessionToggled());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<ConfirmationAlertDialog
|
||||
isOpen={dialog.isTrue}
|
||||
onClose={dialog.setFalse}
|
||||
title={t('controlLayers.newGallerySession')}
|
||||
acceptCallback={newGallerySessionImmediate}
|
||||
acceptButtonText={t('common.ok')}
|
||||
useInert={false}
|
||||
>
|
||||
<Flex direction="column" gap={3}>
|
||||
<Text>{t('controlLayers.newGallerySessionDesc')}</Text>
|
||||
<Text>{t('common.areYouSure')}</Text>
|
||||
<FormControl>
|
||||
<FormLabel>{t('common.dontAskMeAgain')}</FormLabel>
|
||||
<Checkbox isChecked={!shouldConfirmOnNewSession} onChange={onToggleConfirm} />
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</ConfirmationAlertDialog>
|
||||
);
|
||||
});
|
||||
|
||||
NewGallerySessionDialog.displayName = 'NewGallerySessionDialog';
|
||||
|
||||
export const NewCanvasSessionDialog = memo(() => {
|
||||
useAssertSingleton('NewCanvasSessionDialog');
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const dialog = useNewCanvasSessionDialog();
|
||||
const { newCanvasSessionImmediate } = useNewCanvasSession();
|
||||
|
||||
const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession);
|
||||
const onToggleConfirm = useCallback(() => {
|
||||
dispatch(shouldConfirmOnNewSessionToggled());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<ConfirmationAlertDialog
|
||||
isOpen={dialog.isTrue}
|
||||
onClose={dialog.setFalse}
|
||||
title={t('controlLayers.newCanvasSession')}
|
||||
acceptCallback={newCanvasSessionImmediate}
|
||||
acceptButtonText={t('common.ok')}
|
||||
useInert={false}
|
||||
>
|
||||
<Flex direction="column" gap={3}>
|
||||
<Text>{t('controlLayers.newCanvasSessionDesc')}</Text>
|
||||
<Text>{t('common.areYouSure')}</Text>
|
||||
<FormControl>
|
||||
<FormLabel>{t('common.dontAskMeAgain')}</FormLabel>
|
||||
<Checkbox isChecked={!shouldConfirmOnNewSession} onChange={onToggleConfirm} />
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</ConfirmationAlertDialog>
|
||||
);
|
||||
});
|
||||
|
||||
NewCanvasSessionDialog.displayName = 'NewCanvasSessionDialog';
|
||||
@@ -126,6 +126,7 @@ const AddRefImageDropTargetAndButton = memo(() => {
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
AddRefImageDropTargetAndButton.displayName = 'AddRefImageDropTargetAndButton';
|
||||
|
||||
const BboxButton = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
@@ -145,4 +146,4 @@ const BboxButton = memo(() => {
|
||||
/>
|
||||
);
|
||||
});
|
||||
AddRefImageDropTargetAndButton.displayName = 'AddRefImageDropTargetAndButton';
|
||||
BboxButton.displayName = 'BboxButton';
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd';
|
||||
import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
|
||||
import { DndDropTarget } from 'features/dnd/DndDropTarget';
|
||||
import { setGlobalReferenceImage } from 'features/imageActions/actions';
|
||||
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
@@ -22,9 +21,6 @@ export const RefImageNoImageState = memo(() => {
|
||||
[dispatch, id]
|
||||
);
|
||||
const uploadApi = useImageUploadButton({ onUpload, allowMultiple: false });
|
||||
const onClickGalleryButton = useCallback(() => {
|
||||
dispatch(activeTabCanvasRightPanelChanged('gallery'));
|
||||
}, [dispatch]);
|
||||
|
||||
const dndTargetData = useMemo<SetGlobalReferenceImageDndTargetData>(
|
||||
() => setGlobalReferenceImageDndTarget.getData({ id }),
|
||||
@@ -34,9 +30,8 @@ export const RefImageNoImageState = memo(() => {
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
UploadButton: <Button size="sm" variant="link" color="base.300" {...uploadApi.getUploadButtonProps()} />,
|
||||
GalleryButton: <Button onClick={onClickGalleryButton} size="sm" variant="link" color="base.300" />,
|
||||
}),
|
||||
[onClickGalleryButton, uploadApi]
|
||||
[uploadApi]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd';
|
||||
import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
|
||||
import { DndDropTarget } from 'features/dnd/DndDropTarget';
|
||||
import { setGlobalReferenceImage } from 'features/imageActions/actions';
|
||||
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
@@ -25,9 +24,6 @@ export const RefImageNoImageStateWithCanvasOptions = memo(() => {
|
||||
[dispatch, id]
|
||||
);
|
||||
const uploadApi = useImageUploadButton({ onUpload, allowMultiple: false });
|
||||
const onClickGalleryButton = useCallback(() => {
|
||||
dispatch(activeTabCanvasRightPanelChanged('gallery'));
|
||||
}, [dispatch]);
|
||||
const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(id);
|
||||
|
||||
const dndTargetData = useMemo<SetGlobalReferenceImageDndTargetData>(
|
||||
@@ -40,14 +36,11 @@ export const RefImageNoImageStateWithCanvasOptions = memo(() => {
|
||||
UploadButton: (
|
||||
<Button isDisabled={isBusy} size="sm" variant="link" color="base.300" {...uploadApi.getUploadButtonProps()} />
|
||||
),
|
||||
GalleryButton: (
|
||||
<Button onClick={onClickGalleryButton} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
|
||||
),
|
||||
PullBboxButton: (
|
||||
<Button onClick={pullBboxIntoIPAdapter} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
|
||||
),
|
||||
}),
|
||||
[isBusy, onClickGalleryButton, pullBboxIntoIPAdapter, uploadApi]
|
||||
[isBusy, pullBboxIntoIPAdapter, uploadApi]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { SetRegionalGuidanceReferenceImageDndTargetData } from 'features/dn
|
||||
import { setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd';
|
||||
import { DndDropTarget } from 'features/dnd/DndDropTarget';
|
||||
import { setRegionalGuidanceReferenceImage } from 'features/imageActions/actions';
|
||||
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { PiXBold } from 'react-icons/pi';
|
||||
@@ -31,9 +30,6 @@ export const RegionalGuidanceIPAdapterSettingsEmptyState = memo(({ referenceImag
|
||||
[dispatch, entityIdentifier, referenceImageId]
|
||||
);
|
||||
const uploadApi = useImageUploadButton({ onUpload, allowMultiple: false });
|
||||
const onClickGalleryButton = useCallback(() => {
|
||||
dispatch(activeTabCanvasRightPanelChanged('gallery'));
|
||||
}, [dispatch]);
|
||||
const onDeleteIPAdapter = useCallback(() => {
|
||||
dispatch(rgRefImageDeleted({ entityIdentifier, referenceImageId }));
|
||||
}, [dispatch, entityIdentifier, referenceImageId]);
|
||||
@@ -53,14 +49,11 @@ export const RegionalGuidanceIPAdapterSettingsEmptyState = memo(({ referenceImag
|
||||
UploadButton: (
|
||||
<Button isDisabled={isBusy} size="sm" variant="link" color="base.300" {...uploadApi.getUploadButtonProps()} />
|
||||
),
|
||||
GalleryButton: (
|
||||
<Button onClick={onClickGalleryButton} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
|
||||
),
|
||||
PullBboxButton: (
|
||||
<Button onClick={pullBboxIntoIPAdapter} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
|
||||
),
|
||||
}),
|
||||
[isBusy, onClickGalleryButton, pullBboxIntoIPAdapter, uploadApi]
|
||||
[isBusy, pullBboxIntoIPAdapter, uploadApi]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,7 +16,7 @@ export const CanvasSettingsSaveAllImagesToGalleryCheckbox = memo(() => {
|
||||
}, [dispatch]);
|
||||
return (
|
||||
<FormControl w="full">
|
||||
<FormLabel flexGrow={1}>{t('controlLayers.saveAllImagesToGallery')}</FormLabel>
|
||||
<FormLabel flexGrow={1}>{t('controlLayers.settings.saveAllImagesToGallery.label')}</FormLabel>
|
||||
<Checkbox isChecked={saveAllImagesToGallery} onChange={onChange} />
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
@@ -1,585 +0,0 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared';
|
||||
import { selectStagingAreaAutoSwitch } from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import {
|
||||
buildSelectSessionQueueItems,
|
||||
canvasQueueItemDiscarded,
|
||||
canvasSessionReset,
|
||||
} from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import type { ProgressImage } from 'features/nodes/types/common';
|
||||
import type { Atom, MapStore, StoreValue, WritableAtom } from 'nanostores';
|
||||
import { atom, computed, effect, map, subscribeKeys } from 'nanostores';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { getImageDTOSafe } from 'services/api/endpoints/images';
|
||||
import { queueApi } from 'services/api/endpoints/queue';
|
||||
import type { ImageDTO, S } from 'services/api/types';
|
||||
import { $socket } from 'services/events/stores';
|
||||
import { assert, objectEntries } from 'tsafe';
|
||||
|
||||
export type ProgressData = {
|
||||
itemId: number;
|
||||
progressEvent: S['InvocationProgressEvent'] | null;
|
||||
progressImage: ProgressImage | null;
|
||||
imageDTO: ImageDTO | null;
|
||||
imageLoaded: boolean;
|
||||
};
|
||||
|
||||
const getInitialProgressData = (itemId: number): ProgressData => ({
|
||||
itemId,
|
||||
progressEvent: null,
|
||||
progressImage: null,
|
||||
imageDTO: null,
|
||||
imageLoaded: false,
|
||||
});
|
||||
|
||||
export const useProgressData = ($progressData: ProgressDataMap, itemId: number): ProgressData => {
|
||||
const getInitialValue = useCallback(
|
||||
() => $progressData.get()[itemId] ?? getInitialProgressData(itemId),
|
||||
[$progressData, itemId]
|
||||
);
|
||||
const [value, setValue] = useState(getInitialValue);
|
||||
useEffect(() => {
|
||||
const unsub = subscribeKeys($progressData, [itemId], (data) => {
|
||||
const progressData = data[itemId];
|
||||
if (!progressData) {
|
||||
return;
|
||||
}
|
||||
setValue(progressData);
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
};
|
||||
}, [$progressData, itemId]);
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const setProgress = ($progressData: ProgressDataMap, data: S['InvocationProgressEvent']) => {
|
||||
const progressData = $progressData.get();
|
||||
const current = progressData[data.item_id];
|
||||
if (current) {
|
||||
const next = { ...current };
|
||||
next.progressEvent = data;
|
||||
if (data.image) {
|
||||
next.progressImage = data.image;
|
||||
}
|
||||
$progressData.set({
|
||||
...progressData,
|
||||
[data.item_id]: next,
|
||||
});
|
||||
} else {
|
||||
$progressData.set({
|
||||
...progressData,
|
||||
[data.item_id]: {
|
||||
itemId: data.item_id,
|
||||
progressEvent: data,
|
||||
progressImage: data.image ?? null,
|
||||
imageDTO: null,
|
||||
imageLoaded: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export type ProgressDataMap = MapStore<Record<number, ProgressData | undefined>>;
|
||||
|
||||
type CanvasSessionContextValue = {
|
||||
session: { id: string; type: 'simple' | 'advanced' };
|
||||
$items: Atom<S['SessionQueueItem'][]>;
|
||||
$itemCount: Atom<number>;
|
||||
$hasItems: Atom<boolean>;
|
||||
$isPending: Atom<boolean>;
|
||||
$progressData: ProgressDataMap;
|
||||
$selectedItemId: WritableAtom<number | null>;
|
||||
$selectedItem: Atom<S['SessionQueueItem'] | null>;
|
||||
$selectedItemIndex: Atom<number | null>;
|
||||
$selectedItemOutputImageDTO: Atom<ImageDTO | null>;
|
||||
selectNext: () => void;
|
||||
selectPrev: () => void;
|
||||
selectFirst: () => void;
|
||||
selectLast: () => void;
|
||||
onImageLoad: (itemId: number) => void;
|
||||
discard: (itemId: number) => void;
|
||||
discardAll: () => void;
|
||||
};
|
||||
|
||||
const CanvasSessionContext = createContext<CanvasSessionContextValue | null>(null);
|
||||
|
||||
export const CanvasSessionContextProvider = memo(
|
||||
({ id, type, children }: PropsWithChildren<{ id: string; type: 'simple' | 'advanced' }>) => {
|
||||
/**
|
||||
* For best performance and interop with the Canvas, which is outside react but needs to interact with the react
|
||||
* app, all canvas session state is packaged as nanostores atoms. The trickiest part is syncing the queue items
|
||||
* with a nanostores atom.
|
||||
*/
|
||||
const session = useMemo(() => ({ type, id }), [type, id]);
|
||||
|
||||
/**
|
||||
* App store
|
||||
*/
|
||||
const store = useAppStore();
|
||||
|
||||
const socket = useStore($socket);
|
||||
|
||||
/**
|
||||
* Track the last completed item. Used to implement autoswitch.
|
||||
*/
|
||||
const $lastCompletedItemId = useState(() => atom<number | null>(null))[0];
|
||||
|
||||
/**
|
||||
* Track the last started item. Used to implement autoswitch.
|
||||
*/
|
||||
const $lastStartedItemId = useState(() => atom<number | null>(null))[0];
|
||||
|
||||
/**
|
||||
* Manually-synced atom containing queue items for the current session. This is populated from the RTK Query cache
|
||||
* and kept in sync with it via a redux subscription.
|
||||
*/
|
||||
const $items = useState(() => atom<S['SessionQueueItem'][]>([]))[0];
|
||||
|
||||
/**
|
||||
* An internal flag used to work around race conditions with auto-switch switching to queue items before their
|
||||
* output images have fully loaded.
|
||||
*/
|
||||
const $lastLoadedItemId = useState(() => atom<number | null>(null))[0];
|
||||
|
||||
/**
|
||||
* An ephemeral store of progress events and images for all items in the current session.
|
||||
*/
|
||||
const $progressData = useState(() => map<StoreValue<ProgressDataMap>>({}))[0];
|
||||
|
||||
/**
|
||||
* The currently selected queue item's ID, or null if one is not selected.
|
||||
*/
|
||||
const $selectedItemId = useState(() => atom<number | null>(null))[0];
|
||||
|
||||
/**
|
||||
* The number of items. Computed from the queue items array.
|
||||
*/
|
||||
const $itemCount = useState(() => computed([$items], (items) => items.length))[0];
|
||||
|
||||
/**
|
||||
* Whether there are any items. Computed from the queue items array.
|
||||
*/
|
||||
const $hasItems = useState(() => computed([$items], (items) => items.length > 0))[0];
|
||||
|
||||
/**
|
||||
* Whether there are any pending or in-progress items. Computed from the queue items array.
|
||||
*/
|
||||
const $isPending = useState(() =>
|
||||
computed([$items], (items) => items.some((item) => item.status === 'pending' || item.status === 'in_progress'))
|
||||
)[0];
|
||||
|
||||
/**
|
||||
* The currently selected queue item, or null if one is not selected.
|
||||
*/
|
||||
const $selectedItem = useState(() =>
|
||||
computed([$items, $selectedItemId], (items, selectedItemId) => {
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (selectedItemId === null) {
|
||||
return null;
|
||||
}
|
||||
return items.find(({ item_id }) => item_id === selectedItemId) ?? null;
|
||||
})
|
||||
)[0];
|
||||
|
||||
/**
|
||||
* The currently selected queue item's index in the list of items, or null if one is not selected.
|
||||
*/
|
||||
const $selectedItemIndex = useState(() =>
|
||||
computed([$items, $selectedItemId], (items, selectedItemId) => {
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (selectedItemId === null) {
|
||||
return null;
|
||||
}
|
||||
return items.findIndex(({ item_id }) => item_id === selectedItemId) ?? null;
|
||||
})
|
||||
)[0];
|
||||
|
||||
/**
|
||||
* The currently selected queue item's output image name, or null if one is not selected or there is no output
|
||||
* image recorded.
|
||||
*/
|
||||
const $selectedItemOutputImageDTO = useState(() =>
|
||||
computed([$selectedItemId, $progressData], (selectedItemId, progressData) => {
|
||||
if (selectedItemId === null) {
|
||||
return null;
|
||||
}
|
||||
const datum = progressData[selectedItemId];
|
||||
if (!datum) {
|
||||
return null;
|
||||
}
|
||||
return datum.imageDTO;
|
||||
})
|
||||
)[0];
|
||||
|
||||
/**
|
||||
* A redux selector to select all queue items from the RTK Query cache.
|
||||
*/
|
||||
const selectQueueItems = useMemo(() => buildSelectSessionQueueItems(session.id), [session.id]);
|
||||
|
||||
const discard = useCallback(
|
||||
(itemId: number) => {
|
||||
store.dispatch(canvasQueueItemDiscarded({ itemId }));
|
||||
},
|
||||
[store]
|
||||
);
|
||||
|
||||
const discardAll = useCallback(() => {
|
||||
store.dispatch(canvasSessionReset());
|
||||
}, [store]);
|
||||
|
||||
const selectNext = useCallback(() => {
|
||||
const selectedItemId = $selectedItemId.get();
|
||||
if (selectedItemId === null) {
|
||||
return;
|
||||
}
|
||||
const items = $items.get();
|
||||
const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
|
||||
const nextIndex = (currentIndex + 1) % items.length;
|
||||
const nextItem = items[nextIndex];
|
||||
if (!nextItem) {
|
||||
return;
|
||||
}
|
||||
$selectedItemId.set(nextItem.item_id);
|
||||
}, [$items, $selectedItemId]);
|
||||
|
||||
const selectPrev = useCallback(() => {
|
||||
const selectedItemId = $selectedItemId.get();
|
||||
if (selectedItemId === null) {
|
||||
return;
|
||||
}
|
||||
const items = $items.get();
|
||||
const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
|
||||
const prevIndex = (currentIndex - 1 + items.length) % items.length;
|
||||
const prevItem = items[prevIndex];
|
||||
if (!prevItem) {
|
||||
return;
|
||||
}
|
||||
$selectedItemId.set(prevItem.item_id);
|
||||
}, [$items, $selectedItemId]);
|
||||
|
||||
const selectFirst = useCallback(() => {
|
||||
const items = $items.get();
|
||||
const first = items.at(0);
|
||||
if (!first) {
|
||||
return;
|
||||
}
|
||||
$selectedItemId.set(first.item_id);
|
||||
}, [$items, $selectedItemId]);
|
||||
|
||||
const selectLast = useCallback(() => {
|
||||
const items = $items.get();
|
||||
const last = items.at(-1);
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
$selectedItemId.set(last.item_id);
|
||||
}, [$items, $selectedItemId]);
|
||||
|
||||
const onImageLoad = useCallback(
|
||||
(itemId: number) => {
|
||||
const progressData = $progressData.get();
|
||||
const current = progressData[itemId];
|
||||
if (current) {
|
||||
const next = { ...current, imageLoaded: true };
|
||||
$progressData.setKey(itemId, next);
|
||||
} else {
|
||||
$progressData.setKey(itemId, {
|
||||
...getInitialProgressData(itemId),
|
||||
imageLoaded: true,
|
||||
});
|
||||
}
|
||||
if (
|
||||
$lastCompletedItemId.get() === itemId &&
|
||||
selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_finish'
|
||||
) {
|
||||
$selectedItemId.set(itemId);
|
||||
$lastCompletedItemId.set(null);
|
||||
}
|
||||
},
|
||||
[$lastCompletedItemId, $progressData, $selectedItemId, store]
|
||||
);
|
||||
|
||||
// Set up socket listeners
|
||||
useEffect(() => {
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onProgress = (data: S['InvocationProgressEvent']) => {
|
||||
if (data.destination !== session.id) {
|
||||
return;
|
||||
}
|
||||
setProgress($progressData, data);
|
||||
};
|
||||
|
||||
const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => {
|
||||
if (data.destination !== session.id) {
|
||||
return;
|
||||
}
|
||||
if (data.status === 'completed') {
|
||||
$lastCompletedItemId.set(data.item_id);
|
||||
}
|
||||
if (data.status === 'in_progress') {
|
||||
$lastStartedItemId.set(data.item_id);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('invocation_progress', onProgress);
|
||||
socket.on('queue_item_status_changed', onQueueItemStatusChanged);
|
||||
|
||||
return () => {
|
||||
socket.off('invocation_progress', onProgress);
|
||||
socket.off('queue_item_status_changed', onQueueItemStatusChanged);
|
||||
};
|
||||
}, [$lastCompletedItemId, $lastStartedItemId, $progressData, $selectedItemId, session.id, socket]);
|
||||
|
||||
// Set up state subscriptions and effects
|
||||
useEffect(() => {
|
||||
let _prevItems: readonly S['SessionQueueItem'][] = [];
|
||||
// Seed the $items atom with the initial query cache state
|
||||
$items.set(selectQueueItems(store.getState()));
|
||||
|
||||
// Manually keep the $items atom in sync as the query cache is updated
|
||||
const unsubReduxSyncToItemsAtom = store.subscribe(() => {
|
||||
const prevItems = $items.get();
|
||||
const items = selectQueueItems(store.getState());
|
||||
if (items !== prevItems) {
|
||||
_prevItems = prevItems;
|
||||
$items.set(items);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle cases that could result in a nonexistent queue item being selected.
|
||||
const unsubEnsureSelectedItemIdExists = effect(
|
||||
[$items, $selectedItemId, $lastStartedItemId],
|
||||
(items, selectedItemId, lastStartedItemId) => {
|
||||
if (items.length === 0) {
|
||||
// If there are no items, cannot have a selected item.
|
||||
$selectedItemId.set(null);
|
||||
} else if (selectedItemId === null && items.length > 0) {
|
||||
// If there is no selected item but there are items, select the first one.
|
||||
$selectedItemId.set(items[0]?.item_id ?? null);
|
||||
return;
|
||||
} else if (
|
||||
selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_start' &&
|
||||
items.findIndex(({ item_id }) => item_id === lastStartedItemId) !== -1
|
||||
) {
|
||||
$selectedItemId.set(lastStartedItemId);
|
||||
$lastStartedItemId.set(null);
|
||||
} else if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) {
|
||||
// If an item is selected and it is not in the list of items, un-set it. This effect will run again and we'll
|
||||
// the above case, selecting the first item if there are any.
|
||||
let prevIndex = _prevItems.findIndex(({ item_id }) => item_id === selectedItemId);
|
||||
if (prevIndex >= items.length) {
|
||||
prevIndex = items.length - 1;
|
||||
}
|
||||
const nextItem = items[prevIndex];
|
||||
$selectedItemId.set(nextItem?.item_id ?? null);
|
||||
}
|
||||
|
||||
if (items !== _prevItems) {
|
||||
_prevItems = items;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Clean up the progress data when a queue item is discarded.
|
||||
const unsubCleanUpProgressData = $items.subscribe(async (items) => {
|
||||
const progressData = $progressData.get();
|
||||
|
||||
const toDelete: number[] = [];
|
||||
const toUpdate: ProgressData[] = [];
|
||||
|
||||
for (const [id, datum] of objectEntries(progressData)) {
|
||||
if (!datum) {
|
||||
toDelete.push(id);
|
||||
continue;
|
||||
}
|
||||
const item = items.find(({ item_id }) => item_id === datum.itemId);
|
||||
if (!item) {
|
||||
toDelete.push(datum.itemId);
|
||||
} else if (item.status === 'canceled' || item.status === 'failed') {
|
||||
toUpdate.push({
|
||||
...datum,
|
||||
progressEvent: null,
|
||||
progressImage: null,
|
||||
imageDTO: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const datum = progressData[item.item_id];
|
||||
|
||||
if (datum) {
|
||||
if (datum.imageDTO) {
|
||||
continue;
|
||||
}
|
||||
const outputImageName = getOutputImageName(item);
|
||||
if (!outputImageName) {
|
||||
continue;
|
||||
}
|
||||
const imageDTO = await getImageDTOSafe(outputImageName);
|
||||
if (!imageDTO) {
|
||||
continue;
|
||||
}
|
||||
toUpdate.push({
|
||||
...datum,
|
||||
imageDTO,
|
||||
});
|
||||
} else {
|
||||
const outputImageName = getOutputImageName(item);
|
||||
if (!outputImageName) {
|
||||
continue;
|
||||
}
|
||||
const imageDTO = await getImageDTOSafe(outputImageName);
|
||||
if (!imageDTO) {
|
||||
continue;
|
||||
}
|
||||
toUpdate.push({
|
||||
...getInitialProgressData(item.item_id),
|
||||
imageDTO,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const itemId of toDelete) {
|
||||
$progressData.setKey(itemId, undefined);
|
||||
}
|
||||
|
||||
for (const datum of toUpdate) {
|
||||
$progressData.setKey(datum.itemId, datum);
|
||||
}
|
||||
});
|
||||
|
||||
// We only want to auto-switch to completed queue items once their images have fully loaded to prevent flashes
|
||||
// of fallback content and/or progress images. The only surefire way to determine when images have fully loaded
|
||||
// is via the image elements' `onLoad` callback. Images set `$lastLoadedItemId` to their queue item ID in their
|
||||
// `onLoad` handler, and we listen for that here. If auto-switch is enabled, we then switch the to the item.
|
||||
//
|
||||
// TODO: This isn't perfect... we set $lastLoadedItemId in the mini preview component, but the full view
|
||||
// component still needs to retrieve the image from the browser cache... can result in a flash of the progress
|
||||
// image...
|
||||
const unsubHandleAutoSwitch = $lastLoadedItemId.listen((lastLoadedItemId) => {
|
||||
if (lastLoadedItemId === null) {
|
||||
return;
|
||||
}
|
||||
if (selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_finish') {
|
||||
$selectedItemId.set(lastLoadedItemId);
|
||||
}
|
||||
$lastLoadedItemId.set(null);
|
||||
});
|
||||
|
||||
// Create an RTK Query subscription. Without this, the query cache selector will never return anything bc RTK
|
||||
// doesn't know we care about it.
|
||||
const { unsubscribe: unsubQueueItemsQuery } = store.dispatch(
|
||||
queueApi.endpoints.listAllQueueItems.initiate({ destination: session.id })
|
||||
);
|
||||
|
||||
// const unsubListener = store.dispatch(
|
||||
// addAppListener({
|
||||
// matcher: queueApi.endpoints.cancelQueueItem.matchFulfilled,
|
||||
// effect: ({ payload }, { getState }) => {
|
||||
// const { item_id } = payload;
|
||||
|
||||
// const items = selectQueueItems(getState());
|
||||
// if (items.length === 0) {
|
||||
// $selectedItemId.set(null);
|
||||
// } else if ($selectedItemId.get() === null) {
|
||||
// $selectedItemId.set(items[0].item_id);
|
||||
// }
|
||||
// },
|
||||
// })
|
||||
// );
|
||||
|
||||
// Clean up all subscriptions and top-level (i.e. non-computed/derived state)
|
||||
return () => {
|
||||
unsubHandleAutoSwitch();
|
||||
unsubQueueItemsQuery();
|
||||
unsubReduxSyncToItemsAtom();
|
||||
unsubEnsureSelectedItemIdExists();
|
||||
unsubCleanUpProgressData();
|
||||
$items.set([]);
|
||||
$progressData.set({});
|
||||
$selectedItemId.set(null);
|
||||
};
|
||||
}, [
|
||||
$items,
|
||||
$lastLoadedItemId,
|
||||
$lastStartedItemId,
|
||||
$progressData,
|
||||
$selectedItemId,
|
||||
selectQueueItems,
|
||||
session.id,
|
||||
store,
|
||||
]);
|
||||
|
||||
const value = useMemo<CanvasSessionContextValue>(
|
||||
() => ({
|
||||
session,
|
||||
$items,
|
||||
$hasItems,
|
||||
$isPending,
|
||||
$progressData,
|
||||
$selectedItemId,
|
||||
$selectedItem,
|
||||
$selectedItemIndex,
|
||||
$selectedItemOutputImageDTO,
|
||||
$itemCount,
|
||||
selectNext,
|
||||
selectPrev,
|
||||
selectFirst,
|
||||
selectLast,
|
||||
onImageLoad,
|
||||
discard,
|
||||
discardAll,
|
||||
}),
|
||||
[
|
||||
$items,
|
||||
$hasItems,
|
||||
$isPending,
|
||||
$progressData,
|
||||
$selectedItem,
|
||||
$selectedItemId,
|
||||
$selectedItemIndex,
|
||||
session,
|
||||
$selectedItemOutputImageDTO,
|
||||
$itemCount,
|
||||
selectNext,
|
||||
selectPrev,
|
||||
selectFirst,
|
||||
selectLast,
|
||||
onImageLoad,
|
||||
discard,
|
||||
discardAll,
|
||||
]
|
||||
);
|
||||
|
||||
return <CanvasSessionContext.Provider value={value}>{children}</CanvasSessionContext.Provider>;
|
||||
}
|
||||
);
|
||||
CanvasSessionContextProvider.displayName = 'CanvasSessionContextProvider';
|
||||
|
||||
export const useCanvasSessionContext = () => {
|
||||
const ctx = useContext(CanvasSessionContext);
|
||||
assert(ctx !== null, "'useCanvasSessionContext' must be used within a CanvasSessionContextProvider");
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const useOutputImageDTO = (item: S['SessionQueueItem']) => {
|
||||
const ctx = useCanvasSessionContext();
|
||||
const $imageDTO = useState(() =>
|
||||
computed([ctx.$progressData], (progressData) => progressData[item.item_id]?.imageDTO ?? null)
|
||||
)[0];
|
||||
const imageDTO = useStore($imageDTO);
|
||||
|
||||
return imageDTO;
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { CircularProgressProps, SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { CircularProgress, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { getProgressMessage } from 'features/controlLayers/components/SimpleSession/shared';
|
||||
import { getProgressMessage } from 'features/controlLayers/components/StagingArea/shared';
|
||||
import { memo } from 'react';
|
||||
import type { S } from 'services/api/types';
|
||||
|
||||
import { useProgressDatum } from './context';
|
||||
|
||||
const circleStyles: SystemStyleObject = {
|
||||
circle: {
|
||||
transitionProperty: 'none',
|
||||
@@ -18,8 +19,7 @@ const circleStyles: SystemStyleObject = {
|
||||
type Props = { itemId: number; status: S['SessionQueueItem']['status'] } & CircularProgressProps;
|
||||
|
||||
export const QueueItemCircularProgress = memo(({ itemId, status, ...rest }: Props) => {
|
||||
const { $progressData } = useCanvasSessionContext();
|
||||
const { progressEvent } = useProgressData($progressData, itemId);
|
||||
const { progressEvent } = useProgressDatum(itemId);
|
||||
|
||||
if (status !== 'in_progress') {
|
||||
return null;
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { TextProps } from '@invoke-ai/ui-library';
|
||||
import { Text } from '@invoke-ai/ui-library';
|
||||
import { DROP_SHADOW } from 'features/controlLayers/components/SimpleSession/shared';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { DROP_SHADOW } from './shared';
|
||||
|
||||
export const QueueItemNumber = memo(({ number, ...rest }: { number: number } & TextProps) => {
|
||||
return <Text pointerEvents="none" userSelect="none" filter={DROP_SHADOW} {...rest}>{`#${number}`}</Text>;
|
||||
});
|
||||
@@ -1,25 +1,23 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import {
|
||||
useCanvasSessionContext,
|
||||
useOutputImageDTO,
|
||||
useProgressData,
|
||||
} from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { QueueItemCircularProgress } from 'features/controlLayers/components/SimpleSession/QueueItemCircularProgress';
|
||||
import { QueueItemNumber } from 'features/controlLayers/components/SimpleSession/QueueItemNumber';
|
||||
import { QueueItemProgressImage } from 'features/controlLayers/components/SimpleSession/QueueItemProgressImage';
|
||||
import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel';
|
||||
import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared';
|
||||
import { QueueItemCircularProgress } from 'features/controlLayers/components/StagingArea/QueueItemCircularProgress';
|
||||
import { QueueItemProgressImage } from 'features/controlLayers/components/StagingArea/QueueItemProgressImage';
|
||||
import { QueueItemStatusLabel } from 'features/controlLayers/components/StagingArea/QueueItemStatusLabel';
|
||||
import { getQueueItemElementId } from 'features/controlLayers/components/StagingArea/shared';
|
||||
import {
|
||||
selectStagingAreaAutoSwitch,
|
||||
settingsStagingAreaAutoSwitchChanged,
|
||||
} from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import { DndImage } from 'features/dnd/DndImage';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import type { S } from 'services/api/types';
|
||||
|
||||
import { useOutputImageDTO, useStagingAreaContext } from './context';
|
||||
import { QueueItemNumber } from './QueueItemNumber';
|
||||
|
||||
const sx = {
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
@@ -41,19 +39,19 @@ const sx = {
|
||||
type Props = {
|
||||
item: S['SessionQueueItem'];
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemPreviewMini = memo(({ item, isSelected, index }: Props) => {
|
||||
export const QueueItemPreviewMini = memo(({ item, index }: Props) => {
|
||||
const ctx = useStagingAreaContext();
|
||||
const dispatch = useAppDispatch();
|
||||
const ctx = useCanvasSessionContext();
|
||||
const { imageLoaded } = useProgressData(ctx.$progressData, item.item_id);
|
||||
const imageDTO = useOutputImageDTO(item);
|
||||
const $isSelected = useMemo(() => ctx.buildIsSelectedComputed(item.item_id), [ctx, item.item_id]);
|
||||
const isSelected = useStore($isSelected);
|
||||
const imageDTO = useOutputImageDTO(item.item_id);
|
||||
const autoSwitch = useAppSelector(selectStagingAreaAutoSwitch);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
ctx.$selectedItemId.set(item.item_id);
|
||||
}, [ctx.$selectedItemId, item.item_id]);
|
||||
ctx.select(item.item_id);
|
||||
}, [ctx, item.item_id]);
|
||||
|
||||
const onDoubleClick = useCallback(() => {
|
||||
if (autoSwitch !== 'off') {
|
||||
@@ -64,10 +62,6 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, index }: Props) =>
|
||||
}
|
||||
}, [autoSwitch, dispatch]);
|
||||
|
||||
const onLoad = useCallback(() => {
|
||||
ctx.onImageLoad(item.item_id);
|
||||
}, [ctx, item.item_id]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
id={getQueueItemElementId(index)}
|
||||
@@ -77,8 +71,8 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, index }: Props) =>
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<QueueItemStatusLabel item={item} position="absolute" margin="auto" />
|
||||
{imageDTO && <DndImage imageDTO={imageDTO} onLoad={onLoad} asThumbnail position="absolute" />}
|
||||
{!imageLoaded && <QueueItemProgressImage itemId={item.item_id} position="absolute" />}
|
||||
{imageDTO && <DndImage imageDTO={imageDTO} asThumbnail position="absolute" />}
|
||||
<QueueItemProgressImage itemId={item.item_id} position="absolute" />
|
||||
<QueueItemNumber number={index + 1} position="absolute" top={0} left={1} />
|
||||
<QueueItemCircularProgress itemId={item.item_id} status={item.status} position="absolute" top={1} right={2} />
|
||||
</Flex>
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ImageProps } from '@invoke-ai/ui-library';
|
||||
import { Image } from '@invoke-ai/ui-library';
|
||||
import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useProgressDatum } from './context';
|
||||
|
||||
type Props = { itemId: number } & ImageProps;
|
||||
|
||||
export const QueueItemProgressImage = memo(({ itemId, ...rest }: Props) => {
|
||||
const ctx = useCanvasSessionContext();
|
||||
const { progressImage } = useProgressData(ctx.$progressData, itemId);
|
||||
const { progressImage } = useProgressDatum(itemId);
|
||||
|
||||
if (!progressImage) {
|
||||
return null;
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { TextProps } from '@invoke-ai/ui-library';
|
||||
import { Text } from '@invoke-ai/ui-library';
|
||||
import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { memo } from 'react';
|
||||
import type { S } from 'services/api/types';
|
||||
|
||||
import { useProgressDatum } from './context';
|
||||
|
||||
type Props = { item: S['SessionQueueItem'] } & TextProps;
|
||||
|
||||
export const QueueItemStatusLabel = memo(({ item, ...rest }: Props) => {
|
||||
const ctx = useCanvasSessionContext();
|
||||
const { progressImage, imageLoaded } = useProgressData(ctx.$progressData, item.item_id);
|
||||
const { progressImage } = useProgressDatum(item.item_id);
|
||||
|
||||
if (progressImage || imageLoaded) {
|
||||
if (progressImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import {
|
||||
selectStagingAreaAutoSwitch,
|
||||
settingsStagingAreaAutoSwitchChanged,
|
||||
@@ -8,6 +10,9 @@ import { memo, useCallback } from 'react';
|
||||
import { PiCaretLineRightBold, PiCaretRightBold, PiMoonBold } from 'react-icons/pi';
|
||||
|
||||
export const StagingAreaAutoSwitchButtons = memo(() => {
|
||||
const canvasManager = useCanvasManager();
|
||||
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
|
||||
|
||||
const autoSwitch = useAppSelector(selectStagingAreaAutoSwitch);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -29,6 +34,7 @@ export const StagingAreaAutoSwitchButtons = memo(() => {
|
||||
icon={<PiMoonBold />}
|
||||
colorScheme={autoSwitch === 'off' ? 'invokeBlue' : 'base'}
|
||||
onClick={onClickOff}
|
||||
isDisabled={!shouldShowStagedImage}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Switch on start"
|
||||
@@ -36,6 +42,7 @@ export const StagingAreaAutoSwitchButtons = memo(() => {
|
||||
icon={<PiCaretRightBold />}
|
||||
colorScheme={autoSwitch === 'switch_on_start' ? 'invokeBlue' : 'base'}
|
||||
onClick={onClickSwitchOnStart}
|
||||
isDisabled={!shouldShowStagedImage}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Switch on finish"
|
||||
@@ -43,6 +50,7 @@ export const StagingAreaAutoSwitchButtons = memo(() => {
|
||||
icon={<PiCaretLineRightBold />}
|
||||
colorScheme={autoSwitch === 'switch_on_finish' ? 'invokeBlue' : 'base'}
|
||||
onClick={onClickSwitchOnFinished}
|
||||
isDisabled={!shouldShowStagedImage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Box, Flex, forwardRef } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { QueueItemPreviewMini } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewMini';
|
||||
import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { QueueItemPreviewMini } from 'features/controlLayers/components/StagingArea/QueueItemPreviewMini';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
import type { CSSProperties, RefObject } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Components, ItemContent, ListRange, VirtuosoHandle, VirtuosoProps } from 'react-virtuoso';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Components, ComputeItemKey, ItemContent, ListRange, VirtuosoHandle, VirtuosoProps } from 'react-virtuoso';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import type { S } from 'services/api/types';
|
||||
|
||||
import { useStagingAreaContext } from './context';
|
||||
import { getQueueItemElementId } from './shared';
|
||||
|
||||
const log = logger('system');
|
||||
@@ -20,8 +20,6 @@ const virtuosoStyles = {
|
||||
height: '72px',
|
||||
} satisfies CSSProperties;
|
||||
|
||||
type VirtuosoContext = { selectedItemId: number | null };
|
||||
|
||||
/**
|
||||
* Scroll the item at the given index into view if it is not currently visible.
|
||||
*/
|
||||
@@ -132,28 +130,26 @@ const useScrollableStagingArea = (rootRef: RefObject<HTMLDivElement>) => {
|
||||
};
|
||||
|
||||
export const StagingAreaItemsList = memo(() => {
|
||||
const canvasManager = useCanvasManagerSafe();
|
||||
const ctx = useCanvasSessionContext();
|
||||
const canvasManager = useCanvasManager();
|
||||
|
||||
const ctx = useStagingAreaContext();
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const rangeRef = useRef<ListRange>({ startIndex: 0, endIndex: 0 });
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const items = useStore(ctx.$items);
|
||||
const selectedItemId = useStore(ctx.$selectedItemId);
|
||||
|
||||
const context = useMemo(() => ({ selectedItemId }), [selectedItemId]);
|
||||
const scrollerRef = useScrollableStagingArea(rootRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
return canvasManager.stagingArea.connectToSession(ctx.$selectedItemId, ctx.$progressData, ctx.$isPending);
|
||||
}, [canvasManager, ctx.$progressData, ctx.$selectedItemId, ctx.$isPending]);
|
||||
return canvasManager.stagingArea.connectToSession(ctx.$items, ctx.$selectedItem);
|
||||
}, [canvasManager, ctx.$progressData, ctx.$items, ctx.$selectedItem]);
|
||||
|
||||
useEffect(() => {
|
||||
return ctx.$selectedItemIndex.listen((index) => {
|
||||
return ctx.$selectedItemIndex.listen((selectedItemIndex) => {
|
||||
if (selectedItemIndex === null) {
|
||||
return;
|
||||
}
|
||||
if (!virtuosoRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -162,11 +158,7 @@ export const StagingAreaItemsList = memo(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (index === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollIntoView(index, rootRef.current, virtuosoRef.current, rangeRef.current);
|
||||
scrollIntoView(selectedItemIndex, rootRef.current, virtuosoRef.current, rangeRef.current);
|
||||
});
|
||||
}, [ctx.$selectedItemIndex]);
|
||||
|
||||
@@ -176,40 +168,46 @@ export const StagingAreaItemsList = memo(() => {
|
||||
|
||||
return (
|
||||
<Box data-overlayscrollbars-initialize="" ref={rootRef} position="relative" w="full" h="full">
|
||||
<Virtuoso<S['SessionQueueItem'], VirtuosoContext>
|
||||
<Virtuoso<S['SessionQueueItem']>
|
||||
ref={virtuosoRef}
|
||||
context={context}
|
||||
data={items}
|
||||
horizontalDirection
|
||||
style={virtuosoStyles}
|
||||
computeItemKey={computeItemKey}
|
||||
increaseViewportBy={2048}
|
||||
itemContent={itemContent}
|
||||
components={components}
|
||||
rangeChanged={onRangeChanged}
|
||||
// Virtuoso expects the ref to be of HTMLElement | null | Window, but overlayscrollbars doesn't allow Window
|
||||
scrollerRef={scrollerRef as VirtuosoProps<S['SessionQueueItem'], VirtuosoContext>['scrollerRef']}
|
||||
scrollerRef={scrollerRef as VirtuosoProps<S['SessionQueueItem'], void>['scrollerRef']}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
StagingAreaItemsList.displayName = 'StagingAreaItemsList';
|
||||
|
||||
const itemContent: ItemContent<S['SessionQueueItem'], VirtuosoContext> = (index, item, { selectedItemId }) => (
|
||||
<QueueItemPreviewMini
|
||||
key={`${item.item_id}-mini`}
|
||||
item={item}
|
||||
index={index}
|
||||
isSelected={selectedItemId === item.item_id}
|
||||
/>
|
||||
const computeItemKey: ComputeItemKey<S['SessionQueueItem'], void> = (_, item: S['SessionQueueItem']) => {
|
||||
return item.item_id;
|
||||
};
|
||||
|
||||
const itemContent: ItemContent<S['SessionQueueItem'], void> = (index, item) => (
|
||||
<QueueItemPreviewMini key={`${item.item_id}-mini`} item={item} index={index} />
|
||||
);
|
||||
|
||||
const listSx = {
|
||||
'& > * + *': {
|
||||
pl: 2,
|
||||
},
|
||||
'&[data-disabled="true"]': {
|
||||
filter: 'grayscale(1) opacity(0.5)',
|
||||
},
|
||||
};
|
||||
|
||||
const components: Components<S['SessionQueueItem'], VirtuosoContext> = {
|
||||
const components: Components<S['SessionQueueItem']> = {
|
||||
List: forwardRef(({ context: _, ...rest }, ref) => {
|
||||
return <Flex ref={ref} sx={listSx} {...rest} />;
|
||||
const canvasManager = useCanvasManager();
|
||||
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
|
||||
|
||||
return <Flex ref={ref} sx={listSx} data-disabled={!shouldShowStagedImage} {...rest} />;
|
||||
}),
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ButtonGroup, Flex } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context';
|
||||
import { StagingAreaToolbarAcceptButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton';
|
||||
import { StagingAreaToolbarDiscardAllButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton';
|
||||
import { StagingAreaToolbarDiscardSelectedButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton';
|
||||
@@ -10,17 +9,13 @@ import { StagingAreaToolbarNextButton } from 'features/controlLayers/components/
|
||||
import { StagingAreaToolbarPrevButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton';
|
||||
import { StagingAreaToolbarSaveSelectedToGalleryButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton';
|
||||
import { StagingAreaToolbarToggleShowResultsButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarToggleShowResultsButton';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { memo } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { StagingAreaAutoSwitchButtons } from './StagingAreaAutoSwitchButtons';
|
||||
|
||||
export const StagingAreaToolbar = memo(() => {
|
||||
const canvasManager = useCanvasManager();
|
||||
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
|
||||
|
||||
const ctx = useCanvasSessionContext();
|
||||
const ctx = useStagingAreaContext();
|
||||
|
||||
useHotkeys('meta+left', ctx.selectFirst, { preventDefault: true });
|
||||
useHotkeys('meta+right', ctx.selectLast, { preventDefault: true });
|
||||
@@ -28,22 +23,22 @@ export const StagingAreaToolbar = memo(() => {
|
||||
return (
|
||||
<Flex gap={2}>
|
||||
<ButtonGroup borderRadius="base" shadow="dark-lg">
|
||||
<StagingAreaToolbarPrevButton isDisabled={!shouldShowStagedImage} />
|
||||
<StagingAreaToolbarPrevButton />
|
||||
<StagingAreaToolbarImageCountButton />
|
||||
<StagingAreaToolbarNextButton isDisabled={!shouldShowStagedImage} />
|
||||
<StagingAreaToolbarNextButton />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup borderRadius="base" shadow="dark-lg">
|
||||
<StagingAreaToolbarAcceptButton />
|
||||
<StagingAreaToolbarToggleShowResultsButton />
|
||||
<StagingAreaToolbarSaveSelectedToGalleryButton />
|
||||
<StagingAreaToolbarMenu />
|
||||
<StagingAreaToolbarDiscardSelectedButton isDisabled={!shouldShowStagedImage} />
|
||||
<StagingAreaToolbarDiscardSelectedButton />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup borderRadius="base" shadow="dark-lg">
|
||||
<StagingAreaAutoSwitchButtons />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup borderRadius="base" shadow="dark-lg">
|
||||
<StagingAreaToolbarDiscardAllButton isDisabled={!shouldShowStagedImage} />
|
||||
<StagingAreaToolbarDiscardAllButton />
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -1,64 +1,32 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice';
|
||||
import { canvasSessionReset } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { selectBboxRect, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
|
||||
import type { CanvasRasterLayerState } from 'features/controlLayers/store/types';
|
||||
import { imageNameToImageObject } from 'features/controlLayers/store/util';
|
||||
import { useCancelQueueItemsByDestination } from 'features/queue/hooks/useCancelQueueItemsByDestination';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiCheckBold } from 'react-icons/pi';
|
||||
|
||||
export const StagingAreaToolbarAcceptButton = memo(() => {
|
||||
const ctx = useCanvasSessionContext();
|
||||
const dispatch = useAppDispatch();
|
||||
const ctx = useStagingAreaContext();
|
||||
const canvasManager = useCanvasManager();
|
||||
const bboxRect = useAppSelector(selectBboxRect);
|
||||
const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
|
||||
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
|
||||
const isCanvasFocused = useIsRegionFocused('canvas');
|
||||
const selectedItemImageDTO = useStore(ctx.$selectedItemOutputImageDTO);
|
||||
const cancelQueueItemsByDestination = useCancelQueueItemsByDestination();
|
||||
const acceptSelectedIsEnabled = useStore(ctx.$acceptSelectedIsEnabled);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const acceptSelected = useCallback(() => {
|
||||
if (!selectedItemImageDTO) {
|
||||
return;
|
||||
}
|
||||
const { x, y, width, height } = bboxRect;
|
||||
const imageObject = imageNameToImageObject(selectedItemImageDTO.image_name, { width, height });
|
||||
const overrides: Partial<CanvasRasterLayerState> = {
|
||||
position: { x, y },
|
||||
objects: [imageObject],
|
||||
};
|
||||
|
||||
dispatch(rasterLayerAdded({ overrides, isSelected: selectedEntityIdentifier?.type === 'raster_layer' }));
|
||||
dispatch(canvasSessionReset());
|
||||
cancelQueueItemsByDestination.trigger(ctx.session.id, { withToast: false });
|
||||
}, [
|
||||
selectedItemImageDTO,
|
||||
bboxRect,
|
||||
dispatch,
|
||||
selectedEntityIdentifier?.type,
|
||||
cancelQueueItemsByDestination,
|
||||
ctx.session.id,
|
||||
]);
|
||||
|
||||
useHotkeys(
|
||||
['enter'],
|
||||
acceptSelected,
|
||||
ctx.acceptSelected,
|
||||
{
|
||||
preventDefault: true,
|
||||
enabled: isCanvasFocused && shouldShowStagedImage && selectedItemImageDTO !== null,
|
||||
enabled: isCanvasFocused && shouldShowStagedImage && acceptSelectedIsEnabled,
|
||||
},
|
||||
[isCanvasFocused, shouldShowStagedImage, selectedItemImageDTO]
|
||||
[ctx.acceptSelected, isCanvasFocused, shouldShowStagedImage, acceptSelectedIsEnabled]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -66,9 +34,9 @@ export const StagingAreaToolbarAcceptButton = memo(() => {
|
||||
tooltip={`${t('common.accept')} (Enter)`}
|
||||
aria-label={`${t('common.accept')} (Enter)`}
|
||||
icon={<PiCheckBold />}
|
||||
onClick={acceptSelected}
|
||||
onClick={ctx.acceptSelected}
|
||||
colorScheme="invokeBlue"
|
||||
isDisabled={!selectedItemImageDTO || !shouldShowStagedImage || cancelQueueItemsByDestination.isDisabled}
|
||||
isDisabled={!acceptSelectedIsEnabled || !shouldShowStagedImage || cancelQueueItemsByDestination.isDisabled}
|
||||
isLoading={cancelQueueItemsByDestination.isLoading}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { useCancelQueueItemsByDestination } from 'features/queue/hooks/useCancelQueueItemsByDestination';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiTrashSimpleBold } from 'react-icons/pi';
|
||||
|
||||
export const StagingAreaToolbarDiscardAllButton = memo(({ isDisabled }: { isDisabled?: boolean }) => {
|
||||
const ctx = useCanvasSessionContext();
|
||||
export const StagingAreaToolbarDiscardAllButton = memo(() => {
|
||||
const canvasManager = useCanvasManager();
|
||||
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
|
||||
|
||||
const ctx = useStagingAreaContext();
|
||||
const { t } = useTranslation();
|
||||
const cancelQueueItemsByDestination = useCancelQueueItemsByDestination();
|
||||
|
||||
const discardAll = useCallback(() => {
|
||||
ctx.discardAll();
|
||||
cancelQueueItemsByDestination.trigger(ctx.session.id, { withToast: false });
|
||||
}, [cancelQueueItemsByDestination, ctx]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
tooltip={`${t('controlLayers.stagingArea.discardAll')} (Esc)`}
|
||||
aria-label={t('controlLayers.stagingArea.discardAll')}
|
||||
icon={<PiTrashSimpleBold />}
|
||||
onClick={discardAll}
|
||||
onClick={ctx.discardAll}
|
||||
colorScheme="error"
|
||||
isDisabled={isDisabled || cancelQueueItemsByDestination.isDisabled}
|
||||
isDisabled={cancelQueueItemsByDestination.isDisabled || !shouldShowStagedImage}
|
||||
isLoading={cancelQueueItemsByDestination.isLoading}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,34 +1,30 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiXBold } from 'react-icons/pi';
|
||||
|
||||
export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { isDisabled?: boolean }) => {
|
||||
const ctx = useCanvasSessionContext();
|
||||
export const StagingAreaToolbarDiscardSelectedButton = memo(() => {
|
||||
const canvasManager = useCanvasManager();
|
||||
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
|
||||
|
||||
const ctx = useStagingAreaContext();
|
||||
const cancelQueueItem = useCancelQueueItem();
|
||||
const selectedItemId = useStore(ctx.$selectedItemId);
|
||||
const discardSelectedIsEnabled = useStore(ctx.$discardSelectedIsEnabled);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const discardSelected = useCallback(async () => {
|
||||
if (selectedItemId === null) {
|
||||
return;
|
||||
}
|
||||
ctx.discard(selectedItemId);
|
||||
await cancelQueueItem.trigger(selectedItemId, { withToast: false });
|
||||
}, [selectedItemId, ctx, cancelQueueItem]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
tooltip={t('controlLayers.stagingArea.discard')}
|
||||
aria-label={t('controlLayers.stagingArea.discard')}
|
||||
icon={<PiXBold />}
|
||||
onClick={discardSelected}
|
||||
onClick={ctx.discardSelected}
|
||||
colorScheme="invokeBlue"
|
||||
isDisabled={selectedItemId === null || cancelQueueItem.isDisabled || isDisabled}
|
||||
isDisabled={!discardSelectedIsEnabled || cancelQueueItem.isDisabled || !shouldShowStagedImage}
|
||||
isLoading={cancelQueueItem.isLoading}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { Button } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
export const StagingAreaToolbarImageCountButton = memo(() => {
|
||||
const ctx = useCanvasSessionContext();
|
||||
const selectItemIndex = useStore(ctx.$selectedItemIndex);
|
||||
const canvasManager = useCanvasManager();
|
||||
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
|
||||
|
||||
const ctx = useStagingAreaContext();
|
||||
const selectedItem = useStore(ctx.$selectedItem);
|
||||
const itemCount = useStore(ctx.$itemCount);
|
||||
|
||||
const counterText = useMemo(() => {
|
||||
if (itemCount > 0 && selectItemIndex !== null) {
|
||||
return `${selectItemIndex + 1} of ${itemCount}`;
|
||||
if (itemCount > 0 && selectedItem !== null) {
|
||||
return `${selectedItem.index + 1} of ${itemCount}`;
|
||||
} else {
|
||||
return `0 of 0`;
|
||||
}
|
||||
}, [itemCount, selectItemIndex]);
|
||||
}, [itemCount, selectedItem]);
|
||||
|
||||
return (
|
||||
<Button colorScheme="base" pointerEvents="none" minW={28}>
|
||||
<Button colorScheme="base" pointerEvents="none" minW={28} isDisabled={!shouldShowStagedImage}>
|
||||
{counterText}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { StagingAreaToolbarNewLayerFromImageMenuItems } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { memo } from 'react';
|
||||
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
|
||||
|
||||
export const StagingAreaToolbarMenu = memo(() => {
|
||||
const canvasManager = useCanvasManager();
|
||||
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<MenuButton as={IconButton} icon={<PiDotsThreeVerticalBold />} colorScheme="invokeBlue" />
|
||||
<MenuButton
|
||||
tooltip="Image Actions"
|
||||
as={IconButton}
|
||||
icon={<PiDotsThreeVerticalBold />}
|
||||
colorScheme="invokeBlue"
|
||||
isDisabled={!shouldShowStagedImage}
|
||||
/>
|
||||
<MenuList>
|
||||
<StagingAreaToolbarNewLayerFromImageMenuItems />
|
||||
</MenuList>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MenuGroup, MenuItem } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { NewLayerIcon } from 'features/controlLayers/components/common/icons';
|
||||
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { createNewCanvasEntityFromImage } from 'features/imageActions/actions';
|
||||
import { toast } from 'features/toast/toast';
|
||||
@@ -15,8 +15,8 @@ const uploadImageArg = { image_category: 'general', is_intermediate: true, silen
|
||||
export const StagingAreaToolbarNewLayerFromImageMenuItems = memo(() => {
|
||||
const canvasManager = useCanvasManager();
|
||||
const { t } = useTranslation();
|
||||
const ctx = useCanvasSessionContext();
|
||||
const selectedItemOutputImageDTO = useStore(ctx.$selectedItemOutputImageDTO);
|
||||
const ctx = useStagingAreaContext();
|
||||
const selectedItemImageDTO = useStore(ctx.$selectedItemImageDTO);
|
||||
const store = useAppStore();
|
||||
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
|
||||
|
||||
@@ -29,11 +29,11 @@ export const StagingAreaToolbarNewLayerFromImageMenuItems = memo(() => {
|
||||
}, [t]);
|
||||
|
||||
const onClickNewRasterLayerFromImage = useCallback(async () => {
|
||||
if (!selectedItemOutputImageDTO) {
|
||||
if (!selectedItemImageDTO) {
|
||||
return;
|
||||
}
|
||||
const { dispatch, getState } = store;
|
||||
const imageDTO = await copyImage(selectedItemOutputImageDTO.image_name, uploadImageArg);
|
||||
const imageDTO = await copyImage(selectedItemImageDTO.image_name, uploadImageArg);
|
||||
createNewCanvasEntityFromImage({
|
||||
imageDTO,
|
||||
type: 'raster_layer',
|
||||
@@ -42,14 +42,14 @@ export const StagingAreaToolbarNewLayerFromImageMenuItems = memo(() => {
|
||||
overrides: { isEnabled: false }, // We are adding the layer while staging, it should be disabled by default
|
||||
});
|
||||
toastSentToCanvas();
|
||||
}, [selectedItemOutputImageDTO, store, toastSentToCanvas]);
|
||||
}, [selectedItemImageDTO, store, toastSentToCanvas]);
|
||||
|
||||
const onClickNewControlLayerFromImage = useCallback(async () => {
|
||||
if (!selectedItemOutputImageDTO) {
|
||||
if (!selectedItemImageDTO) {
|
||||
return;
|
||||
}
|
||||
const { dispatch, getState } = store;
|
||||
const imageDTO = await copyImage(selectedItemOutputImageDTO.image_name, uploadImageArg);
|
||||
const imageDTO = await copyImage(selectedItemImageDTO.image_name, uploadImageArg);
|
||||
|
||||
createNewCanvasEntityFromImage({
|
||||
imageDTO,
|
||||
@@ -59,14 +59,14 @@ export const StagingAreaToolbarNewLayerFromImageMenuItems = memo(() => {
|
||||
overrides: { isEnabled: false }, // We are adding the layer while staging, it should be disabled by default
|
||||
});
|
||||
toastSentToCanvas();
|
||||
}, [selectedItemOutputImageDTO, store, toastSentToCanvas]);
|
||||
}, [selectedItemImageDTO, store, toastSentToCanvas]);
|
||||
|
||||
const onClickNewInpaintMaskFromImage = useCallback(async () => {
|
||||
if (!selectedItemOutputImageDTO) {
|
||||
if (!selectedItemImageDTO) {
|
||||
return;
|
||||
}
|
||||
const { dispatch, getState } = store;
|
||||
const imageDTO = await copyImage(selectedItemOutputImageDTO.image_name, uploadImageArg);
|
||||
const imageDTO = await copyImage(selectedItemImageDTO.image_name, uploadImageArg);
|
||||
|
||||
createNewCanvasEntityFromImage({
|
||||
imageDTO,
|
||||
@@ -76,14 +76,14 @@ export const StagingAreaToolbarNewLayerFromImageMenuItems = memo(() => {
|
||||
overrides: { isEnabled: false }, // We are adding the layer while staging, it should be disabled by default
|
||||
});
|
||||
toastSentToCanvas();
|
||||
}, [selectedItemOutputImageDTO, store, toastSentToCanvas]);
|
||||
}, [selectedItemImageDTO, store, toastSentToCanvas]);
|
||||
|
||||
const onClickNewRegionalGuidanceFromImage = useCallback(async () => {
|
||||
if (!selectedItemOutputImageDTO) {
|
||||
if (!selectedItemImageDTO) {
|
||||
return;
|
||||
}
|
||||
const { dispatch, getState } = store;
|
||||
const imageDTO = await copyImage(selectedItemOutputImageDTO.image_name, uploadImageArg);
|
||||
const imageDTO = await copyImage(selectedItemImageDTO.image_name, uploadImageArg);
|
||||
|
||||
createNewCanvasEntityFromImage({
|
||||
imageDTO,
|
||||
@@ -93,35 +93,35 @@ export const StagingAreaToolbarNewLayerFromImageMenuItems = memo(() => {
|
||||
overrides: { isEnabled: false }, // We are adding the layer while staging, it should be disabled by default
|
||||
});
|
||||
toastSentToCanvas();
|
||||
}, [selectedItemOutputImageDTO, store, toastSentToCanvas]);
|
||||
}, [selectedItemImageDTO, store, toastSentToCanvas]);
|
||||
|
||||
return (
|
||||
<MenuGroup title="New Layer From Image">
|
||||
<MenuItem
|
||||
icon={<NewLayerIcon />}
|
||||
onClickCapture={onClickNewInpaintMaskFromImage}
|
||||
isDisabled={!selectedItemOutputImageDTO || !shouldShowStagedImage}
|
||||
isDisabled={!selectedItemImageDTO || !shouldShowStagedImage}
|
||||
>
|
||||
{t('controlLayers.inpaintMask')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<NewLayerIcon />}
|
||||
onClickCapture={onClickNewRegionalGuidanceFromImage}
|
||||
isDisabled={!selectedItemOutputImageDTO || !shouldShowStagedImage}
|
||||
isDisabled={!selectedItemImageDTO || !shouldShowStagedImage}
|
||||
>
|
||||
{t('controlLayers.regionalGuidance')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<NewLayerIcon />}
|
||||
onClickCapture={onClickNewControlLayerFromImage}
|
||||
isDisabled={!selectedItemOutputImageDTO || !shouldShowStagedImage}
|
||||
isDisabled={!selectedItemImageDTO || !shouldShowStagedImage}
|
||||
>
|
||||
{t('controlLayers.controlLayer')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<NewLayerIcon />}
|
||||
onClickCapture={onClickNewRasterLayerFromImage}
|
||||
isDisabled={!selectedItemOutputImageDTO || !shouldShowStagedImage}
|
||||
isDisabled={!selectedItemImageDTO || !shouldShowStagedImage}
|
||||
>
|
||||
{t('controlLayers.rasterLayer')}
|
||||
</MenuItem>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowRightBold } from 'react-icons/pi';
|
||||
|
||||
export const StagingAreaToolbarNextButton = memo(({ isDisabled }: { isDisabled?: boolean }) => {
|
||||
const ctx = useCanvasSessionContext();
|
||||
export const StagingAreaToolbarNextButton = memo(() => {
|
||||
const canvasManager = useCanvasManager();
|
||||
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
|
||||
|
||||
const ctx = useStagingAreaContext();
|
||||
const itemCount = useStore(ctx.$itemCount);
|
||||
const isCanvasFocused = useIsRegionFocused('canvas');
|
||||
|
||||
@@ -23,9 +27,9 @@ export const StagingAreaToolbarNextButton = memo(({ isDisabled }: { isDisabled?:
|
||||
ctx.selectNext,
|
||||
{
|
||||
preventDefault: true,
|
||||
enabled: isCanvasFocused && !isDisabled && itemCount > 1,
|
||||
enabled: isCanvasFocused && shouldShowStagedImage && itemCount > 1,
|
||||
},
|
||||
[isCanvasFocused, isDisabled, itemCount, ctx.selectNext]
|
||||
[isCanvasFocused, shouldShowStagedImage, itemCount, ctx.selectNext]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -35,7 +39,7 @@ export const StagingAreaToolbarNextButton = memo(({ isDisabled }: { isDisabled?:
|
||||
icon={<PiArrowRightBold />}
|
||||
onClick={selectNext}
|
||||
colorScheme="invokeBlue"
|
||||
isDisabled={itemCount <= 1 || isDisabled}
|
||||
isDisabled={itemCount <= 1 || !shouldShowStagedImage}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowLeftBold } from 'react-icons/pi';
|
||||
|
||||
export const StagingAreaToolbarPrevButton = memo(({ isDisabled }: { isDisabled?: boolean }) => {
|
||||
const ctx = useCanvasSessionContext();
|
||||
export const StagingAreaToolbarPrevButton = memo(() => {
|
||||
const canvasManager = useCanvasManager();
|
||||
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
|
||||
const ctx = useStagingAreaContext();
|
||||
const itemCount = useStore(ctx.$itemCount);
|
||||
const isCanvasFocused = useIsRegionFocused('canvas');
|
||||
|
||||
@@ -23,9 +26,9 @@ export const StagingAreaToolbarPrevButton = memo(({ isDisabled }: { isDisabled?:
|
||||
ctx.selectPrev,
|
||||
{
|
||||
preventDefault: true,
|
||||
enabled: isCanvasFocused && !isDisabled && itemCount > 1,
|
||||
enabled: isCanvasFocused && shouldShowStagedImage && itemCount > 1,
|
||||
},
|
||||
[isCanvasFocused, isDisabled, itemCount, ctx.selectPrev]
|
||||
[isCanvasFocused, shouldShowStagedImage, itemCount, ctx.selectPrev]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -35,7 +38,7 @@ export const StagingAreaToolbarPrevButton = memo(({ isDisabled }: { isDisabled?:
|
||||
icon={<PiArrowLeftBold />}
|
||||
onClick={selectPrev}
|
||||
colorScheme="invokeBlue"
|
||||
isDisabled={itemCount <= 1 || isDisabled}
|
||||
isDisabled={itemCount <= 1 || !shouldShowStagedImage}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { withResultAsync } from 'common/util/result';
|
||||
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
|
||||
import { toast } from 'features/toast/toast';
|
||||
@@ -16,14 +16,14 @@ const TOAST_ID = 'SAVE_STAGING_AREA_IMAGE_TO_GALLERY';
|
||||
export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => {
|
||||
const canvasManager = useCanvasManager();
|
||||
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
|
||||
const ctx = useCanvasSessionContext();
|
||||
const selectedItemOutputImageDTO = useStore(ctx.$selectedItemOutputImageDTO);
|
||||
const ctx = useStagingAreaContext();
|
||||
const selectedItemImageDTO = useStore(ctx.$selectedItemImageDTO);
|
||||
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const saveSelectedImageToGallery = useCallback(async () => {
|
||||
if (!selectedItemOutputImageDTO) {
|
||||
if (!selectedItemImageDTO) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => {
|
||||
// the gallery without borking the canvas, which may need this image to exist.
|
||||
const result = await withResultAsync(async () => {
|
||||
// Create a new file with the same name, which we will upload
|
||||
await copyImage(selectedItemOutputImageDTO.image_name, {
|
||||
await copyImage(selectedItemImageDTO.image_name, {
|
||||
// Image should show up in the Images tab
|
||||
image_category: 'general',
|
||||
is_intermediate: false,
|
||||
@@ -55,7 +55,7 @@ export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => {
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
}, [autoAddBoardId, selectedItemOutputImageDTO, t]);
|
||||
}, [autoAddBoardId, selectedItemImageDTO, t]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
@@ -64,7 +64,7 @@ export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => {
|
||||
icon={<PiFloppyDiskBold />}
|
||||
onClick={saveSelectedImageToGallery}
|
||||
colorScheme="invokeBlue"
|
||||
isDisabled={!selectedItemOutputImageDTO || !shouldShowStagedImage}
|
||||
isDisabled={!selectedItemImageDTO || !shouldShowStagedImage}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { merge } from 'es-toolkit';
|
||||
import type { StagingAreaAppApi } from 'features/controlLayers/components/StagingArea/state';
|
||||
import type { AutoSwitchMode } from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import type { ImageDTO, S } from 'services/api/types';
|
||||
import type { PartialDeep } from 'type-fest';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export const createMockStagingAreaApp = (): StagingAreaAppApi & {
|
||||
// Additional methods for testing
|
||||
_triggerItemsChanged: (items: S['SessionQueueItem'][]) => void;
|
||||
_triggerQueueItemStatusChanged: (data: S['QueueItemStatusChangedEvent']) => void;
|
||||
_triggerInvocationProgress: (data: S['InvocationProgressEvent']) => void;
|
||||
_setAutoSwitchMode: (mode: AutoSwitchMode) => void;
|
||||
_setImageDTO: (imageName: string, imageDTO: ImageDTO | null) => void;
|
||||
_setLoadImageDelay: (delay: number) => void;
|
||||
} => {
|
||||
const itemsChangedHandlers = new Set<(items: S['SessionQueueItem'][]) => void>();
|
||||
const queueItemStatusChangedHandlers = new Set<(data: S['QueueItemStatusChangedEvent']) => void>();
|
||||
const invocationProgressHandlers = new Set<(data: S['InvocationProgressEvent']) => void>();
|
||||
|
||||
let autoSwitchMode: AutoSwitchMode = 'switch_on_start';
|
||||
const imageDTOs = new Map<string, ImageDTO | null>();
|
||||
let loadImageDelay = 0;
|
||||
|
||||
return {
|
||||
onDiscard: vi.fn(),
|
||||
onDiscardAll: vi.fn(),
|
||||
onAccept: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
onSelectPrev: vi.fn(),
|
||||
onSelectNext: vi.fn(),
|
||||
onSelectFirst: vi.fn(),
|
||||
onSelectLast: vi.fn(),
|
||||
getAutoSwitch: vi.fn(() => autoSwitchMode),
|
||||
onAutoSwitchChange: vi.fn(),
|
||||
getImageDTO: vi.fn((imageName: string) => {
|
||||
return Promise.resolve(imageDTOs.get(imageName) || null);
|
||||
}),
|
||||
loadImage: vi.fn(async (imageName: string) => {
|
||||
if (loadImageDelay > 0) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, loadImageDelay);
|
||||
});
|
||||
}
|
||||
// Mock HTMLImageElement for testing environment
|
||||
const mockImage = {
|
||||
src: imageName,
|
||||
width: 512,
|
||||
height: 512,
|
||||
onload: null,
|
||||
onerror: null,
|
||||
} as HTMLImageElement;
|
||||
return mockImage;
|
||||
}),
|
||||
onItemsChanged: vi.fn((handler) => {
|
||||
itemsChangedHandlers.add(handler);
|
||||
return () => itemsChangedHandlers.delete(handler);
|
||||
}),
|
||||
onQueueItemStatusChanged: vi.fn((handler) => {
|
||||
queueItemStatusChangedHandlers.add(handler);
|
||||
return () => queueItemStatusChangedHandlers.delete(handler);
|
||||
}),
|
||||
onInvocationProgress: vi.fn((handler) => {
|
||||
invocationProgressHandlers.add(handler);
|
||||
return () => invocationProgressHandlers.delete(handler);
|
||||
}),
|
||||
|
||||
// Testing helper methods
|
||||
_triggerItemsChanged: (items: S['SessionQueueItem'][]) => {
|
||||
itemsChangedHandlers.forEach((handler) => handler(items));
|
||||
},
|
||||
_triggerQueueItemStatusChanged: (data: S['QueueItemStatusChangedEvent']) => {
|
||||
queueItemStatusChangedHandlers.forEach((handler) => handler(data));
|
||||
},
|
||||
_triggerInvocationProgress: (data: S['InvocationProgressEvent']) => {
|
||||
invocationProgressHandlers.forEach((handler) => handler(data));
|
||||
},
|
||||
_setAutoSwitchMode: (mode: AutoSwitchMode) => {
|
||||
autoSwitchMode = mode;
|
||||
},
|
||||
_setImageDTO: (imageName: string, imageDTO: ImageDTO | null) => {
|
||||
imageDTOs.set(imageName, imageDTO);
|
||||
},
|
||||
_setLoadImageDelay: (delay: number) => {
|
||||
loadImageDelay = delay;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const createMockQueueItem = (overrides: PartialDeep<S['SessionQueueItem']> = {}): S['SessionQueueItem'] =>
|
||||
merge(
|
||||
{
|
||||
item_id: 1,
|
||||
batch_id: 'test-batch-id',
|
||||
session_id: 'test-session',
|
||||
queue_id: 'test-queue-id',
|
||||
status: 'pending',
|
||||
priority: 0,
|
||||
origin: null,
|
||||
destination: 'test-session',
|
||||
error_type: null,
|
||||
error_message: null,
|
||||
error_traceback: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
field_values: null,
|
||||
retried_from_item_id: null,
|
||||
is_api_validation_run: false,
|
||||
published_workflow_id: null,
|
||||
session: {
|
||||
id: 'test-session',
|
||||
graph: {},
|
||||
execution_graph: {},
|
||||
executed: [],
|
||||
executed_history: [],
|
||||
results: {
|
||||
'test-node-id': {
|
||||
image: {
|
||||
image_name: 'test-image.png',
|
||||
},
|
||||
},
|
||||
},
|
||||
errors: {},
|
||||
prepared_source_mapping: {},
|
||||
source_prepared_mapping: {
|
||||
canvas_output: ['test-node-id'],
|
||||
},
|
||||
},
|
||||
workflow: null,
|
||||
},
|
||||
overrides
|
||||
) as S['SessionQueueItem'];
|
||||
|
||||
export const createMockImageDTO = (overrides: Partial<ImageDTO> = {}): ImageDTO => ({
|
||||
image_name: 'test-image.png',
|
||||
image_url: 'http://test.com/test-image.png',
|
||||
thumbnail_url: 'http://test.com/test-image-thumb.png',
|
||||
image_origin: 'internal',
|
||||
image_category: 'general',
|
||||
width: 512,
|
||||
height: 512,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
deleted_at: null,
|
||||
is_intermediate: false,
|
||||
starred: false,
|
||||
has_workflow: false,
|
||||
session_id: 'test-session',
|
||||
node_id: 'test-node-id',
|
||||
board_id: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const createMockProgressEvent = (
|
||||
overrides: PartialDeep<S['InvocationProgressEvent']> = {}
|
||||
): S['InvocationProgressEvent'] =>
|
||||
merge(
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
queue_id: 'test-queue-id',
|
||||
item_id: 1,
|
||||
batch_id: 'test-batch-id',
|
||||
session_id: 'test-session',
|
||||
origin: null,
|
||||
destination: 'test-session',
|
||||
invocation: {},
|
||||
invocation_source_id: 'test-invocation-source-id',
|
||||
message: 'Processing...',
|
||||
percentage: 50,
|
||||
image: null,
|
||||
} as S['InvocationProgressEvent'],
|
||||
overrides
|
||||
);
|
||||
|
||||
export const createMockQueueItemStatusChangedEvent = (
|
||||
overrides: PartialDeep<S['QueueItemStatusChangedEvent']> = {}
|
||||
): S['QueueItemStatusChangedEvent'] =>
|
||||
merge(
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
queue_id: 'test-queue-id',
|
||||
item_id: 1,
|
||||
batch_id: 'test-batch-id',
|
||||
origin: null,
|
||||
destination: 'test-session',
|
||||
status: 'completed',
|
||||
error_type: null,
|
||||
error_message: null,
|
||||
} as S['QueueItemStatusChangedEvent'],
|
||||
overrides
|
||||
);
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { loadImage } from 'features/controlLayers/konva/util';
|
||||
import {
|
||||
selectStagingAreaAutoSwitch,
|
||||
settingsStagingAreaAutoSwitchChanged,
|
||||
} from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice';
|
||||
import {
|
||||
buildSelectCanvasQueueItems,
|
||||
canvasQueueItemDiscarded,
|
||||
canvasSessionReset,
|
||||
} from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { selectBboxRect, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
|
||||
import type { CanvasRasterLayerState } from 'features/controlLayers/store/types';
|
||||
import { imageNameToImageObject } from 'features/controlLayers/store/util';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { createContext, memo, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { getImageDTOSafe } from 'services/api/endpoints/images';
|
||||
import { queueApi } from 'services/api/endpoints/queue';
|
||||
import type { S } from 'services/api/types';
|
||||
import { $socket } from 'services/events/stores';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
import type { ProgressData, StagingAreaAppApi } from './state';
|
||||
import { getInitialProgressData, StagingAreaApi } from './state';
|
||||
|
||||
const StagingAreaContext = createContext<StagingAreaApi | null>(null);
|
||||
|
||||
export const StagingAreaContextProvider = memo(({ children, sessionId }: PropsWithChildren<{ sessionId: string }>) => {
|
||||
const store = useAppStore();
|
||||
const socket = useStore($socket);
|
||||
const stagingAreaAppApi = useMemo<StagingAreaAppApi>(() => {
|
||||
const selectQueueItems = buildSelectCanvasQueueItems(sessionId);
|
||||
|
||||
const _stagingAreaAppApi: StagingAreaAppApi = {
|
||||
getAutoSwitch: () => selectStagingAreaAutoSwitch(store.getState()),
|
||||
getImageDTO: (imageName: string) => getImageDTOSafe(imageName),
|
||||
loadImage: (imageUrl: string) => loadImage(imageUrl, true),
|
||||
onInvocationProgress: (handler) => {
|
||||
socket?.on('invocation_progress', handler);
|
||||
return () => {
|
||||
socket?.off('invocation_progress', handler);
|
||||
};
|
||||
},
|
||||
onQueueItemStatusChanged: (handler) => {
|
||||
socket?.on('queue_item_status_changed', handler);
|
||||
return () => {
|
||||
socket?.off('queue_item_status_changed', handler);
|
||||
};
|
||||
},
|
||||
onItemsChanged: (handler) => {
|
||||
let prev: S['SessionQueueItem'][] = [];
|
||||
return store.subscribe(() => {
|
||||
const next = selectQueueItems(store.getState());
|
||||
if (prev !== next) {
|
||||
prev = next;
|
||||
handler(next);
|
||||
}
|
||||
});
|
||||
},
|
||||
onDiscard: ({ item_id, status }) => {
|
||||
store.dispatch(canvasQueueItemDiscarded({ itemId: item_id }));
|
||||
if (status === 'in_progress' || status === 'pending') {
|
||||
store.dispatch(queueApi.endpoints.cancelQueueItem.initiate({ item_id }, { track: false }));
|
||||
}
|
||||
},
|
||||
onDiscardAll: () => {
|
||||
store.dispatch(canvasSessionReset());
|
||||
store.dispatch(
|
||||
queueApi.endpoints.cancelQueueItemsByDestination.initiate({ destination: sessionId }, { track: false })
|
||||
);
|
||||
},
|
||||
onAccept: (item, imageDTO) => {
|
||||
const bboxRect = selectBboxRect(store.getState());
|
||||
const { x, y, width, height } = bboxRect;
|
||||
const imageObject = imageNameToImageObject(imageDTO.image_name, { width, height });
|
||||
const selectedEntityIdentifier = selectSelectedEntityIdentifier(store.getState());
|
||||
const overrides: Partial<CanvasRasterLayerState> = {
|
||||
position: { x, y },
|
||||
objects: [imageObject],
|
||||
};
|
||||
|
||||
store.dispatch(rasterLayerAdded({ overrides, isSelected: selectedEntityIdentifier?.type === 'raster_layer' }));
|
||||
store.dispatch(canvasSessionReset());
|
||||
store.dispatch(
|
||||
queueApi.endpoints.cancelQueueItemsByDestination.initiate({ destination: sessionId }, { track: false })
|
||||
);
|
||||
},
|
||||
onAutoSwitchChange: (mode) => {
|
||||
store.dispatch(settingsStagingAreaAutoSwitchChanged(mode));
|
||||
},
|
||||
};
|
||||
|
||||
return _stagingAreaAppApi;
|
||||
}, [sessionId, socket, store]);
|
||||
|
||||
const [stagingAreaApi] = useState(() => new StagingAreaApi());
|
||||
|
||||
useEffect(() => {
|
||||
stagingAreaApi.connectToApp(sessionId, stagingAreaAppApi);
|
||||
|
||||
// We need to subscribe to the queue items query manually to ensure the staging area actually gets the items
|
||||
const { unsubscribe: unsubQueueItemsQuery } = store.dispatch(
|
||||
queueApi.endpoints.listAllQueueItems.initiate({ destination: sessionId })
|
||||
);
|
||||
|
||||
return () => {
|
||||
stagingAreaApi.cleanup();
|
||||
unsubQueueItemsQuery();
|
||||
};
|
||||
}, [sessionId, stagingAreaApi, stagingAreaAppApi, store]);
|
||||
|
||||
return <StagingAreaContext.Provider value={stagingAreaApi}>{children}</StagingAreaContext.Provider>;
|
||||
});
|
||||
StagingAreaContextProvider.displayName = 'StagingAreaContextProvider';
|
||||
|
||||
export const useStagingAreaContext = () => {
|
||||
const ctx = useContext(StagingAreaContext);
|
||||
assert(ctx !== null, "'useStagingAreaContext' must be used within a StagingAreaContextProvider");
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const useOutputImageDTO = (itemId: number) => {
|
||||
const ctx = useStagingAreaContext();
|
||||
const allProgressData = useStore(ctx.$progressData, { keys: [itemId] });
|
||||
return allProgressData[itemId]?.imageDTO ?? null;
|
||||
};
|
||||
|
||||
export const useProgressDatum = (itemId: number): ProgressData => {
|
||||
const ctx = useStagingAreaContext();
|
||||
const allProgressData = useStore(ctx.$progressData, { keys: [itemId] });
|
||||
return allProgressData[itemId] ?? getInitialProgressData(itemId);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { S } from 'services/api/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getOutputImageName, getProgressMessage, getQueueItemElementId } from './shared';
|
||||
|
||||
describe('StagingAreaApi Utility Functions', () => {
|
||||
describe('getProgressMessage', () => {
|
||||
it('should return default message when no data provided', () => {
|
||||
expect(getProgressMessage()).toBe('Generating');
|
||||
expect(getProgressMessage(null)).toBe('Generating');
|
||||
});
|
||||
|
||||
it('should format progress message when data is provided', () => {
|
||||
const progressEvent: S['InvocationProgressEvent'] = {
|
||||
item_id: 1,
|
||||
destination: 'test-session',
|
||||
node_id: 'test-node',
|
||||
source_node_id: 'test-source-node',
|
||||
progress: 0.5,
|
||||
message: 'Processing image...',
|
||||
image: null,
|
||||
} as unknown as S['InvocationProgressEvent'];
|
||||
|
||||
const result = getProgressMessage(progressEvent);
|
||||
expect(result).toBe('Processing image...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQueueItemElementId', () => {
|
||||
it('should generate correct element ID for queue item', () => {
|
||||
expect(getQueueItemElementId(0)).toBe('queue-item-preview-0');
|
||||
expect(getQueueItemElementId(5)).toBe('queue-item-preview-5');
|
||||
expect(getQueueItemElementId(99)).toBe('queue-item-preview-99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOutputImageName', () => {
|
||||
it('should extract image name from completed queue item', () => {
|
||||
const queueItem: S['SessionQueueItem'] = {
|
||||
item_id: 1,
|
||||
status: 'completed',
|
||||
priority: 0,
|
||||
destination: 'test-session',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
started_at: '2024-01-01T00:00:01Z',
|
||||
completed_at: '2024-01-01T00:01:00Z',
|
||||
error: null,
|
||||
session: {
|
||||
id: 'test-session',
|
||||
source_prepared_mapping: {
|
||||
canvas_output: ['output-node-id'],
|
||||
},
|
||||
results: {
|
||||
'output-node-id': {
|
||||
image: {
|
||||
image_name: 'test-output.png',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as S['SessionQueueItem'];
|
||||
|
||||
expect(getOutputImageName(queueItem)).toBe('test-output.png');
|
||||
});
|
||||
|
||||
it('should return null when no canvas output node found', () => {
|
||||
const queueItem = {
|
||||
item_id: 1,
|
||||
status: 'completed',
|
||||
priority: 0,
|
||||
destination: 'test-session',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
started_at: '2024-01-01T00:00:01Z',
|
||||
completed_at: '2024-01-01T00:01:00Z',
|
||||
error: null,
|
||||
session: {
|
||||
id: 'test-session',
|
||||
source_prepared_mapping: {
|
||||
some_other_node: ['other-node-id'],
|
||||
},
|
||||
results: {
|
||||
'other-node-id': {
|
||||
type: 'image_output',
|
||||
image: {
|
||||
image_name: 'test-output.png',
|
||||
},
|
||||
width: 512,
|
||||
height: 512,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as S['SessionQueueItem'];
|
||||
|
||||
expect(getOutputImageName(queueItem)).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null when output node has no results', () => {
|
||||
const queueItem: S['SessionQueueItem'] = {
|
||||
item_id: 1,
|
||||
status: 'completed',
|
||||
priority: 0,
|
||||
destination: 'test-session',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
started_at: '2024-01-01T00:00:01Z',
|
||||
completed_at: '2024-01-01T00:01:00Z',
|
||||
error: null,
|
||||
session: {
|
||||
id: 'test-session',
|
||||
source_prepared_mapping: {
|
||||
canvas_output: ['output-node-id'],
|
||||
},
|
||||
results: {},
|
||||
},
|
||||
} as unknown as S['SessionQueueItem'];
|
||||
|
||||
expect(getOutputImageName(queueItem)).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null when results contain no image fields', () => {
|
||||
const queueItem: S['SessionQueueItem'] = {
|
||||
item_id: 1,
|
||||
status: 'completed',
|
||||
priority: 0,
|
||||
destination: 'test-session',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
started_at: '2024-01-01T00:00:01Z',
|
||||
completed_at: '2024-01-01T00:01:00Z',
|
||||
error: null,
|
||||
session: {
|
||||
id: 'test-session',
|
||||
source_prepared_mapping: {
|
||||
canvas_output: ['output-node-id'],
|
||||
},
|
||||
results: {
|
||||
'output-node-id': {
|
||||
text: 'some text output',
|
||||
number: 42,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as S['SessionQueueItem'];
|
||||
|
||||
expect(getOutputImageName(queueItem)).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle multiple outputs and return first image', () => {
|
||||
const queueItem: S['SessionQueueItem'] = {
|
||||
item_id: 1,
|
||||
status: 'completed',
|
||||
priority: 0,
|
||||
destination: 'test-session',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
started_at: '2024-01-01T00:00:01Z',
|
||||
completed_at: '2024-01-01T00:01:00Z',
|
||||
error: null,
|
||||
session: {
|
||||
id: 'test-session',
|
||||
source_prepared_mapping: {
|
||||
canvas_output: ['output-node-id'],
|
||||
},
|
||||
results: {
|
||||
'output-node-id': {
|
||||
text: 'some text',
|
||||
first_image: {
|
||||
image_name: 'first-image.png',
|
||||
},
|
||||
second_image: {
|
||||
image_name: 'second-image.png',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as S['SessionQueueItem'];
|
||||
|
||||
const result = getOutputImageName(queueItem);
|
||||
expect(result).toBe('first-image.png');
|
||||
});
|
||||
|
||||
it('should handle empty session mapping', () => {
|
||||
const queueItem: S['SessionQueueItem'] = {
|
||||
item_id: 1,
|
||||
status: 'completed',
|
||||
priority: 0,
|
||||
destination: 'test-session',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
started_at: '2024-01-01T00:00:01Z',
|
||||
completed_at: '2024-01-01T00:01:00Z',
|
||||
error: null,
|
||||
session: {
|
||||
id: 'test-session',
|
||||
source_prepared_mapping: {},
|
||||
results: {},
|
||||
},
|
||||
} as unknown as S['SessionQueueItem'];
|
||||
|
||||
expect(getOutputImageName(queueItem)).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,784 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createMockImageDTO,
|
||||
createMockProgressEvent,
|
||||
createMockQueueItem,
|
||||
createMockQueueItemStatusChangedEvent,
|
||||
createMockStagingAreaApp,
|
||||
} from './__mocks__/mockStagingAreaApp';
|
||||
import { StagingAreaApi } from './state';
|
||||
|
||||
describe('StagingAreaApi', () => {
|
||||
let api: StagingAreaApi;
|
||||
let mockApp: ReturnType<typeof createMockStagingAreaApp>;
|
||||
const sessionId = 'test-session';
|
||||
|
||||
beforeEach(() => {
|
||||
mockApp = createMockStagingAreaApp();
|
||||
api = new StagingAreaApi();
|
||||
api.connectToApp(sessionId, mockApp);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
api.cleanup();
|
||||
});
|
||||
|
||||
describe('Constructor and Setup', () => {
|
||||
it('should initialize with correct session ID', () => {
|
||||
expect(api._sessionId).toBe(sessionId);
|
||||
});
|
||||
|
||||
it('should set up event subscriptions', () => {
|
||||
expect(mockApp.onItemsChanged).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(mockApp.onQueueItemStatusChanged).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(mockApp.onInvocationProgress).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
it('should initialize atoms with default values', () => {
|
||||
expect(api.$lastStartedItemId.get()).toBe(null);
|
||||
expect(api.$lastCompletedItemId.get()).toBe(null);
|
||||
expect(api.$items.get()).toEqual([]);
|
||||
expect(api.$progressData.get()).toEqual({});
|
||||
expect(api.$selectedItemId.get()).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Computed Values', () => {
|
||||
it('should compute item count correctly', () => {
|
||||
expect(api.$itemCount.get()).toBe(0);
|
||||
|
||||
const items = [createMockQueueItem({ item_id: 1 })];
|
||||
api.$items.set(items);
|
||||
expect(api.$itemCount.get()).toBe(1);
|
||||
});
|
||||
|
||||
it('should compute hasItems correctly', () => {
|
||||
expect(api.$hasItems.get()).toBe(false);
|
||||
|
||||
const items = [createMockQueueItem({ item_id: 1 })];
|
||||
api.$items.set(items);
|
||||
expect(api.$hasItems.get()).toBe(true);
|
||||
});
|
||||
|
||||
it('should compute isPending correctly', () => {
|
||||
expect(api.$isPending.get()).toBe(false);
|
||||
|
||||
const items = [
|
||||
createMockQueueItem({ item_id: 1, status: 'pending' }),
|
||||
createMockQueueItem({ item_id: 2, status: 'completed' }),
|
||||
];
|
||||
api.$items.set(items);
|
||||
expect(api.$isPending.get()).toBe(true);
|
||||
});
|
||||
|
||||
it('should compute selectedItem correctly', () => {
|
||||
expect(api.$selectedItem.get()).toBe(null);
|
||||
|
||||
const items = [createMockQueueItem({ item_id: 1 }), createMockQueueItem({ item_id: 2 })];
|
||||
api.$items.set(items);
|
||||
api.$selectedItemId.set(1);
|
||||
|
||||
const selectedItem = api.$selectedItem.get();
|
||||
expect(selectedItem).not.toBe(null);
|
||||
expect(selectedItem?.item.item_id).toBe(1);
|
||||
expect(selectedItem?.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should compute selectedItemImageDTO correctly', () => {
|
||||
const items = [createMockQueueItem({ item_id: 1 })];
|
||||
const imageDTO = createMockImageDTO();
|
||||
|
||||
api.$items.set(items);
|
||||
api.$selectedItemId.set(1);
|
||||
api.$progressData.setKey(1, {
|
||||
itemId: 1,
|
||||
progressEvent: null,
|
||||
progressImage: null,
|
||||
imageDTO,
|
||||
});
|
||||
|
||||
expect(api.$selectedItemImageDTO.get()).toBe(imageDTO);
|
||||
});
|
||||
|
||||
it('should compute selectedItemIndex correctly', () => {
|
||||
const items = [createMockQueueItem({ item_id: 1 }), createMockQueueItem({ item_id: 2 })];
|
||||
api.$items.set(items);
|
||||
api.$selectedItemId.set(2);
|
||||
|
||||
expect(api.$selectedItemIndex.get()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Selection Methods', () => {
|
||||
beforeEach(() => {
|
||||
const items = [
|
||||
createMockQueueItem({ item_id: 1 }),
|
||||
createMockQueueItem({ item_id: 2 }),
|
||||
createMockQueueItem({ item_id: 3 }),
|
||||
];
|
||||
api.$items.set(items);
|
||||
});
|
||||
|
||||
it('should select item by ID', () => {
|
||||
api.select(2);
|
||||
expect(api.$selectedItemId.get()).toBe(2);
|
||||
expect(mockApp.onSelect).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('should select next item', () => {
|
||||
api.$selectedItemId.set(1);
|
||||
api.selectNext();
|
||||
expect(api.$selectedItemId.get()).toBe(2);
|
||||
expect(mockApp.onSelectNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should wrap to first item when selecting next from last', () => {
|
||||
api.$selectedItemId.set(3);
|
||||
api.selectNext();
|
||||
expect(api.$selectedItemId.get()).toBe(1);
|
||||
});
|
||||
|
||||
it('should select previous item', () => {
|
||||
api.$selectedItemId.set(2);
|
||||
api.selectPrev();
|
||||
expect(api.$selectedItemId.get()).toBe(1);
|
||||
expect(mockApp.onSelectPrev).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should wrap to last item when selecting previous from first', () => {
|
||||
api.$selectedItemId.set(1);
|
||||
api.selectPrev();
|
||||
expect(api.$selectedItemId.get()).toBe(3);
|
||||
});
|
||||
|
||||
it('should select first item', () => {
|
||||
api.selectFirst();
|
||||
expect(api.$selectedItemId.get()).toBe(1);
|
||||
expect(mockApp.onSelectFirst).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should select last item', () => {
|
||||
api.selectLast();
|
||||
expect(api.$selectedItemId.get()).toBe(3);
|
||||
expect(mockApp.onSelectLast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing when no items exist', () => {
|
||||
api.$items.set([]);
|
||||
api.selectNext();
|
||||
api.selectPrev();
|
||||
api.selectFirst();
|
||||
api.selectLast();
|
||||
|
||||
expect(api.$selectedItemId.get()).toBe(null);
|
||||
});
|
||||
|
||||
it('should do nothing when no item is selected', () => {
|
||||
api.$selectedItemId.set(null);
|
||||
api.selectNext();
|
||||
api.selectPrev();
|
||||
|
||||
expect(api.$selectedItemId.get()).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Discard Methods', () => {
|
||||
beforeEach(() => {
|
||||
const items = [
|
||||
createMockQueueItem({ item_id: 1 }),
|
||||
createMockQueueItem({ item_id: 2 }),
|
||||
createMockQueueItem({ item_id: 3 }),
|
||||
];
|
||||
api.$items.set(items);
|
||||
});
|
||||
|
||||
it('should discard selected item and select next', () => {
|
||||
api.$selectedItemId.set(2);
|
||||
const selectedItem = api.$selectedItem.get();
|
||||
|
||||
api.discardSelected();
|
||||
|
||||
expect(api.$selectedItemId.get()).toBe(3);
|
||||
expect(mockApp.onDiscard).toHaveBeenCalledWith(selectedItem?.item);
|
||||
});
|
||||
|
||||
it('should discard selected item and clamp to last valid index', () => {
|
||||
api.$selectedItemId.set(3);
|
||||
const selectedItem = api.$selectedItem.get();
|
||||
|
||||
api.discardSelected();
|
||||
|
||||
// The logic clamps to the next index, so when discarding last item (index 2),
|
||||
// it tries to select index 3 which clamps to index 2 (item 3)
|
||||
expect(api.$selectedItemId.get()).toBe(3);
|
||||
expect(mockApp.onDiscard).toHaveBeenCalledWith(selectedItem?.item);
|
||||
});
|
||||
|
||||
it('should set selectedItemId to null when discarding last item', () => {
|
||||
api.$items.set([createMockQueueItem({ item_id: 1 })]);
|
||||
api.$selectedItemId.set(1);
|
||||
|
||||
api.discardSelected();
|
||||
|
||||
// When there's only one item, after clamping we get the same item, so it stays selected
|
||||
expect(api.$selectedItemId.get()).toBe(1);
|
||||
});
|
||||
|
||||
it('should do nothing when no item is selected', () => {
|
||||
api.$selectedItemId.set(null);
|
||||
api.discardSelected();
|
||||
|
||||
expect(mockApp.onDiscard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should discard all items', () => {
|
||||
api.$selectedItemId.set(2);
|
||||
api.discardAll();
|
||||
|
||||
expect(api.$selectedItemId.get()).toBe(null);
|
||||
expect(mockApp.onDiscardAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should compute discardSelectedIsEnabled correctly', () => {
|
||||
expect(api.$discardSelectedIsEnabled.get()).toBe(false);
|
||||
|
||||
api.$selectedItemId.set(1);
|
||||
expect(api.$discardSelectedIsEnabled.get()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accept Methods', () => {
|
||||
beforeEach(() => {
|
||||
const items = [createMockQueueItem({ item_id: 1 })];
|
||||
api.$items.set(items);
|
||||
api.$selectedItemId.set(1);
|
||||
});
|
||||
|
||||
it('should accept selected item when image is available', () => {
|
||||
const imageDTO = createMockImageDTO();
|
||||
api.$progressData.setKey(1, {
|
||||
itemId: 1,
|
||||
progressEvent: null,
|
||||
progressImage: null,
|
||||
imageDTO,
|
||||
});
|
||||
|
||||
const selectedItem = api.$selectedItem.get();
|
||||
api.acceptSelected();
|
||||
|
||||
expect(mockApp.onAccept).toHaveBeenCalledWith(selectedItem?.item, imageDTO);
|
||||
});
|
||||
|
||||
it('should do nothing when no image is available', () => {
|
||||
api.$progressData.setKey(1, {
|
||||
itemId: 1,
|
||||
progressEvent: null,
|
||||
progressImage: null,
|
||||
imageDTO: null,
|
||||
});
|
||||
|
||||
api.acceptSelected();
|
||||
|
||||
expect(mockApp.onAccept).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing when no item is selected', () => {
|
||||
api.$selectedItemId.set(null);
|
||||
api.acceptSelected();
|
||||
|
||||
expect(mockApp.onAccept).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should compute acceptSelectedIsEnabled correctly', () => {
|
||||
expect(api.$acceptSelectedIsEnabled.get()).toBe(false);
|
||||
|
||||
const imageDTO = createMockImageDTO();
|
||||
api.$progressData.setKey(1, {
|
||||
itemId: 1,
|
||||
progressEvent: null,
|
||||
progressImage: null,
|
||||
imageDTO,
|
||||
});
|
||||
|
||||
expect(api.$acceptSelectedIsEnabled.get()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Progress Event Handling', () => {
|
||||
it('should handle invocation progress events', () => {
|
||||
const progressEvent = createMockProgressEvent({
|
||||
item_id: 1,
|
||||
destination: sessionId,
|
||||
});
|
||||
|
||||
api.onInvocationProgressEvent(progressEvent);
|
||||
|
||||
const progressData = api.$progressData.get();
|
||||
expect(progressData[1]?.progressEvent).toBe(progressEvent);
|
||||
});
|
||||
|
||||
it('should ignore events for different sessions', () => {
|
||||
const progressEvent = createMockProgressEvent({
|
||||
item_id: 1,
|
||||
destination: 'different-session',
|
||||
});
|
||||
|
||||
api.onInvocationProgressEvent(progressEvent);
|
||||
|
||||
const progressData = api.$progressData.get();
|
||||
expect(progressData[1]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should update existing progress data', () => {
|
||||
api.$progressData.setKey(1, {
|
||||
itemId: 1,
|
||||
progressEvent: null,
|
||||
progressImage: null,
|
||||
imageDTO: createMockImageDTO(),
|
||||
});
|
||||
|
||||
const progressEvent = createMockProgressEvent({
|
||||
item_id: 1,
|
||||
destination: sessionId,
|
||||
});
|
||||
|
||||
api.onInvocationProgressEvent(progressEvent);
|
||||
|
||||
const progressData = api.$progressData.get();
|
||||
expect(progressData[1]?.progressEvent).toBe(progressEvent);
|
||||
expect(progressData[1]?.imageDTO).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Queue Item Status Change Handling', () => {
|
||||
it('should handle completed status and set last completed item', () => {
|
||||
const statusEvent = createMockQueueItemStatusChangedEvent({
|
||||
item_id: 1,
|
||||
destination: sessionId,
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
api.onQueueItemStatusChangedEvent(statusEvent);
|
||||
|
||||
expect(api.$lastCompletedItemId.get()).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle in_progress status with switch_on_start', () => {
|
||||
mockApp._setAutoSwitchMode('switch_on_start');
|
||||
|
||||
const statusEvent = createMockQueueItemStatusChangedEvent({
|
||||
item_id: 1,
|
||||
destination: sessionId,
|
||||
status: 'in_progress',
|
||||
});
|
||||
|
||||
api.onQueueItemStatusChangedEvent(statusEvent);
|
||||
|
||||
expect(api.$lastStartedItemId.get()).toBe(1);
|
||||
});
|
||||
|
||||
it('should ignore events for different sessions', () => {
|
||||
const statusEvent = createMockQueueItemStatusChangedEvent({
|
||||
item_id: 1,
|
||||
destination: 'different-session',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
api.onQueueItemStatusChangedEvent(statusEvent);
|
||||
|
||||
expect(api.$lastCompletedItemId.get()).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Items Changed Event Handling', () => {
|
||||
it('should update items and auto-select first item', async () => {
|
||||
const items = [createMockQueueItem({ item_id: 1 }), createMockQueueItem({ item_id: 2 })];
|
||||
|
||||
await api.onItemsChangedEvent(items);
|
||||
|
||||
expect(api.$items.get()).toBe(items);
|
||||
expect(api.$selectedItemId.get()).toBe(1);
|
||||
});
|
||||
|
||||
it('should clear selection when no items', async () => {
|
||||
api.$selectedItemId.set(1);
|
||||
|
||||
await api.onItemsChangedEvent([]);
|
||||
|
||||
expect(api.$selectedItemId.get()).toBe(null);
|
||||
});
|
||||
|
||||
it('should not change selection if item already selected', async () => {
|
||||
api.$selectedItemId.set(2);
|
||||
|
||||
const items = [createMockQueueItem({ item_id: 1 }), createMockQueueItem({ item_id: 2 })];
|
||||
|
||||
await api.onItemsChangedEvent(items);
|
||||
|
||||
expect(api.$selectedItemId.get()).toBe(2);
|
||||
});
|
||||
|
||||
it('should load images for completed items', async () => {
|
||||
const imageDTO = createMockImageDTO({ image_name: 'test-image.png' });
|
||||
mockApp._setImageDTO('test-image.png', imageDTO);
|
||||
|
||||
const items = [
|
||||
createMockQueueItem({
|
||||
item_id: 1,
|
||||
status: 'completed',
|
||||
}),
|
||||
];
|
||||
|
||||
await api.onItemsChangedEvent(items);
|
||||
|
||||
const progressData = api.$progressData.get();
|
||||
expect(progressData[1]?.imageDTO).toBe(imageDTO);
|
||||
});
|
||||
|
||||
it('should handle auto-switch on completion', async () => {
|
||||
mockApp._setAutoSwitchMode('switch_on_finish');
|
||||
api.$lastCompletedItemId.set(1);
|
||||
|
||||
const imageDTO = createMockImageDTO({ image_name: 'test-image.png' });
|
||||
mockApp._setImageDTO('test-image.png', imageDTO);
|
||||
|
||||
const items = [
|
||||
createMockQueueItem({
|
||||
item_id: 1,
|
||||
status: 'completed',
|
||||
}),
|
||||
];
|
||||
|
||||
await api.onItemsChangedEvent(items);
|
||||
|
||||
// Wait for async image loading - the loadImage promise needs to complete
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
|
||||
expect(api.$selectedItemId.get()).toBe(1);
|
||||
// The lastCompletedItemId should be reset after the loadImage promise resolves
|
||||
expect(api.$lastCompletedItemId.get()).toBe(null);
|
||||
});
|
||||
|
||||
it('should clean up progress data for removed items', async () => {
|
||||
api.$progressData.setKey(999, {
|
||||
itemId: 999,
|
||||
progressEvent: null,
|
||||
progressImage: null,
|
||||
imageDTO: null,
|
||||
});
|
||||
|
||||
const items = [createMockQueueItem({ item_id: 1 })];
|
||||
|
||||
await api.onItemsChangedEvent(items);
|
||||
|
||||
const progressData = api.$progressData.get();
|
||||
expect(progressData[999]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clear progress data for canceled/failed items', async () => {
|
||||
api.$progressData.setKey(1, {
|
||||
itemId: 1,
|
||||
progressEvent: createMockProgressEvent({ item_id: 1 }),
|
||||
progressImage: null,
|
||||
imageDTO: createMockImageDTO(),
|
||||
});
|
||||
|
||||
const items = [createMockQueueItem({ item_id: 1, status: 'canceled' })];
|
||||
|
||||
await api.onItemsChangedEvent(items);
|
||||
|
||||
const progressData = api.$progressData.get();
|
||||
expect(progressData[1]?.progressEvent).toBe(null);
|
||||
expect(progressData[1]?.progressImage).toBe(null);
|
||||
expect(progressData[1]?.imageDTO).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto Switch', () => {
|
||||
it('should set auto switch mode', () => {
|
||||
api.setAutoSwitch('switch_on_finish');
|
||||
expect(mockApp.onAutoSwitchChange).toHaveBeenCalledWith('switch_on_finish');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utility Methods', () => {
|
||||
it('should build isSelected computed correctly', () => {
|
||||
const isSelected = api.buildIsSelectedComputed(1);
|
||||
expect(isSelected.get()).toBe(false);
|
||||
|
||||
api.$selectedItemId.set(1);
|
||||
expect(isSelected.get()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cleanup', () => {
|
||||
it('should reset all state on cleanup', () => {
|
||||
api.$selectedItemId.set(1);
|
||||
api.$items.set([createMockQueueItem({ item_id: 1 })]);
|
||||
api.$lastStartedItemId.set(1);
|
||||
api.$lastCompletedItemId.set(1);
|
||||
api.$progressData.setKey(1, {
|
||||
itemId: 1,
|
||||
progressEvent: null,
|
||||
progressImage: null,
|
||||
imageDTO: null,
|
||||
});
|
||||
|
||||
api.cleanup();
|
||||
|
||||
expect(api.$selectedItemId.get()).toBe(null);
|
||||
expect(api.$items.get()).toEqual([]);
|
||||
expect(api.$lastStartedItemId.get()).toBe(null);
|
||||
expect(api.$lastCompletedItemId.get()).toBe(null);
|
||||
expect(api.$progressData.get()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
describe('Selection with Empty or Single Item Lists', () => {
|
||||
it('should handle selection operations with single item', () => {
|
||||
const items = [createMockQueueItem({ item_id: 1 })];
|
||||
api.$items.set(items);
|
||||
api.$selectedItemId.set(1);
|
||||
|
||||
// Navigation should wrap around to the same item
|
||||
api.selectNext();
|
||||
expect(api.$selectedItemId.get()).toBe(1);
|
||||
|
||||
api.selectPrev();
|
||||
expect(api.$selectedItemId.get()).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle selection operations with empty list', () => {
|
||||
api.$items.set([]);
|
||||
|
||||
api.selectFirst();
|
||||
api.selectLast();
|
||||
api.selectNext();
|
||||
api.selectPrev();
|
||||
|
||||
expect(api.$selectedItemId.get()).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Progress Data Edge Cases', () => {
|
||||
it('should handle progress updates with image data', () => {
|
||||
const progressEvent = createMockProgressEvent({
|
||||
item_id: 1,
|
||||
destination: sessionId,
|
||||
image: { width: 512, height: 512, dataURL: 'foo' },
|
||||
});
|
||||
|
||||
api.onInvocationProgressEvent(progressEvent);
|
||||
|
||||
const progressData = api.$progressData.get();
|
||||
expect(progressData[1]?.progressImage).toBe(progressEvent.image);
|
||||
expect(progressData[1]?.progressEvent).toBe(progressEvent);
|
||||
});
|
||||
|
||||
it('should preserve imageDTO when updating progress', () => {
|
||||
const imageDTO = createMockImageDTO();
|
||||
api.$progressData.setKey(1, {
|
||||
itemId: 1,
|
||||
progressEvent: null,
|
||||
progressImage: null,
|
||||
imageDTO,
|
||||
});
|
||||
|
||||
const progressEvent = createMockProgressEvent({
|
||||
item_id: 1,
|
||||
destination: sessionId,
|
||||
});
|
||||
|
||||
api.onInvocationProgressEvent(progressEvent);
|
||||
|
||||
const progressData = api.$progressData.get();
|
||||
expect(progressData[1]?.imageDTO).toBe(imageDTO);
|
||||
expect(progressData[1]?.progressEvent).toBe(progressEvent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto-Switch Edge Cases', () => {
|
||||
it('should handle auto-switch when item is not in current items list', async () => {
|
||||
mockApp._setAutoSwitchMode('switch_on_start');
|
||||
api.$lastStartedItemId.set(999); // Non-existent item
|
||||
|
||||
const items = [createMockQueueItem({ item_id: 1 })];
|
||||
await api.onItemsChangedEvent(items);
|
||||
|
||||
// Should not switch to non-existent item
|
||||
expect(api.$selectedItemId.get()).toBe(1);
|
||||
expect(api.$lastStartedItemId.get()).toBe(999);
|
||||
});
|
||||
|
||||
it('should handle auto-switch on finish when image loading fails', async () => {
|
||||
mockApp._setAutoSwitchMode('switch_on_finish');
|
||||
api.$lastCompletedItemId.set(1);
|
||||
|
||||
// Mock image loading failure
|
||||
mockApp._setImageDTO('test-image.png', null);
|
||||
|
||||
const items = [
|
||||
createMockQueueItem({
|
||||
item_id: 1,
|
||||
status: 'completed',
|
||||
session: {
|
||||
id: sessionId,
|
||||
source_prepared_mapping: { canvas_output: ['test-node-id'] },
|
||||
results: {
|
||||
'test-node-id': {
|
||||
type: 'image_output',
|
||||
image: { image_name: 'test-image.png' },
|
||||
width: 512,
|
||||
height: 512,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
await api.onItemsChangedEvent(items);
|
||||
|
||||
// Should not switch when image loading fails
|
||||
expect(api.$selectedItemId.get()).toBe(1);
|
||||
expect(api.$lastCompletedItemId.get()).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle auto-switch on finish with slow image loading', async () => {
|
||||
mockApp._setAutoSwitchMode('switch_on_finish');
|
||||
api.$lastCompletedItemId.set(1);
|
||||
|
||||
const imageDTO = createMockImageDTO({ image_name: 'test-image.png' });
|
||||
mockApp._setImageDTO('test-image.png', imageDTO);
|
||||
mockApp._setLoadImageDelay(50); // Add delay to image loading
|
||||
|
||||
const items = [
|
||||
createMockQueueItem({
|
||||
item_id: 1,
|
||||
status: 'completed',
|
||||
session: {
|
||||
id: sessionId,
|
||||
source_prepared_mapping: { canvas_output: ['test-node-id'] },
|
||||
results: { 'test-node-id': { image: { image_name: 'test-image.png' } } },
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
await api.onItemsChangedEvent(items);
|
||||
|
||||
// Should switch after image loads - wait for both the delay and promise resolution
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 150);
|
||||
});
|
||||
|
||||
expect(api.$selectedItemId.get()).toBe(1);
|
||||
// The lastCompletedItemId should be reset after the loadImage promise resolves
|
||||
expect(api.$lastCompletedItemId.get()).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Concurrent Operations', () => {
|
||||
it('should handle rapid item changes', async () => {
|
||||
const items1 = [createMockQueueItem({ item_id: 1 })];
|
||||
const items2 = [createMockQueueItem({ item_id: 2 })];
|
||||
|
||||
// Fire multiple events rapidly
|
||||
const promise1 = api.onItemsChangedEvent(items1);
|
||||
const promise2 = api.onItemsChangedEvent(items2);
|
||||
|
||||
await Promise.all([promise1, promise2]);
|
||||
|
||||
// Should end up with the last set of items
|
||||
expect(api.$items.get()).toBe(items2);
|
||||
// The selectedItemId retains the old value (1) but $selectedItem will be null
|
||||
// because item 1 is no longer in the items list
|
||||
expect(api.$selectedItemId.get()).toBe(1);
|
||||
expect(api.$selectedItem.get()).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle multiple progress events for same item', () => {
|
||||
const event1 = createMockProgressEvent({
|
||||
item_id: 1,
|
||||
destination: sessionId,
|
||||
percentage: 0.3,
|
||||
});
|
||||
const event2 = createMockProgressEvent({
|
||||
item_id: 1,
|
||||
destination: sessionId,
|
||||
percentage: 0.7,
|
||||
});
|
||||
|
||||
api.onInvocationProgressEvent(event1);
|
||||
api.onInvocationProgressEvent(event2);
|
||||
|
||||
const progressData = api.$progressData.get();
|
||||
expect(progressData[1]?.progressEvent).toBe(event2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory Management', () => {
|
||||
it('should clean up progress data for large number of items', async () => {
|
||||
// Create progress data for many items
|
||||
for (let i = 1; i <= 1000; i++) {
|
||||
api.$progressData.setKey(i, {
|
||||
itemId: i,
|
||||
progressEvent: null,
|
||||
progressImage: null,
|
||||
imageDTO: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Only keep a few items
|
||||
const items = [createMockQueueItem({ item_id: 1 }), createMockQueueItem({ item_id: 2 })];
|
||||
|
||||
await api.onItemsChangedEvent(items);
|
||||
|
||||
const progressData = api.$progressData.get();
|
||||
const progressDataKeys = Object.keys(progressData);
|
||||
|
||||
// Should only have progress data for current items
|
||||
expect(progressDataKeys.length).toBeLessThanOrEqual(2);
|
||||
expect(progressData[1]).toBeDefined();
|
||||
expect(progressData[2]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event Subscription Management', () => {
|
||||
it('should handle multiple subscriptions and unsubscriptions', () => {
|
||||
const api2 = new StagingAreaApi();
|
||||
api2.connectToApp(sessionId, mockApp);
|
||||
const api3 = new StagingAreaApi();
|
||||
api3.connectToApp(sessionId, mockApp);
|
||||
|
||||
// All should be subscribed
|
||||
expect(mockApp.onItemsChanged).toHaveBeenCalledTimes(3);
|
||||
|
||||
api2.cleanup();
|
||||
api3.cleanup();
|
||||
|
||||
// Should not affect original api
|
||||
expect(api.$items.get()).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle events after cleanup', () => {
|
||||
api.cleanup();
|
||||
|
||||
// These should not crash
|
||||
const progressEvent = createMockProgressEvent({
|
||||
item_id: 1,
|
||||
destination: sessionId,
|
||||
});
|
||||
|
||||
api.onInvocationProgressEvent(progressEvent);
|
||||
|
||||
// State should remain clean - but the event handler still works
|
||||
// so it will add progress data even after cleanup
|
||||
const progressData = api.$progressData.get();
|
||||
expect(progressData[1]).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,426 @@
|
||||
import { clamp } from 'es-toolkit';
|
||||
import type { AutoSwitchMode } from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import type { ProgressImage } from 'features/nodes/types/common';
|
||||
import type { MapStore } from 'nanostores';
|
||||
import { atom, computed, map } from 'nanostores';
|
||||
import type { ImageDTO, S } from 'services/api/types';
|
||||
import { objectEntries } from 'tsafe';
|
||||
|
||||
import { getOutputImageName } from './shared';
|
||||
|
||||
/**
|
||||
* Interface for the app-level API that the StagingAreaApi depends on.
|
||||
* This provides the connection between the staging area and the rest of the application.
|
||||
*/
|
||||
export type StagingAreaAppApi = {
|
||||
onDiscard?: (item: S['SessionQueueItem']) => void;
|
||||
onDiscardAll?: () => void;
|
||||
onAccept?: (item: S['SessionQueueItem'], imageDTO: ImageDTO) => void;
|
||||
onSelect?: (itemId: number) => void;
|
||||
onSelectPrev?: () => void;
|
||||
onSelectNext?: () => void;
|
||||
onSelectFirst?: () => void;
|
||||
onSelectLast?: () => void;
|
||||
getAutoSwitch: () => AutoSwitchMode;
|
||||
onAutoSwitchChange?: (mode: AutoSwitchMode) => void;
|
||||
getImageDTO: (imageName: string) => Promise<ImageDTO | null>;
|
||||
loadImage: (imageName: string) => Promise<HTMLImageElement>;
|
||||
onItemsChanged: (handler: (data: S['SessionQueueItem'][]) => Promise<void> | void) => () => void;
|
||||
onQueueItemStatusChanged: (handler: (data: S['QueueItemStatusChangedEvent']) => Promise<void> | void) => () => void;
|
||||
onInvocationProgress: (handler: (data: S['InvocationProgressEvent']) => Promise<void> | void) => () => void;
|
||||
};
|
||||
|
||||
/** Progress data for a single queue item */
|
||||
export type ProgressData = {
|
||||
itemId: number;
|
||||
progressEvent: S['InvocationProgressEvent'] | null;
|
||||
progressImage: ProgressImage | null;
|
||||
imageDTO: ImageDTO | null;
|
||||
};
|
||||
|
||||
/** Combined data for the currently selected item */
|
||||
export type SelectedItemData = {
|
||||
item: S['SessionQueueItem'];
|
||||
index: number;
|
||||
progressData: ProgressData;
|
||||
};
|
||||
|
||||
/** Creates initial progress data for a queue item */
|
||||
export const getInitialProgressData = (itemId: number): ProgressData => ({
|
||||
itemId,
|
||||
progressEvent: null,
|
||||
progressImage: null,
|
||||
imageDTO: null,
|
||||
});
|
||||
type ProgressDataMap = Record<number, ProgressData | undefined>;
|
||||
|
||||
/**
|
||||
* API for managing the Canvas Staging Area - a view of the image generation queue.
|
||||
* Provides reactive state management for pending, in-progress, and completed images.
|
||||
* Users can accept images to place on canvas, discard them, navigate between items,
|
||||
* and configure auto-switching behavior.
|
||||
*/
|
||||
export class StagingAreaApi {
|
||||
/** The current session ID. */
|
||||
_sessionId: string | null = null;
|
||||
|
||||
/** The app API */
|
||||
_app: StagingAreaAppApi | null = null;
|
||||
|
||||
/** A set of subscriptions to be cleaned up when we are finished with a session */
|
||||
_subscriptions = new Set<() => void>();
|
||||
|
||||
/** Item ID of the last started item. Used for auto-switch on start. */
|
||||
$lastStartedItemId = atom<number | null>(null);
|
||||
|
||||
/** Item ID of the last completed item. Used for auto-switch on completion. */
|
||||
$lastCompletedItemId = atom<number | null>(null);
|
||||
|
||||
/** All queue items for the current session. */
|
||||
$items = atom<S['SessionQueueItem'][]>([]);
|
||||
|
||||
/** Progress data for all items including events, images, and ImageDTOs. */
|
||||
$progressData = map<ProgressDataMap>({});
|
||||
|
||||
/** ID of the currently selected queue item, or null if none selected. */
|
||||
$selectedItemId = atom<number | null>(null);
|
||||
|
||||
/** Total number of items in the queue. */
|
||||
$itemCount = computed([this.$items], (items) => items.length);
|
||||
|
||||
/** Whether there are any items in the queue. */
|
||||
$hasItems = computed([this.$items], (items) => items.length > 0);
|
||||
|
||||
/** Whether there are any pending or in-progress items. */
|
||||
$isPending = computed([this.$items], (items) =>
|
||||
items.some((item) => item.status === 'pending' || item.status === 'in_progress')
|
||||
);
|
||||
|
||||
/** The currently selected queue item with its index and progress data, or null if none selected. */
|
||||
$selectedItem = computed(
|
||||
[this.$items, this.$selectedItemId, this.$progressData],
|
||||
(items, selectedItemId, progressData) => {
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (selectedItemId === null) {
|
||||
return null;
|
||||
}
|
||||
const item = items.find(({ item_id }) => item_id === selectedItemId);
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
item,
|
||||
index: items.findIndex(({ item_id }) => item_id === selectedItemId),
|
||||
progressData: progressData[selectedItemId] || getInitialProgressData(selectedItemId),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
/** The ImageDTO of the currently selected item, or null if none available. */
|
||||
$selectedItemImageDTO = computed([this.$selectedItem], (selectedItem) => {
|
||||
return selectedItem?.progressData.imageDTO ?? null;
|
||||
});
|
||||
|
||||
/** The index of the currently selected item, or null if none selected. */
|
||||
$selectedItemIndex = computed([this.$selectedItem], (selectedItem) => {
|
||||
return selectedItem?.index ?? null;
|
||||
});
|
||||
|
||||
/** Selects a queue item by ID. */
|
||||
select = (itemId: number) => {
|
||||
this.$selectedItemId.set(itemId);
|
||||
this._app?.onSelect?.(itemId);
|
||||
};
|
||||
|
||||
/** Selects the next item in the queue, wrapping to the first item if at the end. */
|
||||
selectNext = () => {
|
||||
const selectedItem = this.$selectedItem.get();
|
||||
if (selectedItem === null) {
|
||||
return;
|
||||
}
|
||||
const items = this.$items.get();
|
||||
const nextIndex = (selectedItem.index + 1) % items.length;
|
||||
const nextItem = items[nextIndex];
|
||||
if (!nextItem) {
|
||||
return;
|
||||
}
|
||||
this.$selectedItemId.set(nextItem.item_id);
|
||||
this._app?.onSelectNext?.();
|
||||
};
|
||||
|
||||
/** Selects the previous item in the queue, wrapping to the last item if at the beginning. */
|
||||
selectPrev = () => {
|
||||
const selectedItem = this.$selectedItem.get();
|
||||
if (selectedItem === null) {
|
||||
return;
|
||||
}
|
||||
const items = this.$items.get();
|
||||
const prevIndex = (selectedItem.index - 1 + items.length) % items.length;
|
||||
const prevItem = items[prevIndex];
|
||||
if (!prevItem) {
|
||||
return;
|
||||
}
|
||||
this.$selectedItemId.set(prevItem.item_id);
|
||||
this._app?.onSelectPrev?.();
|
||||
};
|
||||
|
||||
/** Selects the first item in the queue. */
|
||||
selectFirst = () => {
|
||||
const items = this.$items.get();
|
||||
const first = items.at(0);
|
||||
if (!first) {
|
||||
return;
|
||||
}
|
||||
this.$selectedItemId.set(first.item_id);
|
||||
this._app?.onSelectFirst?.();
|
||||
};
|
||||
|
||||
/** Selects the last item in the queue. */
|
||||
selectLast = () => {
|
||||
const items = this.$items.get();
|
||||
const last = items.at(-1);
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
this.$selectedItemId.set(last.item_id);
|
||||
this._app?.onSelectLast?.();
|
||||
};
|
||||
|
||||
/** Discards the currently selected item and selects the next available item. */
|
||||
discardSelected = () => {
|
||||
const selectedItem = this.$selectedItem.get();
|
||||
if (selectedItem === null) {
|
||||
return;
|
||||
}
|
||||
const items = this.$items.get();
|
||||
const nextIndex = clamp(selectedItem.index + 1, 0, items.length - 1);
|
||||
const nextItem = items[nextIndex];
|
||||
if (nextItem) {
|
||||
this.$selectedItemId.set(nextItem.item_id);
|
||||
} else {
|
||||
this.$selectedItemId.set(null);
|
||||
}
|
||||
this._app?.onDiscard?.(selectedItem.item);
|
||||
};
|
||||
|
||||
/** Whether the discard selected action is enabled. */
|
||||
$discardSelectedIsEnabled = computed([this.$selectedItem], (selectedItem) => {
|
||||
if (selectedItem === null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
/** Connects to the app, registering listeners and such */
|
||||
connectToApp = (sessionId: string, app: StagingAreaAppApi) => {
|
||||
if (this._sessionId !== sessionId) {
|
||||
this.cleanup();
|
||||
this._sessionId = sessionId;
|
||||
}
|
||||
this._app = app;
|
||||
|
||||
this._subscriptions.add(this._app.onItemsChanged(this.onItemsChangedEvent));
|
||||
this._subscriptions.add(this._app.onQueueItemStatusChanged(this.onQueueItemStatusChangedEvent));
|
||||
this._subscriptions.add(this._app.onInvocationProgress(this.onInvocationProgressEvent));
|
||||
};
|
||||
|
||||
/** Discards all items in the queue. */
|
||||
discardAll = () => {
|
||||
this.$selectedItemId.set(null);
|
||||
this._app?.onDiscardAll?.();
|
||||
};
|
||||
|
||||
/** Accepts the currently selected item if an image is available. */
|
||||
acceptSelected = () => {
|
||||
const selectedItem = this.$selectedItem.get();
|
||||
if (selectedItem === null) {
|
||||
return;
|
||||
}
|
||||
const progressData = this.$progressData.get();
|
||||
const datum = progressData[selectedItem.item.item_id];
|
||||
if (!datum || !datum.imageDTO) {
|
||||
return;
|
||||
}
|
||||
this._app?.onAccept?.(selectedItem.item, datum.imageDTO);
|
||||
};
|
||||
|
||||
/** Whether the accept selected action is enabled. */
|
||||
$acceptSelectedIsEnabled = computed([this.$selectedItem, this.$progressData], (selectedItem, progressData) => {
|
||||
if (selectedItem === null) {
|
||||
return false;
|
||||
}
|
||||
const datum = progressData[selectedItem.item.item_id];
|
||||
return !!datum && !!datum.imageDTO;
|
||||
});
|
||||
|
||||
/** Sets the auto-switch mode. */
|
||||
setAutoSwitch = (mode: AutoSwitchMode) => {
|
||||
this._app?.onAutoSwitchChange?.(mode);
|
||||
};
|
||||
|
||||
/** Handles invocation progress events from the WebSocket. */
|
||||
onInvocationProgressEvent = (data: S['InvocationProgressEvent']) => {
|
||||
if (data.destination !== this._sessionId) {
|
||||
return;
|
||||
}
|
||||
setProgress(this.$progressData, data);
|
||||
};
|
||||
|
||||
/** Handles queue item status change events from the WebSocket. */
|
||||
onQueueItemStatusChangedEvent = (data: S['QueueItemStatusChangedEvent']) => {
|
||||
if (data.destination !== this._sessionId) {
|
||||
return;
|
||||
}
|
||||
if (data.status === 'completed') {
|
||||
/**
|
||||
* There is an unpleasant bit of indirection here. When an item is completed, and auto-switch is set to
|
||||
* switch_on_finish, we want to load the image and switch to it. In this socket handler, we don't have
|
||||
* access to the full queue item, which we need to get the output image and load it. We get the full
|
||||
* queue items as part of the list query, so it's rather inefficient to fetch it again here.
|
||||
*
|
||||
* To reduce the number of extra network requests, we instead store this item as the last completed item.
|
||||
* Then in the progress data sync effect, we process the queue item load its image.
|
||||
*/
|
||||
this.$lastCompletedItemId.set(data.item_id);
|
||||
}
|
||||
if (data.status === 'in_progress' && this._app?.getAutoSwitch() === 'switch_on_start') {
|
||||
this.$lastStartedItemId.set(data.item_id);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles queue items changed events. Updates items, manages progress data,
|
||||
* handles auto-selection, and implements auto-switch behavior.
|
||||
*/
|
||||
onItemsChangedEvent = async (items: S['SessionQueueItem'][]) => {
|
||||
const oldItems = this.$items.get();
|
||||
|
||||
if (items === oldItems) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
// If there are no items, cannot have a selected item.
|
||||
this.$selectedItemId.set(null);
|
||||
} else if (this.$selectedItemId.get() === null && items.length > 0) {
|
||||
// If there is no selected item but there are items, select the first one.
|
||||
this.$selectedItemId.set(items[0]?.item_id ?? null);
|
||||
}
|
||||
|
||||
const progressData = this.$progressData.get();
|
||||
|
||||
const toDelete: number[] = [];
|
||||
const toUpdate: ProgressData[] = [];
|
||||
|
||||
for (const [id, datum] of objectEntries(progressData)) {
|
||||
if (!datum) {
|
||||
toDelete.push(id);
|
||||
continue;
|
||||
}
|
||||
const item = items.find(({ item_id }) => item_id === datum.itemId);
|
||||
if (!item) {
|
||||
toDelete.push(datum.itemId);
|
||||
} else if (item.status === 'canceled' || item.status === 'failed') {
|
||||
toUpdate.push({
|
||||
...datum,
|
||||
progressEvent: null,
|
||||
progressImage: null,
|
||||
imageDTO: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const datum = progressData[item.item_id];
|
||||
|
||||
if (this.$lastStartedItemId.get() === item.item_id && this._app?.getAutoSwitch() === 'switch_on_start') {
|
||||
this.$selectedItemId.set(item.item_id);
|
||||
this.$lastStartedItemId.set(null);
|
||||
}
|
||||
|
||||
if (datum?.imageDTO) {
|
||||
continue;
|
||||
}
|
||||
const outputImageName = getOutputImageName(item);
|
||||
if (!outputImageName) {
|
||||
continue;
|
||||
}
|
||||
const imageDTO = await this._app?.getImageDTO(outputImageName);
|
||||
if (!imageDTO) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// This is the load logic mentioned in the comment in the QueueItemStatusChangedEvent handler above.
|
||||
if (this.$lastCompletedItemId.get() === item.item_id && this._app?.getAutoSwitch() === 'switch_on_finish') {
|
||||
this._app.loadImage(imageDTO.image_url).then(() => {
|
||||
this.$selectedItemId.set(item.item_id);
|
||||
this.$lastCompletedItemId.set(null);
|
||||
});
|
||||
}
|
||||
|
||||
toUpdate.push({
|
||||
...getInitialProgressData(item.item_id),
|
||||
...datum,
|
||||
imageDTO,
|
||||
});
|
||||
}
|
||||
|
||||
for (const itemId of toDelete) {
|
||||
this.$progressData.setKey(itemId, undefined);
|
||||
}
|
||||
|
||||
for (const datum of toUpdate) {
|
||||
this.$progressData.setKey(datum.itemId, datum);
|
||||
}
|
||||
|
||||
this.$items.set(items);
|
||||
};
|
||||
|
||||
/** Creates a computed value that returns true if the given item ID is selected. */
|
||||
buildIsSelectedComputed = (itemId: number) => {
|
||||
return computed([this.$selectedItemId], (selectedItemId) => {
|
||||
return selectedItemId === itemId;
|
||||
});
|
||||
};
|
||||
|
||||
/** Cleans up all state and unsubscribes from all events. */
|
||||
cleanup = () => {
|
||||
this.$lastStartedItemId.set(null);
|
||||
this.$lastCompletedItemId.set(null);
|
||||
this.$items.set([]);
|
||||
this.$progressData.set({});
|
||||
this.$selectedItemId.set(null);
|
||||
this._subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this._subscriptions.clear();
|
||||
};
|
||||
}
|
||||
|
||||
/** Updates progress data for a queue item with the latest progress event. */
|
||||
const setProgress = ($progressData: MapStore<ProgressDataMap>, data: S['InvocationProgressEvent']) => {
|
||||
const progressData = $progressData.get();
|
||||
const current = progressData[data.item_id];
|
||||
if (current) {
|
||||
const next = { ...current };
|
||||
next.progressEvent = data;
|
||||
if (data.image) {
|
||||
next.progressImage = data.image;
|
||||
}
|
||||
$progressData.set({
|
||||
...progressData,
|
||||
[data.item_id]: next,
|
||||
});
|
||||
} else {
|
||||
$progressData.set({
|
||||
...progressData,
|
||||
[data.item_id]: {
|
||||
itemId: data.item_id,
|
||||
progressEvent: data,
|
||||
progressImage: data.image ?? null,
|
||||
imageDTO: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { ToolBrushButton } from 'features/controlLayers/components/Tool/ToolBrus
|
||||
import { ToolColorPickerButton } from 'features/controlLayers/components/Tool/ToolColorPickerButton';
|
||||
import { ToolMoveButton } from 'features/controlLayers/components/Tool/ToolMoveButton';
|
||||
import { ToolRectButton } from 'features/controlLayers/components/Tool/ToolRectButton';
|
||||
import React from 'react';
|
||||
|
||||
import { ToolEraserButton } from './ToolEraserButton';
|
||||
import { ToolViewButton } from './ToolViewButton';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Equals } from 'tsafe';
|
||||
import { assert } from 'tsafe';
|
||||
import { z } from 'zod/v4';
|
||||
import { z } from 'zod';
|
||||
|
||||
const zMode = z.enum(['fill', 'contain', 'cover']);
|
||||
type Mode = z.infer<typeof zMode>;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useEntityAdapter } from 'features/controlLayers/contexts/EntityAdapterC
|
||||
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
import { TRANSPARENCY_CHECKERBOARD_PATTERN_DARK_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern';
|
||||
import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
|
||||
import { memo, useEffect, useMemo, useRef } from 'react';
|
||||
import React, { memo, useEffect, useMemo, useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
const ChakraCanvas = chakra.canvas;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { getFocusedRegion } from 'common/hooks/focus';
|
||||
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
|
||||
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
|
||||
import { entityDeleted } from 'features/controlLayers/store/canvasSlice';
|
||||
import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
|
||||
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
|
||||
import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export function useCanvasDeleteLayerHotkey() {
|
||||
@@ -12,14 +12,13 @@ export function useCanvasDeleteLayerHotkey() {
|
||||
const dispatch = useAppDispatch();
|
||||
const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
|
||||
const isBusy = useCanvasIsBusy();
|
||||
const canvasRightPanelTab = useAppSelector(selectActiveTabCanvasRightPanel);
|
||||
|
||||
const deleteSelectedLayer = useCallback(() => {
|
||||
if (selectedEntityIdentifier === null || isBusy || canvasRightPanelTab !== 'layers') {
|
||||
if (selectedEntityIdentifier === null || isBusy || getFocusedRegion() !== 'layers') {
|
||||
return;
|
||||
}
|
||||
dispatch(entityDeleted({ entityIdentifier: selectedEntityIdentifier }));
|
||||
}, [canvasRightPanelTab, dispatch, isBusy, selectedEntityIdentifier]);
|
||||
}, [dispatch, isBusy, selectedEntityIdentifier]);
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'deleteSelected',
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
selectIsolatedLayerPreview,
|
||||
selectIsolatedStagingPreview,
|
||||
} from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import {
|
||||
buildSelectIsSelected,
|
||||
getSelectIsTypeHidden,
|
||||
@@ -283,7 +282,7 @@ export abstract class CanvasEntityAdapterBase<T extends CanvasEntityState, U ext
|
||||
this.subscriptions.add(
|
||||
this.manager.stateApi.createStoreSubscription(selectIsolatedStagingPreview, this.syncVisibility)
|
||||
);
|
||||
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectIsStaging, this.syncVisibility));
|
||||
this.subscriptions.add(this.manager.stagingArea.$isStaging.listen(this.syncVisibility));
|
||||
this.subscriptions.add(this.manager.stateApi.$filteringAdapter.listen(this.syncVisibility));
|
||||
this.subscriptions.add(this.manager.stateApi.$transformingAdapter.listen(this.syncVisibility));
|
||||
this.subscriptions.add(this.manager.stateApi.$segmentingAdapter.listen(this.syncVisibility));
|
||||
@@ -462,7 +461,7 @@ export abstract class CanvasEntityAdapterBase<T extends CanvasEntityState, U ext
|
||||
* This allows the user to easily see how the new generation fits in with the rest of the canvas without the
|
||||
* other layer types getting in the way.
|
||||
*/
|
||||
const isStaging = this.manager.stateApi.runSelector(selectIsStaging);
|
||||
const isStaging = this.manager.stagingArea.$isStaging.get();
|
||||
const isRasterLayer = isRasterLayerEntityIdentifier(this.entityIdentifier);
|
||||
if (isStaging && !isRasterLayer) {
|
||||
this.setVisibility(false);
|
||||
|
||||
@@ -339,7 +339,9 @@ export class CanvasStageModule extends CanvasModuleBase {
|
||||
|
||||
onStageMouseWheel = (e: KonvaEventObject<WheelEvent>) => {
|
||||
e.evt.preventDefault();
|
||||
this._snapTimeout && window.clearTimeout(this._snapTimeout);
|
||||
if (this._snapTimeout !== null) {
|
||||
window.clearTimeout(this._snapTimeout);
|
||||
}
|
||||
|
||||
if (e.evt.ctrlKey || e.evt.metaKey) {
|
||||
return;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Mutex } from 'async-mutex';
|
||||
import type { ProgressData, ProgressDataMap } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import type { SelectedItemData } from 'features/controlLayers/components/StagingArea/state';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import type { CanvasImageState } from 'features/controlLayers/store/types';
|
||||
import Konva from 'konva';
|
||||
import type { Atom } from 'nanostores';
|
||||
import { atom, effect } from 'nanostores';
|
||||
import type { Logger } from 'roarr';
|
||||
import type { S } from 'services/api/types';
|
||||
|
||||
// To get pixel sizes corresponding to our theme tokens, first find the theme token CSS var in browser dev tools.
|
||||
// For example `var(--invoke-space-8)` is equivalent to using `8` as a space prop in a component.
|
||||
@@ -121,14 +121,12 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
|
||||
this.image = null;
|
||||
|
||||
/**
|
||||
* When we change this flag, we need to re-render the staging area, which hides or shows the staged image.
|
||||
*/
|
||||
this.subscriptions.add(this.$shouldShowStagedImage.listen(this.render));
|
||||
|
||||
/**
|
||||
* Rerender when the image source changes.
|
||||
* Rerender when the anything important changes.
|
||||
*/
|
||||
this.subscriptions.add(this.$imageSrc.listen(this.render));
|
||||
this.subscriptions.add(this.$shouldShowStagedImage.listen(this.render));
|
||||
this.subscriptions.add(this.$isPending.listen(this.render));
|
||||
this.subscriptions.add(this.$isStaging.listen(this.render));
|
||||
|
||||
/**
|
||||
* Sync the $isStaging flag with the redux state. $isStaging is used by the manager to determine the global busy
|
||||
@@ -138,8 +136,7 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
|
||||
* even if the user disabled this in the last staging session.
|
||||
*/
|
||||
this.subscriptions.add(
|
||||
this.manager.stateApi.createStoreSubscription(selectIsStaging, (isStaging, oldIsStaging) => {
|
||||
this.$isStaging.set(isStaging);
|
||||
this.$isStaging.listen((isStaging, oldIsStaging) => {
|
||||
if (isStaging && !oldIsStaging) {
|
||||
this.$shouldShowStagedImage.set(true);
|
||||
}
|
||||
@@ -150,46 +147,49 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
|
||||
initialize = () => {
|
||||
this.log.debug('Initializing module');
|
||||
this.render();
|
||||
this.$isStaging.set(this.manager.stateApi.runSelector(selectIsStaging));
|
||||
};
|
||||
|
||||
connectToSession = (
|
||||
$selectedItemId: Atom<number | null>,
|
||||
$progressData: ProgressDataMap,
|
||||
$isPending: Atom<boolean>
|
||||
) => {
|
||||
const cb = (selectedItemId: number | null, progressData: Record<number, ProgressData | undefined>) => {
|
||||
if (!selectedItemId) {
|
||||
connectToSession = ($items: Atom<S['SessionQueueItem'][]>, $selectedItem: Atom<SelectedItemData | null>) => {
|
||||
const imageSrcListener = (selectedItem: SelectedItemData | null) => {
|
||||
if (!selectedItem) {
|
||||
this.$imageSrc.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const datum = progressData[selectedItemId];
|
||||
|
||||
if (datum?.imageDTO) {
|
||||
this.$imageSrc.set({ type: 'imageName', data: datum.imageDTO.image_name });
|
||||
if (selectedItem.progressData.imageDTO) {
|
||||
this.$imageSrc.set({ type: 'imageName', data: selectedItem.progressData.imageDTO.image_name });
|
||||
return;
|
||||
} else if (datum?.progressImage) {
|
||||
this.$imageSrc.set({ type: 'dataURL', data: datum.progressImage.dataURL });
|
||||
} else if (selectedItem.progressData?.progressImage) {
|
||||
this.$imageSrc.set({ type: 'dataURL', data: selectedItem.progressData.progressImage.dataURL });
|
||||
return;
|
||||
} else {
|
||||
this.$imageSrc.set(null);
|
||||
}
|
||||
};
|
||||
const unsubImageSrc = effect([$selectedItem], imageSrcListener);
|
||||
|
||||
// Run the effect & forcibly render once to initialize
|
||||
cb($selectedItemId.get(), $progressData.get());
|
||||
const isPendingListener = (items: S['SessionQueueItem'][]) => {
|
||||
this.$isPending.set(items.some((item) => item.status === 'pending' || item.status === 'in_progress'));
|
||||
};
|
||||
const unsubIsPending = effect([$items], isPendingListener);
|
||||
|
||||
const isStagingListener = (items: S['SessionQueueItem'][]) => {
|
||||
this.$isStaging.set(items.length > 0);
|
||||
};
|
||||
const unsubIsStaging = effect([$items], isStagingListener);
|
||||
|
||||
// Run the effects & forcibly render once to initialize
|
||||
isStagingListener($items.get());
|
||||
isPendingListener($items.get());
|
||||
imageSrcListener($selectedItem.get());
|
||||
this.render();
|
||||
|
||||
// Sync the $isPending flag with the computed
|
||||
const unsubIsPending = effect([$isPending], (isPending) => {
|
||||
this.$isPending.set(isPending);
|
||||
});
|
||||
|
||||
const unsubImageSrc = effect([$selectedItemId, $progressData], cb);
|
||||
|
||||
return () => {
|
||||
this.$isStaging.set(false);
|
||||
unsubIsStaging();
|
||||
this.$isPending.set(false);
|
||||
unsubIsPending();
|
||||
this.$imageSrc.set(null);
|
||||
unsubImageSrc();
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { PayloadAction, Selector } from '@reduxjs/toolkit';
|
||||
import { createSelector, createSlice } from '@reduxjs/toolkit';
|
||||
import type { PersistConfig, RootState } from 'app/store/store';
|
||||
import { zRgbaColor } from 'features/controlLayers/store/types';
|
||||
import { z } from 'zod/v4';
|
||||
import { z } from 'zod';
|
||||
|
||||
const zAutoSwitchMode = z.enum(['off', 'switch_on_start', 'switch_on_finish']);
|
||||
export type AutoSwitchMode = z.infer<typeof zAutoSwitchMode>;
|
||||
|
||||
const zCanvasSettingsState = z.object({
|
||||
/**
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import { EMPTY_ARRAY } from 'app/store/constants';
|
||||
import type { PersistConfig, RootState } from 'app/store/store';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import { canvasReset } from 'features/controlLayers/store/actions';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import { useMemo } from 'react';
|
||||
import { queueApi } from 'services/api/endpoints/queue';
|
||||
|
||||
type CanvasStagingAreaState = {
|
||||
generateSessionId: string | null;
|
||||
canvasSessionId: string | null;
|
||||
_version: 1;
|
||||
canvasSessionId: string;
|
||||
canvasDiscardedQueueItems: number[];
|
||||
};
|
||||
|
||||
const INITIAL_STATE: CanvasStagingAreaState = {
|
||||
generateSessionId: null,
|
||||
canvasSessionId: null,
|
||||
_version: 1,
|
||||
canvasSessionId: getPrefixedId('canvas'),
|
||||
canvasDiscardedQueueItems: [],
|
||||
};
|
||||
|
||||
@@ -23,46 +25,38 @@ export const canvasSessionSlice = createSlice({
|
||||
name: 'canvasSession',
|
||||
initialState: getInitialState(),
|
||||
reducers: {
|
||||
generateSessionIdChanged: (state, action: PayloadAction<{ id: string }>) => {
|
||||
const { id } = action.payload;
|
||||
state.generateSessionId = id;
|
||||
},
|
||||
generateSessionReset: (state) => {
|
||||
state.generateSessionId = null;
|
||||
},
|
||||
canvasQueueItemDiscarded: (state, action: PayloadAction<{ itemId: number }>) => {
|
||||
const { itemId } = action.payload;
|
||||
if (!state.canvasDiscardedQueueItems.includes(itemId)) {
|
||||
state.canvasDiscardedQueueItems.push(itemId);
|
||||
}
|
||||
},
|
||||
canvasSessionIdChanged: (state, action: PayloadAction<{ id: string }>) => {
|
||||
const { id } = action.payload;
|
||||
state.canvasSessionId = id;
|
||||
state.canvasDiscardedQueueItems = [];
|
||||
canvasSessionReset: {
|
||||
reducer: (state, action: PayloadAction<{ canvasSessionId: string }>) => {
|
||||
const { canvasSessionId } = action.payload;
|
||||
state.canvasSessionId = canvasSessionId;
|
||||
state.canvasDiscardedQueueItems = [];
|
||||
},
|
||||
prepare: () => {
|
||||
return {
|
||||
payload: {
|
||||
canvasSessionId: getPrefixedId('canvas'),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
canvasSessionReset: (state) => {
|
||||
state.canvasSessionId = null;
|
||||
state.canvasDiscardedQueueItems = [];
|
||||
},
|
||||
},
|
||||
extraReducers(builder) {
|
||||
builder.addCase(canvasReset, (state) => {
|
||||
state.canvasSessionId = null;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
generateSessionIdChanged,
|
||||
generateSessionReset,
|
||||
canvasSessionIdChanged,
|
||||
canvasSessionReset,
|
||||
canvasQueueItemDiscarded,
|
||||
} = canvasSessionSlice.actions;
|
||||
export const { canvasSessionReset, canvasQueueItemDiscarded } = canvasSessionSlice.actions;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
const migrate = (state: any): any => {
|
||||
if (!('_version' in state)) {
|
||||
state._version = 1;
|
||||
state.canvasSessionId = state.canvasSessionId ?? getPrefixedId('canvas');
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
@@ -74,13 +68,14 @@ export const canvasStagingAreaPersistConfig: PersistConfig<CanvasStagingAreaStat
|
||||
};
|
||||
|
||||
export const selectCanvasSessionSlice = (s: RootState) => s[canvasSessionSlice.name];
|
||||
|
||||
export const selectCanvasSessionId = createSelector(selectCanvasSessionSlice, ({ canvasSessionId }) => canvasSessionId);
|
||||
export const selectGenerateSessionId = createSelector(
|
||||
|
||||
const selectDiscardedItems = createSelector(
|
||||
selectCanvasSessionSlice,
|
||||
({ generateSessionId }) => generateSessionId
|
||||
({ canvasDiscardedQueueItems }) => canvasDiscardedQueueItems
|
||||
);
|
||||
export const buildSelectSessionQueueItems = (sessionId: string) =>
|
||||
|
||||
export const buildSelectCanvasQueueItems = (sessionId: string) =>
|
||||
createSelector(
|
||||
[queueApi.endpoints.listAllQueueItems.select({ destination: sessionId }), selectDiscardedItems],
|
||||
({ data }, discardedItems) => {
|
||||
@@ -93,21 +88,12 @@ export const buildSelectSessionQueueItems = (sessionId: string) =>
|
||||
}
|
||||
);
|
||||
|
||||
export const selectIsStaging = (state: RootState) => {
|
||||
const sessionId = selectCanvasSessionId(state);
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
const { data } = queueApi.endpoints.listAllQueueItems.select({ destination: sessionId })(state);
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
const discardedItems = selectDiscardedItems(state);
|
||||
return data.some(
|
||||
({ status, item_id }) => status !== 'canceled' && status !== 'failed' && !discardedItems.includes(item_id)
|
||||
);
|
||||
export const buildSelectIsStaging = (sessionId: string) =>
|
||||
createSelector([buildSelectCanvasQueueItems(sessionId)], (queueItems) => {
|
||||
return queueItems.length > 0;
|
||||
});
|
||||
export const useCanvasIsStaging = () => {
|
||||
const sessionId = useAppSelector(selectCanvasSessionId);
|
||||
const selector = useMemo(() => buildSelectIsStaging(sessionId), [sessionId]);
|
||||
return useAppSelector(selector);
|
||||
};
|
||||
const selectDiscardedItems = createSelector(
|
||||
selectCanvasSessionSlice,
|
||||
({ canvasDiscardedQueueItems }) => canvasDiscardedQueueItems
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { zModelIdentifierField } from 'features/nodes/types/common';
|
||||
import { Graph } from 'features/nodes/util/graph/generation/Graph';
|
||||
import type { ControlLoRAModelConfig, ControlNetModelConfig, T2IAdapterModelConfig } from 'services/api/types';
|
||||
import { assert } from 'tsafe';
|
||||
import { z } from 'zod/v4';
|
||||
import { z } from 'zod';
|
||||
|
||||
const zAjustImageChannels = z.enum([
|
||||
'Red (RGBA)',
|
||||
@@ -146,7 +146,7 @@ const zNoiseFilterConfig = z.object({
|
||||
});
|
||||
export type NoiseFilterConfig = z.infer<typeof zNoiseFilterConfig>;
|
||||
|
||||
const zFilterConfig = z.discriminatedUnion('type', [
|
||||
const _zFilterConfig = z.discriminatedUnion('type', [
|
||||
zAdjustImageFilterConfig,
|
||||
zCannyEdgeDetectionFilterConfig,
|
||||
zColorMapFilterConfig,
|
||||
@@ -164,7 +164,7 @@ const zFilterConfig = z.discriminatedUnion('type', [
|
||||
zBlurFilterConfig,
|
||||
zNoiseFilterConfig,
|
||||
]);
|
||||
export type FilterConfig = z.infer<typeof zFilterConfig>;
|
||||
export type FilterConfig = z.infer<typeof _zFilterConfig>;
|
||||
|
||||
const zFilterType = z.enum([
|
||||
'adjust_image',
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { Invocation } from 'services/api/types';
|
||||
import type { Equals } from 'tsafe';
|
||||
import { assert } from 'tsafe';
|
||||
import { describe, test } from 'vitest';
|
||||
import type { z } from 'zod/v4';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type {
|
||||
CanvasEntityIdentifier,
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from 'features/parameters/types/parameterSchemas';
|
||||
import { getImageDTOSafe } from 'services/api/endpoints/images';
|
||||
import type { JsonObject } from 'type-fest';
|
||||
import { z } from 'zod/v4';
|
||||
import { z } from 'zod';
|
||||
|
||||
const zId = z.string().min(1);
|
||||
const zName = z.string().min(1).nullable();
|
||||
@@ -82,8 +82,8 @@ const zIPMethodV2 = z.enum(['full', 'style', 'composition', 'style_strong', 'sty
|
||||
export type IPMethodV2 = z.infer<typeof zIPMethodV2>;
|
||||
export const isIPMethodV2 = (v: unknown): v is IPMethodV2 => zIPMethodV2.safeParse(v).success;
|
||||
|
||||
const zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'view', 'bbox', 'colorPicker']);
|
||||
export type Tool = z.infer<typeof zTool>;
|
||||
const _zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'view', 'bbox', 'colorPicker']);
|
||||
export type Tool = z.infer<typeof _zTool>;
|
||||
|
||||
const zPoints = z.array(z.number()).refine((points) => points.length % 2 === 0, {
|
||||
message: 'Must have an even number of coordinate components',
|
||||
@@ -106,23 +106,23 @@ export const RGBA_BLACK: RgbaColor = { r: 0, g: 0, b: 0, a: 1 };
|
||||
|
||||
const zOpacity = z.number().gte(0).lte(1);
|
||||
|
||||
const zDimensions = z.object({
|
||||
const _zDimensions = z.object({
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
});
|
||||
export type Dimensions = z.infer<typeof zDimensions>;
|
||||
export type Dimensions = z.infer<typeof _zDimensions>;
|
||||
|
||||
const zCoordinate = z.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
});
|
||||
export type Coordinate = z.infer<typeof zCoordinate>;
|
||||
const zCoordinateWithPressure = z.object({
|
||||
const _zCoordinateWithPressure = z.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
pressure: z.number(),
|
||||
});
|
||||
export type CoordinateWithPressure = z.infer<typeof zCoordinateWithPressure>;
|
||||
export type CoordinateWithPressure = z.infer<typeof _zCoordinateWithPressure>;
|
||||
|
||||
const SAM_POINT_LABELS = {
|
||||
background: -1,
|
||||
@@ -154,12 +154,12 @@ export const SAM_POINT_LABEL_STRING_TO_NUMBER: Record<SAMPointLabelString, SAMPo
|
||||
foreground: 1,
|
||||
};
|
||||
|
||||
const zSAMPoint = z.object({
|
||||
const _zSAMPoint = z.object({
|
||||
x: z.number().int().gte(0),
|
||||
y: z.number().int().gte(0),
|
||||
label: zSAMPointLabel,
|
||||
});
|
||||
type SAMPoint = z.infer<typeof zSAMPoint>;
|
||||
type SAMPoint = z.infer<typeof _zSAMPoint>;
|
||||
export type SAMPointWithId = SAMPoint & { id: string };
|
||||
|
||||
const zRect = z.object({
|
||||
@@ -170,10 +170,10 @@ const zRect = z.object({
|
||||
});
|
||||
export type Rect = z.infer<typeof zRect>;
|
||||
|
||||
const zRectWithRotation = zRect.extend({
|
||||
const _zRectWithRotation = zRect.extend({
|
||||
rotation: z.number(),
|
||||
});
|
||||
export type RectWithRotation = z.infer<typeof zRectWithRotation>;
|
||||
export type RectWithRotation = z.infer<typeof _zRectWithRotation>;
|
||||
|
||||
const zCanvasBrushLineState = z.object({
|
||||
id: zId,
|
||||
@@ -402,13 +402,13 @@ export type BoundingBoxScaleMethod = z.infer<typeof zBoundingBoxScaleMethod>;
|
||||
export const isBoundingBoxScaleMethod = (v: unknown): v is BoundingBoxScaleMethod =>
|
||||
zBoundingBoxScaleMethod.safeParse(v).success;
|
||||
|
||||
const zCanvasEntityState = z.discriminatedUnion('type', [
|
||||
const _zCanvasEntityState = z.discriminatedUnion('type', [
|
||||
zCanvasRasterLayerState,
|
||||
zCanvasControlLayerState,
|
||||
zCanvasRegionalGuidanceState,
|
||||
zCanvasInpaintMaskState,
|
||||
]);
|
||||
export type CanvasEntityState = z.infer<typeof zCanvasEntityState>;
|
||||
export type CanvasEntityState = z.infer<typeof _zCanvasEntityState>;
|
||||
|
||||
const zCanvasEntityType = z.union([
|
||||
zCanvasRasterLayerState.shape.type,
|
||||
@@ -433,7 +433,7 @@ export type LoRA = {
|
||||
|
||||
export type EphemeralProgressImage = { sessionId: string; image: ProgressImage };
|
||||
|
||||
export const zAspectRatioID = z.enum(['Free', '21:9', '9:21', '16:9', '3:2', '4:3', '1:1', '3:4', '2:3', '9:16']);
|
||||
export const zAspectRatioID = z.enum(['Free', '21:9', '16:9', '3:2', '4:3', '1:1', '3:4', '2:3', '9:16', '9:21']);
|
||||
export type AspectRatioID = z.infer<typeof zAspectRatioID>;
|
||||
export const isAspectRatioID = (v: unknown): v is AspectRatioID => zAspectRatioID.safeParse(v).success;
|
||||
export const ASPECT_RATIO_MAP: Record<Exclude<AspectRatioID, 'Free'>, { ratio: number; inverseID: AspectRatioID }> = {
|
||||
@@ -469,7 +469,7 @@ export const CHATGPT_ASPECT_RATIOS: Record<ChatGPT4oAspectRatio, Dimensions> = {
|
||||
'2:3': { width: 1024, height: 1536 },
|
||||
} as const;
|
||||
|
||||
export const zFluxKontextAspectRatioID = z.enum(['21:9', '4:3', '1:1', '3:4', '9:21', '16:9', '9:16']);
|
||||
export const zFluxKontextAspectRatioID = z.enum(['21:9', '16:9', '4:3', '1:1', '3:4', '9:16', '9:21']);
|
||||
type FluxKontextAspectRatio = z.infer<typeof zFluxKontextAspectRatioID>;
|
||||
export const isFluxKontextAspectRatioID = (v: unknown): v is z.infer<typeof zFluxKontextAspectRatioID> =>
|
||||
zFluxKontextAspectRatioID.safeParse(v).success;
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { uploadImages } from 'services/api/endpoints/images';
|
||||
import { useBoardName } from 'services/api/hooks/useBoardName';
|
||||
import type { UploadImageArg } from 'services/api/types';
|
||||
import { z } from 'zod/v4';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpg', 'image/jpeg', 'image/webp'];
|
||||
const ACCEPTED_FILE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp'];
|
||||
@@ -41,13 +41,13 @@ const zUploadFile = z
|
||||
// )
|
||||
.refine(
|
||||
(file) => {
|
||||
return ACCEPTED_IMAGE_TYPES.includes(file.type);
|
||||
return ACCEPTED_IMAGE_TYPES.includes(file.type.toLowerCase());
|
||||
},
|
||||
{ message: `File type is not supported` }
|
||||
)
|
||||
.refine(
|
||||
(file) => {
|
||||
return ACCEPTED_FILE_EXTENSIONS.some((ext) => file.name.endsWith(ext));
|
||||
return ACCEPTED_FILE_EXTENSIONS.some((ext) => file.name.toLowerCase().endsWith(ext));
|
||||
},
|
||||
{ message: `File extension is not supported` }
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { PayloadAction, Selector } from '@reduxjs/toolkit';
|
||||
import { createSelector, createSlice } from '@reduxjs/toolkit';
|
||||
import type { PersistConfig, RootState } from 'app/store/store';
|
||||
import { buildZodTypeGuard } from 'common/util/zodUtils';
|
||||
import { z } from 'zod/v4';
|
||||
import { z } from 'zod';
|
||||
|
||||
const zSeedBehaviour = z.enum(['PER_ITERATION', 'PER_PROMPT']);
|
||||
export const isSeedBehaviour = buildZodTypeGuard(zSeedBehaviour);
|
||||
|
||||
@@ -51,7 +51,7 @@ const BoardContextMenu = ({ board, children }: Props) => {
|
||||
board_id: board.board_id,
|
||||
changes: { archived: true },
|
||||
}).unwrap();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast({
|
||||
status: 'error',
|
||||
title: 'Unable to archive board',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { selectBoardsListOrderBy, selectBoardsListOrderDir } from 'features/gall
|
||||
import { boardsListOrderByChanged, boardsListOrderDirChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod/v4';
|
||||
import { z } from 'zod';
|
||||
|
||||
const zOrderBy = z.enum(['created_at', 'board_name']);
|
||||
type OrderBy = z.infer<typeof zOrderBy>;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TRANSPARENCY_CHECKERBOARD_PATTERN_DARK_DATAURL } from 'features/control
|
||||
import type { Dimensions } from 'features/controlLayers/store/types';
|
||||
import { ImageComparisonLabel } from 'features/gallery/components/ImageViewer/ImageComparisonLabel';
|
||||
import { selectComparisonFit } from 'features/gallery/store/gallerySelectors';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
|
||||
|
||||
import type { ComparisonProps } from './common';
|
||||
|
||||
@@ -14,7 +14,7 @@ import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/
|
||||
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
import type { MutableRefObject, RefObject } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
GridComponents,
|
||||
GridComputeItemKey,
|
||||
|
||||
@@ -52,12 +52,12 @@ export const useCreateStylePresetFromMetadata = (imageDTO?: ImageDTO | null) =>
|
||||
|
||||
try {
|
||||
positivePrompt = await MetadataHandlers.PositivePrompt.parse(metadata, store);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
positivePrompt = '';
|
||||
}
|
||||
try {
|
||||
negativePrompt = (await MetadataHandlers.NegativePrompt.parse(metadata, store)) ?? '';
|
||||
} catch (error) {
|
||||
} catch {
|
||||
negativePrompt = '';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { MetadataHandlers, MetadataUtils } from 'features/metadata/parsing';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
@@ -12,7 +12,7 @@ export const useRecallAll = (imageDTO: ImageDTO) => {
|
||||
const store = useAppStore();
|
||||
const tab = useAppSelector(selectActiveTab);
|
||||
const { metadata, isLoading } = useDebouncedMetadata(imageDTO.image_name);
|
||||
const isStaging = useAppSelector(selectIsStaging);
|
||||
const isStaging = useCanvasIsStaging();
|
||||
const clearStylePreset = useClearStylePresetWithToast();
|
||||
|
||||
const isEnabled = useMemo(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { MetadataUtils } from 'features/metadata/parsing';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
@@ -8,7 +8,7 @@ import type { ImageDTO } from 'services/api/types';
|
||||
export const useRecallDimensions = (imageDTO: ImageDTO) => {
|
||||
const store = useAppStore();
|
||||
const tab = useAppSelector(selectActiveTab);
|
||||
const isStaging = useAppSelector(selectIsStaging);
|
||||
const isStaging = useCanvasIsStaging();
|
||||
|
||||
const isEnabled = useMemo(() => {
|
||||
if (tab !== 'canvas' && tab !== 'generate') {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { MetadataHandlers, MetadataUtils } from 'features/metadata/parsing';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
@@ -11,7 +11,7 @@ import { useClearStylePresetWithToast } from './useClearStylePresetWithToast';
|
||||
export const useRecallRemix = (imageDTO: ImageDTO) => {
|
||||
const store = useAppStore();
|
||||
const tab = useAppSelector(selectActiveTab);
|
||||
const isStaging = useAppSelector(selectIsStaging);
|
||||
const isStaging = useCanvasIsStaging();
|
||||
const clearStylePreset = useClearStylePresetWithToast();
|
||||
|
||||
const { metadata, isLoading } = useDebouncedMetadata(imageDTO.image_name);
|
||||
|
||||
@@ -92,7 +92,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { modelsApi } from 'services/api/endpoints/models';
|
||||
import type { AnyModelConfig, ModelType } from 'services/api/types';
|
||||
import { assert } from 'tsafe';
|
||||
import z from 'zod/v4';
|
||||
import z from 'zod';
|
||||
|
||||
const MetadataLabel = ({ i18nKey }: { i18nKey: string }) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -990,7 +990,7 @@ const recallByHandlers = async (arg: {
|
||||
const value = await handler.parse(metadata, store);
|
||||
handler.recall(value, store);
|
||||
recalled.set(handler, value);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -1146,7 +1146,6 @@ export function useSingleMetadataDatum<T>(metadata: unknown, handler: SingleMeta
|
||||
return { data, recall };
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
export function useCollectionMetadataDatum<T extends any[]>(metadata: unknown, handler: CollectionMetadataHandler<T>) {
|
||||
const store = useAppStore();
|
||||
const [data, setData] = useState<Data<T>>(buildUnparsedData);
|
||||
|
||||
@@ -34,6 +34,17 @@ const ModelImage = ({ image_url }: Props) => {
|
||||
minHeight={MODEL_IMAGE_THUMBNAIL_SIZE}
|
||||
minWidth={MODEL_IMAGE_THUMBNAIL_SIZE}
|
||||
borderRadius="base"
|
||||
fallback={
|
||||
<Flex
|
||||
height={MODEL_IMAGE_THUMBNAIL_SIZE}
|
||||
minWidth={MODEL_IMAGE_THUMBNAIL_SIZE}
|
||||
borderRadius="base"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon color="base.500" as={PiImage} boxSize={FALLBACK_ICON_SIZE} />
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Box, IconButton, Image } from '@invoke-ai/ui-library';
|
||||
import { dropzoneAccept } from 'common/hooks/useImageUploadButton';
|
||||
import { typedMemo } from 'common/util/typedMemo';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { useCallback, useState } from 'react';
|
||||
@@ -72,11 +73,7 @@ const ModelImageUpload = ({ model_key, model_image }: Props) => {
|
||||
}, [model_key, t, deleteModelImage]);
|
||||
|
||||
const { getInputProps, getRootProps } = useDropzone({
|
||||
accept: {
|
||||
'image/png': ['.png'],
|
||||
'image/jpeg': ['.jpg', '.jpeg', '.png'],
|
||||
'image/webp': ['.webp'],
|
||||
},
|
||||
accept: dropzoneAccept,
|
||||
onDropAccepted,
|
||||
noDrag: true,
|
||||
multiple: false,
|
||||
|
||||
@@ -22,7 +22,11 @@ const PredictionTypeSelect = ({ control }: Props) => {
|
||||
const value = useMemo(() => options.find((o) => o.value === field.value), [field.value]);
|
||||
const onChange = useCallback<ComboboxOnChange>(
|
||||
(v) => {
|
||||
v?.value === 'none' ? field.onChange(undefined) : field.onChange(v?.value);
|
||||
if (v?.value === 'none') {
|
||||
field.onChange(undefined);
|
||||
} else {
|
||||
field.onChange(v?.value);
|
||||
}
|
||||
},
|
||||
[field]
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
TagLabel,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiPlusBold } from 'react-icons/pi';
|
||||
import { useUpdateModelMutation } from 'services/api/endpoints/models';
|
||||
|
||||
@@ -22,7 +22,6 @@ import { useConnection } from 'features/nodes/hooks/useConnection';
|
||||
import { useIsValidConnection } from 'features/nodes/hooks/useIsValidConnection';
|
||||
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
|
||||
import { useNodeCopyPaste } from 'features/nodes/hooks/useNodeCopyPaste';
|
||||
import { useSyncExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
|
||||
import {
|
||||
$addNodeCmdk,
|
||||
$cursorPos,
|
||||
@@ -83,23 +82,16 @@ export const Flow = memo(() => {
|
||||
const nodes = useAppSelector(selectNodes);
|
||||
const edges = useAppSelector(selectEdges);
|
||||
const viewport = useStore($viewport);
|
||||
const needsFit = useStore($needsFit);
|
||||
const mayUndo = useAppSelector(selectMayUndo);
|
||||
const mayRedo = useAppSelector(selectMayRedo);
|
||||
const shouldSnapToGrid = useAppSelector(selectShouldSnapToGrid);
|
||||
const selectionMode = useAppSelector(selectSelectionMode);
|
||||
const { onConnectStart, onConnect, onConnectEnd } = useConnection();
|
||||
const flowWrapper = useRef<HTMLDivElement>(null);
|
||||
const isValidConnection = useIsValidConnection();
|
||||
const cancelConnection = useReactFlowStore(selectCancelConnection);
|
||||
const updateNodeInternals = useUpdateNodeInternals();
|
||||
const store = useAppStore();
|
||||
const isWorkflowsFocused = useIsRegionFocused('workflows');
|
||||
const isLocked = useIsWorkflowEditorLocked();
|
||||
|
||||
useFocusRegion('workflows', flowWrapper);
|
||||
|
||||
useSyncExecutionState();
|
||||
const [borderRadius] = useToken('radii', ['base']);
|
||||
const flowStyles = useMemo<CSSProperties>(() => ({ borderRadius }), [borderRadius]);
|
||||
|
||||
@@ -110,12 +102,12 @@ export const Flow = memo(() => {
|
||||
if (!flow) {
|
||||
return;
|
||||
}
|
||||
if (needsFit) {
|
||||
if ($needsFit.get()) {
|
||||
$needsFit.set(false);
|
||||
flow.fitView();
|
||||
}
|
||||
},
|
||||
[dispatch, needsFit]
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const onEdgesChange: OnEdgesChange<AnyEdge> = useCallback(
|
||||
@@ -214,6 +206,83 @@ export const Flow = memo(() => {
|
||||
|
||||
// #endregion
|
||||
|
||||
const onNodeClick = useCallback<NodeMouseHandler<AnyNode>>((e, node) => {
|
||||
if (!$isSelectingOutputNode.get()) {
|
||||
return;
|
||||
}
|
||||
if (!isInvocationNode(node)) {
|
||||
return;
|
||||
}
|
||||
const { id } = node.data;
|
||||
$outputNodeId.set(id);
|
||||
$isSelectingOutputNode.set(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactFlow<AnyNode, AnyEdge>
|
||||
id="workflow-editor"
|
||||
ref={flowWrapper}
|
||||
defaultViewport={viewport}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onInit={onInit}
|
||||
onNodeClick={onNodeClick}
|
||||
onMouseMove={onMouseMove}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onReconnect={onReconnect}
|
||||
onReconnectStart={onReconnectStart}
|
||||
onReconnectEnd={onReconnectEnd}
|
||||
onConnectStart={onConnectStart}
|
||||
onConnect={onConnect}
|
||||
onConnectEnd={onConnectEnd}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
connectionLineComponent={CustomConnectionLine}
|
||||
isValidConnection={isValidConnection}
|
||||
edgesFocusable={!isLocked}
|
||||
edgesReconnectable={!isLocked}
|
||||
nodesDraggable={!isLocked}
|
||||
nodesConnectable={!isLocked}
|
||||
nodesFocusable={!isLocked}
|
||||
elementsSelectable={!isLocked}
|
||||
minZoom={0.1}
|
||||
snapToGrid={shouldSnapToGrid}
|
||||
snapGrid={snapGrid}
|
||||
connectionRadius={30}
|
||||
proOptions={proOptions}
|
||||
style={flowStyles}
|
||||
onPaneClick={handlePaneClick}
|
||||
deleteKeyCode={null}
|
||||
selectionMode={selectionMode}
|
||||
elevateEdgesOnSelect
|
||||
nodeDragThreshold={1}
|
||||
noDragClassName={NO_DRAG_CLASS}
|
||||
noWheelClassName={NO_WHEEL_CLASS}
|
||||
noPanClassName={NO_PAN_CLASS}
|
||||
>
|
||||
<Background />
|
||||
</ReactFlow>
|
||||
<HotkeyIsolator />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Flow.displayName = 'Flow';
|
||||
|
||||
const HotkeyIsolator = memo(() => {
|
||||
const isLocked = useIsWorkflowEditorLocked();
|
||||
|
||||
const mayUndo = useAppSelector(selectMayUndo);
|
||||
const mayRedo = useAppSelector(selectMayRedo);
|
||||
|
||||
const cancelConnection = useReactFlowStore(selectCancelConnection);
|
||||
|
||||
const store = useAppStore();
|
||||
const isWorkflowsFocused = useIsRegionFocused('workflows');
|
||||
|
||||
const { copySelection, pasteSelection, pasteSelectionWithEdges } = useNodeCopyPaste();
|
||||
|
||||
useRegisteredHotkeys({
|
||||
@@ -239,12 +308,12 @@ export const Flow = memo(() => {
|
||||
}
|
||||
});
|
||||
if (nodeChanges.length > 0) {
|
||||
dispatch(nodesChanged(nodeChanges));
|
||||
store.dispatch(nodesChanged(nodeChanges));
|
||||
}
|
||||
if (edgeChanges.length > 0) {
|
||||
dispatch(edgesChanged(edgeChanges));
|
||||
store.dispatch(edgesChanged(edgeChanges));
|
||||
}
|
||||
}, [dispatch, store]);
|
||||
}, [store]);
|
||||
useRegisteredHotkeys({
|
||||
id: 'selectAll',
|
||||
category: 'workflows',
|
||||
@@ -273,20 +342,20 @@ export const Flow = memo(() => {
|
||||
id: 'undo',
|
||||
category: 'workflows',
|
||||
callback: () => {
|
||||
dispatch(undo());
|
||||
store.dispatch(undo());
|
||||
},
|
||||
options: { enabled: isWorkflowsFocused && !isLocked && mayUndo, preventDefault: true },
|
||||
dependencies: [mayUndo, isLocked, isWorkflowsFocused],
|
||||
dependencies: [store, mayUndo, isLocked, isWorkflowsFocused],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'redo',
|
||||
category: 'workflows',
|
||||
callback: () => {
|
||||
dispatch(redo());
|
||||
store.dispatch(redo());
|
||||
},
|
||||
options: { enabled: isWorkflowsFocused && !isLocked && mayRedo, preventDefault: true },
|
||||
dependencies: [mayRedo, isLocked, isWorkflowsFocused],
|
||||
dependencies: [store, mayRedo, isLocked, isWorkflowsFocused],
|
||||
});
|
||||
|
||||
const onEscapeHotkey = useCallback(() => {
|
||||
@@ -313,12 +382,12 @@ export const Flow = memo(() => {
|
||||
edgeChanges.push({ type: 'remove', id });
|
||||
});
|
||||
if (nodeChanges.length > 0) {
|
||||
dispatch(nodesChanged(nodeChanges));
|
||||
store.dispatch(nodesChanged(nodeChanges));
|
||||
}
|
||||
if (edgeChanges.length > 0) {
|
||||
dispatch(edgesChanged(edgeChanges));
|
||||
store.dispatch(edgesChanged(edgeChanges));
|
||||
}
|
||||
}, [dispatch, store]);
|
||||
}, [store]);
|
||||
useRegisteredHotkeys({
|
||||
id: 'deleteSelection',
|
||||
category: 'workflows',
|
||||
@@ -327,65 +396,6 @@ export const Flow = memo(() => {
|
||||
dependencies: [deleteSelection, isWorkflowsFocused, isLocked],
|
||||
});
|
||||
|
||||
const onNodeClick = useCallback<NodeMouseHandler<AnyNode>>((e, node) => {
|
||||
if (!$isSelectingOutputNode.get()) {
|
||||
return;
|
||||
}
|
||||
if (!isInvocationNode(node)) {
|
||||
return;
|
||||
}
|
||||
const { id } = node.data;
|
||||
$outputNodeId.set(id);
|
||||
$isSelectingOutputNode.set(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ReactFlow<AnyNode, AnyEdge>
|
||||
id="workflow-editor"
|
||||
ref={flowWrapper}
|
||||
defaultViewport={viewport}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onInit={onInit}
|
||||
onNodeClick={onNodeClick}
|
||||
onMouseMove={onMouseMove}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onReconnect={onReconnect}
|
||||
onReconnectStart={onReconnectStart}
|
||||
onReconnectEnd={onReconnectEnd}
|
||||
onConnectStart={onConnectStart}
|
||||
onConnect={onConnect}
|
||||
onConnectEnd={onConnectEnd}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
connectionLineComponent={CustomConnectionLine}
|
||||
isValidConnection={isValidConnection}
|
||||
edgesFocusable={!isLocked}
|
||||
edgesReconnectable={!isLocked}
|
||||
nodesDraggable={!isLocked}
|
||||
nodesConnectable={!isLocked}
|
||||
nodesFocusable={!isLocked}
|
||||
elementsSelectable={!isLocked}
|
||||
minZoom={0.1}
|
||||
snapToGrid={shouldSnapToGrid}
|
||||
snapGrid={snapGrid}
|
||||
connectionRadius={30}
|
||||
proOptions={proOptions}
|
||||
style={flowStyles}
|
||||
onPaneClick={handlePaneClick}
|
||||
deleteKeyCode={null}
|
||||
selectionMode={selectionMode}
|
||||
elevateEdgesOnSelect
|
||||
nodeDragThreshold={1}
|
||||
noDragClassName={NO_DRAG_CLASS}
|
||||
noWheelClassName={NO_WHEEL_CLASS}
|
||||
noPanClassName={NO_PAN_CLASS}
|
||||
>
|
||||
<Background />
|
||||
</ReactFlow>
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
Flow.displayName = 'Flow';
|
||||
HotkeyIsolator.displayName = 'HotkeyIsolator';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
|
||||
import { selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import { selectNodes } from 'features/nodes/store/selectors';
|
||||
import type { Templates } from 'features/nodes/store/types';
|
||||
import { selectWorkflowSettingsSlice } from 'features/nodes/store/workflowSettingsSlice';
|
||||
import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||
@@ -8,9 +8,9 @@ import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import { getFieldColor } from './getEdgeColor';
|
||||
|
||||
export const buildSelectAreConnectedNodesSelected = (source: string, target: string) =>
|
||||
createSelector(selectNodesSlice, (nodes): boolean => {
|
||||
const sourceNode = nodes.nodes.find((node) => node.id === source);
|
||||
const targetNode = nodes.nodes.find((node) => node.id === target);
|
||||
createSelector(selectNodes, (nodes): boolean => {
|
||||
const sourceNode = nodes.find((node) => node.id === source);
|
||||
const targetNode = nodes.find((node) => node.id === target);
|
||||
|
||||
return Boolean(sourceNode?.selected || targetNode?.selected);
|
||||
});
|
||||
@@ -22,10 +22,13 @@ export const buildSelectEdgeColor = (
|
||||
target: string,
|
||||
targetHandleId: string | null | undefined
|
||||
) =>
|
||||
createSelector(selectNodesSlice, selectWorkflowSettingsSlice, (nodes, workflowSettings): string => {
|
||||
createSelector(selectNodes, selectWorkflowSettingsSlice, (nodes, workflowSettings): string => {
|
||||
const { shouldColorEdges } = workflowSettings;
|
||||
const sourceNode = nodes.nodes.find((node) => node.id === source);
|
||||
const targetNode = nodes.nodes.find((node) => node.id === target);
|
||||
if (!shouldColorEdges) {
|
||||
return colorTokenToCssVar('base.500');
|
||||
}
|
||||
const sourceNode = nodes.find((node) => node.id === source);
|
||||
const targetNode = nodes.find((node) => node.id === target);
|
||||
|
||||
if (!sourceNode || !sourceHandleId || !targetNode || !targetHandleId) {
|
||||
return colorTokenToCssVar('base.500');
|
||||
@@ -37,7 +40,7 @@ export const buildSelectEdgeColor = (
|
||||
const outputFieldTemplate = sourceNodeTemplate?.outputs[sourceHandleId];
|
||||
const sourceType = isInvocationToInvocationEdge ? outputFieldTemplate?.type : undefined;
|
||||
|
||||
return sourceType && shouldColorEdges ? getFieldColor(sourceType) : colorTokenToCssVar('base.500');
|
||||
return sourceType ? getFieldColor(sourceType) : colorTokenToCssVar('base.500');
|
||||
});
|
||||
|
||||
export const buildSelectEdgeLabel = (
|
||||
@@ -47,9 +50,9 @@ export const buildSelectEdgeLabel = (
|
||||
target: string,
|
||||
targetHandleId: string | null | undefined
|
||||
) =>
|
||||
createSelector(selectNodesSlice, (nodes): string | null => {
|
||||
const sourceNode = nodes.nodes.find((node) => node.id === source);
|
||||
const targetNode = nodes.nodes.find((node) => node.id === target);
|
||||
createSelector(selectNodes, (nodes): string | null => {
|
||||
const sourceNode = nodes.find((node) => node.id === source);
|
||||
const targetNode = nodes.find((node) => node.id === target);
|
||||
|
||||
if (!sourceNode || !sourceHandleId || !targetNode || !targetHandleId) {
|
||||
return null;
|
||||
|
||||
@@ -37,7 +37,7 @@ const sx: SystemStyleObject = {
|
||||
};
|
||||
|
||||
const InvocationNode = ({ nodeId, isOpen }: Props) => {
|
||||
const withFooter = useWithFooter(nodeId);
|
||||
const withFooter = useWithFooter();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -64,7 +64,7 @@ const InvocationNode = ({ nodeId, isOpen }: Props) => {
|
||||
export default memo(InvocationNode);
|
||||
|
||||
const ConnectionFields = memo(({ nodeId }: { nodeId: string }) => {
|
||||
const fieldNames = useInputFieldNamesConnection(nodeId);
|
||||
const fieldNames = useInputFieldNamesConnection();
|
||||
return (
|
||||
<>
|
||||
{fieldNames.map((fieldName, i) => (
|
||||
@@ -80,7 +80,7 @@ const ConnectionFields = memo(({ nodeId }: { nodeId: string }) => {
|
||||
ConnectionFields.displayName = 'ConnectionFields';
|
||||
|
||||
const AnyOrDirectFields = memo(({ nodeId }: { nodeId: string }) => {
|
||||
const fieldNames = useInputFieldNamesAnyOrDirect(nodeId);
|
||||
const fieldNames = useInputFieldNamesAnyOrDirect();
|
||||
return (
|
||||
<>
|
||||
{fieldNames.map((fieldName) => (
|
||||
@@ -94,7 +94,7 @@ const AnyOrDirectFields = memo(({ nodeId }: { nodeId: string }) => {
|
||||
AnyOrDirectFields.displayName = 'AnyOrDirectFields';
|
||||
|
||||
const MissingFields = memo(({ nodeId }: { nodeId: string }) => {
|
||||
const fieldNames = useInputFieldNamesMissing(nodeId);
|
||||
const fieldNames = useInputFieldNamesMissing();
|
||||
return (
|
||||
<>
|
||||
{fieldNames.map((fieldName) => (
|
||||
@@ -108,7 +108,7 @@ const MissingFields = memo(({ nodeId }: { nodeId: string }) => {
|
||||
MissingFields.displayName = 'MissingFields';
|
||||
|
||||
const OutputFields = memo(({ nodeId }: { nodeId: string }) => {
|
||||
const fieldNames = useOutputFieldNames(nodeId);
|
||||
const fieldNames = useOutputFieldNames();
|
||||
return (
|
||||
<>
|
||||
{fieldNames.map((fieldName, i) => (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user