Compare commits

...

64 Commits

Author SHA1 Message Date
psychedelicious
c1a4376b75 chore: bump version to v6.1.0rc1 2025-07-11 08:20:02 +10:00
psychedelicious
ef4d5d7377 feat(ui): virtualized list for staging area
Make the staging area a virtualized list so it doesn't choke when there
are a large number (i.e. more than a few hundred) of queue items.
2025-07-11 07:50:57 +10:00
Mary Hipp Rogers
6b0dfd8427 dont reset canvas if studio is loaded with canvas destination (#8252)
Co-authored-by: Mary Hipp <maryhipp@Marys-MacBook-Air.local>
2025-07-10 09:36:41 -04:00
psychedelicious
471c010217 fix(ui): invalid language crashes app
- Apparently locales must use hyphens instead of underscores. This must
have been a fairly recent change that we didn't catch. It caused i18n to
throw for Brasilian Portuguese and both Simplified and Traditional
Mandarin. Change the locales to use the right strings.
- Move the theme + locale provider inside of the error boundary. This
allows errors with locals to be caught by the error boundary instead of
hard-crashing the app. The error screen is unstyled if this happens but
at least it has the reset button.
- Add a migration for the system slice to fix existing users' language
selections. For example, if the user had an incorrect language setting
of `zh_CN`, it will be changed to the correct `zh-CN`.
2025-07-10 14:27:36 +10:00
psychedelicious
b1193022f7 fix(ui): sometimes images added to gallery show as placeholder only
The range-based fetching logic had a subtle bug - it didn't keep track
of what the _current_ visible range is - only the ranges that the user
last scrolled to.

When an image was added to the gallery, the logic saw that the images
had changed, but thought it had already loaded everything it needed to,
so it didn't load the new image.

The updated logic tracks the current visible range separately from the
accumulated scroll ranges to address this issue.
2025-07-10 14:27:36 +10:00
psychedelicious
2152ca092c fix(ui): workaround for dockview bug that lets you drag tabs in certain ways 2025-07-10 14:27:36 +10:00
psychedelicious
ccc62ba56d perf(ui): revised range-based fetching strategy
When the user scrolls in the gallery, we are alerted of the new range of
visible images. Then we fetch those specific images.

Previously, each change of range triggered a throttled function to fetch
that range. The throttle timeout was 100ms.

Now, each change of range appends that range to a list of ranges and
triggers the throttled fetch. The timeout is increased to 500ms, but to
compensate, each fetch handles all ranges that had been accumulated
since the last fetch.

The result is far fewer network requests, but each of them gets more
images.
2025-07-10 14:27:36 +10:00
psychedelicious
9cf82de8c5 fix(ui): check for absolute value of scroll velocity to handle scrolling up 2025-07-10 14:27:36 +10:00
psychedelicious
aced349152 perf(ui): increase viewport in gallery
This allows us to prefetch more images and reduce how often placeholders
are shown as we fetch more images in the gallery.
2025-07-10 14:27:36 +10:00
psychedelicious
0d67ee6548 tests(ui): fix logging mock 2025-07-09 23:15:25 +10:00
psychedelicious
03c21d1607 fix(ui): gallery not updating when saving staging area image 2025-07-09 23:15:25 +10:00
psychedelicious
752e8db1f5 tidy(ui): demote logging in nav api to trace 2025-07-09 23:15:25 +10:00
psychedelicious
85fc861dd9 chore(ui): lint 2025-07-09 23:15:25 +10:00
psychedelicious
458cbfd874 fix(ui): selected model not highlighted 2025-07-09 23:15:25 +10:00
psychedelicious
04331c070a fix(ui): set denoise w/h when running flux fill 2025-07-09 23:15:25 +10:00
psychedelicious
632ddf0cb4 tests(ui): update tests for navigation api 2025-07-09 23:15:25 +10:00
psychedelicious
2b193ff416 fix(ui): delete stored state on error & save new state 2025-07-09 23:15:25 +10:00
psychedelicious
96ee394f9e refactor(ui): use dockview's own ser/de for persistence 2025-07-09 23:15:25 +10:00
psychedelicious
0badc80c0c fix(ui): ignore disabled ref images in readiness checks 2025-07-09 23:15:25 +10:00
psychedelicious
78e6cbf96e fix(ui): default tab is generate 2025-07-09 23:15:25 +10:00
psychedelicious
0b969a661b fix(ui): remove dep on focus from useDeleteImage 2025-07-09 23:15:25 +10:00
psychedelicious
6fe47ec9f8 feat(ui): improve ref image model autoswitch logic 2025-07-09 23:15:25 +10:00
Kent Keirsey
3850dd61f8 update comment 2025-07-09 23:15:25 +10:00
Kent Keirsey
75520eaf0f Match Chatgpt4o and kontext names exactly 2025-07-09 23:15:25 +10:00
Kent Keirsey
10e88c58c1 fix and lint 2025-07-09 23:15:25 +10:00
Kent Keirsey
30ed4dbd92 lint 2025-07-09 23:15:25 +10:00
Kent Keirsey
ed9c090f33 fixes 2025-07-09 23:15:25 +10:00
Kent Keirsey
d29f65ed22 lint fixes 2025-07-09 23:15:25 +10:00
Kent Keirsey
2062ec8ac0 Update invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts
Co-authored-by: Mary Hipp Rogers <maryhipp@gmail.com>
2025-07-09 23:15:25 +10:00
Cursor Agent
49e818338a Changes from background composer bc-abfadb27-a265-41a7-b0db-829879f4701e 2025-07-09 23:15:25 +10:00
Cursor Agent
1caab2b9c4 Implement automatic reference image model switching on base model change
Co-authored-by: kent <kent@invoke.ai>
2025-07-09 23:15:25 +10:00
psychedelicious
50079ea349 fix(ui): big red cancel button has diff behaviour than staging discard 2025-07-09 23:15:25 +10:00
psychedelicious
fffa1b24c4 fix(ui): isStaging selector could return wrong query cache 2025-07-09 23:15:25 +10:00
psychedelicious
a6d6170387 fix(ui): discarding 1 item when 2 items left in staging area discards both 2025-07-09 23:15:25 +10:00
psychedelicious
e5fceb0448 fix(ui): whole app scrolls while selecting staging area image 2025-07-09 23:15:25 +10:00
psychedelicious
059baf5b29 chore(ui): lint 2025-07-09 23:15:25 +10:00
psychedelicious
1be8a9a310 fix(ui): add metadata i18nKey to handler; fixes metadata toasts 2025-07-09 23:15:25 +10:00
psychedelicious
7adc33e04d refactor(ui): metadata recall buttons & hotkeys (WIP) 2025-07-09 23:15:25 +10:00
psychedelicious
7f2dd22d47 refactor(ui): metadata recall buttons & hotkeys (WIP) 2025-07-09 23:15:25 +10:00
psychedelicious
bb50f4b8a2 fix(ui): prevent panels from growing on init
This works but I think a better solution is to use dockview's provided
serialization API to store and restore layouts.
2025-07-09 23:15:25 +10:00
psychedelicious
a48958e0d4 chore(ui): lint 2025-07-09 23:15:25 +10:00
psychedelicious
e3a1e9af53 feat(ui): staging area updates
- Smaller staged image previews.
- Move autoswitch buttons to staging area toolbar, remove from settings
popover and the little three-dots menu. Use persisted autoswitch
setting, which is renamed from `defaultAutoSwitch` to
`stagingAreaAutoSwitch`.
- Fix issue with misaligned border radii in staging area preview images.
Required small changes to DndImage and its usage elsewhere.
- Fix issue where staging area toolbar could show up without any
previews in the list.
- Migrate canvas settings slice to use zod schema and inferred types for
its state.
2025-07-09 23:15:25 +10:00
psychedelicious
c6fe11c42f fix(ui): disable gallery hotkeys when in staging area 2025-07-09 23:15:25 +10:00
psychedelicious
4eb1bd67df fix(ui): hide staging area when there are no items 2025-07-09 23:15:25 +10:00
psychedelicious
c376f914d2 chore: bump version v6.0.0 2025-07-09 23:15:25 +10:00
Kent Keirsey
b5d1c47ef7 final link fix 2025-07-09 10:17:38 +10:00
Kent Keirsey
004a52ca65 fix to direct links 2025-07-09 10:17:38 +10:00
Kent Keirsey
b1d5a51ddf add-quantized-kontext-dev 2025-07-09 10:17:38 +10:00
Kent Keirsey
2b2498eaa1 fix prettier quirk 2025-07-08 14:54:29 -04:00
Kent Keirsey
10dda4440e Fix label 2025-07-08 14:54:29 -04:00
Cursor Agent
98f78abefa Add default auto-switch mode setting for canvas sessions
Co-authored-by: kent <kent@invoke.ai>
2025-07-08 14:54:29 -04:00
Mary Hipp Rogers
cc93fa270f update whats new for v6 (#8234)
Co-authored-by: Mary Hipp <maryhipp@Marys-Air.lan>
2025-07-08 18:24:33 +00:00
Mary Hipp Rogers
014b27680f fix flux kontext error (#8235)
Co-authored-by: Mary Hipp <maryhipp@Marys-Air.lan>
2025-07-08 13:42:48 -04:00
Mary Hipp Rogers
c3d8f875de if on generate tab, recall dimensions instead of bbox (#8233)
Co-authored-by: Mary Hipp <maryhipp@Marys-Air.lan>
2025-07-08 13:09:21 -04:00
Mary Hipp Rogers
79f9dc6e4a fix(ui): dont show option to add new layer from if on generate tab (#8231)
* dont show option to add new layer from if on generate tab

* only disable width/height recall is staging AND canvas tab

---------

Co-authored-by: Mary Hipp <maryhipp@Marys-Air.lan>
2025-07-08 11:46:54 -04:00
psychedelicious
6e1c0c1105 chore: bump version to v6.0.0rc5 2025-07-08 11:26:47 -04:00
Mary Hipp Rogers
0362524040 remove hard-coded flux kontext dev guidance (#8230)
Co-authored-by: Mary Hipp <maryhipp@Marys-Air.lan>
2025-07-08 10:26:20 -04:00
psychedelicious
dc6656459b docs(ui): updated comments for navigation api 2025-07-08 07:30:36 -04:00
psychedelicious
3ea1b97f6f fix(ui): protect against getting stuck on tab loading screen 2025-07-08 07:30:36 -04:00
psychedelicious
a7c7405ccc feat(ui): style model picker selected item 2025-07-08 07:28:07 -04:00
psychedelicious
c391f1117a fix(ui): traverse groups when finding selected model in picker 2025-07-08 07:28:07 -04:00
psychedelicious
b1e2cb8401 fix(ui): queue tab list of queue items
Reverted incomplete change to how queue items are listed. In the future
I think we should redo it to work like the gallery. For now, it is back
the way it was in v5.
2025-07-08 07:22:51 -04:00
Emmanuel Ferdman
db6af134b7 fix: resolve FastAPI deprecation warning for example fields
Signed-off-by: Emmanuel Ferdman <emmanuelferdman@gmail.com>
2025-07-08 20:54:08 +10:00
Emmanuel Ferdman
7e6cffb00c fix: resolve FastAPI deprecation warning for example fields
Signed-off-by: Emmanuel Ferdman <emmanuelferdman@gmail.com>
2025-07-08 20:54:08 +10:00
97 changed files with 2823 additions and 1755 deletions

View File

@@ -72,7 +72,7 @@ async def upload_image(
resize_to: Optional[str] = Body(
default=None,
description=f"Dimensions to resize the image to, must be stringified tuple of 2 integers. Max total pixel count: {ResizeToDimensions.MAX_SIZE}",
example='"[1024,1024]"',
examples=['"[1024,1024]"'],
),
metadata: Optional[str] = Body(
default=None,

View File

@@ -292,7 +292,7 @@ async def get_hugging_face_models(
)
async def update_model_record(
key: Annotated[str, Path(description="Unique key of model")],
changes: Annotated[ModelRecordChanges, Body(description="Model config", example=example_model_input)],
changes: Annotated[ModelRecordChanges, Body(description="Model config", examples=[example_model_input])],
) -> AnyModelConfig:
"""Update a model's config."""
logger = ApiDependencies.invoker.services.logger
@@ -450,7 +450,7 @@ async def install_model(
access_token: Optional[str] = Query(description="access token for the remote resource", default=None),
config: ModelRecordChanges = Body(
description="Object containing fields that override auto-probed values in the model config record, such as name, description and prediction_type ",
example={"name": "string", "description": "string"},
examples=[{"name": "string", "description": "string"}],
),
) -> ModelInstallJob:
"""Install a model using a string identifier.

View File

@@ -143,11 +143,19 @@ flux_dev = StarterModel(
flux_kontext = StarterModel(
name="FLUX.1 Kontext dev",
base=BaseModelType.Flux,
source="black-forest-labs/FLUX.1-Kontext-dev::flux1-kontext-dev.safetensors",
source="https://huggingface.co/black-forest-labs/FLUX.1-Kontext-dev/resolve/main/flux1-kontext-dev.safetensors",
description="FLUX.1 Kontext dev transformer in bfloat16. Total size with dependencies: ~33GB",
type=ModelType.Main,
dependencies=[t5_base_encoder, flux_vae, clip_l_encoder],
)
flux_kontext_quantized = StarterModel(
name="FLUX.1 Kontext dev (Quantized)",
base=BaseModelType.Flux,
source="https://huggingface.co/unsloth/FLUX.1-Kontext-dev-GGUF/resolve/main/flux1-kontext-dev-Q4_K_M.gguf",
description="FLUX.1 Kontext dev quantized (q4_k_m). Total size with dependencies: ~14GB",
type=ModelType.Main,
dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder],
)
sd35_medium = StarterModel(
name="SD3.5 Medium",
base=BaseModelType.StableDiffusion3,
@@ -664,7 +672,7 @@ flux_fill = StarterModel(
# List of starter models, displayed on the frontend.
# The order/sort of this list is not changed by the frontend - set it how you want it here.
STARTER_MODELS: list[StarterModel] = [
flux_kontext,
flux_kontext_quantized,
flux_schnell_quantized,
flux_dev_quantized,
flux_schnell,
@@ -785,7 +793,7 @@ flux_bundle: list[StarterModel] = [
flux_depth_control_lora,
flux_redux,
flux_fill,
flux_kontext,
flux_kontext_quantized,
]
STARTER_BUNDLES: dict[str, StarterModelBundle] = {

View File

@@ -12,6 +12,8 @@ const config: KnipConfig = {
'src/features/parameters/types/parameterSchemas.ts',
// TODO(psyche): maybe we can clean up these utils after canvas v2 release
'src/features/controlLayers/konva/util.ts',
// Will be using this
'src/common/hooks/useAsyncState.ts',
],
ignoreBinaries: ['only-allow'],
paths: {

View File

@@ -1399,7 +1399,7 @@
"fluxFillIncompatibleWithT2IAndI2I": "FLUX Fill is not compatible with Text to Image or Image to Image. Use other FLUX models for these tasks.",
"imagenIncompatibleGenerationMode": "Google {{model}} supports Text to Image only. Use other models for Image to Image, Inpainting and Outpainting tasks.",
"chatGPT4oIncompatibleGenerationMode": "ChatGPT 4o supports Text to Image and Image to Image only. Use other models Inpainting and Outpainting tasks.",
"fluxKontextIncompatibleGenerationMode": "FLUX Kontext supports Text to Image only. Use other models for Image to Image, Inpainting and Outpainting tasks.",
"fluxKontextIncompatibleGenerationMode": "FLUX Kontext does not support generation from images placed on the canvas. Re-try using the Reference Image section and disable any Raster Layers.",
"problemUnpublishingWorkflow": "Problem Unpublishing Workflow",
"problemUnpublishingWorkflowDescription": "There was a problem unpublishing the workflow. Please try again.",
"workflowUnpublished": "Workflow Unpublished",
@@ -2380,6 +2380,11 @@
"saveToGallery": "Save To Gallery",
"showResultsOn": "Showing Results",
"showResultsOff": "Hiding Results"
},
"autoSwitch": {
"off": "Off",
"switchOnStart": "On Start",
"switchOnFinish": "On Finish"
}
},
"upscaling": {
@@ -2555,8 +2560,9 @@
"whatsNew": {
"whatsNewInInvoke": "What's New in Invoke",
"items": [
"Inpainting: Per-mask noise levels and denoise limits.",
"Canvas: Smarter aspect ratios for SDXL and improved scroll-to-zoom."
"Generate images faster with new Launchpads and a simplified Generate tab.",
"Edit with prompts using Flux Kontext Dev.",
"Export to PSD, bulk-hide overlays, organize models & images — all in a reimagined interface built for control."
],
"readReleaseNotes": "Read Release Notes",
"watchRecentReleaseVideos": "Watch Recent Release Videos",

View File

@@ -11,6 +11,7 @@ import { memo, useCallback } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import AppErrorBoundaryFallback from './AppErrorBoundaryFallback';
import ThemeLocaleProvider from './ThemeLocaleProvider';
const DEFAULT_CONFIG = {};
interface Props {
@@ -30,12 +31,14 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
return (
<ErrorBoundary onReset={handleReset} FallbackComponent={AppErrorBoundaryFallback}>
<Box id="invoke-app-wrapper" w="100dvw" h="100dvh" position="relative" overflow="hidden">
<AppContent />
{!didStudioInit && <Loading />}
</Box>
<GlobalHookIsolator config={config} studioInitAction={studioInitAction} />
<GlobalModalIsolator />
<ThemeLocaleProvider>
<Box id="invoke-app-wrapper" w="100dvw" h="100dvh" position="relative" overflow="hidden">
<AppContent />
{!didStudioInit && <Loading />}
</Box>
<GlobalHookIsolator config={config} studioInitAction={studioInitAction} />
<GlobalModalIsolator />
</ThemeLocaleProvider>
</ErrorBoundary>
);
};

View File

@@ -1,11 +1,14 @@
import { useAppSelector } from 'app/store/storeHooks';
import { useIsRegionFocused } from 'common/hooks/focus';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useImageActions } from 'features/gallery/hooks/useImageActions';
import { useLoadWorkflow } from 'features/gallery/hooks/useLoadWorkflow';
import { useRecallAll } from 'features/gallery/hooks/useRecallAll';
import { useRecallDimensions } from 'features/gallery/hooks/useRecallDimensions';
import { useRecallPrompts } from 'features/gallery/hooks/useRecallPrompts';
import { useRecallRemix } from 'features/gallery/hooks/useRecallRemix';
import { useRecallSeed } from 'features/gallery/hooks/useRecallSeed';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
import { useImageDTO } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
@@ -27,59 +30,64 @@ GlobalImageHotkeys.displayName = 'GlobalImageHotkeys';
const GlobalImageHotkeysInternal = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
const isGalleryFocused = useIsRegionFocused('gallery');
const isViewerFocused = useIsRegionFocused('viewer');
const imageActions = useImageActions(imageDTO);
const isStaging = useAppSelector(selectIsStaging);
const isUpscalingEnabled = useFeatureStatus('upscaling');
const isFocusOK = isGalleryFocused || isViewerFocused;
const recallAll = useRecallAll(imageDTO);
const recallRemix = useRecallRemix(imageDTO);
const recallPrompts = useRecallPrompts(imageDTO);
const recallSeed = useRecallSeed(imageDTO);
const recallDimensions = useRecallDimensions(imageDTO);
const loadWorkflow = useLoadWorkflow(imageDTO);
useRegisteredHotkeys({
id: 'loadWorkflow',
category: 'viewer',
callback: imageActions.loadWorkflow,
options: { enabled: isGalleryFocused || isViewerFocused },
dependencies: [imageActions.loadWorkflow, isGalleryFocused, isViewerFocused],
callback: loadWorkflow.load,
options: { enabled: loadWorkflow.isEnabled && isFocusOK },
dependencies: [loadWorkflow, isFocusOK],
});
useRegisteredHotkeys({
id: 'recallAll',
category: 'viewer',
callback: imageActions.recallAll,
options: { enabled: !isStaging && (isGalleryFocused || isViewerFocused) },
dependencies: [imageActions.recallAll, isStaging, isGalleryFocused, isViewerFocused],
callback: recallAll.recall,
options: { enabled: recallAll.isEnabled && isFocusOK },
dependencies: [recallAll, isFocusOK],
});
useRegisteredHotkeys({
id: 'recallSeed',
category: 'viewer',
callback: imageActions.recallSeed,
options: { enabled: isGalleryFocused || isViewerFocused },
dependencies: [imageActions.recallSeed, isGalleryFocused, isViewerFocused],
callback: recallSeed.recall,
options: { enabled: recallSeed.isEnabled && isFocusOK },
dependencies: [recallSeed, isFocusOK],
});
useRegisteredHotkeys({
id: 'recallPrompts',
category: 'viewer',
callback: imageActions.recallPrompts,
options: { enabled: isGalleryFocused || isViewerFocused },
dependencies: [imageActions.recallPrompts, isGalleryFocused, isViewerFocused],
callback: recallPrompts.recall,
options: { enabled: recallPrompts.isEnabled && isFocusOK },
dependencies: [recallPrompts, isFocusOK],
});
useRegisteredHotkeys({
id: 'remix',
category: 'viewer',
callback: imageActions.remix,
options: { enabled: isGalleryFocused || isViewerFocused },
dependencies: [imageActions.remix, isGalleryFocused, isViewerFocused],
callback: recallRemix.recall,
options: { enabled: recallRemix.isEnabled && isFocusOK },
dependencies: [recallRemix, isFocusOK],
});
useRegisteredHotkeys({
id: 'useSize',
category: 'viewer',
callback: imageActions.recallSize,
options: { enabled: !isStaging && (isGalleryFocused || isViewerFocused) },
dependencies: [imageActions.recallSize, isStaging, isGalleryFocused, isViewerFocused],
});
useRegisteredHotkeys({
id: 'runPostprocessing',
category: 'viewer',
callback: imageActions.upscale,
options: { enabled: isUpscalingEnabled && isViewerFocused },
dependencies: [isUpscalingEnabled, imageDTO, isViewerFocused],
callback: recallDimensions.recall,
options: { enabled: recallDimensions.isEnabled && isFocusOK },
dependencies: [recallDimensions, isFocusOK],
});
return null;
});

View File

@@ -42,7 +42,6 @@ import { $socketOptions } from 'services/events/stores';
import type { ManagerOptions, SocketOptions } from 'socket.io-client';
const App = lazy(() => import('./App'));
const ThemeLocaleProvider = lazy(() => import('./ThemeLocaleProvider'));
interface Props extends PropsWithChildren {
apiUrl?: string;
@@ -330,9 +329,7 @@ const InvokeAIUI = ({
<React.StrictMode>
<Provider store={store}>
<React.Suspense fallback={<Loading />}>
<ThemeLocaleProvider>
<App config={config} studioInitAction={studioInitAction} />
</ThemeLocaleProvider>
<App config={config} studioInitAction={studioInitAction} />
</React.Suspense>
</Provider>
</React.StrictMode>

View File

@@ -170,7 +170,6 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
case 'canvas':
// Go to the canvas tab, open the launchpad
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
store.dispatch(canvasReset());
break;
case 'workflows':
// Go to the workflows tab

View File

@@ -1,14 +1,28 @@
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { bboxSyncedToOptimalDimension } from 'features/controlLayers/store/canvasSlice';
import { bboxSyncedToOptimalDimension, rgRefImageModelChanged } from 'features/controlLayers/store/canvasSlice';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { loraDeleted } from 'features/controlLayers/store/lorasSlice';
import { modelChanged, syncedToOptimalDimension, vaeSelected } from 'features/controlLayers/store/paramsSlice';
import { selectBboxModelBase } from 'features/controlLayers/store/selectors';
import { refImageModelChanged, selectReferenceImageEntities } from 'features/controlLayers/store/refImagesSlice';
import {
selectAllEntitiesOfType,
selectBboxModelBase,
selectCanvasSlice,
} from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { modelSelected } from 'features/parameters/store/actions';
import { zParameterModel } from 'features/parameters/types/parameterSchemas';
import { toast } from 'features/toast/toast';
import { t } from 'i18next';
import { selectGlobalRefImageModels, selectRegionalRefImageModels } from 'services/api/hooks/modelsByType';
import type { AnyModelConfig } from 'services/api/types';
import {
isChatGPT4oModelConfig,
isFluxKontextApiModelConfig,
isFluxKontextModelConfig,
isFluxReduxModelConfig,
} from 'services/api/types';
const log = logger('models');
@@ -25,9 +39,8 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
}
const newModel = result.data;
const newBaseModel = newModel.base;
const didBaseModelChange = state.params.model?.base !== newBaseModel;
const newBase = newModel.base;
const didBaseModelChange = state.params.model?.base !== newBase;
if (didBaseModelChange) {
// we may need to reset some incompatible submodels
@@ -35,7 +48,7 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
// handle incompatible loras
state.loras.loras.forEach((lora) => {
if (lora.model.base !== newBaseModel) {
if (lora.model.base !== newBase) {
dispatch(loraDeleted({ id: lora.id }));
modelsCleared += 1;
}
@@ -43,20 +56,82 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
// handle incompatible vae
const { vae } = state.params;
if (vae && vae.base !== newBaseModel) {
if (vae && vae.base !== newBase) {
dispatch(vaeSelected(null));
modelsCleared += 1;
}
// handle incompatible controlnets
// state.canvas.present.controlAdapters.entities.forEach((ca) => {
// if (ca.model?.base !== newBaseModel) {
// modelsCleared += 1;
// if (ca.isEnabled) {
// dispatch(entityIsEnabledToggled({ entityIdentifier: { id: ca.id, type: 'control_adapter' } }));
// }
// }
// });
// Handle incompatible reference image models - switch to first compatible model, with some smart logic
// to choose the best available model based on the new main model.
const allRefImageModels = selectGlobalRefImageModels(state).filter(({ base }) => base === newBase);
let newGlobalRefImageModel = null;
// Certain models require the ref image model to be the same as the main model - others just need a matching
// base. Helper to grab the first exact match or the first available model if no exact match is found.
const exactMatchOrFirst = <T extends AnyModelConfig>(candidates: T[]): T | null =>
candidates.find(({ key }) => key === newModel.key) ?? candidates[0] ?? null;
// The only way we can differentiate between FLUX and FLUX Kontext is to check for "kontext" in the name
if (newModel.base === 'flux' && newModel.name.toLowerCase().includes('kontext')) {
const fluxKontextDevModels = allRefImageModels.filter(isFluxKontextModelConfig);
newGlobalRefImageModel = exactMatchOrFirst(fluxKontextDevModels);
} else if (newModel.base === 'chatgpt-4o') {
const chatGPT4oModels = allRefImageModels.filter(isChatGPT4oModelConfig);
newGlobalRefImageModel = exactMatchOrFirst(chatGPT4oModels);
} else if (newModel.base === 'flux-kontext') {
const fluxKontextApiModels = allRefImageModels.filter(isFluxKontextApiModelConfig);
newGlobalRefImageModel = exactMatchOrFirst(fluxKontextApiModels);
} else if (newModel.base === 'flux') {
const fluxReduxModels = allRefImageModels.filter(isFluxReduxModelConfig);
newGlobalRefImageModel = fluxReduxModels[0] ?? null;
} else {
newGlobalRefImageModel = allRefImageModels[0] ?? null;
}
// All ref image entities are updated to use the same new model
const refImageEntities = selectReferenceImageEntities(state);
for (const entity of refImageEntities) {
const shouldUpdateModel =
(entity.config.model && entity.config.model.base !== newBase) ||
(!entity.config.model && newGlobalRefImageModel);
if (shouldUpdateModel) {
dispatch(
refImageModelChanged({
id: entity.id,
modelConfig: newGlobalRefImageModel,
})
);
modelsCleared += 1;
}
}
// For regional guidance, there is no smart logic - we just pick the first available model.
const newRegionalRefImageModel = selectRegionalRefImageModels(state)[0] ?? null;
// All regional guidance entities are updated to use the same new model.
const canvasState = selectCanvasSlice(state);
const canvasRegionalGuidanceEntities = selectAllEntitiesOfType(canvasState, 'regional_guidance');
for (const entity of canvasRegionalGuidanceEntities) {
for (const refImage of entity.referenceImages) {
// Only change the model if the current one is not compatible with the new base model.
const shouldUpdateModel =
(refImage.config.model && refImage.config.model.base !== newBase) ||
(!refImage.config.model && newRegionalRefImageModel);
if (shouldUpdateModel) {
dispatch(
rgRefImageModelChanged({
entityIdentifier: getEntityIdentifier(entity),
referenceImageId: refImage.id,
modelConfig: newRegionalRefImageModel,
})
);
modelsCleared += 1;
}
}
}
if (modelsCleared > 0) {
toast({

View File

@@ -3,6 +3,7 @@ import { isNil } from 'es-toolkit';
import { bboxHeightChanged, bboxWidthChanged } from 'features/controlLayers/store/canvasSlice';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import {
heightChanged,
setCfgRescaleMultiplier,
setCfgScale,
setGuidance,
@@ -10,6 +11,7 @@ import {
setSteps,
vaePrecisionChanged,
vaeSelected,
widthChanged,
} from 'features/controlLayers/store/paramsSlice';
import { setDefaultSettings } from 'features/parameters/store/actions';
import {
@@ -24,6 +26,7 @@ import {
zParameterVAEModel,
} from 'features/parameters/types/parameterSchemas';
import { toast } from 'features/toast/toast';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { t } from 'i18next';
import { modelConfigsAdapterSelectors, modelsApi } from 'services/api/endpoints/models';
import { isNonRefinerMainModelConfig } from 'services/api/types';
@@ -113,15 +116,24 @@ export const addSetDefaultSettingsListener = (startAppListening: AppStartListeni
const setSizeOptions = { updateAspectRatio: true, clamp: true };
const isStaging = selectIsStaging(getState());
if (!isStaging && width) {
const activeTab = selectActiveTab(getState());
if (activeTab === 'generate') {
if (isParameterWidth(width)) {
dispatch(bboxWidthChanged({ width, ...setSizeOptions }));
dispatch(widthChanged({ width, ...setSizeOptions }));
}
if (isParameterHeight(height)) {
dispatch(heightChanged({ height, ...setSizeOptions }));
}
}
if (!isStaging && height) {
if (isParameterHeight(height)) {
dispatch(bboxHeightChanged({ height, ...setSizeOptions }));
if (activeTab === 'canvas') {
if (!isStaging) {
if (isParameterWidth(width)) {
dispatch(bboxWidthChanged({ width, ...setSizeOptions }));
}
if (isParameterHeight(height)) {
dispatch(bboxHeightChanged({ height, ...setSizeOptions }));
}
}
}

View File

@@ -87,14 +87,10 @@ export const buildGroup = <T extends object>(group: Omit<Group<T>, typeof unique
[uniqueGroupKey]: true,
});
const isGroup = <T extends object>(optionOrGroup: OptionOrGroup<T>): optionOrGroup is Group<T> => {
export const isGroup = <T extends object>(optionOrGroup: OptionOrGroup<T>): optionOrGroup is Group<T> => {
return uniqueGroupKey in optionOrGroup && optionOrGroup[uniqueGroupKey] === true;
};
export const isOption = <T extends object>(optionOrGroup: OptionOrGroup<T>): optionOrGroup is T => {
return !(uniqueGroupKey in optionOrGroup);
};
const DefaultOptionComponent = typedMemo(<T extends object>({ option }: { option: T }) => {
const { getOptionId } = usePickerContext();
return <Text fontWeight="bold">{getOptionId(option)}</Text>;

View File

@@ -0,0 +1,115 @@
import { useStore } from '@nanostores/react';
import { WrappedError } from 'common/util/result';
import type { Atom } from 'nanostores';
import { atom } from 'nanostores';
import { useCallback, useEffect, useMemo, useState } from 'react';
type SuccessState<T> = {
status: 'success';
value: T;
error: null;
};
type ErrorState = {
status: 'error';
value: null;
error: Error;
};
type PendingState = {
status: 'pending';
value: null;
error: null;
};
type IdleState = {
status: 'idle';
value: null;
error: null;
};
export type State<T> = IdleState | PendingState | SuccessState<T> | ErrorState;
type UseAsyncStateOptions = {
immediate?: boolean;
};
type UseAsyncReturn<T> = {
$state: Atom<State<T>>;
trigger: () => Promise<void>;
reset: () => void;
};
export const useAsyncState = <T>(execute: () => Promise<T>, options?: UseAsyncStateOptions): UseAsyncReturn<T> => {
const $state = useState(() =>
atom<State<T>>({
status: 'idle',
value: null,
error: null,
})
)[0];
const trigger = useCallback(async () => {
$state.set({
status: 'pending',
value: null,
error: null,
});
try {
const value = await execute();
$state.set({
status: 'success',
value,
error: null,
});
} catch (error) {
$state.set({
status: 'error',
value: null,
error: WrappedError.wrap(error),
});
}
}, [$state, execute]);
const reset = useCallback(() => {
$state.set({
status: 'idle',
value: null,
error: null,
});
}, [$state]);
useEffect(() => {
if (options?.immediate) {
trigger();
}
}, [options?.immediate, trigger]);
const api = useMemo(
() =>
({
$state,
trigger,
reset,
}) satisfies UseAsyncReturn<T>,
[$state, trigger, reset]
);
return api;
};
type UseAsyncReturnReactive<T> = {
state: State<T>;
trigger: () => Promise<void>;
reset: () => void;
};
export const useAsyncStateReactive = <T>(
execute: () => Promise<T>,
options?: UseAsyncStateOptions
): UseAsyncReturnReactive<T> => {
const { $state, trigger, reset } = useAsyncState(execute, options);
const state = useStore($state);
return { state, trigger, reset };
};

View File

@@ -61,7 +61,7 @@ export const RefImageImage = memo(
)}
{imageDTO && (
<>
<DndImage imageDTO={imageDTO} borderWidth={1} borderStyle="solid" w="full" />
<DndImage imageDTO={imageDTO} borderRadius="base" borderWidth={1} borderStyle="solid" w="full" />
<Flex position="absolute" flexDir="column" top={2} insetInlineEnd={2} gap={1}>
<DndImageIcon
onClick={handleResetControlImage}

View File

@@ -142,6 +142,7 @@ export const RefImagePreview = memo(() => {
role="button"
onClick={onClick}
cursor="pointer"
overflow="hidden"
>
<Image
src={imageDTO?.thumbnail_url}
@@ -151,7 +152,6 @@ export const RefImagePreview = memo(() => {
fallback={<Skeleton h="full" aspectRatio="1/1" />}
maxW="full"
maxH="full"
borderRadius="base"
/>
{isIPAdapterConfig(entity.config) && (
<Flex

View File

@@ -1,5 +1,6 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
useCanvasSessionContext,
useOutputImageDTO,
@@ -10,6 +11,10 @@ import { QueueItemNumber } from 'features/controlLayers/components/SimpleSession
import { QueueItemProgressImage } from 'features/controlLayers/components/SimpleSession/QueueItemProgressImage';
import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel';
import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared';
import {
selectStagingAreaAutoSwitch,
settingsStagingAreaAutoSwitchChanged,
} from 'features/controlLayers/store/canvasSettingsSlice';
import { DndImage } from 'features/dnd/DndImage';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
@@ -21,12 +26,13 @@ const sx = {
pos: 'relative',
alignItems: 'center',
justifyContent: 'center',
h: 108,
w: 108,
flexShrink: 0,
h: 'full',
aspectRatio: '1/1',
borderWidth: 2,
borderRadius: 'base',
bg: 'base.900',
overflow: 'hidden',
'&[data-selected="true"]': {
borderColor: 'invokeBlue.300',
},
@@ -34,28 +40,29 @@ const sx = {
type Props = {
item: S['SessionQueueItem'];
number: number;
index: number;
isSelected: boolean;
};
export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) => {
export const QueueItemPreviewMini = memo(({ item, isSelected, index }: Props) => {
const dispatch = useAppDispatch();
const ctx = useCanvasSessionContext();
const { imageLoaded } = useProgressData(ctx.$progressData, item.item_id);
const imageDTO = useOutputImageDTO(item);
const autoSwitch = useAppSelector(selectStagingAreaAutoSwitch);
const onClick = useCallback(() => {
ctx.$selectedItemId.set(item.item_id);
}, [ctx.$selectedItemId, item.item_id]);
const onDoubleClick = useCallback(() => {
const autoSwitch = ctx.$autoSwitch.get();
if (autoSwitch !== 'off') {
ctx.$autoSwitch.set('off');
dispatch(settingsStagingAreaAutoSwitchChanged('off'));
toast({
title: 'Auto-Switch Disabled',
});
}
}, [ctx.$autoSwitch]);
}, [autoSwitch, dispatch]);
const onLoad = useCallback(() => {
ctx.onImageLoad(item.item_id);
@@ -63,7 +70,7 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) =
return (
<Flex
id={getQueueItemElementId(item.item_id)}
id={getQueueItemElementId(index)}
sx={sx}
data-selected={isSelected}
onClick={onClick}
@@ -72,7 +79,7 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) =
<QueueItemStatusLabel item={item} position="absolute" margin="auto" />
{imageDTO && <DndImage imageDTO={imageDTO} onLoad={onLoad} asThumbnail />}
{!imageLoaded && <QueueItemProgressImage itemId={item.item_id} position="absolute" />}
<QueueItemNumber number={number} position="absolute" top={0} left={1} />
<QueueItemNumber number={index + 1} position="absolute" top={0} left={1} />
<QueueItemCircularProgress itemId={item.item_id} status={item.status} position="absolute" top={1} right={2} />
</Flex>
);

View File

@@ -16,21 +16,21 @@ export const QueueItemStatusLabel = memo(({ item, ...rest }: Props) => {
if (item.status === 'pending') {
return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="base.300" {...rest}>
<Text fontSize="xs" pointerEvents="none" userSelect="none" fontWeight="semibold" color="base.300" {...rest}>
Pending
</Text>
);
}
if (item.status === 'canceled') {
return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="warning.300" {...rest}>
<Text fontSize="xs" pointerEvents="none" userSelect="none" fontWeight="semibold" color="warning.300" {...rest}>
Canceled
</Text>
);
}
if (item.status === 'failed') {
return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="error.300" {...rest}>
<Text fontSize="xs" pointerEvents="none" userSelect="none" fontWeight="semibold" color="error.300" {...rest}>
Failed
</Text>
);
@@ -38,7 +38,7 @@ export const QueueItemStatusLabel = memo(({ item, ...rest }: Props) => {
if (item.status === 'in_progress') {
return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="invokeBlue.300" {...rest}>
<Text fontSize="xs" pointerEvents="none" userSelect="none" fontWeight="semibold" color="invokeBlue.300" {...rest}>
In Progress
</Text>
);
@@ -46,7 +46,14 @@ export const QueueItemStatusLabel = memo(({ item, ...rest }: Props) => {
if (item.status === 'completed') {
return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="invokeGreen.300" {...rest}>
<Text
fontSize="xs"
pointerEvents="none"
userSelect="none"
fontWeight="semibold"
color="invokeGreen.300"
{...rest}
>
Completed
</Text>
);

View File

@@ -1,17 +1,148 @@
import { Flex } from '@invoke-ai/ui-library';
import { Box, Flex, forwardRef } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { logger } from 'app/logging/logger';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { QueueItemPreviewMini } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewMini';
import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { memo, useEffect } from 'react';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import type { CSSProperties, RefObject } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Components, ItemContent, ListRange, VirtuosoHandle, VirtuosoProps } from 'react-virtuoso';
import { Virtuoso } from 'react-virtuoso';
import type { S } from 'services/api/types';
import { getQueueItemElementId } from './shared';
const log = logger('system');
const virtuosoStyles = {
width: '100%',
height: '72px',
} satisfies CSSProperties;
type VirtuosoContext = { selectedItemId: number | null };
/**
* Scroll the item at the given index into view if it is not currently visible.
*/
const scrollIntoView = (
targetIndex: number,
rootEl: HTMLDivElement,
virtuosoHandle: VirtuosoHandle,
range: ListRange
) => {
if (range.endIndex === 0) {
// No range is rendered; no need to scroll to anything.
return;
}
const targetItem = rootEl.querySelector(`#${getQueueItemElementId(targetIndex)}`);
if (!targetItem) {
if (targetIndex > range.endIndex) {
virtuosoHandle.scrollToIndex({
index: targetIndex,
behavior: 'auto',
align: 'end',
});
} else if (targetIndex < range.startIndex) {
virtuosoHandle.scrollToIndex({
index: targetIndex,
behavior: 'auto',
align: 'start',
});
} else {
log.debug(
`Unable to find queue item at index ${targetIndex} but it is in the rendered range ${range.startIndex}-${range.endIndex}`
);
}
return;
}
// We found the image in the DOM, but it might be in the overscan range - rendered but not in the visible viewport.
// Check if it is in the viewport and scroll if necessary.
const itemRect = targetItem.getBoundingClientRect();
const rootRect = rootEl.getBoundingClientRect();
if (itemRect.left < rootRect.left) {
virtuosoHandle.scrollToIndex({
index: targetIndex,
behavior: 'auto',
align: 'start',
});
} else if (itemRect.right > rootRect.right) {
virtuosoHandle.scrollToIndex({
index: targetIndex,
behavior: 'auto',
align: 'end',
});
} else {
// Image is already in view
}
return;
};
const useScrollableStagingArea = (rootRef: RefObject<HTMLDivElement>) => {
const [scroller, scrollerRef] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars({
defer: true,
events: {
initialized(osInstance) {
// force overflow styles
const { viewport } = osInstance.elements();
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
viewport.style.overflowY = `var(--os-viewport-overflow-y)`;
},
},
options: {
scrollbars: {
visibility: 'auto',
autoHide: 'scroll',
autoHideDelay: 1300,
theme: 'os-theme-dark',
},
overflow: {
y: 'hidden',
x: 'scroll',
},
},
});
useEffect(() => {
const { current: root } = rootRef;
if (scroller && root) {
initialize({
target: root,
elements: {
viewport: scroller,
},
});
}
return () => {
osInstance()?.destroy();
};
}, [scroller, initialize, osInstance, rootRef]);
return scrollerRef;
};
export const StagingAreaItemsList = memo(() => {
const canvasManager = useCanvasManagerSafe();
const ctx = useCanvasSessionContext();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const rangeRef = useRef<ListRange>({ startIndex: 0, endIndex: 0 });
const rootRef = useRef<HTMLDivElement>(null);
const items = useStore(ctx.$items);
const selectedItemId = useStore(ctx.$selectedItemId);
const context = useMemo(() => ({ selectedItemId }), [selectedItemId]);
const scrollerRef = useScrollableStagingArea(rootRef);
useEffect(() => {
if (!canvasManager) {
return;
@@ -20,19 +151,64 @@ export const StagingAreaItemsList = memo(() => {
return canvasManager.stagingArea.connectToSession(ctx.$selectedItemId, ctx.$progressData, ctx.$isPending);
}, [canvasManager, ctx.$progressData, ctx.$selectedItemId, ctx.$isPending]);
useEffect(() => {
return ctx.$selectedItemIndex.listen((index) => {
if (!virtuosoRef.current) {
return;
}
if (!rootRef.current) {
return;
}
if (index === null) {
return;
}
scrollIntoView(index, rootRef.current, virtuosoRef.current, rangeRef.current);
});
}, [ctx.$selectedItemIndex]);
const onRangeChanged = useCallback((range: ListRange) => {
rangeRef.current = range;
}, []);
return (
<ScrollableContent overflowX="scroll" overflowY="hidden">
<Flex gap={2} w="full" h="full" justifyContent="safe center">
{items.map((item, i) => (
<QueueItemPreviewMini
key={`${item.item_id}-mini`}
item={item}
number={i + 1}
isSelected={selectedItemId === item.item_id}
/>
))}
</Flex>
</ScrollableContent>
<Box data-overlayscrollbars-initialize="" ref={rootRef} position="relative" w="full" h="full">
<Virtuoso<S['SessionQueueItem'], VirtuosoContext>
ref={virtuosoRef}
context={context}
data={items}
horizontalDirection
style={virtuosoStyles}
itemContent={itemContent}
components={components}
rangeChanged={onRangeChanged}
// Virtuoso expects the ref to be of HTMLElement | null | Window, but overlayscrollbars doesn't allow Window
scrollerRef={scrollerRef as VirtuosoProps<S['SessionQueueItem'], VirtuosoContext>['scrollerRef']}
/>
</Box>
);
});
StagingAreaItemsList.displayName = 'StagingAreaItemsList';
const itemContent: ItemContent<S['SessionQueueItem'], VirtuosoContext> = (index, item, { selectedItemId }) => (
<QueueItemPreviewMini
key={`${item.item_id}-mini`}
item={item}
index={index}
isSelected={selectedItemId === item.item_id}
/>
);
const listSx = {
'& > * + *': {
pl: 2,
},
};
const components: Components<S['SessionQueueItem'], VirtuosoContext> = {
List: forwardRef(({ context: _, ...rest }, ref) => {
return <Flex ref={ref} sx={listSx} {...rest} />;
}),
};

View File

@@ -1,10 +1,12 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { EMPTY_ARRAY } from 'app/store/constants';
import { useAppStore } from 'app/store/storeHooks';
import { buildZodTypeGuard } from 'common/util/zodUtils';
import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared';
import { canvasQueueItemDiscarded, selectDiscardedItems } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { selectStagingAreaAutoSwitch } from 'features/controlLayers/store/canvasSettingsSlice';
import {
buildSelectSessionQueueItems,
canvasQueueItemDiscarded,
canvasSessionReset,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import type { ProgressImage } from 'features/nodes/types/common';
import type { Atom, MapStore, StoreValue, WritableAtom } from 'nanostores';
import { atom, computed, effect, map, subscribeKeys } from 'nanostores';
@@ -15,11 +17,6 @@ import { queueApi } from 'services/api/endpoints/queue';
import type { ImageDTO, S } from 'services/api/types';
import { $socket } from 'services/events/stores';
import { assert, objectEntries } from 'tsafe';
import { z } from 'zod/v4';
const zAutoSwitchMode = z.enum(['off', 'switch_on_start', 'switch_on_finish']);
export const isAutoSwitchMode = buildZodTypeGuard(zAutoSwitchMode);
type AutoSwitchMode = z.infer<typeof zAutoSwitchMode>;
export type ProgressData = {
itemId: number;
@@ -99,13 +96,13 @@ type CanvasSessionContextValue = {
$selectedItem: Atom<S['SessionQueueItem'] | null>;
$selectedItemIndex: Atom<number | null>;
$selectedItemOutputImageDTO: Atom<ImageDTO | null>;
$autoSwitch: WritableAtom<AutoSwitchMode>;
selectNext: () => void;
selectPrev: () => void;
selectFirst: () => void;
selectLast: () => void;
onImageLoad: (itemId: number) => void;
discard: (itemId: number) => void;
discardAll: () => void;
};
const CanvasSessionContext = createContext<CanvasSessionContextValue | null>(null);
@@ -142,11 +139,6 @@ export const CanvasSessionContextProvider = memo(
*/
const $items = useState(() => atom<S['SessionQueueItem'][]>([]))[0];
/**
* Whether auto-switch is enabled.
*/
const $autoSwitch = useState(() => atom<AutoSwitchMode>('switch_on_start'))[0];
/**
* An internal flag used to work around race conditions with auto-switch switching to queue items before their
* output images have fully loaded.
@@ -228,25 +220,9 @@ export const CanvasSessionContextProvider = memo(
)[0];
/**
* A redux selector to select all queue items from the RTK Query cache. It's important that this returns stable
* references if possible to reduce re-renders. All derivations of the queue items (e.g. filtering out canceled
* items) should be done in a nanostores computed.
* A redux selector to select all queue items from the RTK Query cache.
*/
const selectQueueItems = useMemo(
() =>
createSelector(
[queueApi.endpoints.listAllQueueItems.select({ destination: session.id }), selectDiscardedItems],
({ data }, discardedItems) => {
if (!data) {
return EMPTY_ARRAY;
}
return data.filter(
({ status, item_id }) => status !== 'canceled' && status !== 'failed' && !discardedItems.includes(item_id)
);
}
),
[session.id]
);
const selectQueueItems = useMemo(() => buildSelectSessionQueueItems(session.id), [session.id]);
const discard = useCallback(
(itemId: number) => {
@@ -255,6 +231,10 @@ export const CanvasSessionContextProvider = memo(
[store]
);
const discardAll = useCallback(() => {
store.dispatch(canvasSessionReset());
}, [store]);
const selectNext = useCallback(() => {
const selectedItemId = $selectedItemId.get();
if (selectedItemId === null) {
@@ -316,12 +296,15 @@ export const CanvasSessionContextProvider = memo(
imageLoaded: true,
});
}
if ($lastCompletedItemId.get() === itemId && $autoSwitch.get() === 'switch_on_finish') {
if (
$lastCompletedItemId.get() === itemId &&
selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_finish'
) {
$selectedItemId.set(itemId);
$lastCompletedItemId.set(null);
}
},
[$autoSwitch, $lastCompletedItemId, $progressData, $selectedItemId]
[$lastCompletedItemId, $progressData, $selectedItemId, store]
);
// Set up socket listeners
@@ -356,7 +339,7 @@ export const CanvasSessionContextProvider = memo(
socket.off('invocation_progress', onProgress);
socket.off('queue_item_status_changed', onQueueItemStatusChanged);
};
}, [$autoSwitch, $lastCompletedItemId, $lastStartedItemId, $progressData, $selectedItemId, session.id, socket]);
}, [$lastCompletedItemId, $lastStartedItemId, $progressData, $selectedItemId, session.id, socket]);
// Set up state subscriptions and effects
useEffect(() => {
@@ -386,7 +369,7 @@ export const CanvasSessionContextProvider = memo(
$selectedItemId.set(items[0]?.item_id ?? null);
return;
} else if (
$autoSwitch.get() === 'switch_on_start' &&
selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_start' &&
items.findIndex(({ item_id }) => item_id === lastStartedItemId) !== -1
) {
$selectedItemId.set(lastStartedItemId);
@@ -489,7 +472,7 @@ export const CanvasSessionContextProvider = memo(
if (lastLoadedItemId === null) {
return;
}
if ($autoSwitch.get() === 'switch_on_finish') {
if (selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_finish') {
$selectedItemId.set(lastLoadedItemId);
}
$lastLoadedItemId.set(null);
@@ -501,6 +484,22 @@ export const CanvasSessionContextProvider = memo(
queueApi.endpoints.listAllQueueItems.initiate({ destination: session.id })
);
// const unsubListener = store.dispatch(
// addAppListener({
// matcher: queueApi.endpoints.cancelQueueItem.matchFulfilled,
// effect: ({ payload }, { getState }) => {
// const { item_id } = payload;
// const items = selectQueueItems(getState());
// if (items.length === 0) {
// $selectedItemId.set(null);
// } else if ($selectedItemId.get() === null) {
// $selectedItemId.set(items[0].item_id);
// }
// },
// })
// );
// Clean up all subscriptions and top-level (i.e. non-computed/derived state)
return () => {
unsubHandleAutoSwitch();
@@ -514,7 +513,6 @@ export const CanvasSessionContextProvider = memo(
};
}, [
$items,
$autoSwitch,
$lastLoadedItemId,
$lastStartedItemId,
$progressData,
@@ -532,7 +530,6 @@ export const CanvasSessionContextProvider = memo(
$isPending,
$progressData,
$selectedItemId,
$autoSwitch,
$selectedItem,
$selectedItemIndex,
$selectedItemOutputImageDTO,
@@ -543,9 +540,9 @@ export const CanvasSessionContextProvider = memo(
selectLast,
onImageLoad,
discard,
discardAll,
}),
[
$autoSwitch,
$items,
$hasItems,
$isPending,
@@ -562,6 +559,7 @@ export const CanvasSessionContextProvider = memo(
selectLast,
onImageLoad,
discard,
discardAll,
]
);

View File

@@ -13,7 +13,7 @@ export const getProgressMessage = (data?: S['InvocationProgressEvent'] | null) =
export const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))';
export const getQueueItemElementId = (itemId: number) => `queue-item-status-card-${itemId}`;
export const getQueueItemElementId = (index: number) => `queue-item-preview-${index}`;
export const getOutputImageName = (item: S['SessionQueueItem']) => {
const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) =>

View File

@@ -0,0 +1,50 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
selectStagingAreaAutoSwitch,
settingsStagingAreaAutoSwitchChanged,
} from 'features/controlLayers/store/canvasSettingsSlice';
import { memo, useCallback } from 'react';
import { PiCaretLineRightBold, PiCaretRightBold, PiMoonBold } from 'react-icons/pi';
export const StagingAreaAutoSwitchButtons = memo(() => {
const autoSwitch = useAppSelector(selectStagingAreaAutoSwitch);
const dispatch = useAppDispatch();
const onClickOff = useCallback(() => {
dispatch(settingsStagingAreaAutoSwitchChanged('off'));
}, [dispatch]);
const onClickSwitchOnStart = useCallback(() => {
dispatch(settingsStagingAreaAutoSwitchChanged('switch_on_start'));
}, [dispatch]);
const onClickSwitchOnFinished = useCallback(() => {
dispatch(settingsStagingAreaAutoSwitchChanged('switch_on_finish'));
}, [dispatch]);
return (
<>
<IconButton
aria-label="Do not auto-switch"
tooltip="Do not auto-switch"
icon={<PiMoonBold />}
colorScheme={autoSwitch === 'off' ? 'invokeBlue' : 'base'}
onClick={onClickOff}
/>
<IconButton
aria-label="Switch on start"
tooltip="Switch on start"
icon={<PiCaretRightBold />}
colorScheme={autoSwitch === 'switch_on_start' ? 'invokeBlue' : 'base'}
onClick={onClickSwitchOnStart}
/>
<IconButton
aria-label="Switch on finish"
tooltip="Switch on finish"
icon={<PiCaretLineRightBold />}
colorScheme={autoSwitch === 'switch_on_finish' ? 'invokeBlue' : 'base'}
onClick={onClickSwitchOnFinished}
/>
</>
);
});
StagingAreaAutoSwitchButtons.displayName = 'StagingAreaAutoSwitchButtons';

View File

@@ -1,7 +1,6 @@
import { ButtonGroup } from '@invoke-ai/ui-library';
import { ButtonGroup, Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared';
import { StagingAreaToolbarAcceptButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton';
import { StagingAreaToolbarDiscardAllButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton';
import { StagingAreaToolbarDiscardSelectedButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton';
@@ -12,27 +11,22 @@ import { StagingAreaToolbarPrevButton } from 'features/controlLayers/components/
import { StagingAreaToolbarSaveSelectedToGalleryButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton';
import { StagingAreaToolbarToggleShowResultsButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarToggleShowResultsButton';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { memo, useEffect } from 'react';
import { memo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { StagingAreaAutoSwitchButtons } from './StagingAreaAutoSwitchButtons';
export const StagingAreaToolbar = memo(() => {
const canvasManager = useCanvasManager();
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
const ctx = useCanvasSessionContext();
useEffect(() => {
return ctx.$selectedItemId.listen((id) => {
if (id !== null) {
document.getElementById(getQueueItemElementId(id))?.scrollIntoView();
}
});
}, [ctx.$selectedItemId]);
useHotkeys('meta+left', ctx.selectFirst, { preventDefault: true });
useHotkeys('meta+right', ctx.selectLast, { preventDefault: true });
return (
<>
<Flex gap={2}>
<ButtonGroup borderRadius="base" shadow="dark-lg">
<StagingAreaToolbarPrevButton isDisabled={!shouldShowStagedImage} />
<StagingAreaToolbarImageCountButton />
@@ -44,9 +38,14 @@ export const StagingAreaToolbar = memo(() => {
<StagingAreaToolbarSaveSelectedToGalleryButton />
<StagingAreaToolbarMenu />
<StagingAreaToolbarDiscardSelectedButton isDisabled={!shouldShowStagedImage} />
</ButtonGroup>
<ButtonGroup borderRadius="base" shadow="dark-lg">
<StagingAreaAutoSwitchButtons />
</ButtonGroup>
<ButtonGroup borderRadius="base" shadow="dark-lg">
<StagingAreaToolbarDiscardAllButton isDisabled={!shouldShowStagedImage} />
</ButtonGroup>
</>
</Flex>
);
});

View File

@@ -1,7 +1,5 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { canvasSessionReset, generateSessionReset } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useCancelQueueItemsByDestination } from 'features/queue/hooks/useCancelQueueItemsByDestination';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -9,21 +7,13 @@ import { PiTrashSimpleBold } from 'react-icons/pi';
export const StagingAreaToolbarDiscardAllButton = memo(({ isDisabled }: { isDisabled?: boolean }) => {
const ctx = useCanvasSessionContext();
const dispatch = useAppDispatch();
const { t } = useTranslation();
const cancelQueueItemsByDestination = useCancelQueueItemsByDestination();
const discardAll = useCallback(() => {
if (ctx.$isPending.get()) {
cancelQueueItemsByDestination.trigger(ctx.session.id, { withToast: false });
}
if (ctx.session.type === 'advanced') {
dispatch(canvasSessionReset());
} else {
// ctx.session.type === 'simple'
dispatch(generateSessionReset());
}
}, [cancelQueueItemsByDestination, ctx.$isPending, ctx.session.id, ctx.session.type, dispatch]);
ctx.discardAll();
cancelQueueItemsByDestination.trigger(ctx.session.id, { withToast: false });
}, [cancelQueueItemsByDestination, ctx]);
return (
<IconButton
@@ -32,7 +22,6 @@ export const StagingAreaToolbarDiscardAllButton = memo(({ isDisabled }: { isDisa
icon={<PiTrashSimpleBold />}
onClick={discardAll}
colorScheme="error"
fontSize={16}
isDisabled={isDisabled || cancelQueueItemsByDestination.isDisabled}
isLoading={cancelQueueItemsByDestination.isLoading}
/>

View File

@@ -1,15 +1,12 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch } from 'app/store/storeHooks';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { canvasSessionReset, generateSessionReset } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { isDisabled?: boolean }) => {
const dispatch = useAppDispatch();
const ctx = useCanvasSessionContext();
const cancelQueueItem = useCancelQueueItem();
const selectedItemId = useStore(ctx.$selectedItemId);
@@ -22,16 +19,7 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { i
}
ctx.discard(selectedItemId);
await cancelQueueItem.trigger(selectedItemId, { withToast: false });
const itemCount = ctx.$itemCount.get();
if (itemCount <= 1) {
if (ctx.session.type === 'advanced') {
dispatch(canvasSessionReset());
} else {
// ctx.session.type === 'simple'
dispatch(generateSessionReset());
}
}
}, [selectedItemId, ctx, cancelQueueItem, dispatch]);
}, [selectedItemId, ctx, cancelQueueItem]);
return (
<IconButton
@@ -40,7 +28,6 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { i
icon={<PiXBold />}
onClick={discardSelected}
colorScheme="invokeBlue"
fontSize={16}
isDisabled={selectedItemId === null || cancelQueueItem.isDisabled || isDisabled}
isLoading={cancelQueueItem.isLoading}
/>

View File

@@ -1,16 +1,13 @@
import { IconButton, Menu, MenuButton, MenuDivider, MenuList } from '@invoke-ai/ui-library';
import { StagingAreaToolbarMenuAutoSwitch } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch';
import { IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
import { StagingAreaToolbarNewLayerFromImageMenuItems } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage';
import { memo } from 'react';
import { PiDotsThreeBold } from 'react-icons/pi';
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
export const StagingAreaToolbarMenu = memo(() => {
return (
<Menu>
<MenuButton as={IconButton} icon={<PiDotsThreeBold />} colorScheme="invokeBlue" />
<MenuButton as={IconButton} icon={<PiDotsThreeVerticalBold />} colorScheme="invokeBlue" />
<MenuList>
<StagingAreaToolbarMenuAutoSwitch />
<MenuDivider />
<StagingAreaToolbarNewLayerFromImageMenuItems />
</MenuList>
</Menu>

View File

@@ -1,34 +0,0 @@
import { MenuItemOption, MenuOptionGroup } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { isAutoSwitchMode, useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { memo, useCallback } from 'react';
import { assert } from 'tsafe';
export const StagingAreaToolbarMenuAutoSwitch = memo(() => {
const ctx = useCanvasSessionContext();
const autoSwitch = useStore(ctx.$autoSwitch);
const onChange = useCallback(
(val: string | string[]) => {
assert(isAutoSwitchMode(val));
ctx.$autoSwitch.set(val);
},
[ctx.$autoSwitch]
);
return (
<MenuOptionGroup value={autoSwitch} onChange={onChange} title="Auto-Switch" type="radio">
<MenuItemOption value="off" closeOnSelect={false}>
Off
</MenuItemOption>
<MenuItemOption value="switch_on_start" closeOnSelect={false}>
Switch on Start
</MenuItemOption>
<MenuItemOption value="switch_on_finish" closeOnSelect={false}>
Switch on Finish
</MenuItemOption>
</MenuOptionGroup>
);
});
StagingAreaToolbarMenuAutoSwitch.displayName = 'StagingAreaToolbarMenuAutoSwitch';

View File

@@ -1,38 +1,41 @@
import type { PayloadAction, Selector } from '@reduxjs/toolkit';
import { createSelector, createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import type { RgbaColor } from 'features/controlLayers/store/types';
import { zRgbaColor } from 'features/controlLayers/store/types';
import { z } from 'zod/v4';
type CanvasSettingsState = {
const zAutoSwitchMode = z.enum(['off', 'switch_on_start', 'switch_on_finish']);
const zCanvasSettingsState = z.object({
/**
* Whether to show HUD (Heads-Up Display) on the canvas.
*/
showHUD: boolean;
showHUD: z.boolean().default(true),
/**
* Whether to clip lines and shapes to the generation bounding box. If disabled, lines and shapes will be clipped to
* the canvas bounds.
*/
clipToBbox: boolean;
clipToBbox: z.boolean().default(false),
/**
* Whether to show a dynamic grid on the canvas. If disabled, a checkerboard pattern will be shown instead.
*/
dynamicGrid: boolean;
dynamicGrid: z.boolean().default(false),
/**
* Whether to invert the scroll direction when adjusting the brush or eraser width with the scroll wheel.
*/
invertScrollForToolWidth: boolean;
invertScrollForToolWidth: z.boolean().default(false),
/**
* The width of the brush tool.
*/
brushWidth: number;
brushWidth: z.int().gt(0).default(50),
/**
* The width of the eraser tool.
*/
eraserWidth: number;
eraserWidth: z.int().gt(0).default(50),
/**
* The color to use when drawing lines or filling shapes.
*/
color: RgbaColor;
color: zRgbaColor.default({ r: 31, g: 160, b: 224, a: 1 }), // invokeBlue.500
/**
* Whether to composite inpainted/outpainted regions back onto the source image when saving canvas generations.
*
@@ -40,75 +43,61 @@ type CanvasSettingsState = {
*
* When `sendToCanvas` is disabled, this setting is ignored, masked regions will always be composited.
*/
outputOnlyMaskedRegions: boolean;
outputOnlyMaskedRegions: z.boolean().default(true),
/**
* Whether to automatically process the operations like filtering and auto-masking.
*/
autoProcess: boolean;
autoProcess: z.boolean().default(true),
/**
* The snap-to-grid setting for the canvas.
*/
snapToGrid: boolean;
snapToGrid: z.boolean().default(true),
/**
* Whether to show progress on the canvas when generating images.
*/
showProgressOnCanvas: boolean;
showProgressOnCanvas: z.boolean().default(true),
/**
* Whether to show the bounding box overlay on the canvas.
*/
bboxOverlay: boolean;
bboxOverlay: z.boolean().default(false),
/**
* Whether to preserve the masked region instead of inpainting it.
*/
preserveMask: boolean;
preserveMask: z.boolean().default(false),
/**
* Whether to show only raster layers while staging.
*/
isolatedStagingPreview: boolean;
isolatedStagingPreview: z.boolean().default(true),
/**
* Whether to show only the selected layer while filtering, transforming, or doing other operations.
*/
isolatedLayerPreview: boolean;
isolatedLayerPreview: z.boolean().default(true),
/**
* Whether to use pressure sensitivity for the brush and eraser tool when a pen device is used.
*/
pressureSensitivity: boolean;
pressureSensitivity: z.boolean().default(true),
/**
* Whether to show the rule of thirds composition guide overlay on the canvas.
*/
ruleOfThirds: boolean;
ruleOfThirds: z.boolean().default(false),
/**
* Whether to save all staging images to the gallery instead of keeping them as intermediate images.
*/
saveAllImagesToGallery: boolean;
};
saveAllImagesToGallery: z.boolean().default(false),
/**
* The auto-switch mode for the canvas staging area.
*/
stagingAreaAutoSwitch: zAutoSwitchMode.default('switch_on_start'),
});
const initialState: CanvasSettingsState = {
showHUD: true,
clipToBbox: false,
dynamicGrid: false,
brushWidth: 50,
eraserWidth: 50,
invertScrollForToolWidth: false,
color: { r: 31, g: 160, b: 224, a: 1 }, // invokeBlue.500
outputOnlyMaskedRegions: true,
autoProcess: true,
snapToGrid: true,
showProgressOnCanvas: true,
bboxOverlay: false,
preserveMask: false,
isolatedStagingPreview: true,
isolatedLayerPreview: true,
pressureSensitivity: true,
ruleOfThirds: false,
saveAllImagesToGallery: false,
};
type CanvasSettingsState = z.infer<typeof zCanvasSettingsState>;
const getInitialState = () => zCanvasSettingsState.parse({});
export const canvasSettingsSlice = createSlice({
name: 'canvasSettings',
initialState,
initialState: getInitialState(),
reducers: {
settingsClipToBboxChanged: (state, action: PayloadAction<boolean>) => {
settingsClipToBboxChanged: (state, action: PayloadAction<CanvasSettingsState['clipToBbox']>) => {
state.clipToBbox = action.payload;
},
settingsDynamicGridToggled: (state) => {
@@ -117,16 +106,19 @@ export const canvasSettingsSlice = createSlice({
settingsShowHUDToggled: (state) => {
state.showHUD = !state.showHUD;
},
settingsBrushWidthChanged: (state, action: PayloadAction<number>) => {
settingsBrushWidthChanged: (state, action: PayloadAction<CanvasSettingsState['brushWidth']>) => {
state.brushWidth = Math.round(action.payload);
},
settingsEraserWidthChanged: (state, action: PayloadAction<number>) => {
settingsEraserWidthChanged: (state, action: PayloadAction<CanvasSettingsState['eraserWidth']>) => {
state.eraserWidth = Math.round(action.payload);
},
settingsColorChanged: (state, action: PayloadAction<RgbaColor>) => {
settingsColorChanged: (state, action: PayloadAction<CanvasSettingsState['color']>) => {
state.color = action.payload;
},
settingsInvertScrollForToolWidthChanged: (state, action: PayloadAction<boolean>) => {
settingsInvertScrollForToolWidthChanged: (
state,
action: PayloadAction<CanvasSettingsState['invertScrollForToolWidth']>
) => {
state.invertScrollForToolWidth = action.payload;
},
settingsOutputOnlyMaskedRegionsToggled: (state) => {
@@ -162,6 +154,12 @@ export const canvasSettingsSlice = createSlice({
settingsSaveAllImagesToGalleryToggled: (state) => {
state.saveAllImagesToGallery = !state.saveAllImagesToGallery;
},
settingsStagingAreaAutoSwitchChanged: (
state,
action: PayloadAction<CanvasSettingsState['stagingAreaAutoSwitch']>
) => {
state.stagingAreaAutoSwitch = action.payload;
},
},
});
@@ -184,6 +182,7 @@ export const {
settingsPressureSensitivityToggled,
settingsRuleOfThirdsToggled,
settingsSaveAllImagesToGalleryToggled,
settingsStagingAreaAutoSwitchChanged,
} = canvasSettingsSlice.actions;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
@@ -193,7 +192,7 @@ const migrate = (state: any): any => {
export const canvasSettingsPersistConfig: PersistConfig<CanvasSettingsState> = {
name: canvasSettingsSlice.name,
initialState,
initialState: getInitialState(),
migrate,
persistDenylist: [],
};
@@ -219,3 +218,4 @@ export const selectIsolatedLayerPreview = createCanvasSettingsSelector((settings
export const selectPressureSensitivity = createCanvasSettingsSelector((settings) => settings.pressureSensitivity);
export const selectRuleOfThirds = createCanvasSettingsSelector((settings) => settings.ruleOfThirds);
export const selectSaveAllImagesToGallery = createCanvasSettingsSelector((settings) => settings.saveAllImagesToGallery);
export const selectStagingAreaAutoSwitch = createCanvasSettingsSelector((settings) => settings.stagingAreaAutoSwitch);

View File

@@ -1,7 +1,9 @@
import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { EMPTY_ARRAY } from 'app/store/constants';
import type { PersistConfig, RootState } from 'app/store/store';
import { deepClone } from 'common/util/deepClone';
import { canvasReset } from 'features/controlLayers/store/actions';
import { queueApi } from 'services/api/endpoints/queue';
type CanvasStagingAreaState = {
generateSessionId: string | null;
@@ -78,8 +80,34 @@ export const selectGenerateSessionId = createSelector(
selectCanvasSessionSlice,
({ generateSessionId }) => generateSessionId
);
export const selectIsStaging = createSelector(selectCanvasSessionId, (canvasSessionId) => canvasSessionId !== null);
export const selectDiscardedItems = createSelector(
export const buildSelectSessionQueueItems = (sessionId: string) =>
createSelector(
[queueApi.endpoints.listAllQueueItems.select({ destination: sessionId }), selectDiscardedItems],
({ data }, discardedItems) => {
if (!data) {
return EMPTY_ARRAY;
}
return data.filter(
({ status, item_id }) => status !== 'canceled' && status !== 'failed' && !discardedItems.includes(item_id)
);
}
);
export const selectIsStaging = (state: RootState) => {
const sessionId = selectCanvasSessionId(state);
if (!sessionId) {
return false;
}
const { data } = queueApi.endpoints.listAllQueueItems.select({ destination: sessionId })(state);
if (!data) {
return false;
}
const discardedItems = selectDiscardedItems(state);
return data.some(
({ status, item_id }) => status !== 'canceled' && status !== 'failed' && !discardedItems.includes(item_id)
);
};
const selectDiscardedItems = createSelector(
selectCanvasSessionSlice,
({ canvasDiscardedQueueItems }) => canvasDiscardedQueueItems
);

View File

@@ -98,7 +98,7 @@ const zRgbColor = z.object({
b: z.number().int().min(0).max(255),
});
export type RgbColor = z.infer<typeof zRgbColor>;
const zRgbaColor = zRgbColor.extend({
export const zRgbaColor = zRgbColor.extend({
a: z.number().min(0).max(1),
});
export type RgbaColor = z.infer<typeof zRgbaColor>;

View File

@@ -15,7 +15,6 @@ const sx = {
objectFit: 'contain',
maxW: 'full',
maxH: 'full',
borderRadius: 'base',
cursor: 'grab',
'&[data-is-dragging=true]': {
opacity: 0.3,

View File

@@ -1,25 +1,33 @@
import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { useImageActions } from 'features/gallery/hooks/useImageActions';
import { useRecallAll } from 'features/gallery/hooks/useRecallAll';
import { useRecallDimensions } from 'features/gallery/hooks/useRecallDimensions';
import { useRecallPrompts } from 'features/gallery/hooks/useRecallPrompts';
import { useRecallRemix } from 'features/gallery/hooks/useRecallRemix';
import { useRecallSeed } from 'features/gallery/hooks/useRecallSeed';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import {
PiArrowBendUpLeftBold,
PiArrowsCounterClockwiseBold,
PiAsteriskBold,
PiPaintBrushBold,
PiPlantBold,
PiQuotesBold,
PiRulerBold,
} from 'react-icons/pi';
export const ImageMenuItemMetadataRecallActions = memo(() => {
const { t } = useTranslation();
const imageDTO = useImageDTOContext();
const subMenu = useSubMenu();
const { recallAll, remix, recallSeed, recallPrompts, hasMetadata, hasSeed, hasPrompts, createAsPreset } =
useImageActions(imageDTO);
const imageDTO = useImageDTOContext();
const recallAll = useRecallAll(imageDTO);
const recallRemix = useRecallRemix(imageDTO);
const recallPrompts = useRecallPrompts(imageDTO);
const recallSeed = useRecallSeed(imageDTO);
const recallDimensions = useRecallDimensions(imageDTO);
return (
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiArrowBendUpLeftBold />}>
@@ -28,20 +36,24 @@ export const ImageMenuItemMetadataRecallActions = memo(() => {
<SubMenuButtonContent label={t('parameters.recallMetadata')} />
</MenuButton>
<MenuList {...subMenu.menuListProps}>
<MenuItem icon={<PiArrowsCounterClockwiseBold />} onClick={remix} isDisabled={!hasMetadata}>
<MenuItem
icon={<PiArrowsCounterClockwiseBold />}
onClick={recallRemix.recall}
isDisabled={!recallRemix.isEnabled}
>
{t('parameters.remixImage')}
</MenuItem>
<MenuItem icon={<PiQuotesBold />} onClick={recallPrompts} isDisabled={!hasPrompts}>
<MenuItem icon={<PiQuotesBold />} onClick={recallPrompts.recall} isDisabled={!recallPrompts.isEnabled}>
{t('parameters.usePrompt')}
</MenuItem>
<MenuItem icon={<PiPlantBold />} onClick={recallSeed} isDisabled={!hasSeed}>
<MenuItem icon={<PiPlantBold />} onClick={recallSeed.recall} isDisabled={!recallSeed.isEnabled}>
{t('parameters.useSeed')}
</MenuItem>
<MenuItem icon={<PiAsteriskBold />} onClick={recallAll} isDisabled={!hasMetadata}>
<MenuItem icon={<PiAsteriskBold />} onClick={recallAll.recall} isDisabled={!recallAll.isEnabled}>
{t('parameters.useAll')}
</MenuItem>
<MenuItem icon={<PiPaintBrushBold />} onClick={createAsPreset} isDisabled={!hasPrompts}>
{t('stylePresets.useForTemplate')}
<MenuItem icon={<PiRulerBold />} onClick={recallDimensions.recall} isDisabled={!recallDimensions.isEnabled}>
{t('parameters.useSize')}
</MenuItem>
</MenuList>
</Menu>

View File

@@ -0,0 +1,20 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { useCreateStylePresetFromMetadata } from 'features/gallery/hooks/useCreateStylePresetFromMetadata';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPaintBrushBold } from 'react-icons/pi';
export const ImageMenuItemUseAsPromptTemplate = memo(() => {
const { t } = useTranslation();
const imageDTO = useImageDTOContext();
const stylePreset = useCreateStylePresetFromMetadata(imageDTO);
return (
<MenuItem icon={<PiPaintBrushBold />} onClickCapture={stylePreset.create} isDisabled={!stylePreset.isEnabled}>
{t('stylePresets.useForTemplate')}
</MenuItem>
);
});
ImageMenuItemUseAsPromptTemplate.displayName = 'ImageMenuItemUseAsPromptTemplate';

View File

@@ -1,4 +1,5 @@
import { MenuDivider } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { IconMenuItemGroup } from 'common/components/IconMenuItem';
import { ImageMenuItemChangeBoard } from 'features/gallery/components/ImageContextMenu/ImageMenuItemChangeBoard';
import { ImageMenuItemCopy } from 'features/gallery/components/ImageContextMenu/ImageMenuItemCopy';
@@ -16,14 +17,19 @@ import { ImageMenuItemStarUnstar } from 'features/gallery/components/ImageContex
import { ImageMenuItemUseAsRefImage } from 'features/gallery/components/ImageContextMenu/ImageMenuItemUseAsRefImage';
import { ImageMenuItemUseForPromptGeneration } from 'features/gallery/components/ImageContextMenu/ImageMenuItemUseForPromptGeneration';
import { ImageDTOContextProvider } from 'features/gallery/contexts/ImageDTOContext';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo } from 'react';
import type { ImageDTO } from 'services/api/types';
import { ImageMenuItemUseAsPromptTemplate } from './ImageMenuItemUseAsPromptTemplate';
type SingleSelectionMenuItemsProps = {
imageDTO: ImageDTO;
};
const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) => {
const tab = useAppSelector(selectActiveTab);
return (
<ImageDTOContextProvider value={imageDTO}>
<IconMenuItemGroup>
@@ -36,13 +42,14 @@ const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) =
</IconMenuItemGroup>
<MenuDivider />
<ImageMenuItemLoadWorkflow />
<ImageMenuItemMetadataRecallActions />
{(tab === 'canvas' || tab === 'generate') && <ImageMenuItemMetadataRecallActions />}
<MenuDivider />
<ImageMenuItemSendToUpscale />
<ImageMenuItemUseForPromptGeneration />
<ImageMenuItemUseAsRefImage />
{(tab === 'canvas' || tab === 'generate') && <ImageMenuItemUseAsRefImage />}
<ImageMenuItemUseAsPromptTemplate />
<ImageMenuItemNewCanvasFromImageSubMenu />
<ImageMenuItemNewLayerFromImageSubMenu />
{tab === 'canvas' && <ImageMenuItemNewLayerFromImageSubMenu />}
<MenuDivider />
<ImageMenuItemChangeBoard />
<ImageMenuItemStarUnstar />

View File

@@ -85,7 +85,7 @@ const UnrecallableMetadataParsed = typedMemo(
return (
<Box as="span" lineHeight={1}>
<LabelComponent />
<LabelComponent i18nKey={handler.i18nKey} />
<ValueComponent value={data.value} />
</Box>
);
@@ -128,7 +128,7 @@ const SingleMetadataParsed = typedMemo(
onClick={onClick}
/>
<Box as="span" lineHeight={1}>
<LabelComponent />
<LabelComponent i18nKey={handler.i18nKey} />
<ValueComponent value={data.value} />
</Box>
</Flex>
@@ -178,7 +178,7 @@ const CollectionMetadataParsed = typedMemo(
onClick={onClick}
/>
<Box as="span" lineHeight={1}>
<LabelComponent />
<LabelComponent i18nKey={handler.i18nKey} />
<ValueComponent value={value} />
</Box>
</Flex>

View File

@@ -1,21 +1,19 @@
import { Button, Divider, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useAppSelector } from 'app/store/storeHooks';
import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems';
import { useImageActions } from 'features/gallery/hooks/useImageActions';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { newCanvasFromImage } from 'features/imageActions/actions';
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
import { useDeleteImage } from 'features/gallery/hooks/useDeleteImage';
import { useEditImage } from 'features/gallery/hooks/useEditImage';
import { useLoadWorkflow } from 'features/gallery/hooks/useLoadWorkflow';
import { useRecallAll } from 'features/gallery/hooks/useRecallAll';
import { useRecallDimensions } from 'features/gallery/hooks/useRecallDimensions';
import { useRecallPrompts } from 'features/gallery/hooks/useRecallPrompts';
import { useRecallRemix } from 'features/gallery/hooks/useRecallRemix';
import { useRecallSeed } from 'features/gallery/hooks/useRecallSeed';
import { PostProcessingPopover } from 'features/parameters/components/PostProcessing/PostProcessingPopover';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { toast } from 'features/toast/toast';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
import { selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
import { memo, useCallback } from 'react';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import {
PiArrowsCounterClockwiseBold,
@@ -27,51 +25,23 @@ import {
PiQuotesBold,
PiRulerBold,
} from 'react-icons/pi';
import { useImageDTO } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import { useImageViewerContext } from './context';
export const CurrentImageButtons = memo(() => {
export const CurrentImageButtons = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
const { t } = useTranslation();
const ctx = useImageViewerContext();
const hasProgressImage = useStore(ctx.$hasProgressImage);
const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
const isDisabledOverride = hasProgressImage && shouldShowProgressInViewer;
const tab = useAppSelector(selectActiveTab);
const isCanvasOrGenerateTab = tab === 'canvas' || tab === 'generate';
const imageName = useAppSelector(selectLastSelectedImage);
const imageDTO = useImageDTO(imageName);
const hasTemplates = useStore($hasTemplates);
const imageActions = useImageActions(imageDTO);
const isStaging = useAppSelector(selectIsStaging);
const isUpscalingEnabled = useFeatureStatus('upscaling');
const { getState, dispatch } = useAppStore();
const canvasManager = useCanvasManagerSafe();
const handleEdit = useCallback(async () => {
if (!imageDTO) {
return;
}
await newCanvasFromImage({
imageDTO,
type: 'raster_layer',
withInpaintMask: true,
getState,
dispatch,
});
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
// Automatically select the brush tool when editing an image
if (canvasManager) {
canvasManager.tool.$tool.set('brush');
}
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, getState, dispatch, t, canvasManager]);
const recallAll = useRecallAll(imageDTO);
const recallRemix = useRecallRemix(imageDTO);
const recallPrompts = useRecallPrompts(imageDTO);
const recallSeed = useRecallSeed(imageDTO);
const recallDimensions = useRecallDimensions(imageDTO);
const loadWorkflow = useLoadWorkflow(imageDTO);
const editImage = useEditImage(imageDTO);
const deleteImage = useDeleteImage(imageDTO);
return (
<>
@@ -80,7 +50,7 @@ export const CurrentImageButtons = memo(() => {
as={IconButton}
aria-label={t('parameters.imageActions')}
tooltip={t('parameters.imageActions')}
isDisabled={isDisabledOverride || !imageDTO}
isDisabled={!imageDTO}
variant="link"
alignSelf="stretch"
icon={<PiDotsThreeOutlineFill />}
@@ -92,8 +62,8 @@ export const CurrentImageButtons = memo(() => {
<Button
leftIcon={<PiPencilBold />}
onClick={handleEdit}
isDisabled={isDisabledOverride || !imageDTO}
onClick={editImage.edit}
isDisabled={!editImage.isEnabled}
variant="link"
size="sm"
alignSelf="stretch"
@@ -108,62 +78,72 @@ export const CurrentImageButtons = memo(() => {
icon={<PiFlowArrowBold />}
tooltip={`${t('nodes.loadWorkflow')} (W)`}
aria-label={`${t('nodes.loadWorkflow')} (W)`}
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasWorkflow || !hasTemplates}
isDisabled={!loadWorkflow.isEnabled}
variant="link"
alignSelf="stretch"
onClick={imageActions.loadWorkflow}
/>
<IconButton
icon={<PiArrowsCounterClockwiseBold />}
tooltip={`${t('parameters.remixImage')} (R)`}
aria-label={`${t('parameters.remixImage')} (R)`}
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasMetadata}
variant="link"
alignSelf="stretch"
onClick={imageActions.remix}
/>
<IconButton
icon={<PiQuotesBold />}
tooltip={`${t('parameters.usePrompt')} (P)`}
aria-label={`${t('parameters.usePrompt')} (P)`}
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasPrompts}
variant="link"
alignSelf="stretch"
onClick={imageActions.recallPrompts}
/>
<IconButton
icon={<PiPlantBold />}
tooltip={`${t('parameters.useSeed')} (S)`}
aria-label={`${t('parameters.useSeed')} (S)`}
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasSeed}
variant="link"
alignSelf="stretch"
onClick={imageActions.recallSeed}
/>
<IconButton
icon={<PiRulerBold />}
tooltip={`${t('parameters.useSize')} (D)`}
aria-label={`${t('parameters.useSize')} (D)`}
variant="link"
alignSelf="stretch"
onClick={imageActions.recallSize}
isDisabled={isDisabledOverride || !imageDTO || isStaging}
/>
<IconButton
icon={<PiAsteriskBold />}
tooltip={`${t('parameters.useAll')} (A)`}
aria-label={`${t('parameters.useAll')} (A)`}
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasMetadata}
variant="link"
alignSelf="stretch"
onClick={imageActions.recallAll}
onClick={loadWorkflow.load}
/>
{isCanvasOrGenerateTab && (
<IconButton
icon={<PiArrowsCounterClockwiseBold />}
tooltip={`${t('parameters.remixImage')} (R)`}
aria-label={`${t('parameters.remixImage')} (R)`}
isDisabled={!recallRemix.isEnabled}
variant="link"
alignSelf="stretch"
onClick={recallRemix.recall}
/>
)}
{isCanvasOrGenerateTab && (
<IconButton
icon={<PiQuotesBold />}
tooltip={`${t('parameters.usePrompt')} (P)`}
aria-label={`${t('parameters.usePrompt')} (P)`}
isDisabled={!recallPrompts.isEnabled}
variant="link"
alignSelf="stretch"
onClick={recallPrompts.recall}
/>
)}
{isCanvasOrGenerateTab && (
<IconButton
icon={<PiPlantBold />}
tooltip={`${t('parameters.useSeed')} (S)`}
aria-label={`${t('parameters.useSeed')} (S)`}
isDisabled={!recallSeed.isEnabled}
variant="link"
alignSelf="stretch"
onClick={recallSeed.recall}
/>
)}
{isCanvasOrGenerateTab && (
<IconButton
icon={<PiRulerBold />}
tooltip={`${t('parameters.useSize')} (D)`}
aria-label={`${t('parameters.useSize')} (D)`}
variant="link"
alignSelf="stretch"
onClick={recallDimensions.recall}
isDisabled={!recallDimensions.isEnabled}
/>
)}
{isCanvasOrGenerateTab && (
<IconButton
icon={<PiAsteriskBold />}
tooltip={`${t('parameters.useAll')} (A)`}
aria-label={`${t('parameters.useAll')} (A)`}
isDisabled={!recallAll.isEnabled}
variant="link"
alignSelf="stretch"
onClick={recallAll.recall}
/>
)}
{isUpscalingEnabled && <PostProcessingPopover imageDTO={imageDTO} isDisabled={isDisabledOverride} />}
{isUpscalingEnabled && <PostProcessingPopover imageDTO={imageDTO} isDisabled={false} />}
<Divider orientation="vertical" h={8} mx={2} />
<DeleteImageButton onClick={imageActions.delete} isDisabled={isDisabledOverride || !imageDTO} />
<DeleteImageButton onClick={deleteImage.delete} isDisabled={!deleteImage.isEnabled} />
</>
);
});

View File

@@ -50,7 +50,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu
>
{imageDTO && (
<Flex w="full" h="full" position="absolute" alignItems="center" justifyContent="center">
<DndImage imageDTO={imageDTO} onLoad={onLoadImage} />
<DndImage imageDTO={imageDTO} onLoad={onLoadImage} borderRadius="base" />
</Flex>
)}
{!imageDTO && <NoContentForViewer />}

View File

@@ -1,18 +1,24 @@
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { memo } from 'react';
import { useImageDTO } from 'services/api/endpoints/images';
import { CurrentImageButtons } from './CurrentImageButtons';
import { ToggleProgressButton } from './ToggleProgressButton';
export const ViewerToolbar = memo(() => {
const imageName = useAppSelector(selectLastSelectedImage);
const imageDTO = useImageDTO(imageName);
return (
<Flex w="full" justifyContent="center" h={8}>
<ToggleProgressButton />
<Spacer />
<CurrentImageButtons />
{imageDTO && <CurrentImageButtons imageDTO={imageDTO} />}
<Spacer />
<ToggleMetadataViewerButton />
{imageDTO && <ToggleMetadataViewerButton />}
</Flex>
);
});

View File

@@ -2,6 +2,7 @@ import { Box, Flex, forwardRef, Grid, GridItem, Spinner, Text } from '@invoke-ai
import { createSelector } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { getFocusedRegion } from 'common/hooks/focus';
import { useRangeBasedImageFetching } from 'features/gallery/hooks/useRangeBasedImageFetching';
import type { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors';
import {
@@ -221,6 +222,10 @@ const useKeyboardNavigation = (
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (getFocusedRegion() !== 'gallery') {
// Only handle keyboard navigation when the gallery is focused
return;
}
// Only handle arrow keys
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
return;
@@ -477,11 +482,6 @@ export const NewGallery = memo(() => {
const context = useMemo<GridContext>(() => ({ imageNames, queryArgs }), [imageNames, queryArgs]);
// Item content function
const itemContent: GridItemContent<string, GridContext> = useCallback((index, imageName) => {
return <ImageAtPosition index={index} imageName={imageName} />;
}, []);
if (isLoading) {
return (
<Flex w="full" h="full" alignItems="center" justifyContent="center" gap={4}>
@@ -506,7 +506,7 @@ export const NewGallery = memo(() => {
ref={virtuosoRef}
context={context}
data={imageNames}
increaseViewportBy={2048}
increaseViewportBy={4096}
itemContent={itemContent}
computeItemKey={computeItemKey}
components={components}
@@ -523,8 +523,12 @@ export const NewGallery = memo(() => {
NewGallery.displayName = 'NewGallery';
const scrollSeekConfiguration: ScrollSeekConfiguration = {
enter: (velocity) => velocity > 4096,
exit: (velocity) => velocity === 0,
enter: (velocity) => {
return Math.abs(velocity) > 2048;
},
exit: (velocity) => {
return velocity === 0;
},
};
// Styles
@@ -544,6 +548,10 @@ const ListComponent: GridComponents<GridContext>['List'] = forwardRef(({ context
});
ListComponent.displayName = 'ListComponent';
const itemContent: GridItemContent<string, GridContext> = (index, imageName) => {
return <ImageAtPosition index={index} imageName={imageName} />;
};
const ItemComponent: GridComponents<GridContext>['Item'] = forwardRef(({ context: _, ...rest }, ref) => (
<GridItem ref={ref} aspectRatio="1/1" {...rest} />
));

View File

@@ -0,0 +1,26 @@
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import {
activeStylePresetIdChanged,
selectStylePresetActivePresetId,
} from 'features/stylePresets/store/stylePresetSlice';
import { toast } from 'features/toast/toast';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const useClearStylePresetWithToast = () => {
const store = useAppStore();
const { t } = useTranslation();
const activeStylePresetId = useAppSelector(selectStylePresetActivePresetId);
const clearStylePreset = useCallback(() => {
if (activeStylePresetId) {
store.dispatch(activeStylePresetIdChanged(null));
toast({
status: 'info',
title: t('stylePresets.promptTemplateCleared'),
});
}
}, [activeStylePresetId, store, t]);
return clearStylePreset;
};

View File

@@ -0,0 +1,81 @@
import { useAppStore } from 'app/store/storeHooks';
import { MetadataHandlers, MetadataUtils } from 'features/metadata/parsing';
import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
import type { ImageDTO } from 'services/api/types';
export const useCreateStylePresetFromMetadata = (imageDTO?: ImageDTO | null) => {
const store = useAppStore();
const [hasPrompts, setHasPrompts] = useState(false);
const { metadata } = useDebouncedMetadata(imageDTO?.image_name);
useEffect(() => {
MetadataUtils.hasMetadataByHandlers({
handlers: [MetadataHandlers.PositivePrompt, MetadataHandlers.NegativePrompt],
metadata,
store,
require: 'some',
})
.then((result) => {
setHasPrompts(result);
})
.catch(() => {
setHasPrompts(false);
});
}, [metadata, store]);
const isEnabled = useMemo(() => {
if (!imageDTO) {
return false;
}
if (!hasPrompts) {
return false;
}
return true;
}, [hasPrompts, imageDTO]);
const create = useCallback(async () => {
if (!imageDTO) {
return;
}
if (!metadata) {
return;
}
if (!isEnabled) {
return;
}
let positivePrompt: string;
let negativePrompt: string;
try {
positivePrompt = await MetadataHandlers.PositivePrompt.parse(metadata, store);
} catch (error) {
positivePrompt = '';
}
try {
negativePrompt = (await MetadataHandlers.NegativePrompt.parse(metadata, store)) ?? '';
} catch (error) {
negativePrompt = '';
}
$stylePresetModalState.set({
prefilledFormData: {
name: '',
positivePrompt,
negativePrompt,
imageUrl: imageDTO.image_url,
type: 'user',
},
updatingStylePresetId: null,
isModalOpen: true,
});
}, [imageDTO, isEnabled, metadata, store]);
return {
create,
isEnabled,
};
};

View File

@@ -0,0 +1,28 @@
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
import { useCallback, useMemo } from 'react';
import type { ImageDTO } from 'services/api/types';
export const useDeleteImage = (imageDTO?: ImageDTO | null) => {
const deleteImageModal = useDeleteImageModalApi();
const isEnabled = useMemo(() => {
if (!imageDTO) {
return;
}
return true;
}, [imageDTO]);
const _delete = useCallback(() => {
if (!imageDTO) {
return;
}
if (!isEnabled) {
return;
}
deleteImageModal.delete([imageDTO.image_name]);
}, [deleteImageModal, imageDTO, isEnabled]);
return {
delete: _delete,
isEnabled,
};
};

View File

@@ -0,0 +1,57 @@
import { useAppStore } from 'app/store/storeHooks';
import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { newCanvasFromImage } from 'features/imageActions/actions';
import { toast } from 'features/toast/toast';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ImageDTO } from 'services/api/types';
export const useEditImage = (imageDTO?: ImageDTO | null) => {
const { t } = useTranslation();
const { getState, dispatch } = useAppStore();
const canvasManager = useCanvasManagerSafe();
const isEnabled = useMemo(() => {
if (!imageDTO) {
return false;
}
return true;
}, [imageDTO]);
const edit = useCallback(async () => {
if (!imageDTO) {
return;
}
if (!isEnabled) {
return;
}
await newCanvasFromImage({
imageDTO,
type: 'raster_layer',
withInpaintMask: true,
getState,
dispatch,
});
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
if (canvasManager) {
canvasManager.tool.$tool.set('brush');
}
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, isEnabled, getState, dispatch, canvasManager, t]);
return {
edit,
isEnabled,
};
};

View File

@@ -1,209 +0,0 @@
import { useStore } from '@nanostores/react';
import { adHocPostProcessingRequested } from 'app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
import { MetadataHandlers, MetadataUtils } from 'features/metadata/parsing';
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal';
import {
activeStylePresetIdChanged,
selectStylePresetActivePresetId,
} from 'features/stylePresets/store/stylePresetSlice';
import { toast } from 'features/toast/toast';
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
import type { ImageDTO } from 'services/api/types';
export const useImageActions = (imageDTO: ImageDTO | null) => {
const store = useAppStore();
const { t } = useTranslation();
const activeStylePresetId = useAppSelector(selectStylePresetActivePresetId);
const isStaging = useAppSelector(selectIsStaging);
const { metadata } = useDebouncedMetadata(imageDTO?.image_name ?? null);
const [hasMetadata, setHasMetadata] = useState(false);
const [hasSeed, setHasSeed] = useState(false);
const [hasPrompts, setHasPrompts] = useState(false);
const hasTemplates = useStore($hasTemplates);
const deleteImageModal = useDeleteImageModalApi();
useEffect(() => {
const parseMetadata = async () => {
if (metadata) {
setHasMetadata(true);
try {
await MetadataHandlers.Seed.parse(metadata, store);
setHasSeed(true);
} catch {
setHasSeed(false);
}
let hasPrompt = false;
// Need to catch all of these to avoid unhandled promise rejections bubbling up to instrumented error handlers
for (const handler of [
MetadataHandlers.PositivePrompt,
MetadataHandlers.NegativePrompt,
MetadataHandlers.PositiveStylePrompt,
MetadataHandlers.NegativeStylePrompt,
]) {
try {
await handler.parse(metadata, store);
hasPrompt = true;
break;
} catch {
// noop
}
}
setHasPrompts(hasPrompt);
} else {
setHasMetadata(false);
setHasSeed(false);
setHasPrompts(false);
}
};
parseMetadata();
}, [metadata, store]);
const clearStylePreset = useCallback(() => {
if (activeStylePresetId) {
store.dispatch(activeStylePresetIdChanged(null));
toast({
status: 'info',
title: t('stylePresets.promptTemplateCleared'),
});
}
}, [activeStylePresetId, store, t]);
const recallAll = useCallback(() => {
if (!imageDTO) {
return;
}
if (!metadata) {
return;
}
MetadataUtils.recallAll(metadata, store, isStaging ? [MetadataHandlers.Width, MetadataHandlers.Height] : []);
clearStylePreset();
}, [imageDTO, metadata, store, isStaging, clearStylePreset]);
const remix = useCallback(() => {
if (!imageDTO) {
return;
}
if (!metadata) {
return;
}
// Recalls all metadata parameters except seed
MetadataUtils.recallAll(metadata, store, [MetadataHandlers.Seed]);
clearStylePreset();
}, [imageDTO, metadata, store, clearStylePreset]);
const recallSeed = useCallback(() => {
if (!imageDTO) {
return;
}
if (!metadata) {
return;
}
MetadataUtils.recallByHandler({ metadata, store, handler: MetadataHandlers.Seed });
}, [imageDTO, metadata, store]);
const recallPrompts = useCallback(() => {
if (!imageDTO) {
return;
}
if (!metadata) {
return;
}
MetadataUtils.recallPrompts(metadata, store);
clearStylePreset();
}, [imageDTO, metadata, store, clearStylePreset]);
const createAsPreset = useCallback(async () => {
if (!imageDTO) {
return;
}
if (!metadata) {
return;
}
let positivePrompt: string;
let negativePrompt: string;
try {
positivePrompt = await MetadataHandlers.PositivePrompt.parse(metadata, store);
} catch (error) {
positivePrompt = '';
}
try {
negativePrompt = (await MetadataHandlers.NegativePrompt.parse(metadata, store)) ?? '';
} catch (error) {
negativePrompt = '';
}
$stylePresetModalState.set({
prefilledFormData: {
name: '',
positivePrompt,
negativePrompt,
imageUrl: imageDTO.image_url,
type: 'user',
},
updatingStylePresetId: null,
isModalOpen: true,
});
}, [imageDTO, metadata, store]);
const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
const loadWorkflowFromImage = useCallback(() => {
if (!imageDTO) {
return;
}
if (!imageDTO.has_workflow || !hasTemplates) {
return;
}
loadWorkflowWithDialog({ type: 'image', data: imageDTO.image_name });
}, [hasTemplates, imageDTO, loadWorkflowWithDialog]);
const recallSize = useCallback(() => {
if (!imageDTO) {
return;
}
if (isStaging) {
return;
}
MetadataUtils.recallDimensions(imageDTO, store);
}, [imageDTO, isStaging, store]);
const upscale = useCallback(() => {
if (!imageDTO) {
return;
}
store.dispatch(adHocPostProcessingRequested({ imageDTO }));
}, [imageDTO, store]);
const _delete = useCallback(() => {
if (!imageDTO) {
return;
}
deleteImageModal.delete([imageDTO.image_name]);
}, [deleteImageModal, imageDTO]);
return {
hasMetadata,
hasSeed,
hasPrompts,
recallAll,
remix,
recallSeed,
recallPrompts,
createAsPreset,
loadWorkflow: loadWorkflowFromImage,
hasWorkflow: imageDTO?.has_workflow ?? false,
recallSize,
upscale,
delete: _delete,
};
};

View File

@@ -0,0 +1,34 @@
import { useStore } from '@nanostores/react';
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import { useCallback, useMemo } from 'react';
import type { ImageDTO } from 'services/api/types';
export const useLoadWorkflow = (imageDTO: ImageDTO) => {
const hasTemplates = useStore($hasTemplates);
const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
const isEnabled = useMemo(() => {
if (!imageDTO.has_workflow) {
return false;
}
if (!hasTemplates) {
return false;
}
return true;
}, [hasTemplates, imageDTO]);
const load = useCallback(() => {
if (!imageDTO) {
return;
}
if (!isEnabled) {
return;
}
loadWorkflowWithDialog({ type: 'image', data: imageDTO.image_name });
}, [imageDTO, isEnabled, loadWorkflowWithDialog]);
return { load, isEnabled };
};

View File

@@ -1,5 +1,5 @@
import { useAppStore } from 'app/store/storeHooks';
import { useCallback } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { ListRange } from 'react-virtuoso';
import { imagesApi, useGetImageDTOsByNamesMutation } from 'services/api/endpoints/images';
import { useThrottledCallback } from 'use-debounce';
@@ -13,33 +13,20 @@ interface UseRangeBasedImageFetchingReturn {
onRangeChanged: (range: ListRange) => void;
}
const getUncachedNames = (imageNames: string[], cachedImageNames: string[], range: ListRange): string[] => {
if (range.startIndex === range.endIndex) {
// If the start and end indices are the same, no range to fetch
return [];
}
const getUncachedNames = (imageNames: string[], cachedImageNames: string[], ranges: ListRange[]): string[] => {
const uncachedNamesSet = new Set<string>();
const cachedImageNamesSet = new Set(cachedImageNames);
if (imageNames.length === 0) {
return [];
}
const start = Math.max(0, range.startIndex);
const end = Math.min(imageNames.length - 1, range.endIndex);
if (cachedImageNames.length === 0) {
return imageNames.slice(start, end + 1);
}
const uncachedNames: string[] = [];
for (let i = start; i <= end; i++) {
const imageName = imageNames[i]!;
if (!cachedImageNames.includes(imageName)) {
uncachedNames.push(imageName);
for (const range of ranges) {
for (let i = range.startIndex; i <= range.endIndex; i++) {
const n = imageNames[i]!;
if (n && !cachedImageNamesSet.has(n)) {
uncachedNamesSet.add(n);
}
}
}
return uncachedNames;
return Array.from(uncachedNamesSet);
};
/**
@@ -53,30 +40,36 @@ export const useRangeBasedImageFetching = ({
}: UseRangeBasedImageFetchingArgs): UseRangeBasedImageFetchingReturn => {
const store = useAppStore();
const [getImageDTOsByNames] = useGetImageDTOsByNamesMutation();
const [lastRange, setLastRange] = useState<ListRange | null>(null);
const [pendingRanges, setPendingRanges] = useState<ListRange[]>([]);
const fetchImages = useCallback(
(visibleRange: ListRange) => {
(ranges: ListRange[], imageNames: string[]) => {
if (!enabled) {
return;
}
const cachedImageNames = imagesApi.util.selectCachedArgsForQuery(store.getState(), 'getImageDTO');
const uncachedNames = getUncachedNames(imageNames, cachedImageNames, visibleRange);
const uncachedNames = getUncachedNames(imageNames, cachedImageNames, ranges);
if (uncachedNames.length === 0) {
return;
}
getImageDTOsByNames({ image_names: uncachedNames });
setPendingRanges([]);
},
[enabled, getImageDTOsByNames, imageNames, store]
[enabled, getImageDTOsByNames, store]
);
const throttledFetchImages = useThrottledCallback(fetchImages, 100);
const throttledFetchImages = useThrottledCallback(fetchImages, 500);
const onRangeChanged = useCallback(
(range: ListRange) => {
throttledFetchImages(range);
},
[throttledFetchImages]
);
const onRangeChanged = useCallback((range: ListRange) => {
setLastRange(range);
setPendingRanges((prev) => [...prev, range]);
}, []);
useEffect(() => {
const combinedRanges = lastRange ? [...pendingRanges, lastRange] : pendingRanges;
throttledFetchImages(combinedRanges, imageNames);
}, [imageNames, lastRange, pendingRanges, throttledFetchImages]);
return {
onRangeChanged,

View File

@@ -0,0 +1,57 @@
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { MetadataHandlers, MetadataUtils } from 'features/metadata/parsing';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { useCallback, useMemo } from 'react';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
import type { ImageDTO } from 'services/api/types';
import { useClearStylePresetWithToast } from './useClearStylePresetWithToast';
export const useRecallAll = (imageDTO: ImageDTO) => {
const store = useAppStore();
const tab = useAppSelector(selectActiveTab);
const { metadata, isLoading } = useDebouncedMetadata(imageDTO.image_name);
const isStaging = useAppSelector(selectIsStaging);
const clearStylePreset = useClearStylePresetWithToast();
const isEnabled = useMemo(() => {
if (isLoading) {
return false;
}
if (tab !== 'canvas' && tab !== 'generate') {
return false;
}
if (!metadata) {
return false;
}
return true;
}, [isLoading, metadata, tab]);
const handlersToSkip = useMemo(() => {
if (tab === 'canvas' && isStaging) {
// When we are staging and on canvas, the bbox is locked - we cannot recall width and height
return [MetadataHandlers.Width, MetadataHandlers.Height];
}
return undefined;
}, [isStaging, tab]);
const recall = useCallback(() => {
if (!metadata) {
return;
}
if (!isEnabled) {
return;
}
MetadataUtils.recallAll(metadata, store, handlersToSkip);
clearStylePreset();
}, [metadata, isEnabled, store, handlersToSkip, clearStylePreset]);
return {
recall,
isEnabled,
};
};

View File

@@ -0,0 +1,36 @@
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { MetadataUtils } from 'features/metadata/parsing';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { useCallback, useMemo } from 'react';
import type { ImageDTO } from 'services/api/types';
export const useRecallDimensions = (imageDTO: ImageDTO) => {
const store = useAppStore();
const tab = useAppSelector(selectActiveTab);
const isStaging = useAppSelector(selectIsStaging);
const isEnabled = useMemo(() => {
if (tab !== 'canvas' && tab !== 'generate') {
return false;
}
if (tab === 'canvas' && isStaging) {
return false;
}
return true;
}, [isStaging, tab]);
const recall = useCallback(() => {
if (!isEnabled) {
return;
}
MetadataUtils.recallDimensions(imageDTO, store);
}, [isEnabled, imageDTO, store]);
return {
recall,
isEnabled,
};
};

View File

@@ -0,0 +1,72 @@
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { MetadataHandlers, MetadataUtils } from 'features/metadata/parsing';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
import type { ImageDTO } from 'services/api/types';
import { useClearStylePresetWithToast } from './useClearStylePresetWithToast';
export const useRecallPrompts = (imageDTO: ImageDTO) => {
const store = useAppStore();
const tab = useAppSelector(selectActiveTab);
const clearStylePreset = useClearStylePresetWithToast();
const [hasPrompts, setHasPrompts] = useState(false);
const { metadata, isLoading } = useDebouncedMetadata(imageDTO.image_name);
useEffect(() => {
const parse = async () => {
try {
const result = await MetadataUtils.hasMetadataByHandlers({
handlers: [
MetadataHandlers.PositivePrompt,
MetadataHandlers.NegativePrompt,
MetadataHandlers.PositiveStylePrompt,
MetadataHandlers.NegativeStylePrompt,
],
metadata,
store,
require: 'some',
});
setHasPrompts(result);
} catch {
setHasPrompts(false);
}
};
parse();
}, [metadata, store]);
const isEnabled = useMemo(() => {
if (isLoading) {
return false;
}
if (tab !== 'canvas' && tab !== 'generate') {
return false;
}
if (!hasPrompts) {
return false;
}
return true;
}, [hasPrompts, isLoading, tab]);
const recall = useCallback(() => {
if (!metadata) {
return;
}
if (!isEnabled) {
return;
}
MetadataUtils.recallPrompts(metadata, store);
clearStylePreset();
}, [metadata, isEnabled, store, clearStylePreset]);
return {
recall,
isEnabled,
};
};

View File

@@ -0,0 +1,60 @@
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { MetadataHandlers, MetadataUtils } from 'features/metadata/parsing';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { useCallback, useMemo } from 'react';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
import type { ImageDTO } from 'services/api/types';
import { useClearStylePresetWithToast } from './useClearStylePresetWithToast';
export const useRecallRemix = (imageDTO: ImageDTO) => {
const store = useAppStore();
const tab = useAppSelector(selectActiveTab);
const isStaging = useAppSelector(selectIsStaging);
const clearStylePreset = useClearStylePresetWithToast();
const { metadata, isLoading } = useDebouncedMetadata(imageDTO.image_name);
const isEnabled = useMemo(() => {
if (isLoading) {
return false;
}
if (tab !== 'canvas' && tab !== 'generate') {
return false;
}
if (!metadata) {
return false;
}
return true;
}, [isLoading, metadata, tab]);
const handlersToSkip = useMemo(() => {
// Remix always skips the seed handler
const _handlersToSkip = [MetadataHandlers.Seed];
if (tab === 'canvas' && isStaging) {
// When we are staging and on canvas, the bbox is locked - we cannot recall width and height
_handlersToSkip.push(MetadataHandlers.Width, MetadataHandlers.Height);
}
return _handlersToSkip;
}, [isStaging, tab]);
const recall = useCallback(() => {
if (!metadata) {
return;
}
if (!isEnabled) {
return;
}
MetadataUtils.recallAll(metadata, store, handlersToSkip);
clearStylePreset();
}, [metadata, isEnabled, store, handlersToSkip, clearStylePreset]);
return {
recall,
isEnabled,
};
};

View File

@@ -0,0 +1,62 @@
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { MetadataHandlers, MetadataUtils } from 'features/metadata/parsing';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
import type { ImageDTO } from 'services/api/types';
export const useRecallSeed = (imageDTO: ImageDTO) => {
const store = useAppStore();
const tab = useAppSelector(selectActiveTab);
const [hasSeed, setHasSeed] = useState(false);
const { metadata, isLoading } = useDebouncedMetadata(imageDTO.image_name);
useEffect(() => {
const parse = async () => {
try {
await MetadataHandlers.Seed.parse(metadata, store);
setHasSeed(true);
} catch {
setHasSeed(false);
}
};
parse();
}, [metadata, store]);
const isEnabled = useMemo(() => {
if (isLoading) {
return false;
}
if (tab !== 'canvas' && tab !== 'generate') {
return false;
}
if (!metadata) {
return false;
}
if (!hasSeed) {
return false;
}
return true;
}, [hasSeed, isLoading, metadata, tab]);
const recall = useCallback(() => {
if (!metadata) {
return;
}
if (!isEnabled) {
return;
}
MetadataUtils.recallByHandler({ metadata, handler: MetadataHandlers.Seed, store });
}, [metadata, isEnabled, store]);
return {
recall,
isEnabled,
};
};

View File

@@ -8,6 +8,7 @@ import { getPrefixedId } from 'features/controlLayers/konva/util';
import { bboxHeightChanged, bboxWidthChanged, canvasMetadataRecalled } from 'features/controlLayers/store/canvasSlice';
import { loraAllDeleted, loraRecalled } from 'features/controlLayers/store/lorasSlice';
import {
heightChanged,
negativePrompt2Changed,
negativePromptChanged,
positivePrompt2Changed,
@@ -31,6 +32,7 @@ import {
setSteps,
shouldConcatPromptsChanged,
vaeSelected,
widthChanged,
} from 'features/controlLayers/store/paramsSlice';
import { refImagesRecalled } from 'features/controlLayers/store/refImagesSlice';
import type { CanvasMetadata, LoRA, RefImageState } from 'features/controlLayers/store/types';
@@ -82,8 +84,9 @@ import {
zParameterStrength,
} from 'features/parameters/types/parameterSchemas';
import { toast } from 'features/toast/toast';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { t } from 'i18next';
import type { ComponentType, ReactNode } from 'react';
import type { ComponentType } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { modelsApi } from 'services/api/endpoints/models';
@@ -170,7 +173,8 @@ export type SingleMetadataHandler<T> = {
type: string;
parse: (metadata: unknown, store: AppStore) => Promise<T>;
recall: (value: T, store: AppStore) => void;
LabelComponent: ComponentType;
i18nKey: string;
LabelComponent: ComponentType<{ i18nKey: string }>;
ValueComponent: ComponentType<SingleMetadataValueProps<T>>;
};
@@ -184,7 +188,8 @@ export type CollectionMetadataHandler<T extends any[]> = {
parse: (metadata: unknown, store: AppStore) => Promise<T>;
recall: (values: T, store: AppStore) => void;
recallOne: (value: T[number], store: AppStore) => void;
LabelComponent: ComponentType;
i18nKey: string;
LabelComponent: ComponentType<{ i18nKey: string }>;
ValueComponent: ComponentType<CollectionMetadataValueProps<T>>;
};
@@ -196,7 +201,8 @@ export type UnrecallableMetadataHandler<T> = {
[UnrecallableMetadataKey]: true;
type: string;
parse: (metadata: unknown, store: AppStore) => Promise<T>;
LabelComponent: ComponentType;
i18nKey: string;
LabelComponent: ComponentType<{ i18nKey: string }>;
ValueComponent: ComponentType<UnrecallableMetadataValueProps<T>>;
};
@@ -221,7 +227,8 @@ const CreatedBy: UnrecallableMetadataHandler<string> = {
const parsed = z.string().parse(raw);
return Promise.resolve(parsed);
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.createdBy" />,
i18nKey: 'metadata.createdBy',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: UnrecallableMetadataValueProps<string>) => <MetadataPrimitiveValue value={value} />,
};
//#endregion Created By
@@ -235,7 +242,8 @@ const GenerationMode: UnrecallableMetadataHandler<string> = {
const parsed = z.string().parse(raw);
return Promise.resolve(parsed);
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.generationMode" />,
i18nKey: 'metadata.generationMode',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: UnrecallableMetadataValueProps<string>) => <MetadataPrimitiveValue value={value} />,
};
//#endregion Generation Mode
@@ -252,7 +260,8 @@ const PositivePrompt: SingleMetadataHandler<ParameterPositivePrompt> = {
recall: (value, store) => {
store.dispatch(positivePromptChanged(value));
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.positivePrompt" />,
i18nKey: 'metadata.positivePrompt',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterPositivePrompt>) => (
<MetadataPrimitiveValue value={value} />
),
@@ -271,7 +280,8 @@ const NegativePrompt: SingleMetadataHandler<ParameterNegativePrompt> = {
recall: (value, store) => {
store.dispatch(negativePromptChanged(value || null));
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.negativePrompt" />,
i18nKey: 'metadata.negativePrompt',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterNegativePrompt>) => (
<MetadataPrimitiveValue value={value} />
),
@@ -290,7 +300,8 @@ const PositiveStylePrompt: SingleMetadataHandler<ParameterPositiveStylePromptSDX
recall: (value, store) => {
store.dispatch(positivePrompt2Changed(value));
},
LabelComponent: () => <MetadataLabel i18nKey="sdxl.posStylePrompt" />,
i18nKey: 'sdxl.posStylePrompt',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterPositiveStylePromptSDXL>) => (
<MetadataPrimitiveValue value={value} />
),
@@ -309,7 +320,8 @@ const NegativeStylePrompt: SingleMetadataHandler<ParameterPositiveStylePromptSDX
recall: (value, store) => {
store.dispatch(negativePrompt2Changed(value));
},
LabelComponent: () => <MetadataLabel i18nKey="sdxl.negStylePrompt" />,
i18nKey: 'sdxl.negStylePrompt',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterPositiveStylePromptSDXL>) => (
<MetadataPrimitiveValue value={value} />
),
@@ -328,7 +340,8 @@ const CFGScale: SingleMetadataHandler<ParameterCFGScale> = {
recall: (value, store) => {
store.dispatch(setCfgScale(value));
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.cfgScale" />,
i18nKey: 'metadata.cfgScale',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterCFGScale>) => <MetadataPrimitiveValue value={value} />,
};
//#endregion CFG Scale
@@ -345,7 +358,8 @@ const CFGRescaleMultiplier: SingleMetadataHandler<ParameterCFGRescaleMultiplier>
recall: (value, store) => {
store.dispatch(setCfgRescaleMultiplier(value));
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.cfgRescaleMultiplier" />,
i18nKey: 'metadata.cfgRescaleMultiplier',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterCFGRescaleMultiplier>) => (
<MetadataPrimitiveValue value={value} />
),
@@ -364,7 +378,8 @@ const Guidance: SingleMetadataHandler<ParameterGuidance> = {
recall: (value, store) => {
store.dispatch(setGuidance(value));
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.guidance" />,
i18nKey: 'metadata.guidance',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterGuidance>) => <MetadataPrimitiveValue value={value} />,
};
//#endregion Guidance
@@ -381,7 +396,8 @@ const Scheduler: SingleMetadataHandler<ParameterScheduler> = {
recall: (value, store) => {
store.dispatch(setScheduler(value));
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.scheduler" />,
i18nKey: 'metadata.scheduler',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterScheduler>) => <MetadataPrimitiveValue value={value} />,
};
//#endregion Scheduler
@@ -396,9 +412,15 @@ const Width: SingleMetadataHandler<ParameterWidth> = {
return Promise.resolve(parsed);
},
recall: (value, store) => {
store.dispatch(bboxWidthChanged({ width: value, updateAspectRatio: true, clamp: true }));
const activeTab = selectActiveTab(store.getState());
if (activeTab === 'canvas') {
store.dispatch(bboxWidthChanged({ width: value, updateAspectRatio: true, clamp: true }));
} else if (activeTab === 'generate') {
store.dispatch(widthChanged({ width: value, updateAspectRatio: true, clamp: true }));
}
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.width" />,
i18nKey: 'metadata.width',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterWidth>) => <MetadataPrimitiveValue value={value} />,
};
//#endregion Width
@@ -413,9 +435,15 @@ const Height: SingleMetadataHandler<ParameterHeight> = {
return Promise.resolve(parsed);
},
recall: (value, store) => {
store.dispatch(bboxHeightChanged({ height: value, updateAspectRatio: true, clamp: true }));
const activeTab = selectActiveTab(store.getState());
if (activeTab === 'canvas') {
store.dispatch(bboxHeightChanged({ height: value, updateAspectRatio: true, clamp: true }));
} else if (activeTab === 'generate') {
store.dispatch(heightChanged({ height: value, updateAspectRatio: true, clamp: true }));
}
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.height" />,
i18nKey: 'metadata.height',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterHeight>) => <MetadataPrimitiveValue value={value} />,
};
//#endregion Height
@@ -432,7 +460,8 @@ const Seed: SingleMetadataHandler<ParameterSeed> = {
recall: (value, store) => {
store.dispatch(setSeed(value));
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.seed" />,
i18nKey: 'metadata.seed',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSeed>) => <MetadataPrimitiveValue value={value} />,
};
//#endregion Seed
@@ -449,7 +478,8 @@ const Steps: SingleMetadataHandler<ParameterSteps> = {
recall: (value, store) => {
store.dispatch(setSteps(value));
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.steps" />,
i18nKey: 'metadata.steps',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSteps>) => <MetadataPrimitiveValue value={value} />,
};
//#endregion Steps
@@ -466,7 +496,8 @@ const DenoisingStrength: SingleMetadataHandler<ParameterStrength> = {
recall: (value, store) => {
store.dispatch(setImg2imgStrength(value));
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.strength" />,
i18nKey: 'metadata.strength',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterStrength>) => <MetadataPrimitiveValue value={value} />,
};
//#endregion DenoisingStrength
@@ -483,7 +514,8 @@ const SeamlessX: SingleMetadataHandler<ParameterSeamlessX> = {
recall: (value, store) => {
store.dispatch(setSeamlessXAxis(value));
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.seamlessXAxis" />,
i18nKey: 'metadata.seamlessXAxis',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSeamlessX>) => <MetadataPrimitiveValue value={value} />,
};
//#endregion SeamlessX
@@ -500,7 +532,8 @@ const SeamlessY: SingleMetadataHandler<ParameterSeamlessY> = {
recall: (value, store) => {
store.dispatch(setSeamlessYAxis(value));
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.seamlessYAxis" />,
i18nKey: 'metadata.seamlessYAxis',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSeamlessY>) => <MetadataPrimitiveValue value={value} />,
};
//#endregion SeamlessY
@@ -520,7 +553,8 @@ const RefinerModel: SingleMetadataHandler<ParameterSDXLRefinerModel> = {
recall: (value, store) => {
store.dispatch(refinerModelChanged(value));
},
LabelComponent: () => <MetadataLabel i18nKey="sdxl.refinermodel" />,
i18nKey: 'sdxl.refinermodel',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSDXLRefinerModel>) => (
<MetadataPrimitiveValue value={`${value.name} (${value.base.toUpperCase()})`} />
),
@@ -539,7 +573,8 @@ const RefinerSteps: SingleMetadataHandler<ParameterSteps> = {
recall: (value, store) => {
store.dispatch(setRefinerSteps(value));
},
LabelComponent: () => <MetadataLabel i18nKey="sdxl.refinerSteps" />,
i18nKey: 'sdxl.refinerSteps',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSteps>) => <MetadataPrimitiveValue value={value} />,
};
//#endregion RefinerSteps
@@ -556,7 +591,8 @@ const RefinerCFGScale: SingleMetadataHandler<ParameterCFGScale> = {
recall: (value, store) => {
store.dispatch(setRefinerCFGScale(value));
},
LabelComponent: () => <MetadataLabel i18nKey="sdxl.cfgScale" />,
i18nKey: 'sdxl.cfgScale',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterCFGScale>) => <MetadataPrimitiveValue value={value} />,
};
//#endregion RefinerCFGScale
@@ -573,7 +609,8 @@ const RefinerScheduler: SingleMetadataHandler<ParameterScheduler> = {
recall: (value, store) => {
store.dispatch(setRefinerScheduler(value));
},
LabelComponent: () => <MetadataLabel i18nKey="sdxl.scheduler" />,
i18nKey: 'sdxl.scheduler',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterScheduler>) => <MetadataPrimitiveValue value={value} />,
};
//#endregion RefinerScheduler
@@ -590,7 +627,8 @@ const RefinerPositiveAestheticScore: SingleMetadataHandler<ParameterSDXLRefinerP
recall: (value, store) => {
store.dispatch(setRefinerPositiveAestheticScore(value));
},
LabelComponent: () => <MetadataLabel i18nKey="sdxl.posAestheticScore" />,
i18nKey: 'sdxl.posAestheticScore',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSDXLRefinerPositiveAestheticScore>) => (
<MetadataPrimitiveValue value={value} />
),
@@ -609,7 +647,8 @@ const RefinerNegativeAestheticScore: SingleMetadataHandler<ParameterSDXLRefinerN
recall: (value, store) => {
store.dispatch(setRefinerNegativeAestheticScore(value));
},
LabelComponent: () => <MetadataLabel i18nKey="sdxl.negAestheticScore" />,
i18nKey: 'sdxl.negAestheticScore',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSDXLRefinerNegativeAestheticScore>) => (
<MetadataPrimitiveValue value={value} />
),
@@ -628,7 +667,8 @@ const RefinerDenoisingStart: SingleMetadataHandler<ParameterSDXLRefinerStart> =
recall: (value, store) => {
store.dispatch(setRefinerStart(value));
},
LabelComponent: () => <MetadataLabel i18nKey="sdxl.refinerStart" />,
i18nKey: 'sdxl.refinerStart',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSDXLRefinerStart>) => (
<MetadataPrimitiveValue value={value} />
),
@@ -648,7 +688,8 @@ const MainModel: SingleMetadataHandler<ParameterModel> = {
recall: (value, store) => {
store.dispatch(modelSelected(value));
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.model" />,
i18nKey: 'metadata.model',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterModel>) => (
<MetadataPrimitiveValue value={`${value.name} (${value.base.toUpperCase()})`} />
),
@@ -669,7 +710,8 @@ const VAEModel: SingleMetadataHandler<ParameterVAEModel> = {
recall: (value, store) => {
store.dispatch(vaeSelected(value));
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.vae" />,
i18nKey: 'metadata.vae',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterVAEModel>) => (
<MetadataPrimitiveValue value={`${value.name} (${value.base.toUpperCase()})`} />
),
@@ -733,7 +775,8 @@ const LoRAs: CollectionMetadataHandler<LoRA[]> = {
store.dispatch(loraRecalled({ lora }));
}
},
LabelComponent: () => <MetadataLabel i18nKey="models.lora" />,
i18nKey: 'models.lora',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: CollectionMetadataValueProps<LoRA[]>) => (
<MetadataPrimitiveValue value={`${value.model.name} (${value.model.base.toUpperCase()}) - ${value.weight}`} />
),
@@ -763,7 +806,8 @@ const CanvasLayers: SingleMetadataHandler<CanvasMetadata> = {
}
store.dispatch(canvasMetadataRecalled(value));
},
LabelComponent: () => <MetadataLabel i18nKey="metadata.canvasV2Metadata" />,
i18nKey: 'metadata.canvasV2Metadata',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: SingleMetadataValueProps<CanvasMetadata>) => {
const { t } = useTranslation();
const count =
@@ -810,7 +854,8 @@ const RefImages: CollectionMetadataHandler<RefImageState[]> = {
const entities = [{ ...data, id: getPrefixedId('reference_image') }];
store.dispatch(refImagesRecalled({ entities, replace: false }));
},
LabelComponent: () => <MetadataLabel i18nKey="controlLayers.referenceImage" />,
i18nKey: 'controlLayers.referenceImage',
LabelComponent: MetadataLabel,
ValueComponent: ({ value }: CollectionMetadataValueProps<RefImageState[]>) => {
if (value.config.model) {
return <MetadataPrimitiveValue value={value.config.model.name} />;
@@ -862,7 +907,7 @@ export const MetadataHandlers = {
// ipAdapterToIPAdapterLayer: parseIPAdapterToIPAdapterLayer,
} as const;
const successToast = (parameter: ReactNode) => {
const successToast = (parameter: string) => {
toast({
id: 'PARAMETER_SET',
title: t('toast.parameterSet'),
@@ -871,7 +916,7 @@ const successToast = (parameter: ReactNode) => {
});
};
const failedToast = (parameter: ReactNode, message?: ReactNode) => {
const failedToast = (parameter: string, message?: string) => {
toast({
id: 'PARAMETER_NOT_SET',
title: t('toast.parameterNotSet'),
@@ -902,9 +947,9 @@ const recallByHandler = async (arg: {
if (!silent) {
if (didRecall) {
successToast(<handler.LabelComponent />);
successToast(t(handler.i18nKey));
} else {
failedToast(<handler.LabelComponent />);
failedToast(t(handler.i18nKey));
}
}
@@ -1006,6 +1051,28 @@ const recallPrompts = async (metadata: unknown, store: AppStore) => {
}
};
const hasMetadataByHandlers = async (arg: {
metadata: unknown;
handlers: (SingleMetadataHandler<any> | CollectionMetadataHandler<any[]>)[];
store: AppStore;
require: 'some' | 'all';
}) => {
const { metadata, handlers, store, require } = arg;
for (const handler of handlers) {
try {
await handler.parse(metadata, store);
if (require === 'some') {
return true;
}
} catch {
if (require === 'all') {
return false;
}
}
}
return true;
};
const recallDimensions = async (metadata: unknown, store: AppStore) => {
const recalled = await recallByHandlers({
metadata,
@@ -1035,6 +1102,7 @@ const recallAll = async (
};
export const MetadataUtils = {
hasMetadataByHandlers,
recallByHandler,
recallByHandlers,
recallAll,

View File

@@ -32,7 +32,7 @@ const CurrentImageNode = (props: NodeProps) => {
if (imageDTO) {
return (
<Wrapper nodeProps={props}>
<DndImage imageDTO={imageDTO} />
<DndImage imageDTO={imageDTO} borderRadius="base" />
</Wrapper>
);
}

View File

@@ -146,6 +146,7 @@ const ImageGridItemContent = memo(
return (
<>
<DndImage
borderRadius="base"
imageDTO={query.data}
asThumbnail
objectFit="contain"

View File

@@ -76,7 +76,7 @@ const ImageFieldInputComponent = (props: FieldComponentProps<ImageFieldInputInst
)}
{imageDTO && (
<>
<Flex borderRadius="base" borderWidth={1} borderStyle="solid">
<Flex borderRadius="base" borderWidth={1} borderStyle="solid" overflow="hidden">
<DndImage imageDTO={imageDTO} asThumbnail />
</Flex>
<Text

View File

@@ -14,7 +14,7 @@ const ImageOutputPreview = ({ output }: Props) => {
return null;
}
return <DndImage imageDTO={imageDTO} />;
return <DndImage imageDTO={imageDTO} borderRadius="base" />;
};
export default memo(ImageOutputPreview);

View File

@@ -30,11 +30,14 @@ export const addFLUXFill = async ({
denoise.denoising_start = denoising_start;
denoise.denoising_end = denoising_end;
const { originalSize, scaledSize, rect } = getOriginalAndScaledSizesForOtherModes(state);
denoise.width = scaledSize.width;
denoise.height = scaledSize.height;
const params = selectParamsSlice(state);
const canvasSettings = selectCanvasSettingsSlice(state);
const { originalSize, scaledSize, rect } = getOriginalAndScaledSizesForOtherModes(state);
const rasterAdapters = manager.compositor.getVisibleAdaptersOfType('raster_layer');
const initialImage = await manager.compositor.getCompositeImageDTO(rasterAdapters, rect, {
is_intermediate: true,

View File

@@ -78,8 +78,6 @@ export const buildFLUXGraph = async (arg: GraphBuilderArg): Promise<GraphBuilder
if (generationMode !== 'txt2img') {
throw new UnsupportedGenerationModeError(t('toast.fluxKontextIncompatibleGenerationMode'));
}
guidance = 30;
}
const g = new Graph(getPrefixedId('flux_graph'));

View File

@@ -26,7 +26,7 @@ export const buildFluxKontextGraph = (arg: GraphBuilderArg): GraphBuilderReturn
assert(model.base === 'flux-kontext', 'Selected model is not a FLUX Kontext API model');
if (generationMode !== 'txt2img') {
throw new UnsupportedGenerationModeError(t('toast.imagenIncompatibleGenerationMode', { model: 'FLUX Kontext' }));
throw new UnsupportedGenerationModeError(t('toast.fluxKontextIncompatibleGenerationMode'));
}
log.debug({ generationMode, manager: manager?.id }, 'Building FLUX Kontext graph');

View File

@@ -18,7 +18,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { $onClickGoToModelManager } from 'app/store/nanostores/onClickGoToModelManager';
import { useAppSelector } from 'app/store/storeHooks';
import type { Group, PickerContextState } from 'common/components/Picker/Picker';
import { buildGroup, getRegex, isOption, Picker, usePickerContext } from 'common/components/Picker/Picker';
import { buildGroup, getRegex, isGroup, Picker, usePickerContext } from 'common/components/Picker/Picker';
import { useDisclosure } from 'common/hooks/useBoolean';
import { typedMemo } from 'common/util/typedMemo';
import { uniq } from 'es-toolkit/compat';
@@ -277,8 +277,22 @@ export const ModelPicker = typedMemo(
if (!selectedModelConfig) {
return undefined;
}
let _selectedOption: WithStarred<T> | undefined = undefined;
return options.filter(isOption).find((o) => o.key === selectedModelConfig.key);
for (const optionOrGroup of options) {
if (isGroup(optionOrGroup)) {
const result = optionOrGroup.options.find((o) => o.key === selectedModelConfig.key);
if (result) {
_selectedOption = result;
break;
}
} else if (optionOrGroup.key === selectedModelConfig.key) {
_selectedOption = optionOrGroup;
break;
}
}
return _selectedOption;
}, [options, selectedModelConfig]);
const onClose = useCallback(() => {
@@ -361,9 +375,19 @@ const optionSx: SystemStyleObject = {
cursor: 'pointer',
borderRadius: 'base',
'&[data-selected="true"]': {
bg: 'base.700',
bg: 'invokeBlue.300',
color: 'base.900',
'.extra-info': {
color: 'base.700',
},
'.picker-option': {
fontWeight: 'bold',
'&[data-is-compact="true"]': {
fontWeight: 'semibold',
},
},
'&[data-active="true"]': {
bg: 'base.650',
bg: 'invokeBlue.250',
},
},
'&[data-active="true"]': {
@@ -400,17 +424,31 @@ const PickerOptionComponent = typedMemo(
<Flex flexDir="column" gap={1} flex={1}>
<Flex gap={2} alignItems="center">
{option.starred && <Icon as={PiLinkSimple} color="invokeYellow.500" boxSize={4} />}
<Text sx={optionNameSx} data-is-compact={compactView}>
<Text className="picker-option" sx={optionNameSx} data-is-compact={compactView}>
{option.name}
</Text>
<Spacer />
{option.file_size > 0 && (
<Text variant="subtext" fontStyle="italic" noOfLines={1} flexShrink={0} overflow="visible">
<Text
className="extra-info"
variant="subtext"
fontStyle="italic"
noOfLines={1}
flexShrink={0}
overflow="visible"
>
{filesize(option.file_size)}
</Text>
)}
{option.usage_info && (
<Text variant="subtext" fontStyle="italic" noOfLines={1} flexShrink={0} overflow="visible">
<Text
className="extra-info"
variant="subtext"
fontStyle="italic"
noOfLines={1}
flexShrink={0}
overflow="visible"
>
{option.usage_info}
</Text>
)}

View File

@@ -0,0 +1,28 @@
import type { ButtonProps } from '@invoke-ai/ui-library';
import { Button } from '@invoke-ai/ui-library';
import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXCircle } from 'react-icons/pi';
export const CancelAllExceptCurrentButton = memo((props: ButtonProps) => {
const { t } = useTranslation();
const api = useCancelAllExceptCurrentQueueItemDialog();
return (
<Button
isDisabled={api.isDisabled}
isLoading={api.isLoading}
aria-label={t('queue.clear')}
tooltip={t('queue.cancelAllExceptCurrentTooltip')}
leftIcon={<PiXCircle />}
colorScheme="error"
onClick={api.openDialog}
{...props}
>
{t('queue.clear')}
</Button>
);
});
CancelAllExceptCurrentButton.displayName = 'CancelAllExceptCurrentButton';

View File

@@ -1,32 +0,0 @@
import type { ButtonProps } from '@invoke-ai/ui-library';
import { Button } from '@invoke-ai/ui-library';
import { useDeleteAllExceptCurrentQueueItemDialog } from 'features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXCircle } from 'react-icons/pi';
type Props = ButtonProps;
export const DeleteAllExceptCurrentButton = memo((props: Props) => {
const { t } = useTranslation();
const deleteAllExceptCurrent = useDeleteAllExceptCurrentQueueItemDialog();
return (
<>
<Button
onClick={deleteAllExceptCurrent.openDialog}
isLoading={deleteAllExceptCurrent.isLoading}
isDisabled={deleteAllExceptCurrent.isDisabled}
tooltip={t('queue.cancelAllExceptCurrentTooltip')}
leftIcon={<PiXCircle />}
colorScheme="error"
data-testid={t('queue.clear')}
{...props}
>
{t('queue.clear')}
</Button>
</>
);
});
DeleteAllExceptCurrentButton.displayName = 'DeleteAllExceptCurrentButton';

View File

@@ -3,7 +3,7 @@ import { Badge, ButtonGroup, Collapse, Flex, IconButton, Text } from '@invoke-ai
import QueueStatusBadge from 'features/queue/components/common/QueueStatusBadge';
import { useDestinationText } from 'features/queue/components/QueueList/useDestinationText';
import { useOriginText } from 'features/queue/components/QueueList/useOriginText';
import { useDeleteQueueItem } from 'features/queue/hooks/useDeleteQueueItem';
import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem';
import { useRetryQueueItem } from 'features/queue/hooks/useRetryQueueItem';
import { getSecondsFromTimestamps } from 'features/queue/util/getSecondsFromTimestamps';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
@@ -38,13 +38,13 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => {
const handleToggle = useCallback(() => {
context.toggleQueueItem(item.item_id);
}, [context, item.item_id]);
const deleteQueueItem = useDeleteQueueItem();
const onClickDeleteQueueItem = useCallback(
const cancelQueueItem = useCancelQueueItem();
const onClickCancelQueueItem = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
deleteQueueItem.trigger(item.item_id);
cancelQueueItem.trigger(item.item_id);
},
[deleteQueueItem, item.item_id]
[cancelQueueItem, item.item_id]
);
const retryQueueItem = useRetryQueueItem();
const onClickRetryQueueItem = useCallback(
@@ -135,9 +135,9 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => {
<ButtonGroup size="xs" variant="ghost">
{(!isFailed || !isRetryEnabled || isValidationRun) && (
<IconButton
onClick={onClickDeleteQueueItem}
onClick={onClickCancelQueueItem}
isDisabled={isCanceled}
isLoading={deleteQueueItem.isLoading}
isLoading={cancelQueueItem.isLoading}
aria-label={t('queue.cancelItem')}
icon={<PiXBold />}
/>

View File

@@ -5,7 +5,7 @@ import { useDestinationText } from 'features/queue/components/QueueList/useDesti
import { useOriginText } from 'features/queue/components/QueueList/useOriginText';
import { useBatchIsCanceled } from 'features/queue/hooks/useBatchIsCanceled';
import { useCancelBatch } from 'features/queue/hooks/useCancelBatch';
import { useDeleteQueueItem } from 'features/queue/hooks/useDeleteQueueItem';
import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem';
import { useRetryQueueItem } from 'features/queue/hooks/useRetryQueueItem';
import { getSecondsFromTimestamps } from 'features/queue/util/getSecondsFromTimestamps';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
@@ -13,20 +13,22 @@ import type { ReactNode } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiXBold } from 'react-icons/pi';
import { useGetQueueItemQuery } from 'services/api/endpoints/queue';
import type { S } from 'services/api/types';
type Props = {
queueItem: S['SessionQueueItem'];
};
const QueueItemComponent = ({ queueItem }: Props) => {
const { session_id, batch_id, item_id, origin, destination } = queueItem;
const QueueItemComponent = ({ queueItem: queueItemDTO }: Props) => {
const { session_id, batch_id, item_id, origin, destination } = queueItemDTO;
const { t } = useTranslation();
const isRetryEnabled = useFeatureStatus('retryQueueItem');
const isBatchCanceled = useBatchIsCanceled(batch_id);
const cancelBatch = useCancelBatch();
const deleteQueueItem = useDeleteQueueItem();
const cancelQueueItem = useCancelQueueItem();
const retryQueueItem = useRetryQueueItem();
const { data: queueItem } = useGetQueueItemQuery(item_id);
const originText = useOriginText(origin);
const destinationText = useDestinationText(destination);
@@ -57,8 +59,8 @@ const QueueItemComponent = ({ queueItem }: Props) => {
}, [cancelBatch, batch_id]);
const onCancelQueueItem = useCallback(() => {
deleteQueueItem.trigger(item_id);
}, [deleteQueueItem, item_id]);
cancelQueueItem.trigger(item_id);
}, [cancelQueueItem, item_id]);
const onRetryQueueItem = useCallback(() => {
retryQueueItem.trigger(item_id);
@@ -85,8 +87,8 @@ const QueueItemComponent = ({ queueItem }: Props) => {
{(!isFailed || !isRetryEnabled) && (
<Button
onClick={onCancelQueueItem}
isLoading={deleteQueueItem.isLoading}
isDisabled={deleteQueueItem.isDisabled || queueItem ? isCanceled : true}
isLoading={cancelQueueItem.isLoading}
isDisabled={cancelQueueItem.isDisabled || queueItem ? isCanceled : true}
aria-label={t('queue.cancelItem')}
leftIcon={<PiXBold />}
colorScheme="error"

View File

@@ -13,7 +13,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { Components, ItemContent } from 'react-virtuoso';
import { Virtuoso } from 'react-virtuoso';
import { useListQueueItemsQuery } from 'services/api/endpoints/queue';
import { queueItemsAdapterSelectors, useListQueueItemsQuery } from 'services/api/endpoints/queue';
import type { S } from 'services/api/types';
import QueueItemComponent from './QueueItemComponent';
@@ -70,7 +70,7 @@ const QueueList = () => {
if (!listQueueItemsData) {
return [];
}
return listQueueItemsData.items;
return queueItemsAdapterSelectors.selectAll(listQueueItemsData);
}, [listQueueItemsData]);
const handleLoadMore = useCallback(() => {

View File

@@ -1,8 +1,8 @@
import { ButtonGroup, Flex } from '@invoke-ai/ui-library';
import { DeleteAllExceptCurrentButton } from 'features/queue/components/DeleteAllExceptCurrentButton';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
import { CancelAllExceptCurrentButton } from './CancelAllExceptCurrentButton';
import ClearModelCacheButton from './ClearModelCacheButton';
import PauseProcessorButton from './PauseProcessorButton';
import PruneQueueButton from './PruneQueueButton';
@@ -23,7 +23,7 @@ const QueueTabQueueControls = () => {
)}
<ButtonGroup w={28} orientation="vertical" size="sm">
<PruneQueueButton />
<DeleteAllExceptCurrentButton />
<CancelAllExceptCurrentButton />
</ButtonGroup>
</Flex>
<ClearModelCacheButton />

View File

@@ -33,7 +33,7 @@ export const queueSlice = createSlice({
},
});
export const { listCursorChanged, listPriorityChanged } = queueSlice.actions;
export const { listCursorChanged, listPriorityChanged, listParamsReset } = queueSlice.actions;
const selectQueueSlice = (state: RootState) => state.queue;
const createQueueSelector = <T>(selector: Selector<QueueState, T>) => createSelector(selectQueueSlice, selector);

View File

@@ -287,17 +287,19 @@ const getReasonsWhyCannotEnqueueGenerateTab = (arg: {
reasons.push({ content: i18n.t('parameters.invoke.fluxKontextMultipleReferenceImages') });
}
refImages.entities.forEach((entity, i) => {
const layerNumber = i + 1;
const refImageLiteral = i18n.t(LAYER_TYPE_TO_TKEY['reference_image']);
const prefix = `${refImageLiteral} #${layerNumber}`;
const problems = getGlobalReferenceImageWarnings(entity, model);
refImages.entities
.filter(({ isEnabled }) => isEnabled)
.forEach((entity, i) => {
const layerNumber = i + 1;
const refImageLiteral = i18n.t(LAYER_TYPE_TO_TKEY['reference_image']);
const prefix = `${refImageLiteral} #${layerNumber}`;
const problems = getGlobalReferenceImageWarnings(entity, model);
if (problems.length) {
const content = upperFirst(problems.map((p) => i18n.t(p)).join(', '));
reasons.push({ prefix, content });
}
});
if (problems.length) {
const content = upperFirst(problems.map((p) => i18n.t(p)).join(', '));
reasons.push({ prefix, content });
}
});
return reasons;
};

View File

@@ -37,7 +37,7 @@ export const UpscaleInitialImage = () => {
{!imageDTO && <UploadImageIconButton w="full" h="full" isError={!imageDTO} onUpload={onUpload} fontSize={36} />}
{imageDTO && (
<>
<DndImage imageDTO={imageDTO} />
<DndImage imageDTO={imageDTO} borderRadius="base" />
<Flex position="absolute" flexDir="column" top={1} insetInlineEnd={1} gap={1}>
<DndImageIcon
onClick={onReset}

View File

@@ -144,7 +144,6 @@ export const useHotkeyData = (): HotkeysData => {
addHotkey('viewer', 'recallPrompts', ['p']);
addHotkey('viewer', 'remix', ['r']);
addHotkey('viewer', 'useSize', ['d']);
addHotkey('viewer', 'runPostprocessing', ['shift+u']);
addHotkey('viewer', 'toggleMetadata', ['i']);
// Gallery

View File

@@ -26,14 +26,14 @@ const optionsObject: Record<Language, string> = {
nl: 'Nederlands',
pl: 'Polski',
pt: 'Português',
pt_BR: 'Português do Brasil',
'pt-BR': 'Português do Brasil',
ru: 'Русский',
sv: 'Svenska',
tr: 'Türkçe',
ua: 'Украї́нська',
vi: 'Tiếng Việt',
zh_CN: '简体中文',
zh_Hant: '漢語',
'zh-CN': '简体中文',
'zh-Hant': '漢語',
};
const options = map(optionsObject, (label, value) => ({ label, value }));

View File

@@ -9,7 +9,7 @@ import { uniq } from 'es-toolkit/compat';
import type { Language, SystemState } from './types';
const initialSystemState: SystemState = {
_version: 1,
_version: 2,
shouldConfirmOnDelete: true,
shouldAntialiasProgressImage: false,
shouldConfirmOnNewSession: true,
@@ -96,6 +96,10 @@ const migrateSystemState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
if (state._version === 1) {
state.language = (state as SystemState).language.replace('_', '-');
state._version = 2;
}
return state;
};

View File

@@ -17,20 +17,20 @@ const zLanguage = z.enum([
'nl',
'pl',
'pt',
'pt_BR',
'pt-BR',
'ru',
'sv',
'tr',
'ua',
'vi',
'zh_CN',
'zh_Hant',
'zh-CN',
'zh-Hant',
]);
export type Language = z.infer<typeof zLanguage>;
export const isLanguage = (v: unknown): v is Language => zLanguage.safeParse(v).success;
export interface SystemState {
_version: 1;
_version: 2;
shouldConfirmOnDelete: boolean;
shouldAntialiasProgressImage: boolean;
shouldConfirmOnNewSession: boolean;

View File

@@ -58,7 +58,7 @@ const TabContent = memo(() => {
TabContent.displayName = 'TabContent';
const SwitchingTabsLoader = memo(() => {
const isSwitchingTabs = useStore(navigationApi.$isSwitchingTabs);
const isSwitchingTabs = useStore(navigationApi.$isLoading);
if (isSwitchingTabs) {
return <Loading />;

View File

@@ -13,8 +13,6 @@ import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD';
import { InvokeCanvasComponent } from 'features/controlLayers/components/InvokeCanvasComponent';
import { SelectObject } from 'features/controlLayers/components/SelectObject/SelectObject';
import { CanvasSessionContextProvider } from 'features/controlLayers/components/SimpleSession/context';
import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList';
import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar';
import { Transform } from 'features/controlLayers/components/Transform/Transform';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
@@ -23,6 +21,8 @@ import { selectCanvasSessionId } from 'features/controlLayers/store/canvasStagin
import { memo, useCallback } from 'react';
import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi';
import { StagingArea } from './StagingArea';
const MenuContent = memo(() => {
return (
<CanvasManagerProviderGate>
@@ -106,23 +106,7 @@ export const CanvasWorkspacePanel = memo(() => {
{canvasId !== null && (
<CanvasManagerProviderGate>
<CanvasSessionContextProvider type="advanced" id={canvasId}>
<Flex
position="absolute"
flexDir="column"
bottom={4}
gap={2}
align="center"
justify="center"
left={4}
right={4}
>
<Flex position="relative" maxW="full" w="full" h={108}>
<StagingAreaItemsList />
</Flex>
<Flex gap={2}>
<StagingAreaToolbar />
</Flex>
</Flex>
<StagingArea />
</CanvasSessionContextProvider>
</CanvasManagerProviderGate>
)}

View File

@@ -0,0 +1,22 @@
import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList';
import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo } from 'react';
export const StagingArea = memo(() => {
const isStaging = useAppSelector(selectIsStaging);
if (!isStaging) {
return null;
}
return (
<Flex position="absolute" flexDir="column" bottom={2} gap={2} align="center" justify="center" left={2} right={2}>
<StagingAreaItemsList />
<StagingAreaToolbar />
</Flex>
);
});
StagingArea.displayName = 'StagingArea';

View File

@@ -16,6 +16,8 @@ import {
PiTextAaBold,
} from 'react-icons/pi';
import { useHackOutDvTabDraggable } from './use-hack-out-dv-tab-draggable';
const TAB_ICONS: Record<TabName, IconType> = {
generate: PiTextAaBold,
canvas: PiBoundingBoxBold,
@@ -41,6 +43,8 @@ export const TabWithLaunchpadIcon = memo((props: IDockviewPanelHeaderProps) => {
setFocusedRegion(props.params.focusRegion);
}, [props.params.focusRegion]);
useHackOutDvTabDraggable(ref);
return (
<Flex ref={ref} alignItems="center" h="full" px={4} gap={3} onPointerDown={onPointerDown}>
<Icon as={TAB_ICONS[activeTab]} color="invokeYellow.300" boxSize={5} />

View File

@@ -5,6 +5,7 @@ import type { IDockviewPanelHeaderProps } from 'dockview';
import { memo, useCallback, useRef } from 'react';
import type { PanelParameters } from './auto-layout-context';
import { useHackOutDvTabDraggable } from './use-hack-out-dv-tab-draggable';
export const TabWithoutCloseButton = memo((props: IDockviewPanelHeaderProps<PanelParameters>) => {
const ref = useRef<HTMLDivElement>(null);
@@ -20,6 +21,8 @@ export const TabWithoutCloseButton = memo((props: IDockviewPanelHeaderProps<Pane
setFocusedRegion(props.params.focusRegion);
}, [props.params.focusRegion]);
useHackOutDvTabDraggable(ref);
return (
<Flex ref={ref} alignItems="center" h="full" onPointerDown={onPointerDown}>
<Text userSelect="none" px={4}>

View File

@@ -7,6 +7,7 @@ import { memo, useCallback, useRef } from 'react';
import { useIsGenerationInProgress } from 'services/api/endpoints/queue';
import type { PanelParameters } from './auto-layout-context';
import { useHackOutDvTabDraggable } from './use-hack-out-dv-tab-draggable';
export const TabWithoutCloseButtonAndWithProgressIndicator = memo(
(props: IDockviewPanelHeaderProps<PanelParameters>) => {
@@ -25,6 +26,8 @@ export const TabWithoutCloseButtonAndWithProgressIndicator = memo(
setFocusedRegion(props.params.focusRegion);
}, [props.params.focusRegion]);
useHackOutDvTabDraggable(ref);
return (
<Flex ref={ref} position="relative" alignItems="center" h="full" onPointerDown={onPointerDown}>
<Text userSelect="none" px={4}>

View File

@@ -1,11 +1,4 @@
import type {
DockviewApi,
GridviewApi,
IDockviewPanel,
IDockviewReactProps,
IGridviewPanel,
IGridviewReactProps,
} from 'dockview';
import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview';
import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview';
import { CanvasLayersPanel } from 'features/controlLayers/components/CanvasLayersPanelContent';
import { CanvasLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel';
@@ -71,54 +64,50 @@ const mainPanelComponents: AutoLayoutDockviewComponents = {
};
const initializeCenterPanelLayout = (tab: TabName, api: DockviewApi) => {
const launchpad = api.addPanel<PanelParameters>({
id: LAUNCHPAD_PANEL_ID,
component: LAUNCHPAD_PANEL_ID,
title: 'Launchpad',
tabComponent: TAB_WITH_LAUNCHPAD_ICON_ID,
params: {
tab,
focusRegion: 'launchpad',
},
navigationApi.registerContainer(tab, 'main', api, () => {
const launchpad = api.addPanel<PanelParameters>({
id: LAUNCHPAD_PANEL_ID,
component: LAUNCHPAD_PANEL_ID,
title: 'Launchpad',
tabComponent: TAB_WITH_LAUNCHPAD_ICON_ID,
params: {
tab,
focusRegion: 'launchpad',
},
});
api.addPanel<PanelParameters>({
id: WORKSPACE_PANEL_ID,
component: WORKSPACE_PANEL_ID,
title: 'Canvas',
tabComponent: DEFAULT_TAB_ID,
params: {
tab,
focusRegion: 'canvas',
},
position: {
direction: 'within',
referencePanel: launchpad.id,
},
});
api.addPanel<PanelParameters>({
id: VIEWER_PANEL_ID,
component: VIEWER_PANEL_ID,
title: 'Image Viewer',
tabComponent: DEFAULT_TAB_ID,
params: {
tab,
focusRegion: 'viewer',
},
position: {
direction: 'within',
referencePanel: launchpad.id,
},
});
launchpad.api.setActive();
});
const workspace = api.addPanel<PanelParameters>({
id: WORKSPACE_PANEL_ID,
component: WORKSPACE_PANEL_ID,
title: 'Canvas',
tabComponent: DEFAULT_TAB_ID,
params: {
tab,
focusRegion: 'canvas',
},
position: {
direction: 'within',
referencePanel: launchpad.id,
},
});
const viewer = api.addPanel<PanelParameters>({
id: VIEWER_PANEL_ID,
component: VIEWER_PANEL_ID,
title: 'Image Viewer',
tabComponent: DEFAULT_TAB_ID,
params: {
tab,
focusRegion: 'viewer',
},
position: {
direction: 'within',
referencePanel: launchpad.id,
},
});
launchpad.api.setActive();
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
navigationApi.registerPanel(tab, WORKSPACE_PANEL_ID, workspace);
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
return { launchpad, workspace, viewer } satisfies Record<string, IDockviewPanel>;
};
const MainPanel = memo(() => {
@@ -157,54 +146,49 @@ const rightPanelComponents: AutoLayoutGridviewComponents = {
};
const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => {
const gallery = api.addPanel<PanelParameters>({
id: GALLERY_PANEL_ID,
component: GALLERY_PANEL_ID,
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'gallery',
},
navigationApi.registerContainer(tab, 'right', api, () => {
const gallery = api.addPanel<PanelParameters>({
id: GALLERY_PANEL_ID,
component: GALLERY_PANEL_ID,
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'gallery',
},
});
const boards = api.addPanel<PanelParameters>({
id: BOARDS_PANEL_ID,
component: BOARDS_PANEL_ID,
minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'boards',
},
position: {
direction: 'above',
referencePanel: gallery.id,
},
});
api.addPanel<PanelParameters>({
id: LAYERS_PANEL_ID,
component: LAYERS_PANEL_ID,
minimumHeight: LAYERS_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'layers',
},
position: {
direction: 'below',
referencePanel: gallery.id,
},
});
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX });
boards.api.setSize({ height: CANVAS_BOARD_PANEL_DEFAULT_HEIGHT_PX });
});
const layers = api.addPanel<PanelParameters>({
id: LAYERS_PANEL_ID,
component: LAYERS_PANEL_ID,
minimumHeight: LAYERS_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'layers',
},
position: {
direction: 'below',
referencePanel: gallery.id,
},
});
const boards = api.addPanel<PanelParameters>({
id: BOARDS_PANEL_ID,
component: BOARDS_PANEL_ID,
minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'boards',
},
position: {
direction: 'above',
referencePanel: gallery.id,
},
});
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
boards.api.setSize({ height: CANVAS_BOARD_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
// Register panels with navigation API
navigationApi.registerPanel(tab, GALLERY_PANEL_ID, gallery);
navigationApi.registerPanel(tab, LAYERS_PANEL_ID, layers);
navigationApi.registerPanel(tab, BOARDS_PANEL_ID, boards);
return { gallery, layers, boards } satisfies Record<string, IGridviewPanel>;
};
const RightPanel = memo(() => {
@@ -232,19 +216,16 @@ const leftPanelComponents: AutoLayoutGridviewComponents = {
};
const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => {
const settings = api.addPanel<PanelParameters>({
id: SETTINGS_PANEL_ID,
component: SETTINGS_PANEL_ID,
params: {
tab,
focusRegion: 'settings',
},
navigationApi.registerContainer(tab, 'left', api, () => {
api.addPanel<PanelParameters>({
id: SETTINGS_PANEL_ID,
component: SETTINGS_PANEL_ID,
params: {
tab,
focusRegion: 'settings',
},
});
});
// Register panel with navigation API
navigationApi.registerPanel(tab, SETTINGS_PANEL_ID, settings);
return { settings } satisfies Record<string, IGridviewPanel>;
};
const LeftPanel = memo(() => {
@@ -273,47 +254,44 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
[RIGHT_PANEL_ID]: RightPanel,
};
const initializeRootPanelLayout = (api: GridviewApi) => {
const main = api.addPanel({
id: MAIN_PANEL_ID,
component: MAIN_PANEL_ID,
priority: LayoutPriority.High,
const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => {
navigationApi.registerContainer(tab, 'root', api, () => {
const main = api.addPanel({
id: MAIN_PANEL_ID,
component: MAIN_PANEL_ID,
priority: LayoutPriority.High,
});
const left = api.addPanel({
id: LEFT_PANEL_ID,
component: LEFT_PANEL_ID,
minimumWidth: LEFT_PANEL_MIN_SIZE_PX,
priority: LayoutPriority.Low,
position: {
direction: 'left',
referencePanel: main.id,
},
});
const right = api.addPanel({
id: RIGHT_PANEL_ID,
component: RIGHT_PANEL_ID,
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
priority: LayoutPriority.Low,
position: {
direction: 'right',
referencePanel: main.id,
},
});
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
});
const left = api.addPanel({
id: LEFT_PANEL_ID,
component: LEFT_PANEL_ID,
minimumWidth: LEFT_PANEL_MIN_SIZE_PX,
position: {
direction: 'left',
referencePanel: main.id,
},
});
const right = api.addPanel({
id: RIGHT_PANEL_ID,
component: RIGHT_PANEL_ID,
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
position: {
direction: 'right',
referencePanel: main.id,
},
});
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
navigationApi.registerPanel('canvas', LEFT_PANEL_ID, left);
navigationApi.registerPanel('canvas', MAIN_PANEL_ID, main);
navigationApi.registerPanel('canvas', RIGHT_PANEL_ID, right);
return { main, left, right } satisfies Record<string, IGridviewPanel>;
};
export const CanvasTabAutoLayout = memo(() => {
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
initializeRootPanelLayout(api);
navigationApi.onTabReady('canvas');
initializeRootPanelLayout('canvas', api);
}, []);
useEffect(

View File

@@ -1,11 +1,4 @@
import type {
DockviewApi,
GridviewApi,
IDockviewPanel,
IDockviewReactProps,
IGridviewPanel,
IGridviewReactProps,
} from 'dockview';
import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview';
import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview';
import { GenerateLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel';
import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent';
@@ -65,38 +58,35 @@ const mainPanelComponents: AutoLayoutDockviewComponents = {
};
const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => {
const launchpad = api.addPanel<PanelParameters>({
id: LAUNCHPAD_PANEL_ID,
component: LAUNCHPAD_PANEL_ID,
title: 'Launchpad',
tabComponent: TAB_WITH_LAUNCHPAD_ICON_ID,
params: {
tab,
focusRegion: 'launchpad',
},
navigationApi.registerContainer(tab, 'main', api, () => {
const launchpad = api.addPanel<PanelParameters>({
id: LAUNCHPAD_PANEL_ID,
component: LAUNCHPAD_PANEL_ID,
title: 'Launchpad',
tabComponent: TAB_WITH_LAUNCHPAD_ICON_ID,
params: {
tab,
focusRegion: 'launchpad',
},
});
api.addPanel<PanelParameters>({
id: VIEWER_PANEL_ID,
component: VIEWER_PANEL_ID,
title: 'Image Viewer',
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
params: {
tab,
focusRegion: 'viewer',
},
position: {
direction: 'within',
referencePanel: launchpad.id,
},
});
launchpad.api.setActive();
});
const viewer = api.addPanel<PanelParameters>({
id: VIEWER_PANEL_ID,
component: VIEWER_PANEL_ID,
title: 'Image Viewer',
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
params: {
tab,
focusRegion: 'viewer',
},
position: {
direction: 'within',
referencePanel: launchpad.id,
},
});
launchpad.api.setActive();
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
return { launchpad, viewer } satisfies Record<string, IDockviewPanel>;
};
const MainPanel = memo(() => {
@@ -134,39 +124,35 @@ const rightPanelComponents: AutoLayoutGridviewComponents = {
};
const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => {
const gallery = api.addPanel<PanelParameters>({
id: GALLERY_PANEL_ID,
component: GALLERY_PANEL_ID,
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'gallery',
},
navigationApi.registerContainer(tab, 'right', api, () => {
const gallery = api.addPanel<PanelParameters>({
id: GALLERY_PANEL_ID,
component: GALLERY_PANEL_ID,
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'gallery',
},
});
const boards = api.addPanel<PanelParameters>({
id: BOARDS_PANEL_ID,
component: BOARDS_PANEL_ID,
minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'boards',
},
position: {
direction: 'above',
referencePanel: gallery.id,
},
});
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX });
boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX });
});
const boards = api.addPanel<PanelParameters>({
id: BOARDS_PANEL_ID,
component: BOARDS_PANEL_ID,
minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'boards',
},
position: {
direction: 'above',
referencePanel: gallery.id,
},
});
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
// Register panels with navigation API
navigationApi.registerPanel(tab, GALLERY_PANEL_ID, gallery);
navigationApi.registerPanel(tab, BOARDS_PANEL_ID, boards);
return { gallery, boards } satisfies Record<string, IGridviewPanel>;
};
const RightPanel = memo(() => {
@@ -194,19 +180,16 @@ const leftPanelComponents: AutoLayoutGridviewComponents = {
};
const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => {
const settings = api.addPanel<PanelParameters>({
id: SETTINGS_PANEL_ID,
component: SETTINGS_PANEL_ID,
params: {
tab,
focusRegion: 'settings',
},
navigationApi.registerContainer(tab, 'left', api, () => {
api.addPanel<PanelParameters>({
id: SETTINGS_PANEL_ID,
component: SETTINGS_PANEL_ID,
params: {
tab,
focusRegion: 'settings',
},
});
});
// Register panel with navigation API
navigationApi.registerPanel(tab, SETTINGS_PANEL_ID, settings);
return { settings } satisfies Record<string, IGridviewPanel>;
};
const LeftPanel = memo(() => {
@@ -235,47 +218,42 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
[RIGHT_PANEL_ID]: RightPanel,
};
const initializeRootPanelLayout = (layoutApi: GridviewApi) => {
const main = layoutApi.addPanel<PanelParameters>({
id: MAIN_PANEL_ID,
component: MAIN_PANEL_ID,
priority: LayoutPriority.High,
const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => {
navigationApi.registerContainer(tab, 'root', api, () => {
const main = api.addPanel<PanelParameters>({
id: MAIN_PANEL_ID,
component: MAIN_PANEL_ID,
priority: LayoutPriority.High,
});
const left = api.addPanel<PanelParameters>({
id: LEFT_PANEL_ID,
component: LEFT_PANEL_ID,
minimumWidth: LEFT_PANEL_MIN_SIZE_PX,
position: {
direction: 'left',
referencePanel: main.id,
},
});
const right = api.addPanel<PanelParameters>({
id: RIGHT_PANEL_ID,
component: RIGHT_PANEL_ID,
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
position: {
direction: 'right',
referencePanel: main.id,
},
});
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
});
const left = layoutApi.addPanel<PanelParameters>({
id: LEFT_PANEL_ID,
component: LEFT_PANEL_ID,
minimumWidth: LEFT_PANEL_MIN_SIZE_PX,
position: {
direction: 'left',
referencePanel: main.id,
},
});
const right = layoutApi.addPanel<PanelParameters>({
id: RIGHT_PANEL_ID,
component: RIGHT_PANEL_ID,
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
position: {
direction: 'right',
referencePanel: main.id,
},
});
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
navigationApi.registerPanel('generate', LEFT_PANEL_ID, left);
navigationApi.registerPanel('generate', MAIN_PANEL_ID, main);
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, right);
return { main, left, right } satisfies Record<string, IGridviewPanel>;
};
export const GenerateTabAutoLayout = memo(() => {
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
initializeRootPanelLayout(api);
navigationApi.onTabReady('generate');
initializeRootPanelLayout('generate', api);
}, []);
useEffect(

View File

@@ -1,8 +1,9 @@
import type { GridviewApi, IGridviewPanel, IGridviewReactProps } from 'dockview';
import type { GridviewApi, IGridviewReactProps } from 'dockview';
import { GridviewReact, LayoutPriority, Orientation } from 'dockview';
import ModelManagerTab from 'features/ui/components/tabs/ModelManagerTab';
import type { RootLayoutGridviewComponents } from 'features/ui/layouts/auto-layout-context';
import { AutoLayoutProvider } from 'features/ui/layouts/auto-layout-context';
import type { TabName } from 'features/ui/store/uiTypes';
import { memo, useCallback, useEffect } from 'react';
import { navigationApi } from './navigation-api';
@@ -12,22 +13,19 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
[MODELS_PANEL_ID]: ModelManagerTab,
};
const initializeRootPanelLayout = (layoutApi: GridviewApi) => {
const models = layoutApi.addPanel({
id: MODELS_PANEL_ID,
component: MODELS_PANEL_ID,
priority: LayoutPriority.High,
const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => {
navigationApi.registerContainer(tab, 'root', api, () => {
api.addPanel({
id: MODELS_PANEL_ID,
component: MODELS_PANEL_ID,
priority: LayoutPriority.High,
});
});
navigationApi.registerPanel('models', MODELS_PANEL_ID, models);
return { models } satisfies Record<string, IGridviewPanel>;
};
export const ModelsTabAutoLayout = memo(() => {
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
initializeRootPanelLayout(api);
navigationApi.onTabReady('models');
initializeRootPanelLayout('models', api);
}, []);
useEffect(

View File

@@ -1,3 +1,4 @@
import type { DockviewApi, GridviewApi } from 'dockview';
import { DockviewPanel, GridviewPanel } from 'dockview';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -10,6 +11,7 @@ import {
RIGHT_PANEL_ID,
RIGHT_PANEL_MIN_SIZE_PX,
SETTINGS_PANEL_ID,
SWITCH_TABS_FAKE_DELAY_MS,
WORKSPACE_PANEL_ID,
} from './shared';
@@ -20,6 +22,7 @@ vi.mock('app/logging/logger', () => ({
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
}),
}));
@@ -28,8 +31,9 @@ vi.mock('dockview', async () => {
// Mock GridviewPanel class for instanceof checks
class MockGridviewPanel {
maximumWidth: number;
minimumWidth: number;
maximumWidth?: number;
minimumWidth?: number;
width?: number;
api = {
setActive: vi.fn(),
setConstraints: vi.fn(),
@@ -37,9 +41,10 @@ vi.mock('dockview', async () => {
onDidDimensionsChange: vi.fn(() => ({ dispose: vi.fn() })),
};
constructor(config: { maximumWidth?: number; minimumWidth?: number } = {}) {
this.maximumWidth = config.maximumWidth ?? Number.MAX_SAFE_INTEGER;
this.minimumWidth = config.minimumWidth ?? 0;
constructor(config: { maximumWidth?: number; minimumWidth?: number; width?: number } = {}) {
this.maximumWidth = config.maximumWidth;
this.minimumWidth = config.minimumWidth;
this.width = config.width;
}
}
@@ -61,7 +66,7 @@ vi.mock('dockview', async () => {
});
// Mock panel with setActive method
const createMockPanel = (config: { maximumWidth?: number; minimumWidth?: number } = {}) => {
const createMockPanel = (config: { maximumWidth?: number; minimumWidth?: number; width?: number } = {}) => {
/* @ts-expect-error we are mocking GridviewPanel to be a concrete class */
return new GridviewPanel(config);
};
@@ -75,27 +80,27 @@ describe('AppNavigationApi', () => {
let navigationApi: NavigationApi;
let mockSetAppTab: ReturnType<typeof vi.fn>;
let mockGetAppTab: ReturnType<typeof vi.fn>;
let mockSetPanelState: ReturnType<typeof vi.fn>;
let mockGetPanelState: ReturnType<typeof vi.fn>;
let mockDeletePanelState: ReturnType<typeof vi.fn>;
let mockSetStorage: ReturnType<typeof vi.fn>;
let mockGetStorage: ReturnType<typeof vi.fn>;
let mockDeleteStorage: ReturnType<typeof vi.fn>;
let mockAppApi: NavigationAppApi;
beforeEach(() => {
navigationApi = new NavigationApi();
mockSetAppTab = vi.fn();
mockGetAppTab = vi.fn();
mockSetPanelState = vi.fn();
mockGetPanelState = vi.fn();
mockDeletePanelState = vi.fn();
mockSetStorage = vi.fn();
mockGetStorage = vi.fn();
mockDeleteStorage = vi.fn();
mockAppApi = {
activeTab: {
set: mockSetAppTab,
get: mockGetAppTab,
},
panelStorage: {
set: mockSetPanelState,
get: mockGetPanelState,
delete: mockDeletePanelState,
storage: {
set: mockSetStorage,
get: mockGetStorage,
delete: mockDeleteStorage,
},
};
});
@@ -115,9 +120,9 @@ describe('AppNavigationApi', () => {
expect(navigationApi._app).not.toBeNull();
expect(navigationApi._app?.activeTab.set).toBe(mockSetAppTab);
expect(navigationApi._app?.activeTab.get).toBe(mockGetAppTab);
expect(navigationApi._app?.panelStorage.set).toBe(mockSetPanelState);
expect(navigationApi._app?.panelStorage.get).toBe(mockGetPanelState);
expect(navigationApi._app?.panelStorage.delete).toBe(mockDeletePanelState);
expect(navigationApi._app?.storage.set).toBe(mockSetStorage);
expect(navigationApi._app?.storage.get).toBe(mockGetStorage);
expect(navigationApi._app?.storage.delete).toBe(mockDeleteStorage);
});
it('should disconnect from app', () => {
@@ -128,10 +133,89 @@ describe('AppNavigationApi', () => {
});
});
describe('Tab Switching', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
it('should switch tabs', () => {
navigationApi.connectToApp(mockAppApi);
navigationApi.switchToTab('canvas');
expect(mockSetAppTab).toHaveBeenCalledWith('canvas');
});
it('should not set the tab if it is already on that tab', () => {
navigationApi.connectToApp(mockAppApi);
mockGetAppTab.mockReturnValue('canvas');
navigationApi.switchToTab('canvas');
expect(mockSetAppTab).not.toHaveBeenCalled();
});
it('should set the $isLoading atom when switching', () => {
navigationApi.connectToApp(mockAppApi);
mockGetAppTab.mockReturnValue('generate');
navigationApi.switchToTab('canvas');
expect(navigationApi.$isLoading.get()).toBe(true);
});
it('should unset the $isLoading atom after a fake delay', () => {
navigationApi.connectToApp(mockAppApi);
mockGetAppTab.mockReturnValue('generate');
navigationApi.switchToTab('canvas');
expect(navigationApi.$isLoading.get()).toBe(true);
vi.advanceTimersByTime(SWITCH_TABS_FAKE_DELAY_MS);
expect(navigationApi.$isLoading.get()).toBe(false);
});
it('should handle rapid tab changes', () => {
navigationApi.connectToApp(mockAppApi);
mockGetAppTab.mockReturnValue('generate');
navigationApi.switchToTab('canvas');
expect(navigationApi.$isLoading.get()).toBe(true);
navigationApi.switchToTab('generate');
expect(navigationApi.$isLoading.get()).toBe(true);
vi.advanceTimersByTime(SWITCH_TABS_FAKE_DELAY_MS / 5);
navigationApi.switchToTab('canvas');
expect(navigationApi.$isLoading.get()).toBe(true);
vi.advanceTimersByTime(SWITCH_TABS_FAKE_DELAY_MS / 5);
navigationApi.switchToTab('generate');
expect(navigationApi.$isLoading.get()).toBe(true);
vi.advanceTimersByTime(SWITCH_TABS_FAKE_DELAY_MS / 5);
navigationApi.switchToTab('canvas');
expect(navigationApi.$isLoading.get()).toBe(true);
vi.advanceTimersByTime(SWITCH_TABS_FAKE_DELAY_MS);
expect(navigationApi.$isLoading.get()).toBe(false);
});
it('should not switch tabs if the app is not connected', () => {
navigationApi.switchToTab('canvas');
expect(mockSetAppTab).not.toHaveBeenCalled();
});
});
describe('Panel Registration', () => {
it('should register and unregister panels', () => {
const mockPanel = createMockPanel();
const unregister = navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
const unregister = navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
expect(typeof unregister).toBe('function');
expect(navigationApi.isPanelRegistered('generate', SETTINGS_PANEL_ID)).toBe(true);
@@ -148,7 +232,7 @@ describe('AppNavigationApi', () => {
const waitPromise = navigationApi.waitForPanel('generate', SETTINGS_PANEL_ID);
// Register the panel
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
// Wait should resolve
await expect(waitPromise).resolves.toBeUndefined();
@@ -158,8 +242,8 @@ describe('AppNavigationApi', () => {
const mockPanel1 = createMockPanel();
const mockPanel2 = createMockDockPanel();
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel1);
navigationApi.registerPanel('generate', LAUNCHPAD_PANEL_ID, mockPanel2);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel1);
navigationApi._registerPanel('generate', LAUNCHPAD_PANEL_ID, mockPanel2);
expect(navigationApi.isPanelRegistered('generate', SETTINGS_PANEL_ID)).toBe(true);
expect(navigationApi.isPanelRegistered('generate', LAUNCHPAD_PANEL_ID)).toBe(true);
@@ -173,8 +257,8 @@ describe('AppNavigationApi', () => {
const mockPanel1 = createMockPanel();
const mockPanel2 = createMockPanel();
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel1);
navigationApi.registerPanel('canvas', SETTINGS_PANEL_ID, mockPanel2);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel1);
navigationApi._registerPanel('canvas', SETTINGS_PANEL_ID, mockPanel2);
expect(navigationApi.isPanelRegistered('generate', SETTINGS_PANEL_ID)).toBe(true);
expect(navigationApi.isPanelRegistered('canvas', SETTINGS_PANEL_ID)).toBe(true);
@@ -185,95 +269,6 @@ describe('AppNavigationApi', () => {
});
});
describe('Panel Storage', () => {
beforeEach(() => {
navigationApi.connectToApp(mockAppApi);
});
it('stores initial gridview state when none exists', () => {
const key = `generate:${LEFT_PANEL_ID}`;
mockGetPanelState.mockReturnValue(undefined);
const panel = createMockPanel();
// simulate real dimensions
panel.api.height = 200;
panel.api.width = 400;
navigationApi.registerPanel('generate', LEFT_PANEL_ID, panel);
expect(mockGetPanelState).toHaveBeenCalledWith(key);
expect(mockSetPanelState).toHaveBeenCalledWith(key, {
id: key,
type: 'gridview-panel',
dimensions: { height: 200, width: 400 },
});
});
it('restores gridview from stored state', () => {
const key = `generate:${LEFT_PANEL_ID}`;
const stored = { id: key, type: 'gridview-panel', dimensions: { height: 50, width: 75 } };
mockGetPanelState.mockReturnValue(stored);
const panel = createMockPanel();
navigationApi.registerPanel('generate', LEFT_PANEL_ID, panel);
expect(panel.api.setSize).toHaveBeenCalledWith({ height: 50, width: 75 });
expect(mockDeletePanelState).not.toHaveBeenCalled();
});
it('collapses gridview when stored dimensions are zero', () => {
const key = `generate:${LEFT_PANEL_ID}`;
const stored = { id: key, type: 'gridview-panel', dimensions: { height: 0, width: 0 } };
mockGetPanelState.mockReturnValue(stored);
const panel = createMockPanel();
navigationApi.registerPanel('generate', LEFT_PANEL_ID, panel);
expect(panel.api.setConstraints).toHaveBeenCalledWith({ minimumWidth: 0, maximumWidth: 0 });
expect(panel.api.setConstraints).toHaveBeenCalledWith({ minimumHeight: 0, maximumHeight: 0 });
expect(panel.api.setSize).toHaveBeenCalledWith({ height: 0, width: 0 });
});
it('stores initial dockview state when none exists', () => {
const key = `generate:${LAUNCHPAD_PANEL_ID}`;
mockGetPanelState.mockReturnValue(undefined);
const panel = createMockDockPanel();
Object.defineProperty(panel.api, 'isActive', { value: true });
navigationApi.registerPanel('generate', LAUNCHPAD_PANEL_ID, panel);
expect(mockGetPanelState).toHaveBeenCalledWith(key);
expect(mockSetPanelState).toHaveBeenCalledWith(key, {
id: key,
type: 'dockview-panel',
isActive: true,
});
});
it('restores dockview active state', () => {
const key = `generate:${LAUNCHPAD_PANEL_ID}`;
const stored = { id: key, type: 'dockview-panel', isActive: true };
mockGetPanelState.mockReturnValue(stored);
const panel = createMockDockPanel();
navigationApi.registerPanel('generate', LAUNCHPAD_PANEL_ID, panel);
expect(panel.api.setActive).toHaveBeenCalled();
});
it('deletes mismatched dockview state', () => {
const key = `generate:${LAUNCHPAD_PANEL_ID}`;
const stored = { id: key, type: 'gridview-panel', dimensions: { height: 5, width: 5 } };
mockGetPanelState.mockReturnValue(stored);
const panel = createMockDockPanel();
navigationApi.registerPanel('generate', LAUNCHPAD_PANEL_ID, panel);
expect(mockDeletePanelState).toHaveBeenCalledWith(key);
});
});
describe('Panel Focus', () => {
beforeEach(() => {
navigationApi.connectToApp(mockAppApi);
@@ -281,7 +276,7 @@ describe('AppNavigationApi', () => {
it('should focus panel in already registered tab', async () => {
const mockPanel = createMockPanel();
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
mockGetAppTab.mockReturnValue('generate');
const result = await navigationApi.focusPanel('generate', SETTINGS_PANEL_ID);
@@ -293,7 +288,7 @@ describe('AppNavigationApi', () => {
it('should switch tab before focusing panel', async () => {
const mockPanel = createMockPanel();
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
mockGetAppTab.mockReturnValue('canvas'); // Currently on different tab
const result = await navigationApi.focusPanel('generate', SETTINGS_PANEL_ID);
@@ -312,7 +307,7 @@ describe('AppNavigationApi', () => {
// Register panel after a short delay
setTimeout(() => {
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
}, 100);
const result = await focusPromise;
@@ -325,8 +320,8 @@ describe('AppNavigationApi', () => {
const mockGridPanel = createMockPanel();
const mockDockPanel = createMockDockPanel();
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockGridPanel);
navigationApi.registerPanel('generate', LAUNCHPAD_PANEL_ID, mockDockPanel);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockGridPanel);
navigationApi._registerPanel('generate', LAUNCHPAD_PANEL_ID, mockDockPanel);
mockGetAppTab.mockReturnValue('generate');
// Test gridview panel
@@ -357,7 +352,7 @@ describe('AppNavigationApi', () => {
throw new Error('Mock error');
});
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
mockGetAppTab.mockReturnValue('generate');
const result = await navigationApi.focusPanel('generate', SETTINGS_PANEL_ID);
@@ -367,7 +362,7 @@ describe('AppNavigationApi', () => {
it('should work without app connection', async () => {
const mockPanel = createMockPanel();
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
// Don't connect to app
const result = await navigationApi.focusPanel('generate', SETTINGS_PANEL_ID);
@@ -380,7 +375,7 @@ describe('AppNavigationApi', () => {
describe('Panel Waiting', () => {
it('should resolve immediately for already registered panels', async () => {
const mockPanel = createMockPanel();
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
const waitPromise = navigationApi.waitForPanel('generate', SETTINGS_PANEL_ID);
@@ -394,7 +389,7 @@ describe('AppNavigationApi', () => {
const waitPromise2 = navigationApi.waitForPanel('generate', SETTINGS_PANEL_ID);
setTimeout(() => {
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
}, 50);
await expect(Promise.all([waitPromise1, waitPromise2])).resolves.toEqual([undefined, undefined]);
@@ -403,14 +398,14 @@ describe('AppNavigationApi', () => {
it('should timeout if panel is not registered', async () => {
const waitPromise = navigationApi.waitForPanel('generate', SETTINGS_PANEL_ID, 100);
await expect(waitPromise).rejects.toThrow('Panel generate:settings registration timed out after 100ms');
await expect(waitPromise).rejects.toThrow(/Panel .* registration timed out after 100ms/);
});
it('should handle custom timeout', async () => {
const start = Date.now();
const waitPromise = navigationApi.waitForPanel('generate', SETTINGS_PANEL_ID, 200);
await expect(waitPromise).rejects.toThrow('Panel generate:settings registration timed out after 200ms');
await expect(waitPromise).rejects.toThrow(/Panel .* registration timed out after 200ms/);
const elapsed = Date.now() - start;
// TODO(psyche): Use vitest's fake timeres
@@ -426,9 +421,9 @@ describe('AppNavigationApi', () => {
const mockPanel2 = createMockPanel();
const mockPanel3 = createMockPanel();
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel1);
navigationApi.registerPanel('generate', LAUNCHPAD_PANEL_ID, mockPanel2);
navigationApi.registerPanel('canvas', SETTINGS_PANEL_ID, mockPanel3);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel1);
navigationApi._registerPanel('generate', LAUNCHPAD_PANEL_ID, mockPanel2);
navigationApi._registerPanel('canvas', SETTINGS_PANEL_ID, mockPanel3);
expect(navigationApi.getRegisteredPanels('generate')).toHaveLength(2);
expect(navigationApi.getRegisteredPanels('canvas')).toHaveLength(1);
@@ -458,7 +453,7 @@ describe('AppNavigationApi', () => {
mockGetAppTab.mockReturnValue('canvas');
// Register panel
const unregister = navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
const unregister = navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
// Focus panel (should switch tab and focus)
const result = await navigationApi.focusPanel('generate', SETTINGS_PANEL_ID);
@@ -484,9 +479,9 @@ describe('AppNavigationApi', () => {
mockGetAppTab.mockReturnValue('generate');
// Register panels
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel1);
navigationApi.registerPanel('generate', LAUNCHPAD_PANEL_ID, mockPanel2);
navigationApi.registerPanel('canvas', WORKSPACE_PANEL_ID, mockPanel3);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel1);
navigationApi._registerPanel('generate', LAUNCHPAD_PANEL_ID, mockPanel2);
navigationApi._registerPanel('canvas', WORKSPACE_PANEL_ID, mockPanel3);
// Focus panels
await navigationApi.focusPanel('generate', SETTINGS_PANEL_ID);
@@ -510,7 +505,7 @@ describe('AppNavigationApi', () => {
// Register after delay
setTimeout(() => {
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
}, 50);
const result = await focusPromise;
@@ -527,7 +522,7 @@ describe('AppNavigationApi', () => {
it('should focus panel in active tab', async () => {
const mockPanel = createMockPanel();
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
mockGetAppTab.mockReturnValue('generate');
const result = await navigationApi.focusPanelInActiveTab(SETTINGS_PANEL_ID);
@@ -583,7 +578,7 @@ describe('AppNavigationApi', () => {
describe('getPanel', () => {
it('should return registered panel', () => {
const mockPanel = createMockPanel();
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
const result = navigationApi.getPanel('generate', SETTINGS_PANEL_ID);
@@ -609,8 +604,8 @@ describe('AppNavigationApi', () => {
});
it('should expand collapsed left panel', () => {
const mockPanel = createMockPanel({ maximumWidth: 0 });
navigationApi.registerPanel('generate', LEFT_PANEL_ID, mockPanel);
const mockPanel = createMockPanel({ width: 0 });
navigationApi._registerPanel('generate', LEFT_PANEL_ID, mockPanel);
mockGetAppTab.mockReturnValue('generate');
const result = navigationApi.toggleLeftPanel();
@@ -625,7 +620,7 @@ describe('AppNavigationApi', () => {
it('should collapse expanded left panel', () => {
const mockPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER });
navigationApi.registerPanel('generate', LEFT_PANEL_ID, mockPanel);
navigationApi._registerPanel('generate', LEFT_PANEL_ID, mockPanel);
mockGetAppTab.mockReturnValue('generate');
const result = navigationApi.toggleLeftPanel();
@@ -656,7 +651,7 @@ describe('AppNavigationApi', () => {
it('should return false when panel is not GridviewPanel', () => {
const mockPanel = createMockDockPanel();
navigationApi.registerPanel('generate', LEFT_PANEL_ID, mockPanel);
navigationApi._registerPanel('generate', LEFT_PANEL_ID, mockPanel);
mockGetAppTab.mockReturnValue('generate');
const result = navigationApi.toggleLeftPanel();
@@ -671,8 +666,8 @@ describe('AppNavigationApi', () => {
});
it('should expand collapsed right panel', () => {
const mockPanel = createMockPanel({ maximumWidth: 0 });
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, mockPanel);
const mockPanel = createMockPanel({ width: 0 });
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, mockPanel);
mockGetAppTab.mockReturnValue('generate');
const result = navigationApi.toggleRightPanel();
@@ -687,7 +682,7 @@ describe('AppNavigationApi', () => {
it('should collapse expanded right panel', () => {
const mockPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER });
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, mockPanel);
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, mockPanel);
mockGetAppTab.mockReturnValue('generate');
const result = navigationApi.toggleRightPanel();
@@ -718,7 +713,7 @@ describe('AppNavigationApi', () => {
it('should return false when panel is not GridviewPanel', () => {
const mockPanel = createMockDockPanel();
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, mockPanel);
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, mockPanel);
mockGetAppTab.mockReturnValue('generate');
const result = navigationApi.toggleRightPanel();
@@ -733,11 +728,11 @@ describe('AppNavigationApi', () => {
});
it('should expand both panels when left is collapsed', () => {
const leftPanel = createMockPanel({ maximumWidth: 0 });
const leftPanel = createMockPanel({ width: 0 });
const rightPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER });
navigationApi.registerPanel('generate', LEFT_PANEL_ID, leftPanel);
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
navigationApi._registerPanel('generate', LEFT_PANEL_ID, leftPanel);
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
mockGetAppTab.mockReturnValue('generate');
const result = navigationApi.toggleLeftAndRightPanels();
@@ -757,10 +752,10 @@ describe('AppNavigationApi', () => {
it('should expand both panels when right is collapsed', () => {
const leftPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER });
const rightPanel = createMockPanel({ maximumWidth: 0 });
const rightPanel = createMockPanel({ width: 0 });
navigationApi.registerPanel('generate', LEFT_PANEL_ID, leftPanel);
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
navigationApi._registerPanel('generate', LEFT_PANEL_ID, leftPanel);
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
mockGetAppTab.mockReturnValue('generate');
const result = navigationApi.toggleLeftAndRightPanels();
@@ -782,8 +777,8 @@ describe('AppNavigationApi', () => {
const leftPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER });
const rightPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER });
navigationApi.registerPanel('generate', LEFT_PANEL_ID, leftPanel);
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
navigationApi._registerPanel('generate', LEFT_PANEL_ID, leftPanel);
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
mockGetAppTab.mockReturnValue('generate');
const result = navigationApi.toggleLeftAndRightPanels();
@@ -802,11 +797,11 @@ describe('AppNavigationApi', () => {
});
it('should expand both panels when both are collapsed', () => {
const leftPanel = createMockPanel({ maximumWidth: 0 });
const rightPanel = createMockPanel({ maximumWidth: 0 });
const leftPanel = createMockPanel({ width: 0 });
const rightPanel = createMockPanel({ width: 0 });
navigationApi.registerPanel('generate', LEFT_PANEL_ID, leftPanel);
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
navigationApi._registerPanel('generate', LEFT_PANEL_ID, leftPanel);
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
mockGetAppTab.mockReturnValue('generate');
const result = navigationApi.toggleLeftAndRightPanels();
@@ -844,8 +839,8 @@ describe('AppNavigationApi', () => {
const leftPanel = createMockDockPanel();
const rightPanel = createMockDockPanel();
navigationApi.registerPanel('generate', LEFT_PANEL_ID, leftPanel);
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
navigationApi._registerPanel('generate', LEFT_PANEL_ID, leftPanel);
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
mockGetAppTab.mockReturnValue('generate');
const result = navigationApi.toggleLeftAndRightPanels();
@@ -860,11 +855,11 @@ describe('AppNavigationApi', () => {
});
it('should reset both panels to expanded state', () => {
const leftPanel = createMockPanel({ maximumWidth: 0 });
const rightPanel = createMockPanel({ maximumWidth: 0 });
const leftPanel = createMockPanel({ width: 0 });
const rightPanel = createMockPanel({ width: 0 });
navigationApi.registerPanel('generate', LEFT_PANEL_ID, leftPanel);
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
navigationApi._registerPanel('generate', LEFT_PANEL_ID, leftPanel);
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
mockGetAppTab.mockReturnValue('generate');
const result = navigationApi.resetLeftAndRightPanels();
@@ -905,8 +900,8 @@ describe('AppNavigationApi', () => {
const leftPanel = createMockDockPanel();
const rightPanel = createMockDockPanel();
navigationApi.registerPanel('generate', LEFT_PANEL_ID, leftPanel);
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
navigationApi._registerPanel('generate', LEFT_PANEL_ID, leftPanel);
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
mockGetAppTab.mockReturnValue('generate');
const result = navigationApi.resetLeftAndRightPanels();
@@ -926,9 +921,9 @@ describe('AppNavigationApi', () => {
const settingsPanel = createMockPanel();
// Register panels
navigationApi.registerPanel('generate', LEFT_PANEL_ID, leftPanel);
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, settingsPanel);
navigationApi._registerPanel('generate', LEFT_PANEL_ID, leftPanel);
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, settingsPanel);
mockGetAppTab.mockReturnValue('generate');
// Focus a panel in active tab
@@ -955,10 +950,10 @@ describe('AppNavigationApi', () => {
it('should handle tab switching with panel operations', () => {
const generateLeftPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER });
const canvasLeftPanel = createMockPanel({ maximumWidth: 0 });
const canvasLeftPanel = createMockPanel({ width: 0 });
navigationApi.registerPanel('generate', LEFT_PANEL_ID, generateLeftPanel);
navigationApi.registerPanel('canvas', LEFT_PANEL_ID, canvasLeftPanel);
navigationApi._registerPanel('generate', LEFT_PANEL_ID, generateLeftPanel);
navigationApi._registerPanel('canvas', LEFT_PANEL_ID, canvasLeftPanel);
// Start on generate tab
mockGetAppTab.mockReturnValue('generate');
@@ -992,4 +987,122 @@ describe('AppNavigationApi', () => {
expect(focusResult).toBe(false);
});
});
describe('registerContainer', () => {
const tab = 'generate';
const viewId = 'myView';
const key = `${tab}:container:${viewId}`;
beforeEach(() => {
navigationApi = new NavigationApi();
navigationApi.connectToApp(mockAppApi);
});
it('initializes from scratch when no stored state', () => {
mockGetStorage.mockReturnValue(undefined);
const initialize = vi.fn();
const panel1 = { id: 'p1' };
const panel2 = { id: 'p2' };
const mockApi = {
panels: [panel1, panel2],
toJSON: vi.fn(() => ({ foo: 'bar' })),
onDidLayoutChange: vi.fn(() => ({ dispose: vi.fn() })),
} as unknown as DockviewApi | GridviewApi;
navigationApi.registerContainer(tab, viewId, mockApi, initialize);
expect(initialize).toHaveBeenCalledOnce();
expect(mockSetStorage).toHaveBeenCalledOnce();
expect(mockSetStorage).toHaveBeenCalledWith(key, { foo: 'bar' });
// panels registered
expect(navigationApi.isPanelRegistered(tab, 'p1')).toBe(true);
expect(navigationApi.isPanelRegistered(tab, 'p2')).toBe(true);
});
it('restores from storage when fromJSON succeeds', () => {
const stored = { saved: true };
mockGetStorage.mockReturnValue(stored);
const initialize = vi.fn();
const panel = { id: 'p' };
const mockApi = {
panels: [panel],
fromJSON: vi.fn(),
toJSON: vi.fn(),
onDidLayoutChange: vi.fn(() => ({ dispose: vi.fn() })),
} as unknown as DockviewApi | GridviewApi;
navigationApi.registerContainer(tab, viewId, mockApi, initialize);
expect(mockApi.fromJSON).toHaveBeenCalledWith(stored);
expect(initialize).not.toHaveBeenCalled();
expect(mockSetStorage).not.toHaveBeenCalled(); // no initial persist
expect(navigationApi.isPanelRegistered(tab, 'p')).toBe(true);
});
it('re-initializes when fromJSON throws, deletes then sets', () => {
const stored = { saved: true };
mockGetStorage.mockReturnValue(stored);
const initialize = vi.fn();
const panel = { id: 'p' };
const mockApi = {
panels: [panel],
fromJSON: vi.fn(() => {
throw new Error('bad');
}),
toJSON: vi.fn(() => ({ new: 'state' })),
onDidLayoutChange: vi.fn(() => ({ dispose: vi.fn() })),
} as unknown as DockviewApi | GridviewApi;
navigationApi.registerContainer(tab, viewId, mockApi, initialize);
expect(mockApi.fromJSON).toHaveBeenCalledWith(stored);
expect(mockDeleteStorage).toHaveBeenCalledOnce();
expect(mockDeleteStorage).toHaveBeenCalledWith(key);
expect(initialize).toHaveBeenCalledOnce();
expect(mockSetStorage).toHaveBeenCalledOnce();
expect(mockSetStorage).toHaveBeenCalledWith(key, { new: 'state' });
expect(navigationApi.isPanelRegistered(tab, 'p')).toBe(true);
});
it('persists on layout change after debounce', () => {
vi.useFakeTimers();
mockGetStorage.mockReturnValue(undefined);
const initialize = vi.fn();
const panel = { id: 'p' };
let layoutCb: () => void = () => {};
const mockApi = {
panels: [panel],
toJSON: vi.fn(() => ({ x: 1 })),
onDidLayoutChange: vi.fn((cb) => {
layoutCb = cb;
return { dispose: vi.fn() };
}),
} as unknown as DockviewApi | GridviewApi;
navigationApi.registerContainer(tab, viewId, mockApi, initialize);
// first set: initial persistence
expect(mockSetStorage).toHaveBeenCalledWith(key, { x: 1 });
// simulate layout change
layoutCb();
// advance past debounce (300ms)
vi.advanceTimersByTime(300);
expect(mockSetStorage).toHaveBeenCalledTimes(2);
expect(mockSetStorage).toHaveBeenLastCalledWith(key, { x: 1 });
vi.useRealTimers();
});
it('does nothing if app not connected', () => {
navigationApi.disconnectFromApp();
const initialize = vi.fn();
const mockApi = {
panels: [],
fromJSON: vi.fn(),
toJSON: vi.fn(),
onDidLayoutChange: vi.fn(),
} as unknown as DockviewApi | GridviewApi;
expect(() => navigationApi.registerContainer(tab, viewId, mockApi, initialize)).not.toThrow();
expect(mockGetStorage).not.toHaveBeenCalled();
expect(initialize).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,8 +1,11 @@
import { logger } from 'app/logging/logger';
import { createDeferredPromise, type Deferred } from 'common/util/createDeferredPromise';
import { DockviewPanel, GridviewPanel, type IDockviewPanel, type IGridviewPanel } from 'dockview';
import { parseify } from 'common/util/serialize';
import type { DockviewApi, GridviewApi, IDockviewPanel, IGridviewPanel } from 'dockview';
import { GridviewPanel } from 'dockview';
import { debounce } from 'es-toolkit';
import type { StoredDockviewPanelState, StoredGridviewPanelState, TabName } from 'features/ui/store/uiTypes';
import type { Serializable, TabName } from 'features/ui/store/uiTypes';
import type { Atom } from 'nanostores';
import { atom } from 'nanostores';
import {
@@ -27,14 +30,23 @@ type Waiter = {
timeoutId: ReturnType<typeof setTimeout> | null;
};
/**
* The API exposed by the application to manage navigation and panel states.
*/
export type NavigationAppApi = {
/**
* API to manage the currently active tab in the application.
*/
activeTab: {
get: () => TabName;
set: (tab: TabName) => void;
};
panelStorage: {
get: (id: string) => StoredDockviewPanelState | StoredGridviewPanelState | undefined;
set: (id: string, state: StoredDockviewPanelState | StoredGridviewPanelState) => void;
/**
* API to manage the storage of panel states.
*/
storage: {
get: (id: string) => Serializable | undefined;
set: (id: string, state: Serializable) => void;
delete: (id: string) => void;
};
};
@@ -54,20 +66,17 @@ export class NavigationApi {
/**
* A flag indicating if the application is currently switching tabs, which can take some time.
*/
$isSwitchingTabs = atom(false);
/**
* The timeout used to add a short additional delay when switching tabs.
*
* The time it takes to switch tabs varies depending on the tab, and sometimes it is very fast, resulting in a flicker
* of the loading screen. This timeout is used to artificially extend the time the loading screen is shown.
*/
switchingTabsTimeout: ReturnType<typeof setTimeout> | null = null;
private _$isLoading = atom(false);
$isLoading: Atom<boolean> = this._$isLoading;
/**
* Separator used to create unique keys for panels. Typo protection.
*/
KEY_SEPARATOR = ':';
/**
* The application API that provides methods to set and get the current app tab and manage panel storage.
*/
_app: NavigationAppApi | null = null;
/**
@@ -86,154 +95,46 @@ export class NavigationApi {
this._app = null;
};
/**
* Sets the flag indicating that the navigation is loading and schedules a debounced hide of the loading screen.
*/
_showFakeLoadingScreen = () => {
log.trace('Showing fake loading screen for tab switch');
this._$isLoading.set(true);
this._hideLoadingScreenDebounced();
};
/**
* Debounced function to hide the loading screen after a delay.
*/
_hideLoadingScreenDebounced = debounce(() => {
log.trace('Hiding fake loading screen for tab switch');
this._$isLoading.set(false);
}, SWITCH_TABS_FAKE_DELAY_MS);
/**
* Switch to a specific app tab.
*
* The loading screen will be shown while the tab is switching.
* The loading screen will be shown while the tab is switching (and for a little while longer to smooth out the UX).
*
* @param tab - The tab to switch to
* @return True if the switch was successful, false otherwise
*/
switchToTab = (tab: TabName): boolean => {
if (this.switchingTabsTimeout !== null) {
clearTimeout(this.switchingTabsTimeout);
this.switchingTabsTimeout = null;
}
if (tab === this._app?.activeTab.get?.()) {
return true;
}
this.$isSwitchingTabs.set(true);
log.debug(`Switching to tab: ${tab}`);
if (this._app) {
this._app.activeTab.set(tab);
return true;
} else {
log.error('No setAppTab function available to switch tabs');
if (!this._app) {
log.error('No app connected to switch tabs');
return false;
}
};
/**
* Callback for when a tab is ready after switching.
*
* Hides the loading screen after a short delay.
*/
onTabReady = (tab: TabName): void => {
this.switchingTabsTimeout = setTimeout(() => {
this.$isSwitchingTabs.set(false);
log.debug(`Tab ${tab} ready`);
}, SWITCH_TABS_FAKE_DELAY_MS);
};
_initGridviewPanelStorage = (key: string, panel: IGridviewPanel) => {
if (!this._app) {
log.error('App not connected');
return;
}
const storedState = this._app.panelStorage.get(key);
if (!storedState) {
log.debug('No stored state for panel, setting initial state');
const { height, width } = panel.api;
this._app.panelStorage.set(key, {
id: key,
type: 'gridview-panel',
dimensions: { height, width },
});
} else {
if (storedState.type !== 'gridview-panel') {
log.error(`Panel ${key} type mismatch: expected gridview-panel, got ${storedState.type}`);
this._app.panelStorage.delete(key);
return;
}
log.debug({ storedState }, 'Found stored state for panel, restoring');
// If the panel's dimensions are 0, we assume it was collapsed by the user. But when panels are initialzed,
// by default they may have a minimize dimension greater than 0. If we attempt to set a size of 0, it will
// not work - dockview will instead set the size to the minimum size.
//
// The user-facing issue is that the panel will not remember if it was collapsed or not, and will always
// be expanded when navigating to the tab.
//
// To fix this, if we find a stored state with dimensions of 0, we set the constraints to 0 before setting the
// size.
if (storedState.dimensions.width === 0) {
panel.api.setConstraints({ minimumWidth: 0, maximumWidth: 0 });
}
if (storedState.dimensions.height === 0) {
panel.api.setConstraints({ minimumHeight: 0, maximumHeight: 0 });
}
panel.api.setSize(storedState.dimensions);
}
const { dispose } = panel.api.onDidDimensionsChange(
debounce(({ width, height }) => {
log.debug({ key, width, height }, 'Panel dimensions changed');
if (!this._app) {
log.error('App not connected');
return;
}
this._app.panelStorage.set(key, {
id: key,
type: 'gridview-panel',
dimensions: { width, height },
});
}, 1000)
);
return dispose;
};
_initDockviewPanelStorage = (key: string, panel: IDockviewPanel) => {
if (!this._app) {
log.error('App not connected');
return;
}
const storedState = this._app.panelStorage.get(key);
if (!storedState) {
const { isActive } = panel.api;
this._app.panelStorage.set(key, {
id: key,
type: 'dockview-panel',
isActive,
});
} else {
if (storedState.type !== 'dockview-panel') {
log.error(`Panel ${key} type mismatch: expected dockview-panel, got ${storedState.type}`);
this._app.panelStorage.delete(key);
return;
}
if (storedState.isActive) {
panel.api.setActive();
}
if (tab === this._app.activeTab.get()) {
log.trace(`Already on tab: ${tab}`);
return true;
}
const { dispose } = panel.api.onDidActiveChange(
debounce(({ isActive }) => {
if (!this._app) {
log.error('App not connected');
return;
}
this._app.panelStorage.set(key, {
id: key,
type: 'dockview-panel',
isActive,
});
}, 1000)
);
return dispose;
};
_initPanelStorage = (key: string, panel: PanelType) => {
if (panel instanceof GridviewPanel) {
return this._initGridviewPanelStorage(key, panel);
} else if (panel instanceof DockviewPanel) {
return this._initDockviewPanelStorage(key, panel);
} else {
log.error(`Unsupported panel type: ${panel.constructor.name}`);
return;
}
log.trace(`Switching to tab: ${tab}`);
this._showFakeLoadingScreen();
this._app.activeTab.set(tab);
return true;
};
/**
@@ -244,13 +145,11 @@ export class NavigationApi {
* @param panel - The panel instance
* @returns Cleanup function to unregister the panel
*/
registerPanel = (tab: TabName, panelId: string, panel: PanelType): (() => void) => {
_registerPanel = <T extends PanelType>(tab: TabName, panelId: string, panel: T): (() => void) => {
const key = this._getPanelKey(tab, panelId);
this.panels.set(key, panel);
const cleanupPanelStorage = this._initPanelStorage(key, panel);
// Resolve any pending waiters for this panel, notifying them that the panel is now registered.
const waiter = this.waiters.get(key);
if (waiter) {
@@ -261,15 +160,64 @@ export class NavigationApi {
this.waiters.delete(key);
}
log.debug(`Registered panel ${key}`);
log.trace(`Registered panel ${key}`);
return () => {
cleanupPanelStorage?.();
this.panels.delete(key);
log.debug(`Unregistered panel ${key}`);
log.trace(`Unregistered panel ${key}`);
};
};
/**
* Registers a container (Dockview or Gridview) with the navigation API.
*
* This method initializes the container from storage if available, or calls the provided initialize function
* to set it up from scratch.
*
* @param tab - The tab this container belongs to
* @param id - Unique identifier for the container
* @param api - The DockviewApi or GridviewApi instance
* @param initialize - Function to call if the container needs to be initialized from scratch
*/
registerContainer = (tab: TabName, id: string, api: DockviewApi | GridviewApi, initialize: () => void) => {
if (!this._app) {
log.error('App not connected to register view');
return;
}
const key = this._getContainerKey(tab, id);
const stored = this._app.storage.get(key);
if (stored) {
try {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
api.fromJSON(stored as any);
log.trace({ stored: parseify(stored) }, `Restored view ${key} from storage`);
} catch (error) {
log.error({ error: parseify(error) }, `Failed to restore view ${key} from storage`);
this._app.storage.delete(key);
initialize();
this._app.storage.set(key, api.toJSON());
}
} else {
initialize();
log.trace(`Initialized ${key} from scratch`);
this._app.storage.set(key, api.toJSON());
}
for (const panel of api.panels) {
this._registerPanel(tab, panel.id, panel);
}
api.onDidLayoutChange(
debounce(() => {
this._app?.storage.set(key, api.toJSON());
}, 300)
);
log.trace(`Registered view ${key}`);
};
/**
* Waits for a panel to be ready.
*
@@ -319,17 +267,38 @@ export class NavigationApi {
};
/**
* Get the prefix for a tab to create unique keys for panels.
* Get the prefix for a tab to create unique keys for panels/containers.
*/
_getTabPrefix = (tab: TabName): string => {
return `${tab}${this.KEY_SEPARATOR}`;
};
/**
* Gets a prefix for a panel based on its tab.
*/
_getPanelPrefix = (tab: TabName): string => {
return `${this._getTabPrefix(tab)}panel${this.KEY_SEPARATOR}`;
};
/**
* Get the unique key for a panel based on its tab and ID.
*/
_getPanelKey = (tab: TabName, panelId: string): string => {
return `${this._getTabPrefix(tab)}${panelId}`;
return `${this._getPanelPrefix(tab)}${panelId}`;
};
/**
* Gets a prefix for a container based on its tab.
*/
_getContainerPrefix = (tab: TabName): string => {
return `${this._getTabPrefix(tab)}container${this.KEY_SEPARATOR}`;
};
/**
* Get the unique key for a container based on its tab and ID.
*/
_getContainerKey = (tab: TabName, viewId: string): string => {
return `${this._getContainerPrefix(tab)}${viewId}`;
};
/**
@@ -368,7 +337,7 @@ export class NavigationApi {
// Dockview uses the term "active", but we use "focused" for consistency.
panel.api.setActive();
log.debug(`Focused panel ${key}`);
log.trace(`Focused panel ${key}`);
return true;
} catch (error) {
@@ -458,7 +427,7 @@ export class NavigationApi {
return false;
}
const isCollapsed = leftPanel.maximumWidth === 0;
const isCollapsed = leftPanel.width === 0;
if (isCollapsed) {
this._expandPanel(leftPanel, LEFT_PANEL_MIN_SIZE_PX);
} else {
@@ -491,7 +460,7 @@ export class NavigationApi {
return false;
}
const isCollapsed = rightPanel.maximumWidth === 0;
const isCollapsed = rightPanel.width === 0;
if (isCollapsed) {
this._expandPanel(rightPanel, RIGHT_PANEL_MIN_SIZE_PX);
} else {
@@ -527,8 +496,8 @@ export class NavigationApi {
return false;
}
const isLeftCollapsed = leftPanel.maximumWidth === 0;
const isRightCollapsed = rightPanel.maximumWidth === 0;
const isLeftCollapsed = leftPanel.width === 0;
const isRightCollapsed = rightPanel.width === 0;
if (isLeftCollapsed || isRightCollapsed) {
this._expandPanel(leftPanel, LEFT_PANEL_MIN_SIZE_PX);
@@ -593,7 +562,7 @@ export class NavigationApi {
* @returns Array of panel IDs
*/
getRegisteredPanels = (tab: TabName): string[] => {
const prefix = this._getTabPrefix(tab);
const prefix = this._getPanelPrefix(tab);
return Array.from(this.panels.keys())
.filter((key) => key.startsWith(prefix))
.map((key) => key.substring(prefix.length));
@@ -604,7 +573,7 @@ export class NavigationApi {
* @param tab - The tab to unregister panels for
*/
unregisterTab = (tab: TabName): void => {
const prefix = this._getTabPrefix(tab);
const prefix = this._getPanelPrefix(tab);
const keysToDelete = Array.from(this.panels.keys()).filter((key) => key.startsWith(prefix));
for (const key of keysToDelete) {
@@ -624,7 +593,7 @@ export class NavigationApi {
this.waiters.delete(key);
}
log.debug(`Unregistered all panels for tab ${tab}`);
log.trace(`Unregistered all panels for tab ${tab}`);
};
}

View File

@@ -1,8 +1,9 @@
import type { GridviewApi, IGridviewPanel, IGridviewReactProps } from 'dockview';
import type { GridviewApi, IGridviewReactProps } from 'dockview';
import { GridviewReact, LayoutPriority, Orientation } from 'dockview';
import QueueTab from 'features/ui/components/tabs/QueueTab';
import type { RootLayoutGridviewComponents } from 'features/ui/layouts/auto-layout-context';
import { AutoLayoutProvider } from 'features/ui/layouts/auto-layout-context';
import type { TabName } from 'features/ui/store/uiTypes';
import { memo, useCallback, useEffect } from 'react';
import { navigationApi } from './navigation-api';
@@ -12,22 +13,19 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
[QUEUE_PANEL_ID]: QueueTab,
};
const initializeRootPanelLayout = (layoutApi: GridviewApi) => {
const queue = layoutApi.addPanel({
id: QUEUE_PANEL_ID,
component: QUEUE_PANEL_ID,
priority: LayoutPriority.High,
const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => {
navigationApi.registerContainer(tab, 'root', api, () => {
api.addPanel({
id: QUEUE_PANEL_ID,
component: QUEUE_PANEL_ID,
priority: LayoutPriority.High,
});
});
navigationApi.registerPanel('queue', QUEUE_PANEL_ID, queue);
return { queue } satisfies Record<string, IGridviewPanel>;
};
export const QueueTabAutoLayout = memo(() => {
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
initializeRootPanelLayout(api);
navigationApi.onTabReady('queue');
initializeRootPanelLayout('queue', api);
}, []);
useEffect(

View File

@@ -1,11 +1,4 @@
import type {
DockviewApi,
GridviewApi,
IDockviewPanel,
IDockviewReactProps,
IGridviewPanel,
IGridviewReactProps,
} from 'dockview';
import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview';
import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview';
import { UpscalingLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/UpscalingLaunchpadPanel';
import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent';
@@ -64,46 +57,42 @@ const mainPanelComponents: AutoLayoutDockviewComponents = {
[PROGRESS_PANEL_ID]: withPanelContainer(GenerationProgressPanel),
};
const initializeCenterPanelLayout = (tab: TabName, api: DockviewApi) => {
const launchpad = api.addPanel<PanelParameters>({
id: LAUNCHPAD_PANEL_ID,
component: LAUNCHPAD_PANEL_ID,
title: 'Launchpad',
tabComponent: TAB_WITH_LAUNCHPAD_ICON_ID,
params: {
tab,
focusRegion: 'launchpad',
},
const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => {
navigationApi.registerContainer(tab, 'main', api, () => {
const launchpad = api.addPanel<PanelParameters>({
id: LAUNCHPAD_PANEL_ID,
component: LAUNCHPAD_PANEL_ID,
title: 'Launchpad',
tabComponent: TAB_WITH_LAUNCHPAD_ICON_ID,
params: {
tab,
focusRegion: 'launchpad',
},
});
api.addPanel<PanelParameters>({
id: VIEWER_PANEL_ID,
component: VIEWER_PANEL_ID,
title: 'Image Viewer',
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
params: {
tab,
focusRegion: 'viewer',
},
position: {
direction: 'within',
referencePanel: launchpad.id,
},
});
launchpad.api.setActive();
});
const viewer = api.addPanel<PanelParameters>({
id: VIEWER_PANEL_ID,
component: VIEWER_PANEL_ID,
title: 'Image Viewer',
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
params: {
tab,
focusRegion: 'viewer',
},
position: {
direction: 'within',
referencePanel: launchpad.id,
},
});
launchpad.api.setActive();
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
return { launchpad, viewer } satisfies Record<string, IDockviewPanel>;
};
const MainPanel = memo(() => {
const { tab } = useAutoLayoutContext();
const onReady = useCallback<IDockviewReactProps['onReady']>(
({ api }) => {
initializeCenterPanelLayout(tab, api);
initializeMainPanelLayout(tab, api);
},
[tab]
);
@@ -133,39 +122,35 @@ const rightPanelComponents: AutoLayoutGridviewComponents = {
};
const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => {
const gallery = api.addPanel<PanelParameters>({
id: GALLERY_PANEL_ID,
component: GALLERY_PANEL_ID,
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'gallery',
},
navigationApi.registerContainer(tab, 'right', api, () => {
const gallery = api.addPanel<PanelParameters>({
id: GALLERY_PANEL_ID,
component: GALLERY_PANEL_ID,
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'gallery',
},
});
const boards = api.addPanel<PanelParameters>({
id: BOARDS_PANEL_ID,
component: BOARDS_PANEL_ID,
minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'boards',
},
position: {
direction: 'above',
referencePanel: gallery.id,
},
});
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX });
boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX });
});
const boards = api.addPanel<PanelParameters>({
id: BOARDS_PANEL_ID,
component: BOARDS_PANEL_ID,
minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'boards',
},
position: {
direction: 'above',
referencePanel: gallery.id,
},
});
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
// Register panels with navigation API
navigationApi.registerPanel(tab, GALLERY_PANEL_ID, gallery);
navigationApi.registerPanel(tab, BOARDS_PANEL_ID, boards);
return { gallery, boards } satisfies Record<string, IGridviewPanel>;
};
const RightPanel = memo(() => {
@@ -193,19 +178,16 @@ const leftPanelComponents: AutoLayoutGridviewComponents = {
};
const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => {
const settings = api.addPanel<PanelParameters>({
id: SETTINGS_PANEL_ID,
component: SETTINGS_PANEL_ID,
params: {
tab,
focusRegion: 'settings',
},
navigationApi.registerContainer(tab, 'left', api, () => {
api.addPanel<PanelParameters>({
id: SETTINGS_PANEL_ID,
component: SETTINGS_PANEL_ID,
params: {
tab,
focusRegion: 'settings',
},
});
});
// Register panel with navigation API
navigationApi.registerPanel(tab, SETTINGS_PANEL_ID, settings);
return { settings } satisfies Record<string, IGridviewPanel>;
};
const LeftPanel = memo(() => {
@@ -235,47 +217,42 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
[RIGHT_PANEL_ID]: RightPanel,
};
const initializeRootPanelLayout = (layoutApi: GridviewApi) => {
const main = layoutApi.addPanel({
id: MAIN_PANEL_ID,
component: MAIN_PANEL_ID,
priority: LayoutPriority.High,
const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => {
navigationApi.registerContainer(tab, 'root', api, () => {
const main = api.addPanel({
id: MAIN_PANEL_ID,
component: MAIN_PANEL_ID,
priority: LayoutPriority.High,
});
const left = api.addPanel({
id: LEFT_PANEL_ID,
component: LEFT_PANEL_ID,
minimumWidth: LEFT_PANEL_MIN_SIZE_PX,
position: {
direction: 'left',
referencePanel: main.id,
},
});
const right = api.addPanel({
id: RIGHT_PANEL_ID,
component: RIGHT_PANEL_ID,
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
position: {
direction: 'right',
referencePanel: main.id,
},
});
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
});
const left = layoutApi.addPanel({
id: LEFT_PANEL_ID,
component: LEFT_PANEL_ID,
minimumWidth: LEFT_PANEL_MIN_SIZE_PX,
position: {
direction: 'left',
referencePanel: main.id,
},
});
const right = layoutApi.addPanel({
id: RIGHT_PANEL_ID,
component: RIGHT_PANEL_ID,
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
position: {
direction: 'right',
referencePanel: main.id,
},
});
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
navigationApi.registerPanel('upscaling', LEFT_PANEL_ID, left);
navigationApi.registerPanel('upscaling', MAIN_PANEL_ID, main);
navigationApi.registerPanel('upscaling', RIGHT_PANEL_ID, right);
return { main, left, right } satisfies Record<string, IGridviewPanel>;
};
export const UpscalingTabAutoLayout = memo(() => {
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
initializeRootPanelLayout(api);
navigationApi.onTabReady('upscaling');
initializeRootPanelLayout('upscaling', api);
}, []);
useEffect(

View File

@@ -11,9 +11,9 @@ const getIsCollapsed = (
collapsedSize?: number
) => {
if (orientation === 'vertical') {
return panel.height <= (collapsedSize ?? panel.minimumHeight);
return panel.height <= (collapsedSize ?? panel.minimumHeight ?? 0);
}
return panel.width <= (collapsedSize ?? panel.minimumWidth);
return panel.width <= (collapsedSize ?? panel.minimumWidth ?? 0);
};
export const useCollapsibleGridviewPanel = (
@@ -36,9 +36,9 @@ export const useCollapsibleGridviewPanel = (
lastExpandedSizeRef.current = orientation === 'vertical' ? panel.height : panel.width;
if (orientation === 'vertical') {
panel.api.setSize({ height: collapsedSize ?? panel.minimumHeight });
panel.api.setSize({ height: collapsedSize ?? panel.minimumHeight ?? 0 });
} else {
panel.api.setSize({ width: collapsedSize ?? panel.minimumWidth });
panel.api.setSize({ width: collapsedSize ?? panel.minimumWidth ?? 0 });
}
}, [collapsedSize, orientation, panelId, tab]);

View File

@@ -0,0 +1,22 @@
import type { RefObject } from 'react';
import { useEffect } from 'react';
/**
* Prevent undesired dnd behavior in Dockview tabs.
*
* Dockview always sets the draggable flag on its tab elements, even when dnd is disabled. This hook traverses
* up from the provided ref to find the closest tab element and sets its `draggable` attribute to `false`.
*/
export const useHackOutDvTabDraggable = (ref: RefObject<HTMLElement>) => {
useEffect(() => {
const el = ref.current;
if (!el) {
return;
}
const parentTab = el.closest('.dv-tab');
if (!parentTab) {
return;
}
parentTab.setAttribute('draggable', 'false');
}, [ref]);
};

View File

@@ -1,9 +1,10 @@
import { useAppStore } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { panelStateChanged, setActiveTab } from 'features/ui/store/uiSlice';
import type { StoredDockviewPanelState, StoredGridviewPanelState, TabName } from 'features/ui/store/uiTypes';
import { dockviewStorageKeyChanged, setActiveTab } from 'features/ui/store/uiSlice';
import type { TabName } from 'features/ui/store/uiTypes';
import { useEffect, useMemo } from 'react';
import type { JsonObject } from 'type-fest';
import { navigationApi } from './navigation-api';
@@ -25,15 +26,15 @@ export const useNavigationApi = () => {
store.dispatch(setActiveTab(tab));
},
},
panelStorage: {
storage: {
get: (id: string) => {
return store.getState().ui.panels[id];
},
set: (id: string, state: StoredDockviewPanelState | StoredGridviewPanelState) => {
store.dispatch(panelStateChanged({ id, state }));
set: (id: string, state: JsonObject) => {
store.dispatch(dockviewStorageKeyChanged({ id, state }));
},
delete: (id: string) => {
store.dispatch(panelStateChanged({ id, state: undefined }));
store.dispatch(dockviewStorageKeyChanged({ id, state: undefined }));
},
},
}),

View File

@@ -1,11 +1,4 @@
import type {
DockviewApi,
GridviewApi,
IDockviewPanel,
IDockviewReactProps,
IGridviewPanel,
IGridviewReactProps,
} from 'dockview';
import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview';
import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview';
import { WorkflowsLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/WorkflowsLaunchpadPanel';
import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent';
@@ -68,54 +61,50 @@ const mainPanelComponents: AutoLayoutDockviewComponents = {
};
const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => {
const launchpad = api.addPanel<PanelParameters>({
id: LAUNCHPAD_PANEL_ID,
component: LAUNCHPAD_PANEL_ID,
title: 'Launchpad',
tabComponent: TAB_WITH_LAUNCHPAD_ICON_ID,
params: {
tab,
focusRegion: 'launchpad',
},
navigationApi.registerContainer(tab, 'main', api, () => {
const launchpad = api.addPanel<PanelParameters>({
id: LAUNCHPAD_PANEL_ID,
component: LAUNCHPAD_PANEL_ID,
title: 'Launchpad',
tabComponent: TAB_WITH_LAUNCHPAD_ICON_ID,
params: {
tab,
focusRegion: 'launchpad',
},
});
api.addPanel<PanelParameters>({
id: WORKSPACE_PANEL_ID,
component: WORKSPACE_PANEL_ID,
title: 'Workflow Editor',
tabComponent: DEFAULT_TAB_ID,
params: {
tab,
focusRegion: 'workflows',
},
position: {
direction: 'within',
referencePanel: launchpad.id,
},
});
api.addPanel<PanelParameters>({
id: VIEWER_PANEL_ID,
component: VIEWER_PANEL_ID,
title: 'Image Viewer',
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
params: {
tab,
focusRegion: 'viewer',
},
position: {
direction: 'within',
referencePanel: launchpad.id,
},
});
launchpad.api.setActive();
});
const workspace = api.addPanel<PanelParameters>({
id: WORKSPACE_PANEL_ID,
component: WORKSPACE_PANEL_ID,
title: 'Workflow Editor',
tabComponent: DEFAULT_TAB_ID,
params: {
tab,
focusRegion: 'workflows',
},
position: {
direction: 'within',
referencePanel: launchpad.id,
},
});
const viewer = api.addPanel<PanelParameters>({
id: VIEWER_PANEL_ID,
component: VIEWER_PANEL_ID,
title: 'Image Viewer',
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
params: {
tab,
focusRegion: 'viewer',
},
position: {
direction: 'within',
referencePanel: launchpad.id,
},
});
launchpad.api.setActive();
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
navigationApi.registerPanel(tab, WORKSPACE_PANEL_ID, workspace);
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
return { launchpad, workspace, viewer } satisfies Record<string, IDockviewPanel>;
};
const MainPanel = memo(() => {
@@ -153,39 +142,35 @@ const rightPanelComponents: AutoLayoutGridviewComponents = {
};
const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => {
const gallery = api.addPanel<PanelParameters>({
id: GALLERY_PANEL_ID,
component: GALLERY_PANEL_ID,
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'gallery',
},
navigationApi.registerContainer(tab, 'right', api, () => {
const gallery = api.addPanel<PanelParameters>({
id: GALLERY_PANEL_ID,
component: GALLERY_PANEL_ID,
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'gallery',
},
});
const boards = api.addPanel<PanelParameters>({
id: BOARDS_PANEL_ID,
component: BOARDS_PANEL_ID,
minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'boards',
},
position: {
direction: 'above',
referencePanel: gallery.id,
},
});
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX });
boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX });
});
const boards = api.addPanel<PanelParameters>({
id: BOARDS_PANEL_ID,
component: BOARDS_PANEL_ID,
minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX,
params: {
tab,
focusRegion: 'boards',
},
position: {
direction: 'above',
referencePanel: gallery.id,
},
});
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
// Register panels with navigation API
navigationApi.registerPanel(tab, GALLERY_PANEL_ID, gallery);
navigationApi.registerPanel(tab, BOARDS_PANEL_ID, boards);
return { gallery, boards } satisfies Record<string, IGridviewPanel>;
};
const RightPanel = memo(() => {
@@ -213,19 +198,16 @@ const leftPanelComponents: AutoLayoutGridviewComponents = {
};
const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => {
const settings = api.addPanel<PanelParameters>({
id: SETTINGS_PANEL_ID,
component: SETTINGS_PANEL_ID,
params: {
tab,
focusRegion: 'settings',
},
navigationApi.registerContainer(tab, 'left', api, () => {
api.addPanel<PanelParameters>({
id: SETTINGS_PANEL_ID,
component: SETTINGS_PANEL_ID,
params: {
tab,
focusRegion: 'settings',
},
});
});
// Register panel with navigation API
navigationApi.registerPanel(tab, SETTINGS_PANEL_ID, settings);
return { settings } satisfies Record<string, IGridviewPanel>;
};
const LeftPanel = memo(() => {
@@ -254,45 +236,42 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
[RIGHT_PANEL_ID]: RightPanel,
};
const initializeRootPanelLayout = (api: GridviewApi) => {
const main = api.addPanel({
id: MAIN_PANEL_ID,
component: MAIN_PANEL_ID,
priority: LayoutPriority.High,
});
const left = api.addPanel({
id: LEFT_PANEL_ID,
component: LEFT_PANEL_ID,
minimumWidth: LEFT_PANEL_MIN_SIZE_PX,
position: {
direction: 'left',
referencePanel: MAIN_PANEL_ID,
},
});
const right = api.addPanel({
id: RIGHT_PANEL_ID,
component: RIGHT_PANEL_ID,
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
position: {
direction: 'right',
referencePanel: MAIN_PANEL_ID,
},
});
const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => {
navigationApi.registerContainer(tab, 'root', api, () => {
const main = api.addPanel({
id: MAIN_PANEL_ID,
component: MAIN_PANEL_ID,
priority: LayoutPriority.High,
});
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
const left = api.addPanel({
id: LEFT_PANEL_ID,
component: LEFT_PANEL_ID,
minimumWidth: LEFT_PANEL_MIN_SIZE_PX,
position: {
direction: 'left',
referencePanel: main.id,
},
});
navigationApi.registerPanel('workflows', LEFT_PANEL_ID, left);
navigationApi.registerPanel('workflows', MAIN_PANEL_ID, main);
navigationApi.registerPanel('workflows', RIGHT_PANEL_ID, right);
const right = api.addPanel({
id: RIGHT_PANEL_ID,
component: RIGHT_PANEL_ID,
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
position: {
direction: 'right',
referencePanel: main.id,
},
});
return { main, left, right } satisfies Record<string, IGridviewPanel>;
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
});
};
export const WorkflowsTabAutoLayout = memo(() => {
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
initializeRootPanelLayout(api);
navigationApi.onTabReady('workflows');
initializeRootPanelLayout('workflows', api);
}, []);
useEffect(

View File

@@ -51,7 +51,7 @@ export const uiSlice = createSlice({
const { id, size } = action.payload;
state.textAreaSizes[id] = size;
},
panelStateChanged: (
dockviewStorageKeyChanged: (
state,
action: PayloadAction<{
id: keyof UIState['panels'];
@@ -80,7 +80,7 @@ export const {
expanderStateChanged,
shouldShowNotificationChanged,
textAreaSizesStateChanged,
panelStateChanged,
dockviewStorageKeyChanged,
} = uiSlice.actions;
export const selectUiSlice = (state: RootState) => state.ui;

View File

@@ -1,4 +1,5 @@
import { deepClone } from 'common/util/deepClone';
import { isPlainObject } from 'es-toolkit';
import { z } from 'zod/v4';
const zTabName = z.enum(['generate', 'canvas', 'upscaling', 'workflows', 'models', 'queue']);
@@ -10,35 +11,19 @@ const zPartialDimensions = z.object({
height: z.number().optional(),
});
const zDimensions = z.object({
width: z.number(),
height: z.number(),
});
const zDockviewPanelState = z.object({
id: z.string(),
type: z.literal('dockview-panel'),
isActive: z.boolean(),
});
export type StoredDockviewPanelState = z.infer<typeof zDockviewPanelState>;
const zGridviewPanelState = z.object({
id: z.string(),
type: z.literal('gridview-panel'),
dimensions: zDimensions,
});
export type StoredGridviewPanelState = z.infer<typeof zGridviewPanelState>;
const zSerializable = z.any().refine(isPlainObject);
export type Serializable = z.infer<typeof zSerializable>;
const zUIState = z.object({
_version: z.literal(3).default(3),
activeTab: zTabName.default('canvas'),
activeTab: zTabName.default('generate'),
activeTabCanvasRightPanel: zCanvasRightPanelTabName.default('gallery'),
shouldShowImageDetails: z.boolean().default(false),
shouldShowProgressInViewer: z.boolean().default(true),
accordions: z.record(z.string(), z.boolean()).default(() => ({})),
expanders: z.record(z.string(), z.boolean()).default(() => ({})),
textAreaSizes: z.record(z.string(), zPartialDimensions).default({}),
panels: z.record(z.string(), z.discriminatedUnion('type', [zDockviewPanelState, zGridviewPanelState])).default({}),
panels: z.record(z.string(), zSerializable).default({}),
shouldShowNotificationV2: z.boolean().default(true),
});
const INITIAL_STATE = zUIState.parse({});

View File

@@ -263,7 +263,6 @@ export const imagesApi = api.injectEndpoints({
},
};
},
invalidatesTags: (result) => {
if (!result || result.is_intermediate) {
// Don't add it to anything
@@ -276,6 +275,7 @@ export const imagesApi = api.injectEndpoints({
...getTagsToInvalidateForBoardAffectingMutation([boardId]),
'ImageCollectionCounts',
{ type: 'ImageCollection', id: LIST_TAG },
'ImageNameList',
];
},
}),

View File

@@ -1,4 +1,8 @@
import type { EntityState, ThunkDispatch, UnknownAction } from '@reduxjs/toolkit';
import { createEntityAdapter } from '@reduxjs/toolkit';
import { getSelectorsOptions } from 'app/store/createMemoizedSelector';
import { $queueId } from 'app/store/nanostores/queueId';
import { listParamsReset } from 'features/queue/store/queueSlice';
import queryString from 'query-string';
import type { components, paths } from 'services/api/schema';
@@ -31,6 +35,30 @@ export type SessionQueueItemStatus = NonNullable<
NonNullable<paths['/api/v1/queue/{queue_id}/list']['get']['parameters']['query']>['status']
>;
export const queueItemsAdapter = createEntityAdapter<components['schemas']['SessionQueueItem'], string>({
selectId: (queueItem) => String(queueItem.item_id),
sortComparer: (a, b) => {
// Sort by priority in descending order
if (a.priority > b.priority) {
return -1;
}
if (a.priority < b.priority) {
return 1;
}
// If priority is the same, sort by id in ascending order
if (a.item_id < b.item_id) {
return -1;
}
if (a.item_id > b.item_id) {
return 1;
}
return 0;
},
});
export const queueItemsAdapterSelectors = queueItemsAdapter.getSelectors(undefined, getSelectorsOptions);
export const queueApi = api.injectEndpoints({
endpoints: (build) => ({
enqueueBatch: build.mutation<
@@ -50,6 +78,57 @@ export const queueApi = api.injectEndpoints({
{ type: 'SessionQueueItem', id: LIST_TAG },
{ type: 'SessionQueueItem', id: LIST_ALL_TAG },
],
onQueryStarted: async (arg, api) => {
const { dispatch, queryFulfilled } = api;
try {
const { data } = await queryFulfilled;
resetListQueryData(dispatch);
/**
* When a batch is enqueued, we need to update the queue status. While it might be templting to invalidate the
* `SessionQueueStatus` tag here, this can introduce a race condition when the queue item executes quickly:
*
* - Enqueue via this query
* - On success, we invalidate `SessionQueueStatus` tag - network request sent to server
* - The server gets the queue status request and responds, but this takes some time... in the meantime:
* - The new queue item starts executing, and we receive a socket queue item status changed event
* - We optimistically update the queue status in the queue item status changed socket handler
* - At this point, the queue status is correct
* - Finally, we get the queue status from the tag invalidation request - but it's reporting the queue status
* from _before_ the last queue event
* - The queue status is now incorrect!
*
* Ok, what if we just never did optimistic updates and invalidated the tag in the queue event handlers instead?
* It's much simpler that way, but it causes a lot of network requests - 3 per queue item, as it moves from
* pending -> in_progress -> completed/failed/canceled.
*
* We can do a bit of extra work here, incrementing the pending and total counts in the queue status, and do
* similar optimistic updates in the socket handler. Because this optimistic update runs immediately after the
* enqueue network request, it should always occur _before_ the next queue event, so no race condition:
*
* - Enqueue batch via this query
* - On success, optimistically update - this happens immediately on the HTTP OK - before the next queue event
* - At this point, the queue status is correct
* - A queue item status changes and we receive a socket event w/ updated status
* - Update status optimistically in socket handler
* - Queue status is still correct
*
* This problem occurs most commonly with canvas filters like Canny edge detection, which are single-node
* graphs that execute very quickly. Image generation graphs take long enough to not trigger this race
* condition - even when all nodes are cached on the server.
*/
dispatch(
queueApi.util.updateQueryData('getQueueStatus', undefined, (draft) => {
if (!draft) {
return;
}
draft.queue.pending += data.enqueued;
draft.queue.total += data.enqueued;
})
);
} catch {
// no-op
}
},
}),
resumeProcessor: build.mutation<
paths['/api/v1/queue/{queue_id}/processor/resume']['put']['responses']['200']['content']['application/json'],
@@ -85,6 +164,15 @@ export const queueApi = api.injectEndpoints({
{ type: 'SessionQueueItem', id: LIST_TAG },
{ type: 'SessionQueueItem', id: LIST_ALL_TAG },
],
onQueryStarted: async (arg, api) => {
const { dispatch, queryFulfilled } = api;
try {
await queryFulfilled;
resetListQueryData(dispatch);
} catch {
// no-op
}
},
}),
clearQueue: build.mutation<
paths['/api/v1/queue/{queue_id}/clear']['put']['responses']['200']['content']['application/json'],
@@ -104,6 +192,15 @@ export const queueApi = api.injectEndpoints({
{ type: 'SessionQueueItem', id: LIST_TAG },
{ type: 'SessionQueueItem', id: LIST_ALL_TAG },
],
onQueryStarted: async (arg, api) => {
const { dispatch, queryFulfilled } = api;
try {
await queryFulfilled;
resetListQueryData(dispatch);
} catch {
// no-op
}
},
}),
getCurrentQueueItem: build.query<
paths['/api/v1/queue/{queue_id}/current']['get']['responses']['200']['content']['application/json'],
@@ -187,6 +284,25 @@ export const queueApi = api.injectEndpoints({
url: buildQueueUrl(`i/${item_id}/cancel`),
method: 'PUT',
}),
onQueryStarted: async (item_id, { dispatch, queryFulfilled }) => {
try {
const { data } = await queryFulfilled;
dispatch(
queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => {
queueItemsAdapter.updateOne(draft, {
id: String(item_id),
changes: {
status: data.status,
completed_at: data.completed_at,
updated_at: data.updated_at,
},
});
})
);
} catch {
// no-op
}
},
invalidatesTags: (result) => {
if (!result) {
return [];
@@ -210,6 +326,15 @@ export const queueApi = api.injectEndpoints({
method: 'PUT',
body,
}),
onQueryStarted: async (arg, api) => {
const { dispatch, queryFulfilled } = api;
try {
await queryFulfilled;
resetListQueryData(dispatch);
} catch {
// no-op
}
},
invalidatesTags: (result, error, { batch_ids }) => {
if (!result) {
return [];
@@ -256,16 +381,6 @@ export const queueApi = api.injectEndpoints({
}),
invalidatesTags: ['SessionQueueStatus', 'BatchStatus', 'QueueCountsByDestination', 'SessionQueueItem'],
}),
deleteAllExceptCurrent: build.mutation<
paths['/api/v1/queue/{queue_id}/delete_all_except_current']['put']['responses']['200']['content']['application/json'],
void
>({
query: () => ({
url: buildQueueUrl('delete_all_except_current'),
method: 'PUT',
}),
invalidatesTags: ['SessionQueueStatus', 'BatchStatus', 'QueueCountsByDestination', 'SessionQueueItem'],
}),
retryItemsById: build.mutation<
paths['/api/v1/queue/{queue_id}/retry_items_by_id']['put']['responses']['200']['content']['application/json'],
paths['/api/v1/queue/{queue_id}/retry_items_by_id']['put']['requestBody']['content']['application/json']
@@ -275,6 +390,15 @@ export const queueApi = api.injectEndpoints({
method: 'PUT',
body,
}),
onQueryStarted: async (arg, api) => {
const { dispatch, queryFulfilled } = api;
try {
await queryFulfilled;
resetListQueryData(dispatch);
} catch {
// no-op
}
},
invalidatesTags: (result, error, item_ids) => {
if (!result) {
return [];
@@ -290,24 +414,31 @@ export const queueApi = api.injectEndpoints({
},
}),
listQueueItems: build.query<
components['schemas']['CursorPaginatedResults_SessionQueueItem_'],
{ cursor?: number; priority?: number; destination?: string } | undefined
EntityState<components['schemas']['SessionQueueItem'], string> & {
has_more: boolean;
},
{ cursor?: number; priority?: number } | undefined
>({
query: (queryArgs) => ({
url: getListQueueItemsUrl(queryArgs),
method: 'GET',
}),
keepUnusedDataFor: 60 * 5, // 5 minutes
providesTags: (result, _error, _args) => {
if (!result) {
return [];
}
return [
'FetchOnReconnect',
{ type: 'SessionQueueItem', id: LIST_TAG },
...result.items.map(({ item_id }) => ({ type: 'SessionQueueItem', id: item_id }) satisfies ApiTagDescription),
];
serializeQueryArgs: () => {
return buildQueueUrl('list');
},
transformResponse: (response: components['schemas']['CursorPaginatedResults_SessionQueueItem_']) =>
queueItemsAdapter.addMany(
queueItemsAdapter.getInitialState({
has_more: response.has_more,
}),
response.items
),
merge: (cache, response) => {
queueItemsAdapter.addMany(cache, queueItemsAdapterSelectors.selectAll(response));
cache.has_more = response.has_more;
},
forceRefetch: ({ currentArg, previousArg }) => currentArg !== previousArg,
keepUnusedDataFor: 60 * 5, // 5 minutes
}),
listAllQueueItems: build.query<
paths['/api/v1/queue/{queue_id}/list_all']['get']['responses']['200']['content']['application/json'],
@@ -356,6 +487,16 @@ export const queueApi = api.injectEndpoints({
{ type: 'SessionQueueItem', id: LIST_ALL_TAG },
],
}),
deleteAllExceptCurrent: build.mutation<
paths['/api/v1/queue/{queue_id}/delete_all_except_current']['put']['responses']['200']['content']['application/json'],
void
>({
query: () => ({
url: buildQueueUrl('delete_all_except_current'),
method: 'PUT',
}),
invalidatesTags: ['SessionQueueStatus', 'BatchStatus', 'QueueCountsByDestination', 'SessionQueueItem'],
}),
getQueueCountsByDestination: build.query<
paths['/api/v1/queue/{queue_id}/counts_by_destination']['get']['responses']['200']['content']['application/json'],
paths['/api/v1/queue/{queue_id}/counts_by_destination']['get']['parameters']['query']
@@ -378,6 +519,7 @@ export const {
useClearQueueMutation,
usePruneQueueMutation,
useGetQueueStatusQuery,
useGetQueueItemQuery,
useListQueueItemsQuery,
useCancelQueueItemMutation,
useCancelQueueItemsByDestinationMutation,
@@ -392,6 +534,24 @@ export const {
export const selectQueueStatus = queueApi.endpoints.getQueueStatus.select();
const resetListQueryData = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dispatch: ThunkDispatch<any, any, UnknownAction>
) => {
dispatch(
queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => {
// remove all items from the list
queueItemsAdapter.removeAll(draft);
// reset the has_more flag
draft.has_more = false;
})
);
// set the list cursor and priority to undefined
dispatch(listParamsReset());
// we have to manually kick off another query to get the first page and re-initialize the list
dispatch(queueApi.endpoints.listQueueItems.initiate(undefined));
};
export const enqueueMutationFixedCacheKeyOptions = {
fixedCacheKey: 'enqueueBatch',
} as const;

View File

@@ -129,6 +129,17 @@ export const selectIPAdapterModels = buildModelsSelector(isIPAdapterModelConfig)
// export const selectEmbeddingModels = buildModelsSelector(isTIModelConfig);
// export const selectVAEModels = buildModelsSelector(isVAEModelConfig);
// export const selectFluxVAEModels = buildModelsSelector(isFluxVAEModelConfig);
export const selectGlobalRefImageModels = buildModelsSelector(
(config) =>
isIPAdapterModelConfig(config) ||
isFluxReduxModelConfig(config) ||
isChatGPT4oModelConfig(config) ||
isFluxKontextApiModelConfig(config) ||
isFluxKontextModelConfig(config)
);
export const selectRegionalRefImageModels = buildModelsSelector(
(config) => isIPAdapterModelConfig(config) || isFluxReduxModelConfig(config)
);
export const buildSelectModelConfig = <T extends AnyModelConfig>(
key: string,

View File

@@ -22266,16 +22266,6 @@ export interface operations {
};
requestBody: {
content: {
/** @example {
* "path": "/path/to/model",
* "name": "model_name",
* "base": "sd-1",
* "type": "main",
* "format": "checkpoint",
* "config_path": "configs/stable-diffusion/v1-inference.yaml",
* "description": "Model description",
* "variant": "normal"
* } */
"application/json": components["schemas"]["ModelRecordChanges"];
};
};
@@ -22579,10 +22569,6 @@ export interface operations {
};
requestBody: {
content: {
/** @example {
* "name": "string",
* "description": "string"
* } */
"application/json": components["schemas"]["ModelRecordChanges"];
};
};

View File

@@ -22,7 +22,7 @@ import { t } from 'i18next';
import type { ApiTagDescription } from 'services/api';
import { api, LIST_ALL_TAG, LIST_TAG } from 'services/api';
import { modelsApi } from 'services/api/endpoints/models';
import { queueApi } from 'services/api/endpoints/queue';
import { queueApi, queueItemsAdapter } from 'services/api/endpoints/queue';
import { workflowsApi } from 'services/api/endpoints/workflows';
import { buildOnInvocationComplete } from 'services/events/onInvocationComplete';
import { buildOnModelInstallError } from 'services/events/onModelInstallError';
@@ -343,10 +343,42 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
socket.on('queue_item_status_changed', (data) => {
// we've got new status for the queue item, batch and queue
const { item_id, session_id, status, batch_status, error_type, error_message, destination } = data;
const {
item_id,
session_id,
status,
batch_status,
error_type,
error_message,
destination,
started_at,
updated_at,
completed_at,
error_traceback,
credits,
} = data;
log.debug({ data }, `Queue item ${item_id} status updated: ${status}`);
// // Update this specific queue item in the list of queue items
dispatch(
queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => {
queueItemsAdapter.updateOne(draft, {
id: String(item_id),
changes: {
status,
started_at,
updated_at: updated_at ?? undefined,
completed_at: completed_at ?? undefined,
error_type,
error_message,
error_traceback,
credits,
},
});
})
);
// Invalidate caches for things we cannot easily update
const tagsToInvalidate: ApiTagDescription[] = [
'SessionQueueStatus',

View File

@@ -1 +1 @@
__version__ = "6.0.0rc4"
__version__ = "6.1.0rc1"