Compare commits

..

2 Commits

Author SHA1 Message Date
psychedelicious
efb187ced2 chore: bump version to v6.0.0rc5 2025-07-08 15:02:24 +10:00
psychedelicious
ca9150e9b3 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 15:01:45 +10:00
77 changed files with 1605 additions and 2297 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}",
examples=['"[1024,1024]"'],
example='"[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", examples=[example_model_input])],
changes: Annotated[ModelRecordChanges, Body(description="Model config", example=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 ",
examples=[{"name": "string", "description": "string"}],
example={"name": "string", "description": "string"},
),
) -> ModelInstallJob:
"""Install a model using a string identifier.

View File

@@ -143,19 +143,11 @@ flux_dev = StarterModel(
flux_kontext = StarterModel(
name="FLUX.1 Kontext dev",
base=BaseModelType.Flux,
source="https://huggingface.co/black-forest-labs/FLUX.1-Kontext-dev/resolve/main/flux1-kontext-dev.safetensors",
source="black-forest-labs/FLUX.1-Kontext-dev::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,
@@ -672,7 +664,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_quantized,
flux_kontext,
flux_schnell_quantized,
flux_dev_quantized,
flux_schnell,
@@ -793,7 +785,7 @@ flux_bundle: list[StarterModel] = [
flux_depth_control_lora,
flux_redux,
flux_fill,
flux_kontext_quantized,
flux_kontext,
]
STARTER_BUNDLES: dict[str, StarterModelBundle] = {

View File

@@ -12,8 +12,6 @@ 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 does not support generation from images placed on the canvas. Re-try using the Reference Image section and disable any Raster Layers.",
"fluxKontextIncompatibleGenerationMode": "FLUX Kontext supports Text to Image only. Use other models for Image to Image, Inpainting and Outpainting tasks.",
"problemUnpublishingWorkflow": "Problem Unpublishing Workflow",
"problemUnpublishingWorkflowDescription": "There was a problem unpublishing the workflow. Please try again.",
"workflowUnpublished": "Workflow Unpublished",
@@ -2380,11 +2380,6 @@
"saveToGallery": "Save To Gallery",
"showResultsOn": "Showing Results",
"showResultsOff": "Hiding Results"
},
"autoSwitch": {
"off": "Off",
"switchOnStart": "On Start",
"switchOnFinish": "On Finish"
}
},
"upscaling": {
@@ -2560,9 +2555,8 @@
"whatsNew": {
"whatsNewInInvoke": "What's New in Invoke",
"items": [
"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."
"Inpainting: Per-mask noise levels and denoise limits.",
"Canvas: Smarter aspect ratios for SDXL and improved scroll-to-zoom."
],
"readReleaseNotes": "Read Release Notes",
"watchRecentReleaseVideos": "Watch Recent Release Videos",

View File

@@ -1,14 +1,11 @@
import { useAppSelector } from 'app/store/storeHooks';
import { useIsRegionFocused } from 'common/hooks/focus';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
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 { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useImageActions } from 'features/gallery/hooks/useImageActions';
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';
@@ -30,64 +27,59 @@ GlobalImageHotkeys.displayName = 'GlobalImageHotkeys';
const GlobalImageHotkeysInternal = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
const isGalleryFocused = useIsRegionFocused('gallery');
const isViewerFocused = useIsRegionFocused('viewer');
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);
const imageActions = useImageActions(imageDTO);
const isStaging = useAppSelector(selectIsStaging);
const isUpscalingEnabled = useFeatureStatus('upscaling');
useRegisteredHotkeys({
id: 'loadWorkflow',
category: 'viewer',
callback: loadWorkflow.load,
options: { enabled: loadWorkflow.isEnabled && isFocusOK },
dependencies: [loadWorkflow, isFocusOK],
callback: imageActions.loadWorkflow,
options: { enabled: isGalleryFocused || isViewerFocused },
dependencies: [imageActions.loadWorkflow, isGalleryFocused, isViewerFocused],
});
useRegisteredHotkeys({
id: 'recallAll',
category: 'viewer',
callback: recallAll.recall,
options: { enabled: recallAll.isEnabled && isFocusOK },
dependencies: [recallAll, isFocusOK],
callback: imageActions.recallAll,
options: { enabled: !isStaging && (isGalleryFocused || isViewerFocused) },
dependencies: [imageActions.recallAll, isStaging, isGalleryFocused, isViewerFocused],
});
useRegisteredHotkeys({
id: 'recallSeed',
category: 'viewer',
callback: recallSeed.recall,
options: { enabled: recallSeed.isEnabled && isFocusOK },
dependencies: [recallSeed, isFocusOK],
callback: imageActions.recallSeed,
options: { enabled: isGalleryFocused || isViewerFocused },
dependencies: [imageActions.recallSeed, isGalleryFocused, isViewerFocused],
});
useRegisteredHotkeys({
id: 'recallPrompts',
category: 'viewer',
callback: recallPrompts.recall,
options: { enabled: recallPrompts.isEnabled && isFocusOK },
dependencies: [recallPrompts, isFocusOK],
callback: imageActions.recallPrompts,
options: { enabled: isGalleryFocused || isViewerFocused },
dependencies: [imageActions.recallPrompts, isGalleryFocused, isViewerFocused],
});
useRegisteredHotkeys({
id: 'remix',
category: 'viewer',
callback: recallRemix.recall,
options: { enabled: recallRemix.isEnabled && isFocusOK },
dependencies: [recallRemix, isFocusOK],
callback: imageActions.remix,
options: { enabled: isGalleryFocused || isViewerFocused },
dependencies: [imageActions.remix, isGalleryFocused, isViewerFocused],
});
useRegisteredHotkeys({
id: 'useSize',
category: 'viewer',
callback: recallDimensions.recall,
options: { enabled: recallDimensions.isEnabled && isFocusOK },
dependencies: [recallDimensions, isFocusOK],
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],
});
return null;
});

View File

@@ -1,28 +1,14 @@
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { bboxSyncedToOptimalDimension, rgRefImageModelChanged } from 'features/controlLayers/store/canvasSlice';
import { bboxSyncedToOptimalDimension } 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 { refImageModelChanged, selectReferenceImageEntities } from 'features/controlLayers/store/refImagesSlice';
import {
selectAllEntitiesOfType,
selectBboxModelBase,
selectCanvasSlice,
} from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { selectBboxModelBase } from 'features/controlLayers/store/selectors';
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');
@@ -39,8 +25,9 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
}
const newModel = result.data;
const newBase = newModel.base;
const didBaseModelChange = state.params.model?.base !== newBase;
const newBaseModel = newModel.base;
const didBaseModelChange = state.params.model?.base !== newBaseModel;
if (didBaseModelChange) {
// we may need to reset some incompatible submodels
@@ -48,7 +35,7 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
// handle incompatible loras
state.loras.loras.forEach((lora) => {
if (lora.model.base !== newBase) {
if (lora.model.base !== newBaseModel) {
dispatch(loraDeleted({ id: lora.id }));
modelsCleared += 1;
}
@@ -56,82 +43,20 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
// handle incompatible vae
const { vae } = state.params;
if (vae && vae.base !== newBase) {
if (vae && vae.base !== newBaseModel) {
dispatch(vaeSelected(null));
modelsCleared += 1;
}
// 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;
}
}
}
// 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' } }));
// }
// }
// });
if (modelsCleared > 0) {
toast({

View File

@@ -3,7 +3,6 @@ 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,
@@ -11,7 +10,6 @@ import {
setSteps,
vaePrecisionChanged,
vaeSelected,
widthChanged,
} from 'features/controlLayers/store/paramsSlice';
import { setDefaultSettings } from 'features/parameters/store/actions';
import {
@@ -26,7 +24,6 @@ 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';
@@ -116,24 +113,15 @@ export const addSetDefaultSettingsListener = (startAppListening: AppStartListeni
const setSizeOptions = { updateAspectRatio: true, clamp: true };
const isStaging = selectIsStaging(getState());
const activeTab = selectActiveTab(getState());
if (activeTab === 'generate') {
if (!isStaging && width) {
if (isParameterWidth(width)) {
dispatch(widthChanged({ width, ...setSizeOptions }));
}
if (isParameterHeight(height)) {
dispatch(heightChanged({ height, ...setSizeOptions }));
dispatch(bboxWidthChanged({ width, ...setSizeOptions }));
}
}
if (activeTab === 'canvas') {
if (!isStaging) {
if (isParameterWidth(width)) {
dispatch(bboxWidthChanged({ width, ...setSizeOptions }));
}
if (isParameterHeight(height)) {
dispatch(bboxHeightChanged({ height, ...setSizeOptions }));
}
if (!isStaging && height) {
if (isParameterHeight(height)) {
dispatch(bboxHeightChanged({ height, ...setSizeOptions }));
}
}

View File

@@ -87,10 +87,14 @@ export const buildGroup = <T extends object>(group: Omit<Group<T>, typeof unique
[uniqueGroupKey]: true,
});
export const isGroup = <T extends object>(optionOrGroup: OptionOrGroup<T>): optionOrGroup is Group<T> => {
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

@@ -1,115 +0,0 @@
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} borderRadius="base" borderWidth={1} borderStyle="solid" w="full" />
<DndImage imageDTO={imageDTO} borderWidth={1} borderStyle="solid" w="full" />
<Flex position="absolute" flexDir="column" top={2} insetInlineEnd={2} gap={1}>
<DndImageIcon
onClick={handleResetControlImage}

View File

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

View File

@@ -1,6 +1,5 @@
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,
@@ -11,10 +10,6 @@ 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';
@@ -26,12 +21,12 @@ const sx = {
pos: 'relative',
alignItems: 'center',
justifyContent: 'center',
h: 108,
w: 108,
flexShrink: 0,
aspectRatio: '1/1',
borderWidth: 2,
borderRadius: 'base',
bg: 'base.900',
overflow: 'hidden',
'&[data-selected="true"]': {
borderColor: 'invokeBlue.300',
},
@@ -44,24 +39,23 @@ type Props = {
};
export const QueueItemPreviewMini = memo(({ item, isSelected, number }: 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') {
dispatch(settingsStagingAreaAutoSwitchChanged('off'));
ctx.$autoSwitch.set('off');
toast({
title: 'Auto-Switch Disabled',
});
}
}, [autoSwitch, dispatch]);
}, [ctx.$autoSwitch]);
const onLoad = useCallback(() => {
ctx.onImageLoad(item.item_id);

View File

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

View File

@@ -21,20 +21,18 @@ export const StagingAreaItemsList = memo(() => {
}, [canvasManager, ctx.$progressData, ctx.$selectedItemId, ctx.$isPending]);
return (
<Flex position="relative" maxW="full" w="full" h="72px">
<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>
</Flex>
<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>
);
});
StagingAreaItemsList.displayName = 'StagingAreaItemsList';

View File

@@ -1,12 +1,10 @@
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 { selectStagingAreaAutoSwitch } from 'features/controlLayers/store/canvasSettingsSlice';
import {
buildSelectSessionQueueItems,
canvasQueueItemDiscarded,
canvasSessionReset,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import { canvasQueueItemDiscarded, selectDiscardedItems } 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';
@@ -17,6 +15,11 @@ 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;
@@ -96,13 +99,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);
@@ -139,6 +142,11 @@ 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.
@@ -220,9 +228,25 @@ export const CanvasSessionContextProvider = memo(
)[0];
/**
* A redux selector to select all queue items from the RTK Query cache.
* 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.
*/
const selectQueueItems = useMemo(() => buildSelectSessionQueueItems(session.id), [session.id]);
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 discard = useCallback(
(itemId: number) => {
@@ -231,10 +255,6 @@ export const CanvasSessionContextProvider = memo(
[store]
);
const discardAll = useCallback(() => {
store.dispatch(canvasSessionReset());
}, [store]);
const selectNext = useCallback(() => {
const selectedItemId = $selectedItemId.get();
if (selectedItemId === null) {
@@ -296,15 +316,12 @@ export const CanvasSessionContextProvider = memo(
imageLoaded: true,
});
}
if (
$lastCompletedItemId.get() === itemId &&
selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_finish'
) {
if ($lastCompletedItemId.get() === itemId && $autoSwitch.get() === 'switch_on_finish') {
$selectedItemId.set(itemId);
$lastCompletedItemId.set(null);
}
},
[$lastCompletedItemId, $progressData, $selectedItemId, store]
[$autoSwitch, $lastCompletedItemId, $progressData, $selectedItemId]
);
// Set up socket listeners
@@ -339,7 +356,7 @@ export const CanvasSessionContextProvider = memo(
socket.off('invocation_progress', onProgress);
socket.off('queue_item_status_changed', onQueueItemStatusChanged);
};
}, [$lastCompletedItemId, $lastStartedItemId, $progressData, $selectedItemId, session.id, socket]);
}, [$autoSwitch, $lastCompletedItemId, $lastStartedItemId, $progressData, $selectedItemId, session.id, socket]);
// Set up state subscriptions and effects
useEffect(() => {
@@ -369,7 +386,7 @@ export const CanvasSessionContextProvider = memo(
$selectedItemId.set(items[0]?.item_id ?? null);
return;
} else if (
selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_start' &&
$autoSwitch.get() === 'switch_on_start' &&
items.findIndex(({ item_id }) => item_id === lastStartedItemId) !== -1
) {
$selectedItemId.set(lastStartedItemId);
@@ -472,7 +489,7 @@ export const CanvasSessionContextProvider = memo(
if (lastLoadedItemId === null) {
return;
}
if (selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_finish') {
if ($autoSwitch.get() === 'switch_on_finish') {
$selectedItemId.set(lastLoadedItemId);
}
$lastLoadedItemId.set(null);
@@ -484,22 +501,6 @@ 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();
@@ -513,6 +514,7 @@ export const CanvasSessionContextProvider = memo(
};
}, [
$items,
$autoSwitch,
$lastLoadedItemId,
$lastStartedItemId,
$progressData,
@@ -530,6 +532,7 @@ export const CanvasSessionContextProvider = memo(
$isPending,
$progressData,
$selectedItemId,
$autoSwitch,
$selectedItem,
$selectedItemIndex,
$selectedItemOutputImageDTO,
@@ -540,9 +543,9 @@ export const CanvasSessionContextProvider = memo(
selectLast,
onImageLoad,
discard,
discardAll,
}),
[
$autoSwitch,
$items,
$hasItems,
$isPending,
@@ -559,7 +562,6 @@ export const CanvasSessionContextProvider = memo(
selectLast,
onImageLoad,
discard,
discardAll,
]
);

View File

@@ -1,50 +0,0 @@
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,4 +1,4 @@
import { ButtonGroup, Flex } from '@invoke-ai/ui-library';
import { ButtonGroup } 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';
@@ -15,8 +15,6 @@ import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerP
import { memo, useEffect } 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);
@@ -26,18 +24,15 @@ export const StagingAreaToolbar = memo(() => {
useEffect(() => {
return ctx.$selectedItemId.listen((id) => {
if (id !== null) {
document
.getElementById(getQueueItemElementId(id))
?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'auto' });
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 />
@@ -49,14 +44,9 @@ 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,5 +1,7 @@
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';
@@ -7,13 +9,21 @@ 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(() => {
ctx.discardAll();
cancelQueueItemsByDestination.trigger(ctx.session.id, { withToast: false });
}, [cancelQueueItemsByDestination, ctx]);
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]);
return (
<IconButton
@@ -22,6 +32,7 @@ export const StagingAreaToolbarDiscardAllButton = memo(({ isDisabled }: { isDisa
icon={<PiTrashSimpleBold />}
onClick={discardAll}
colorScheme="error"
fontSize={16}
isDisabled={isDisabled || cancelQueueItemsByDestination.isDisabled}
isLoading={cancelQueueItemsByDestination.isLoading}
/>

View File

@@ -1,12 +1,15 @@
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);
@@ -19,7 +22,16 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { i
}
ctx.discard(selectedItemId);
await cancelQueueItem.trigger(selectedItemId, { withToast: false });
}, [selectedItemId, ctx, cancelQueueItem]);
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]);
return (
<IconButton
@@ -28,6 +40,7 @@ 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,13 +1,16 @@
import { IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
import { IconButton, Menu, MenuButton, MenuDivider, MenuList } from '@invoke-ai/ui-library';
import { StagingAreaToolbarMenuAutoSwitch } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch';
import { StagingAreaToolbarNewLayerFromImageMenuItems } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage';
import { memo } from 'react';
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
import { PiDotsThreeBold } from 'react-icons/pi';
export const StagingAreaToolbarMenu = memo(() => {
return (
<Menu>
<MenuButton as={IconButton} icon={<PiDotsThreeVerticalBold />} colorScheme="invokeBlue" />
<MenuButton as={IconButton} icon={<PiDotsThreeBold />} colorScheme="invokeBlue" />
<MenuList>
<StagingAreaToolbarMenuAutoSwitch />
<MenuDivider />
<StagingAreaToolbarNewLayerFromImageMenuItems />
</MenuList>
</Menu>

View File

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

View File

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

View File

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

View File

@@ -1,20 +0,0 @@
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,5 +1,4 @@
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';
@@ -17,19 +16,14 @@ 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>
@@ -42,14 +36,13 @@ const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) =
</IconMenuItemGroup>
<MenuDivider />
<ImageMenuItemLoadWorkflow />
{(tab === 'canvas' || tab === 'generate') && <ImageMenuItemMetadataRecallActions />}
<ImageMenuItemMetadataRecallActions />
<MenuDivider />
<ImageMenuItemSendToUpscale />
<ImageMenuItemUseForPromptGeneration />
{(tab === 'canvas' || tab === 'generate') && <ImageMenuItemUseAsRefImage />}
<ImageMenuItemUseAsPromptTemplate />
<ImageMenuItemUseAsRefImage />
<ImageMenuItemNewCanvasFromImageSubMenu />
{tab === 'canvas' && <ImageMenuItemNewLayerFromImageSubMenu />}
<ImageMenuItemNewLayerFromImageSubMenu />
<MenuDivider />
<ImageMenuItemChangeBoard />
<ImageMenuItemStarUnstar />

View File

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

View File

@@ -1,19 +1,21 @@
import { Button, Divider, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
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 { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems';
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 { 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 { PostProcessingPopover } from 'features/parameters/components/PostProcessing/PostProcessingPopover';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo } from 'react';
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 { useTranslation } from 'react-i18next';
import {
PiArrowsCounterClockwiseBold,
@@ -25,23 +27,51 @@ import {
PiQuotesBold,
PiRulerBold,
} from 'react-icons/pi';
import type { ImageDTO } from 'services/api/types';
import { useImageDTO } from 'services/api/endpoints/images';
export const CurrentImageButtons = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
import { useImageViewerContext } from './context';
export const CurrentImageButtons = memo(() => {
const { t } = useTranslation();
const tab = useAppSelector(selectActiveTab);
const isCanvasOrGenerateTab = tab === 'canvas' || tab === 'generate';
const ctx = useImageViewerContext();
const hasProgressImage = useStore(ctx.$hasProgressImage);
const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
const isDisabledOverride = hasProgressImage && shouldShowProgressInViewer;
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 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);
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]);
return (
<>
@@ -50,7 +80,7 @@ export const CurrentImageButtons = memo(({ imageDTO }: { imageDTO: ImageDTO }) =
as={IconButton}
aria-label={t('parameters.imageActions')}
tooltip={t('parameters.imageActions')}
isDisabled={!imageDTO}
isDisabled={isDisabledOverride || !imageDTO}
variant="link"
alignSelf="stretch"
icon={<PiDotsThreeOutlineFill />}
@@ -62,8 +92,8 @@ export const CurrentImageButtons = memo(({ imageDTO }: { imageDTO: ImageDTO }) =
<Button
leftIcon={<PiPencilBold />}
onClick={editImage.edit}
isDisabled={!editImage.isEnabled}
onClick={handleEdit}
isDisabled={isDisabledOverride || !imageDTO}
variant="link"
size="sm"
alignSelf="stretch"
@@ -78,72 +108,62 @@ export const CurrentImageButtons = memo(({ imageDTO }: { imageDTO: ImageDTO }) =
icon={<PiFlowArrowBold />}
tooltip={`${t('nodes.loadWorkflow')} (W)`}
aria-label={`${t('nodes.loadWorkflow')} (W)`}
isDisabled={!loadWorkflow.isEnabled}
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasWorkflow || !hasTemplates}
variant="link"
alignSelf="stretch"
onClick={loadWorkflow.load}
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}
/>
{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={false} />}
{isUpscalingEnabled && <PostProcessingPopover imageDTO={imageDTO} isDisabled={isDisabledOverride} />}
<Divider orientation="vertical" h={8} mx={2} />
<DeleteImageButton onClick={deleteImage.delete} isDisabled={!deleteImage.isEnabled} />
<DeleteImageButton onClick={imageActions.delete} isDisabled={isDisabledOverride || !imageDTO} />
</>
);
});

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} borderRadius="base" />
<DndImage imageDTO={imageDTO} onLoad={onLoadImage} />
</Flex>
)}
{!imageDTO && <NoContentForViewer />}

View File

@@ -1,24 +1,18 @@
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 />
{imageDTO && <CurrentImageButtons imageDTO={imageDTO} />}
<CurrentImageButtons />
<Spacer />
{imageDTO && <ToggleMetadataViewerButton />}
<ToggleMetadataViewerButton />
</Flex>
);
});

View File

@@ -2,7 +2,6 @@ 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 {
@@ -222,10 +221,6 @@ 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;

View File

@@ -1,26 +0,0 @@
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

@@ -1,81 +0,0 @@
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

@@ -1,28 +0,0 @@
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

@@ -1,57 +0,0 @@
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

@@ -0,0 +1,209 @@
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

@@ -1,34 +0,0 @@
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, useEffect, useRef } from 'react';
import { useCallback } from 'react';
import type { ListRange } from 'react-virtuoso';
import { imagesApi, useGetImageDTOsByNamesMutation } from 'services/api/endpoints/images';
import { useThrottledCallback } from 'use-debounce';
@@ -53,7 +53,6 @@ export const useRangeBasedImageFetching = ({
}: UseRangeBasedImageFetchingArgs): UseRangeBasedImageFetchingReturn => {
const store = useAppStore();
const [getImageDTOsByNames] = useGetImageDTOsByNamesMutation();
const lastRangeRef = useRef<ListRange | null>(null);
const fetchImages = useCallback(
(visibleRange: ListRange) => {
@@ -66,7 +65,6 @@ export const useRangeBasedImageFetching = ({
return;
}
getImageDTOsByNames({ image_names: uncachedNames });
lastRangeRef.current = visibleRange;
},
[enabled, getImageDTOsByNames, imageNames, store]
);
@@ -80,13 +78,6 @@ export const useRangeBasedImageFetching = ({
[throttledFetchImages]
);
useEffect(() => {
if (!lastRangeRef.current) {
return;
}
throttledFetchImages(lastRangeRef.current);
}, [imageNames, throttledFetchImages]);
return {
onRangeChanged,
};

View File

@@ -1,57 +0,0 @@
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

@@ -1,36 +0,0 @@
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

@@ -1,72 +0,0 @@
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

@@ -1,60 +0,0 @@
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

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

View File

@@ -146,7 +146,6 @@ 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" overflow="hidden">
<Flex borderRadius="base" borderWidth={1} borderStyle="solid">
<DndImage imageDTO={imageDTO} asThumbnail />
</Flex>
<Text

View File

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

View File

@@ -30,14 +30,11 @@ 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,6 +78,8 @@ 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.fluxKontextIncompatibleGenerationMode'));
throw new UnsupportedGenerationModeError(t('toast.imagenIncompatibleGenerationMode', { model: 'FLUX Kontext' }));
}
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, isGroup, Picker, usePickerContext } from 'common/components/Picker/Picker';
import { buildGroup, getRegex, isOption, 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,22 +277,8 @@ export const ModelPicker = typedMemo(
if (!selectedModelConfig) {
return undefined;
}
let _selectedOption: WithStarred<T> | undefined = undefined;
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;
return options.filter(isOption).find((o) => o.key === selectedModelConfig.key);
}, [options, selectedModelConfig]);
const onClose = useCallback(() => {
@@ -375,19 +361,9 @@ const optionSx: SystemStyleObject = {
cursor: 'pointer',
borderRadius: 'base',
'&[data-selected="true"]': {
bg: 'invokeBlue.300',
color: 'base.900',
'.extra-info': {
color: 'base.700',
},
'.picker-option': {
fontWeight: 'bold',
'&[data-is-compact="true"]': {
fontWeight: 'semibold',
},
},
bg: 'base.700',
'&[data-active="true"]': {
bg: 'invokeBlue.250',
bg: 'base.650',
},
},
'&[data-active="true"]': {
@@ -424,31 +400,17 @@ 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 className="picker-option" sx={optionNameSx} data-is-compact={compactView}>
<Text sx={optionNameSx} data-is-compact={compactView}>
{option.name}
</Text>
<Spacer />
{option.file_size > 0 && (
<Text
className="extra-info"
variant="subtext"
fontStyle="italic"
noOfLines={1}
flexShrink={0}
overflow="visible"
>
<Text variant="subtext" fontStyle="italic" noOfLines={1} flexShrink={0} overflow="visible">
{filesize(option.file_size)}
</Text>
)}
{option.usage_info && (
<Text
className="extra-info"
variant="subtext"
fontStyle="italic"
noOfLines={1}
flexShrink={0}
overflow="visible"
>
<Text variant="subtext" fontStyle="italic" noOfLines={1} flexShrink={0} overflow="visible">
{option.usage_info}
</Text>
)}

View File

@@ -287,19 +287,17 @@ const getReasonsWhyCannotEnqueueGenerateTab = (arg: {
reasons.push({ content: i18n.t('parameters.invoke.fluxKontextMultipleReferenceImages') });
}
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);
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);
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} borderRadius="base" />
<DndImage imageDTO={imageDTO} />
<Flex position="absolute" flexDir="column" top={1} insetInlineEnd={1} gap={1}>
<DndImageIcon
onClick={onReset}

View File

@@ -144,6 +144,7 @@ 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

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

View File

@@ -13,6 +13,8 @@ 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';
@@ -21,8 +23,6 @@ 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,7 +106,23 @@ export const CanvasWorkspacePanel = memo(() => {
{canvasId !== null && (
<CanvasManagerProviderGate>
<CanvasSessionContextProvider type="advanced" id={canvasId}>
<StagingArea />
<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>
</CanvasSessionContextProvider>
</CanvasManagerProviderGate>
)}

View File

@@ -1,22 +0,0 @@
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={4} gap={2} align="center" justify="center" left={4} right={4}>
<StagingAreaItemsList />
<StagingAreaToolbar />
</Flex>
);
});
StagingArea.displayName = 'StagingArea';

View File

@@ -1,4 +1,11 @@
import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview';
import type {
DockviewApi,
GridviewApi,
IDockviewPanel,
IDockviewReactProps,
IGridviewPanel,
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';
@@ -64,50 +71,54 @@ const mainPanelComponents: AutoLayoutDockviewComponents = {
};
const initializeCenterPanelLayout = (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: 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 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 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(() => {
@@ -146,49 +157,54 @@ const rightPanelComponents: AutoLayoutGridviewComponents = {
};
const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => {
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 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 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(() => {
@@ -216,16 +232,19 @@ const leftPanelComponents: AutoLayoutGridviewComponents = {
};
const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => {
navigationApi.registerContainer(tab, 'left', api, () => {
api.addPanel<PanelParameters>({
id: SETTINGS_PANEL_ID,
component: SETTINGS_PANEL_ID,
params: {
tab,
focusRegion: 'settings',
},
});
const settings = 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,44 +273,47 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
[RIGHT_PANEL_ID]: RightPanel,
};
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 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.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('canvas', api);
initializeRootPanelLayout(api);
navigationApi.onTabReady('canvas');
}, []);
useEffect(

View File

@@ -1,4 +1,11 @@
import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview';
import type {
DockviewApi,
GridviewApi,
IDockviewPanel,
IDockviewReactProps,
IGridviewPanel,
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';
@@ -58,35 +65,38 @@ const mainPanelComponents: AutoLayoutDockviewComponents = {
};
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 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 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(() => {
@@ -124,35 +134,39 @@ const rightPanelComponents: AutoLayoutGridviewComponents = {
};
const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => {
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 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, 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(() => {
@@ -180,16 +194,19 @@ const leftPanelComponents: AutoLayoutGridviewComponents = {
};
const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => {
navigationApi.registerContainer(tab, 'left', api, () => {
api.addPanel<PanelParameters>({
id: SETTINGS_PANEL_ID,
component: SETTINGS_PANEL_ID,
params: {
tab,
focusRegion: 'settings',
},
});
const settings = 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(() => {
@@ -218,42 +235,47 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
[RIGHT_PANEL_ID]: RightPanel,
};
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 initializeRootPanelLayout = (layoutApi: GridviewApi) => {
const main = layoutApi.addPanel<PanelParameters>({
id: MAIN_PANEL_ID,
component: MAIN_PANEL_ID,
priority: LayoutPriority.High,
});
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('generate', api);
initializeRootPanelLayout(api);
navigationApi.onTabReady('generate');
}, []);
useEffect(

View File

@@ -1,9 +1,8 @@
import type { GridviewApi, IGridviewReactProps } from 'dockview';
import type { GridviewApi, IGridviewPanel, 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';
@@ -13,19 +12,22 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
[MODELS_PANEL_ID]: ModelManagerTab,
};
const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => {
navigationApi.registerContainer(tab, 'root', api, () => {
api.addPanel({
id: MODELS_PANEL_ID,
component: MODELS_PANEL_ID,
priority: LayoutPriority.High,
});
const initializeRootPanelLayout = (layoutApi: GridviewApi) => {
const models = layoutApi.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('models', api);
initializeRootPanelLayout(api);
navigationApi.onTabReady('models');
}, []);
useEffect(

View File

@@ -1,4 +1,3 @@
import type { DockviewApi, GridviewApi } from 'dockview';
import { DockviewPanel, GridviewPanel } from 'dockview';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -11,7 +10,6 @@ import {
RIGHT_PANEL_ID,
RIGHT_PANEL_MIN_SIZE_PX,
SETTINGS_PANEL_ID,
SWITCH_TABS_FAKE_DELAY_MS,
WORKSPACE_PANEL_ID,
} from './shared';
@@ -22,7 +20,6 @@ vi.mock('app/logging/logger', () => ({
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
}),
}));
@@ -31,9 +28,8 @@ vi.mock('dockview', async () => {
// Mock GridviewPanel class for instanceof checks
class MockGridviewPanel {
maximumWidth?: number;
minimumWidth?: number;
width?: number;
maximumWidth: number;
minimumWidth: number;
api = {
setActive: vi.fn(),
setConstraints: vi.fn(),
@@ -41,10 +37,9 @@ vi.mock('dockview', async () => {
onDidDimensionsChange: vi.fn(() => ({ dispose: vi.fn() })),
};
constructor(config: { maximumWidth?: number; minimumWidth?: number; width?: number } = {}) {
this.maximumWidth = config.maximumWidth;
this.minimumWidth = config.minimumWidth;
this.width = config.width;
constructor(config: { maximumWidth?: number; minimumWidth?: number } = {}) {
this.maximumWidth = config.maximumWidth ?? Number.MAX_SAFE_INTEGER;
this.minimumWidth = config.minimumWidth ?? 0;
}
}
@@ -66,7 +61,7 @@ vi.mock('dockview', async () => {
});
// Mock panel with setActive method
const createMockPanel = (config: { maximumWidth?: number; minimumWidth?: number; width?: number } = {}) => {
const createMockPanel = (config: { maximumWidth?: number; minimumWidth?: number } = {}) => {
/* @ts-expect-error we are mocking GridviewPanel to be a concrete class */
return new GridviewPanel(config);
};
@@ -80,27 +75,27 @@ describe('AppNavigationApi', () => {
let navigationApi: NavigationApi;
let mockSetAppTab: ReturnType<typeof vi.fn>;
let mockGetAppTab: ReturnType<typeof vi.fn>;
let mockSetStorage: ReturnType<typeof vi.fn>;
let mockGetStorage: ReturnType<typeof vi.fn>;
let mockDeleteStorage: ReturnType<typeof vi.fn>;
let mockSetPanelState: ReturnType<typeof vi.fn>;
let mockGetPanelState: ReturnType<typeof vi.fn>;
let mockDeletePanelState: ReturnType<typeof vi.fn>;
let mockAppApi: NavigationAppApi;
beforeEach(() => {
navigationApi = new NavigationApi();
mockSetAppTab = vi.fn();
mockGetAppTab = vi.fn();
mockSetStorage = vi.fn();
mockGetStorage = vi.fn();
mockDeleteStorage = vi.fn();
mockSetPanelState = vi.fn();
mockGetPanelState = vi.fn();
mockDeletePanelState = vi.fn();
mockAppApi = {
activeTab: {
set: mockSetAppTab,
get: mockGetAppTab,
},
storage: {
set: mockSetStorage,
get: mockGetStorage,
delete: mockDeleteStorage,
panelStorage: {
set: mockSetPanelState,
get: mockGetPanelState,
delete: mockDeletePanelState,
},
};
});
@@ -120,9 +115,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?.storage.set).toBe(mockSetStorage);
expect(navigationApi._app?.storage.get).toBe(mockGetStorage);
expect(navigationApi._app?.storage.delete).toBe(mockDeleteStorage);
expect(navigationApi._app?.panelStorage.set).toBe(mockSetPanelState);
expect(navigationApi._app?.panelStorage.get).toBe(mockGetPanelState);
expect(navigationApi._app?.panelStorage.delete).toBe(mockDeletePanelState);
});
it('should disconnect from app', () => {
@@ -133,89 +128,10 @@ 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);
@@ -232,7 +148,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();
@@ -242,8 +158,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);
@@ -257,8 +173,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);
@@ -269,6 +185,95 @@ 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);
@@ -276,7 +281,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);
@@ -288,7 +293,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);
@@ -307,7 +312,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;
@@ -320,8 +325,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
@@ -352,7 +357,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);
@@ -362,7 +367,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);
@@ -375,7 +380,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);
@@ -389,7 +394,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]);
@@ -398,14 +403,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 .* registration timed out after 100ms/);
await expect(waitPromise).rejects.toThrow('Panel generate:settings 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 .* registration timed out after 200ms/);
await expect(waitPromise).rejects.toThrow('Panel generate:settings registration timed out after 200ms');
const elapsed = Date.now() - start;
// TODO(psyche): Use vitest's fake timeres
@@ -421,9 +426,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);
@@ -453,7 +458,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);
@@ -479,9 +484,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);
@@ -505,7 +510,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;
@@ -522,7 +527,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);
@@ -578,7 +583,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);
@@ -604,8 +609,8 @@ describe('AppNavigationApi', () => {
});
it('should expand collapsed left panel', () => {
const mockPanel = createMockPanel({ width: 0 });
navigationApi._registerPanel('generate', LEFT_PANEL_ID, mockPanel);
const mockPanel = createMockPanel({ maximumWidth: 0 });
navigationApi.registerPanel('generate', LEFT_PANEL_ID, mockPanel);
mockGetAppTab.mockReturnValue('generate');
const result = navigationApi.toggleLeftPanel();
@@ -620,7 +625,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();
@@ -651,7 +656,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();
@@ -666,8 +671,8 @@ describe('AppNavigationApi', () => {
});
it('should expand collapsed right panel', () => {
const mockPanel = createMockPanel({ width: 0 });
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, mockPanel);
const mockPanel = createMockPanel({ maximumWidth: 0 });
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, mockPanel);
mockGetAppTab.mockReturnValue('generate');
const result = navigationApi.toggleRightPanel();
@@ -682,7 +687,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();
@@ -713,7 +718,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();
@@ -728,11 +733,11 @@ describe('AppNavigationApi', () => {
});
it('should expand both panels when left is collapsed', () => {
const leftPanel = createMockPanel({ width: 0 });
const leftPanel = createMockPanel({ maximumWidth: 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();
@@ -752,10 +757,10 @@ describe('AppNavigationApi', () => {
it('should expand both panels when right is collapsed', () => {
const leftPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER });
const rightPanel = createMockPanel({ width: 0 });
const rightPanel = createMockPanel({ maximumWidth: 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();
@@ -777,8 +782,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();
@@ -797,11 +802,11 @@ describe('AppNavigationApi', () => {
});
it('should expand both panels when both are collapsed', () => {
const leftPanel = createMockPanel({ width: 0 });
const rightPanel = createMockPanel({ width: 0 });
const leftPanel = createMockPanel({ maximumWidth: 0 });
const rightPanel = createMockPanel({ maximumWidth: 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();
@@ -839,8 +844,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();
@@ -855,11 +860,11 @@ describe('AppNavigationApi', () => {
});
it('should reset both panels to expanded state', () => {
const leftPanel = createMockPanel({ width: 0 });
const rightPanel = createMockPanel({ width: 0 });
const leftPanel = createMockPanel({ maximumWidth: 0 });
const rightPanel = createMockPanel({ maximumWidth: 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();
@@ -900,8 +905,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();
@@ -921,9 +926,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
@@ -950,10 +955,10 @@ describe('AppNavigationApi', () => {
it('should handle tab switching with panel operations', () => {
const generateLeftPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER });
const canvasLeftPanel = createMockPanel({ width: 0 });
const canvasLeftPanel = createMockPanel({ maximumWidth: 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');
@@ -987,122 +992,4 @@ 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,11 +1,8 @@
import { logger } from 'app/logging/logger';
import { createDeferredPromise, type Deferred } from 'common/util/createDeferredPromise';
import { parseify } from 'common/util/serialize';
import type { DockviewApi, GridviewApi, IDockviewPanel, IGridviewPanel } from 'dockview';
import { GridviewPanel } from 'dockview';
import { DockviewPanel, GridviewPanel, type IDockviewPanel, type IGridviewPanel } from 'dockview';
import { debounce } from 'es-toolkit';
import type { Serializable, TabName } from 'features/ui/store/uiTypes';
import type { Atom } from 'nanostores';
import type { StoredDockviewPanelState, StoredGridviewPanelState, TabName } from 'features/ui/store/uiTypes';
import { atom } from 'nanostores';
import {
@@ -30,23 +27,14 @@ 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;
};
/**
* API to manage the storage of panel states.
*/
storage: {
get: (id: string) => Serializable | undefined;
set: (id: string, state: Serializable) => void;
panelStorage: {
get: (id: string) => StoredDockviewPanelState | StoredGridviewPanelState | undefined;
set: (id: string, state: StoredDockviewPanelState | StoredGridviewPanelState) => void;
delete: (id: string) => void;
};
};
@@ -66,17 +54,20 @@ export class NavigationApi {
/**
* A flag indicating if the application is currently switching tabs, which can take some time.
*/
private _$isLoading = atom(false);
$isLoading: Atom<boolean> = this._$isLoading;
$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;
/**
* 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;
/**
@@ -95,46 +86,154 @@ 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 (and for a little while longer to smooth out the UX).
* The loading screen will be shown while the tab is switching.
*
* @param tab - The tab to switch to
* @return True if the switch was successful, false otherwise
*/
switchToTab = (tab: TabName): boolean => {
if (!this._app) {
log.error('No app connected to switch tabs');
return false;
if (this.switchingTabsTimeout !== null) {
clearTimeout(this.switchingTabsTimeout);
this.switchingTabsTimeout = null;
}
if (tab === this._app.activeTab.get()) {
log.trace(`Already on tab: ${tab}`);
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');
return false;
}
};
log.trace(`Switching to tab: ${tab}`);
this._showFakeLoadingScreen();
this._app.activeTab.set(tab);
return true;
/**
* 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();
}
}
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;
}
};
/**
@@ -145,11 +244,13 @@ export class NavigationApi {
* @param panel - The panel instance
* @returns Cleanup function to unregister the panel
*/
_registerPanel = <T extends PanelType>(tab: TabName, panelId: string, panel: T): (() => void) => {
registerPanel = (tab: TabName, panelId: string, panel: PanelType): (() => 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) {
@@ -160,64 +261,15 @@ export class NavigationApi {
this.waiters.delete(key);
}
log.trace(`Registered panel ${key}`);
log.debug(`Registered panel ${key}`);
return () => {
cleanupPanelStorage?.();
this.panels.delete(key);
log.trace(`Unregistered panel ${key}`);
log.debug(`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.
*
@@ -267,38 +319,17 @@ export class NavigationApi {
};
/**
* Get the prefix for a tab to create unique keys for panels/containers.
* Get the prefix for a tab to create unique keys for panels.
*/
_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._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}`;
return `${this._getTabPrefix(tab)}${panelId}`;
};
/**
@@ -337,7 +368,7 @@ export class NavigationApi {
// Dockview uses the term "active", but we use "focused" for consistency.
panel.api.setActive();
log.trace(`Focused panel ${key}`);
log.debug(`Focused panel ${key}`);
return true;
} catch (error) {
@@ -427,7 +458,7 @@ export class NavigationApi {
return false;
}
const isCollapsed = leftPanel.width === 0;
const isCollapsed = leftPanel.maximumWidth === 0;
if (isCollapsed) {
this._expandPanel(leftPanel, LEFT_PANEL_MIN_SIZE_PX);
} else {
@@ -460,7 +491,7 @@ export class NavigationApi {
return false;
}
const isCollapsed = rightPanel.width === 0;
const isCollapsed = rightPanel.maximumWidth === 0;
if (isCollapsed) {
this._expandPanel(rightPanel, RIGHT_PANEL_MIN_SIZE_PX);
} else {
@@ -496,8 +527,8 @@ export class NavigationApi {
return false;
}
const isLeftCollapsed = leftPanel.width === 0;
const isRightCollapsed = rightPanel.width === 0;
const isLeftCollapsed = leftPanel.maximumWidth === 0;
const isRightCollapsed = rightPanel.maximumWidth === 0;
if (isLeftCollapsed || isRightCollapsed) {
this._expandPanel(leftPanel, LEFT_PANEL_MIN_SIZE_PX);
@@ -562,7 +593,7 @@ export class NavigationApi {
* @returns Array of panel IDs
*/
getRegisteredPanels = (tab: TabName): string[] => {
const prefix = this._getPanelPrefix(tab);
const prefix = this._getTabPrefix(tab);
return Array.from(this.panels.keys())
.filter((key) => key.startsWith(prefix))
.map((key) => key.substring(prefix.length));
@@ -573,7 +604,7 @@ export class NavigationApi {
* @param tab - The tab to unregister panels for
*/
unregisterTab = (tab: TabName): void => {
const prefix = this._getPanelPrefix(tab);
const prefix = this._getTabPrefix(tab);
const keysToDelete = Array.from(this.panels.keys()).filter((key) => key.startsWith(prefix));
for (const key of keysToDelete) {
@@ -593,7 +624,7 @@ export class NavigationApi {
this.waiters.delete(key);
}
log.trace(`Unregistered all panels for tab ${tab}`);
log.debug(`Unregistered all panels for tab ${tab}`);
};
}

View File

@@ -1,9 +1,8 @@
import type { GridviewApi, IGridviewReactProps } from 'dockview';
import type { GridviewApi, IGridviewPanel, 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';
@@ -13,19 +12,22 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
[QUEUE_PANEL_ID]: QueueTab,
};
const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => {
navigationApi.registerContainer(tab, 'root', api, () => {
api.addPanel({
id: QUEUE_PANEL_ID,
component: QUEUE_PANEL_ID,
priority: LayoutPriority.High,
});
const initializeRootPanelLayout = (layoutApi: GridviewApi) => {
const queue = layoutApi.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('queue', api);
initializeRootPanelLayout(api);
navigationApi.onTabReady('queue');
}, []);
useEffect(

View File

@@ -1,4 +1,11 @@
import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview';
import type {
DockviewApi,
GridviewApi,
IDockviewPanel,
IDockviewReactProps,
IGridviewPanel,
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';
@@ -57,42 +64,46 @@ const mainPanelComponents: AutoLayoutDockviewComponents = {
[PROGRESS_PANEL_ID]: withPanelContainer(GenerationProgressPanel),
};
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 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 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 }) => {
initializeMainPanelLayout(tab, api);
initializeCenterPanelLayout(tab, api);
},
[tab]
);
@@ -122,35 +133,39 @@ const rightPanelComponents: AutoLayoutGridviewComponents = {
};
const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => {
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 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, 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(() => {
@@ -178,16 +193,19 @@ const leftPanelComponents: AutoLayoutGridviewComponents = {
};
const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => {
navigationApi.registerContainer(tab, 'left', api, () => {
api.addPanel<PanelParameters>({
id: SETTINGS_PANEL_ID,
component: SETTINGS_PANEL_ID,
params: {
tab,
focusRegion: 'settings',
},
});
const settings = 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(() => {
@@ -217,42 +235,47 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
[RIGHT_PANEL_ID]: RightPanel,
};
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 initializeRootPanelLayout = (layoutApi: GridviewApi) => {
const main = layoutApi.addPanel({
id: MAIN_PANEL_ID,
component: MAIN_PANEL_ID,
priority: LayoutPriority.High,
});
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('upscaling', api);
initializeRootPanelLayout(api);
navigationApi.onTabReady('upscaling');
}, []);
useEffect(

View File

@@ -11,9 +11,9 @@ const getIsCollapsed = (
collapsedSize?: number
) => {
if (orientation === 'vertical') {
return panel.height <= (collapsedSize ?? panel.minimumHeight ?? 0);
return panel.height <= (collapsedSize ?? panel.minimumHeight);
}
return panel.width <= (collapsedSize ?? panel.minimumWidth ?? 0);
return panel.width <= (collapsedSize ?? panel.minimumWidth);
};
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 ?? 0 });
panel.api.setSize({ height: collapsedSize ?? panel.minimumHeight });
} else {
panel.api.setSize({ width: collapsedSize ?? panel.minimumWidth ?? 0 });
panel.api.setSize({ width: collapsedSize ?? panel.minimumWidth });
}
}, [collapsedSize, orientation, panelId, tab]);

View File

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

View File

@@ -1,4 +1,11 @@
import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview';
import type {
DockviewApi,
GridviewApi,
IDockviewPanel,
IDockviewReactProps,
IGridviewPanel,
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';
@@ -61,50 +68,54 @@ const mainPanelComponents: AutoLayoutDockviewComponents = {
};
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: 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 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 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(() => {
@@ -142,35 +153,39 @@ const rightPanelComponents: AutoLayoutGridviewComponents = {
};
const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => {
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 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, 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(() => {
@@ -198,16 +213,19 @@ const leftPanelComponents: AutoLayoutGridviewComponents = {
};
const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => {
navigationApi.registerContainer(tab, 'left', api, () => {
api.addPanel<PanelParameters>({
id: SETTINGS_PANEL_ID,
component: SETTINGS_PANEL_ID,
params: {
tab,
focusRegion: 'settings',
},
});
const settings = 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(() => {
@@ -236,42 +254,45 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
[RIGHT_PANEL_ID]: RightPanel,
};
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 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,
},
});
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
navigationApi.registerPanel('workflows', LEFT_PANEL_ID, left);
navigationApi.registerPanel('workflows', MAIN_PANEL_ID, main);
navigationApi.registerPanel('workflows', RIGHT_PANEL_ID, right);
return { main, left, right } satisfies Record<string, IGridviewPanel>;
};
export const WorkflowsTabAutoLayout = memo(() => {
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
initializeRootPanelLayout('workflows', api);
initializeRootPanelLayout(api);
navigationApi.onTabReady('workflows');
}, []);
useEffect(

View File

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

View File

@@ -1,5 +1,4 @@
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']);
@@ -11,19 +10,35 @@ const zPartialDimensions = z.object({
height: z.number().optional(),
});
const zSerializable = z.any().refine(isPlainObject);
export type Serializable = z.infer<typeof zSerializable>;
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 zUIState = z.object({
_version: z.literal(3).default(3),
activeTab: zTabName.default('generate'),
activeTab: zTabName.default('canvas'),
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(), zSerializable).default({}),
panels: z.record(z.string(), z.discriminatedUnion('type', [zDockviewPanelState, zGridviewPanelState])).default({}),
shouldShowNotificationV2: z.boolean().default(true),
});
const INITIAL_STATE = zUIState.parse({});

View File

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

View File

@@ -129,17 +129,6 @@ 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,6 +22266,16 @@ 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"];
};
};
@@ -22569,6 +22579,10 @@ export interface operations {
};
requestBody: {
content: {
/** @example {
* "name": "string",
* "description": "string"
* } */
"application/json": components["schemas"]["ModelRecordChanges"];
};
};

View File

@@ -1 +1 @@
__version__ = "6.0.0"
__version__ = "6.0.0rc5"