Compare commits

...

45 Commits

Author SHA1 Message Date
Millun Atluri
6ccd72349d {release} v3.6.0rc6 (#5467)
## What type of PR is this? (check all applicable)

Release v3.6.0rc6

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

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


## Description
Release candidate $6 

## QA Instructions, Screenshots, Recordings

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



## Merge Plan
Merge when approved

## [optional] Are there any post deployment tasks we need to perform?
Release on PyPi & Github
2024-01-10 11:19:39 -05:00
Millun Atluri
30e12376d3 {release} v3.6.0rc6 2024-01-10 10:45:33 -05:00
psychedelicious
23c8a893e1 fix(ui): fix gallery display bug, major lag
- Fixed a bug where after you load more, changing boards doesn't work. The offset and limit for the list image query had some wonky logic, now resolved.
- Addressed major lag in gallery when selecting an image.

Both issues were related to the useMultiselect and useGalleryImages hooks, which caused every image in the gallery to re-render on whenever the selection changed. There's no way to memoize away this - we need to know when the selection changes. This is a longstanding issue.

The selection is only used in a callback, though - the onClick handler for an image to select it (or add it to the existing selection). We don't really need the reactivity for a callback, so we don't need to listen for changes to the selection.

The logic to handle multiple selection is moved to a new `galleryImageClicked` listener, which does all the selection right when it is needed.

The result is that gallery images no long need to do heavy re-renders on any selection change.

Besides the multiselect click handler, there was also inefficient use of DND payloads. Previously, the `IMAGE_DTOS` type had a payload of image DTO objects. This was only used to drag gallery selection into a board. There is no need to hold onto image DTOs when we have the selection state already in redux. We were recalculating this payload for every image, on every tick.

This payload is now just the board id (the only piece of information we need for this particular DND event).

- I also removed some unused DND types while making this change.
2024-01-10 08:22:46 -05:00
psychedelicious
7d93329401 feat(ui): de-jank context menu
There was a lot of convoluted, janky logic related to trying to not mount the context menu's portal until its needed. This was in the library where the component was originally copied from.

I've removed that and resolved the jank, at the cost of there being an extra portal for each instance of the context menu. Don't think this is going to be an issue. If it is, the whole context menu could be refactored to be a singleton.
2024-01-10 08:22:46 -05:00
Eugene Brodsky
968fb655a4 Report ci disk space + minor docker fixes (#5461)
* ci: add docker build timout; log free space on runner before and after build

* docker: bump frontend builder to node=20.x; skip linting on build

* chore: gitignore .pnpm-store

* update code owners for docker and CI

---------

Co-authored-by: Millun Atluri <Millu@users.noreply.github.com>
2024-01-10 05:20:26 +00:00
psychedelicious
80ec9f4131 chore(ui): lint 2024-01-10 00:11:05 -05:00
psychedelicious
f19def5f7b feat(ui): replace aspect ratio icon
closes #5448
2024-01-10 00:11:05 -05:00
psychedelicious
9e1dd8ac9c fix(ui): reset canvas coords/dims on reset 2024-01-10 00:11:05 -05:00
psychedelicious
ebd68b7a6c feat(ui): support reset canvas view when no image on canvas 2024-01-10 00:11:05 -05:00
psychedelicious
68a231afea feat(ui): move canvas stage and base layer to nanostores 2024-01-10 00:11:05 -05:00
psychedelicious
21ab650ac0 feat(ui): move canvas tool to nanostores
I was troubleshooting a hotkeys issue on canvas and thought I had broken the tool logic in a past change so I redid it moving it to nanostores. In the end, the issue was an upstream but with the hotkeys library, but I like having tool in nanostores so I'm leaving it.

It's ephemeral interaction state anyways, doesn't need to be in redux.
2024-01-10 00:11:05 -05:00
psychedelicious
b501bd709f fix(ui): canvas bbox number input wonky
It was rounding dimensions when it shouldn't.

Closes #5453
2024-01-10 00:11:05 -05:00
psychedelicious
4082f25062 feat(ui): do not optimize size when changing between models with same base model
There's a challenge to accomplish this due to our slice structure - the model is stored in `generationSlice`, but `canvasSlice` also needs to have awareness of it. For example, when the model changes, the canvas slice doesn't know what the previous model was, so it doesn't know whether or not to optimize the size.

This means we need to lift the "should we optimize size" information up. To do this, the `modelChanged` action creator accepts the previous model as an optional second arg.

Now the canvas has access to both the previous model and new model selection, and can decide whether or not it should optimize its size setting in the same way that the generation slice does.

Closes  #5452
2024-01-10 00:11:05 -05:00
psychedelicious
63d74b4ba6 feat(ui): remove unnecessary tabChanged listener
This was needed when we didn't support SDXL on canvas.
2024-01-10 00:11:05 -05:00
psychedelicious
da5907613b fix(ui): fix typing of usGalleryImages
For some reason `ReturnType<typeof useListImagesQuery>` isn't working correctly, and destructuring `queryResult` it results in `any`, when the hook is used.

I've removed the explicit return typing so that consumers of the hook get correct types.
2024-01-10 00:11:05 -05:00
psychedelicious
3a9201bd31 feat: pin deps
Organise deps into ~3 categories:
- Core generation dependencies, pinned for reproducible builds.
- Core application dependencies, pinned for reproducible builds.
- Auxiliary dependencies, pinned only if necessary.

I pinned / bumped these to latest:
- `controlnet_aux`
- `fastapi`
- `fastapi-events`
- `huggingface-hub`
- `numpy`
- `python-socketio`
- `torchmetrics`
- `transformers`
- `uvicorn`

I checked the release notes for these and didn't see any breaking changes that would affect us. There is a `fastapi` breaking change in v108 related to background tasks but it doesn't affect us.

I tested on a fresh venv. The app still works and I can generate on macOS.

Hopefully, enforcing explicit pinned versions will reduce the issues where people get CPU torch.

It also means we should periodically bump versions up to ensure we don't get too far behind on our dependencies and have to do painful upgrades.
2024-01-10 00:03:29 -05:00
psychedelicious
d6e2cb7cef fix(ui): use memoized selector for workflow watcher
Minor perf improvement.
2024-01-10 15:32:16 +11:00
psychedelicious
0809e832d4 fix(ui): use less brutally strict workflow validation
Workflow building would fail when a current image node was in the workflow due to the strict validation.

So we need to use the other workflow builder util first, which strips out extraneous data.

This bug was introduced during an attempt to optimize the workflow building logic, which was causing slowdowns on the workflow editor.
2024-01-10 14:31:14 +11:00
Lincoln Stein
7269c9f02e Enable correct probing of LoRA latent-consistency/lcm-lora-sdxl (#5449)
- Closes #5435

Co-authored-by: Lincoln Stein <lstein@gmail.com>
2024-01-08 17:18:26 -05:00
Mary Hipp Rogers
d86d7e5c33 do not show toast if 403 is triggered by forbidden image (#5447)
* do not show toast if 403 is triggered by lack of image access

* remove log

* lint

---------

Co-authored-by: Mary Hipp <maryhipp@Marys-MacBook-Air.local>
2024-01-08 12:15:46 -05:00
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
219 changed files with 3953 additions and 3375 deletions

8
.github/CODEOWNERS vendored
View File

@@ -1,5 +1,5 @@
# continuous integration
/.github/workflows/ @lstein @blessedcoolant @hipsterusername
/.github/workflows/ @lstein @blessedcoolant @hipsterusername @ebr
# documentation
/docs/ @lstein @blessedcoolant @hipsterusername @Millu
@@ -10,7 +10,7 @@
# installation and configuration
/pyproject.toml @lstein @blessedcoolant @hipsterusername
/docker/ @lstein @blessedcoolant @hipsterusername
/docker/ @lstein @blessedcoolant @hipsterusername @ebr
/scripts/ @ebr @lstein @hipsterusername
/installer/ @lstein @ebr @hipsterusername
/invokeai/assets @lstein @ebr @hipsterusername
@@ -26,9 +26,7 @@
# front ends
/invokeai/frontend/CLI @lstein @hipsterusername
/invokeai/frontend/install @lstein @ebr @hipsterusername
/invokeai/frontend/install @lstein @ebr @hipsterusername
/invokeai/frontend/merge @lstein @blessedcoolant @hipsterusername
/invokeai/frontend/training @lstein @blessedcoolant @hipsterusername
/invokeai/frontend/web @psychedelicious @blessedcoolant @maryhipp @hipsterusername

View File

@@ -40,10 +40,14 @@ jobs:
- name: Free up more disk space on the runner
# https://github.com/actions/runner-images/issues/2840#issuecomment-1284059930
run: |
echo "----- Free space before cleanup"
df -h
sudo rm -rf /usr/share/dotnet
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
sudo swapoff /mnt/swapfile
sudo rm -rf /mnt/swapfile
echo "----- Free space after cleanup"
df -h
- name: Checkout
uses: actions/checkout@v3
@@ -91,6 +95,7 @@ jobs:
# password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build container
timeout-minutes: 40
id: docker_build
uses: docker/build-push-action@v4
with:

View File

@@ -59,7 +59,7 @@ RUN --mount=type=cache,target=/root/.cache/pip \
# #### Build the Web UI ------------------------------------
FROM node:18-slim AS web-builder
FROM node:20-slim AS web-builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -68,7 +68,7 @@ WORKDIR /build
COPY invokeai/frontend/web/ ./
RUN --mount=type=cache,target=/pnpm/store \
pnpm install --frozen-lockfile
RUN pnpm run build
RUN npx vite build
#### Runtime stage ---------------------------------------

View File

@@ -241,12 +241,12 @@ class InvokeAiInstance:
pip[
"install",
"--require-virtualenv",
"numpy~=1.24.0", # choose versions that won't be uninstalled during phase 2
"numpy==1.26.3", # choose versions that won't be uninstalled during phase 2
"urllib3~=1.26.0",
"requests~=2.28.0",
"torch==2.1.2",
"torchmetrics==0.11.4",
"torchvision>=0.16.2",
"torchvision==0.16.2",
"--force-reinstall",
"--find-links" if find_links is not None else None,
find_links,

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

@@ -370,6 +370,8 @@ class LoRACheckpointProbe(CheckpointProbeBase):
return BaseModelType.StableDiffusion1
elif token_vector_length == 1024:
return BaseModelType.StableDiffusion2
elif token_vector_length == 1280:
return BaseModelType.StableDiffusionXL # recognizes format at https://civitai.com/models/224641
elif token_vector_length == 2048:
return BaseModelType.StableDiffusionXL
else:

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

@@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
.pnpm-store
# We want to distribute the repo
dist
dist/**

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

@@ -5,6 +5,7 @@ import type {
UnknownAction,
} from '@reduxjs/toolkit';
import { addListener, createListenerMiddleware } from '@reduxjs/toolkit';
import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
import type { AppDispatch, RootState } from 'app/store/store';
import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingAreaImageListener';
@@ -69,7 +70,6 @@ import { addSessionRetrievalErrorEventListener } from './listeners/socketio/sock
import { addSocketSubscribedEventListener as addSocketSubscribedListener } from './listeners/socketio/socketSubscribed';
import { addSocketUnsubscribedEventListener as addSocketUnsubscribedListener } from './listeners/socketio/socketUnsubscribed';
import { addStagingAreaImageSavedListener } from './listeners/stagingAreaImageSaved';
import { addTabChangedListener } from './listeners/tabChanged';
import { addUpdateAllNodesRequestedListener } from './listeners/updateAllNodesRequested';
import { addUpscaleRequestedListener } from './listeners/upscaleRequested';
import { addWorkflowLoadRequestedListener } from './listeners/workflowLoadRequested';
@@ -118,6 +118,9 @@ addImageToDeleteSelectedListener();
addImagesStarredListener();
addImagesUnstarredListener();
// Gallery
addGalleryImageClickedListener();
// User Invoked
addEnqueueRequestedCanvasListener();
addEnqueueRequestedNodes();
@@ -136,19 +139,7 @@ addCanvasMergedListener();
addStagingAreaImageSavedListener();
addCommitStagingAreaImageListener();
/**
* Socket.IO Events - these handle SIO events directly and pass on internal application actions.
* We don't handle SIO events in slices via `extraReducers` because some of these events shouldn't
* actually be handled at all.
*
* For example, we don't want to respond to progress events for canceled sessions. To avoid
* duplicating the logic to determine if an event should be responded to, we handle all of that
* "is this session canceled?" logic in these listeners.
*
* The `socketGeneratorProgress` listener will then only dispatch the `appSocketGeneratorProgress`
* action if it should be handled by the rest of the application. It is this `appSocketGeneratorProgress`
* action that is handled by reducers in slices.
*/
// Socket.IO
addGeneratorProgressListener();
addGraphExecutionStateCompleteListener();
addInvocationCompleteListener();
@@ -196,8 +187,5 @@ addFirstListImagesListener();
// Ad-hoc upscale workflwo
addUpscaleRequestedListener();
// Tab Change
addTabChangedListener();
// Dynamic prompts
addDynamicPromptsListener();

View File

@@ -1,8 +1,8 @@
import { $logger } from 'app/logging/logger';
import { canvasMerged } from 'features/canvas/store/actions';
import { $canvasBaseLayer } from 'features/canvas/store/canvasNanostore';
import { setMergedCanvas } from 'features/canvas/store/canvasSlice';
import { getFullBaseLayerBlob } from 'features/canvas/util/getFullBaseLayerBlob';
import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider';
import { addToast } from 'features/system/store/systemSlice';
import { t } from 'i18next';
import { imagesApi } from 'services/api/endpoints/images';
@@ -30,7 +30,7 @@ export const addCanvasMergedListener = () => {
return;
}
const canvasBaseLayer = getCanvasBaseLayer();
const canvasBaseLayer = $canvasBaseLayer.get();
if (!canvasBaseLayer) {
moduleLog.error('Problem getting canvas base layer');

View File

@@ -1,6 +1,6 @@
import { enqueueRequested } from 'app/store/actions';
import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph';
import { buildWorkflowRight } from 'features/nodes/util/workflow/buildWorkflow';
import { buildWorkflowWithValidation } from 'features/nodes/util/workflow/buildWorkflow';
import { queueApi } from 'services/api/endpoints/queue';
import type { BatchConfig } from 'services/api/types';
@@ -15,7 +15,7 @@ export const addEnqueueRequestedNodes = () => {
const { nodes, edges } = state.nodes;
const workflow = state.workflow;
const graph = buildNodesGraph(state.nodes);
const builtWorkflow = buildWorkflowRight({
const builtWorkflow = buildWorkflowWithValidation({
nodes,
edges,
workflow,

View File

@@ -0,0 +1,80 @@
import { createAction } from '@reduxjs/toolkit';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { selectionChanged } from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import { imagesSelectors } from 'services/api/util';
import { startAppListening } from '..';
export const galleryImageClicked = createAction<{
imageDTO: ImageDTO;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
}>('gallery/imageClicked');
/**
* This listener handles the logic for selecting images in the gallery.
*
* Previously, this logic was in a `useCallback` with the whole gallery selection as a dependency. Every time
* the selection changed, the callback got recreated and all images rerendered. This could easily block for
* hundreds of ms, more for lower end devices.
*
* Moving this logic into a listener means we don't need to recalculate anything dynamically and the gallery
* is much more responsive.
*/
export const addGalleryImageClickedListener = () => {
startAppListening({
actionCreator: galleryImageClicked,
effect: async (action, { dispatch, getState }) => {
const { imageDTO, shiftKey, ctrlKey, metaKey } = action.payload;
const state = getState();
const queryArgs = selectListImagesQueryArgs(state);
const { data: listImagesData } =
imagesApi.endpoints.listImages.select(queryArgs)(state);
if (!listImagesData) {
// Should never happen if we have clicked a gallery image
return;
}
const imageDTOs = imagesSelectors.selectAll(listImagesData);
const selection = state.gallery.selection;
if (shiftKey) {
const rangeEndImageName = imageDTO.image_name;
const lastSelectedImage = selection[selection.length - 1]?.image_name;
const lastClickedIndex = imageDTOs.findIndex(
(n) => n.image_name === lastSelectedImage
);
const currentClickedIndex = imageDTOs.findIndex(
(n) => n.image_name === rangeEndImageName
);
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
// We have a valid range!
const start = Math.min(lastClickedIndex, currentClickedIndex);
const end = Math.max(lastClickedIndex, currentClickedIndex);
const imagesToSelect = imageDTOs.slice(start, end + 1);
dispatch(selectionChanged(selection.concat(imagesToSelect)));
}
} else if (ctrlKey || metaKey) {
if (
selection.some((i) => i.image_name === imageDTO.image_name) &&
selection.length > 1
) {
dispatch(
selectionChanged(
selection.filter((n) => n.image_name !== imageDTO.image_name)
)
);
} else {
dispatch(selectionChanged(selection.concat(imageDTO)));
}
} else {
dispatch(selectionChanged([imageDTO]));
}
},
});
};

View File

@@ -8,7 +8,7 @@ import {
import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types';
import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions';
import { isModalOpenChanged } from 'features/deleteImageModal/store/slice';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { isImageFieldInputInstance } from 'features/nodes/types/field';
@@ -49,7 +49,7 @@ export const addRequestedSingleImageDeletionListener = () => {
if (imageDTO && imageDTO?.image_name === lastSelectedImage) {
const { image_name } = imageDTO;
const baseQueryArgs = selectListImagesBaseQueryArgs(state);
const baseQueryArgs = selectListImagesQueryArgs(state);
const { data } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
@@ -180,9 +180,9 @@ export const addRequestedMultipleImageDeletionListener = () => {
imagesApi.endpoints.deleteImages.initiate({ imageDTOs })
).unwrap();
const state = getState();
const baseQueryArgs = selectListImagesBaseQueryArgs(state);
const queryArgs = selectListImagesQueryArgs(state);
const { data } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
imagesApi.endpoints.listImages.select(queryArgs)(state);
const newSelectedImageDTO = data
? imagesSelectors.selectAll(data)[0]

View File

@@ -12,7 +12,6 @@ import type {
} from 'features/dnd/types';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { workflowExposedFieldAdded } from 'features/nodes/store/workflowSlice';
import {
initialImageChanged,
selectOptimalDimension,
@@ -35,10 +34,10 @@ export const addImageDroppedListener = () => {
if (activeData.payloadType === 'IMAGE_DTO') {
log.debug({ activeData, overData }, 'Image dropped');
} else if (activeData.payloadType === 'IMAGE_DTOS') {
} else if (activeData.payloadType === 'GALLERY_SELECTION') {
log.debug(
{ activeData, overData },
`Images (${activeData.payload.imageDTOs.length}) dropped`
`Images (${getState().gallery.selection.length}) dropped`
);
} else if (activeData.payloadType === 'NODE_FIELD') {
log.debug(
@@ -49,19 +48,6 @@ export const addImageDroppedListener = () => {
log.debug({ activeData, overData }, `Unknown payload dropped`);
}
if (
overData.actionType === 'ADD_FIELD_TO_LINEAR' &&
activeData.payloadType === 'NODE_FIELD'
) {
const { nodeId, field } = activeData.payload;
dispatch(
workflowExposedFieldAdded({
nodeId,
fieldName: field.name,
})
);
}
/**
* Image dropped on current image
*/
@@ -207,10 +193,9 @@ export const addImageDroppedListener = () => {
*/
if (
overData.actionType === 'ADD_TO_BOARD' &&
activeData.payloadType === 'IMAGE_DTOS' &&
activeData.payload.imageDTOs
activeData.payloadType === 'GALLERY_SELECTION'
) {
const { imageDTOs } = activeData.payload;
const imageDTOs = getState().gallery.selection;
const { boardId } = overData.context;
dispatch(
imagesApi.endpoints.addImagesToBoard.initiate({
@@ -226,10 +211,9 @@ export const addImageDroppedListener = () => {
*/
if (
overData.actionType === 'REMOVE_FROM_BOARD' &&
activeData.payloadType === 'IMAGE_DTOS' &&
activeData.payload.imageDTOs
activeData.payloadType === 'GALLERY_SELECTION'
) {
const { imageDTOs } = activeData.payload;
const imageDTOs = getState().gallery.selection;
dispatch(
imagesApi.endpoints.removeImagesFromBoard.initiate({
imageDTOs,

View File

@@ -37,8 +37,10 @@ export const addModelSelectedListener = () => {
const newModel = result.data;
const { base_model } = newModel;
const didBaseModelChange =
state.generation.model?.base_model !== base_model;
if (state.generation.model?.base_model !== base_model) {
if (didBaseModelChange) {
// we may need to reset some incompatible submodels
let modelsCleared = 0;
@@ -81,7 +83,7 @@ export const addModelSelectedListener = () => {
}
}
dispatch(modelChanged(newModel));
dispatch(modelChanged(newModel, state.generation.model));
},
});
};

View File

@@ -74,7 +74,7 @@ export const addModelsLoadedListener = () => {
return;
}
dispatch(modelChanged(result.data));
dispatch(modelChanged(result.data, currentModel));
},
});
startAppListening({
@@ -149,7 +149,7 @@ export const addModelsLoadedListener = () => {
if (!firstModel) {
// No custom VAEs loaded at all; use the default
dispatch(modelChanged(null));
dispatch(vaeSelected(null));
return;
}
@@ -227,7 +227,7 @@ export const addModelsLoadedListener = () => {
const log = logger('models');
log.info(
{ models: action.payload.entities },
`ControlNet models loaded (${action.payload.ids.length})`
`T2I Adapter models loaded (${action.payload.ids.length})`
);
selectAllT2IAdapters(getState().controlAdapters).forEach((ca) => {

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

@@ -1,67 +0,0 @@
import { modelChanged } from 'features/parameters/store/generationSlice';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { NON_REFINER_BASE_MODELS } from 'services/api/constants';
import {
mainModelsAdapterSelectors,
modelsApi,
} from 'services/api/endpoints/models';
import { startAppListening } from '..';
export const addTabChangedListener = () => {
startAppListening({
actionCreator: setActiveTab,
effect: async (action, { getState, dispatch }) => {
const activeTabName = action.payload;
if (activeTabName === 'unifiedCanvas') {
const currentBaseModel = getState().generation.model?.base_model;
if (
currentBaseModel &&
['sd-1', 'sd-2', 'sdxl'].includes(currentBaseModel)
) {
// if we're already on a valid model, no change needed
return;
}
try {
// just grab fresh models
const modelsRequest = dispatch(
modelsApi.endpoints.getMainModels.initiate(NON_REFINER_BASE_MODELS)
);
const models = await modelsRequest.unwrap();
// cancel this cache subscription
modelsRequest.unsubscribe();
if (!models.ids.length) {
// no valid canvas models
dispatch(modelChanged(null));
return;
}
// need to filter out all the invalid canvas models (currently refiner & any)
const validCanvasModels = mainModelsAdapterSelectors
.selectAll(models)
.filter((model) =>
['sd-1', 'sd-2', 'sdxl'].includes(model.base_model)
);
const firstValidCanvasModel = validCanvasModels[0];
if (!firstValidCanvasModel) {
// no valid canvas models
dispatch(modelChanged(null));
return;
}
const { base_model, model_name, model_type } = firstValidCanvasModel;
dispatch(modelChanged({ base_model, model_name, model_type }));
} catch {
// network request failed, bail
dispatch(modelChanged(null));
}
}
},
});
};

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

@@ -1,16 +1,5 @@
/**
* This is a copy-paste of https://github.com/lukasbach/chakra-ui-contextmenu with a small change.
*
* The reactflow background element somehow prevents the chakra `useOutsideClick()` hook from working.
* With a menu open, clicking on the reactflow background element doesn't close the menu.
*
* Reactflow does provide an `onPaneClick` to handle clicks on the background element, but it is not
* straightforward to programatically close the menu.
*
* As a (hopefully temporary) workaround, we will use a dirty hack:
* - create `globalContextMenuCloseTrigger: number` in `ui` slice
* - increment it in `onPaneClick` (and wherever else we want to close the menu)
* - `useEffect()` to close the menu when `globalContextMenuCloseTrigger` changes
* Adapted from https://github.com/lukasbach/chakra-ui-contextmenu
*/
import type {
ChakraProps,
@@ -18,9 +7,9 @@ import type {
MenuProps,
PortalProps,
} from '@chakra-ui/react';
import { Portal, useEventListener } from '@chakra-ui/react';
import { Portal, useDisclosure, useEventListener } from '@chakra-ui/react';
import { InvMenu, InvMenuButton } from 'common/components/InvMenu/wrapper';
import { useGlobalMenuCloseTrigger } from 'common/hooks/useGlobalMenuCloseTrigger';
import { useGlobalMenuClose } from 'common/hooks/useGlobalMenuClose';
import { typedMemo } from 'common/util/typedMemo';
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -34,94 +23,89 @@ export interface InvContextMenuProps<T extends HTMLElement = HTMLDivElement> {
export const InvContextMenu = typedMemo(
<T extends HTMLElement = HTMLElement>(props: InvContextMenuProps<T>) => {
const [isOpen, setIsOpen] = useState(false);
const [isRendered, setIsRendered] = useState(false);
const [isDeferredOpen, setIsDeferredOpen] = useState(false);
const [position, setPosition] = useState<[number, number]>([0, 0]);
const { isOpen, onOpen, onClose } = useDisclosure();
const [position, setPosition] = useState([-1, -1]);
const targetRef = useRef<T>(null);
const lastPositionRef = useRef([-1, -1]);
const timeoutRef = useRef(0);
useEffect(() => {
if (isOpen) {
setTimeout(() => {
setIsRendered(true);
setTimeout(() => {
setIsDeferredOpen(true);
});
});
} else {
setIsDeferredOpen(false);
const timeout = setTimeout(() => {
setIsRendered(isOpen);
}, 1000);
return () => clearTimeout(timeout);
}
}, [isOpen]);
useGlobalMenuClose(onClose);
const onClose = useCallback(() => {
setIsOpen(false);
setIsDeferredOpen(false);
setIsRendered(false);
}, []);
const onContextMenu = useCallback(
(e: MouseEvent) => {
if (e.shiftKey) {
onClose();
return;
}
if (
targetRef.current?.contains(e.target as HTMLElement) ||
e.target === targetRef.current
) {
// clear pending delayed open
window.clearTimeout(timeoutRef.current);
e.preventDefault();
if (
lastPositionRef.current[0] !== e.pageX ||
lastPositionRef.current[1] !== e.pageY
) {
// if the mouse moved, we need to close, wait for animation and reopen the menu at the new position
onClose();
timeoutRef.current = window.setTimeout(() => {
onOpen();
setPosition([e.pageX, e.pageY]);
}, 100);
} else {
// else we can just open the menu at the current position
onOpen();
setPosition([e.pageX, e.pageY]);
}
}
lastPositionRef.current = [e.pageX, e.pageY];
},
[onClose, onOpen]
);
// This is the change from the original chakra-ui-contextmenu
// Close all menus when the globalContextMenuCloseTrigger changes
useGlobalMenuCloseTrigger(onClose);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
useEventListener('contextmenu', (e) => {
if (
targetRef.current?.contains(e.target as HTMLElement) ||
e.target === targetRef.current
) {
e.preventDefault();
setIsOpen(true);
setPosition([e.pageX, e.pageY]);
} else {
setIsOpen(false);
}
});
const onCloseHandler = useCallback(() => {
props.menuProps?.onClose?.();
setIsOpen(false);
}, [props.menuProps]);
useEventListener('contextmenu', onContextMenu);
return (
<>
{props.children(targetRef)}
{isRendered && (
<Portal {...props.portalProps}>
<InvMenu
isLazy
isOpen={isDeferredOpen}
gutter={0}
onClose={onCloseHandler}
placement="auto-end"
{...props.menuProps}
>
<InvMenuButton
aria-hidden={true}
w={1}
h={1}
position="absolute"
left={position[0]}
top={position[1]}
cursor="default"
bg="transparent"
size="sm"
_hover={_hover}
{...props.menuButtonProps}
/>
{props.renderMenu()}
</InvMenu>
</Portal>
)}
<Portal {...props.portalProps}>
<InvMenu
isLazy
isOpen={isOpen}
gutter={0}
placement="auto-end"
onClose={onClose}
{...props.menuProps}
>
<InvMenuButton
aria-hidden={true}
w={1}
h={1}
position="absolute"
left={position[0]}
top={position[1]}
cursor="default"
bg="transparent"
size="sm"
_hover={_hover}
pointerEvents="none"
{...props.menuButtonProps}
/>
{props.renderMenu()}
</InvMenu>
</Portal>
</>
);
}
);
const _hover: ChakraProps['_hover'] = { bg: 'transparent' };
Object.assign(InvContextMenu, {
displayName: 'InvContextMenu',
});

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

@@ -3,6 +3,7 @@ import {
MenuList as ChakraMenuList,
Portal,
} from '@chakra-ui/react';
import { skipMouseEvent } from 'common/util/skipMouseEvent';
import { memo } from 'react';
import { menuListMotionProps } from './constants';
@@ -16,6 +17,7 @@ export const InvMenuList = memo(
<ChakraMenuList
ref={ref}
motionProps={menuListMotionProps}
onContextMenu={skipMouseEvent}
{...props}
/>
</Portal>

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

@@ -15,7 +15,7 @@ const $onCloseCallbacks = atom<CB[]>([]);
* This hook provides a way to close all menus by calling `onCloseGlobal()`. Menus that want to be closed
* in this way should register themselves by passing a callback to `useGlobalMenuCloseTrigger()`.
*/
export const useGlobalMenuCloseTrigger = (onClose?: CB) => {
export const useGlobalMenuClose = (onClose?: CB) => {
useEffect(() => {
if (!onClose) {
return;

View File

@@ -1,27 +1,33 @@
// https://stackoverflow.com/a/73731908
import { useCallback, useEffect, useState } from 'react';
export function useSingleAndDoubleClick(
handleSingleClick: () => void,
handleDoubleClick: () => void,
delay = 250
) {
export type UseSingleAndDoubleClickOptions = {
onSingleClick: () => void;
onDoubleClick: () => void;
latency?: number;
};
export function useSingleAndDoubleClick({
onSingleClick,
onDoubleClick,
latency = 250,
}: UseSingleAndDoubleClickOptions): () => void {
const [click, setClick] = useState(0);
useEffect(() => {
const timer = setTimeout(() => {
if (click === 1) {
handleSingleClick();
onSingleClick();
}
setClick(0);
}, delay);
}, latency);
if (click === 2) {
handleDoubleClick();
onDoubleClick();
}
return () => clearTimeout(timer);
}, [click, handleSingleClick, handleDoubleClick, delay]);
}, [click, onDoubleClick, latency, onSingleClick]);
const onClick = useCallback(() => setClick((prev) => prev + 1), []);

View File

@@ -0,0 +1,8 @@
import type { MouseEvent } from 'react';
/**
* Prevents the default behavior of the event.
*/
export const skipMouseEvent = (e: MouseEvent) => {
e.preventDefault();
};

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

@@ -10,20 +10,19 @@ import useCanvasMouseOut from 'features/canvas/hooks/useCanvasMouseOut';
import useCanvasMouseUp from 'features/canvas/hooks/useCanvasMouseUp';
import useCanvasWheel from 'features/canvas/hooks/useCanvasZoom';
import {
$canvasBaseLayer,
$canvasStage,
$isModifyingBoundingBox,
$isMouseOverBoundingBox,
$isMovingStage,
$isTransformingBoundingBox,
$tool,
} from 'features/canvas/store/canvasNanostore';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import {
canvasResized,
selectCanvasSlice,
} from 'features/canvas/store/canvasSlice';
import {
setCanvasBaseLayer,
setCanvasStage,
} from 'features/canvas/util/konvaInstanceProvider';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Vector2d } from 'konva/lib/types';
@@ -61,7 +60,6 @@ const IAICanvas = () => {
);
const shouldShowGrid = useAppSelector((s) => s.canvas.shouldShowGrid);
const stageScale = useAppSelector((s) => s.canvas.stageScale);
const tool = useAppSelector((s) => s.canvas.tool);
const shouldShowIntermediates = useAppSelector(
(s) => s.canvas.shouldShowIntermediates
);
@@ -78,10 +76,11 @@ const IAICanvas = () => {
const isMovingStage = useStore($isMovingStage);
const isTransformingBoundingBox = useStore($isTransformingBoundingBox);
const isMouseOverBoundingBox = useStore($isMouseOverBoundingBox);
const tool = useStore($tool);
useCanvasHotkeys();
const canvasStageRefCallback = useCallback((el: Konva.Stage) => {
setCanvasStage(el as Konva.Stage);
stageRef.current = el;
const canvasStageRefCallback = useCallback((stageElement: Konva.Stage) => {
$canvasStage.set(stageElement);
stageRef.current = stageElement;
}, []);
const stageCursor = useMemo(() => {
if (tool === 'move' || isStaging) {
@@ -104,10 +103,14 @@ const IAICanvas = () => {
shouldRestrictStrokesToBox,
tool,
]);
const canvasBaseLayerRefCallback = useCallback((el: Konva.Layer) => {
setCanvasBaseLayer(el as Konva.Layer);
canvasBaseLayerRef.current = el;
}, []);
const canvasBaseLayerRefCallback = useCallback(
(layerElement: Konva.Layer) => {
$canvasBaseLayer.set(layerElement);
canvasBaseLayerRef.current = layerElement;
},
[]
);
const lastCursorPositionRef = useRef<Vector2d>({ x: 0, y: 0 });

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

@@ -5,6 +5,7 @@ import {
$cursorPosition,
$isMovingBoundingBox,
$isTransformingBoundingBox,
$tool,
} from 'features/canvas/store/canvasNanostore';
import { selectCanvasSlice } from 'features/canvas/store/canvasSlice';
import { rgbaColorToString } from 'features/canvas/util/colorToString';
@@ -89,7 +90,7 @@ const IAICanvasToolPreview = (props: GroupConfig) => {
const maskColorString = useAppSelector((s) =>
rgbaColorToString({ ...s.canvas.maskColor, a: 0.5 })
);
const tool = useAppSelector((s) => s.canvas.tool);
const tool = useStore($tool);
const layer = useAppSelector((s) => s.canvas.layer);
const dotRadius = useAppSelector((s) => 1.5 / s.canvas.stageScale);
const strokeWidth = useAppSelector((s) => 1.5 / s.canvas.stageScale);

View File

@@ -8,11 +8,11 @@ import {
} from 'common/util/roundDownToMultiple';
import {
$isDrawing,
$isMouseOverBoundingBox,
$isMouseOverBoundingBoxOutline,
$isMovingBoundingBox,
$isTransformingBoundingBox,
setIsMouseOverBoundingBox,
setIsMovingBoundingBox,
setIsTransformingBoundingBox,
$tool,
} from 'features/canvas/store/canvasNanostore';
import {
aspectRatioChanged,
@@ -30,7 +30,7 @@ import type Konva from 'konva';
import type { GroupConfig } from 'konva/lib/Group';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Vector2d } from 'konva/lib/types';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { Group, Rect, Transformer } from 'react-konva';
@@ -49,18 +49,19 @@ const IAICanvasBoundingBox = (props: IAICanvasBoundingBoxPreviewProps) => {
);
const stageScale = useAppSelector((s) => s.canvas.stageScale);
const shouldSnapToGrid = useAppSelector((s) => s.canvas.shouldSnapToGrid);
const tool = useAppSelector((s) => s.canvas.tool);
const hitStrokeWidth = useAppSelector((s) => 20 / s.canvas.stageScale);
const aspectRatio = useAppSelector((s) => s.canvas.aspectRatio);
const optimalDimension = useAppSelector(selectOptimalDimension);
const transformerRef = useRef<Konva.Transformer>(null);
const shapeRef = useRef<Konva.Rect>(null);
const shift = useStore($shift);
const tool = useStore($tool);
const isDrawing = useStore($isDrawing);
const isMovingBoundingBox = useStore($isMovingBoundingBox);
const isTransformingBoundingBox = useStore($isTransformingBoundingBox);
const [isMouseOverBoundingBoxOutline, setIsMouseOverBoundingBoxOutline] =
useState(false);
const isMouseOverBoundingBoxOutline = useStore(
$isMouseOverBoundingBoxOutline
);
useEffect(() => {
if (!transformerRef.current || !shapeRef.current) {
@@ -228,43 +229,43 @@ const IAICanvasBoundingBox = (props: IAICanvasBoundingBoxPreviewProps) => {
);
const handleStartedTransforming = useCallback(() => {
setIsTransformingBoundingBox(true);
$isTransformingBoundingBox.set(true);
}, []);
const handleEndedTransforming = useCallback(() => {
setIsTransformingBoundingBox(false);
setIsMovingBoundingBox(false);
setIsMouseOverBoundingBox(false);
setIsMouseOverBoundingBoxOutline(false);
$isTransformingBoundingBox.set(false);
$isMovingBoundingBox.set(false);
$isMouseOverBoundingBox.set(false);
$isMouseOverBoundingBoxOutline.set(false);
}, []);
const handleStartedMoving = useCallback(() => {
setIsMovingBoundingBox(true);
$isMovingBoundingBox.set(true);
}, []);
const handleEndedModifying = useCallback(() => {
setIsTransformingBoundingBox(false);
setIsMovingBoundingBox(false);
setIsMouseOverBoundingBox(false);
setIsMouseOverBoundingBoxOutline(false);
$isTransformingBoundingBox.set(false);
$isMovingBoundingBox.set(false);
$isMouseOverBoundingBox.set(false);
$isMouseOverBoundingBoxOutline.set(false);
}, []);
const handleMouseOver = useCallback(() => {
setIsMouseOverBoundingBoxOutline(true);
$isMouseOverBoundingBoxOutline.set(true);
}, []);
const handleMouseOut = useCallback(() => {
!isTransformingBoundingBox &&
!isMovingBoundingBox &&
setIsMouseOverBoundingBoxOutline(false);
$isMouseOverBoundingBoxOutline.set(false);
}, [isMovingBoundingBox, isTransformingBoundingBox]);
const handleMouseEnterBoundingBox = useCallback(() => {
setIsMouseOverBoundingBox(true);
$isMouseOverBoundingBox.set(true);
}, []);
const handleMouseLeaveBoundingBox = useCallback(() => {
setIsMouseOverBoundingBox(false);
$isMouseOverBoundingBox.set(false);
}, []);
const stroke = useMemo(() => {

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

@@ -1,4 +1,5 @@
import { Box, Flex } from '@chakra-ui/react';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIColorPicker from 'common/components/IAIColorPicker';
import { InvButtonGroup } from 'common/components/InvButtonGroup/InvButtonGroup';
@@ -10,14 +11,16 @@ import {
InvPopoverTrigger,
} from 'common/components/InvPopover/wrapper';
import { InvSlider } from 'common/components/InvSlider/InvSlider';
import { resetToolInteractionState } from 'features/canvas/store/canvasNanostore';
import {
$tool,
resetToolInteractionState,
} from 'features/canvas/store/canvasNanostore';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import {
addEraseRect,
addFillRect,
setBrushColor,
setBrushSize,
setTool,
} from 'features/canvas/store/canvasSlice';
import { InvIconButton, InvPopover } from 'index';
import { clamp } from 'lodash-es';
@@ -26,17 +29,19 @@ 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 marks = [1, 25, 50, 75, 100];
const IAICanvasToolChooserOptions = () => {
const dispatch = useAppDispatch();
const tool = useAppSelector((s) => s.canvas.tool);
const tool = useStore($tool);
const brushColor = useAppSelector((s) => s.canvas.brushColor);
const brushSize = useAppSelector((s) => s.canvas.brushSize);
const isStaging = useAppSelector(isStagingSelector);
@@ -163,17 +168,17 @@ const IAICanvasToolChooserOptions = () => {
);
const handleSelectBrushTool = useCallback(() => {
dispatch(setTool('brush'));
$tool.set('brush');
resetToolInteractionState();
}, [dispatch]);
}, []);
const handleSelectEraserTool = useCallback(() => {
dispatch(setTool('eraser'));
$tool.set('eraser');
resetToolInteractionState();
}, [dispatch]);
}, []);
const handleSelectColorPickerTool = useCallback(() => {
dispatch(setTool('colorPicker'));
$tool.set('colorPicker');
resetToolInteractionState();
}, [dispatch]);
}, []);
const handleFillRect = useCallback(() => {
dispatch(addFillRect());
}, [dispatch]);
@@ -198,7 +203,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 +211,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 +219,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 +243,7 @@ const IAICanvasToolChooserOptions = () => {
<InvIconButton
aria-label={t('unifiedCanvas.brushOptions')}
tooltip={t('unifiedCanvas.brushOptions')}
icon={<FaSlidersH />}
icon={<PiSlidersHorizontalBold />}
/>
</InvPopoverTrigger>
<InvPopoverContent>
@@ -281,5 +286,3 @@ const IAICanvasToolChooserOptions = () => {
};
export default memo(IAICanvasToolChooserOptions);
const marks = [1, 25, 50, 75, 100];

View File

@@ -1,4 +1,5 @@
import { Flex } from '@chakra-ui/react';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InvButtonGroup } from 'common/components/InvButtonGroup/InvButtonGroup';
import { InvControl } from 'common/components/InvControl/InvControl';
@@ -14,31 +15,30 @@ import {
canvasMerged,
canvasSavedToGallery,
} from 'features/canvas/store/actions';
import { $canvasBaseLayer, $tool } from 'features/canvas/store/canvasNanostore';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import {
resetCanvas,
resetCanvasView,
setIsMaskEnabled,
setLayer,
setTool,
} from 'features/canvas/store/canvasSlice';
import type { CanvasLayer } from 'features/canvas/store/canvasTypes';
import { LAYER_NAMES_DICT } from 'features/canvas/store/canvasTypes';
import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider';
import { InvIconButton } from 'index';
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';
@@ -50,9 +50,8 @@ const IAICanvasToolbar = () => {
const dispatch = useAppDispatch();
const isMaskEnabled = useAppSelector((s) => s.canvas.isMaskEnabled);
const layer = useAppSelector((s) => s.canvas.layer);
const tool = useAppSelector((s) => s.canvas.tool);
const tool = useStore($tool);
const isStaging = useAppSelector(isStagingSelector);
const canvasBaseLayer = getCanvasBaseLayer();
const { t } = useTranslation();
const { isClipboardAPIAvailable } = useCopyImageToClipboard();
@@ -81,7 +80,7 @@ const IAICanvasToolbar = () => {
enabled: () => true,
preventDefault: true,
},
[canvasBaseLayer]
[]
);
useHotkeys(
@@ -93,7 +92,7 @@ const IAICanvasToolbar = () => {
enabled: () => !isStaging,
preventDefault: true,
},
[canvasBaseLayer]
[]
);
useHotkeys(
@@ -105,7 +104,7 @@ const IAICanvasToolbar = () => {
enabled: () => !isStaging,
preventDefault: true,
},
[canvasBaseLayer]
[]
);
useHotkeys(
@@ -117,7 +116,7 @@ const IAICanvasToolbar = () => {
enabled: () => !isStaging && isClipboardAPIAvailable,
preventDefault: true,
},
[canvasBaseLayer, isClipboardAPIAvailable]
[isClipboardAPIAvailable]
);
useHotkeys(
@@ -129,33 +128,42 @@ const IAICanvasToolbar = () => {
enabled: () => !isStaging,
preventDefault: true,
},
[canvasBaseLayer]
[]
);
const handleSelectMoveTool = useCallback(() => {
dispatch(setTool('move'));
}, [dispatch]);
$tool.set('move');
}, []);
const handleClickResetCanvasView = useSingleAndDoubleClick(
() => handleResetCanvasView(false),
() => handleResetCanvasView(true)
const handleResetCanvasView = useCallback(
(shouldScaleTo1 = false) => {
const canvasBaseLayer = $canvasBaseLayer.get();
if (!canvasBaseLayer) {
return;
}
const clientRect = canvasBaseLayer.getClientRect({
skipTransform: true,
});
dispatch(
resetCanvasView({
contentRect: clientRect,
shouldScaleTo1,
})
);
},
[dispatch]
);
const onSingleClick = useCallback(() => {
handleResetCanvasView(false);
}, [handleResetCanvasView]);
const onDoubleClick = useCallback(() => {
handleResetCanvasView(true);
}, [handleResetCanvasView]);
const handleResetCanvasView = (shouldScaleTo1 = false) => {
const canvasBaseLayer = getCanvasBaseLayer();
if (!canvasBaseLayer) {
return;
}
const clientRect = canvasBaseLayer.getClientRect({
skipTransform: true,
});
dispatch(
resetCanvasView({
contentRect: clientRect,
shouldScaleTo1,
})
);
};
const handleClickResetCanvasView = useSingleAndDoubleClick({
onSingleClick,
onDoubleClick,
});
const handleResetCanvas = useCallback(() => {
dispatch(resetCanvas());
@@ -217,14 +225,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 +241,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 +256,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 +264,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 +278,7 @@ const IAICanvasToolbar = () => {
<InvIconButton
aria-label={`${t('common.upload')}`}
tooltip={`${t('common.upload')}`}
icon={<FaUpload />}
icon={<PiUploadSimpleBold />}
isDisabled={isStaging}
{...getUploadButtonProps()}
/>
@@ -278,7 +286,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

@@ -1,8 +1,8 @@
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
$isMovingBoundingBox,
setIsMovingStage,
$isMovingStage,
$tool,
} from 'features/canvas/store/canvasNanostore';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { setStageCoordinates } from 'features/canvas/store/canvasSlice';
@@ -12,18 +12,19 @@ import { useCallback } from 'react';
const useCanvasDrag = () => {
const dispatch = useAppDispatch();
const isStaging = useAppSelector(isStagingSelector);
const tool = useAppSelector((s) => s.canvas.tool);
const isMovingBoundingBox = useStore($isMovingBoundingBox);
const handleDragStart = useCallback(() => {
if (!((tool === 'move' || isStaging) && !isMovingBoundingBox)) {
if (
!(($tool.get() === 'move' || isStaging) && !$isMovingBoundingBox.get())
) {
return;
}
setIsMovingStage(true);
}, [isMovingBoundingBox, isStaging, tool]);
$isMovingStage.set(true);
}, [isStaging]);
const handleDragMove = useCallback(
(e: KonvaEventObject<MouseEvent>) => {
if (!((tool === 'move' || isStaging) && !isMovingBoundingBox)) {
const tool = $tool.get();
if (!((tool === 'move' || isStaging) && !$isMovingBoundingBox.get())) {
return;
}
@@ -31,15 +32,17 @@ const useCanvasDrag = () => {
dispatch(setStageCoordinates(newCoordinates));
},
[dispatch, isMovingBoundingBox, isStaging, tool]
[dispatch, isStaging]
);
const handleDragEnd = useCallback(() => {
if (!((tool === 'move' || isStaging) && !isMovingBoundingBox)) {
if (
!(($tool.get() === 'move' || isStaging) && !$isMovingBoundingBox.get())
) {
return;
}
setIsMovingStage(false);
}, [isMovingBoundingBox, isStaging, tool]);
$isMovingStage.set(false);
}, [isStaging]);
return {
handleDragStart,

View File

@@ -1,5 +1,8 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
$canvasStage,
$tool,
$toolStash,
resetCanvasInteractionState,
resetToolInteractionState,
} from 'features/canvas/store/canvasNanostore';
@@ -9,12 +12,9 @@ import {
setIsMaskEnabled,
setShouldShowBoundingBox,
setShouldSnapToGrid,
setTool,
} from 'features/canvas/store/canvasSlice';
import type { CanvasTool } from 'features/canvas/store/canvasTypes';
import { getCanvasStage } from 'features/canvas/util/konvaInstanceProvider';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { useCallback, useRef } from 'react';
import { useCallback, useEffect } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
const useInpaintingCanvasHotkeys = () => {
@@ -23,12 +23,9 @@ const useInpaintingCanvasHotkeys = () => {
const shouldShowBoundingBox = useAppSelector(
(s) => s.canvas.shouldShowBoundingBox
);
const tool = useAppSelector((s) => s.canvas.tool);
const isStaging = useAppSelector(isStagingSelector);
const isMaskEnabled = useAppSelector((s) => s.canvas.isMaskEnabled);
const shouldSnapToGrid = useAppSelector((s) => s.canvas.shouldSnapToGrid);
const previousToolRef = useRef<CanvasTool | null>(null);
const canvasStage = getCanvasStage();
// Beta Keys
const handleClearMask = useCallback(() => dispatch(clearMask()), [dispatch]);
@@ -96,37 +93,47 @@ const useInpaintingCanvasHotkeys = () => {
[activeTabName, shouldShowBoundingBox]
);
useHotkeys(
['space'],
(e: KeyboardEvent) => {
if (e.repeat) {
return;
useEffect(() => {
window.addEventListener('keydown', (e) => {
if (e.key === ' ' && !e.repeat) {
console.log('spaceeee');
}
});
}, []);
canvasStage?.container().focus();
const onKeyDown = useCallback((e: KeyboardEvent) => {
if (e.repeat || e.key !== ' ') {
return;
}
if ($toolStash.get() || $tool.get() === 'move') {
return;
}
$canvasStage.get()?.container().focus();
$toolStash.set($tool.get());
$tool.set('move');
resetToolInteractionState();
}, []);
const onKeyUp = useCallback((e: KeyboardEvent) => {
if (e.repeat || e.key !== ' ') {
return;
}
if (!$toolStash.get() || $tool.get() !== 'move') {
return;
}
$canvasStage.get()?.container().focus();
$tool.set($toolStash.get() ?? 'move');
$toolStash.set(null);
}, []);
if (tool !== 'move') {
previousToolRef.current = tool;
dispatch(setTool('move'));
resetToolInteractionState();
}
useEffect(() => {
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
if (
tool === 'move' &&
previousToolRef.current &&
previousToolRef.current !== 'move'
) {
dispatch(setTool(previousToolRef.current));
previousToolRef.current = 'move';
}
},
{
keyup: true,
keydown: true,
preventDefault: true,
},
[tool, previousToolRef]
);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [onKeyDown, onKeyUp]);
};
export default useInpaintingCanvasHotkeys;

View File

@@ -1,7 +1,8 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
setIsDrawing,
setIsMovingStage,
$isDrawing,
$isMovingStage,
$tool,
} from 'features/canvas/store/canvasNanostore';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { addLine } from 'features/canvas/store/canvasSlice';
@@ -15,7 +16,6 @@ import useColorPicker from './useColorUnderCursor';
const useCanvasMouseDown = (stageRef: MutableRefObject<Konva.Stage | null>) => {
const dispatch = useAppDispatch();
const tool = useAppSelector((s) => s.canvas.tool);
const isStaging = useAppSelector(isStagingSelector);
const { commitColorUnderCursor } = useColorPicker();
@@ -26,9 +26,10 @@ const useCanvasMouseDown = (stageRef: MutableRefObject<Konva.Stage | null>) => {
}
stageRef.current.container().focus();
const tool = $tool.get();
if (tool === 'move' || isStaging) {
setIsMovingStage(true);
$isMovingStage.set(true);
return;
}
@@ -45,12 +46,17 @@ const useCanvasMouseDown = (stageRef: MutableRefObject<Konva.Stage | null>) => {
e.evt.preventDefault();
setIsDrawing(true);
$isDrawing.set(true);
// Add a new line starting from the current cursor position.
dispatch(addLine([scaledCursorPosition.x, scaledCursorPosition.y]));
dispatch(
addLine({
points: [scaledCursorPosition.x, scaledCursorPosition.y],
tool,
})
);
},
[stageRef, tool, isStaging, dispatch, commitColorUnderCursor]
[stageRef, isStaging, dispatch, commitColorUnderCursor]
);
};

View File

@@ -1,8 +1,8 @@
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
$cursorPosition,
$isDrawing,
setCursorPosition,
$tool,
} from 'features/canvas/store/canvasNanostore';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { addPointToCurrentLine } from 'features/canvas/store/canvasSlice';
@@ -20,8 +20,6 @@ const useCanvasMouseMove = (
lastCursorPositionRef: MutableRefObject<Vector2d>
) => {
const dispatch = useAppDispatch();
const isDrawing = useStore($isDrawing);
const tool = useAppSelector((s) => s.canvas.tool);
const isStaging = useAppSelector(isStagingSelector);
const { updateColorUnderCursor } = useColorPicker();
@@ -36,16 +34,17 @@ const useCanvasMouseMove = (
return;
}
setCursorPosition(scaledCursorPosition);
$cursorPosition.set(scaledCursorPosition);
lastCursorPositionRef.current = scaledCursorPosition;
const tool = $tool.get();
if (tool === 'colorPicker') {
updateColorUnderCursor();
return;
}
if (!isDrawing || tool === 'move' || isStaging) {
if (!$isDrawing.get() || tool === 'move' || isStaging) {
return;
}
@@ -56,11 +55,9 @@ const useCanvasMouseMove = (
}, [
didMouseMoveRef,
dispatch,
isDrawing,
isStaging,
lastCursorPositionRef,
stageRef,
tool,
updateColorUnderCursor,
]);
};

View File

@@ -2,8 +2,8 @@ import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
$isDrawing,
setIsDrawing,
setIsMovingStage,
$isMovingStage,
$tool,
} from 'features/canvas/store/canvasNanostore';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { addPointToCurrentLine } from 'features/canvas/store/canvasSlice';
@@ -18,12 +18,11 @@ const useCanvasMouseUp = (
) => {
const dispatch = useAppDispatch();
const isDrawing = useStore($isDrawing);
const tool = useAppSelector((s) => s.canvas.tool);
const isStaging = useAppSelector(isStagingSelector);
return useCallback(() => {
if (tool === 'move' || isStaging) {
setIsMovingStage(false);
if ($tool.get() === 'move' || isStaging) {
$isMovingStage.set(false);
return;
}
@@ -46,8 +45,8 @@ const useCanvasMouseUp = (
} else {
didMouseMoveRef.current = false;
}
setIsDrawing(false);
}, [didMouseMoveRef, dispatch, isDrawing, isStaging, stageRef, tool]);
$isDrawing.set(false);
}, [didMouseMoveRef, dispatch, isDrawing, isStaging, stageRef]);
};
export default useCanvasMouseUp;

View File

@@ -1,21 +1,22 @@
import { useAppDispatch } from 'app/store/storeHooks';
import {
$canvasBaseLayer,
$canvasStage,
$tool,
} from 'features/canvas/store/canvasNanostore';
import {
commitColorPickerColor,
setColorPickerColor,
} from 'features/canvas/store/canvasSlice';
import {
getCanvasBaseLayer,
getCanvasStage,
} from 'features/canvas/util/konvaInstanceProvider';
import Konva from 'konva';
import { useCallback } from 'react';
const useColorPicker = () => {
const dispatch = useAppDispatch();
const canvasBaseLayer = getCanvasBaseLayer();
const stage = getCanvasStage();
const updateColorUnderCursor = useCallback(() => {
const stage = $canvasStage.get();
const canvasBaseLayer = $canvasBaseLayer.get();
if (!stage || !canvasBaseLayer) {
return;
}
@@ -47,10 +48,11 @@ const useColorPicker = () => {
}
dispatch(setColorPickerColor({ r, g, b, a }));
}, [canvasBaseLayer, dispatch, stage]);
}, [dispatch]);
const commitColorUnderCursor = useCallback(() => {
dispatch(commitColorPickerColor());
$tool.set('brush');
}, [dispatch]);
return { updateColorUnderCursor, commitColorUnderCursor };

View File

@@ -1,7 +1,11 @@
import type { CanvasTool } from 'features/canvas/store/canvasTypes';
import type Konva from 'konva';
import type { Vector2d } from 'konva/lib/types';
import { atom, computed } from 'nanostores';
export const $cursorPosition = atom<Vector2d | null>(null);
export const $tool = atom<CanvasTool>('move');
export const $toolStash = atom<CanvasTool | null>(null);
export const $isDrawing = atom<boolean>(false);
export const $isMouseOverBoundingBox = atom<boolean>(false);
export const $isMoveBoundingBoxKeyHeld = atom<boolean>(false);
@@ -9,6 +13,7 @@ export const $isMoveStageKeyHeld = atom<boolean>(false);
export const $isMovingBoundingBox = atom<boolean>(false);
export const $isMovingStage = atom<boolean>(false);
export const $isTransformingBoundingBox = atom<boolean>(false);
export const $isMouseOverBoundingBoxOutline = atom<boolean>(false);
export const $isModifyingBoundingBox = computed(
[$isTransformingBoundingBox, $isMovingBoundingBox],
(isTransformingBoundingBox, isMovingBoundingBox) =>
@@ -25,49 +30,15 @@ export const resetCanvasInteractionState = () => {
$isMovingStage.set(false);
};
export const setCursorPosition = (cursorPosition: Vector2d | null) => {
$cursorPosition.set(cursorPosition);
};
export const setIsDrawing = (isDrawing: boolean) => {
$isDrawing.set(isDrawing);
};
export const setIsMouseOverBoundingBox = (isMouseOverBoundingBox: boolean) => {
$isMouseOverBoundingBox.set(isMouseOverBoundingBox);
};
export const setIsMoveBoundingBoxKeyHeld = (
isMoveBoundingBoxKeyHeld: boolean
) => {
$isMoveBoundingBoxKeyHeld.set(isMoveBoundingBoxKeyHeld);
};
export const setIsMoveStageKeyHeld = (isMoveStageKeyHeld: boolean) => {
$isMoveStageKeyHeld.set(isMoveStageKeyHeld);
};
export const setIsMovingBoundingBox = (isMovingBoundingBox: boolean) => {
$isMovingBoundingBox.set(isMovingBoundingBox);
};
export const setIsMovingStage = (isMovingStage: boolean) => {
$isMovingStage.set(isMovingStage);
};
export const setIsTransformingBoundingBox = (
isTransformingBoundingBox: boolean
) => {
$isTransformingBoundingBox.set(isTransformingBoundingBox);
};
export const resetToolInteractionState = () => {
setIsTransformingBoundingBox(false);
setIsMouseOverBoundingBox(false);
setIsMovingBoundingBox(false);
setIsMovingStage(false);
$isTransformingBoundingBox.set(false);
$isMouseOverBoundingBox.set(false);
$isMovingBoundingBox.set(false);
$isMovingStage.set(false);
};
export const setCanvasInteractionStateMouseOut = () => {
setCursorPosition(null);
$cursorPosition.set(null);
};
export const $canvasBaseLayer = atom<Konva.Layer | null>(null);
export const $canvasStage = atom<Konva.Stage | null>(null);

View File

@@ -10,7 +10,7 @@ import calculateScale from 'features/canvas/util/calculateScale';
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 +23,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 +53,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 },
@@ -84,7 +85,6 @@ export const initialCanvasState: CanvasState = {
stageCoordinates: { x: 0, y: 0 },
stageDimensions: { width: 0, height: 0 },
stageScale: 1,
tool: 'brush',
batchIds: [],
aspectRatio: {
id: '1:1',
@@ -99,13 +99,10 @@ const setBoundingBoxDimensionsReducer = (
optimalDimension: number
) => {
const boundingBoxDimensions = payload;
const newDimensions = roundDimensionsToMultiple(
{
...state.boundingBoxDimensions,
...boundingBoxDimensions,
},
CANVAS_GRID_SIZE_FINE
);
const newDimensions = {
...state.boundingBoxDimensions,
...boundingBoxDimensions,
};
state.boundingBoxDimensions = newDimensions;
if (state.boundingBoxScaleMethod === 'auto') {
const scaledDimensions = getScaledBoundingBoxDimensions(
@@ -120,18 +117,9 @@ export const canvasSlice = createSlice({
name: 'canvas',
initialState: initialCanvasState,
reducers: {
setTool: (state, action: PayloadAction<CanvasTool>) => {
state.tool = action.payload;
},
setLayer: (state, action: PayloadAction<CanvasLayer>) => {
state.layer = action.payload;
},
toggleTool: (state) => {
const currentTool = state.tool;
if (currentTool !== 'move') {
state.tool = currentTool === 'brush' ? 'eraser' : 'brush';
}
},
setMaskColor: (state, action: PayloadAction<RgbaColor>) => {
state.maskColor = action.payload;
},
@@ -377,9 +365,13 @@ export const canvasSlice = createSlice({
state.futureLayerStates = [];
},
addLine: (state, action: PayloadAction<number[]>) => {
const { tool, layer, brushColor, brushSize, shouldRestrictStrokesToBox } =
addLine: (
state,
action: PayloadAction<{ points: number[]; tool: CanvasTool }>
) => {
const { layer, brushColor, brushSize, shouldRestrictStrokesToBox } =
state;
const { points, tool } = action.payload;
if (tool === 'move' || tool === 'colorPicker') {
return;
@@ -402,7 +394,7 @@ export const canvasSlice = createSlice({
layer,
tool,
strokeWidth: newStrokeWidth,
points: action.payload,
points,
...newColor,
};
@@ -470,10 +462,31 @@ export const canvasSlice = createSlice({
},
resetCanvas: (state) => {
state.pastLayerStates.push(cloneDeep(state.layerState));
state.layerState = cloneDeep(initialLayerState);
state.futureLayerStates = [];
state.batchIds = [];
state.boundingBoxCoordinates = {
...initialCanvasState.boundingBoxCoordinates,
};
state.boundingBoxDimensions = {
...initialCanvasState.boundingBoxDimensions,
};
state.stageScale = calculateScale(
state.stageDimensions.width,
state.stageDimensions.height,
state.boundingBoxDimensions.width,
state.boundingBoxDimensions.height,
STAGE_PADDING_PERCENTAGE
);
state.stageCoordinates = calculateCoordinates(
state.stageDimensions.width,
state.stageDimensions.height,
0,
0,
state.boundingBoxDimensions.width,
state.boundingBoxDimensions.height,
1
);
},
canvasResized: (
state,
@@ -496,32 +509,28 @@ export const canvasSlice = createSlice({
stageDimensions: { width: stageWidth, height: stageHeight },
} = state;
const { x, y, width, height } = contentRect;
const newScale = shouldScaleTo1
? 1
: calculateScale(
stageWidth,
stageHeight,
contentRect.width || state.boundingBoxDimensions.width,
contentRect.height || state.boundingBoxDimensions.height,
STAGE_PADDING_PERCENTAGE
);
if (width !== 0 && height !== 0) {
const newScale = shouldScaleTo1
? 1
: calculateScale(
stageWidth,
stageHeight,
width,
height,
STAGE_PADDING_PERCENTAGE
);
const newCoordinates = calculateCoordinates(
stageWidth,
stageHeight,
contentRect.x || state.boundingBoxCoordinates.x,
contentRect.y || state.boundingBoxCoordinates.y,
contentRect.width || state.boundingBoxDimensions.width,
contentRect.height || state.boundingBoxDimensions.height,
newScale
);
const newCoordinates = calculateCoordinates(
stageWidth,
stageHeight,
x,
y,
width,
height,
newScale
);
state.stageScale = newScale;
state.stageCoordinates = newCoordinates;
}
state.stageScale = newScale;
state.stageCoordinates = newCoordinates;
},
nextStagingAreaImage: (state) => {
if (!state.layerState.stagingArea.images.length) {
@@ -665,7 +674,6 @@ export const canvasSlice = createSlice({
...state.colorPickerColor,
a: state.brushColor.a,
};
state.tool = 'brush';
},
setMergedCanvas: (state, action: PayloadAction<CanvasImage>) => {
state.pastLayerStates.push(cloneDeep(state.layerState));
@@ -680,6 +688,12 @@ export const canvasSlice = createSlice({
},
extraReducers: (builder) => {
builder.addCase(modelChanged, (state, action) => {
if (
action.meta.previousModel?.base_model === action.payload?.base_model
) {
// The base model hasn't changed, we don't need to optimize the size
return;
}
const optimalDimension = getOptimalDimension(action.payload);
const { width, height } = state.boundingBoxDimensions;
if (getIsSizeOptimal(width, height, optimalDimension)) {
@@ -695,7 +709,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;
@@ -766,9 +780,7 @@ export const {
setShouldSnapToGrid,
setStageCoordinates,
setStageScale,
setTool,
toggleShouldLockBoundingBox,
toggleTool,
undo,
setScaledBoundingBoxDimensions,
setShouldRestrictStrokesToBox,
@@ -784,3 +796,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;
@@ -148,7 +149,6 @@ export interface CanvasState {
stageCoordinates: Vector2d;
stageDimensions: Dimensions;
stageScale: number;
tool: CanvasTool;
generationMode?: GenerationMode;
batchIds: string[];
aspectRatio: AspectRatioState;

View File

@@ -1,6 +1,6 @@
import type { RootState } from 'app/store/store';
import { $canvasBaseLayer } from 'features/canvas/store/canvasNanostore';
import { getCanvasBaseLayer } from './konvaInstanceProvider';
import { konvaNodeToBlob } from './konvaNodeToBlob';
/**
@@ -10,7 +10,7 @@ export const getBaseLayerBlob = async (
state: RootState,
alwaysUseBoundingBox: boolean = false
) => {
const canvasBaseLayer = getCanvasBaseLayer();
const canvasBaseLayer = $canvasBaseLayer.get();
if (!canvasBaseLayer) {
throw new Error('Problem getting base layer blob');

View File

@@ -1,15 +1,18 @@
import { logger } from 'app/logging/logger';
import {
$canvasBaseLayer,
$canvasStage,
} from 'features/canvas/store/canvasNanostore';
import type {
CanvasLayerState,
Dimensions,
} from 'features/canvas/store/canvasTypes';
import { isCanvasMaskLine } from 'features/canvas/store/canvasTypes';
import { konvaNodeToImageData } from 'features/canvas/util/konvaNodeToImageData';
import type { Vector2d } from 'konva/lib/types';
import createMaskStage from './createMaskStage';
import { getCanvasBaseLayer, getCanvasStage } from './konvaInstanceProvider';
import { konvaNodeToBlob } from './konvaNodeToBlob';
import { konvaNodeToImageData } from './konvaNodeToImageData';
/**
* Gets Blob and ImageData objects for the base and mask layers
@@ -23,8 +26,8 @@ export const getCanvasData = async (
) => {
const log = logger('canvas');
const canvasBaseLayer = getCanvasBaseLayer();
const canvasStage = getCanvasStage();
const canvasBaseLayer = $canvasBaseLayer.get();
const canvasStage = $canvasStage.get();
if (!canvasBaseLayer || !canvasStage) {
log.error('Unable to find canvas / stage');

View File

@@ -1,11 +1,12 @@
import { getCanvasBaseLayer } from './konvaInstanceProvider';
import { $canvasBaseLayer } from 'features/canvas/store/canvasNanostore';
import { konvaNodeToBlob } from './konvaNodeToBlob';
/**
* Gets the canvas base layer blob, without bounding box
*/
export const getFullBaseLayerBlob = async () => {
const canvasBaseLayer = getCanvasBaseLayer();
const canvasBaseLayer = $canvasBaseLayer.get();
if (!canvasBaseLayer) {
return;

View File

@@ -1,16 +0,0 @@
import type Konva from 'konva';
let canvasBaseLayer: Konva.Layer | null = null;
let canvasStage: Konva.Stage | null = null;
export const setCanvasBaseLayer = (layer: Konva.Layer) => {
canvasBaseLayer = layer;
};
export const getCanvasBaseLayer = () => canvasBaseLayer;
export const setCanvasStage = (stage: Konva.Stage) => {
canvasStage = stage;
};
export const getCanvasStage = () => canvasStage;

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

@@ -1,5 +1,6 @@
import type { ChakraProps } from '@chakra-ui/react';
import { Box, Flex, Heading, Image } from '@chakra-ui/react';
import { useAppSelector } from 'app/store/storeHooks';
import { InvText } from 'common/components/InvText/wrapper';
import type { TypesafeDraggableData } from 'features/dnd/types';
import { memo } from 'react';
@@ -34,6 +35,7 @@ const multiImageStyles: ChakraProps['sx'] = {
const DragPreview = (props: OverlayDragImageProps) => {
const { t } = useTranslation();
const selectionCount = useAppSelector((s) => s.gallery.selection.length);
if (!props.dragData) {
return null;
}
@@ -79,10 +81,10 @@ const DragPreview = (props: OverlayDragImageProps) => {
);
}
if (props.dragData.payloadType === 'IMAGE_DTOS') {
if (props.dragData.payloadType === 'GALLERY_SELECTION') {
return (
<Flex sx={multiImageStyles}>
<Heading>{props.dragData.payload.imageDTOs.length}</Heading>
<Heading>{selectionCount}</Heading>
<Heading size="sm">{t('parameters.images')}</Heading>
</Flex>
);

View File

@@ -10,6 +10,7 @@ import type {
useDroppable as useOriginalDroppable,
UseDroppableArguments,
} from '@dnd-kit/core';
import type { BoardId } from 'features/gallery/store/types';
import type {
FieldInputInstance,
FieldInputTemplate,
@@ -51,15 +52,6 @@ export type NodesImageDropData = BaseDropData & {
};
};
export type NodesMultiImageDropData = BaseDropData & {
actionType: 'SET_MULTI_NODES_IMAGE';
context: { nodeId: string; fieldName: string };
};
export type AddToBatchDropData = BaseDropData & {
actionType: 'ADD_TO_BATCH';
};
export type AddToBoardDropData = BaseDropData & {
actionType: 'ADD_TO_BOARD';
context: { boardId: string };
@@ -69,21 +61,14 @@ export type RemoveFromBoardDropData = BaseDropData & {
actionType: 'REMOVE_FROM_BOARD';
};
export type AddFieldToLinearViewDropData = BaseDropData & {
actionType: 'ADD_FIELD_TO_LINEAR';
};
export type TypesafeDroppableData =
| CurrentImageDropData
| InitialImageDropData
| ControlAdapterDropData
| CanvasInitialImageDropData
| NodesImageDropData
| AddToBatchDropData
| NodesMultiImageDropData
| AddToBoardDropData
| RemoveFromBoardDropData
| AddFieldToLinearViewDropData;
| RemoveFromBoardDropData;
type BaseDragData = {
id: string;
@@ -103,15 +88,15 @@ export type ImageDraggableData = BaseDragData & {
payload: { imageDTO: ImageDTO };
};
export type ImageDTOsDraggableData = BaseDragData & {
payloadType: 'IMAGE_DTOS';
payload: { imageDTOs: ImageDTO[] };
export type GallerySelectionDraggableData = BaseDragData & {
payloadType: 'GALLERY_SELECTION';
payload: { boardId: BoardId };
};
export type TypesafeDraggableData =
| NodeFieldDraggableData
| ImageDraggableData
| ImageDTOsDraggableData;
| GallerySelectionDraggableData;
export interface UseDroppableTypesafeArguments
extends Omit<UseDroppableArguments, 'data'> {

View File

@@ -16,8 +16,6 @@ export const isValidDrop = (
}
switch (actionType) {
case 'ADD_FIELD_TO_LINEAR':
return payloadType === 'NODE_FIELD';
case 'SET_CURRENT_IMAGE':
return payloadType === 'IMAGE_DTO';
case 'SET_INITIAL_IMAGE':
@@ -28,15 +26,13 @@ export const isValidDrop = (
return payloadType === 'IMAGE_DTO';
case 'SET_NODES_IMAGE':
return payloadType === 'IMAGE_DTO';
case 'SET_MULTI_NODES_IMAGE':
return payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
case 'ADD_TO_BATCH':
return payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
case 'ADD_TO_BOARD': {
// If the board is the same, don't allow the drop
// Check the payload types
const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
const isPayloadValid = ['IMAGE_DTO', 'GALLERY_SELECTION'].includes(
payloadType
);
if (!isPayloadValid) {
return false;
}
@@ -50,12 +46,10 @@ export const isValidDrop = (
return currentBoard !== destinationBoard;
}
if (payloadType === 'IMAGE_DTOS') {
if (payloadType === 'GALLERY_SELECTION') {
// Assume all images are on the same board - this is true for the moment
const { imageDTOs } = active.data.current.payload;
const currentBoard = imageDTOs[0]?.board_id ?? 'none';
const currentBoard = active.data.current.payload.boardId;
const destinationBoard = overData.context.boardId;
return currentBoard !== destinationBoard;
}
@@ -65,7 +59,9 @@ export const isValidDrop = (
// If the board is the same, don't allow the drop
// Check the payload types
const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
const isPayloadValid = ['IMAGE_DTO', 'GALLERY_SELECTION'].includes(
payloadType
);
if (!isPayloadValid) {
return false;
}
@@ -78,11 +74,8 @@ export const isValidDrop = (
return currentBoard !== 'none';
}
if (payloadType === 'IMAGE_DTOS') {
// Assume all images are on the same board - this is true for the moment
const { imageDTOs } = active.data.current.payload;
const currentBoard = imageDTOs[0]?.board_id ?? 'none';
if (payloadType === 'GALLERY_SELECTION') {
const currentBoard = active.data.current.payload.boardId;
return currentBoard !== 'none';
}

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

@@ -12,7 +12,6 @@ import {
import type { BoardId } from 'features/gallery/store/types';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { addToast } from 'features/system/store/systemSlice';
import type { MouseEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaDownload, FaPlus } from 'react-icons/fa';
@@ -90,13 +89,9 @@ const BoardContextMenu = ({
}
}, [t, board_id, bulkDownload, dispatch]);
const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => {
e.preventDefault();
}, []);
const renderMenuFunc = useCallback(
() => (
<InvMenuList visibility="visible" onContextMenu={skipEvent}>
<InvMenuList visibility="visible">
<InvMenuGroup title={boardName}>
<InvMenuItem
icon={<FaPlus />}
@@ -131,7 +126,6 @@ const BoardContextMenu = ({
isBulkDownloadEnabled,
isSelectedForAutoAdd,
setBoardToDelete,
skipEvent,
t,
]
);

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')}

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