Compare commits

...

25 Commits

Author SHA1 Message Date
Millun Atluri
5d87578746 {release} v3.5.0rc5 (#5446)
## What type of PR is this? (check all applicable)

Release - InvokeAI v3.5.0rc5


## Have you discussed this change with the InvokeAI team?
- [X] Yes
- [ ] No, because:

      
## Have you updated all relevant documentation?
- [X] Yes
- [ ] No


## Description
Release - InvokeAI v3.5.0rc5


## QA Instructions, Screenshots, Recordings

[InvokeAI-installer-v3.6.0rc5.zip](https://github.com/invoke-ai/InvokeAI/files/13863661/InvokeAI-installer-v3.6.0rc5.zip)


## [optional] Are there any post deployment tasks we need to perform?
Releasee on PyPi & GitHub
2024-01-09 03:40:34 +11:00
Millun Atluri
04aef021fc {release} v3.5.0rc5 2024-01-08 10:42:16 -05:00
psychedelicious
0fc08bb384 ui: redesign followups 8 (#5445)
* feat(ui): get rid of convoluted socket vs appSocket redux actions

There's no need to have `socket...` and `appSocket...` actions.

I did this initially due to a misunderstanding about the sequence of handling from middleware to reducers.

* feat(ui): bump deps

Mainly bumping to get latest `redux-remember`.

A change to socket.io required a change to the types in `useSocketIO`.

* chore(ui): format

* feat(ui): add error handling to redux persistence layer

- Add an error handler to `redux-remember` config using our logger
- Add custom errors representing storage set and get failures
- Update storage driver to raise these accordingly
- wrap method to clear idbkeyval storage and tidy its logic up

* feat(ui): add debuggingLoggerMiddleware

This simply logs every action and a diff of the state change.

Due to the noise this creates, it's not added by default at all. Add it to the middlewares if you want to use it.

* feat(ui): add $socket to window if in dev mode

* fix(ui): do not enable cancel hotkeys on inputs

* fix(ui): use JSON.stringify for ROARR logger serializer

A recent change to ROARR introduced limits to the size of data that will logged. This ends up making our logs far less useful. Change the serializer back to what it was previously.

* feat(ui): change diff util, update debuggerLoggerMiddleware

The previous diff library would present deleted things as `undefined`. Unfortunately, a JSON.stringify cycle will strip those values out. The ROARR logger does this and so the diffs end up being a lot less useful, not showing removed keys.

The new diff library uses a different format for the delta that serializes nicely.

* feat(ui): add migrations to redux persistence layer

- All persisted slices must now have a slice config, consisting of their initial state and a migrate callback. The migrate callback is very simple for now, with no type safety. It adds missing properties to the state. A future enhancement might be to model the each slice's state with e.g. zod and have proper validation and types.
- Persisted slices now have a `_version` property
- The migrate callback is called inside `redux-remember`'s `unserialize` handler. I couldn't figure out a good way to put this into the reducer and do logging (reducers should have no side effects). Also I ran into a weird race condition that I couldn't figure out. And finally, the typings are tricky. This works for now.
- `generationSlice` and `canvasSlice` both need migrations for the new aspect ratio setup, this has been added
- Stuff related to persistence has been moved in to `store.ts` for simplicity

* feat(ui): clean up StorageError class

* fix(ui): scale method default is now 'auto'

* feat(ui): when changing controlnet model, enable autoconfig

* fix(ui): make embedding popover immediately accessible

Prevents hotkeys from being captured when embeddings are still loading.
2024-01-08 09:11:45 -05:00
Josh Corbett
5779542084 Updated icons + Minor UI Tweaks (#5427)
* feat: 💄 updated icons + minor ui tweaks

* revert: 💄 removes ui tweaks

* revert: 💄 removed more ui tweaks

removed more ui tweaks and a commented-out icon import

* style: 🚨 satisfy the linter

---------

Co-authored-by: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
2024-01-07 14:14:44 +11:00
blessedcoolant
ebda81e96e fix(ui): fix add node autoconnect (#5434)
## What type of PR is this? (check all applicable)

- [ ] Refactor
- [ ] Feature
- [x] Bug Fix
- [ ] Optimization
- [ ] Documentation Update
- [ ] Community Node Submission


## Description

The new select component appears to close itself before calling the
onchange handler. This short-circuits the autoconnect logic. Tweaked so
the ordering is correct.

## Related Tickets & Documents

<!--
For pull requests that relate or close an issue, please include them
below. 

For example having the text: "closes #1234" would connect the current
pull
request to issue 1234.  And when we merge the pull request, Github will
automatically close the issue.
-->

- Closes #5425

## QA Instructions, Screenshots, Recordings

bug should be fixed

<!-- 
Please provide steps on how to test changes, any hardware or 
software specifications as well as any other pertinent information. 
-->

## Merge Plan

This PR can be merged when approved

<!--
A merge plan describes how this PR should be handled after it is
approved.

Example merge plans:
- "This PR can be merged when approved"
- "This must be squash-merged when approved"
- "DO NOT MERGE - I will rebase and tidy commits before merging"
- "#dev-chat on discord needs to be advised of this change when it is
merged"

A merge plan is particularly important for large PRs or PRs that touch
the
database in any way.
-->
2024-01-07 08:32:52 +05:30
psychedelicious
3fe332e85f fix(ui): fix add node autoconnect
The new select component appears to close itself before calling the onchange handler. This short-circuits the autoconnect logic. Tweaked so the ordering is correct.
2024-01-07 14:00:22 +11:00
psychedelicious
3428ea1b3c feat(ui): use config for all numerical params
Centralize the initial/min/max/etc values for all numerical params. We used this for some but at some point stopped updating it.

All numerical params now use their respective configs. Far fewer hardcoded values throughout the app now.

Also updated the config types a bit to better accommodate slider vs number input constraints.
2024-01-07 13:49:29 +11:00
Wubbbi
6024fc7baf Update diffusers to the lastest version 2024-01-06 21:47:51 -05:00
psychedelicious
75c1c4ce5a fix(ui): fix gallery nav math
- Use the virtuoso grid item container and list containers to calculate imagesPerRow, skipping manual compensation for padding of images
- Round the imagesPerRow instead of flooring - we often will end up with values like 4.99999 due to floating point precision
- Update `getDownImage` comments & logic to be clearer
- Use variables for the ids in query selectors, preventing future typos
- Only scroll if the new selected image is different from the prev one
2024-01-06 20:52:09 -05:00
Lincoln Stein
ffa05a0bb3 Only replace vae when it is the broken SDXL 1.0 version 2024-01-06 14:06:47 -05:00
Lincoln Stein
a20e17330b blackify 2024-01-06 14:06:47 -05:00
Lincoln Stein
4e83644433 if sdxl-vae-fp16-fix model is available then bake it in when converting ckpts 2024-01-06 14:06:47 -05:00
Surisen
604f0083f2 translationBot(ui): update translation (Chinese (Simplified))
Currently translated at 100.0% (1402 of 1402 strings)

Co-authored-by: Surisen <zhonghx0804@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/zh_Hans/
Translation: InvokeAI/Web UI
2024-01-07 01:21:04 +11:00
Васянатор
2a8a158823 translationBot(ui): update translation (Russian)
Currently translated at 96.2% (1349 of 1402 strings)

Co-authored-by: Васянатор <ilabulanov339@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/ru/
Translation: InvokeAI/Web UI
2024-01-07 01:21:04 +11:00
psychedelicious
f8c3db72e9 feat(ui): improved arrow key navigation in gallery
- Fix preexisting bug where gallery network requests were duplicated when triggering infinite scroll
- Refactor `useNextPrevImage` to not use `state => state` as an input selector - logic split up into different hooks
- Remove use instant scroll for arrow key navigation - smooth scroll is janky when you hold the arrow down and it fires rapidly
- Move gallery nav hotkeys to GalleryImageGrid component, so they work whenever the gallery is open (previously didn't work on canvas or workflow editor tabs)
- Use nanostores for gallery grid refs instead of passing context with virtuoso's context feature, making it much simpler to do the imperative gallery nav
- General gallery hook/component cleanup
2024-01-07 01:19:32 +11:00
psychedelicious
60815807f9 fix(ui): fix merge issue w/ selectors 2024-01-07 01:19:32 +11:00
Rohinish
196fb0e014 added support for bottom key 2024-01-07 01:19:32 +11:00
Rohinish
eba668956d up button support in gallery navigation 2024-01-07 01:19:32 +11:00
Millun Atluri
ee5ec023f4 {release} v3.6.0rc4 (#5424)
## What type of PR is this? (check all applicable)

Release v3.6.0rc4


## Have you discussed this change with the InvokeAI team?
- [X] Yes
- [ ] No, because:

      
## Have you updated all relevant documentation?
- [X] Yes
- [ ] No


## Description
Release for v3.6.0rc4

## Related Tickets & Documents

<!--
For pull requests that relate or close an issue, please include them
below. 

For example having the text: "closes #1234" would connect the current
pull
request to issue 1234.  And when we merge the pull request, Github will
automatically close the issue.
-->

- Related Issue #
- Closes #

## QA Instructions, Screenshots, Recordings
[Uploading InvokeAI-installer-v3.6.0rc4.zip…](Installer Zip)

<!-- 
Please provide steps on how to test changes, any hardware or 
software specifications as well as any other pertinent information. 
-->

## Merge Plan
- This PR can be merged when approved
<!--
A merge plan describes how this PR should be handled after it is
approved.

Example merge plans:
- "This PR can be merged when approved"
- "This must be squash-merged when approved"
- "DO NOT MERGE - I will rebase and tidy commits before merging"
- "#dev-chat on discord needs to be advised of this change when it is
merged"

A merge plan is particularly important for large PRs or PRs that touch
the
database in any way.
-->

## Added/updated tests?

- [ ] Yes
- [ ] No : _please replace this line with details on why tests
      have not been included_

## [optional] Are there any post deployment tasks we need to perform?
Release on PyPi & GitHub
2024-01-06 12:02:57 +11:00
Millun Atluri
d59661e0af {release} v3.6.0rc4 2024-01-06 11:08:00 +11:00
psychedelicious
f51e8eeae1 fix(ui): better node footer spacing 2024-01-06 09:09:38 +11:00
psychedelicious
6e06935e75 fix(ui): fix favicon
It wasn't in the right place to be bundled into `assets/` by vite.

Also replaced uncategorized board's fallback image with new logo.
2024-01-06 09:09:38 +11:00
Ryan Dick
f7f697849c Skip weight initialization when resizing text encoder token embeddings to accomodate new TI embeddings. This saves time. 2024-01-05 15:16:00 -05:00
Mary Hipp Rogers
8e17e29a5c fix text color for lora card (#5417)
* use label

* lint

---------

Co-authored-by: Mary Hipp <maryhipp@Marys-MacBook-Air.local>
2024-01-05 09:57:16 -05:00
Mary Hipp Rogers
12e9f17f7a only GET intermediates if that setting is an option (#5416)
* only GET intermediates if that setting is an option

* lint

---------

Co-authored-by: Mary Hipp <maryhipp@Marys-MacBook-Air.local>
2024-01-05 09:40:34 -05:00
169 changed files with 3269 additions and 2657 deletions

View File

@@ -0,0 +1,31 @@
# Copyright (c) 2024 Lincoln Stein and the InvokeAI Development Team
"""
This module exports the function has_baked_in_sdxl_vae().
It returns True if an SDXL checkpoint model has the original SDXL 1.0 VAE,
which doesn't work properly in fp16 mode.
"""
import hashlib
from pathlib import Path
from safetensors.torch import load_file
SDXL_1_0_VAE_HASH = "bc40b16c3a0fa4625abdfc01c04ffc21bf3cefa6af6c7768ec61eb1f1ac0da51"
def has_baked_in_sdxl_vae(checkpoint_path: Path) -> bool:
"""Return true if the checkpoint contains a custom (non SDXL-1.0) VAE."""
hash = _vae_hash(checkpoint_path)
return hash != SDXL_1_0_VAE_HASH
def _vae_hash(checkpoint_path: Path) -> str:
checkpoint = load_file(checkpoint_path, device="cpu")
vae_keys = [x for x in checkpoint.keys() if x.startswith("first_stage_model.")]
hash = hashlib.new("sha256")
for key in vae_keys:
value = checkpoint[key]
hash.update(bytes(key, "UTF-8"))
hash.update(bytes(str(value), "UTF-8"))
return hash.hexdigest()

View File

@@ -13,6 +13,7 @@ from safetensors.torch import load_file
from transformers import CLIPTextModel, CLIPTokenizer
from invokeai.app.shared.models import FreeUConfig
from invokeai.backend.model_management.model_load_optimizations import skip_torch_weight_init
from .models.lora import LoRAModel
@@ -211,8 +212,12 @@ class ModelPatcher:
for i in range(ti_embedding.shape[0]):
new_tokens_added += ti_tokenizer.add_tokens(_get_trigger(ti_name, i))
# modify text_encoder
text_encoder.resize_token_embeddings(init_tokens_count + new_tokens_added, pad_to_multiple_of)
# Modify text_encoder.
# resize_token_embeddings(...) constructs a new torch.nn.Embedding internally. Initializing the weights of
# this embedding is slow and unnecessary, so we wrap this step in skip_torch_weight_init() to save some
# time.
with skip_torch_weight_init():
text_encoder.resize_token_embeddings(init_tokens_count + new_tokens_added, pad_to_multiple_of)
model_embeddings = text_encoder.get_input_embeddings()
for ti_name, ti in ti_list:

View File

@@ -1,11 +1,16 @@
import json
import os
from enum import Enum
from pathlib import Path
from typing import Literal, Optional
from omegaconf import OmegaConf
from pydantic import Field
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.backend.model_management.detect_baked_in_vae import has_baked_in_sdxl_vae
from invokeai.backend.util.logging import InvokeAILogger
from .base import (
BaseModelType,
DiffusersModel,
@@ -116,14 +121,28 @@ class StableDiffusionXLModel(DiffusersModel):
# The convert script adapted from the diffusers package uses
# strings for the base model type. To avoid making too many
# source code changes, we simply translate here
if Path(output_path).exists():
return output_path
if isinstance(config, cls.CheckpointConfig):
from invokeai.backend.model_management.models.stable_diffusion import _convert_ckpt_and_cache
# Hack in VAE-fp16 fix - If model sdxl-vae-fp16-fix is installed,
# then we bake it into the converted model unless there is already
# a nonstandard VAE installed.
kwargs = {}
app_config = InvokeAIAppConfig.get_config()
vae_path = app_config.models_path / "sdxl/vae/sdxl-vae-fp16-fix"
if vae_path.exists() and not has_baked_in_sdxl_vae(Path(model_path)):
InvokeAILogger.get_logger().warning("No baked-in VAE detected. Inserting sdxl-vae-fp16-fix.")
kwargs["vae_path"] = vae_path
return _convert_ckpt_and_cache(
version=base_model,
model_config=config,
output_path=output_path,
use_safetensors=False, # corrupts sdxl models for some reason
**kwargs,
)
else:
return model_path

View File

Before

Width:  |  Height:  |  Size: 272 B

After

Width:  |  Height:  |  Size: 272 B

View File

@@ -0,0 +1,3 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M29.1951 10.6667H42V2H2V10.6667H14.8049L29.1951 33.3333H42V42H2V33.3333H14.8049" stroke="#E6FD13" stroke-width="2.8"/>
</svg>

After

Width:  |  Height:  |  Size: 231 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -8,8 +8,8 @@
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>InvokeAI - A Stable Diffusion Toolkit</title>
<link rel="mask-icon" href="/invoke-key-ylw-sm.svg" color="#E6FD13" sizes="any" />
<link rel="icon" href="/invoke-key-char-on-ylw.svg" />
<link rel="mask-icon" type="icon" href="favicon-outline.svg" color="#E6FD13" sizes="any" />
<link rel="icon" type="icon" href="favicon-key.svg" />
<style>
html,
body {

View File

@@ -73,10 +73,11 @@
"chakra-react-select": "^4.7.6",
"compare-versions": "^6.1.0",
"dateformat": "^5.0.3",
"framer-motion": "^10.16.16",
"i18next": "^23.7.13",
"framer-motion": "^10.17.9",
"i18next": "^23.7.16",
"i18next-http-backend": "^2.4.2",
"idb-keyval": "^6.2.1",
"jsondiffpatch": "^0.6.0",
"konva": "^9.3.0",
"lodash-es": "^4.17.21",
"nanostores": "^0.9.5",
@@ -90,7 +91,7 @@
"react-dropzone": "^14.2.3",
"react-error-boundary": "^4.0.12",
"react-hook-form": "^7.49.2",
"react-hotkeys-hook": "4.4.1",
"react-hotkeys-hook": "4.4.3",
"react-i18next": "^14.0.0",
"react-icons": "^4.12.0",
"react-konva": "^18.2.10",
@@ -102,10 +103,10 @@
"react-virtuoso": "^4.6.2",
"reactflow": "^11.10.1",
"redux-dynamic-middlewares": "^2.2.0",
"redux-remember": "^5.0.1",
"redux-remember": "^5.1.0",
"roarr": "^7.21.0",
"serialize-error": "^11.0.3",
"socket.io-client": "^4.7.2",
"socket.io-client": "^4.7.3",
"type-fest": "^4.9.0",
"use-debounce": "^10.0.0",
"use-image": "^1.1.1",
@@ -121,27 +122,27 @@
"ts-toolbelt": "^9.6.0"
},
"devDependencies": {
"@arthurgeron/eslint-plugin-react-usememo": "^2.2.2",
"@arthurgeron/eslint-plugin-react-usememo": "^2.2.3",
"@chakra-ui/cli": "^2.4.1",
"@storybook/addon-docs": "^7.6.6",
"@storybook/addon-essentials": "^7.6.6",
"@storybook/addon-interactions": "^7.6.6",
"@storybook/addon-links": "^7.6.6",
"@storybook/addon-storysource": "^7.6.6",
"@storybook/blocks": "^7.6.6",
"@storybook/manager-api": "^7.6.6",
"@storybook/react": "^7.6.6",
"@storybook/react-vite": "^7.6.6",
"@storybook/test": "^7.6.6",
"@storybook/theming": "^7.6.6",
"@storybook/addon-docs": "^7.6.7",
"@storybook/addon-essentials": "^7.6.7",
"@storybook/addon-interactions": "^7.6.7",
"@storybook/addon-links": "^7.6.7",
"@storybook/addon-storysource": "^7.6.7",
"@storybook/blocks": "^7.6.7",
"@storybook/manager-api": "^7.6.7",
"@storybook/react": "^7.6.7",
"@storybook/react-vite": "^7.6.7",
"@storybook/test": "^7.6.7",
"@storybook/theming": "^7.6.7",
"@types/dateformat": "^5.0.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.10.6",
"@types/react": "^18.2.46",
"@types/node": "^20.10.7",
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.16.0",
"@typescript-eslint/parser": "^6.16.0",
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"concurrently": "^8.2.2",
"eslint": "^8.56.0",
@@ -159,10 +160,10 @@
"openapi-typescript": "^6.7.3",
"prettier": "^3.1.1",
"rollup-plugin-visualizer": "^5.12.0",
"storybook": "^7.6.6",
"storybook": "^7.6.7",
"ts-toolbelt": "^9.6.0",
"typescript": "^5.3.3",
"vite": "^5.0.10",
"vite": "^5.0.11",
"vite-plugin-css-injected-by-js": "^3.3.1",
"vite-plugin-dts": "^3.7.0",
"vite-plugin-eslint": "^1.8.1",

File diff suppressed because it is too large Load Diff

View File

@@ -121,7 +121,11 @@
"unsaved": "несохраненный",
"input": "Вход",
"details": "Детали",
"notInstalled": "Нет $t(common.installed)"
"notInstalled": "Нет $t(common.installed)",
"preferencesLabel": "Предпочтения",
"or": "или",
"advancedOptions": "Расширенные настройки",
"free": "Свободно"
},
"gallery": {
"generations": "Генерации",
@@ -365,7 +369,22 @@
"desc": "Открывает меню добавления узла",
"title": "Добавление узлов"
},
"nodesHotkeys": "Горячие клавиши узлов"
"nodesHotkeys": "Горячие клавиши узлов",
"cancelAndClear": {
"desc": "Отмена текущего элемента очереди и очистка всех ожидающих элементов",
"title": "Отменить и очистить"
},
"resetOptionsAndGallery": {
"title": "Сброс настроек и галереи",
"desc": "Сброс панелей галереи и настроек"
},
"searchHotkeys": "Поиск горячих клавиш",
"noHotkeysFound": "Горячие клавиши не найдены",
"toggleOptionsAndGallery": {
"desc": "Открытие и закрытие панели опций и галереи",
"title": "Переключить опции и галерею"
},
"clearSearch": "Очистить поиск"
},
"modelManager": {
"modelManager": "Менеджер моделей",
@@ -1188,7 +1207,8 @@
"handAndFace": "Руки и Лицо",
"enableIPAdapter": "Включить IP Adapter",
"maxFaces": "Макс Лица",
"mlsdDescription": "Минималистичный детектор отрезков линии"
"mlsdDescription": "Минималистичный детектор отрезков линии",
"resizeSimple": "Изменить размер (простой)"
},
"boards": {
"autoAddBoard": "Авто добавление Доски",
@@ -1540,7 +1560,8 @@
"cancelBatchFailed": "Проблема с отменой пакета",
"clearQueueAlertDialog2": "Вы уверены, что хотите очистить очередь?",
"item": "Элемент",
"graphFailedToQueue": "Не удалось поставить график в очередь"
"graphFailedToQueue": "Не удалось поставить график в очередь",
"openQueue": "Открыть очередь"
},
"sdxl": {
"refinerStart": "Запуск перерисовщика",
@@ -1635,9 +1656,38 @@
"selectModel": "Выберите модель",
"noRefinerModelsInstalled": "Модели SDXL Refiner не установлены",
"noLoRAsInstalled": "Нет установленных LoRA",
"selectLoRA": "Выберите LoRA"
"selectLoRA": "Выберите LoRA",
"noMainModelSelected": "Базовая модель не выбрана",
"lora": "LoRA",
"allLoRAsAdded": "Все LoRA добавлены",
"defaultVAE": "Стандартное VAE",
"incompatibleBaseModel": "Несовместимая базовая модель",
"loraAlreadyAdded": "LoRA уже добавлена"
},
"app": {
"storeNotInitialized": "Магазин не инициализирован"
},
"accordions": {
"compositing": {
"infillTab": "Заполнение",
"coherenceTab": "Согласованность",
"title": "Композиция"
},
"control": {
"controlAdaptersTab": "Адаптеры контроля",
"ipTab": "Запросы изображений",
"title": "Контроль"
},
"generation": {
"title": "Генерация",
"conceptsTab": "Концепты",
"modelTab": "Модель"
},
"advanced": {
"title": "Расширенные"
},
"image": {
"title": "Изображение"
}
}
}

View File

@@ -121,7 +121,11 @@
"nextPage": "下一页",
"saveAs": "保存为",
"unsaved": "未保存",
"ai": "ai"
"ai": "ai",
"preferencesLabel": "首选项",
"or": "或",
"advancedOptions": "高级选项",
"free": "自由"
},
"gallery": {
"generations": "生成的图像",
@@ -164,18 +168,18 @@
"starImage": "收藏图像"
},
"hotkeys": {
"keyboardShortcuts": "键盘快捷键",
"appHotkeys": "应用快捷键",
"generalHotkeys": "一般快捷键",
"galleryHotkeys": "图库快捷键",
"unifiedCanvasHotkeys": "统一画布快捷键",
"keyboardShortcuts": "快捷键",
"appHotkeys": "应用",
"generalHotkeys": "一般",
"galleryHotkeys": "图库",
"unifiedCanvasHotkeys": "统一画布",
"invoke": {
"title": "Invoke",
"desc": "生成图像"
},
"cancel": {
"title": "取消",
"desc": "取消图像生成"
"desc": "取消当前队列项目"
},
"focusPrompt": {
"title": "打开提示词框",
@@ -361,11 +365,26 @@
"title": "接受暂存图像",
"desc": "接受当前暂存区中的图像"
},
"nodesHotkeys": "节点快捷键",
"nodesHotkeys": "节点",
"addNodes": {
"title": "添加节点",
"desc": "打开添加节点菜单"
}
},
"cancelAndClear": {
"desc": "取消当前队列项目并且清除所有待定项目",
"title": "取消和清除"
},
"resetOptionsAndGallery": {
"title": "重置选项和图库",
"desc": "重置选项和图库面板"
},
"searchHotkeys": "检索快捷键",
"noHotkeysFound": "未找到快捷键",
"toggleOptionsAndGallery": {
"desc": "打开和关闭选项和图库面板",
"title": "开关选项和图库"
},
"clearSearch": "清除检索项"
},
"modelManager": {
"modelManager": "模型管理器",
@@ -563,8 +582,8 @@
"info": "信息",
"initialImage": "初始图像",
"showOptionsPanel": "显示侧栏浮窗 (O 或 T)",
"seamlessYAxis": "Y 轴",
"seamlessXAxis": "X 轴",
"seamlessYAxis": "无缝平铺 Y 轴",
"seamlessXAxis": "无缝平铺 X 轴",
"boundingBoxWidth": "边界框宽度",
"boundingBoxHeight": "边界框高度",
"denoisingStrength": "去噪强度",
@@ -611,7 +630,7 @@
"readyToInvoke": "准备调用",
"noControlImageForControlAdapter": "有 #{{number}} 个 Control Adapter 缺失控制图像",
"noModelForControlAdapter": "有 #{{number}} 个 Control Adapter 没有选择模型。",
"incompatibleBaseModelForControlAdapter": "有 #{{number}} 个 Control Adapter 模型与主模型不匹配。"
"incompatibleBaseModelForControlAdapter": "有 #{{number}} 个 Control Adapter 模型与主模型不兼容。"
},
"patchmatchDownScaleSize": "缩小",
"coherenceSteps": "步数",
@@ -642,7 +661,14 @@
"unmasked": "取消遮罩",
"cfgRescaleMultiplier": "CFG 重缩放倍数",
"cfgRescale": "CFG 重缩放",
"useSize": "使用尺寸"
"useSize": "使用尺寸",
"setToOptimalSize": "优化模型大小",
"setToOptimalSizeTooSmall": "$t(parameters.setToOptimalSize) (可能过小)",
"imageSize": "图像尺寸",
"lockAspectRatio": "锁定纵横比",
"swapDimensions": "交换尺寸",
"aspect": "纵横",
"setToOptimalSizeTooLarge": "$t(parameters.setToOptimalSize) (可能过大)"
},
"settings": {
"models": "模型",
@@ -1201,7 +1227,8 @@
"openPose": "Openpose",
"controlAdapter_other": "Control Adapters",
"lineartAnime": "Lineart Anime",
"canny": "Canny"
"canny": "Canny",
"resizeSimple": "缩放(简单)"
},
"queue": {
"status": "状态",
@@ -1248,7 +1275,7 @@
"notReady": "无法排队",
"batchFailedToQueue": "批次加入队列失败",
"batchValues": "批次数",
"queueCountPrediction": "添加 {{predicted}} 到队列",
"queueCountPrediction": "{{promptsCount}} 提示词 × {{iterations}} 迭代次数 -> {{count}} 次生成",
"batchQueued": "加入队列的批次",
"queuedCount": "{{pending}} 待处理",
"front": "前",
@@ -1262,7 +1289,8 @@
"queueMaxExceeded": "超出最大值 {{max_queue_size}},将跳过 {{skip}}",
"graphFailedToQueue": "节点图加入队列失败",
"batchFieldValues": "批处理值",
"time": "时间"
"time": "时间",
"openQueue": "打开队列"
},
"sdxl": {
"refinerStart": "Refiner 开始作用时机",
@@ -1276,11 +1304,12 @@
"denoisingStrength": "去噪强度",
"refinermodel": "Refiner 模型",
"posAestheticScore": "正向美学评分",
"concatPromptStyle": "接提示词 & 样式",
"concatPromptStyle": "接提示词 & 样式",
"loading": "加载中...",
"steps": "步数",
"posStylePrompt": "正向样式提示词",
"refiner": "Refiner"
"refiner": "Refiner",
"freePromptStyle": "手动输入样式提示词"
},
"metadata": {
"positivePrompt": "正向提示词",
@@ -1324,7 +1353,13 @@
"noLoRAsInstalled": "无已安装的 LoRA",
"esrganModel": "ESRGAN 模型",
"addLora": "添加 LoRA",
"noLoRAsLoaded": "无已加载的 LoRA"
"noLoRAsLoaded": "无已加载的 LoRA",
"noMainModelSelected": "未选择主模型",
"lora": "LoRA",
"allLoRAsAdded": "已添加所有 LoRA",
"defaultVAE": "默认 VAE",
"incompatibleBaseModel": "不兼容基础模型",
"loraAlreadyAdded": "LoRA 已经被添加"
},
"boards": {
"autoAddBoard": "自动添加面板",
@@ -1368,7 +1403,9 @@
"maxPrompts": "最大提示词数",
"dynamicPrompts": "动态提示词",
"promptsWithCount_other": "{{count}} 个提示词",
"promptsPreview": "提示词预览"
"promptsPreview": "提示词预览",
"showDynamicPrompts": "显示动态提示词",
"loading": "生成动态提示词中..."
},
"popovers": {
"compositingMaskAdjustments": {
@@ -1650,5 +1687,28 @@
},
"app": {
"storeNotInitialized": "商店尚未初始化"
},
"accordions": {
"compositing": {
"infillTab": "内补",
"coherenceTab": "一致性层",
"title": "合成"
},
"control": {
"controlAdaptersTab": "Control Adapters",
"ipTab": "图像提示",
"title": "Control"
},
"generation": {
"title": "生成",
"conceptsTab": "概念",
"modelTab": "模型"
},
"advanced": {
"title": "高级"
},
"image": {
"title": "图像"
}
}
}

View File

@@ -43,7 +43,7 @@ export const useSocketIO = () => {
}, [baseUrl]);
const socketOptions = useMemo(() => {
const options: Parameters<typeof io>[0] = {
const options: Partial<ManagerOptions & SocketOptions> = {
timeout: 60000,
path: '/ws/socket.io',
autoConnect: false, // achtung! removing this breaks the dynamic middleware
@@ -71,7 +71,7 @@ export const useSocketIO = () => {
setEventListeners({ dispatch, socket });
socket.connect();
if ($isDebugging.get()) {
if ($isDebugging.get() || import.meta.env.MODE === 'development') {
window.$socketOptions = $socketOptions;
console.log('Socket initialized', socket);
}
@@ -79,7 +79,7 @@ export const useSocketIO = () => {
$isSocketInitialized.set(true);
return () => {
if ($isDebugging.get()) {
if ($isDebugging.get() || import.meta.env.MODE === 'development') {
window.$socketOptions = undefined;
console.log('Socket teardown', socket);
}

View File

@@ -1,9 +1,14 @@
import { createLogWriter } from '@roarr/browser-log-writer';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
import type { Logger, MessageSerializer } from 'roarr';
import { ROARR, Roarr } from 'roarr';
import { z } from 'zod';
const serializeMessage: MessageSerializer = (message) => {
return JSON.stringify(message);
};
ROARR.serializeMessage = serializeMessage;
ROARR.write = createLogWriter();
export const BASE_CONTEXT = {};

View File

@@ -0,0 +1,37 @@
import { StorageError } from 'app/store/enhancers/reduxRemember/errors';
import type { UseStore } from 'idb-keyval';
import {
clear,
createStore as createIDBKeyValStore,
get,
set,
} from 'idb-keyval';
import { action, atom } from 'nanostores';
import type { Driver } from 'redux-remember';
// Create a custom idb-keyval store (just needed to customize the name)
export const $idbKeyValStore = atom<UseStore>(
createIDBKeyValStore('invoke', 'invoke-store')
);
export const clearIdbKeyValStore = action($idbKeyValStore, 'clear', (store) => {
clear(store.get());
});
// Create redux-remember driver, wrapping idb-keyval
export const idbKeyValDriver: Driver = {
getItem: (key) => {
try {
return get(key, $idbKeyValStore.get());
} catch (originalError) {
throw new StorageError({ key, originalError });
}
},
setItem: (key, value) => {
try {
return set(key, value, $idbKeyValStore.get());
} catch (originalError) {
throw new StorageError({ key, value, originalError });
}
},
};

View File

@@ -0,0 +1,41 @@
import { logger } from 'app/logging/logger';
import { parseify } from 'common/util/serialize';
import { PersistError, RehydrateError } from 'redux-remember';
import { serializeError } from 'serialize-error';
export type StorageErrorArgs = {
key: string;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ // any is correct
value?: any;
originalError?: unknown;
};
export class StorageError extends Error {
key: string;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ // any is correct
value?: any;
originalError?: Error;
constructor({ key, value, originalError }: StorageErrorArgs) {
super(`Error setting ${key}`);
this.name = 'StorageSetError';
this.key = key;
if (value !== undefined) {
this.value = value;
}
if (originalError instanceof Error) {
this.originalError = originalError;
}
}
}
export const errorHandler = (err: PersistError | RehydrateError) => {
const log = logger('system');
if (err instanceof PersistError) {
log.error({ error: serializeError(err) }, 'Problem persisting state');
} else if (err instanceof RehydrateError) {
log.error({ error: serializeError(err) }, 'Problem rehydrating state');
} else {
log.error({ error: parseify(err) }, 'Problem in persistence layer');
}
};

View File

@@ -1,30 +0,0 @@
import { canvasPersistDenylist } from 'features/canvas/store/canvasPersistDenylist';
import { controlAdaptersPersistDenylist } from 'features/controlAdapters/store/controlAdaptersPersistDenylist';
import { dynamicPromptsPersistDenylist } from 'features/dynamicPrompts/store/dynamicPromptsPersistDenylist';
import { galleryPersistDenylist } from 'features/gallery/store/galleryPersistDenylist';
import { nodesPersistDenylist } from 'features/nodes/store/nodesPersistDenylist';
import { generationPersistDenylist } from 'features/parameters/store/generationPersistDenylist';
import { postprocessingPersistDenylist } from 'features/parameters/store/postprocessingPersistDenylist';
import { systemPersistDenylist } from 'features/system/store/systemPersistDenylist';
import { uiPersistDenylist } from 'features/ui/store/uiPersistDenylist';
import { omit } from 'lodash-es';
import type { SerializeFunction } from 'redux-remember';
const serializationDenylist: {
[key: string]: string[];
} = {
canvas: canvasPersistDenylist,
gallery: galleryPersistDenylist,
generation: generationPersistDenylist,
nodes: nodesPersistDenylist,
postprocessing: postprocessingPersistDenylist,
system: systemPersistDenylist,
ui: uiPersistDenylist,
controlNet: controlAdaptersPersistDenylist,
dynamicPrompts: dynamicPromptsPersistDenylist,
};
export const serialize: SerializeFunction = (data, key) => {
const result = omit(data, serializationDenylist[key] ?? []);
return JSON.stringify(result);
};

View File

@@ -1,34 +0,0 @@
import { initialCanvasState } from 'features/canvas/store/canvasSlice';
import { initialControlAdapterState } from 'features/controlAdapters/store/controlAdaptersSlice';
import { initialDynamicPromptsState } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { initialGalleryState } from 'features/gallery/store/gallerySlice';
import { initialNodesState } from 'features/nodes/store/nodesSlice';
import { initialGenerationState } from 'features/parameters/store/generationSlice';
import { initialPostprocessingState } from 'features/parameters/store/postprocessingSlice';
import { initialSDXLState } from 'features/sdxl/store/sdxlSlice';
import { initialConfigState } from 'features/system/store/configSlice';
import { initialSystemState } from 'features/system/store/systemSlice';
import { initialUIState } from 'features/ui/store/uiSlice';
import { defaultsDeep } from 'lodash-es';
import type { UnserializeFunction } from 'redux-remember';
const initialStates: {
[key: string]: object; // TODO: type this properly
} = {
canvas: initialCanvasState,
gallery: initialGalleryState,
generation: initialGenerationState,
nodes: initialNodesState,
postprocessing: initialPostprocessingState,
system: initialSystemState,
config: initialConfigState,
ui: initialUIState,
controlAdapters: initialControlAdapterState,
dynamicPrompts: initialDynamicPromptsState,
sdxl: initialSDXLState,
};
export const unserialize: UnserializeFunction = (data, key) => {
const result = defaultsDeep(JSON.parse(data), initialStates[key]);
return result;
};

View File

@@ -0,0 +1,16 @@
import type { Middleware, MiddlewareAPI } from '@reduxjs/toolkit';
import { diff } from 'jsondiffpatch';
/**
* Super simple logger middleware. Useful for debugging when the redux devtools are awkward.
*/
export const debugLoggerMiddleware: Middleware =
(api: MiddlewareAPI) => (next) => (action) => {
const originalState = api.getState();
console.log('REDUX: dispatching', action);
const result = next(action);
const nextState = api.getState();
console.log('REDUX: next state', nextState);
console.log('REDUX: diff', diff(originalState, nextState));
return result;
};

View File

@@ -1,8 +1,10 @@
import type { UnknownAction } from '@reduxjs/toolkit';
import { isAnyGraphBuilt } from 'features/nodes/store/actions';
import { nodeTemplatesBuilt } from 'features/nodes/store/nodeTemplatesSlice';
import { cloneDeep } from 'lodash-es';
import { receivedOpenAPISchema } from 'services/api/thunks/schema';
import type { Graph } from 'services/api/types';
import { socketGeneratorProgress } from 'services/events/actions';
export const actionSanitizer = <A extends UnknownAction>(action: A): A => {
if (isAnyGraphBuilt(action)) {
@@ -30,5 +32,14 @@ export const actionSanitizer = <A extends UnknownAction>(action: A): A => {
};
}
if (socketGeneratorProgress.match(action)) {
const sanitized = cloneDeep(action);
if (sanitized.payload.data.progress_image) {
sanitized.payload.data.progress_image.dataURL =
'<Progress image omitted>';
}
return sanitized;
}
return action;
};

View File

@@ -1,16 +1,16 @@
/**
* This is a list of actions that should be excluded in the Redux DevTools.
*/
export const actionsDenylist = [
export const actionsDenylist: string[] = [
// very spammy canvas actions
'canvas/setStageCoordinates',
'canvas/setStageScale',
'canvas/setBoundingBoxCoordinates',
'canvas/setBoundingBoxDimensions',
'canvas/addPointToCurrentLine',
// 'canvas/setStageCoordinates',
// 'canvas/setStageScale',
// 'canvas/setBoundingBoxCoordinates',
// 'canvas/setBoundingBoxDimensions',
// 'canvas/addPointToCurrentLine',
// bazillions during generation
'socket/socketGeneratorProgress',
'socket/appSocketGeneratorProgress',
// 'socket/socketGeneratorProgress',
// 'socket/appSocketGeneratorProgress',
// this happens after every state change
'@@REMEMBER_PERSISTED',
// '@@REMEMBER_PERSISTED',
];

View File

@@ -11,7 +11,7 @@ import {
import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt';
import { setPositivePrompt } from 'features/parameters/store/generationSlice';
import { utilitiesApi } from 'services/api/endpoints/utilities';
import { appSocketConnected } from 'services/events/actions';
import { socketConnected } from 'services/events/actions';
import { startAppListening } from '..';
@@ -20,7 +20,7 @@ const matcher = isAnyOf(
combinatorialToggled,
maxPromptsChanged,
maxPromptsReset,
appSocketConnected
socketConnected
);
export const addDynamicPromptsListener = () => {

View File

@@ -3,16 +3,16 @@ import { isInitializedChanged } from 'features/system/store/systemSlice';
import { size } from 'lodash-es';
import { api } from 'services/api';
import { receivedOpenAPISchema } from 'services/api/thunks/schema';
import { appSocketConnected, socketConnected } from 'services/events/actions';
import { socketConnected } from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addSocketConnectedEventListener = () => {
startAppListening({
actionCreator: socketConnected,
effect: (action, { dispatch, getState }) => {
const log = logger('socketio');
log.debug('Connected');
const { nodeTemplates, config, system } = getState();
@@ -29,9 +29,6 @@ export const addSocketConnectedEventListener = () => {
} else {
dispatch(isInitializedChanged(true));
}
// pass along the socket event as an application action
dispatch(appSocketConnected(action.payload));
},
});
};

View File

@@ -1,20 +1,15 @@
import { logger } from 'app/logging/logger';
import {
appSocketDisconnected,
socketDisconnected,
} from 'services/events/actions';
import { socketDisconnected } from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addSocketDisconnectedEventListener = () => {
startAppListening({
actionCreator: socketDisconnected,
effect: (action, { dispatch }) => {
const log = logger('socketio');
effect: () => {
log.debug('Disconnected');
// pass along the socket event as an application action
dispatch(appSocketDisconnected(action.payload));
},
});
};

View File

@@ -1,20 +1,15 @@
import { logger } from 'app/logging/logger';
import {
appSocketGeneratorProgress,
socketGeneratorProgress,
} from 'services/events/actions';
import { socketGeneratorProgress } from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addGeneratorProgressEventListener = () => {
startAppListening({
actionCreator: socketGeneratorProgress,
effect: (action, { dispatch }) => {
const log = logger('socketio');
effect: (action) => {
log.trace(action.payload, `Generator progress`);
dispatch(appSocketGeneratorProgress(action.payload));
},
});
};

View File

@@ -1,19 +1,15 @@
import { logger } from 'app/logging/logger';
import {
appSocketGraphExecutionStateComplete,
socketGraphExecutionStateComplete,
} from 'services/events/actions';
import { socketGraphExecutionStateComplete } from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addGraphExecutionStateCompleteEventListener = () => {
startAppListening({
actionCreator: socketGraphExecutionStateComplete,
effect: (action, { dispatch }) => {
const log = logger('socketio');
effect: (action) => {
log.debug(action.payload, 'Session complete');
// pass along the socket event as an application action
dispatch(appSocketGraphExecutionStateComplete(action.payload));
},
});
};

View File

@@ -15,21 +15,19 @@ import {
import { boardsApi } from 'services/api/endpoints/boards';
import { imagesApi } from 'services/api/endpoints/images';
import { imagesAdapter } from 'services/api/util';
import {
appSocketInvocationComplete,
socketInvocationComplete,
} from 'services/events/actions';
import { socketInvocationComplete } from 'services/events/actions';
import { startAppListening } from '../..';
// These nodes output an image, but do not actually *save* an image, so we don't want to handle the gallery logic on them
const nodeTypeDenylist = ['load_image', 'image'];
const log = logger('socketio');
export const addInvocationCompleteEventListener = () => {
startAppListening({
actionCreator: socketInvocationComplete,
effect: async (action, { dispatch, getState }) => {
const log = logger('socketio');
const { data } = action.payload;
log.debug(
{ data: parseify(data) },
@@ -136,8 +134,6 @@ export const addInvocationCompleteEventListener = () => {
}
}
}
// pass along the socket event as an application action
dispatch(appSocketInvocationComplete(action.payload));
},
});
};

View File

@@ -1,21 +1,18 @@
import { logger } from 'app/logging/logger';
import {
appSocketInvocationError,
socketInvocationError,
} from 'services/events/actions';
import { socketInvocationError } from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addInvocationErrorEventListener = () => {
startAppListening({
actionCreator: socketInvocationError,
effect: (action, { dispatch }) => {
const log = logger('socketio');
effect: (action) => {
log.error(
action.payload,
`Invocation error (${action.payload.data.node.type})`
);
dispatch(appSocketInvocationError(action.payload));
},
});
};

View File

@@ -1,21 +1,18 @@
import { logger } from 'app/logging/logger';
import {
appSocketInvocationRetrievalError,
socketInvocationRetrievalError,
} from 'services/events/actions';
import { socketInvocationRetrievalError } from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addInvocationRetrievalErrorEventListener = () => {
startAppListening({
actionCreator: socketInvocationRetrievalError,
effect: (action, { dispatch }) => {
const log = logger('socketio');
effect: (action) => {
log.error(
action.payload,
`Invocation retrieval error (${action.payload.data.graph_execution_state_id})`
);
dispatch(appSocketInvocationRetrievalError(action.payload));
},
});
};

View File

@@ -1,23 +1,18 @@
import { logger } from 'app/logging/logger';
import {
appSocketInvocationStarted,
socketInvocationStarted,
} from 'services/events/actions';
import { socketInvocationStarted } from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addInvocationStartedEventListener = () => {
startAppListening({
actionCreator: socketInvocationStarted,
effect: (action, { dispatch }) => {
const log = logger('socketio');
effect: (action) => {
log.debug(
action.payload,
`Invocation started (${action.payload.data.node.type})`
);
dispatch(appSocketInvocationStarted(action.payload));
},
});
};

View File

@@ -1,18 +1,17 @@
import { logger } from 'app/logging/logger';
import {
appSocketModelLoadCompleted,
appSocketModelLoadStarted,
socketModelLoadCompleted,
socketModelLoadStarted,
} from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addModelLoadEventListener = () => {
startAppListening({
actionCreator: socketModelLoadStarted,
effect: (action, { dispatch }) => {
const log = logger('socketio');
effect: (action) => {
const { base_model, model_name, model_type, submodel } =
action.payload.data;
@@ -23,16 +22,12 @@ export const addModelLoadEventListener = () => {
}
log.debug(action.payload, message);
// pass along the socket event as an application action
dispatch(appSocketModelLoadStarted(action.payload));
},
});
startAppListening({
actionCreator: socketModelLoadCompleted,
effect: (action, { dispatch }) => {
const log = logger('socketio');
effect: (action) => {
const { base_model, model_name, model_type, submodel } =
action.payload.data;
@@ -43,8 +38,6 @@ export const addModelLoadEventListener = () => {
}
log.debug(action.payload, message);
// pass along the socket event as an application action
dispatch(appSocketModelLoadCompleted(action.payload));
},
});
};

View File

@@ -1,18 +1,15 @@
import { logger } from 'app/logging/logger';
import { queueApi, queueItemsAdapter } from 'services/api/endpoints/queue';
import {
appSocketQueueItemStatusChanged,
socketQueueItemStatusChanged,
} from 'services/events/actions';
import { socketQueueItemStatusChanged } from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addSocketQueueItemStatusChangedEventListener = () => {
startAppListening({
actionCreator: socketQueueItemStatusChanged,
effect: async (action, { dispatch }) => {
const log = logger('socketio');
// we've got new status for the queue item, batch and queue
const { queue_item, batch_status, queue_status } = action.payload.data;
@@ -73,9 +70,6 @@ export const addSocketQueueItemStatusChangedEventListener = () => {
'InvocationCacheStatus',
])
);
// Pass the event along
dispatch(appSocketQueueItemStatusChanged(action.payload));
},
});
};

View File

@@ -1,21 +1,18 @@
import { logger } from 'app/logging/logger';
import {
appSocketSessionRetrievalError,
socketSessionRetrievalError,
} from 'services/events/actions';
import { socketSessionRetrievalError } from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addSessionRetrievalErrorEventListener = () => {
startAppListening({
actionCreator: socketSessionRetrievalError,
effect: (action, { dispatch }) => {
const log = logger('socketio');
effect: (action) => {
log.error(
action.payload,
`Session retrieval error (${action.payload.data.graph_execution_state_id})`
);
dispatch(appSocketSessionRetrievalError(action.payload));
},
});
};

View File

@@ -1,18 +1,15 @@
import { logger } from 'app/logging/logger';
import {
appSocketSubscribedSession,
socketSubscribedSession,
} from 'services/events/actions';
import { socketSubscribedSession } from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addSocketSubscribedEventListener = () => {
startAppListening({
actionCreator: socketSubscribedSession,
effect: (action, { dispatch }) => {
const log = logger('socketio');
effect: (action) => {
log.debug(action.payload, 'Subscribed');
dispatch(appSocketSubscribedSession(action.payload));
},
});
};

View File

@@ -1,18 +1,14 @@
import { logger } from 'app/logging/logger';
import {
appSocketUnsubscribedSession,
socketUnsubscribedSession,
} from 'services/events/actions';
import { socketUnsubscribedSession } from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addSocketUnsubscribedEventListener = () => {
startAppListening({
actionCreator: socketUnsubscribedSession,
effect: (action, { dispatch }) => {
const log = logger('socketio');
effect: (action) => {
log.debug(action.payload, 'Unsubscribed');
dispatch(appSocketUnsubscribedSession(action.payload));
},
});
};

View File

@@ -4,40 +4,94 @@ import {
combineReducers,
configureStore,
} from '@reduxjs/toolkit';
import canvasReducer from 'features/canvas/store/canvasSlice';
import { logger } from 'app/logging/logger';
import { idbKeyValDriver } from 'app/store/enhancers/reduxRemember/driver';
import { errorHandler } from 'app/store/enhancers/reduxRemember/errors';
import { canvasPersistDenylist } from 'features/canvas/store/canvasPersistDenylist';
import canvasReducer, {
initialCanvasState,
migrateCanvasState,
} from 'features/canvas/store/canvasSlice';
import changeBoardModalReducer from 'features/changeBoardModal/store/slice';
import controlAdaptersReducer from 'features/controlAdapters/store/controlAdaptersSlice';
import { controlAdaptersPersistDenylist } from 'features/controlAdapters/store/controlAdaptersPersistDenylist';
import controlAdaptersReducer, {
initialControlAdaptersState,
migrateControlAdaptersState,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import deleteImageModalReducer from 'features/deleteImageModal/store/slice';
import dynamicPromptsReducer from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import galleryReducer from 'features/gallery/store/gallerySlice';
import hrfReducer from 'features/hrf/store/hrfSlice';
import loraReducer from 'features/lora/store/loraSlice';
import modelmanagerReducer from 'features/modelManager/store/modelManagerSlice';
import nodesReducer from 'features/nodes/store/nodesSlice';
import { dynamicPromptsPersistDenylist } from 'features/dynamicPrompts/store/dynamicPromptsPersistDenylist';
import dynamicPromptsReducer, {
initialDynamicPromptsState,
migrateDynamicPromptsState,
} from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { galleryPersistDenylist } from 'features/gallery/store/galleryPersistDenylist';
import galleryReducer, {
initialGalleryState,
migrateGalleryState,
} from 'features/gallery/store/gallerySlice';
import hrfReducer, {
initialHRFState,
migrateHRFState,
} from 'features/hrf/store/hrfSlice';
import loraReducer, {
initialLoraState,
migrateLoRAState,
} from 'features/lora/store/loraSlice';
import modelmanagerReducer, {
initialModelManagerState,
migrateModelManagerState,
} from 'features/modelManager/store/modelManagerSlice';
import { nodesPersistDenylist } from 'features/nodes/store/nodesPersistDenylist';
import nodesReducer, {
initialNodesState,
migrateNodesState,
} from 'features/nodes/store/nodesSlice';
import nodeTemplatesReducer from 'features/nodes/store/nodeTemplatesSlice';
import workflowReducer from 'features/nodes/store/workflowSlice';
import generationReducer from 'features/parameters/store/generationSlice';
import postprocessingReducer from 'features/parameters/store/postprocessingSlice';
import workflowReducer, {
initialWorkflowState,
migrateWorkflowState,
} from 'features/nodes/store/workflowSlice';
import { generationPersistDenylist } from 'features/parameters/store/generationPersistDenylist';
import generationReducer, {
initialGenerationState,
migrateGenerationState,
} from 'features/parameters/store/generationSlice';
import { postprocessingPersistDenylist } from 'features/parameters/store/postprocessingPersistDenylist';
import postprocessingReducer, {
initialPostprocessingState,
migratePostprocessingState,
} from 'features/parameters/store/postprocessingSlice';
import queueReducer from 'features/queue/store/queueSlice';
import sdxlReducer from 'features/sdxl/store/sdxlSlice';
import sdxlReducer, {
initialSDXLState,
migrateSDXLState,
} from 'features/sdxl/store/sdxlSlice';
import configReducer from 'features/system/store/configSlice';
import systemReducer from 'features/system/store/systemSlice';
import uiReducer from 'features/ui/store/uiSlice';
import { createStore as createIDBKeyValStore, get, set } from 'idb-keyval';
import { systemPersistDenylist } from 'features/system/store/systemPersistDenylist';
import systemReducer, {
initialSystemState,
migrateSystemState,
} from 'features/system/store/systemSlice';
import { uiPersistDenylist } from 'features/ui/store/uiPersistDenylist';
import uiReducer, {
initialUIState,
migrateUIState,
} from 'features/ui/store/uiSlice';
import { diff } from 'jsondiffpatch';
import { defaultsDeep, keys, omit, pick } from 'lodash-es';
import dynamicMiddlewares from 'redux-dynamic-middlewares';
import type { Driver } from 'redux-remember';
import type { SerializeFunction, UnserializeFunction } from 'redux-remember';
import { rememberEnhancer, rememberReducer } from 'redux-remember';
import { serializeError } from 'serialize-error';
import { api } from 'services/api';
import { authToastMiddleware } from 'services/api/authToastMiddleware';
import type { JsonObject } from 'type-fest';
import { STORAGE_PREFIX } from './constants';
import { serialize } from './enhancers/reduxRemember/serialize';
import { unserialize } from './enhancers/reduxRemember/unserialize';
import { actionSanitizer } from './middleware/devtools/actionSanitizer';
import { actionsDenylist } from './middleware/devtools/actionsDenylist';
import { stateSanitizer } from './middleware/devtools/stateSanitizer';
import { listenerMiddleware } from './middleware/listenerMiddleware';
const allReducers = {
canvas: canvasReducer,
gallery: galleryReducer,
@@ -65,7 +119,7 @@ const rootReducer = combineReducers(allReducers);
const rememberedRootReducer = rememberReducer(rootReducer);
const rememberedKeys: (keyof typeof allReducers)[] = [
const rememberedKeys = [
'canvas',
'gallery',
'generation',
@@ -80,15 +134,106 @@ const rememberedKeys: (keyof typeof allReducers)[] = [
'lora',
'modelmanager',
'hrf',
];
] satisfies (keyof typeof allReducers)[];
// Create a custom idb-keyval store (just needed to customize the name)
export const idbKeyValStore = createIDBKeyValStore('invoke', 'invoke-store');
type SliceConfig = {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
initialState: any;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
migrate: (state: any) => any;
};
// Create redux-remember driver, wrapping idb-keyval
const idbKeyValDriver: Driver = {
getItem: (key) => get(key, idbKeyValStore),
setItem: (key, value) => set(key, value, idbKeyValStore),
const sliceConfigs: {
[key in (typeof rememberedKeys)[number]]: SliceConfig;
} = {
canvas: { initialState: initialCanvasState, migrate: migrateCanvasState },
gallery: { initialState: initialGalleryState, migrate: migrateGalleryState },
generation: {
initialState: initialGenerationState,
migrate: migrateGenerationState,
},
nodes: { initialState: initialNodesState, migrate: migrateNodesState },
postprocessing: {
initialState: initialPostprocessingState,
migrate: migratePostprocessingState,
},
system: { initialState: initialSystemState, migrate: migrateSystemState },
workflow: {
initialState: initialWorkflowState,
migrate: migrateWorkflowState,
},
ui: { initialState: initialUIState, migrate: migrateUIState },
controlAdapters: {
initialState: initialControlAdaptersState,
migrate: migrateControlAdaptersState,
},
dynamicPrompts: {
initialState: initialDynamicPromptsState,
migrate: migrateDynamicPromptsState,
},
sdxl: { initialState: initialSDXLState, migrate: migrateSDXLState },
lora: { initialState: initialLoraState, migrate: migrateLoRAState },
modelmanager: {
initialState: initialModelManagerState,
migrate: migrateModelManagerState,
},
hrf: { initialState: initialHRFState, migrate: migrateHRFState },
};
const unserialize: UnserializeFunction = (data, key) => {
const log = logger('system');
const config = sliceConfigs[key as keyof typeof sliceConfigs];
if (!config) {
throw new Error(`No unserialize config for slice "${key}"`);
}
try {
const { initialState, migrate } = config;
const parsed = JSON.parse(data);
// strip out old keys
const stripped = pick(parsed, keys(initialState));
// run (additive) migrations
const migrated = migrate(stripped);
// merge in initial state as default values, covering any missing keys
const transformed = defaultsDeep(migrated, initialState);
log.debug(
{
persistedData: parsed,
rehydratedData: transformed,
diff: diff(parsed, transformed) as JsonObject, // this is always serializable
},
`Rehydrated slice "${key}"`
);
return transformed;
} catch (err) {
log.warn(
{ error: serializeError(err) },
`Error rehydrating slice "${key}", falling back to default initial state`
);
return config.initialState;
}
};
const serializationDenylist: {
[key in (typeof rememberedKeys)[number]]?: string[];
} = {
canvas: canvasPersistDenylist,
gallery: galleryPersistDenylist,
generation: generationPersistDenylist,
nodes: nodesPersistDenylist,
postprocessing: postprocessingPersistDenylist,
system: systemPersistDenylist,
ui: uiPersistDenylist,
controlAdapters: controlAdaptersPersistDenylist,
dynamicPrompts: dynamicPromptsPersistDenylist,
};
export const serialize: SerializeFunction = (data, key) => {
const result = omit(
data,
serializationDenylist[key as keyof typeof serializationDenylist] ?? []
);
return JSON.stringify(result);
};
export const createStore = (uniqueStoreKey?: string, persist = true) =>
@@ -114,6 +259,7 @@ export const createStore = (uniqueStoreKey?: string, persist = true) =>
prefix: uniqueStoreKey
? `${STORAGE_PREFIX}${uniqueStoreKey}-`
: STORAGE_PREFIX,
errorHandler,
})
);
}
@@ -124,21 +270,9 @@ export const createStore = (uniqueStoreKey?: string, persist = true) =>
stateSanitizer,
trace: true,
predicate: (state, action) => {
// TODO: hook up to the log level param in system slice
// manually type state, cannot type the arg
// const typedState = state as ReturnType<typeof rootReducer>;
// TODO: doing this breaks the rtk query devtools, commenting out for now
// if (action.type.startsWith('api/')) {
// // don't log api actions, with manual cache updates they are extremely noisy
// return false;
// }
if (actionsDenylist.includes(action.type)) {
// don't log other noisy actions
return false;
}
return true;
},
},

View File

@@ -43,6 +43,16 @@ export type SDFeature =
| 'vae'
| 'hrf';
export type NumericalParameterConfig = {
initial: number;
sliderMin: number;
sliderMax: number;
numberInputMin: number;
numberInputMax: number;
fineStep: number;
coarseStep: number;
};
/**
* Configuration options for the InvokeAI UI.
* Distinct from system settings which may be changed inside the app.
@@ -66,69 +76,32 @@ export type AppConfig = {
defaultModel?: string;
disabledControlNetModels: string[];
disabledControlNetProcessors: (keyof typeof CONTROLNET_PROCESSORS)[];
iterations: {
initial: number;
min: number;
sliderMax: number;
inputMax: number;
fineStep: number;
coarseStep: number;
};
width: {
initial: number;
min: number;
sliderMax: number;
inputMax: number;
fineStep: number;
coarseStep: number;
};
height: {
initial: number;
min: number;
sliderMax: number;
inputMax: number;
fineStep: number;
coarseStep: number;
};
steps: {
initial: number;
min: number;
sliderMax: number;
inputMax: number;
fineStep: number;
coarseStep: number;
};
guidance: {
initial: number;
min: number;
sliderMax: number;
inputMax: number;
fineStep: number;
coarseStep: number;
};
img2imgStrength: {
initial: number;
min: number;
sliderMax: number;
inputMax: number;
fineStep: number;
coarseStep: number;
};
hrfStrength: {
initial: number;
min: number;
sliderMax: number;
inputMax: number;
fineStep: number;
coarseStep: number;
};
// Core parameters
iterations: NumericalParameterConfig;
width: NumericalParameterConfig; // initial value comes from model
height: NumericalParameterConfig; // initial value comes from model
steps: NumericalParameterConfig;
guidance: NumericalParameterConfig;
cfgRescaleMultiplier: NumericalParameterConfig;
img2imgStrength: NumericalParameterConfig;
// Canvas
boundingBoxHeight: NumericalParameterConfig; // initial value comes from model
boundingBoxWidth: NumericalParameterConfig; // initial value comes from model
scaledBoundingBoxHeight: NumericalParameterConfig; // initial value comes from model
scaledBoundingBoxWidth: NumericalParameterConfig; // initial value comes from model
canvasCoherenceStrength: NumericalParameterConfig;
canvasCoherenceSteps: NumericalParameterConfig;
infillTileSize: NumericalParameterConfig;
infillPatchmatchDownscaleSize: NumericalParameterConfig;
// Misc advanced
clipSkip: NumericalParameterConfig; // slider and input max are ignored for this, because the values depend on the model
maskBlur: NumericalParameterConfig;
hrfStrength: NumericalParameterConfig;
dynamicPrompts: {
maxPrompts: {
initial: number;
min: number;
sliderMax: number;
inputMax: number;
};
maxPrompts: NumericalParameterConfig;
};
ca: {
weight: NumericalParameterConfig;
};
};
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -45,6 +45,7 @@ export const InvControl = memo(
orientation={orientation}
isDisabled={isDisabled}
{...formControlProps}
{...ctx.controlProps}
>
<Flex className="invcontrol-label-wrapper">
{label && (

View File

@@ -1,9 +1,10 @@
import type { FormLabelProps } from '@chakra-ui/react';
import type { FormControlProps, FormLabelProps } from '@chakra-ui/react';
import type { PropsWithChildren } from 'react';
import { createContext, memo } from 'react';
export type InvControlGroupProps = {
labelProps?: FormLabelProps;
controlProps?: FormControlProps;
isDisabled?: boolean;
orientation?: 'horizontal' | 'vertical';
};

View File

@@ -42,9 +42,9 @@ const line = definePartsStyle(() => ({
px: 4,
py: 1,
fontSize: 'sm',
color: 'base.400',
color: 'base.200',
_selected: {
color: 'blue.400',
color: 'blue.200',
},
},
tabpanel: {

View File

@@ -1,10 +1,9 @@
import { idbKeyValStore } from 'app/store/store';
import { clear } from 'idb-keyval';
import { clearIdbKeyValStore } from 'app/store/enhancers/reduxRemember/driver';
import { useCallback } from 'react';
export const useClearStorage = () => {
const clearStorage = useCallback(() => {
clear(idbKeyValStore);
clearIdbKeyValStore();
localStorage.clear();
}, []);

View File

@@ -57,7 +57,6 @@ export const useGlobalHotkeys = () => {
{
enabled: () => !isDisabledCancelQueueItem && !isLoadingCancelQueueItem,
preventDefault: true,
enableOnFormTags: ['input', 'textarea', 'select'],
},
[cancelQueueItem, isDisabledCancelQueueItem, isLoadingCancelQueueItem]
);
@@ -74,7 +73,6 @@ export const useGlobalHotkeys = () => {
{
enabled: () => !isDisabledClearQueue && !isLoadingClearQueue,
preventDefault: true,
enableOnFormTags: ['input', 'textarea', 'select'],
},
[clearQueue, isDisabledClearQueue, isLoadingClearQueue]
);

View File

@@ -6,7 +6,7 @@ import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { clearCanvasHistory } from 'features/canvas/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { FaTrash } from 'react-icons/fa';
import { PiTrashSimpleFill } from 'react-icons/pi';
const ClearCanvasHistoryButtonModal = () => {
const isStaging = useAppSelector(isStagingSelector);
@@ -23,7 +23,7 @@ const ClearCanvasHistoryButtonModal = () => {
<InvButton
onClick={onOpen}
size="sm"
leftIcon={<FaTrash />}
leftIcon={<PiTrashSimpleFill />}
isDisabled={isStaging}
>
{t('unifiedCanvas.clearCanvasHistory')}

View File

@@ -19,14 +19,14 @@ import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import {
FaArrowLeft,
FaArrowRight,
FaCheck,
FaEye,
FaEyeSlash,
FaSave,
FaTimes,
} from 'react-icons/fa';
PiArrowLeftBold,
PiArrowRightBold,
PiCheckBold,
PiEyeBold,
PiEyeSlashBold,
PiFloppyDiskBold,
PiXBold,
} from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
const selector = createMemoizedSelector(selectCanvasSlice, (canvas) => {
@@ -140,7 +140,7 @@ const IAICanvasStagingAreaToolbar = () => {
<InvIconButton
tooltip={`${t('unifiedCanvas.previous')} (Left)`}
aria-label={`${t('unifiedCanvas.previous')} (Left)`}
icon={<FaArrowLeft />}
icon={<PiArrowLeftBold />}
onClick={handlePrevImage}
colorScheme="invokeBlue"
isDisabled={!shouldShowStagingImage}
@@ -154,7 +154,7 @@ const IAICanvasStagingAreaToolbar = () => {
<InvIconButton
tooltip={`${t('unifiedCanvas.next')} (Right)`}
aria-label={`${t('unifiedCanvas.next')} (Right)`}
icon={<FaArrowRight />}
icon={<PiArrowRightBold />}
onClick={handleNextImage}
colorScheme="invokeBlue"
isDisabled={!shouldShowStagingImage}
@@ -164,7 +164,7 @@ const IAICanvasStagingAreaToolbar = () => {
<InvIconButton
tooltip={`${t('unifiedCanvas.accept')} (Enter)`}
aria-label={`${t('unifiedCanvas.accept')} (Enter)`}
icon={<FaCheck />}
icon={<PiCheckBold />}
onClick={handleAccept}
colorScheme="invokeBlue"
/>
@@ -180,7 +180,7 @@ const IAICanvasStagingAreaToolbar = () => {
: t('unifiedCanvas.showResultsOff')
}
data-alert={!shouldShowStagingImage}
icon={shouldShowStagingImage ? <FaEye /> : <FaEyeSlash />}
icon={shouldShowStagingImage ? <PiEyeBold /> : <PiEyeSlashBold />}
onClick={handleToggleShouldShowStagingImage}
colorScheme="invokeBlue"
/>
@@ -188,14 +188,14 @@ const IAICanvasStagingAreaToolbar = () => {
tooltip={t('unifiedCanvas.saveToGallery')}
aria-label={t('unifiedCanvas.saveToGallery')}
isDisabled={!imageDTO || !imageDTO.is_intermediate}
icon={<FaSave />}
icon={<PiFloppyDiskBold />}
onClick={handleSaveToGallery}
colorScheme="invokeBlue"
/>
<InvIconButton
tooltip={t('unifiedCanvas.discardAll')}
aria-label={t('unifiedCanvas.discardAll')}
icon={<FaTimes />}
icon={<PiXBold />}
onClick={handleDiscardStagingArea}
colorScheme="error"
fontSize={20}

View File

@@ -25,7 +25,11 @@ import { memo, useCallback } from 'react';
import type { RgbaColor } from 'react-colorful';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { FaMask, FaSave, FaTrash } from 'react-icons/fa';
import {
PiExcludeBold,
PiFloppyDiskBackFill,
PiTrashSimpleFill,
} from 'react-icons/pi';
const IAICanvasMaskOptions = () => {
const dispatch = useAppDispatch();
@@ -110,7 +114,7 @@ const IAICanvasMaskOptions = () => {
<InvIconButton
aria-label={t('unifiedCanvas.maskingOptions')}
tooltip={t('unifiedCanvas.maskingOptions')}
icon={<FaMask />}
icon={<PiExcludeBold />}
isChecked={layer === 'mask'}
isDisabled={isStaging}
/>
@@ -136,12 +140,16 @@ const IAICanvasMaskOptions = () => {
onChange={handleChangeMaskColor}
/>
</Box>
<InvButton size="sm" leftIcon={<FaSave />} onClick={handleSaveMask}>
<InvButton
size="sm"
leftIcon={<PiFloppyDiskBackFill />}
onClick={handleSaveMask}
>
{t('unifiedCanvas.saveMask')}
</InvButton>
<InvButton
size="sm"
leftIcon={<FaTrash />}
leftIcon={<PiTrashSimpleFill />}
onClick={handleClearMask}
>
{t('unifiedCanvas.clearMask')}

View File

@@ -5,7 +5,7 @@ import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { FaRedo } from 'react-icons/fa';
import { PiArrowClockwiseBold } from 'react-icons/pi';
const IAICanvasRedoButton = () => {
const dispatch = useAppDispatch();
@@ -34,7 +34,7 @@ const IAICanvasRedoButton = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.redo')} (Ctrl+Shift+Z)`}
tooltip={`${t('unifiedCanvas.redo')} (Ctrl+Shift+Z)`}
icon={<FaRedo />}
icon={<PiArrowClockwiseBold />}
onClick={handleRedo}
isDisabled={!canRedo}
/>

View File

@@ -24,7 +24,7 @@ import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { FaWrench } from 'react-icons/fa';
import { PiGearSixBold } from 'react-icons/pi';
const IAICanvasSettingsButtonPopover = () => {
const dispatch = useAppDispatch();
@@ -114,7 +114,7 @@ const IAICanvasSettingsButtonPopover = () => {
<InvIconButton
tooltip={t('unifiedCanvas.canvasSettings')}
aria-label={t('unifiedCanvas.canvasSettings')}
icon={<FaWrench />}
icon={<PiGearSixBold />}
/>
</InvPopoverTrigger>
<InvPopoverContent>

View File

@@ -26,13 +26,13 @@ import type { RgbaColor } from 'react-colorful';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import {
FaEraser,
FaEyeDropper,
FaFillDrip,
FaPaintBrush,
FaSlidersH,
FaTimes,
} from 'react-icons/fa';
PiEraserBold,
PiEyedropperBold,
PiPaintBrushBold,
PiPaintBucketBold,
PiSlidersHorizontalBold,
PiXBold,
} from 'react-icons/pi';
const IAICanvasToolChooserOptions = () => {
const dispatch = useAppDispatch();
@@ -198,7 +198,7 @@ const IAICanvasToolChooserOptions = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.brush')} (B)`}
tooltip={`${t('unifiedCanvas.brush')} (B)`}
icon={<FaPaintBrush />}
icon={<PiPaintBrushBold />}
isChecked={tool === 'brush' && !isStaging}
onClick={handleSelectBrushTool}
isDisabled={isStaging}
@@ -206,7 +206,7 @@ const IAICanvasToolChooserOptions = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.eraser')} (E)`}
tooltip={`${t('unifiedCanvas.eraser')} (E)`}
icon={<FaEraser />}
icon={<PiEraserBold />}
isChecked={tool === 'eraser' && !isStaging}
isDisabled={isStaging}
onClick={handleSelectEraserTool}
@@ -214,21 +214,21 @@ const IAICanvasToolChooserOptions = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.fillBoundingBox')} (Shift+F)`}
tooltip={`${t('unifiedCanvas.fillBoundingBox')} (Shift+F)`}
icon={<FaFillDrip />}
icon={<PiPaintBucketBold />}
isDisabled={isStaging}
onClick={handleFillRect}
/>
<InvIconButton
aria-label={`${t('unifiedCanvas.eraseBoundingBox')} (Del/Backspace)`}
tooltip={`${t('unifiedCanvas.eraseBoundingBox')} (Del/Backspace)`}
icon={<FaTimes />}
icon={<PiXBold />}
isDisabled={isStaging}
onClick={handleEraseBoundingBox}
/>
<InvIconButton
aria-label={`${t('unifiedCanvas.colorPicker')} (C)`}
tooltip={`${t('unifiedCanvas.colorPicker')} (C)`}
icon={<FaEyeDropper />}
icon={<PiEyedropperBold />}
isChecked={tool === 'colorPicker' && !isStaging}
isDisabled={isStaging}
onClick={handleSelectColorPickerTool}
@@ -238,7 +238,7 @@ const IAICanvasToolChooserOptions = () => {
<InvIconButton
aria-label={t('unifiedCanvas.brushOptions')}
tooltip={t('unifiedCanvas.brushOptions')}
icon={<FaSlidersH />}
icon={<PiSlidersHorizontalBold />}
/>
</InvPopoverTrigger>
<InvPopoverContent>

View File

@@ -30,15 +30,15 @@ import { memo, useCallback, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import {
FaArrowsAlt,
FaCopy,
FaCrosshairs,
FaDownload,
FaLayerGroup,
FaSave,
FaTrash,
FaUpload,
} from 'react-icons/fa';
PiCopyBold,
PiCrosshairSimpleBold,
PiDownloadSimpleBold,
PiFloppyDiskBold,
PiHandGrabbingBold,
PiStackBold,
PiTrashSimpleBold,
PiUploadSimpleBold,
} from 'react-icons/pi';
import IAICanvasMaskOptions from './IAICanvasMaskOptions';
import IAICanvasRedoButton from './IAICanvasRedoButton';
@@ -217,14 +217,14 @@ const IAICanvasToolbar = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.move')} (V)`}
tooltip={`${t('unifiedCanvas.move')} (V)`}
icon={<FaArrowsAlt />}
icon={<PiHandGrabbingBold />}
isChecked={tool === 'move' || isStaging}
onClick={handleSelectMoveTool}
/>
<InvIconButton
aria-label={`${t('unifiedCanvas.resetView')} (R)`}
tooltip={`${t('unifiedCanvas.resetView')} (R)`}
icon={<FaCrosshairs />}
icon={<PiCrosshairSimpleBold />}
onClick={handleClickResetCanvasView}
/>
</InvButtonGroup>
@@ -233,14 +233,14 @@ const IAICanvasToolbar = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.mergeVisible')} (Shift+M)`}
tooltip={`${t('unifiedCanvas.mergeVisible')} (Shift+M)`}
icon={<FaLayerGroup />}
icon={<PiStackBold />}
onClick={handleMergeVisible}
isDisabled={isStaging}
/>
<InvIconButton
aria-label={`${t('unifiedCanvas.saveToGallery')} (Shift+S)`}
tooltip={`${t('unifiedCanvas.saveToGallery')} (Shift+S)`}
icon={<FaSave />}
icon={<PiFloppyDiskBold />}
onClick={handleSaveToGallery}
isDisabled={isStaging}
/>
@@ -248,7 +248,7 @@ const IAICanvasToolbar = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.copyToClipboard')} (Cmd/Ctrl+C)`}
tooltip={`${t('unifiedCanvas.copyToClipboard')} (Cmd/Ctrl+C)`}
icon={<FaCopy />}
icon={<PiCopyBold />}
onClick={handleCopyImageToClipboard}
isDisabled={isStaging}
/>
@@ -256,7 +256,7 @@ const IAICanvasToolbar = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.downloadAsImage')} (Shift+D)`}
tooltip={`${t('unifiedCanvas.downloadAsImage')} (Shift+D)`}
icon={<FaDownload />}
icon={<PiDownloadSimpleBold />}
onClick={handleDownloadAsImage}
isDisabled={isStaging}
/>
@@ -270,7 +270,7 @@ const IAICanvasToolbar = () => {
<InvIconButton
aria-label={`${t('common.upload')}`}
tooltip={`${t('common.upload')}`}
icon={<FaUpload />}
icon={<PiUploadSimpleBold />}
isDisabled={isStaging}
{...getUploadButtonProps()}
/>
@@ -278,7 +278,7 @@ const IAICanvasToolbar = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.clearCanvas')}`}
tooltip={`${t('unifiedCanvas.clearCanvas')}`}
icon={<FaTrash />}
icon={<PiTrashSimpleBold />}
onClick={handleResetCanvas}
colorScheme="error"
isDisabled={isStaging}

View File

@@ -5,7 +5,7 @@ import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { FaUndo } from 'react-icons/fa';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
const IAICanvasUndoButton = () => {
const dispatch = useAppDispatch();
@@ -33,7 +33,7 @@ const IAICanvasUndoButton = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.undo')} (Ctrl+Z)`}
tooltip={`${t('unifiedCanvas.undo')} (Ctrl+Z)`}
icon={<FaUndo />}
icon={<PiArrowCounterClockwiseBold />}
onClick={handleUndo}
isDisabled={!canUndo}
/>

View File

@@ -11,6 +11,7 @@ import { STAGE_PADDING_PERCENTAGE } from 'features/canvas/util/constants';
import floorCoordinates from 'features/canvas/util/floorCoordinates';
import getScaledBoundingBoxDimensions from 'features/canvas/util/getScaledBoundingBoxDimensions';
import roundDimensionsToMultiple from 'features/canvas/util/roundDimensionsToMultiple';
import { initialAspectRatioState } from 'features/parameters/components/ImageSize/constants';
import type { AspectRatioState } from 'features/parameters/components/ImageSize/types';
import { modelChanged } from 'features/parameters/store/generationSlice';
import type { PayloadActionWithOptimalDimension } from 'features/parameters/store/types';
@@ -23,7 +24,7 @@ import { clamp, cloneDeep } from 'lodash-es';
import type { RgbaColor } from 'react-colorful';
import { queueApi } from 'services/api/endpoints/queue';
import type { ImageDTO } from 'services/api/types';
import { appSocketQueueItemStatusChanged } from 'services/events/actions';
import { socketQueueItemStatusChanged } from 'services/events/actions';
import type {
BoundingBoxScaleMethod,
@@ -53,10 +54,11 @@ export const initialLayerState: CanvasLayerState = {
};
export const initialCanvasState: CanvasState = {
_version: 1,
boundingBoxCoordinates: { x: 0, y: 0 },
boundingBoxDimensions: { width: 512, height: 512 },
boundingBoxPreviewFill: { r: 0, g: 0, b: 0, a: 0.5 },
boundingBoxScaleMethod: 'none',
boundingBoxScaleMethod: 'auto',
brushColor: { r: 90, g: 90, b: 255, a: 1 },
brushSize: 50,
colorPickerColor: { r: 90, g: 90, b: 255, a: 1 },
@@ -695,7 +697,7 @@ export const canvasSlice = createSlice({
);
});
builder.addCase(appSocketQueueItemStatusChanged, (state, action) => {
builder.addCase(socketQueueItemStatusChanged, (state, action) => {
const batch_status = action.payload.data.batch_status;
if (!state.batchIds.includes(batch_status.batch_id)) {
return;
@@ -784,3 +786,12 @@ export const {
export default canvasSlice.reducer;
export const selectCanvasSlice = (state: RootState) => state.canvas;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const migrateCanvasState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
state.aspectRatio = initialAspectRatioState;
}
return state;
};

View File

@@ -117,6 +117,7 @@ export const isCanvasAnyLine = (
): obj is CanvasMaskLine | CanvasBaseLine => obj.kind === 'line';
export interface CanvasState {
_version: 1;
boundingBoxCoordinates: Vector2d;
boundingBoxDimensions: Dimensions;
boundingBoxPreviewFill: RgbaColor;

View File

@@ -16,7 +16,7 @@ import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { FaCopy, FaTrash } from 'react-icons/fa';
import { PiCopyBold, PiTrashSimpleBold } from 'react-icons/pi';
import { useToggle } from 'react-use';
import ControlAdapterImagePreview from './ControlAdapterImagePreview';
@@ -106,7 +106,7 @@ const ControlAdapterConfig = (props: { id: string; number: number }) => {
tooltip={t('controlnet.duplicate')}
aria-label={t('controlnet.duplicate')}
onClick={handleDuplicate}
icon={<FaCopy />}
icon={<PiCopyBold />}
/>
<InvIconButton
size="sm"
@@ -114,7 +114,7 @@ const ControlAdapterConfig = (props: { id: string; number: number }) => {
aria-label={t('controlnet.delete')}
colorScheme="error"
onClick={handleDelete}
icon={<FaTrash />}
icon={<PiTrashSimpleBold />}
/>
<InvIconButton
size="sm"
@@ -132,8 +132,6 @@ const ControlAdapterConfig = (props: { id: string; number: number }) => {
variant="ghost"
icon={
<ChevronUpIcon
boxSize={4}
color="base.300"
transform={isExpanded ? 'rotate(0deg)' : 'rotate(180deg)'}
transitionProperty="common"
transitionDuration="normal"
@@ -143,7 +141,7 @@ const ControlAdapterConfig = (props: { id: string; number: number }) => {
</Flex>
<Flex w="full" flexDir="column" gap={4}>
<Flex gap={4} w="full" alignItems="center">
<Flex gap={8} w="full" alignItems="center">
<Flex flexDir="column" gap={2} h={32} w="full">
<ParamControlAdapterWeight id={id} />
<ParamControlAdapterBeginEnd id={id} />

View File

@@ -7,7 +7,7 @@ import {
} from 'features/canvas/store/actions';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { FaImage, FaMask } from 'react-icons/fa';
import { PiExcludeBold, PiImageSquareBold } from 'react-icons/pi';
type ControlNetCanvasImageImportsProps = {
id: string;
@@ -29,17 +29,17 @@ const ControlNetCanvasImageImports = (
}, [id, dispatch]);
return (
<Flex gap={2}>
<Flex gap={4}>
<InvIconButton
size="sm"
icon={<FaImage />}
icon={<PiImageSquareBold />}
tooltip={t('controlnet.importImageFromCanvas')}
aria-label={t('controlnet.importImageFromCanvas')}
onClick={handleImportImageFromCanvas}
/>
<InvIconButton
size="sm"
icon={<FaMask />}
icon={<PiExcludeBold />}
tooltip={t('controlnet.importMaskFromCanvas')}
aria-label={t('controlnet.importMaskFromCanvas')}
onClick={handleImportMaskFromCanvas}

View File

@@ -1,4 +1,4 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InvControl } from 'common/components/InvControl/InvControl';
import { InvControlGroup } from 'common/components/InvControl/InvControlGroup';
import { InvNumberInput } from 'common/components/InvNumberInput/InvNumberInput';
@@ -17,10 +17,22 @@ type ParamControlAdapterWeightProps = {
const formatValue = (v: number) => v.toFixed(2);
const ParamControlAdapterWeight = ({ id }: ParamControlAdapterWeightProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isEnabled = useControlAdapterIsEnabled(id);
const weight = useControlAdapterWeight(id);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const initial = useAppSelector((s) => s.config.sd.ca.weight.initial);
const sliderMin = useAppSelector((s) => s.config.sd.ca.weight.sliderMin);
const sliderMax = useAppSelector((s) => s.config.sd.ca.weight.sliderMax);
const numberInputMin = useAppSelector(
(s) => s.config.sd.ca.weight.numberInputMin
);
const numberInputMax = useAppSelector(
(s) => s.config.sd.ca.weight.numberInputMax
);
const coarseStep = useAppSelector((s) => s.config.sd.ca.weight.coarseStep);
const fineStep = useAppSelector((s) => s.config.sd.ca.weight.fineStep);
const onChange = useCallback(
(weight: number) => {
dispatch(controlAdapterWeightChanged({ id, weight }));
@@ -43,23 +55,23 @@ const ParamControlAdapterWeight = ({ id }: ParamControlAdapterWeightProps) => {
<InvSlider
value={weight}
onChange={onChange}
defaultValue={1}
min={0}
max={2}
step={0.05}
fineStep={0.01}
defaultValue={initial}
min={sliderMin}
max={sliderMax}
step={coarseStep}
fineStep={fineStep}
marks={marks}
formatValue={formatValue}
/>
<InvNumberInput
value={weight}
onChange={onChange}
min={-1}
max={2}
step={0.05}
fineStep={0.01}
min={numberInputMin}
max={numberInputMax}
step={coarseStep}
fineStep={fineStep}
maxW={20}
defaultValue={1}
defaultValue={initial}
/>
</InvControl>
</InvControlGroup>

View File

@@ -9,7 +9,7 @@ import type {
ParameterT2IAdapterModel,
} from 'features/parameters/types/parameterSchemas';
import { cloneDeep, merge, uniq } from 'lodash-es';
import { appSocketInvocationError } from 'services/events/actions';
import { socketInvocationError } from 'services/events/actions';
import { v4 as uuidv4 } from 'uuid';
import { controlAdapterImageProcessed } from './actions';
@@ -51,10 +51,12 @@ export const {
selectTotal: selectControlAdapterTotal,
} = caAdapterSelectors;
export const initialControlAdapterState: ControlAdaptersState =
export const initialControlAdaptersState: ControlAdaptersState =
caAdapter.getInitialState<{
_version: 1;
pendingControlImages: string[];
}>({
_version: 1,
pendingControlImages: [],
});
@@ -96,7 +98,7 @@ export const selectValidT2IAdapters = (controlAdapters: ControlAdaptersState) =>
export const controlAdaptersSlice = createSlice({
name: 'controlAdapters',
initialState: initialControlAdapterState,
initialState: initialControlAdaptersState,
reducers: {
controlAdapterAdded: {
reducer: (
@@ -267,31 +269,29 @@ export const controlAdaptersSlice = createSlice({
const update: Update<ControlNetConfig | T2IAdapterConfig, string> = {
id,
changes: { model },
changes: { model, shouldAutoConfig: true },
};
update.changes.processedControlImage = null;
if (cn.shouldAutoConfig) {
let processorType: ControlAdapterProcessorType | undefined = undefined;
let processorType: ControlAdapterProcessorType | undefined = undefined;
for (const modelSubstring in CONTROLADAPTER_MODEL_DEFAULT_PROCESSORS) {
if (model.model_name.includes(modelSubstring)) {
processorType =
CONTROLADAPTER_MODEL_DEFAULT_PROCESSORS[modelSubstring];
break;
}
for (const modelSubstring in CONTROLADAPTER_MODEL_DEFAULT_PROCESSORS) {
if (model.model_name.includes(modelSubstring)) {
processorType =
CONTROLADAPTER_MODEL_DEFAULT_PROCESSORS[modelSubstring];
break;
}
}
if (processorType) {
update.changes.processorType = processorType;
update.changes.processorNode = CONTROLNET_PROCESSORS[processorType]
.default as RequiredControlAdapterProcessorNode;
} else {
update.changes.processorType = 'none';
update.changes.processorNode = CONTROLNET_PROCESSORS.none
.default as RequiredControlAdapterProcessorNode;
}
if (processorType) {
update.changes.processorType = processorType;
update.changes.processorNode = CONTROLNET_PROCESSORS[processorType]
.default as RequiredControlAdapterProcessorNode;
} else {
update.changes.processorType = 'none';
update.changes.processorNode = CONTROLNET_PROCESSORS.none
.default as RequiredControlAdapterProcessorNode;
}
caAdapter.updateOne(state, update);
@@ -435,7 +435,7 @@ export const controlAdaptersSlice = createSlice({
caAdapter.updateOne(state, update);
},
controlAdaptersReset: () => {
return cloneDeep(initialControlAdapterState);
return cloneDeep(initialControlAdaptersState);
},
pendingControlImagesCleared: (state) => {
state.pendingControlImages = [];
@@ -454,7 +454,7 @@ export const controlAdaptersSlice = createSlice({
}
});
builder.addCase(appSocketInvocationError, (state) => {
builder.addCase(socketInvocationError, (state) => {
state.pendingControlImages = [];
});
},
@@ -493,3 +493,11 @@ export const isAnyControlAdapterAdded = isAnyOf(
export const selectControlAdaptersSlice = (state: RootState) =>
state.controlAdapters;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const migrateControlAdaptersState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
return state;
};

View File

@@ -3,7 +3,7 @@ import { InvIconButton } from 'common/components/InvIconButton/InvIconButton';
import type { InvIconButtonProps } from 'common/components/InvIconButton/types';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaTrash } from 'react-icons/fa';
import { PiTrashSimpleBold } from 'react-icons/pi';
type DeleteImageButtonProps = Omit<InvIconButtonProps, 'aria-label'> & {
onClick: () => void;
@@ -17,7 +17,7 @@ export const DeleteImageButton = memo((props: DeleteImageButtonProps) => {
return (
<InvIconButton
onClick={onClick}
icon={<FaTrash />}
icon={<PiTrashSimpleBold />}
tooltip={`${t('gallery.deleteImage')} (Del)`}
aria-label={`${t('gallery.deleteImage')} (Del)`}
isDisabled={isDisabled || !isConnected}

View File

@@ -7,12 +7,17 @@ import { useTranslation } from 'react-i18next';
const ParamDynamicPromptsMaxPrompts = () => {
const maxPrompts = useAppSelector((s) => s.dynamicPrompts.maxPrompts);
const min = useAppSelector((s) => s.config.sd.dynamicPrompts.maxPrompts.min);
const sliderMin = useAppSelector(
(s) => s.config.sd.dynamicPrompts.maxPrompts.sliderMin
);
const sliderMax = useAppSelector(
(s) => s.config.sd.dynamicPrompts.maxPrompts.sliderMax
);
const inputMax = useAppSelector(
(s) => s.config.sd.dynamicPrompts.maxPrompts.inputMax
const numberInputMin = useAppSelector(
(s) => s.config.sd.dynamicPrompts.maxPrompts.numberInputMin
);
const numberInputMax = useAppSelector(
(s) => s.config.sd.dynamicPrompts.maxPrompts.numberInputMax
);
const initial = useAppSelector(
(s) => s.config.sd.dynamicPrompts.maxPrompts.initial
@@ -36,14 +41,15 @@ const ParamDynamicPromptsMaxPrompts = () => {
renderInfoPopoverInPortal={false}
>
<InvSlider
min={min}
min={sliderMin}
max={sliderMax}
value={maxPrompts}
defaultValue={initial}
onChange={handleChange}
marks
withNumberInput
numberInputMax={inputMax}
numberInputMin={numberInputMin}
numberInputMax={numberInputMax}
/>
</InvControl>
);

View File

@@ -7,7 +7,9 @@ export const zSeedBehaviour = z.enum(['PER_ITERATION', 'PER_PROMPT']);
export type SeedBehaviour = z.infer<typeof zSeedBehaviour>;
export const isSeedBehaviour = (v: unknown): v is SeedBehaviour =>
zSeedBehaviour.safeParse(v).success;
export interface DynamicPromptsState {
_version: 1;
maxPrompts: number;
combinatorial: boolean;
prompts: string[];
@@ -18,6 +20,7 @@ export interface DynamicPromptsState {
}
export const initialDynamicPromptsState: DynamicPromptsState = {
_version: 1,
maxPrompts: 100,
combinatorial: true,
prompts: [],
@@ -78,3 +81,11 @@ export default dynamicPromptsSlice.reducer;
export const selectDynamicPromptsSlice = (state: RootState) =>
state.dynamicPrompts;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const migrateDynamicPromptsState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
return state;
};

View File

@@ -2,7 +2,6 @@ import type { ChakraProps } from '@chakra-ui/react';
import { useAppSelector } from 'app/store/storeHooks';
import { InvControl } from 'common/components/InvControl/InvControl';
import { InvSelect } from 'common/components/InvSelect/InvSelect';
import { InvSelectFallback } from 'common/components/InvSelect/InvSelectFallback';
import { useGroupedModelInvSelect } from 'common/components/InvSelect/useGroupedModelInvSelect';
import type { EmbeddingSelectProps } from 'features/embedding/types';
import { t } from 'i18next';
@@ -47,23 +46,16 @@ export const EmbeddingSelect = memo(
onChange: _onChange,
});
if (isLoading) {
return <InvSelectFallback label={t('common.loading')} />;
}
if (options.length === 0) {
return <InvSelectFallback label={t('embedding.noEmbeddingsLoaded')} />;
}
return (
<InvControl isDisabled={!options.length}>
<InvControl>
<InvSelect
placeholder={t('embedding.addEmbedding')}
placeholder={
isLoading ? t('common.loading') : t('embedding.addEmbedding')
}
defaultMenuIsOpen
autoFocus
value={null}
options={options}
isDisabled={!options.length}
noOptionsMessage={noOptionsMessage}
onChange={onChange}
onMenuClose={onClose}

View File

@@ -1,7 +1,7 @@
import { InvIconButton } from 'common/components/InvIconButton/InvIconButton';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { FaPlus } from 'react-icons/fa';
import { PiPlusBold } from 'react-icons/pi';
import { useCreateBoardMutation } from 'services/api/endpoints/boards';
const AddBoardButton = () => {
@@ -14,7 +14,7 @@ const AddBoardButton = () => {
return (
<InvIconButton
icon={<FaPlus />}
icon={<PiPlusBold />}
isLoading={isLoading}
tooltip={t('boards.addBoard')}
aria-label={t('boards.addBoard')}

View File

@@ -1,4 +1,3 @@
import { CloseIcon } from '@chakra-ui/icons';
import { Input, InputGroup, InputRightElement } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InvIconButton } from 'common/components/InvIconButton/InvIconButton';
@@ -6,6 +5,7 @@ import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice';
import type { ChangeEvent, KeyboardEvent } from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
const BoardsSearch = () => {
const dispatch = useAppDispatch();
@@ -66,7 +66,7 @@ const BoardsSearch = () => {
size="sm"
variant="ghost"
aria-label={t('boards.clearSearch')}
icon={<CloseIcon boxSize={3} />}
icon={<PiXBold />}
/>
</InputRightElement>
)}

View File

@@ -1,6 +1,6 @@
import { Box, Flex, Image } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import InvokeAILogoImage from 'assets/images/logo.png';
import InvokeLogoSVG from 'assets/images/invoke-key-wht-lrg.svg';
import IAIDroppable from 'common/components/IAIDroppable';
import { InvText } from 'common/components/InvText/wrapper';
import { InvTooltip } from 'common/components/InvTooltip/InvTooltip';
@@ -101,10 +101,10 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
alignItems="center"
>
<Image
src={InvokeAILogoImage}
src={InvokeLogoSVG}
alt="invoke-ai-logo"
opacity={0.4}
filter="grayscale(1)"
opacity={0.7}
mixBlendMode="overlay"
mt={-6}
w={16}
h={16}

View File

@@ -151,7 +151,7 @@ const DeleteBoardModal = (props: Props) => {
</Flex>
</InvAlertDialogBody>
<InvAlertDialogFooter>
<Flex w="full" gap={2} justifyContent="space-between">
<Flex w="full" gap={2} justifyContent="end">
<InvButton ref={cancelRef} onClick={handleClose}>
{t('boards.cancel')}
</InvButton>

View File

@@ -29,14 +29,15 @@ import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import {
FaAsterisk,
FaCode,
FaHourglassHalf,
FaQuoteRight,
FaRulerVertical,
FaSeedling,
} from 'react-icons/fa';
import { FaCircleNodes, FaEllipsis } from 'react-icons/fa6';
PiAsteriskBold,
PiDotsThreeOutlineFill,
PiFlowArrowBold,
PiHourglassHighBold,
PiInfoBold,
PiPlantBold,
PiQuotesBold,
PiRulerBold,
} from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
@@ -210,7 +211,7 @@ const CurrentImageButtons = () => {
aria-label={t('parameters.imageActions')}
tooltip={t('parameters.imageActions')}
isDisabled={!imageDTO}
icon={<FaEllipsis />}
icon={<PiDotsThreeOutlineFill />}
/>
<InvMenuList>
{imageDTO && <SingleSelectionMenuItems imageDTO={imageDTO} />}
@@ -220,7 +221,7 @@ const CurrentImageButtons = () => {
<InvButtonGroup isDisabled={shouldDisableToolbarButtons}>
<InvIconButton
icon={<FaCircleNodes />}
icon={<PiFlowArrowBold />}
tooltip={`${t('nodes.loadWorkflow')} (W)`}
aria-label={`${t('nodes.loadWorkflow')} (W)`}
isDisabled={!imageDTO?.has_workflow}
@@ -229,7 +230,7 @@ const CurrentImageButtons = () => {
/>
<InvIconButton
isLoading={isLoadingMetadata}
icon={<FaQuoteRight />}
icon={<PiQuotesBold />}
tooltip={`${t('parameters.usePrompt')} (P)`}
aria-label={`${t('parameters.usePrompt')} (P)`}
isDisabled={!metadata?.positive_prompt}
@@ -237,7 +238,7 @@ const CurrentImageButtons = () => {
/>
<InvIconButton
isLoading={isLoadingMetadata}
icon={<FaSeedling />}
icon={<PiPlantBold />}
tooltip={`${t('parameters.useSeed')} (S)`}
aria-label={`${t('parameters.useSeed')} (S)`}
isDisabled={metadata?.seed === null || metadata?.seed === undefined}
@@ -245,7 +246,7 @@ const CurrentImageButtons = () => {
/>
<InvIconButton
isLoading={isLoadingMetadata}
icon={<FaRulerVertical />}
icon={<PiRulerBold />}
tooltip={`${t('parameters.useSize')} (D)`}
aria-label={`${t('parameters.useSize')} (D)`}
isDisabled={
@@ -258,7 +259,7 @@ const CurrentImageButtons = () => {
/>
<InvIconButton
isLoading={isLoadingMetadata}
icon={<FaAsterisk />}
icon={<PiAsteriskBold />}
tooltip={`${t('parameters.useAll')} (A)`}
aria-label={`${t('parameters.useAll')} (A)`}
isDisabled={!metadata}
@@ -274,7 +275,7 @@ const CurrentImageButtons = () => {
<InvButtonGroup>
<InvIconButton
icon={<FaCode />}
icon={<PiInfoBold />}
tooltip={`${t('parameters.info')} (I)`}
aria-label={`${t('parameters.info')} (I)`}
isChecked={shouldShowImageDetails}
@@ -286,7 +287,7 @@ const CurrentImageButtons = () => {
<InvIconButton
aria-label={t('settings.displayInProgress')}
tooltip={t('settings.displayInProgress')}
icon={<FaHourglassHalf />}
icon={<PiHourglassHighBold />}
isChecked={shouldShowProgressInViewer}
onClick={handleClickProgressImagesToggle}
/>

View File

@@ -11,13 +11,11 @@ import type {
import ProgressImage from 'features/gallery/components/CurrentImage/ProgressImage';
import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer';
import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
import { useNextPrevImage } from 'features/gallery/hooks/useNextPrevImage';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import type { AnimationProps } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { FaImage } from 'react-icons/fa';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
@@ -39,43 +37,6 @@ const CurrentImagePreview = () => {
(s) => s.ui.shouldShowProgressInViewer
);
const {
handlePrevImage,
handleNextImage,
isOnLastImage,
handleLoadMoreImages,
areMoreImagesAvailable,
isFetching,
} = useNextPrevImage();
useHotkeys(
'left',
() => {
handlePrevImage();
},
[handlePrevImage]
);
useHotkeys(
'right',
() => {
if (isOnLastImage && areMoreImagesAvailable && !isFetching) {
handleLoadMoreImages();
return;
}
if (!isOnLastImage) {
handleNextImage();
}
},
[
isOnLastImage,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,
handleNextImage,
]
);
const { currentData: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken);
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {

View File

@@ -19,7 +19,7 @@ import {
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { FaWrench } from 'react-icons/fa';
import { RiSettings4Fill } from 'react-icons/ri';
import BoardAutoAddSelect from './Boards/BoardAutoAddSelect';
@@ -61,7 +61,7 @@ const GallerySettingsPopover = () => {
tooltip={t('gallery.gallerySettings')}
aria-label={t('gallery.gallerySettings')}
size="sm"
icon={<FaWrench />}
icon={<RiSettings4Fill />}
/>
</InvPopoverTrigger>
<InvPopoverContent>

View File

@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react';
import { $customStarUI } from 'app/store/nanostores/customStarUI';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InvMenuItem } from 'common/components/InvMenu/InvMenuItem';
import { InvMenuDivider } from 'common/components/InvMenu/wrapper';
import {
imagesToChangeSelected,
isModalOpenChanged,
@@ -11,8 +12,13 @@ import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { addToast } from 'features/system/store/systemSlice';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaDownload, FaFolder, FaTrash } from 'react-icons/fa';
import { MdStar, MdStarBorder } from 'react-icons/md';
import {
PiDownloadSimpleBold,
PiFoldersBold,
PiStarBold,
PiStarFill,
PiTrashSimpleBold,
} from 'react-icons/pi';
import {
useBulkDownloadImagesMutation,
useStarImagesMutation,
@@ -90,7 +96,7 @@ const MultipleSelectionMenuItems = () => {
<>
{areAllStarred && (
<InvMenuItem
icon={customStarUi ? customStarUi.on.icon : <MdStarBorder />}
icon={customStarUi ? customStarUi.on.icon : <PiStarBold />}
onClickCapture={handleUnstarSelection}
>
{customStarUi ? customStarUi.off.text : `Unstar All`}
@@ -98,23 +104,27 @@ const MultipleSelectionMenuItems = () => {
)}
{(areAllUnstarred || (!areAllStarred && !areAllUnstarred)) && (
<InvMenuItem
icon={customStarUi ? customStarUi.on.icon : <MdStar />}
icon={customStarUi ? customStarUi.on.icon : <PiStarFill />}
onClickCapture={handleStarSelection}
>
{customStarUi ? customStarUi.on.text : `Star All`}
</InvMenuItem>
)}
{isBulkDownloadEnabled && (
<InvMenuItem icon={<FaDownload />} onClickCapture={handleBulkDownload}>
<InvMenuItem
icon={<PiDownloadSimpleBold />}
onClickCapture={handleBulkDownload}
>
{t('gallery.downloadSelection')}
</InvMenuItem>
)}
<InvMenuItem icon={<FaFolder />} onClickCapture={handleChangeBoard}>
<InvMenuItem icon={<PiFoldersBold />} onClickCapture={handleChangeBoard}>
{t('boards.changeBoard')}
</InvMenuItem>
<InvMenuDivider />
<InvMenuItem
color="error.300"
icon={<FaTrash />}
icon={<PiTrashSimpleBold />}
onClickCapture={handleDeleteSelection}
>
{t('gallery.deleteSelection')}

View File

@@ -4,6 +4,7 @@ import { useAppToaster } from 'app/components/Toaster';
import { $customStarUI } from 'app/store/nanostores/customStarUI';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InvMenuItem } from 'common/components/InvMenu/InvMenuItem';
import { InvMenuDivider } from 'common/components/InvMenu/wrapper';
import { useCopyImageToClipboard } from 'common/hooks/useCopyImageToClipboard';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import {
@@ -25,18 +26,18 @@ import { memo, useCallback } from 'react';
import { flushSync } from 'react-dom';
import { useTranslation } from 'react-i18next';
import {
FaAsterisk,
FaCopy,
FaDownload,
FaExternalLinkAlt,
FaFolder,
FaQuoteRight,
FaSeedling,
FaShare,
FaTrash,
} from 'react-icons/fa';
import { FaCircleNodes } from 'react-icons/fa6';
import { MdStar, MdStarBorder } from 'react-icons/md';
PiAsteriskBold,
PiCopyBold,
PiDownloadSimpleBold,
PiFlowArrowBold,
PiFoldersBold,
PiPlantBold,
PiQuotesBold,
PiShareFatBold,
PiStarBold,
PiStarFill,
PiTrashSimpleBold,
} from 'react-icons/pi';
import {
useStarImagesMutation,
useUnstarImagesMutation,
@@ -155,12 +156,12 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
as="a"
href={imageDTO.image_url}
target="_blank"
icon={<FaExternalLinkAlt />}
icon={<PiShareFatBold />}
>
{t('common.openInNewTab')}
</InvMenuItem>
{isClipboardAPIAvailable && (
<InvMenuItem icon={<FaCopy />} onClickCapture={handleCopyImage}>
<InvMenuItem icon={<PiCopyBold />} onClickCapture={handleCopyImage}>
{t('parameters.copyImage')}
</InvMenuItem>
)}
@@ -169,17 +170,18 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
download={true}
href={imageDTO.image_url}
target="_blank"
icon={<FaDownload />}
icon={<PiDownloadSimpleBold />}
w="100%"
>
{t('parameters.downloadImage')}
</InvMenuItem>
<InvMenuDivider />
<InvMenuItem
icon={
getAndLoadEmbeddedWorkflowResult.isLoading ? (
<SpinnerIcon />
) : (
<FaCircleNodes />
<PiFlowArrowBold />
)
}
onClickCapture={handleLoadWorkflow}
@@ -188,7 +190,7 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
{t('nodes.loadWorkflow')}
</InvMenuItem>
<InvMenuItem
icon={isLoadingMetadata ? <SpinnerIcon /> : <FaQuoteRight />}
icon={isLoadingMetadata ? <SpinnerIcon /> : <PiQuotesBold />}
onClickCapture={handleRecallPrompt}
isDisabled={
isLoadingMetadata ||
@@ -199,21 +201,22 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
{t('parameters.usePrompt')}
</InvMenuItem>
<InvMenuItem
icon={isLoadingMetadata ? <SpinnerIcon /> : <FaSeedling />}
icon={isLoadingMetadata ? <SpinnerIcon /> : <PiPlantBold />}
onClickCapture={handleRecallSeed}
isDisabled={isLoadingMetadata || metadata?.seed === undefined}
>
{t('parameters.useSeed')}
</InvMenuItem>
<InvMenuItem
icon={isLoadingMetadata ? <SpinnerIcon /> : <FaAsterisk />}
icon={isLoadingMetadata ? <SpinnerIcon /> : <PiAsteriskBold />}
onClickCapture={handleUseAllParameters}
isDisabled={isLoadingMetadata || !metadata}
>
{t('parameters.useAll')}
</InvMenuItem>
<InvMenuDivider />
<InvMenuItem
icon={<FaShare />}
icon={<PiShareFatBold />}
onClickCapture={handleSendToImageToImage}
id="send-to-img2img"
>
@@ -221,34 +224,36 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
</InvMenuItem>
{isCanvasEnabled && (
<InvMenuItem
icon={<FaShare />}
icon={<PiShareFatBold />}
onClickCapture={handleSendToCanvas}
id="send-to-canvas"
>
{t('parameters.sendToUnifiedCanvas')}
</InvMenuItem>
)}
<InvMenuItem icon={<FaFolder />} onClickCapture={handleChangeBoard}>
<InvMenuDivider />
<InvMenuItem icon={<PiFoldersBold />} onClickCapture={handleChangeBoard}>
{t('boards.changeBoard')}
</InvMenuItem>
{imageDTO.starred ? (
<InvMenuItem
icon={customStarUi ? customStarUi.off.icon : <MdStar />}
icon={customStarUi ? customStarUi.off.icon : <PiStarFill />}
onClickCapture={handleUnstarImage}
>
{customStarUi ? customStarUi.off.text : t('gallery.unstarImage')}
</InvMenuItem>
) : (
<InvMenuItem
icon={customStarUi ? customStarUi.on.icon : <MdStarBorder />}
icon={customStarUi ? customStarUi.on.icon : <PiStarBold />}
onClickCapture={handleStarImage}
>
{customStarUi ? customStarUi.on.text : t('gallery.starImage')}
</InvMenuItem>
)}
<InvMenuDivider />
<InvMenuItem
color="error.300"
icon={<FaTrash />}
icon={<PiTrashSimpleBold />}
onClickCapture={handleDelete}
>
{t('gallery.deleteImage')}

View File

@@ -15,7 +15,8 @@ import { InvButtonGroup } from 'common/components/InvButtonGroup/InvButtonGroup'
import { galleryViewChanged } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { FaImages, FaServer } from 'react-icons/fa';
import { PiImagesBold } from 'react-icons/pi';
import { RiServerLine } from 'react-icons/ri';
import BoardsList from './Boards/BoardsList/BoardsList';
import GalleryBoardName from './GalleryBoardName';
@@ -83,7 +84,7 @@ const ImageGalleryContent = () => {
isChecked={galleryView === 'images'}
onClick={handleClickImages}
w="full"
leftIcon={<FaImages />}
leftIcon={<PiImagesBold size="16px" />}
data-testid="images-tab"
>
{t('parameters.images')}
@@ -94,7 +95,7 @@ const ImageGalleryContent = () => {
isChecked={galleryView === 'assets'}
onClick={handleClickAssets}
w="full"
leftIcon={<FaServer />}
leftIcon={<RiServerLine size="16px" />}
data-testid="assets-tab"
>
{t('gallery.assets')}

View File

@@ -13,14 +13,13 @@ import type {
ImageDTOsDraggableData,
TypesafeDraggableData,
} from 'features/dnd/types';
import type { VirtuosoGalleryContext } from 'features/gallery/components/ImageGrid/types';
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
import { useMultiselect } from 'features/gallery/hooks/useMultiselect';
import { useScrollToVisible } from 'features/gallery/hooks/useScrollToVisible';
import { useScrollIntoView } from 'features/gallery/hooks/useScrollIntoView';
import type { MouseEvent } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaTrash } from 'react-icons/fa';
import { MdStar, MdStarBorder } from 'react-icons/md';
import { PiStarBold, PiStarFill, PiTrashSimpleFill } from 'react-icons/pi';
import {
useGetImageDTOQuery,
useStarImagesMutation,
@@ -35,12 +34,11 @@ const imageIconStyleOverrides: SystemStyleObject = {
interface HoverableImageProps {
imageName: string;
index: number;
virtuosoContext: VirtuosoGalleryContext;
}
const GalleryImage = (props: HoverableImageProps) => {
const dispatch = useAppDispatch();
const { imageName, virtuosoContext } = props;
const { imageName } = props;
const { currentData: imageDTO } = useGetImageDTOQuery(imageName);
const shift = useStore($shift);
const { t } = useTranslation();
@@ -50,11 +48,10 @@ const GalleryImage = (props: HoverableImageProps) => {
const customStarUi = useStore($customStarUI);
const imageContainerRef = useScrollToVisible(
const imageContainerRef = useScrollIntoView(
isSelected,
props.index,
selectionCount,
virtuosoContext
selectionCount
);
const handleDelete = useCallback(
@@ -114,10 +111,10 @@ const GalleryImage = (props: HoverableImageProps) => {
const starIcon = useMemo(() => {
if (imageDTO?.starred) {
return customStarUi ? customStarUi.on.icon : <MdStar size="20" />;
return customStarUi ? customStarUi.on.icon : <PiStarFill size="20" />;
}
if (!imageDTO?.starred && isHovered) {
return customStarUi ? customStarUi.off.icon : <MdStarBorder size="20" />;
return customStarUi ? customStarUi.off.icon : <PiStarBold size="20" />;
}
}, [imageDTO?.starred, isHovered, customStarUi]);
@@ -131,12 +128,22 @@ const GalleryImage = (props: HoverableImageProps) => {
return '';
}, [imageDTO?.starred, customStarUi]);
const dataTestId = useMemo(
() => getGalleryImageDataTestId(imageDTO?.image_name),
[imageDTO?.image_name]
);
if (!imageDTO) {
return <IAIFillSkeleton />;
}
return (
<Box w="full" h="full" data-testid={`image-${imageDTO.image_name}`}>
<Box
w="full"
h="full"
className="gallerygrid-image"
data-testid={dataTestId}
>
<Flex
ref={imageContainerRef}
userSelect="none"
@@ -169,7 +176,7 @@ const GalleryImage = (props: HoverableImageProps) => {
{isHovered && shift && (
<IAIDndImageIcon
onClick={handleDelete}
icon={<FaTrash />}
icon={<PiTrashSimpleFill size="16px" />}
tooltip={t('gallery.deleteImage')}
styleOverrides={imageIconStyleOverrides}
/>

View File

@@ -4,13 +4,12 @@ import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { InvButton } from 'common/components/InvButton/InvButton';
import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
import type { VirtuosoGalleryContext } from 'features/gallery/components/ImageGrid/types';
import { $useNextPrevImageState } from 'features/gallery/hooks/useNextPrevImage';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { IMAGE_LIMIT } from 'features/gallery/store/types';
import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
import { useGalleryHotkeys } from 'features/gallery/hooks/useGalleryHotkeys';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import type { CSSProperties } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaExclamationCircle, FaImage } from 'react-icons/fa';
import type {
@@ -20,10 +19,6 @@ import type {
VirtuosoGridHandle,
} from 'react-virtuoso';
import { VirtuosoGrid } from 'react-virtuoso';
import {
useLazyListImagesQuery,
useListImagesQuery,
} from 'services/api/endpoints/images';
import { useBoardTotal } from 'services/api/hooks/useBoardTotal';
import GalleryImage from './GalleryImage';
@@ -46,56 +41,24 @@ const GalleryImageGrid = () => {
);
const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
const { currentViewTotal } = useBoardTotal(selectedBoardId);
const queryArgs = useAppSelector(selectListImagesBaseQueryArgs);
const virtuosoRangeRef = useRef<ListRange | null>(null);
const virtuosoRef = useRef<VirtuosoGridHandle>(null);
const { currentData, isFetching, isSuccess, isError } =
useListImagesQuery(queryArgs);
const [listImages] = useLazyListImagesQuery();
const areMoreAvailable = useMemo(() => {
if (!currentData || !currentViewTotal) {
return false;
}
return currentData.ids.length < currentViewTotal;
}, [currentData, currentViewTotal]);
const handleLoadMoreImages = useCallback(() => {
if (!areMoreAvailable) {
return;
}
listImages({
...queryArgs,
offset: currentData?.ids.length ?? 0,
limit: IMAGE_LIMIT,
});
}, [areMoreAvailable, listImages, queryArgs, currentData?.ids.length]);
const virtuosoContext = useMemo<VirtuosoGalleryContext>(() => {
return {
virtuosoRef,
rootRef,
virtuosoRangeRef,
};
}, []);
const itemContentFunc: ItemContent<EntityId, VirtuosoGalleryContext> =
useCallback(
(index, imageName, virtuosoContext) => (
<GalleryImage
key={imageName}
index={index}
imageName={imageName as string}
virtuosoContext={virtuosoContext}
/>
),
[]
);
const {
areMoreImagesAvailable,
handleLoadMoreImages,
queryResult: { currentData, isFetching, isSuccess, isError },
} = useGalleryImages();
useGalleryHotkeys();
const itemContentFunc: ItemContent<EntityId, void> = useCallback(
(index, imageName) => (
<GalleryImage
key={imageName}
index={index}
imageName={imageName as string}
/>
),
[]
);
useEffect(() => {
// Initialize the gallery's custom scrollbar
@@ -116,8 +79,10 @@ const GalleryImageGrid = () => {
}, []);
useEffect(() => {
$useNextPrevImageState.setKey('virtuosoRef', virtuosoRef);
$useNextPrevImageState.setKey('virtuosoRangeRef', virtuosoRangeRef);
virtuosoGridRefs.set({ rootRef, virtuosoRangeRef, virtuosoRef });
return () => {
virtuosoGridRefs.set({});
};
}, []);
if (!currentData) {
@@ -142,7 +107,7 @@ const GalleryImageGrid = () => {
if (isSuccess && currentData) {
return (
<>
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
<Box ref={rootRef} data-overlayscrollbars="" h="100%" id="gallery-grid">
<VirtuosoGrid
style={virtuosoStyles}
data={currentData.ids}
@@ -152,13 +117,12 @@ const GalleryImageGrid = () => {
itemContent={itemContentFunc}
ref={virtuosoRef}
rangeChanged={onRangeChanged}
context={virtuosoContext}
overscan={10}
/>
</Box>
<InvButton
onClick={handleLoadMoreImages}
isDisabled={!areMoreAvailable}
isDisabled={!areMoreImagesAvailable}
isLoading={isFetching}
loadingText={t('gallery.loading')}
flexShrink={0}

View File

@@ -3,13 +3,15 @@ import { Box, forwardRef } from '@chakra-ui/react';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
export const imageItemContainerTestId = 'image-item-container';
type ItemContainerProps = PropsWithChildren & FlexProps;
const ItemContainer = forwardRef((props: ItemContainerProps, ref) => (
<Box
className="item-container"
ref={ref}
p={1.5}
data-testid="image-item-container"
data-testid={imageItemContainerTestId}
>
{props.children}
</Box>

View File

@@ -4,6 +4,8 @@ import { useAppSelector } from 'app/store/storeHooks';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
export const imageListContainerTestId = 'image-list-container';
type ListContainerProps = PropsWithChildren & FlexProps;
const ListContainer = forwardRef((props: ListContainerProps, ref) => {
const galleryImageMinimumWidth = useAppSelector(
@@ -16,7 +18,7 @@ const ListContainer = forwardRef((props: ListContainerProps, ref) => {
className="list-container"
ref={ref}
gridTemplateColumns={`repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr))`}
data-testid="image-list-container"
data-testid={imageListContainerTestId}
>
{props.children}
</Grid>

View File

@@ -0,0 +1,2 @@
export const getGalleryImageDataTestId = (imageName?: string) =>
`gallery-image-${imageName}`;

View File

@@ -1,8 +1,11 @@
import { atom } from 'nanostores';
import type { RefObject } from 'react';
import type { ListRange, VirtuosoGridHandle } from 'react-virtuoso';
export type VirtuosoGalleryContext = {
virtuosoRef: RefObject<VirtuosoGridHandle>;
rootRef: RefObject<HTMLDivElement>;
virtuosoRangeRef: RefObject<ListRange>;
export type VirtuosoGridRefs = {
virtuosoRef?: RefObject<VirtuosoGridHandle>;
rootRef?: RefObject<HTMLDivElement>;
virtuosoRangeRef?: RefObject<ListRange>;
};
export const virtuosoGridRefs = atom<VirtuosoGridRefs>({});

View File

@@ -1,7 +1,8 @@
import type { ChakraProps } from '@chakra-ui/react';
import { Box, Flex, Spinner } from '@chakra-ui/react';
import { InvIconButton } from 'common/components/InvIconButton/InvIconButton';
import { useNextPrevImage } from 'features/gallery/hooks/useNextPrevImage';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaAngleDoubleRight, FaAngleLeft, FaAngleRight } from 'react-icons/fa';
@@ -14,15 +15,14 @@ const nextPrevButtonStyles: ChakraProps['sx'] = {
const NextPrevImageButtons = () => {
const { t } = useTranslation();
const { handleLeftImage, handleRightImage, isOnFirstImage, isOnLastImage } =
useGalleryNavigation();
const {
handlePrevImage,
handleNextImage,
isOnFirstImage,
isOnLastImage,
handleLoadMoreImages,
areMoreImagesAvailable,
isFetching,
} = useNextPrevImage();
handleLoadMoreImages,
queryResult: { isFetching },
} = useGalleryImages();
return (
<Box pos="relative" h="full" w="full">
@@ -37,7 +37,7 @@ const NextPrevImageButtons = () => {
aria-label={t('accessibility.previousImage')}
icon={<FaAngleLeft size={64} />}
variant="unstyled"
onClick={handlePrevImage}
onClick={handleLeftImage}
boxSize={16}
sx={nextPrevButtonStyles}
/>
@@ -54,7 +54,7 @@ const NextPrevImageButtons = () => {
aria-label={t('accessibility.nextImage')}
icon={<FaAngleRight size={64} />}
variant="unstyled"
onClick={handleNextImage}
onClick={handleRightImage}
boxSize={16}
sx={nextPrevButtonStyles}
/>

View File

@@ -0,0 +1,79 @@
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation';
import { useHotkeys } from 'react-hotkeys-hook';
/**
* Registers gallery hotkeys. This hook is a singleton.
*/
export const useGalleryHotkeys = () => {
const {
areMoreImagesAvailable,
handleLoadMoreImages,
queryResult: { isFetching },
} = useGalleryImages();
const {
handleLeftImage,
handleRightImage,
handleUpImage,
handleDownImage,
isOnLastImage,
areImagesBelowCurrent,
} = useGalleryNavigation();
useHotkeys(
'left',
() => {
handleLeftImage();
},
[handleLeftImage]
);
useHotkeys(
'right',
() => {
if (isOnLastImage && areMoreImagesAvailable && !isFetching) {
handleLoadMoreImages();
return;
}
if (!isOnLastImage) {
handleRightImage();
}
},
[
isOnLastImage,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,
handleRightImage,
]
);
useHotkeys(
'up',
() => {
handleUpImage();
},
{ preventDefault: true },
[handleUpImage]
);
useHotkeys(
'down',
() => {
if (!areImagesBelowCurrent && areMoreImagesAvailable && !isFetching) {
handleLoadMoreImages();
return;
}
handleDownImage();
},
{ preventDefault: true },
[
areImagesBelowCurrent,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,
handleDownImage,
]
);
};

View File

@@ -0,0 +1,73 @@
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { IMAGE_LIMIT } from 'features/gallery/store/types';
import { atom } from 'nanostores';
import { useCallback, useMemo } from 'react';
import {
useGetBoardAssetsTotalQuery,
useGetBoardImagesTotalQuery,
} from 'services/api/endpoints/boards';
import { useListImagesQuery } from 'services/api/endpoints/images';
import type { ListImagesArgs } from 'services/api/types';
export type UseGalleryImagesReturn = {
handleLoadMoreImages: () => void;
areMoreImagesAvailable: boolean;
queryResult: ReturnType<typeof useListImagesQuery>;
};
// The gallery is a singleton but multiple components need access to its query data.
// If we don't define the query args outside of the hook, then each component will
// have its own query args and trigger multiple requests. We use an atom to store
// the query args outside of the hook so that all consumers use the same query args.
const $queryArgs = atom<ListImagesArgs | null>(null);
/**
* Provides access to the gallery images and a way to imperatively fetch more.
*
* This hook is a singleton.
*/
export const useGalleryImages = (): UseGalleryImagesReturn => {
const galleryView = useAppSelector((s) => s.gallery.galleryView);
const baseQueryArgs = useAppSelector(selectListImagesBaseQueryArgs);
const queryArgs = useStore($queryArgs);
const queryResult = useListImagesQuery(queryArgs ?? baseQueryArgs);
const boardId = useMemo(
() => baseQueryArgs.board_id ?? 'none',
[baseQueryArgs.board_id]
);
const { data: assetsTotal } = useGetBoardAssetsTotalQuery(boardId);
const { data: imagesTotal } = useGetBoardImagesTotalQuery(boardId);
const currentViewTotal = useMemo(
() => (galleryView === 'images' ? imagesTotal?.total : assetsTotal?.total),
[assetsTotal?.total, galleryView, imagesTotal?.total]
);
const loadedImagesCount = useMemo(
() => queryResult.data?.ids.length ?? 0,
[queryResult.data?.ids.length]
);
const areMoreImagesAvailable = useMemo(() => {
if (!currentViewTotal || !queryResult.data) {
return false;
}
return queryResult.data.ids.length < currentViewTotal;
}, [queryResult.data, currentViewTotal]);
const handleLoadMoreImages = useCallback(() => {
// To load more images, we update the query args with an offset and limit.
const _queryArgs: ListImagesArgs = loadedImagesCount
? {
...baseQueryArgs,
offset: loadedImagesCount,
limit: IMAGE_LIMIT,
}
: baseQueryArgs;
$queryArgs.set(_queryArgs);
}, [baseQueryArgs, loadedImagesCount]);
return {
areMoreImagesAvailable,
handleLoadMoreImages,
queryResult,
};
};

View File

@@ -0,0 +1,211 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
import { imageItemContainerTestId } from 'features/gallery/components/ImageGrid/ImageGridItemContainer';
import { imageListContainerTestId } from 'features/gallery/components/ImageGrid/ImageGridListContainer';
import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { getIsVisible } from 'features/gallery/util/getIsVisible';
import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
import { clamp } from 'lodash-es';
import { useCallback, useMemo } from 'react';
import type { ImageDTO } from 'services/api/types';
import { imagesSelectors } from 'services/api/util';
/**
* This hook is used to navigate the gallery using the arrow keys.
*
* The gallery is rendered as a grid. In order to navigate the grid,
* we need to know how many images are in each row and whether or not
* an image is visible in the gallery.
*
* We use direct DOM query selectors to check if an image is visible
* to avoid having to track a ref for each image.
*/
/**
* Gets the number of images per row in the gallery by grabbing their DOM elements.
*/
const getImagesPerRow = (): number => {
const widthOfGalleryImage =
document
.querySelector(`[data-testid="${imageItemContainerTestId}"]`)
?.getBoundingClientRect().width ?? 1;
const widthOfGalleryGrid =
document
.querySelector(`[data-testid="${imageListContainerTestId}"]`)
?.getBoundingClientRect().width ?? 0;
const imagesPerRow = Math.round(widthOfGalleryGrid / widthOfGalleryImage);
return imagesPerRow;
};
/**
* Scrolls to the image with the given name.
* If the image is not fully visible, it will not be scrolled to.
* @param imageName The image name to scroll to.
* @param index The index of the image in the gallery.
*/
const scrollToImage = (imageName: string, index: number) => {
const virtuosoContext = virtuosoGridRefs.get();
const range = virtuosoContext.virtuosoRangeRef?.current;
const root = virtuosoContext.rootRef?.current;
const virtuoso = virtuosoContext.virtuosoRef?.current;
if (!range || !virtuoso || !root) {
return;
}
const imageElement = document.querySelector(
`[data-testid="${getGalleryImageDataTestId(imageName)}"]`
);
const itemRect = imageElement?.getBoundingClientRect();
const rootRect = root.getBoundingClientRect();
if (!itemRect || !getIsVisible(itemRect, rootRect)) {
virtuoso.scrollToIndex({
index,
align: getScrollToIndexAlign(index, range),
});
}
};
// Utilities to get the image to the left, right, up, or down of the current image.
const getLeftImage = (images: ImageDTO[], currentIndex: number) => {
const index = clamp(currentIndex - 1, 0, images.length - 1);
const image = images[index];
return { index, image };
};
const getRightImage = (images: ImageDTO[], currentIndex: number) => {
const index = clamp(currentIndex + 1, 0, images.length - 1);
const image = images[index];
return { index, image };
};
const getUpImage = (images: ImageDTO[], currentIndex: number) => {
const imagesPerRow = getImagesPerRow();
// If we are on the first row, we want to stay on the first row, not go to first image
const isOnFirstRow = currentIndex < imagesPerRow;
const index = isOnFirstRow
? currentIndex
: clamp(currentIndex - imagesPerRow, 0, images.length - 1);
const image = images[index];
return { index, image };
};
const getDownImage = (images: ImageDTO[], currentIndex: number) => {
const imagesPerRow = getImagesPerRow();
// If there are no images below the current image, we want to stay where we are
const areImagesBelow = currentIndex < images.length - imagesPerRow;
const index = areImagesBelow
? clamp(currentIndex + imagesPerRow, 0, images.length - 1)
: currentIndex;
const image = images[index];
return { index, image };
};
const getImageFuncs = {
left: getLeftImage,
right: getRightImage,
up: getUpImage,
down: getDownImage,
};
export type UseGalleryNavigationReturn = {
handleLeftImage: () => void;
handleRightImage: () => void;
handleUpImage: () => void;
handleDownImage: () => void;
isOnFirstImage: boolean;
isOnLastImage: boolean;
areImagesBelowCurrent: boolean;
};
/**
* Provides access to the gallery navigation via arrow keys.
* Also provides information about the current image's position in the gallery,
* useful for determining whether to load more images or display navigatin
* buttons.
*/
export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
const dispatch = useAppDispatch();
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
const {
queryResult: { data },
} = useGalleryImages();
const loadedImagesCount = useMemo(
() => data?.ids.length ?? 0,
[data?.ids.length]
);
const lastSelectedImageIndex = useMemo(() => {
if (!data || !lastSelectedImage) {
return 0;
}
return imagesSelectors
.selectAll(data)
.findIndex((i) => i.image_name === lastSelectedImage.image_name);
}, [lastSelectedImage, data]);
const handleNavigation = useCallback(
(direction: 'left' | 'right' | 'up' | 'down') => {
if (!data) {
return;
}
const { index, image } = getImageFuncs[direction](
imagesSelectors.selectAll(data),
lastSelectedImageIndex
);
if (!image || index === lastSelectedImageIndex) {
return;
}
dispatch(imageSelected(image));
scrollToImage(image.image_name, index);
},
[dispatch, lastSelectedImageIndex, data]
);
const isOnFirstImage = useMemo(
() => lastSelectedImageIndex === 0,
[lastSelectedImageIndex]
);
const isOnLastImage = useMemo(
() => lastSelectedImageIndex === loadedImagesCount - 1,
[lastSelectedImageIndex, loadedImagesCount]
);
const areImagesBelowCurrent = useMemo(() => {
const imagesPerRow = getImagesPerRow();
return lastSelectedImageIndex + imagesPerRow < loadedImagesCount;
}, [lastSelectedImageIndex, loadedImagesCount]);
const handleLeftImage = useCallback(() => {
handleNavigation('left');
}, [handleNavigation]);
const handleRightImage = useCallback(() => {
handleNavigation('right');
}, [handleNavigation]);
const handleUpImage = useCallback(() => {
handleNavigation('up');
}, [handleNavigation]);
const handleDownImage = useCallback(() => {
handleNavigation('down');
}, [handleNavigation]);
return {
handleLeftImage,
handleRightImage,
handleUpImage,
handleDownImage,
isOnFirstImage,
isOnLastImage,
areImagesBelowCurrent,
};
};

View File

@@ -1,6 +1,6 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import {
selectGallerySlice,
selectionChanged,
@@ -8,29 +8,24 @@ import {
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import type { MouseEvent } from 'react';
import { useCallback, useMemo } from 'react';
import { useListImagesQuery } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import { imagesSelectors } from 'services/api/util';
const selector = createMemoizedSelector(
[selectGallerySlice, selectListImagesBaseQueryArgs],
(gallery, queryArgs) => {
return {
queryArgs,
selection: gallery.selection,
};
}
const selectGallerySelection = createMemoizedSelector(
selectGallerySlice,
(gallery) => gallery.selection
);
const EMPTY_ARRAY: ImageDTO[] = [];
export const useMultiselect = (imageDTO?: ImageDTO) => {
const dispatch = useAppDispatch();
const { queryArgs, selection } = useAppSelector(selector);
const { imageDTOs } = useListImagesQuery(queryArgs, {
selectFromResult: (result) => ({
imageDTOs: result.data ? imagesSelectors.selectAll(result.data) : [],
}),
});
const selection = useAppSelector(selectGallerySelection);
const { data } = useGalleryImages().queryResult;
const imageDTOs = useMemo(
() => (data ? imagesSelectors.selectAll(data) : EMPTY_ARRAY),
[data]
);
const isMultiSelectEnabled = useFeatureStatus('multiselect').isFeatureEnabled;

View File

@@ -1,174 +0,0 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import type { RootState } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { IMAGE_LIMIT } from 'features/gallery/store/types';
import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
import { clamp } from 'lodash-es';
import { map } from 'nanostores';
import type { RefObject } from 'react';
import { useCallback } from 'react';
import type { ListRange, VirtuosoGridHandle } from 'react-virtuoso';
import { boardsApi } from 'services/api/endpoints/boards';
import {
imagesApi,
useLazyListImagesQuery,
} from 'services/api/endpoints/images';
import type { ListImagesArgs } from 'services/api/types';
import { imagesSelectors } from 'services/api/util';
export type UseNextPrevImageState = {
virtuosoRef: RefObject<VirtuosoGridHandle> | undefined;
virtuosoRangeRef: RefObject<ListRange> | undefined;
};
export const $useNextPrevImageState = map<UseNextPrevImageState>({
virtuosoRef: undefined,
virtuosoRangeRef: undefined,
});
export const nextPrevImageButtonsSelector = createMemoizedSelector(
[(state: RootState) => state, selectListImagesBaseQueryArgs],
(state, baseQueryArgs) => {
const { data, status } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
const { data: totalsData } =
state.gallery.galleryView === 'images'
? boardsApi.endpoints.getBoardImagesTotal.select(
baseQueryArgs.board_id ?? 'none'
)(state)
: boardsApi.endpoints.getBoardAssetsTotal.select(
baseQueryArgs.board_id ?? 'none'
)(state);
const lastSelectedImage =
state.gallery.selection[state.gallery.selection.length - 1];
const isFetching = status === 'pending';
if (!data || !lastSelectedImage || totalsData?.total === 0) {
return {
isFetching,
queryArgs: baseQueryArgs,
isOnFirstImage: true,
isOnLastImage: true,
};
}
const queryArgs: ListImagesArgs = {
...baseQueryArgs,
offset: data.ids.length,
limit: IMAGE_LIMIT,
};
const images = imagesSelectors.selectAll(data);
const currentImageIndex = images.findIndex(
(i) => i.image_name === lastSelectedImage.image_name
);
const nextImageIndex = clamp(currentImageIndex + 1, 0, images.length - 1);
const prevImageIndex = clamp(currentImageIndex - 1, 0, images.length - 1);
const nextImageId = images[nextImageIndex]?.image_name;
const prevImageId = images[prevImageIndex]?.image_name;
const nextImage = nextImageId
? imagesSelectors.selectById(data, nextImageId)
: undefined;
const prevImage = prevImageId
? imagesSelectors.selectById(data, prevImageId)
: undefined;
const imagesLength = images.length;
return {
loadedImagesCount: images.length,
currentImageIndex,
areMoreImagesAvailable: (totalsData?.total ?? 0) > imagesLength,
isFetching: status === 'pending',
nextImage,
prevImage,
nextImageIndex,
prevImageIndex,
queryArgs,
};
}
);
export const useNextPrevImage = () => {
const dispatch = useAppDispatch();
const {
nextImage,
nextImageIndex,
prevImage,
prevImageIndex,
areMoreImagesAvailable,
isFetching,
queryArgs,
loadedImagesCount,
currentImageIndex,
} = useAppSelector(nextPrevImageButtonsSelector);
const handlePrevImage = useCallback(() => {
prevImage && dispatch(imageSelected(prevImage));
const range = $useNextPrevImageState.get().virtuosoRangeRef?.current;
const virtuoso = $useNextPrevImageState.get().virtuosoRef?.current;
if (!range || !virtuoso) {
return;
}
if (
prevImageIndex !== undefined &&
(prevImageIndex < range.startIndex || prevImageIndex > range.endIndex)
) {
virtuoso.scrollToIndex({
index: prevImageIndex,
behavior: 'smooth',
align: getScrollToIndexAlign(prevImageIndex, range),
});
}
}, [dispatch, prevImage, prevImageIndex]);
const handleNextImage = useCallback(() => {
nextImage && dispatch(imageSelected(nextImage));
const range = $useNextPrevImageState.get().virtuosoRangeRef?.current;
const virtuoso = $useNextPrevImageState.get().virtuosoRef?.current;
if (!range || !virtuoso) {
return;
}
if (
nextImageIndex !== undefined &&
(nextImageIndex < range.startIndex || nextImageIndex > range.endIndex)
) {
virtuoso.scrollToIndex({
index: nextImageIndex,
behavior: 'smooth',
align: getScrollToIndexAlign(nextImageIndex, range),
});
}
}, [dispatch, nextImage, nextImageIndex]);
const [listImages] = useLazyListImagesQuery();
const handleLoadMoreImages = useCallback(() => {
listImages(queryArgs);
}, [listImages, queryArgs]);
return {
handlePrevImage,
handleNextImage,
isOnFirstImage: currentImageIndex === 0,
isOnLastImage:
currentImageIndex !== undefined &&
currentImageIndex === loadedImagesCount - 1,
nextImage,
prevImage,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,
};
};

View File

@@ -0,0 +1,52 @@
import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
import { getIsVisible } from 'features/gallery/util/getIsVisible';
import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
import { useEffect, useRef } from 'react';
/**
* Scrolls an image into view when it is selected. This is necessary because
* the image grid is virtualized, so the image may not be visible when it is
* selected.
*
* Also handles when an image is selected programmatically - for example, when
* auto-switching the new gallery images.
*
* @param isSelected Whether the image is selected.
* @param index The index of the image in the gallery.
* @param selectionCount The number of images selected.
* @returns
*/
export const useScrollIntoView = (
isSelected: boolean,
index: number,
selectionCount: number
) => {
const imageContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isSelected || selectionCount !== 1) {
return;
}
const virtuosoContext = virtuosoGridRefs.get();
const range = virtuosoContext.virtuosoRangeRef?.current;
const root = virtuosoContext.rootRef?.current;
const virtuoso = virtuosoContext.virtuosoRef?.current;
if (!range || !virtuoso || !root) {
return;
}
const itemRect = imageContainerRef.current?.getBoundingClientRect();
const rootRect = root.getBoundingClientRect();
if (!itemRect || !getIsVisible(itemRect, rootRect)) {
virtuoso.scrollToIndex({
index,
align: getScrollToIndexAlign(index, range),
});
}
}, [isSelected, index, selectionCount]);
return imageContainerRef;
};

View File

@@ -1,46 +0,0 @@
import type { VirtuosoGalleryContext } from 'features/gallery/components/ImageGrid/types';
import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
import { useEffect, useRef } from 'react';
export const useScrollToVisible = (
isSelected: boolean,
index: number,
selectionCount: number,
virtuosoContext: VirtuosoGalleryContext
) => {
const imageContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (
!isSelected ||
selectionCount !== 1 ||
!virtuosoContext.rootRef.current ||
!virtuosoContext.virtuosoRef.current ||
!virtuosoContext.virtuosoRangeRef.current ||
!imageContainerRef.current
) {
return;
}
const itemRect = imageContainerRef.current.getBoundingClientRect();
const rootRect = virtuosoContext.rootRef.current.getBoundingClientRect();
const itemIsVisible =
itemRect.top >= rootRect.top &&
itemRect.bottom <= rootRect.bottom &&
itemRect.left >= rootRect.left &&
itemRect.right <= rootRect.right;
if (!itemIsVisible) {
virtuosoContext.virtuosoRef.current.scrollToIndex({
index,
behavior: 'smooth',
align: getScrollToIndexAlign(
index,
virtuosoContext.virtuosoRangeRef.current
),
});
}
}, [isSelected, index, selectionCount, virtuosoContext]);
return imageContainerRef;
};

View File

@@ -106,3 +106,11 @@ const isAnyBoardDeleted = isAnyOf(
);
export const selectGallerySlice = (state: RootState) => state.gallery;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const migrateGalleryState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
return state;
};

View File

@@ -0,0 +1,12 @@
/**
* Gets whether the item is visible in the root element.
*/
export const getIsVisible = (itemRect: DOMRect, rootRect: DOMRect) => {
return (
itemRect.top >= rootRect.top &&
itemRect.bottom <= rootRect.bottom &&
itemRect.left >= rootRect.left &&
itemRect.right <= rootRect.right
);
};

View File

@@ -8,8 +8,14 @@ import { useTranslation } from 'react-i18next';
const ParamHrfStrength = () => {
const hrfStrength = useAppSelector((s) => s.hrf.hrfStrength);
const initial = useAppSelector((s) => s.config.sd.hrfStrength.initial);
const min = useAppSelector((s) => s.config.sd.hrfStrength.min);
const sliderMin = useAppSelector((s) => s.config.sd.hrfStrength.sliderMin);
const sliderMax = useAppSelector((s) => s.config.sd.hrfStrength.sliderMax);
const numberInputMin = useAppSelector(
(s) => s.config.sd.hrfStrength.numberInputMin
);
const numberInputMax = useAppSelector(
(s) => s.config.sd.hrfStrength.numberInputMax
);
const coarseStep = useAppSelector((s) => s.config.sd.hrfStrength.coarseStep);
const fineStep = useAppSelector((s) => s.config.sd.hrfStrength.fineStep);
const dispatch = useAppDispatch();
@@ -25,7 +31,7 @@ const ParamHrfStrength = () => {
return (
<InvControl label={t('parameters.denoisingStrength')}>
<InvSlider
min={min}
min={sliderMin}
max={sliderMax}
step={coarseStep}
fineStep={fineStep}
@@ -34,6 +40,8 @@ const ParamHrfStrength = () => {
onChange={onChange}
marks
withNumberInput
numberInputMin={numberInputMin}
numberInputMax={numberInputMax}
/>
</InvControl>
);

View File

@@ -7,12 +7,14 @@ import type {
} from 'features/parameters/types/parameterSchemas';
export interface HRFState {
_version: 1;
hrfEnabled: boolean;
hrfStrength: ParameterStrength;
hrfMethod: ParameterHRFMethod;
}
export const initialHRFState: HRFState = {
_version: 1,
hrfStrength: 0.45,
hrfEnabled: false,
hrfMethod: 'ESRGAN',
@@ -41,3 +43,11 @@ export const { setHrfEnabled, setHrfStrength, setHrfMethod } = hrfSlice.actions;
export default hrfSlice.reducer;
export const selectHrfSlice = (state: RootState) => state.hrf;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const migrateHRFState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
return state;
};

View File

@@ -4,10 +4,10 @@ import {
InvCardBody,
InvCardHeader,
} from 'common/components/InvCard/wrapper';
import { InvLabel } from 'common/components/InvControl/InvLabel';
import { InvIconButton } from 'common/components/InvIconButton/InvIconButton';
import { InvNumberInput } from 'common/components/InvNumberInput/InvNumberInput';
import { InvSlider } from 'common/components/InvSlider/InvSlider';
import { InvText } from 'common/components/InvText/wrapper';
import type { LoRA } from 'features/lora/store/loraSlice';
import { loraRemoved, loraWeightChanged } from 'features/lora/store/loraSlice';
import { memo, useCallback } from 'react';
@@ -35,9 +35,9 @@ export const LoRACard = memo((props: LoRACardProps) => {
return (
<InvCard variant="lora">
<InvCardHeader>
<InvText noOfLines={1} wordBreak="break-all">
<InvLabel noOfLines={1} wordBreak="break-all">
{lora.model_name}
</InvText>
</InvLabel>
<InvIconButton
aria-label="Remove LoRA"
variant="ghost"

View File

@@ -14,16 +14,18 @@ export const defaultLoRAConfig = {
};
export type LoraState = {
_version: 1;
loras: Record<string, LoRA>;
};
export const intialLoraState: LoraState = {
export const initialLoraState: LoraState = {
_version: 1,
loras: {},
};
export const loraSlice = createSlice({
name: 'lora',
initialState: intialLoraState,
initialState: initialLoraState,
reducers: {
loraAdded: (state, action: PayloadAction<LoRAModelConfigEntity>) => {
const { model_name, id, base_model } = action.payload;
@@ -77,3 +79,11 @@ export const {
export default loraSlice.reducer;
export const selectLoraSlice = (state: RootState) => state.lora;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const migrateLoRAState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
return state;
};

View File

@@ -3,7 +3,7 @@ import type { InvButtonProps } from 'common/components/InvButton/types';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaSync } from 'react-icons/fa';
import { PiArrowsClockwiseBold } from 'react-icons/pi';
import { useSyncModels } from './useSyncModels';
@@ -21,7 +21,7 @@ export const SyncModelsButton = memo(
<InvButton
isLoading={isLoading}
onClick={syncModels}
leftIcon={<FaSync />}
leftIcon={<PiArrowsClockwiseBold />}
minW="max-content"
{...props}
>

View File

@@ -3,7 +3,7 @@ import type { InvIconButtonProps } from 'common/components/InvIconButton/types';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaSync } from 'react-icons/fa';
import { PiArrowsClockwiseBold } from 'react-icons/pi';
import { useSyncModels } from './useSyncModels';
@@ -19,7 +19,7 @@ export const SyncModelsIconButton = memo(
return (
<InvIconButton
icon={<FaSync />}
icon={<PiArrowsClockwiseBold />}
tooltip={t('modelManager.syncModels')}
aria-label={t('modelManager.syncModels')}
isLoading={isLoading}

View File

@@ -3,11 +3,13 @@ import { createSlice } from '@reduxjs/toolkit';
import type { RootState } from 'app/store/store';
type ModelManagerState = {
_version: 1;
searchFolder: string | null;
advancedAddScanModel: string | null;
};
const initialModelManagerState: ModelManagerState = {
export const initialModelManagerState: ModelManagerState = {
_version: 1,
searchFolder: null,
advancedAddScanModel: null,
};
@@ -31,3 +33,11 @@ export const { setSearchFolder, setAdvancedAddScanModel } =
export default modelManagerSlice.reducer;
export const selectModelManagerSlice = (state: RootState) => state.modelmanager;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const migrateModelManagerState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
return state;
};

View File

@@ -1,4 +1,3 @@
import { DeleteIcon } from '@chakra-ui/icons';
import { Badge, Flex, useDisclosure } from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks';
import { InvButton } from 'common/components/InvButton/InvButton';
@@ -11,6 +10,7 @@ import { addToast } from 'features/system/store/systemSlice';
import { makeToast } from 'features/system/util/makeToast';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold } from 'react-icons/pi';
import type {
LoRAModelConfigEntity,
MainModelConfigEntity,
@@ -109,7 +109,7 @@ const ModelListItem = (props: ModelListItemProps) => {
</Flex>
<InvIconButton
onClick={onOpen}
icon={<DeleteIcon />}
icon={<PiTrashSimpleBold />}
aria-label={t('modelManager.deleteConfig')}
colorScheme="error"
/>

View File

@@ -155,8 +155,9 @@ const AddNodePopover = () => {
return;
}
addNode(v.value);
dispatch(addNodePopoverClosed());
},
[addNode]
[addNode, dispatch]
);
const onClose = useCallback(() => {
@@ -235,6 +236,7 @@ const AddNodePopover = () => {
onMenuClose={onClose}
onKeyDown={onKeyDown}
inputRef={inputRef}
closeMenuOnSelect={false}
/>
</InvPopoverBody>
</InvPopoverContent>

View File

@@ -1,4 +1,6 @@
import type { ChakraProps } from '@chakra-ui/react';
import { Flex } from '@chakra-ui/react';
import { InvControlGroup } from 'common/components/InvControl/InvControlGroup';
import { useHasImageOutput } from 'features/nodes/hooks/useHasImageOutput';
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
@@ -11,6 +13,8 @@ type Props = {
nodeId: string;
};
const props: ChakraProps = { w: 'unset' };
const InvocationNodeFooter = ({ nodeId }: Props) => {
const hasImageOutput = useHasImageOutput(nodeId);
const isCacheEnabled = useFeatureStatus('invocationCache').isFeatureEnabled;
@@ -20,13 +24,16 @@ const InvocationNodeFooter = ({ nodeId }: Props) => {
layerStyle="nodeFooter"
w="full"
borderBottomRadius="base"
gap={4}
px={2}
py={0}
h={8}
justifyContent="space-between"
>
{isCacheEnabled && <UseCacheCheckbox nodeId={nodeId} />}
{hasImageOutput && <SaveToGalleryCheckbox nodeId={nodeId} />}
<InvControlGroup controlProps={props} labelProps={props}>
{isCacheEnabled && <UseCacheCheckbox nodeId={nodeId} />}
{hasImageOutput && <SaveToGalleryCheckbox nodeId={nodeId} />}
</InvControlGroup>
</Flex>
);
};

View File

@@ -8,11 +8,11 @@ import {
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
FaExpand,
// FaInfo,
FaMapMarkerAlt,
} from 'react-icons/fa';
import { FaMagnifyingGlassMinus, FaMagnifyingGlassPlus } from 'react-icons/fa6';
PiFrameCornersBold,
PiMagnifyingGlassMinusBold,
PiMagnifyingGlassPlusBold,
PiMapPinBold,
} from 'react-icons/pi';
import { useReactFlow } from 'reactflow';
const ViewportControls = () => {
@@ -52,19 +52,19 @@ const ViewportControls = () => {
tooltip={t('nodes.zoomInNodes')}
aria-label={t('nodes.zoomInNodes')}
onClick={handleClickedZoomIn}
icon={<FaMagnifyingGlassPlus />}
icon={<PiMagnifyingGlassPlusBold />}
/>
<InvIconButton
tooltip={t('nodes.zoomOutNodes')}
aria-label={t('nodes.zoomOutNodes')}
onClick={handleClickedZoomOut}
icon={<FaMagnifyingGlassMinus />}
icon={<PiMagnifyingGlassMinusBold />}
/>
<InvIconButton
tooltip={t('nodes.fitViewportNodes')}
aria-label={t('nodes.fitViewportNodes')}
onClick={handleClickedFitView}
icon={<FaExpand />}
icon={<PiFrameCornersBold />}
/>
{/* <InvTooltip
label={
@@ -93,7 +93,7 @@ const ViewportControls = () => {
}
isChecked={shouldShowMinimapPanel}
onClick={handleClickedToggleMiniMapPanel}
icon={<FaMapMarkerAlt />}
icon={<PiMapPinBold />}
/>
</InvButtonGroup>
);

View File

@@ -3,7 +3,7 @@ import { InvIconButton } from 'common/components/InvIconButton/InvIconButton';
import { addNodePopoverOpened } from 'features/nodes/store/nodesSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { FaPlus } from 'react-icons/fa';
import { PiPlusBold } from 'react-icons/pi';
const AddNodeButton = () => {
const dispatch = useAppDispatch();
@@ -16,7 +16,7 @@ const AddNodeButton = () => {
<InvIconButton
tooltip={t('nodes.addNodeToolTip')}
aria-label={t('nodes.addNode')}
icon={<FaPlus />}
icon={<PiPlusBold />}
onClick={handleOpenAddNodePopover}
pointerEvents="auto"
/>

View File

@@ -2,7 +2,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { InvButton } from 'common/components/InvButton/InvButton';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { FaSyncAlt } from 'react-icons/fa';
import { PiArrowsClockwiseBold } from 'react-icons/pi';
import { receivedOpenAPISchema } from 'services/api/thunks/schema';
const ReloadNodeTemplatesButton = () => {
@@ -15,7 +15,7 @@ const ReloadNodeTemplatesButton = () => {
return (
<InvButton
leftIcon={<FaSyncAlt />}
leftIcon={<PiArrowsClockwiseBold />}
tooltip={t('nodes.reloadNodeTemplates')}
aria-label={t('nodes.reloadNodeTemplates')}
onClick={handleReloadSchema}

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