mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-16 16:57:58 -05:00
Compare commits
64 Commits
v6.0.0rc5
...
psychedeli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1a4376b75 | ||
|
|
ef4d5d7377 | ||
|
|
6b0dfd8427 | ||
|
|
471c010217 | ||
|
|
b1193022f7 | ||
|
|
2152ca092c | ||
|
|
ccc62ba56d | ||
|
|
9cf82de8c5 | ||
|
|
aced349152 | ||
|
|
0d67ee6548 | ||
|
|
03c21d1607 | ||
|
|
752e8db1f5 | ||
|
|
85fc861dd9 | ||
|
|
458cbfd874 | ||
|
|
04331c070a | ||
|
|
632ddf0cb4 | ||
|
|
2b193ff416 | ||
|
|
96ee394f9e | ||
|
|
0badc80c0c | ||
|
|
78e6cbf96e | ||
|
|
0b969a661b | ||
|
|
6fe47ec9f8 | ||
|
|
3850dd61f8 | ||
|
|
75520eaf0f | ||
|
|
10e88c58c1 | ||
|
|
30ed4dbd92 | ||
|
|
ed9c090f33 | ||
|
|
d29f65ed22 | ||
|
|
2062ec8ac0 | ||
|
|
49e818338a | ||
|
|
1caab2b9c4 | ||
|
|
50079ea349 | ||
|
|
fffa1b24c4 | ||
|
|
a6d6170387 | ||
|
|
e5fceb0448 | ||
|
|
059baf5b29 | ||
|
|
1be8a9a310 | ||
|
|
7adc33e04d | ||
|
|
7f2dd22d47 | ||
|
|
bb50f4b8a2 | ||
|
|
a48958e0d4 | ||
|
|
e3a1e9af53 | ||
|
|
c6fe11c42f | ||
|
|
4eb1bd67df | ||
|
|
c376f914d2 | ||
|
|
b5d1c47ef7 | ||
|
|
004a52ca65 | ||
|
|
b1d5a51ddf | ||
|
|
2b2498eaa1 | ||
|
|
10dda4440e | ||
|
|
98f78abefa | ||
|
|
cc93fa270f | ||
|
|
014b27680f | ||
|
|
c3d8f875de | ||
|
|
79f9dc6e4a | ||
|
|
6e1c0c1105 | ||
|
|
0362524040 | ||
|
|
dc6656459b | ||
|
|
3ea1b97f6f | ||
|
|
a7c7405ccc | ||
|
|
c391f1117a | ||
|
|
b1e2cb8401 | ||
|
|
db6af134b7 | ||
|
|
7e6cffb00c |
@@ -72,7 +72,7 @@ async def upload_image(
|
||||
resize_to: Optional[str] = Body(
|
||||
default=None,
|
||||
description=f"Dimensions to resize the image to, must be stringified tuple of 2 integers. Max total pixel count: {ResizeToDimensions.MAX_SIZE}",
|
||||
example='"[1024,1024]"',
|
||||
examples=['"[1024,1024]"'],
|
||||
),
|
||||
metadata: Optional[str] = Body(
|
||||
default=None,
|
||||
|
||||
@@ -292,7 +292,7 @@ async def get_hugging_face_models(
|
||||
)
|
||||
async def update_model_record(
|
||||
key: Annotated[str, Path(description="Unique key of model")],
|
||||
changes: Annotated[ModelRecordChanges, Body(description="Model config", example=example_model_input)],
|
||||
changes: Annotated[ModelRecordChanges, Body(description="Model config", examples=[example_model_input])],
|
||||
) -> AnyModelConfig:
|
||||
"""Update a model's config."""
|
||||
logger = ApiDependencies.invoker.services.logger
|
||||
@@ -450,7 +450,7 @@ async def install_model(
|
||||
access_token: Optional[str] = Query(description="access token for the remote resource", default=None),
|
||||
config: ModelRecordChanges = Body(
|
||||
description="Object containing fields that override auto-probed values in the model config record, such as name, description and prediction_type ",
|
||||
example={"name": "string", "description": "string"},
|
||||
examples=[{"name": "string", "description": "string"}],
|
||||
),
|
||||
) -> ModelInstallJob:
|
||||
"""Install a model using a string identifier.
|
||||
|
||||
@@ -143,11 +143,19 @@ flux_dev = StarterModel(
|
||||
flux_kontext = StarterModel(
|
||||
name="FLUX.1 Kontext dev",
|
||||
base=BaseModelType.Flux,
|
||||
source="black-forest-labs/FLUX.1-Kontext-dev::flux1-kontext-dev.safetensors",
|
||||
source="https://huggingface.co/black-forest-labs/FLUX.1-Kontext-dev/resolve/main/flux1-kontext-dev.safetensors",
|
||||
description="FLUX.1 Kontext dev transformer in bfloat16. Total size with dependencies: ~33GB",
|
||||
type=ModelType.Main,
|
||||
dependencies=[t5_base_encoder, flux_vae, clip_l_encoder],
|
||||
)
|
||||
flux_kontext_quantized = StarterModel(
|
||||
name="FLUX.1 Kontext dev (Quantized)",
|
||||
base=BaseModelType.Flux,
|
||||
source="https://huggingface.co/unsloth/FLUX.1-Kontext-dev-GGUF/resolve/main/flux1-kontext-dev-Q4_K_M.gguf",
|
||||
description="FLUX.1 Kontext dev quantized (q4_k_m). Total size with dependencies: ~14GB",
|
||||
type=ModelType.Main,
|
||||
dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder],
|
||||
)
|
||||
sd35_medium = StarterModel(
|
||||
name="SD3.5 Medium",
|
||||
base=BaseModelType.StableDiffusion3,
|
||||
@@ -664,7 +672,7 @@ flux_fill = StarterModel(
|
||||
# List of starter models, displayed on the frontend.
|
||||
# The order/sort of this list is not changed by the frontend - set it how you want it here.
|
||||
STARTER_MODELS: list[StarterModel] = [
|
||||
flux_kontext,
|
||||
flux_kontext_quantized,
|
||||
flux_schnell_quantized,
|
||||
flux_dev_quantized,
|
||||
flux_schnell,
|
||||
@@ -785,7 +793,7 @@ flux_bundle: list[StarterModel] = [
|
||||
flux_depth_control_lora,
|
||||
flux_redux,
|
||||
flux_fill,
|
||||
flux_kontext,
|
||||
flux_kontext_quantized,
|
||||
]
|
||||
|
||||
STARTER_BUNDLES: dict[str, StarterModelBundle] = {
|
||||
|
||||
@@ -12,6 +12,8 @@ const config: KnipConfig = {
|
||||
'src/features/parameters/types/parameterSchemas.ts',
|
||||
// TODO(psyche): maybe we can clean up these utils after canvas v2 release
|
||||
'src/features/controlLayers/konva/util.ts',
|
||||
// Will be using this
|
||||
'src/common/hooks/useAsyncState.ts',
|
||||
],
|
||||
ignoreBinaries: ['only-allow'],
|
||||
paths: {
|
||||
|
||||
@@ -1399,7 +1399,7 @@
|
||||
"fluxFillIncompatibleWithT2IAndI2I": "FLUX Fill is not compatible with Text to Image or Image to Image. Use other FLUX models for these tasks.",
|
||||
"imagenIncompatibleGenerationMode": "Google {{model}} supports Text to Image only. Use other models for Image to Image, Inpainting and Outpainting tasks.",
|
||||
"chatGPT4oIncompatibleGenerationMode": "ChatGPT 4o supports Text to Image and Image to Image only. Use other models Inpainting and Outpainting tasks.",
|
||||
"fluxKontextIncompatibleGenerationMode": "FLUX Kontext supports Text to Image only. Use other models for Image to Image, Inpainting and Outpainting tasks.",
|
||||
"fluxKontextIncompatibleGenerationMode": "FLUX Kontext does not support generation from images placed on the canvas. Re-try using the Reference Image section and disable any Raster Layers.",
|
||||
"problemUnpublishingWorkflow": "Problem Unpublishing Workflow",
|
||||
"problemUnpublishingWorkflowDescription": "There was a problem unpublishing the workflow. Please try again.",
|
||||
"workflowUnpublished": "Workflow Unpublished",
|
||||
@@ -2380,6 +2380,11 @@
|
||||
"saveToGallery": "Save To Gallery",
|
||||
"showResultsOn": "Showing Results",
|
||||
"showResultsOff": "Hiding Results"
|
||||
},
|
||||
"autoSwitch": {
|
||||
"off": "Off",
|
||||
"switchOnStart": "On Start",
|
||||
"switchOnFinish": "On Finish"
|
||||
}
|
||||
},
|
||||
"upscaling": {
|
||||
@@ -2555,8 +2560,9 @@
|
||||
"whatsNew": {
|
||||
"whatsNewInInvoke": "What's New in Invoke",
|
||||
"items": [
|
||||
"Inpainting: Per-mask noise levels and denoise limits.",
|
||||
"Canvas: Smarter aspect ratios for SDXL and improved scroll-to-zoom."
|
||||
"Generate images faster with new Launchpads and a simplified Generate tab.",
|
||||
"Edit with prompts using Flux Kontext Dev.",
|
||||
"Export to PSD, bulk-hide overlays, organize models & images — all in a reimagined interface built for control."
|
||||
],
|
||||
"readReleaseNotes": "Read Release Notes",
|
||||
"watchRecentReleaseVideos": "Watch Recent Release Videos",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { memo, useCallback } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
import AppErrorBoundaryFallback from './AppErrorBoundaryFallback';
|
||||
import ThemeLocaleProvider from './ThemeLocaleProvider';
|
||||
const DEFAULT_CONFIG = {};
|
||||
|
||||
interface Props {
|
||||
@@ -30,12 +31,14 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
|
||||
|
||||
return (
|
||||
<ErrorBoundary onReset={handleReset} FallbackComponent={AppErrorBoundaryFallback}>
|
||||
<Box id="invoke-app-wrapper" w="100dvw" h="100dvh" position="relative" overflow="hidden">
|
||||
<AppContent />
|
||||
{!didStudioInit && <Loading />}
|
||||
</Box>
|
||||
<GlobalHookIsolator config={config} studioInitAction={studioInitAction} />
|
||||
<GlobalModalIsolator />
|
||||
<ThemeLocaleProvider>
|
||||
<Box id="invoke-app-wrapper" w="100dvw" h="100dvh" position="relative" overflow="hidden">
|
||||
<AppContent />
|
||||
{!didStudioInit && <Loading />}
|
||||
</Box>
|
||||
<GlobalHookIsolator config={config} studioInitAction={studioInitAction} />
|
||||
<GlobalModalIsolator />
|
||||
</ThemeLocaleProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { useImageActions } from 'features/gallery/hooks/useImageActions';
|
||||
import { useLoadWorkflow } from 'features/gallery/hooks/useLoadWorkflow';
|
||||
import { useRecallAll } from 'features/gallery/hooks/useRecallAll';
|
||||
import { useRecallDimensions } from 'features/gallery/hooks/useRecallDimensions';
|
||||
import { useRecallPrompts } from 'features/gallery/hooks/useRecallPrompts';
|
||||
import { useRecallRemix } from 'features/gallery/hooks/useRecallRemix';
|
||||
import { useRecallSeed } from 'features/gallery/hooks/useRecallSeed';
|
||||
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
||||
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import { memo } from 'react';
|
||||
import { useImageDTO } from 'services/api/endpoints/images';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
@@ -27,59 +30,64 @@ GlobalImageHotkeys.displayName = 'GlobalImageHotkeys';
|
||||
const GlobalImageHotkeysInternal = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
|
||||
const isGalleryFocused = useIsRegionFocused('gallery');
|
||||
const isViewerFocused = useIsRegionFocused('viewer');
|
||||
const imageActions = useImageActions(imageDTO);
|
||||
const isStaging = useAppSelector(selectIsStaging);
|
||||
const isUpscalingEnabled = useFeatureStatus('upscaling');
|
||||
|
||||
const isFocusOK = isGalleryFocused || isViewerFocused;
|
||||
|
||||
const recallAll = useRecallAll(imageDTO);
|
||||
const recallRemix = useRecallRemix(imageDTO);
|
||||
const recallPrompts = useRecallPrompts(imageDTO);
|
||||
const recallSeed = useRecallSeed(imageDTO);
|
||||
const recallDimensions = useRecallDimensions(imageDTO);
|
||||
const loadWorkflow = useLoadWorkflow(imageDTO);
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'loadWorkflow',
|
||||
category: 'viewer',
|
||||
callback: imageActions.loadWorkflow,
|
||||
options: { enabled: isGalleryFocused || isViewerFocused },
|
||||
dependencies: [imageActions.loadWorkflow, isGalleryFocused, isViewerFocused],
|
||||
callback: loadWorkflow.load,
|
||||
options: { enabled: loadWorkflow.isEnabled && isFocusOK },
|
||||
dependencies: [loadWorkflow, isFocusOK],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'recallAll',
|
||||
category: 'viewer',
|
||||
callback: imageActions.recallAll,
|
||||
options: { enabled: !isStaging && (isGalleryFocused || isViewerFocused) },
|
||||
dependencies: [imageActions.recallAll, isStaging, isGalleryFocused, isViewerFocused],
|
||||
callback: recallAll.recall,
|
||||
options: { enabled: recallAll.isEnabled && isFocusOK },
|
||||
dependencies: [recallAll, isFocusOK],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'recallSeed',
|
||||
category: 'viewer',
|
||||
callback: imageActions.recallSeed,
|
||||
options: { enabled: isGalleryFocused || isViewerFocused },
|
||||
dependencies: [imageActions.recallSeed, isGalleryFocused, isViewerFocused],
|
||||
callback: recallSeed.recall,
|
||||
options: { enabled: recallSeed.isEnabled && isFocusOK },
|
||||
dependencies: [recallSeed, isFocusOK],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'recallPrompts',
|
||||
category: 'viewer',
|
||||
callback: imageActions.recallPrompts,
|
||||
options: { enabled: isGalleryFocused || isViewerFocused },
|
||||
dependencies: [imageActions.recallPrompts, isGalleryFocused, isViewerFocused],
|
||||
callback: recallPrompts.recall,
|
||||
options: { enabled: recallPrompts.isEnabled && isFocusOK },
|
||||
dependencies: [recallPrompts, isFocusOK],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'remix',
|
||||
category: 'viewer',
|
||||
callback: imageActions.remix,
|
||||
options: { enabled: isGalleryFocused || isViewerFocused },
|
||||
dependencies: [imageActions.remix, isGalleryFocused, isViewerFocused],
|
||||
callback: recallRemix.recall,
|
||||
options: { enabled: recallRemix.isEnabled && isFocusOK },
|
||||
dependencies: [recallRemix, isFocusOK],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'useSize',
|
||||
category: 'viewer',
|
||||
callback: imageActions.recallSize,
|
||||
options: { enabled: !isStaging && (isGalleryFocused || isViewerFocused) },
|
||||
dependencies: [imageActions.recallSize, isStaging, isGalleryFocused, isViewerFocused],
|
||||
});
|
||||
useRegisteredHotkeys({
|
||||
id: 'runPostprocessing',
|
||||
category: 'viewer',
|
||||
callback: imageActions.upscale,
|
||||
options: { enabled: isUpscalingEnabled && isViewerFocused },
|
||||
dependencies: [isUpscalingEnabled, imageDTO, isViewerFocused],
|
||||
callback: recallDimensions.recall,
|
||||
options: { enabled: recallDimensions.isEnabled && isFocusOK },
|
||||
dependencies: [recallDimensions, isFocusOK],
|
||||
});
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ import { $socketOptions } from 'services/events/stores';
|
||||
import type { ManagerOptions, SocketOptions } from 'socket.io-client';
|
||||
|
||||
const App = lazy(() => import('./App'));
|
||||
const ThemeLocaleProvider = lazy(() => import('./ThemeLocaleProvider'));
|
||||
|
||||
interface Props extends PropsWithChildren {
|
||||
apiUrl?: string;
|
||||
@@ -330,9 +329,7 @@ const InvokeAIUI = ({
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<React.Suspense fallback={<Loading />}>
|
||||
<ThemeLocaleProvider>
|
||||
<App config={config} studioInitAction={studioInitAction} />
|
||||
</ThemeLocaleProvider>
|
||||
<App config={config} studioInitAction={studioInitAction} />
|
||||
</React.Suspense>
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
|
||||
@@ -170,7 +170,6 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
|
||||
case 'canvas':
|
||||
// Go to the canvas tab, open the launchpad
|
||||
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
|
||||
store.dispatch(canvasReset());
|
||||
break;
|
||||
case 'workflows':
|
||||
// Go to the workflows tab
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import { bboxSyncedToOptimalDimension } from 'features/controlLayers/store/canvasSlice';
|
||||
import { bboxSyncedToOptimalDimension, rgRefImageModelChanged } from 'features/controlLayers/store/canvasSlice';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { loraDeleted } from 'features/controlLayers/store/lorasSlice';
|
||||
import { modelChanged, syncedToOptimalDimension, vaeSelected } from 'features/controlLayers/store/paramsSlice';
|
||||
import { selectBboxModelBase } from 'features/controlLayers/store/selectors';
|
||||
import { refImageModelChanged, selectReferenceImageEntities } from 'features/controlLayers/store/refImagesSlice';
|
||||
import {
|
||||
selectAllEntitiesOfType,
|
||||
selectBboxModelBase,
|
||||
selectCanvasSlice,
|
||||
} from 'features/controlLayers/store/selectors';
|
||||
import { getEntityIdentifier } from 'features/controlLayers/store/types';
|
||||
import { modelSelected } from 'features/parameters/store/actions';
|
||||
import { zParameterModel } from 'features/parameters/types/parameterSchemas';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { t } from 'i18next';
|
||||
import { selectGlobalRefImageModels, selectRegionalRefImageModels } from 'services/api/hooks/modelsByType';
|
||||
import type { AnyModelConfig } from 'services/api/types';
|
||||
import {
|
||||
isChatGPT4oModelConfig,
|
||||
isFluxKontextApiModelConfig,
|
||||
isFluxKontextModelConfig,
|
||||
isFluxReduxModelConfig,
|
||||
} from 'services/api/types';
|
||||
|
||||
const log = logger('models');
|
||||
|
||||
@@ -25,9 +39,8 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
|
||||
}
|
||||
|
||||
const newModel = result.data;
|
||||
|
||||
const newBaseModel = newModel.base;
|
||||
const didBaseModelChange = state.params.model?.base !== newBaseModel;
|
||||
const newBase = newModel.base;
|
||||
const didBaseModelChange = state.params.model?.base !== newBase;
|
||||
|
||||
if (didBaseModelChange) {
|
||||
// we may need to reset some incompatible submodels
|
||||
@@ -35,7 +48,7 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
|
||||
|
||||
// handle incompatible loras
|
||||
state.loras.loras.forEach((lora) => {
|
||||
if (lora.model.base !== newBaseModel) {
|
||||
if (lora.model.base !== newBase) {
|
||||
dispatch(loraDeleted({ id: lora.id }));
|
||||
modelsCleared += 1;
|
||||
}
|
||||
@@ -43,20 +56,82 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
|
||||
|
||||
// handle incompatible vae
|
||||
const { vae } = state.params;
|
||||
if (vae && vae.base !== newBaseModel) {
|
||||
if (vae && vae.base !== newBase) {
|
||||
dispatch(vaeSelected(null));
|
||||
modelsCleared += 1;
|
||||
}
|
||||
|
||||
// handle incompatible controlnets
|
||||
// state.canvas.present.controlAdapters.entities.forEach((ca) => {
|
||||
// if (ca.model?.base !== newBaseModel) {
|
||||
// modelsCleared += 1;
|
||||
// if (ca.isEnabled) {
|
||||
// dispatch(entityIsEnabledToggled({ entityIdentifier: { id: ca.id, type: 'control_adapter' } }));
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// Handle incompatible reference image models - switch to first compatible model, with some smart logic
|
||||
// to choose the best available model based on the new main model.
|
||||
const allRefImageModels = selectGlobalRefImageModels(state).filter(({ base }) => base === newBase);
|
||||
|
||||
let newGlobalRefImageModel = null;
|
||||
|
||||
// Certain models require the ref image model to be the same as the main model - others just need a matching
|
||||
// base. Helper to grab the first exact match or the first available model if no exact match is found.
|
||||
const exactMatchOrFirst = <T extends AnyModelConfig>(candidates: T[]): T | null =>
|
||||
candidates.find(({ key }) => key === newModel.key) ?? candidates[0] ?? null;
|
||||
|
||||
// The only way we can differentiate between FLUX and FLUX Kontext is to check for "kontext" in the name
|
||||
if (newModel.base === 'flux' && newModel.name.toLowerCase().includes('kontext')) {
|
||||
const fluxKontextDevModels = allRefImageModels.filter(isFluxKontextModelConfig);
|
||||
newGlobalRefImageModel = exactMatchOrFirst(fluxKontextDevModels);
|
||||
} else if (newModel.base === 'chatgpt-4o') {
|
||||
const chatGPT4oModels = allRefImageModels.filter(isChatGPT4oModelConfig);
|
||||
newGlobalRefImageModel = exactMatchOrFirst(chatGPT4oModels);
|
||||
} else if (newModel.base === 'flux-kontext') {
|
||||
const fluxKontextApiModels = allRefImageModels.filter(isFluxKontextApiModelConfig);
|
||||
newGlobalRefImageModel = exactMatchOrFirst(fluxKontextApiModels);
|
||||
} else if (newModel.base === 'flux') {
|
||||
const fluxReduxModels = allRefImageModels.filter(isFluxReduxModelConfig);
|
||||
newGlobalRefImageModel = fluxReduxModels[0] ?? null;
|
||||
} else {
|
||||
newGlobalRefImageModel = allRefImageModels[0] ?? null;
|
||||
}
|
||||
|
||||
// All ref image entities are updated to use the same new model
|
||||
const refImageEntities = selectReferenceImageEntities(state);
|
||||
for (const entity of refImageEntities) {
|
||||
const shouldUpdateModel =
|
||||
(entity.config.model && entity.config.model.base !== newBase) ||
|
||||
(!entity.config.model && newGlobalRefImageModel);
|
||||
|
||||
if (shouldUpdateModel) {
|
||||
dispatch(
|
||||
refImageModelChanged({
|
||||
id: entity.id,
|
||||
modelConfig: newGlobalRefImageModel,
|
||||
})
|
||||
);
|
||||
modelsCleared += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// For regional guidance, there is no smart logic - we just pick the first available model.
|
||||
const newRegionalRefImageModel = selectRegionalRefImageModels(state)[0] ?? null;
|
||||
|
||||
// All regional guidance entities are updated to use the same new model.
|
||||
const canvasState = selectCanvasSlice(state);
|
||||
const canvasRegionalGuidanceEntities = selectAllEntitiesOfType(canvasState, 'regional_guidance');
|
||||
for (const entity of canvasRegionalGuidanceEntities) {
|
||||
for (const refImage of entity.referenceImages) {
|
||||
// Only change the model if the current one is not compatible with the new base model.
|
||||
const shouldUpdateModel =
|
||||
(refImage.config.model && refImage.config.model.base !== newBase) ||
|
||||
(!refImage.config.model && newRegionalRefImageModel);
|
||||
|
||||
if (shouldUpdateModel) {
|
||||
dispatch(
|
||||
rgRefImageModelChanged({
|
||||
entityIdentifier: getEntityIdentifier(entity),
|
||||
referenceImageId: refImage.id,
|
||||
modelConfig: newRegionalRefImageModel,
|
||||
})
|
||||
);
|
||||
modelsCleared += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modelsCleared > 0) {
|
||||
toast({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isNil } from 'es-toolkit';
|
||||
import { bboxHeightChanged, bboxWidthChanged } from 'features/controlLayers/store/canvasSlice';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import {
|
||||
heightChanged,
|
||||
setCfgRescaleMultiplier,
|
||||
setCfgScale,
|
||||
setGuidance,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
setSteps,
|
||||
vaePrecisionChanged,
|
||||
vaeSelected,
|
||||
widthChanged,
|
||||
} from 'features/controlLayers/store/paramsSlice';
|
||||
import { setDefaultSettings } from 'features/parameters/store/actions';
|
||||
import {
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
zParameterVAEModel,
|
||||
} from 'features/parameters/types/parameterSchemas';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { t } from 'i18next';
|
||||
import { modelConfigsAdapterSelectors, modelsApi } from 'services/api/endpoints/models';
|
||||
import { isNonRefinerMainModelConfig } from 'services/api/types';
|
||||
@@ -113,15 +116,24 @@ export const addSetDefaultSettingsListener = (startAppListening: AppStartListeni
|
||||
const setSizeOptions = { updateAspectRatio: true, clamp: true };
|
||||
|
||||
const isStaging = selectIsStaging(getState());
|
||||
if (!isStaging && width) {
|
||||
const activeTab = selectActiveTab(getState());
|
||||
if (activeTab === 'generate') {
|
||||
if (isParameterWidth(width)) {
|
||||
dispatch(bboxWidthChanged({ width, ...setSizeOptions }));
|
||||
dispatch(widthChanged({ width, ...setSizeOptions }));
|
||||
}
|
||||
if (isParameterHeight(height)) {
|
||||
dispatch(heightChanged({ height, ...setSizeOptions }));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isStaging && height) {
|
||||
if (isParameterHeight(height)) {
|
||||
dispatch(bboxHeightChanged({ height, ...setSizeOptions }));
|
||||
if (activeTab === 'canvas') {
|
||||
if (!isStaging) {
|
||||
if (isParameterWidth(width)) {
|
||||
dispatch(bboxWidthChanged({ width, ...setSizeOptions }));
|
||||
}
|
||||
if (isParameterHeight(height)) {
|
||||
dispatch(bboxHeightChanged({ height, ...setSizeOptions }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,14 +87,10 @@ export const buildGroup = <T extends object>(group: Omit<Group<T>, typeof unique
|
||||
[uniqueGroupKey]: true,
|
||||
});
|
||||
|
||||
const isGroup = <T extends object>(optionOrGroup: OptionOrGroup<T>): optionOrGroup is Group<T> => {
|
||||
export const isGroup = <T extends object>(optionOrGroup: OptionOrGroup<T>): optionOrGroup is Group<T> => {
|
||||
return uniqueGroupKey in optionOrGroup && optionOrGroup[uniqueGroupKey] === true;
|
||||
};
|
||||
|
||||
export const isOption = <T extends object>(optionOrGroup: OptionOrGroup<T>): optionOrGroup is T => {
|
||||
return !(uniqueGroupKey in optionOrGroup);
|
||||
};
|
||||
|
||||
const DefaultOptionComponent = typedMemo(<T extends object>({ option }: { option: T }) => {
|
||||
const { getOptionId } = usePickerContext();
|
||||
return <Text fontWeight="bold">{getOptionId(option)}</Text>;
|
||||
|
||||
115
invokeai/frontend/web/src/common/hooks/useAsyncState.ts
Normal file
115
invokeai/frontend/web/src/common/hooks/useAsyncState.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { WrappedError } from 'common/util/result';
|
||||
import type { Atom } from 'nanostores';
|
||||
import { atom } from 'nanostores';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type SuccessState<T> = {
|
||||
status: 'success';
|
||||
value: T;
|
||||
error: null;
|
||||
};
|
||||
|
||||
type ErrorState = {
|
||||
status: 'error';
|
||||
value: null;
|
||||
error: Error;
|
||||
};
|
||||
|
||||
type PendingState = {
|
||||
status: 'pending';
|
||||
value: null;
|
||||
error: null;
|
||||
};
|
||||
|
||||
type IdleState = {
|
||||
status: 'idle';
|
||||
value: null;
|
||||
error: null;
|
||||
};
|
||||
|
||||
export type State<T> = IdleState | PendingState | SuccessState<T> | ErrorState;
|
||||
|
||||
type UseAsyncStateOptions = {
|
||||
immediate?: boolean;
|
||||
};
|
||||
|
||||
type UseAsyncReturn<T> = {
|
||||
$state: Atom<State<T>>;
|
||||
trigger: () => Promise<void>;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export const useAsyncState = <T>(execute: () => Promise<T>, options?: UseAsyncStateOptions): UseAsyncReturn<T> => {
|
||||
const $state = useState(() =>
|
||||
atom<State<T>>({
|
||||
status: 'idle',
|
||||
value: null,
|
||||
error: null,
|
||||
})
|
||||
)[0];
|
||||
|
||||
const trigger = useCallback(async () => {
|
||||
$state.set({
|
||||
status: 'pending',
|
||||
value: null,
|
||||
error: null,
|
||||
});
|
||||
try {
|
||||
const value = await execute();
|
||||
$state.set({
|
||||
status: 'success',
|
||||
value,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
$state.set({
|
||||
status: 'error',
|
||||
value: null,
|
||||
error: WrappedError.wrap(error),
|
||||
});
|
||||
}
|
||||
}, [$state, execute]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
$state.set({
|
||||
status: 'idle',
|
||||
value: null,
|
||||
error: null,
|
||||
});
|
||||
}, [$state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (options?.immediate) {
|
||||
trigger();
|
||||
}
|
||||
}, [options?.immediate, trigger]);
|
||||
|
||||
const api = useMemo(
|
||||
() =>
|
||||
({
|
||||
$state,
|
||||
trigger,
|
||||
reset,
|
||||
}) satisfies UseAsyncReturn<T>,
|
||||
[$state, trigger, reset]
|
||||
);
|
||||
|
||||
return api;
|
||||
};
|
||||
|
||||
type UseAsyncReturnReactive<T> = {
|
||||
state: State<T>;
|
||||
trigger: () => Promise<void>;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export const useAsyncStateReactive = <T>(
|
||||
execute: () => Promise<T>,
|
||||
options?: UseAsyncStateOptions
|
||||
): UseAsyncReturnReactive<T> => {
|
||||
const { $state, trigger, reset } = useAsyncState(execute, options);
|
||||
const state = useStore($state);
|
||||
|
||||
return { state, trigger, reset };
|
||||
};
|
||||
@@ -61,7 +61,7 @@ export const RefImageImage = memo(
|
||||
)}
|
||||
{imageDTO && (
|
||||
<>
|
||||
<DndImage imageDTO={imageDTO} borderWidth={1} borderStyle="solid" w="full" />
|
||||
<DndImage imageDTO={imageDTO} borderRadius="base" borderWidth={1} borderStyle="solid" w="full" />
|
||||
<Flex position="absolute" flexDir="column" top={2} insetInlineEnd={2} gap={1}>
|
||||
<DndImageIcon
|
||||
onClick={handleResetControlImage}
|
||||
|
||||
@@ -142,6 +142,7 @@ export const RefImagePreview = memo(() => {
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
cursor="pointer"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Image
|
||||
src={imageDTO?.thumbnail_url}
|
||||
@@ -151,7 +152,6 @@ export const RefImagePreview = memo(() => {
|
||||
fallback={<Skeleton h="full" aspectRatio="1/1" />}
|
||||
maxW="full"
|
||||
maxH="full"
|
||||
borderRadius="base"
|
||||
/>
|
||||
{isIPAdapterConfig(entity.config) && (
|
||||
<Flex
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import {
|
||||
useCanvasSessionContext,
|
||||
useOutputImageDTO,
|
||||
@@ -10,6 +11,10 @@ import { QueueItemNumber } from 'features/controlLayers/components/SimpleSession
|
||||
import { QueueItemProgressImage } from 'features/controlLayers/components/SimpleSession/QueueItemProgressImage';
|
||||
import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel';
|
||||
import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared';
|
||||
import {
|
||||
selectStagingAreaAutoSwitch,
|
||||
settingsStagingAreaAutoSwitchChanged,
|
||||
} from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import { DndImage } from 'features/dnd/DndImage';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { memo, useCallback } from 'react';
|
||||
@@ -21,12 +26,13 @@ const sx = {
|
||||
pos: 'relative',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
h: 108,
|
||||
w: 108,
|
||||
flexShrink: 0,
|
||||
h: 'full',
|
||||
aspectRatio: '1/1',
|
||||
borderWidth: 2,
|
||||
borderRadius: 'base',
|
||||
bg: 'base.900',
|
||||
overflow: 'hidden',
|
||||
'&[data-selected="true"]': {
|
||||
borderColor: 'invokeBlue.300',
|
||||
},
|
||||
@@ -34,28 +40,29 @@ const sx = {
|
||||
|
||||
type Props = {
|
||||
item: S['SessionQueueItem'];
|
||||
number: number;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) => {
|
||||
export const QueueItemPreviewMini = memo(({ item, isSelected, index }: Props) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const ctx = useCanvasSessionContext();
|
||||
const { imageLoaded } = useProgressData(ctx.$progressData, item.item_id);
|
||||
const imageDTO = useOutputImageDTO(item);
|
||||
const autoSwitch = useAppSelector(selectStagingAreaAutoSwitch);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
ctx.$selectedItemId.set(item.item_id);
|
||||
}, [ctx.$selectedItemId, item.item_id]);
|
||||
|
||||
const onDoubleClick = useCallback(() => {
|
||||
const autoSwitch = ctx.$autoSwitch.get();
|
||||
if (autoSwitch !== 'off') {
|
||||
ctx.$autoSwitch.set('off');
|
||||
dispatch(settingsStagingAreaAutoSwitchChanged('off'));
|
||||
toast({
|
||||
title: 'Auto-Switch Disabled',
|
||||
});
|
||||
}
|
||||
}, [ctx.$autoSwitch]);
|
||||
}, [autoSwitch, dispatch]);
|
||||
|
||||
const onLoad = useCallback(() => {
|
||||
ctx.onImageLoad(item.item_id);
|
||||
@@ -63,7 +70,7 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) =
|
||||
|
||||
return (
|
||||
<Flex
|
||||
id={getQueueItemElementId(item.item_id)}
|
||||
id={getQueueItemElementId(index)}
|
||||
sx={sx}
|
||||
data-selected={isSelected}
|
||||
onClick={onClick}
|
||||
@@ -72,7 +79,7 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) =
|
||||
<QueueItemStatusLabel item={item} position="absolute" margin="auto" />
|
||||
{imageDTO && <DndImage imageDTO={imageDTO} onLoad={onLoad} asThumbnail />}
|
||||
{!imageLoaded && <QueueItemProgressImage itemId={item.item_id} position="absolute" />}
|
||||
<QueueItemNumber number={number} position="absolute" top={0} left={1} />
|
||||
<QueueItemNumber number={index + 1} position="absolute" top={0} left={1} />
|
||||
<QueueItemCircularProgress itemId={item.item_id} status={item.status} position="absolute" top={1} right={2} />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -16,21 +16,21 @@ export const QueueItemStatusLabel = memo(({ item, ...rest }: Props) => {
|
||||
|
||||
if (item.status === 'pending') {
|
||||
return (
|
||||
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="base.300" {...rest}>
|
||||
<Text fontSize="xs" pointerEvents="none" userSelect="none" fontWeight="semibold" color="base.300" {...rest}>
|
||||
Pending
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (item.status === 'canceled') {
|
||||
return (
|
||||
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="warning.300" {...rest}>
|
||||
<Text fontSize="xs" pointerEvents="none" userSelect="none" fontWeight="semibold" color="warning.300" {...rest}>
|
||||
Canceled
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (item.status === 'failed') {
|
||||
return (
|
||||
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="error.300" {...rest}>
|
||||
<Text fontSize="xs" pointerEvents="none" userSelect="none" fontWeight="semibold" color="error.300" {...rest}>
|
||||
Failed
|
||||
</Text>
|
||||
);
|
||||
@@ -38,7 +38,7 @@ export const QueueItemStatusLabel = memo(({ item, ...rest }: Props) => {
|
||||
|
||||
if (item.status === 'in_progress') {
|
||||
return (
|
||||
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="invokeBlue.300" {...rest}>
|
||||
<Text fontSize="xs" pointerEvents="none" userSelect="none" fontWeight="semibold" color="invokeBlue.300" {...rest}>
|
||||
In Progress
|
||||
</Text>
|
||||
);
|
||||
@@ -46,7 +46,14 @@ export const QueueItemStatusLabel = memo(({ item, ...rest }: Props) => {
|
||||
|
||||
if (item.status === 'completed') {
|
||||
return (
|
||||
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="invokeGreen.300" {...rest}>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
pointerEvents="none"
|
||||
userSelect="none"
|
||||
fontWeight="semibold"
|
||||
color="invokeGreen.300"
|
||||
{...rest}
|
||||
>
|
||||
Completed
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,148 @@
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { Box, Flex, forwardRef } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { QueueItemPreviewMini } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewMini';
|
||||
import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { memo, useEffect } from 'react';
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
import type { CSSProperties, RefObject } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Components, ItemContent, ListRange, VirtuosoHandle, VirtuosoProps } from 'react-virtuoso';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import type { S } from 'services/api/types';
|
||||
|
||||
import { getQueueItemElementId } from './shared';
|
||||
|
||||
const log = logger('system');
|
||||
|
||||
const virtuosoStyles = {
|
||||
width: '100%',
|
||||
height: '72px',
|
||||
} satisfies CSSProperties;
|
||||
|
||||
type VirtuosoContext = { selectedItemId: number | null };
|
||||
|
||||
/**
|
||||
* Scroll the item at the given index into view if it is not currently visible.
|
||||
*/
|
||||
const scrollIntoView = (
|
||||
targetIndex: number,
|
||||
rootEl: HTMLDivElement,
|
||||
virtuosoHandle: VirtuosoHandle,
|
||||
range: ListRange
|
||||
) => {
|
||||
if (range.endIndex === 0) {
|
||||
// No range is rendered; no need to scroll to anything.
|
||||
return;
|
||||
}
|
||||
|
||||
const targetItem = rootEl.querySelector(`#${getQueueItemElementId(targetIndex)}`);
|
||||
|
||||
if (!targetItem) {
|
||||
if (targetIndex > range.endIndex) {
|
||||
virtuosoHandle.scrollToIndex({
|
||||
index: targetIndex,
|
||||
behavior: 'auto',
|
||||
align: 'end',
|
||||
});
|
||||
} else if (targetIndex < range.startIndex) {
|
||||
virtuosoHandle.scrollToIndex({
|
||||
index: targetIndex,
|
||||
behavior: 'auto',
|
||||
align: 'start',
|
||||
});
|
||||
} else {
|
||||
log.debug(
|
||||
`Unable to find queue item at index ${targetIndex} but it is in the rendered range ${range.startIndex}-${range.endIndex}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// We found the image in the DOM, but it might be in the overscan range - rendered but not in the visible viewport.
|
||||
// Check if it is in the viewport and scroll if necessary.
|
||||
|
||||
const itemRect = targetItem.getBoundingClientRect();
|
||||
const rootRect = rootEl.getBoundingClientRect();
|
||||
|
||||
if (itemRect.left < rootRect.left) {
|
||||
virtuosoHandle.scrollToIndex({
|
||||
index: targetIndex,
|
||||
behavior: 'auto',
|
||||
align: 'start',
|
||||
});
|
||||
} else if (itemRect.right > rootRect.right) {
|
||||
virtuosoHandle.scrollToIndex({
|
||||
index: targetIndex,
|
||||
behavior: 'auto',
|
||||
align: 'end',
|
||||
});
|
||||
} else {
|
||||
// Image is already in view
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
const useScrollableStagingArea = (rootRef: RefObject<HTMLDivElement>) => {
|
||||
const [scroller, scrollerRef] = useState<HTMLElement | null>(null);
|
||||
const [initialize, osInstance] = useOverlayScrollbars({
|
||||
defer: true,
|
||||
events: {
|
||||
initialized(osInstance) {
|
||||
// force overflow styles
|
||||
const { viewport } = osInstance.elements();
|
||||
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
|
||||
viewport.style.overflowY = `var(--os-viewport-overflow-y)`;
|
||||
},
|
||||
},
|
||||
options: {
|
||||
scrollbars: {
|
||||
visibility: 'auto',
|
||||
autoHide: 'scroll',
|
||||
autoHideDelay: 1300,
|
||||
theme: 'os-theme-dark',
|
||||
},
|
||||
overflow: {
|
||||
y: 'hidden',
|
||||
x: 'scroll',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const { current: root } = rootRef;
|
||||
|
||||
if (scroller && root) {
|
||||
initialize({
|
||||
target: root,
|
||||
elements: {
|
||||
viewport: scroller,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
osInstance()?.destroy();
|
||||
};
|
||||
}, [scroller, initialize, osInstance, rootRef]);
|
||||
|
||||
return scrollerRef;
|
||||
};
|
||||
|
||||
export const StagingAreaItemsList = memo(() => {
|
||||
const canvasManager = useCanvasManagerSafe();
|
||||
const ctx = useCanvasSessionContext();
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const rangeRef = useRef<ListRange>({ startIndex: 0, endIndex: 0 });
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const items = useStore(ctx.$items);
|
||||
const selectedItemId = useStore(ctx.$selectedItemId);
|
||||
|
||||
const context = useMemo(() => ({ selectedItemId }), [selectedItemId]);
|
||||
const scrollerRef = useScrollableStagingArea(rootRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasManager) {
|
||||
return;
|
||||
@@ -20,19 +151,64 @@ export const StagingAreaItemsList = memo(() => {
|
||||
return canvasManager.stagingArea.connectToSession(ctx.$selectedItemId, ctx.$progressData, ctx.$isPending);
|
||||
}, [canvasManager, ctx.$progressData, ctx.$selectedItemId, ctx.$isPending]);
|
||||
|
||||
useEffect(() => {
|
||||
return ctx.$selectedItemIndex.listen((index) => {
|
||||
if (!virtuosoRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rootRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (index === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollIntoView(index, rootRef.current, virtuosoRef.current, rangeRef.current);
|
||||
});
|
||||
}, [ctx.$selectedItemIndex]);
|
||||
|
||||
const onRangeChanged = useCallback((range: ListRange) => {
|
||||
rangeRef.current = range;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollableContent overflowX="scroll" overflowY="hidden">
|
||||
<Flex gap={2} w="full" h="full" justifyContent="safe center">
|
||||
{items.map((item, i) => (
|
||||
<QueueItemPreviewMini
|
||||
key={`${item.item_id}-mini`}
|
||||
item={item}
|
||||
number={i + 1}
|
||||
isSelected={selectedItemId === item.item_id}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</ScrollableContent>
|
||||
<Box data-overlayscrollbars-initialize="" ref={rootRef} position="relative" w="full" h="full">
|
||||
<Virtuoso<S['SessionQueueItem'], VirtuosoContext>
|
||||
ref={virtuosoRef}
|
||||
context={context}
|
||||
data={items}
|
||||
horizontalDirection
|
||||
style={virtuosoStyles}
|
||||
itemContent={itemContent}
|
||||
components={components}
|
||||
rangeChanged={onRangeChanged}
|
||||
// Virtuoso expects the ref to be of HTMLElement | null | Window, but overlayscrollbars doesn't allow Window
|
||||
scrollerRef={scrollerRef as VirtuosoProps<S['SessionQueueItem'], VirtuosoContext>['scrollerRef']}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
StagingAreaItemsList.displayName = 'StagingAreaItemsList';
|
||||
|
||||
const itemContent: ItemContent<S['SessionQueueItem'], VirtuosoContext> = (index, item, { selectedItemId }) => (
|
||||
<QueueItemPreviewMini
|
||||
key={`${item.item_id}-mini`}
|
||||
item={item}
|
||||
index={index}
|
||||
isSelected={selectedItemId === item.item_id}
|
||||
/>
|
||||
);
|
||||
|
||||
const listSx = {
|
||||
'& > * + *': {
|
||||
pl: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const components: Components<S['SessionQueueItem'], VirtuosoContext> = {
|
||||
List: forwardRef(({ context: _, ...rest }, ref) => {
|
||||
return <Flex ref={ref} sx={listSx} {...rest} />;
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { EMPTY_ARRAY } from 'app/store/constants';
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { buildZodTypeGuard } from 'common/util/zodUtils';
|
||||
import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared';
|
||||
import { canvasQueueItemDiscarded, selectDiscardedItems } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { selectStagingAreaAutoSwitch } from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import {
|
||||
buildSelectSessionQueueItems,
|
||||
canvasQueueItemDiscarded,
|
||||
canvasSessionReset,
|
||||
} from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import type { ProgressImage } from 'features/nodes/types/common';
|
||||
import type { Atom, MapStore, StoreValue, WritableAtom } from 'nanostores';
|
||||
import { atom, computed, effect, map, subscribeKeys } from 'nanostores';
|
||||
@@ -15,11 +17,6 @@ import { queueApi } from 'services/api/endpoints/queue';
|
||||
import type { ImageDTO, S } from 'services/api/types';
|
||||
import { $socket } from 'services/events/stores';
|
||||
import { assert, objectEntries } from 'tsafe';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
const zAutoSwitchMode = z.enum(['off', 'switch_on_start', 'switch_on_finish']);
|
||||
export const isAutoSwitchMode = buildZodTypeGuard(zAutoSwitchMode);
|
||||
type AutoSwitchMode = z.infer<typeof zAutoSwitchMode>;
|
||||
|
||||
export type ProgressData = {
|
||||
itemId: number;
|
||||
@@ -99,13 +96,13 @@ type CanvasSessionContextValue = {
|
||||
$selectedItem: Atom<S['SessionQueueItem'] | null>;
|
||||
$selectedItemIndex: Atom<number | null>;
|
||||
$selectedItemOutputImageDTO: Atom<ImageDTO | null>;
|
||||
$autoSwitch: WritableAtom<AutoSwitchMode>;
|
||||
selectNext: () => void;
|
||||
selectPrev: () => void;
|
||||
selectFirst: () => void;
|
||||
selectLast: () => void;
|
||||
onImageLoad: (itemId: number) => void;
|
||||
discard: (itemId: number) => void;
|
||||
discardAll: () => void;
|
||||
};
|
||||
|
||||
const CanvasSessionContext = createContext<CanvasSessionContextValue | null>(null);
|
||||
@@ -142,11 +139,6 @@ export const CanvasSessionContextProvider = memo(
|
||||
*/
|
||||
const $items = useState(() => atom<S['SessionQueueItem'][]>([]))[0];
|
||||
|
||||
/**
|
||||
* Whether auto-switch is enabled.
|
||||
*/
|
||||
const $autoSwitch = useState(() => atom<AutoSwitchMode>('switch_on_start'))[0];
|
||||
|
||||
/**
|
||||
* An internal flag used to work around race conditions with auto-switch switching to queue items before their
|
||||
* output images have fully loaded.
|
||||
@@ -228,25 +220,9 @@ export const CanvasSessionContextProvider = memo(
|
||||
)[0];
|
||||
|
||||
/**
|
||||
* A redux selector to select all queue items from the RTK Query cache. It's important that this returns stable
|
||||
* references if possible to reduce re-renders. All derivations of the queue items (e.g. filtering out canceled
|
||||
* items) should be done in a nanostores computed.
|
||||
* A redux selector to select all queue items from the RTK Query cache.
|
||||
*/
|
||||
const selectQueueItems = useMemo(
|
||||
() =>
|
||||
createSelector(
|
||||
[queueApi.endpoints.listAllQueueItems.select({ destination: session.id }), selectDiscardedItems],
|
||||
({ data }, discardedItems) => {
|
||||
if (!data) {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
return data.filter(
|
||||
({ status, item_id }) => status !== 'canceled' && status !== 'failed' && !discardedItems.includes(item_id)
|
||||
);
|
||||
}
|
||||
),
|
||||
[session.id]
|
||||
);
|
||||
const selectQueueItems = useMemo(() => buildSelectSessionQueueItems(session.id), [session.id]);
|
||||
|
||||
const discard = useCallback(
|
||||
(itemId: number) => {
|
||||
@@ -255,6 +231,10 @@ export const CanvasSessionContextProvider = memo(
|
||||
[store]
|
||||
);
|
||||
|
||||
const discardAll = useCallback(() => {
|
||||
store.dispatch(canvasSessionReset());
|
||||
}, [store]);
|
||||
|
||||
const selectNext = useCallback(() => {
|
||||
const selectedItemId = $selectedItemId.get();
|
||||
if (selectedItemId === null) {
|
||||
@@ -316,12 +296,15 @@ export const CanvasSessionContextProvider = memo(
|
||||
imageLoaded: true,
|
||||
});
|
||||
}
|
||||
if ($lastCompletedItemId.get() === itemId && $autoSwitch.get() === 'switch_on_finish') {
|
||||
if (
|
||||
$lastCompletedItemId.get() === itemId &&
|
||||
selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_finish'
|
||||
) {
|
||||
$selectedItemId.set(itemId);
|
||||
$lastCompletedItemId.set(null);
|
||||
}
|
||||
},
|
||||
[$autoSwitch, $lastCompletedItemId, $progressData, $selectedItemId]
|
||||
[$lastCompletedItemId, $progressData, $selectedItemId, store]
|
||||
);
|
||||
|
||||
// Set up socket listeners
|
||||
@@ -356,7 +339,7 @@ export const CanvasSessionContextProvider = memo(
|
||||
socket.off('invocation_progress', onProgress);
|
||||
socket.off('queue_item_status_changed', onQueueItemStatusChanged);
|
||||
};
|
||||
}, [$autoSwitch, $lastCompletedItemId, $lastStartedItemId, $progressData, $selectedItemId, session.id, socket]);
|
||||
}, [$lastCompletedItemId, $lastStartedItemId, $progressData, $selectedItemId, session.id, socket]);
|
||||
|
||||
// Set up state subscriptions and effects
|
||||
useEffect(() => {
|
||||
@@ -386,7 +369,7 @@ export const CanvasSessionContextProvider = memo(
|
||||
$selectedItemId.set(items[0]?.item_id ?? null);
|
||||
return;
|
||||
} else if (
|
||||
$autoSwitch.get() === 'switch_on_start' &&
|
||||
selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_start' &&
|
||||
items.findIndex(({ item_id }) => item_id === lastStartedItemId) !== -1
|
||||
) {
|
||||
$selectedItemId.set(lastStartedItemId);
|
||||
@@ -489,7 +472,7 @@ export const CanvasSessionContextProvider = memo(
|
||||
if (lastLoadedItemId === null) {
|
||||
return;
|
||||
}
|
||||
if ($autoSwitch.get() === 'switch_on_finish') {
|
||||
if (selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_finish') {
|
||||
$selectedItemId.set(lastLoadedItemId);
|
||||
}
|
||||
$lastLoadedItemId.set(null);
|
||||
@@ -501,6 +484,22 @@ export const CanvasSessionContextProvider = memo(
|
||||
queueApi.endpoints.listAllQueueItems.initiate({ destination: session.id })
|
||||
);
|
||||
|
||||
// const unsubListener = store.dispatch(
|
||||
// addAppListener({
|
||||
// matcher: queueApi.endpoints.cancelQueueItem.matchFulfilled,
|
||||
// effect: ({ payload }, { getState }) => {
|
||||
// const { item_id } = payload;
|
||||
|
||||
// const items = selectQueueItems(getState());
|
||||
// if (items.length === 0) {
|
||||
// $selectedItemId.set(null);
|
||||
// } else if ($selectedItemId.get() === null) {
|
||||
// $selectedItemId.set(items[0].item_id);
|
||||
// }
|
||||
// },
|
||||
// })
|
||||
// );
|
||||
|
||||
// Clean up all subscriptions and top-level (i.e. non-computed/derived state)
|
||||
return () => {
|
||||
unsubHandleAutoSwitch();
|
||||
@@ -514,7 +513,6 @@ export const CanvasSessionContextProvider = memo(
|
||||
};
|
||||
}, [
|
||||
$items,
|
||||
$autoSwitch,
|
||||
$lastLoadedItemId,
|
||||
$lastStartedItemId,
|
||||
$progressData,
|
||||
@@ -532,7 +530,6 @@ export const CanvasSessionContextProvider = memo(
|
||||
$isPending,
|
||||
$progressData,
|
||||
$selectedItemId,
|
||||
$autoSwitch,
|
||||
$selectedItem,
|
||||
$selectedItemIndex,
|
||||
$selectedItemOutputImageDTO,
|
||||
@@ -543,9 +540,9 @@ export const CanvasSessionContextProvider = memo(
|
||||
selectLast,
|
||||
onImageLoad,
|
||||
discard,
|
||||
discardAll,
|
||||
}),
|
||||
[
|
||||
$autoSwitch,
|
||||
$items,
|
||||
$hasItems,
|
||||
$isPending,
|
||||
@@ -562,6 +559,7 @@ export const CanvasSessionContextProvider = memo(
|
||||
selectLast,
|
||||
onImageLoad,
|
||||
discard,
|
||||
discardAll,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export const getProgressMessage = (data?: S['InvocationProgressEvent'] | null) =
|
||||
|
||||
export const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))';
|
||||
|
||||
export const getQueueItemElementId = (itemId: number) => `queue-item-status-card-${itemId}`;
|
||||
export const getQueueItemElementId = (index: number) => `queue-item-preview-${index}`;
|
||||
|
||||
export const getOutputImageName = (item: S['SessionQueueItem']) => {
|
||||
const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) =>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import {
|
||||
selectStagingAreaAutoSwitch,
|
||||
settingsStagingAreaAutoSwitchChanged,
|
||||
} from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { PiCaretLineRightBold, PiCaretRightBold, PiMoonBold } from 'react-icons/pi';
|
||||
|
||||
export const StagingAreaAutoSwitchButtons = memo(() => {
|
||||
const autoSwitch = useAppSelector(selectStagingAreaAutoSwitch);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onClickOff = useCallback(() => {
|
||||
dispatch(settingsStagingAreaAutoSwitchChanged('off'));
|
||||
}, [dispatch]);
|
||||
const onClickSwitchOnStart = useCallback(() => {
|
||||
dispatch(settingsStagingAreaAutoSwitchChanged('switch_on_start'));
|
||||
}, [dispatch]);
|
||||
const onClickSwitchOnFinished = useCallback(() => {
|
||||
dispatch(settingsStagingAreaAutoSwitchChanged('switch_on_finish'));
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
aria-label="Do not auto-switch"
|
||||
tooltip="Do not auto-switch"
|
||||
icon={<PiMoonBold />}
|
||||
colorScheme={autoSwitch === 'off' ? 'invokeBlue' : 'base'}
|
||||
onClick={onClickOff}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Switch on start"
|
||||
tooltip="Switch on start"
|
||||
icon={<PiCaretRightBold />}
|
||||
colorScheme={autoSwitch === 'switch_on_start' ? 'invokeBlue' : 'base'}
|
||||
onClick={onClickSwitchOnStart}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Switch on finish"
|
||||
tooltip="Switch on finish"
|
||||
icon={<PiCaretLineRightBold />}
|
||||
colorScheme={autoSwitch === 'switch_on_finish' ? 'invokeBlue' : 'base'}
|
||||
onClick={onClickSwitchOnFinished}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
StagingAreaAutoSwitchButtons.displayName = 'StagingAreaAutoSwitchButtons';
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ButtonGroup } from '@invoke-ai/ui-library';
|
||||
import { ButtonGroup, Flex } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared';
|
||||
import { StagingAreaToolbarAcceptButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton';
|
||||
import { StagingAreaToolbarDiscardAllButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton';
|
||||
import { StagingAreaToolbarDiscardSelectedButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton';
|
||||
@@ -12,27 +11,22 @@ import { StagingAreaToolbarPrevButton } from 'features/controlLayers/components/
|
||||
import { StagingAreaToolbarSaveSelectedToGalleryButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton';
|
||||
import { StagingAreaToolbarToggleShowResultsButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarToggleShowResultsButton';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { memo, useEffect } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { StagingAreaAutoSwitchButtons } from './StagingAreaAutoSwitchButtons';
|
||||
|
||||
export const StagingAreaToolbar = memo(() => {
|
||||
const canvasManager = useCanvasManager();
|
||||
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
|
||||
|
||||
const ctx = useCanvasSessionContext();
|
||||
|
||||
useEffect(() => {
|
||||
return ctx.$selectedItemId.listen((id) => {
|
||||
if (id !== null) {
|
||||
document.getElementById(getQueueItemElementId(id))?.scrollIntoView();
|
||||
}
|
||||
});
|
||||
}, [ctx.$selectedItemId]);
|
||||
|
||||
useHotkeys('meta+left', ctx.selectFirst, { preventDefault: true });
|
||||
useHotkeys('meta+right', ctx.selectLast, { preventDefault: true });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex gap={2}>
|
||||
<ButtonGroup borderRadius="base" shadow="dark-lg">
|
||||
<StagingAreaToolbarPrevButton isDisabled={!shouldShowStagedImage} />
|
||||
<StagingAreaToolbarImageCountButton />
|
||||
@@ -44,9 +38,14 @@ export const StagingAreaToolbar = memo(() => {
|
||||
<StagingAreaToolbarSaveSelectedToGalleryButton />
|
||||
<StagingAreaToolbarMenu />
|
||||
<StagingAreaToolbarDiscardSelectedButton isDisabled={!shouldShowStagedImage} />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup borderRadius="base" shadow="dark-lg">
|
||||
<StagingAreaAutoSwitchButtons />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup borderRadius="base" shadow="dark-lg">
|
||||
<StagingAreaToolbarDiscardAllButton isDisabled={!shouldShowStagedImage} />
|
||||
</ButtonGroup>
|
||||
</>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { canvasSessionReset, generateSessionReset } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { useCancelQueueItemsByDestination } from 'features/queue/hooks/useCancelQueueItemsByDestination';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -9,21 +7,13 @@ import { PiTrashSimpleBold } from 'react-icons/pi';
|
||||
|
||||
export const StagingAreaToolbarDiscardAllButton = memo(({ isDisabled }: { isDisabled?: boolean }) => {
|
||||
const ctx = useCanvasSessionContext();
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
const cancelQueueItemsByDestination = useCancelQueueItemsByDestination();
|
||||
|
||||
const discardAll = useCallback(() => {
|
||||
if (ctx.$isPending.get()) {
|
||||
cancelQueueItemsByDestination.trigger(ctx.session.id, { withToast: false });
|
||||
}
|
||||
if (ctx.session.type === 'advanced') {
|
||||
dispatch(canvasSessionReset());
|
||||
} else {
|
||||
// ctx.session.type === 'simple'
|
||||
dispatch(generateSessionReset());
|
||||
}
|
||||
}, [cancelQueueItemsByDestination, ctx.$isPending, ctx.session.id, ctx.session.type, dispatch]);
|
||||
ctx.discardAll();
|
||||
cancelQueueItemsByDestination.trigger(ctx.session.id, { withToast: false });
|
||||
}, [cancelQueueItemsByDestination, ctx]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
@@ -32,7 +22,6 @@ export const StagingAreaToolbarDiscardAllButton = memo(({ isDisabled }: { isDisa
|
||||
icon={<PiTrashSimpleBold />}
|
||||
onClick={discardAll}
|
||||
colorScheme="error"
|
||||
fontSize={16}
|
||||
isDisabled={isDisabled || cancelQueueItemsByDestination.isDisabled}
|
||||
isLoading={cancelQueueItemsByDestination.isLoading}
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { canvasSessionReset, generateSessionReset } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiXBold } from 'react-icons/pi';
|
||||
|
||||
export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { isDisabled?: boolean }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const ctx = useCanvasSessionContext();
|
||||
const cancelQueueItem = useCancelQueueItem();
|
||||
const selectedItemId = useStore(ctx.$selectedItemId);
|
||||
@@ -22,16 +19,7 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { i
|
||||
}
|
||||
ctx.discard(selectedItemId);
|
||||
await cancelQueueItem.trigger(selectedItemId, { withToast: false });
|
||||
const itemCount = ctx.$itemCount.get();
|
||||
if (itemCount <= 1) {
|
||||
if (ctx.session.type === 'advanced') {
|
||||
dispatch(canvasSessionReset());
|
||||
} else {
|
||||
// ctx.session.type === 'simple'
|
||||
dispatch(generateSessionReset());
|
||||
}
|
||||
}
|
||||
}, [selectedItemId, ctx, cancelQueueItem, dispatch]);
|
||||
}, [selectedItemId, ctx, cancelQueueItem]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
@@ -40,7 +28,6 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { i
|
||||
icon={<PiXBold />}
|
||||
onClick={discardSelected}
|
||||
colorScheme="invokeBlue"
|
||||
fontSize={16}
|
||||
isDisabled={selectedItemId === null || cancelQueueItem.isDisabled || isDisabled}
|
||||
isLoading={cancelQueueItem.isLoading}
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { IconButton, Menu, MenuButton, MenuDivider, MenuList } from '@invoke-ai/ui-library';
|
||||
import { StagingAreaToolbarMenuAutoSwitch } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch';
|
||||
import { IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
|
||||
import { StagingAreaToolbarNewLayerFromImageMenuItems } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage';
|
||||
import { memo } from 'react';
|
||||
import { PiDotsThreeBold } from 'react-icons/pi';
|
||||
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
|
||||
|
||||
export const StagingAreaToolbarMenu = memo(() => {
|
||||
return (
|
||||
<Menu>
|
||||
<MenuButton as={IconButton} icon={<PiDotsThreeBold />} colorScheme="invokeBlue" />
|
||||
<MenuButton as={IconButton} icon={<PiDotsThreeVerticalBold />} colorScheme="invokeBlue" />
|
||||
<MenuList>
|
||||
<StagingAreaToolbarMenuAutoSwitch />
|
||||
<MenuDivider />
|
||||
<StagingAreaToolbarNewLayerFromImageMenuItems />
|
||||
</MenuList>
|
||||
</Menu>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { MenuItemOption, MenuOptionGroup } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { isAutoSwitchMode, useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
export const StagingAreaToolbarMenuAutoSwitch = memo(() => {
|
||||
const ctx = useCanvasSessionContext();
|
||||
const autoSwitch = useStore(ctx.$autoSwitch);
|
||||
|
||||
const onChange = useCallback(
|
||||
(val: string | string[]) => {
|
||||
assert(isAutoSwitchMode(val));
|
||||
ctx.$autoSwitch.set(val);
|
||||
},
|
||||
[ctx.$autoSwitch]
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuOptionGroup value={autoSwitch} onChange={onChange} title="Auto-Switch" type="radio">
|
||||
<MenuItemOption value="off" closeOnSelect={false}>
|
||||
Off
|
||||
</MenuItemOption>
|
||||
<MenuItemOption value="switch_on_start" closeOnSelect={false}>
|
||||
Switch on Start
|
||||
</MenuItemOption>
|
||||
<MenuItemOption value="switch_on_finish" closeOnSelect={false}>
|
||||
Switch on Finish
|
||||
</MenuItemOption>
|
||||
</MenuOptionGroup>
|
||||
);
|
||||
});
|
||||
|
||||
StagingAreaToolbarMenuAutoSwitch.displayName = 'StagingAreaToolbarMenuAutoSwitch';
|
||||
@@ -1,38 +1,41 @@
|
||||
import type { PayloadAction, Selector } from '@reduxjs/toolkit';
|
||||
import { createSelector, createSlice } from '@reduxjs/toolkit';
|
||||
import type { PersistConfig, RootState } from 'app/store/store';
|
||||
import type { RgbaColor } from 'features/controlLayers/store/types';
|
||||
import { zRgbaColor } from 'features/controlLayers/store/types';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
type CanvasSettingsState = {
|
||||
const zAutoSwitchMode = z.enum(['off', 'switch_on_start', 'switch_on_finish']);
|
||||
|
||||
const zCanvasSettingsState = z.object({
|
||||
/**
|
||||
* Whether to show HUD (Heads-Up Display) on the canvas.
|
||||
*/
|
||||
showHUD: boolean;
|
||||
showHUD: z.boolean().default(true),
|
||||
/**
|
||||
* Whether to clip lines and shapes to the generation bounding box. If disabled, lines and shapes will be clipped to
|
||||
* the canvas bounds.
|
||||
*/
|
||||
clipToBbox: boolean;
|
||||
clipToBbox: z.boolean().default(false),
|
||||
/**
|
||||
* Whether to show a dynamic grid on the canvas. If disabled, a checkerboard pattern will be shown instead.
|
||||
*/
|
||||
dynamicGrid: boolean;
|
||||
dynamicGrid: z.boolean().default(false),
|
||||
/**
|
||||
* Whether to invert the scroll direction when adjusting the brush or eraser width with the scroll wheel.
|
||||
*/
|
||||
invertScrollForToolWidth: boolean;
|
||||
invertScrollForToolWidth: z.boolean().default(false),
|
||||
/**
|
||||
* The width of the brush tool.
|
||||
*/
|
||||
brushWidth: number;
|
||||
brushWidth: z.int().gt(0).default(50),
|
||||
/**
|
||||
* The width of the eraser tool.
|
||||
*/
|
||||
eraserWidth: number;
|
||||
eraserWidth: z.int().gt(0).default(50),
|
||||
/**
|
||||
* The color to use when drawing lines or filling shapes.
|
||||
*/
|
||||
color: RgbaColor;
|
||||
color: zRgbaColor.default({ r: 31, g: 160, b: 224, a: 1 }), // invokeBlue.500
|
||||
/**
|
||||
* Whether to composite inpainted/outpainted regions back onto the source image when saving canvas generations.
|
||||
*
|
||||
@@ -40,75 +43,61 @@ type CanvasSettingsState = {
|
||||
*
|
||||
* When `sendToCanvas` is disabled, this setting is ignored, masked regions will always be composited.
|
||||
*/
|
||||
outputOnlyMaskedRegions: boolean;
|
||||
outputOnlyMaskedRegions: z.boolean().default(true),
|
||||
/**
|
||||
* Whether to automatically process the operations like filtering and auto-masking.
|
||||
*/
|
||||
autoProcess: boolean;
|
||||
autoProcess: z.boolean().default(true),
|
||||
/**
|
||||
* The snap-to-grid setting for the canvas.
|
||||
*/
|
||||
snapToGrid: boolean;
|
||||
snapToGrid: z.boolean().default(true),
|
||||
/**
|
||||
* Whether to show progress on the canvas when generating images.
|
||||
*/
|
||||
showProgressOnCanvas: boolean;
|
||||
showProgressOnCanvas: z.boolean().default(true),
|
||||
/**
|
||||
* Whether to show the bounding box overlay on the canvas.
|
||||
*/
|
||||
bboxOverlay: boolean;
|
||||
bboxOverlay: z.boolean().default(false),
|
||||
/**
|
||||
* Whether to preserve the masked region instead of inpainting it.
|
||||
*/
|
||||
preserveMask: boolean;
|
||||
preserveMask: z.boolean().default(false),
|
||||
/**
|
||||
* Whether to show only raster layers while staging.
|
||||
*/
|
||||
isolatedStagingPreview: boolean;
|
||||
isolatedStagingPreview: z.boolean().default(true),
|
||||
/**
|
||||
* Whether to show only the selected layer while filtering, transforming, or doing other operations.
|
||||
*/
|
||||
isolatedLayerPreview: boolean;
|
||||
isolatedLayerPreview: z.boolean().default(true),
|
||||
/**
|
||||
* Whether to use pressure sensitivity for the brush and eraser tool when a pen device is used.
|
||||
*/
|
||||
pressureSensitivity: boolean;
|
||||
pressureSensitivity: z.boolean().default(true),
|
||||
/**
|
||||
* Whether to show the rule of thirds composition guide overlay on the canvas.
|
||||
*/
|
||||
ruleOfThirds: boolean;
|
||||
ruleOfThirds: z.boolean().default(false),
|
||||
/**
|
||||
* Whether to save all staging images to the gallery instead of keeping them as intermediate images.
|
||||
*/
|
||||
saveAllImagesToGallery: boolean;
|
||||
};
|
||||
saveAllImagesToGallery: z.boolean().default(false),
|
||||
/**
|
||||
* The auto-switch mode for the canvas staging area.
|
||||
*/
|
||||
stagingAreaAutoSwitch: zAutoSwitchMode.default('switch_on_start'),
|
||||
});
|
||||
|
||||
const initialState: CanvasSettingsState = {
|
||||
showHUD: true,
|
||||
clipToBbox: false,
|
||||
dynamicGrid: false,
|
||||
brushWidth: 50,
|
||||
eraserWidth: 50,
|
||||
invertScrollForToolWidth: false,
|
||||
color: { r: 31, g: 160, b: 224, a: 1 }, // invokeBlue.500
|
||||
outputOnlyMaskedRegions: true,
|
||||
autoProcess: true,
|
||||
snapToGrid: true,
|
||||
showProgressOnCanvas: true,
|
||||
bboxOverlay: false,
|
||||
preserveMask: false,
|
||||
isolatedStagingPreview: true,
|
||||
isolatedLayerPreview: true,
|
||||
pressureSensitivity: true,
|
||||
ruleOfThirds: false,
|
||||
saveAllImagesToGallery: false,
|
||||
};
|
||||
type CanvasSettingsState = z.infer<typeof zCanvasSettingsState>;
|
||||
const getInitialState = () => zCanvasSettingsState.parse({});
|
||||
|
||||
export const canvasSettingsSlice = createSlice({
|
||||
name: 'canvasSettings',
|
||||
initialState,
|
||||
initialState: getInitialState(),
|
||||
reducers: {
|
||||
settingsClipToBboxChanged: (state, action: PayloadAction<boolean>) => {
|
||||
settingsClipToBboxChanged: (state, action: PayloadAction<CanvasSettingsState['clipToBbox']>) => {
|
||||
state.clipToBbox = action.payload;
|
||||
},
|
||||
settingsDynamicGridToggled: (state) => {
|
||||
@@ -117,16 +106,19 @@ export const canvasSettingsSlice = createSlice({
|
||||
settingsShowHUDToggled: (state) => {
|
||||
state.showHUD = !state.showHUD;
|
||||
},
|
||||
settingsBrushWidthChanged: (state, action: PayloadAction<number>) => {
|
||||
settingsBrushWidthChanged: (state, action: PayloadAction<CanvasSettingsState['brushWidth']>) => {
|
||||
state.brushWidth = Math.round(action.payload);
|
||||
},
|
||||
settingsEraserWidthChanged: (state, action: PayloadAction<number>) => {
|
||||
settingsEraserWidthChanged: (state, action: PayloadAction<CanvasSettingsState['eraserWidth']>) => {
|
||||
state.eraserWidth = Math.round(action.payload);
|
||||
},
|
||||
settingsColorChanged: (state, action: PayloadAction<RgbaColor>) => {
|
||||
settingsColorChanged: (state, action: PayloadAction<CanvasSettingsState['color']>) => {
|
||||
state.color = action.payload;
|
||||
},
|
||||
settingsInvertScrollForToolWidthChanged: (state, action: PayloadAction<boolean>) => {
|
||||
settingsInvertScrollForToolWidthChanged: (
|
||||
state,
|
||||
action: PayloadAction<CanvasSettingsState['invertScrollForToolWidth']>
|
||||
) => {
|
||||
state.invertScrollForToolWidth = action.payload;
|
||||
},
|
||||
settingsOutputOnlyMaskedRegionsToggled: (state) => {
|
||||
@@ -162,6 +154,12 @@ export const canvasSettingsSlice = createSlice({
|
||||
settingsSaveAllImagesToGalleryToggled: (state) => {
|
||||
state.saveAllImagesToGallery = !state.saveAllImagesToGallery;
|
||||
},
|
||||
settingsStagingAreaAutoSwitchChanged: (
|
||||
state,
|
||||
action: PayloadAction<CanvasSettingsState['stagingAreaAutoSwitch']>
|
||||
) => {
|
||||
state.stagingAreaAutoSwitch = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -184,6 +182,7 @@ export const {
|
||||
settingsPressureSensitivityToggled,
|
||||
settingsRuleOfThirdsToggled,
|
||||
settingsSaveAllImagesToGalleryToggled,
|
||||
settingsStagingAreaAutoSwitchChanged,
|
||||
} = canvasSettingsSlice.actions;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
@@ -193,7 +192,7 @@ const migrate = (state: any): any => {
|
||||
|
||||
export const canvasSettingsPersistConfig: PersistConfig<CanvasSettingsState> = {
|
||||
name: canvasSettingsSlice.name,
|
||||
initialState,
|
||||
initialState: getInitialState(),
|
||||
migrate,
|
||||
persistDenylist: [],
|
||||
};
|
||||
@@ -219,3 +218,4 @@ export const selectIsolatedLayerPreview = createCanvasSettingsSelector((settings
|
||||
export const selectPressureSensitivity = createCanvasSettingsSelector((settings) => settings.pressureSensitivity);
|
||||
export const selectRuleOfThirds = createCanvasSettingsSelector((settings) => settings.ruleOfThirds);
|
||||
export const selectSaveAllImagesToGallery = createCanvasSettingsSelector((settings) => settings.saveAllImagesToGallery);
|
||||
export const selectStagingAreaAutoSwitch = createCanvasSettingsSelector((settings) => settings.stagingAreaAutoSwitch);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import { EMPTY_ARRAY } from 'app/store/constants';
|
||||
import type { PersistConfig, RootState } from 'app/store/store';
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import { canvasReset } from 'features/controlLayers/store/actions';
|
||||
import { queueApi } from 'services/api/endpoints/queue';
|
||||
|
||||
type CanvasStagingAreaState = {
|
||||
generateSessionId: string | null;
|
||||
@@ -78,8 +80,34 @@ export const selectGenerateSessionId = createSelector(
|
||||
selectCanvasSessionSlice,
|
||||
({ generateSessionId }) => generateSessionId
|
||||
);
|
||||
export const selectIsStaging = createSelector(selectCanvasSessionId, (canvasSessionId) => canvasSessionId !== null);
|
||||
export const selectDiscardedItems = createSelector(
|
||||
export const buildSelectSessionQueueItems = (sessionId: string) =>
|
||||
createSelector(
|
||||
[queueApi.endpoints.listAllQueueItems.select({ destination: sessionId }), selectDiscardedItems],
|
||||
({ data }, discardedItems) => {
|
||||
if (!data) {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
return data.filter(
|
||||
({ status, item_id }) => status !== 'canceled' && status !== 'failed' && !discardedItems.includes(item_id)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const selectIsStaging = (state: RootState) => {
|
||||
const sessionId = selectCanvasSessionId(state);
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
const { data } = queueApi.endpoints.listAllQueueItems.select({ destination: sessionId })(state);
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
const discardedItems = selectDiscardedItems(state);
|
||||
return data.some(
|
||||
({ status, item_id }) => status !== 'canceled' && status !== 'failed' && !discardedItems.includes(item_id)
|
||||
);
|
||||
};
|
||||
const selectDiscardedItems = createSelector(
|
||||
selectCanvasSessionSlice,
|
||||
({ canvasDiscardedQueueItems }) => canvasDiscardedQueueItems
|
||||
);
|
||||
|
||||
@@ -98,7 +98,7 @@ const zRgbColor = z.object({
|
||||
b: z.number().int().min(0).max(255),
|
||||
});
|
||||
export type RgbColor = z.infer<typeof zRgbColor>;
|
||||
const zRgbaColor = zRgbColor.extend({
|
||||
export const zRgbaColor = zRgbColor.extend({
|
||||
a: z.number().min(0).max(1),
|
||||
});
|
||||
export type RgbaColor = z.infer<typeof zRgbaColor>;
|
||||
|
||||
@@ -15,7 +15,6 @@ const sx = {
|
||||
objectFit: 'contain',
|
||||
maxW: 'full',
|
||||
maxH: 'full',
|
||||
borderRadius: 'base',
|
||||
cursor: 'grab',
|
||||
'&[data-is-dragging=true]': {
|
||||
opacity: 0.3,
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
|
||||
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
|
||||
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
|
||||
import { useImageActions } from 'features/gallery/hooks/useImageActions';
|
||||
import { useRecallAll } from 'features/gallery/hooks/useRecallAll';
|
||||
import { useRecallDimensions } from 'features/gallery/hooks/useRecallDimensions';
|
||||
import { useRecallPrompts } from 'features/gallery/hooks/useRecallPrompts';
|
||||
import { useRecallRemix } from 'features/gallery/hooks/useRecallRemix';
|
||||
import { useRecallSeed } from 'features/gallery/hooks/useRecallSeed';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
PiArrowBendUpLeftBold,
|
||||
PiArrowsCounterClockwiseBold,
|
||||
PiAsteriskBold,
|
||||
PiPaintBrushBold,
|
||||
PiPlantBold,
|
||||
PiQuotesBold,
|
||||
PiRulerBold,
|
||||
} from 'react-icons/pi';
|
||||
|
||||
export const ImageMenuItemMetadataRecallActions = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const imageDTO = useImageDTOContext();
|
||||
const subMenu = useSubMenu();
|
||||
|
||||
const { recallAll, remix, recallSeed, recallPrompts, hasMetadata, hasSeed, hasPrompts, createAsPreset } =
|
||||
useImageActions(imageDTO);
|
||||
const imageDTO = useImageDTOContext();
|
||||
|
||||
const recallAll = useRecallAll(imageDTO);
|
||||
const recallRemix = useRecallRemix(imageDTO);
|
||||
const recallPrompts = useRecallPrompts(imageDTO);
|
||||
const recallSeed = useRecallSeed(imageDTO);
|
||||
const recallDimensions = useRecallDimensions(imageDTO);
|
||||
|
||||
return (
|
||||
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiArrowBendUpLeftBold />}>
|
||||
@@ -28,20 +36,24 @@ export const ImageMenuItemMetadataRecallActions = memo(() => {
|
||||
<SubMenuButtonContent label={t('parameters.recallMetadata')} />
|
||||
</MenuButton>
|
||||
<MenuList {...subMenu.menuListProps}>
|
||||
<MenuItem icon={<PiArrowsCounterClockwiseBold />} onClick={remix} isDisabled={!hasMetadata}>
|
||||
<MenuItem
|
||||
icon={<PiArrowsCounterClockwiseBold />}
|
||||
onClick={recallRemix.recall}
|
||||
isDisabled={!recallRemix.isEnabled}
|
||||
>
|
||||
{t('parameters.remixImage')}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<PiQuotesBold />} onClick={recallPrompts} isDisabled={!hasPrompts}>
|
||||
<MenuItem icon={<PiQuotesBold />} onClick={recallPrompts.recall} isDisabled={!recallPrompts.isEnabled}>
|
||||
{t('parameters.usePrompt')}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<PiPlantBold />} onClick={recallSeed} isDisabled={!hasSeed}>
|
||||
<MenuItem icon={<PiPlantBold />} onClick={recallSeed.recall} isDisabled={!recallSeed.isEnabled}>
|
||||
{t('parameters.useSeed')}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<PiAsteriskBold />} onClick={recallAll} isDisabled={!hasMetadata}>
|
||||
<MenuItem icon={<PiAsteriskBold />} onClick={recallAll.recall} isDisabled={!recallAll.isEnabled}>
|
||||
{t('parameters.useAll')}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<PiPaintBrushBold />} onClick={createAsPreset} isDisabled={!hasPrompts}>
|
||||
{t('stylePresets.useForTemplate')}
|
||||
<MenuItem icon={<PiRulerBold />} onClick={recallDimensions.recall} isDisabled={!recallDimensions.isEnabled}>
|
||||
{t('parameters.useSize')}
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MenuItem } from '@invoke-ai/ui-library';
|
||||
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
|
||||
import { useCreateStylePresetFromMetadata } from 'features/gallery/hooks/useCreateStylePresetFromMetadata';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiPaintBrushBold } from 'react-icons/pi';
|
||||
|
||||
export const ImageMenuItemUseAsPromptTemplate = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const imageDTO = useImageDTOContext();
|
||||
const stylePreset = useCreateStylePresetFromMetadata(imageDTO);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<PiPaintBrushBold />} onClickCapture={stylePreset.create} isDisabled={!stylePreset.isEnabled}>
|
||||
{t('stylePresets.useForTemplate')}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
ImageMenuItemUseAsPromptTemplate.displayName = 'ImageMenuItemUseAsPromptTemplate';
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MenuDivider } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { IconMenuItemGroup } from 'common/components/IconMenuItem';
|
||||
import { ImageMenuItemChangeBoard } from 'features/gallery/components/ImageContextMenu/ImageMenuItemChangeBoard';
|
||||
import { ImageMenuItemCopy } from 'features/gallery/components/ImageContextMenu/ImageMenuItemCopy';
|
||||
@@ -16,14 +17,19 @@ import { ImageMenuItemStarUnstar } from 'features/gallery/components/ImageContex
|
||||
import { ImageMenuItemUseAsRefImage } from 'features/gallery/components/ImageContextMenu/ImageMenuItemUseAsRefImage';
|
||||
import { ImageMenuItemUseForPromptGeneration } from 'features/gallery/components/ImageContextMenu/ImageMenuItemUseForPromptGeneration';
|
||||
import { ImageDTOContextProvider } from 'features/gallery/contexts/ImageDTOContext';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { memo } from 'react';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
import { ImageMenuItemUseAsPromptTemplate } from './ImageMenuItemUseAsPromptTemplate';
|
||||
|
||||
type SingleSelectionMenuItemsProps = {
|
||||
imageDTO: ImageDTO;
|
||||
};
|
||||
|
||||
const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) => {
|
||||
const tab = useAppSelector(selectActiveTab);
|
||||
|
||||
return (
|
||||
<ImageDTOContextProvider value={imageDTO}>
|
||||
<IconMenuItemGroup>
|
||||
@@ -36,13 +42,14 @@ const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) =
|
||||
</IconMenuItemGroup>
|
||||
<MenuDivider />
|
||||
<ImageMenuItemLoadWorkflow />
|
||||
<ImageMenuItemMetadataRecallActions />
|
||||
{(tab === 'canvas' || tab === 'generate') && <ImageMenuItemMetadataRecallActions />}
|
||||
<MenuDivider />
|
||||
<ImageMenuItemSendToUpscale />
|
||||
<ImageMenuItemUseForPromptGeneration />
|
||||
<ImageMenuItemUseAsRefImage />
|
||||
{(tab === 'canvas' || tab === 'generate') && <ImageMenuItemUseAsRefImage />}
|
||||
<ImageMenuItemUseAsPromptTemplate />
|
||||
<ImageMenuItemNewCanvasFromImageSubMenu />
|
||||
<ImageMenuItemNewLayerFromImageSubMenu />
|
||||
{tab === 'canvas' && <ImageMenuItemNewLayerFromImageSubMenu />}
|
||||
<MenuDivider />
|
||||
<ImageMenuItemChangeBoard />
|
||||
<ImageMenuItemStarUnstar />
|
||||
|
||||
@@ -85,7 +85,7 @@ const UnrecallableMetadataParsed = typedMemo(
|
||||
|
||||
return (
|
||||
<Box as="span" lineHeight={1}>
|
||||
<LabelComponent />
|
||||
<LabelComponent i18nKey={handler.i18nKey} />
|
||||
<ValueComponent value={data.value} />
|
||||
</Box>
|
||||
);
|
||||
@@ -128,7 +128,7 @@ const SingleMetadataParsed = typedMemo(
|
||||
onClick={onClick}
|
||||
/>
|
||||
<Box as="span" lineHeight={1}>
|
||||
<LabelComponent />
|
||||
<LabelComponent i18nKey={handler.i18nKey} />
|
||||
<ValueComponent value={data.value} />
|
||||
</Box>
|
||||
</Flex>
|
||||
@@ -178,7 +178,7 @@ const CollectionMetadataParsed = typedMemo(
|
||||
onClick={onClick}
|
||||
/>
|
||||
<Box as="span" lineHeight={1}>
|
||||
<LabelComponent />
|
||||
<LabelComponent i18nKey={handler.i18nKey} />
|
||||
<ValueComponent value={value} />
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { Button, Divider, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
|
||||
import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems';
|
||||
import { useImageActions } from 'features/gallery/hooks/useImageActions';
|
||||
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
||||
import { newCanvasFromImage } from 'features/imageActions/actions';
|
||||
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
|
||||
import { useDeleteImage } from 'features/gallery/hooks/useDeleteImage';
|
||||
import { useEditImage } from 'features/gallery/hooks/useEditImage';
|
||||
import { useLoadWorkflow } from 'features/gallery/hooks/useLoadWorkflow';
|
||||
import { useRecallAll } from 'features/gallery/hooks/useRecallAll';
|
||||
import { useRecallDimensions } from 'features/gallery/hooks/useRecallDimensions';
|
||||
import { useRecallPrompts } from 'features/gallery/hooks/useRecallPrompts';
|
||||
import { useRecallRemix } from 'features/gallery/hooks/useRecallRemix';
|
||||
import { useRecallSeed } from 'features/gallery/hooks/useRecallSeed';
|
||||
import { PostProcessingPopover } from 'features/parameters/components/PostProcessing/PostProcessingPopover';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { navigationApi } from 'features/ui/layouts/navigation-api';
|
||||
import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
|
||||
import { selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
PiArrowsCounterClockwiseBold,
|
||||
@@ -27,51 +25,23 @@ import {
|
||||
PiQuotesBold,
|
||||
PiRulerBold,
|
||||
} from 'react-icons/pi';
|
||||
import { useImageDTO } from 'services/api/endpoints/images';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
import { useImageViewerContext } from './context';
|
||||
|
||||
export const CurrentImageButtons = memo(() => {
|
||||
export const CurrentImageButtons = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
|
||||
const { t } = useTranslation();
|
||||
const ctx = useImageViewerContext();
|
||||
const hasProgressImage = useStore(ctx.$hasProgressImage);
|
||||
const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
|
||||
const isDisabledOverride = hasProgressImage && shouldShowProgressInViewer;
|
||||
const tab = useAppSelector(selectActiveTab);
|
||||
const isCanvasOrGenerateTab = tab === 'canvas' || tab === 'generate';
|
||||
|
||||
const imageName = useAppSelector(selectLastSelectedImage);
|
||||
const imageDTO = useImageDTO(imageName);
|
||||
const hasTemplates = useStore($hasTemplates);
|
||||
const imageActions = useImageActions(imageDTO);
|
||||
const isStaging = useAppSelector(selectIsStaging);
|
||||
const isUpscalingEnabled = useFeatureStatus('upscaling');
|
||||
const { getState, dispatch } = useAppStore();
|
||||
const canvasManager = useCanvasManagerSafe();
|
||||
|
||||
const handleEdit = useCallback(async () => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
|
||||
await newCanvasFromImage({
|
||||
imageDTO,
|
||||
type: 'raster_layer',
|
||||
withInpaintMask: true,
|
||||
getState,
|
||||
dispatch,
|
||||
});
|
||||
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
|
||||
|
||||
// Automatically select the brush tool when editing an image
|
||||
if (canvasManager) {
|
||||
canvasManager.tool.$tool.set('brush');
|
||||
}
|
||||
|
||||
toast({
|
||||
id: 'SENT_TO_CANVAS',
|
||||
title: t('toast.sentToCanvas'),
|
||||
status: 'success',
|
||||
});
|
||||
}, [imageDTO, getState, dispatch, t, canvasManager]);
|
||||
const recallAll = useRecallAll(imageDTO);
|
||||
const recallRemix = useRecallRemix(imageDTO);
|
||||
const recallPrompts = useRecallPrompts(imageDTO);
|
||||
const recallSeed = useRecallSeed(imageDTO);
|
||||
const recallDimensions = useRecallDimensions(imageDTO);
|
||||
const loadWorkflow = useLoadWorkflow(imageDTO);
|
||||
const editImage = useEditImage(imageDTO);
|
||||
const deleteImage = useDeleteImage(imageDTO);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -80,7 +50,7 @@ export const CurrentImageButtons = memo(() => {
|
||||
as={IconButton}
|
||||
aria-label={t('parameters.imageActions')}
|
||||
tooltip={t('parameters.imageActions')}
|
||||
isDisabled={isDisabledOverride || !imageDTO}
|
||||
isDisabled={!imageDTO}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
icon={<PiDotsThreeOutlineFill />}
|
||||
@@ -92,8 +62,8 @@ export const CurrentImageButtons = memo(() => {
|
||||
|
||||
<Button
|
||||
leftIcon={<PiPencilBold />}
|
||||
onClick={handleEdit}
|
||||
isDisabled={isDisabledOverride || !imageDTO}
|
||||
onClick={editImage.edit}
|
||||
isDisabled={!editImage.isEnabled}
|
||||
variant="link"
|
||||
size="sm"
|
||||
alignSelf="stretch"
|
||||
@@ -108,62 +78,72 @@ export const CurrentImageButtons = memo(() => {
|
||||
icon={<PiFlowArrowBold />}
|
||||
tooltip={`${t('nodes.loadWorkflow')} (W)`}
|
||||
aria-label={`${t('nodes.loadWorkflow')} (W)`}
|
||||
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasWorkflow || !hasTemplates}
|
||||
isDisabled={!loadWorkflow.isEnabled}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={imageActions.loadWorkflow}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<PiArrowsCounterClockwiseBold />}
|
||||
tooltip={`${t('parameters.remixImage')} (R)`}
|
||||
aria-label={`${t('parameters.remixImage')} (R)`}
|
||||
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasMetadata}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={imageActions.remix}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<PiQuotesBold />}
|
||||
tooltip={`${t('parameters.usePrompt')} (P)`}
|
||||
aria-label={`${t('parameters.usePrompt')} (P)`}
|
||||
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasPrompts}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={imageActions.recallPrompts}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<PiPlantBold />}
|
||||
tooltip={`${t('parameters.useSeed')} (S)`}
|
||||
aria-label={`${t('parameters.useSeed')} (S)`}
|
||||
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasSeed}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={imageActions.recallSeed}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<PiRulerBold />}
|
||||
tooltip={`${t('parameters.useSize')} (D)`}
|
||||
aria-label={`${t('parameters.useSize')} (D)`}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={imageActions.recallSize}
|
||||
isDisabled={isDisabledOverride || !imageDTO || isStaging}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<PiAsteriskBold />}
|
||||
tooltip={`${t('parameters.useAll')} (A)`}
|
||||
aria-label={`${t('parameters.useAll')} (A)`}
|
||||
isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasMetadata}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={imageActions.recallAll}
|
||||
onClick={loadWorkflow.load}
|
||||
/>
|
||||
{isCanvasOrGenerateTab && (
|
||||
<IconButton
|
||||
icon={<PiArrowsCounterClockwiseBold />}
|
||||
tooltip={`${t('parameters.remixImage')} (R)`}
|
||||
aria-label={`${t('parameters.remixImage')} (R)`}
|
||||
isDisabled={!recallRemix.isEnabled}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={recallRemix.recall}
|
||||
/>
|
||||
)}
|
||||
{isCanvasOrGenerateTab && (
|
||||
<IconButton
|
||||
icon={<PiQuotesBold />}
|
||||
tooltip={`${t('parameters.usePrompt')} (P)`}
|
||||
aria-label={`${t('parameters.usePrompt')} (P)`}
|
||||
isDisabled={!recallPrompts.isEnabled}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={recallPrompts.recall}
|
||||
/>
|
||||
)}
|
||||
{isCanvasOrGenerateTab && (
|
||||
<IconButton
|
||||
icon={<PiPlantBold />}
|
||||
tooltip={`${t('parameters.useSeed')} (S)`}
|
||||
aria-label={`${t('parameters.useSeed')} (S)`}
|
||||
isDisabled={!recallSeed.isEnabled}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={recallSeed.recall}
|
||||
/>
|
||||
)}
|
||||
{isCanvasOrGenerateTab && (
|
||||
<IconButton
|
||||
icon={<PiRulerBold />}
|
||||
tooltip={`${t('parameters.useSize')} (D)`}
|
||||
aria-label={`${t('parameters.useSize')} (D)`}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={recallDimensions.recall}
|
||||
isDisabled={!recallDimensions.isEnabled}
|
||||
/>
|
||||
)}
|
||||
{isCanvasOrGenerateTab && (
|
||||
<IconButton
|
||||
icon={<PiAsteriskBold />}
|
||||
tooltip={`${t('parameters.useAll')} (A)`}
|
||||
aria-label={`${t('parameters.useAll')} (A)`}
|
||||
isDisabled={!recallAll.isEnabled}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
onClick={recallAll.recall}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isUpscalingEnabled && <PostProcessingPopover imageDTO={imageDTO} isDisabled={isDisabledOverride} />}
|
||||
{isUpscalingEnabled && <PostProcessingPopover imageDTO={imageDTO} isDisabled={false} />}
|
||||
|
||||
<Divider orientation="vertical" h={8} mx={2} />
|
||||
|
||||
<DeleteImageButton onClick={imageActions.delete} isDisabled={isDisabledOverride || !imageDTO} />
|
||||
<DeleteImageButton onClick={deleteImage.delete} isDisabled={!deleteImage.isEnabled} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu
|
||||
>
|
||||
{imageDTO && (
|
||||
<Flex w="full" h="full" position="absolute" alignItems="center" justifyContent="center">
|
||||
<DndImage imageDTO={imageDTO} onLoad={onLoadImage} />
|
||||
<DndImage imageDTO={imageDTO} onLoad={onLoadImage} borderRadius="base" />
|
||||
</Flex>
|
||||
)}
|
||||
{!imageDTO && <NoContentForViewer />}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { Flex, Spacer } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton';
|
||||
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
||||
import { memo } from 'react';
|
||||
import { useImageDTO } from 'services/api/endpoints/images';
|
||||
|
||||
import { CurrentImageButtons } from './CurrentImageButtons';
|
||||
import { ToggleProgressButton } from './ToggleProgressButton';
|
||||
|
||||
export const ViewerToolbar = memo(() => {
|
||||
const imageName = useAppSelector(selectLastSelectedImage);
|
||||
const imageDTO = useImageDTO(imageName);
|
||||
|
||||
return (
|
||||
<Flex w="full" justifyContent="center" h={8}>
|
||||
<ToggleProgressButton />
|
||||
<Spacer />
|
||||
<CurrentImageButtons />
|
||||
{imageDTO && <CurrentImageButtons imageDTO={imageDTO} />}
|
||||
<Spacer />
|
||||
<ToggleMetadataViewerButton />
|
||||
{imageDTO && <ToggleMetadataViewerButton />}
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Box, Flex, forwardRef, Grid, GridItem, Spinner, Text } from '@invoke-ai
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { getFocusedRegion } from 'common/hooks/focus';
|
||||
import { useRangeBasedImageFetching } from 'features/gallery/hooks/useRangeBasedImageFetching';
|
||||
import type { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import {
|
||||
@@ -221,6 +222,10 @@ const useKeyboardNavigation = (
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (getFocusedRegion() !== 'gallery') {
|
||||
// Only handle keyboard navigation when the gallery is focused
|
||||
return;
|
||||
}
|
||||
// Only handle arrow keys
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
|
||||
return;
|
||||
@@ -477,11 +482,6 @@ export const NewGallery = memo(() => {
|
||||
|
||||
const context = useMemo<GridContext>(() => ({ imageNames, queryArgs }), [imageNames, queryArgs]);
|
||||
|
||||
// Item content function
|
||||
const itemContent: GridItemContent<string, GridContext> = useCallback((index, imageName) => {
|
||||
return <ImageAtPosition index={index} imageName={imageName} />;
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Flex w="full" h="full" alignItems="center" justifyContent="center" gap={4}>
|
||||
@@ -506,7 +506,7 @@ export const NewGallery = memo(() => {
|
||||
ref={virtuosoRef}
|
||||
context={context}
|
||||
data={imageNames}
|
||||
increaseViewportBy={2048}
|
||||
increaseViewportBy={4096}
|
||||
itemContent={itemContent}
|
||||
computeItemKey={computeItemKey}
|
||||
components={components}
|
||||
@@ -523,8 +523,12 @@ export const NewGallery = memo(() => {
|
||||
NewGallery.displayName = 'NewGallery';
|
||||
|
||||
const scrollSeekConfiguration: ScrollSeekConfiguration = {
|
||||
enter: (velocity) => velocity > 4096,
|
||||
exit: (velocity) => velocity === 0,
|
||||
enter: (velocity) => {
|
||||
return Math.abs(velocity) > 2048;
|
||||
},
|
||||
exit: (velocity) => {
|
||||
return velocity === 0;
|
||||
},
|
||||
};
|
||||
|
||||
// Styles
|
||||
@@ -544,6 +548,10 @@ const ListComponent: GridComponents<GridContext>['List'] = forwardRef(({ context
|
||||
});
|
||||
ListComponent.displayName = 'ListComponent';
|
||||
|
||||
const itemContent: GridItemContent<string, GridContext> = (index, imageName) => {
|
||||
return <ImageAtPosition index={index} imageName={imageName} />;
|
||||
};
|
||||
|
||||
const ItemComponent: GridComponents<GridContext>['Item'] = forwardRef(({ context: _, ...rest }, ref) => (
|
||||
<GridItem ref={ref} aspectRatio="1/1" {...rest} />
|
||||
));
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import {
|
||||
activeStylePresetIdChanged,
|
||||
selectStylePresetActivePresetId,
|
||||
} from 'features/stylePresets/store/stylePresetSlice';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const useClearStylePresetWithToast = () => {
|
||||
const store = useAppStore();
|
||||
const { t } = useTranslation();
|
||||
const activeStylePresetId = useAppSelector(selectStylePresetActivePresetId);
|
||||
|
||||
const clearStylePreset = useCallback(() => {
|
||||
if (activeStylePresetId) {
|
||||
store.dispatch(activeStylePresetIdChanged(null));
|
||||
toast({
|
||||
status: 'info',
|
||||
title: t('stylePresets.promptTemplateCleared'),
|
||||
});
|
||||
}
|
||||
}, [activeStylePresetId, store, t]);
|
||||
|
||||
return clearStylePreset;
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { MetadataHandlers, MetadataUtils } from 'features/metadata/parsing';
|
||||
import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
export const useCreateStylePresetFromMetadata = (imageDTO?: ImageDTO | null) => {
|
||||
const store = useAppStore();
|
||||
const [hasPrompts, setHasPrompts] = useState(false);
|
||||
|
||||
const { metadata } = useDebouncedMetadata(imageDTO?.image_name);
|
||||
|
||||
useEffect(() => {
|
||||
MetadataUtils.hasMetadataByHandlers({
|
||||
handlers: [MetadataHandlers.PositivePrompt, MetadataHandlers.NegativePrompt],
|
||||
metadata,
|
||||
store,
|
||||
require: 'some',
|
||||
})
|
||||
.then((result) => {
|
||||
setHasPrompts(result);
|
||||
})
|
||||
.catch(() => {
|
||||
setHasPrompts(false);
|
||||
});
|
||||
}, [metadata, store]);
|
||||
|
||||
const isEnabled = useMemo(() => {
|
||||
if (!imageDTO) {
|
||||
return false;
|
||||
}
|
||||
if (!hasPrompts) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [hasPrompts, imageDTO]);
|
||||
|
||||
const create = useCallback(async () => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let positivePrompt: string;
|
||||
let negativePrompt: string;
|
||||
|
||||
try {
|
||||
positivePrompt = await MetadataHandlers.PositivePrompt.parse(metadata, store);
|
||||
} catch (error) {
|
||||
positivePrompt = '';
|
||||
}
|
||||
try {
|
||||
negativePrompt = (await MetadataHandlers.NegativePrompt.parse(metadata, store)) ?? '';
|
||||
} catch (error) {
|
||||
negativePrompt = '';
|
||||
}
|
||||
|
||||
$stylePresetModalState.set({
|
||||
prefilledFormData: {
|
||||
name: '',
|
||||
positivePrompt,
|
||||
negativePrompt,
|
||||
imageUrl: imageDTO.image_url,
|
||||
type: 'user',
|
||||
},
|
||||
updatingStylePresetId: null,
|
||||
isModalOpen: true,
|
||||
});
|
||||
}, [imageDTO, isEnabled, metadata, store]);
|
||||
|
||||
return {
|
||||
create,
|
||||
isEnabled,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
export const useDeleteImage = (imageDTO?: ImageDTO | null) => {
|
||||
const deleteImageModal = useDeleteImageModalApi();
|
||||
|
||||
const isEnabled = useMemo(() => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
return true;
|
||||
}, [imageDTO]);
|
||||
const _delete = useCallback(() => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
deleteImageModal.delete([imageDTO.image_name]);
|
||||
}, [deleteImageModal, imageDTO, isEnabled]);
|
||||
|
||||
return {
|
||||
delete: _delete,
|
||||
isEnabled,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { newCanvasFromImage } from 'features/imageActions/actions';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { navigationApi } from 'features/ui/layouts/navigation-api';
|
||||
import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
export const useEditImage = (imageDTO?: ImageDTO | null) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { getState, dispatch } = useAppStore();
|
||||
const canvasManager = useCanvasManagerSafe();
|
||||
|
||||
const isEnabled = useMemo(() => {
|
||||
if (!imageDTO) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [imageDTO]);
|
||||
|
||||
const edit = useCallback(async () => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await newCanvasFromImage({
|
||||
imageDTO,
|
||||
type: 'raster_layer',
|
||||
withInpaintMask: true,
|
||||
getState,
|
||||
dispatch,
|
||||
});
|
||||
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
|
||||
|
||||
if (canvasManager) {
|
||||
canvasManager.tool.$tool.set('brush');
|
||||
}
|
||||
|
||||
toast({
|
||||
id: 'SENT_TO_CANVAS',
|
||||
title: t('toast.sentToCanvas'),
|
||||
status: 'success',
|
||||
});
|
||||
}, [imageDTO, isEnabled, getState, dispatch, canvasManager, t]);
|
||||
|
||||
return {
|
||||
edit,
|
||||
isEnabled,
|
||||
};
|
||||
};
|
||||
@@ -1,209 +0,0 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { adHocPostProcessingRequested } from 'app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener';
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
|
||||
import { MetadataHandlers, MetadataUtils } from 'features/metadata/parsing';
|
||||
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
|
||||
import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal';
|
||||
import {
|
||||
activeStylePresetIdChanged,
|
||||
selectStylePresetActivePresetId,
|
||||
} from 'features/stylePresets/store/stylePresetSlice';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
export const useImageActions = (imageDTO: ImageDTO | null) => {
|
||||
const store = useAppStore();
|
||||
const { t } = useTranslation();
|
||||
const activeStylePresetId = useAppSelector(selectStylePresetActivePresetId);
|
||||
const isStaging = useAppSelector(selectIsStaging);
|
||||
const { metadata } = useDebouncedMetadata(imageDTO?.image_name ?? null);
|
||||
const [hasMetadata, setHasMetadata] = useState(false);
|
||||
const [hasSeed, setHasSeed] = useState(false);
|
||||
const [hasPrompts, setHasPrompts] = useState(false);
|
||||
const hasTemplates = useStore($hasTemplates);
|
||||
const deleteImageModal = useDeleteImageModalApi();
|
||||
|
||||
useEffect(() => {
|
||||
const parseMetadata = async () => {
|
||||
if (metadata) {
|
||||
setHasMetadata(true);
|
||||
try {
|
||||
await MetadataHandlers.Seed.parse(metadata, store);
|
||||
setHasSeed(true);
|
||||
} catch {
|
||||
setHasSeed(false);
|
||||
}
|
||||
|
||||
let hasPrompt = false;
|
||||
// Need to catch all of these to avoid unhandled promise rejections bubbling up to instrumented error handlers
|
||||
for (const handler of [
|
||||
MetadataHandlers.PositivePrompt,
|
||||
MetadataHandlers.NegativePrompt,
|
||||
MetadataHandlers.PositiveStylePrompt,
|
||||
MetadataHandlers.NegativeStylePrompt,
|
||||
]) {
|
||||
try {
|
||||
await handler.parse(metadata, store);
|
||||
hasPrompt = true;
|
||||
break;
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
setHasPrompts(hasPrompt);
|
||||
} else {
|
||||
setHasMetadata(false);
|
||||
setHasSeed(false);
|
||||
setHasPrompts(false);
|
||||
}
|
||||
};
|
||||
parseMetadata();
|
||||
}, [metadata, store]);
|
||||
|
||||
const clearStylePreset = useCallback(() => {
|
||||
if (activeStylePresetId) {
|
||||
store.dispatch(activeStylePresetIdChanged(null));
|
||||
toast({
|
||||
status: 'info',
|
||||
title: t('stylePresets.promptTemplateCleared'),
|
||||
});
|
||||
}
|
||||
}, [activeStylePresetId, store, t]);
|
||||
|
||||
const recallAll = useCallback(() => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
MetadataUtils.recallAll(metadata, store, isStaging ? [MetadataHandlers.Width, MetadataHandlers.Height] : []);
|
||||
clearStylePreset();
|
||||
}, [imageDTO, metadata, store, isStaging, clearStylePreset]);
|
||||
|
||||
const remix = useCallback(() => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
// Recalls all metadata parameters except seed
|
||||
MetadataUtils.recallAll(metadata, store, [MetadataHandlers.Seed]);
|
||||
clearStylePreset();
|
||||
}, [imageDTO, metadata, store, clearStylePreset]);
|
||||
|
||||
const recallSeed = useCallback(() => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
MetadataUtils.recallByHandler({ metadata, store, handler: MetadataHandlers.Seed });
|
||||
}, [imageDTO, metadata, store]);
|
||||
|
||||
const recallPrompts = useCallback(() => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
MetadataUtils.recallPrompts(metadata, store);
|
||||
clearStylePreset();
|
||||
}, [imageDTO, metadata, store, clearStylePreset]);
|
||||
|
||||
const createAsPreset = useCallback(async () => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
let positivePrompt: string;
|
||||
let negativePrompt: string;
|
||||
|
||||
try {
|
||||
positivePrompt = await MetadataHandlers.PositivePrompt.parse(metadata, store);
|
||||
} catch (error) {
|
||||
positivePrompt = '';
|
||||
}
|
||||
try {
|
||||
negativePrompt = (await MetadataHandlers.NegativePrompt.parse(metadata, store)) ?? '';
|
||||
} catch (error) {
|
||||
negativePrompt = '';
|
||||
}
|
||||
|
||||
$stylePresetModalState.set({
|
||||
prefilledFormData: {
|
||||
name: '',
|
||||
positivePrompt,
|
||||
negativePrompt,
|
||||
imageUrl: imageDTO.image_url,
|
||||
type: 'user',
|
||||
},
|
||||
updatingStylePresetId: null,
|
||||
isModalOpen: true,
|
||||
});
|
||||
}, [imageDTO, metadata, store]);
|
||||
|
||||
const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
|
||||
|
||||
const loadWorkflowFromImage = useCallback(() => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
if (!imageDTO.has_workflow || !hasTemplates) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadWorkflowWithDialog({ type: 'image', data: imageDTO.image_name });
|
||||
}, [hasTemplates, imageDTO, loadWorkflowWithDialog]);
|
||||
|
||||
const recallSize = useCallback(() => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
if (isStaging) {
|
||||
return;
|
||||
}
|
||||
MetadataUtils.recallDimensions(imageDTO, store);
|
||||
}, [imageDTO, isStaging, store]);
|
||||
|
||||
const upscale = useCallback(() => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
store.dispatch(adHocPostProcessingRequested({ imageDTO }));
|
||||
}, [imageDTO, store]);
|
||||
|
||||
const _delete = useCallback(() => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
deleteImageModal.delete([imageDTO.image_name]);
|
||||
}, [deleteImageModal, imageDTO]);
|
||||
|
||||
return {
|
||||
hasMetadata,
|
||||
hasSeed,
|
||||
hasPrompts,
|
||||
recallAll,
|
||||
remix,
|
||||
recallSeed,
|
||||
recallPrompts,
|
||||
createAsPreset,
|
||||
loadWorkflow: loadWorkflowFromImage,
|
||||
hasWorkflow: imageDTO?.has_workflow ?? false,
|
||||
recallSize,
|
||||
upscale,
|
||||
delete: _delete,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
|
||||
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
export const useLoadWorkflow = (imageDTO: ImageDTO) => {
|
||||
const hasTemplates = useStore($hasTemplates);
|
||||
|
||||
const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
|
||||
|
||||
const isEnabled = useMemo(() => {
|
||||
if (!imageDTO.has_workflow) {
|
||||
return false;
|
||||
}
|
||||
if (!hasTemplates) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [hasTemplates, imageDTO]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadWorkflowWithDialog({ type: 'image', data: imageDTO.image_name });
|
||||
}, [imageDTO, isEnabled, loadWorkflowWithDialog]);
|
||||
|
||||
return { load, isEnabled };
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { ListRange } from 'react-virtuoso';
|
||||
import { imagesApi, useGetImageDTOsByNamesMutation } from 'services/api/endpoints/images';
|
||||
import { useThrottledCallback } from 'use-debounce';
|
||||
@@ -13,33 +13,20 @@ interface UseRangeBasedImageFetchingReturn {
|
||||
onRangeChanged: (range: ListRange) => void;
|
||||
}
|
||||
|
||||
const getUncachedNames = (imageNames: string[], cachedImageNames: string[], range: ListRange): string[] => {
|
||||
if (range.startIndex === range.endIndex) {
|
||||
// If the start and end indices are the same, no range to fetch
|
||||
return [];
|
||||
}
|
||||
const getUncachedNames = (imageNames: string[], cachedImageNames: string[], ranges: ListRange[]): string[] => {
|
||||
const uncachedNamesSet = new Set<string>();
|
||||
const cachedImageNamesSet = new Set(cachedImageNames);
|
||||
|
||||
if (imageNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const start = Math.max(0, range.startIndex);
|
||||
const end = Math.min(imageNames.length - 1, range.endIndex);
|
||||
|
||||
if (cachedImageNames.length === 0) {
|
||||
return imageNames.slice(start, end + 1);
|
||||
}
|
||||
|
||||
const uncachedNames: string[] = [];
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
const imageName = imageNames[i]!;
|
||||
if (!cachedImageNames.includes(imageName)) {
|
||||
uncachedNames.push(imageName);
|
||||
for (const range of ranges) {
|
||||
for (let i = range.startIndex; i <= range.endIndex; i++) {
|
||||
const n = imageNames[i]!;
|
||||
if (n && !cachedImageNamesSet.has(n)) {
|
||||
uncachedNamesSet.add(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uncachedNames;
|
||||
return Array.from(uncachedNamesSet);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -53,30 +40,36 @@ export const useRangeBasedImageFetching = ({
|
||||
}: UseRangeBasedImageFetchingArgs): UseRangeBasedImageFetchingReturn => {
|
||||
const store = useAppStore();
|
||||
const [getImageDTOsByNames] = useGetImageDTOsByNamesMutation();
|
||||
const [lastRange, setLastRange] = useState<ListRange | null>(null);
|
||||
const [pendingRanges, setPendingRanges] = useState<ListRange[]>([]);
|
||||
|
||||
const fetchImages = useCallback(
|
||||
(visibleRange: ListRange) => {
|
||||
(ranges: ListRange[], imageNames: string[]) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const cachedImageNames = imagesApi.util.selectCachedArgsForQuery(store.getState(), 'getImageDTO');
|
||||
const uncachedNames = getUncachedNames(imageNames, cachedImageNames, visibleRange);
|
||||
const uncachedNames = getUncachedNames(imageNames, cachedImageNames, ranges);
|
||||
if (uncachedNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
getImageDTOsByNames({ image_names: uncachedNames });
|
||||
setPendingRanges([]);
|
||||
},
|
||||
[enabled, getImageDTOsByNames, imageNames, store]
|
||||
[enabled, getImageDTOsByNames, store]
|
||||
);
|
||||
|
||||
const throttledFetchImages = useThrottledCallback(fetchImages, 100);
|
||||
const throttledFetchImages = useThrottledCallback(fetchImages, 500);
|
||||
|
||||
const onRangeChanged = useCallback(
|
||||
(range: ListRange) => {
|
||||
throttledFetchImages(range);
|
||||
},
|
||||
[throttledFetchImages]
|
||||
);
|
||||
const onRangeChanged = useCallback((range: ListRange) => {
|
||||
setLastRange(range);
|
||||
setPendingRanges((prev) => [...prev, range]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const combinedRanges = lastRange ? [...pendingRanges, lastRange] : pendingRanges;
|
||||
throttledFetchImages(combinedRanges, imageNames);
|
||||
}, [imageNames, lastRange, pendingRanges, throttledFetchImages]);
|
||||
|
||||
return {
|
||||
onRangeChanged,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { MetadataHandlers, MetadataUtils } from 'features/metadata/parsing';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
import { useClearStylePresetWithToast } from './useClearStylePresetWithToast';
|
||||
|
||||
export const useRecallAll = (imageDTO: ImageDTO) => {
|
||||
const store = useAppStore();
|
||||
const tab = useAppSelector(selectActiveTab);
|
||||
const { metadata, isLoading } = useDebouncedMetadata(imageDTO.image_name);
|
||||
const isStaging = useAppSelector(selectIsStaging);
|
||||
const clearStylePreset = useClearStylePresetWithToast();
|
||||
|
||||
const isEnabled = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tab !== 'canvas' && tab !== 'generate') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!metadata) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [isLoading, metadata, tab]);
|
||||
|
||||
const handlersToSkip = useMemo(() => {
|
||||
if (tab === 'canvas' && isStaging) {
|
||||
// When we are staging and on canvas, the bbox is locked - we cannot recall width and height
|
||||
return [MetadataHandlers.Width, MetadataHandlers.Height];
|
||||
}
|
||||
return undefined;
|
||||
}, [isStaging, tab]);
|
||||
|
||||
const recall = useCallback(() => {
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
MetadataUtils.recallAll(metadata, store, handlersToSkip);
|
||||
clearStylePreset();
|
||||
}, [metadata, isEnabled, store, handlersToSkip, clearStylePreset]);
|
||||
|
||||
return {
|
||||
recall,
|
||||
isEnabled,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { MetadataUtils } from 'features/metadata/parsing';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
export const useRecallDimensions = (imageDTO: ImageDTO) => {
|
||||
const store = useAppStore();
|
||||
const tab = useAppSelector(selectActiveTab);
|
||||
const isStaging = useAppSelector(selectIsStaging);
|
||||
|
||||
const isEnabled = useMemo(() => {
|
||||
if (tab !== 'canvas' && tab !== 'generate') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tab === 'canvas' && isStaging) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [isStaging, tab]);
|
||||
|
||||
const recall = useCallback(() => {
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
MetadataUtils.recallDimensions(imageDTO, store);
|
||||
}, [isEnabled, imageDTO, store]);
|
||||
|
||||
return {
|
||||
recall,
|
||||
isEnabled,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { MetadataHandlers, MetadataUtils } from 'features/metadata/parsing';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
import { useClearStylePresetWithToast } from './useClearStylePresetWithToast';
|
||||
|
||||
export const useRecallPrompts = (imageDTO: ImageDTO) => {
|
||||
const store = useAppStore();
|
||||
const tab = useAppSelector(selectActiveTab);
|
||||
const clearStylePreset = useClearStylePresetWithToast();
|
||||
const [hasPrompts, setHasPrompts] = useState(false);
|
||||
|
||||
const { metadata, isLoading } = useDebouncedMetadata(imageDTO.image_name);
|
||||
|
||||
useEffect(() => {
|
||||
const parse = async () => {
|
||||
try {
|
||||
const result = await MetadataUtils.hasMetadataByHandlers({
|
||||
handlers: [
|
||||
MetadataHandlers.PositivePrompt,
|
||||
MetadataHandlers.NegativePrompt,
|
||||
MetadataHandlers.PositiveStylePrompt,
|
||||
MetadataHandlers.NegativeStylePrompt,
|
||||
],
|
||||
metadata,
|
||||
store,
|
||||
require: 'some',
|
||||
});
|
||||
setHasPrompts(result);
|
||||
} catch {
|
||||
setHasPrompts(false);
|
||||
}
|
||||
};
|
||||
|
||||
parse();
|
||||
}, [metadata, store]);
|
||||
|
||||
const isEnabled = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tab !== 'canvas' && tab !== 'generate') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasPrompts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [hasPrompts, isLoading, tab]);
|
||||
|
||||
const recall = useCallback(() => {
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
MetadataUtils.recallPrompts(metadata, store);
|
||||
clearStylePreset();
|
||||
}, [metadata, isEnabled, store, clearStylePreset]);
|
||||
|
||||
return {
|
||||
recall,
|
||||
isEnabled,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { MetadataHandlers, MetadataUtils } from 'features/metadata/parsing';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
import { useClearStylePresetWithToast } from './useClearStylePresetWithToast';
|
||||
|
||||
export const useRecallRemix = (imageDTO: ImageDTO) => {
|
||||
const store = useAppStore();
|
||||
const tab = useAppSelector(selectActiveTab);
|
||||
const isStaging = useAppSelector(selectIsStaging);
|
||||
const clearStylePreset = useClearStylePresetWithToast();
|
||||
|
||||
const { metadata, isLoading } = useDebouncedMetadata(imageDTO.image_name);
|
||||
|
||||
const isEnabled = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tab !== 'canvas' && tab !== 'generate') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!metadata) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [isLoading, metadata, tab]);
|
||||
|
||||
const handlersToSkip = useMemo(() => {
|
||||
// Remix always skips the seed handler
|
||||
const _handlersToSkip = [MetadataHandlers.Seed];
|
||||
if (tab === 'canvas' && isStaging) {
|
||||
// When we are staging and on canvas, the bbox is locked - we cannot recall width and height
|
||||
_handlersToSkip.push(MetadataHandlers.Width, MetadataHandlers.Height);
|
||||
}
|
||||
return _handlersToSkip;
|
||||
}, [isStaging, tab]);
|
||||
|
||||
const recall = useCallback(() => {
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
MetadataUtils.recallAll(metadata, store, handlersToSkip);
|
||||
clearStylePreset();
|
||||
}, [metadata, isEnabled, store, handlersToSkip, clearStylePreset]);
|
||||
|
||||
return {
|
||||
recall,
|
||||
isEnabled,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { MetadataHandlers, MetadataUtils } from 'features/metadata/parsing';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
export const useRecallSeed = (imageDTO: ImageDTO) => {
|
||||
const store = useAppStore();
|
||||
const tab = useAppSelector(selectActiveTab);
|
||||
const [hasSeed, setHasSeed] = useState(false);
|
||||
|
||||
const { metadata, isLoading } = useDebouncedMetadata(imageDTO.image_name);
|
||||
|
||||
useEffect(() => {
|
||||
const parse = async () => {
|
||||
try {
|
||||
await MetadataHandlers.Seed.parse(metadata, store);
|
||||
setHasSeed(true);
|
||||
} catch {
|
||||
setHasSeed(false);
|
||||
}
|
||||
};
|
||||
|
||||
parse();
|
||||
}, [metadata, store]);
|
||||
|
||||
const isEnabled = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tab !== 'canvas' && tab !== 'generate') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!metadata) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasSeed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [hasSeed, isLoading, metadata, tab]);
|
||||
|
||||
const recall = useCallback(() => {
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
MetadataUtils.recallByHandler({ metadata, handler: MetadataHandlers.Seed, store });
|
||||
}, [metadata, isEnabled, store]);
|
||||
|
||||
return {
|
||||
recall,
|
||||
isEnabled,
|
||||
};
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import { bboxHeightChanged, bboxWidthChanged, canvasMetadataRecalled } from 'features/controlLayers/store/canvasSlice';
|
||||
import { loraAllDeleted, loraRecalled } from 'features/controlLayers/store/lorasSlice';
|
||||
import {
|
||||
heightChanged,
|
||||
negativePrompt2Changed,
|
||||
negativePromptChanged,
|
||||
positivePrompt2Changed,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
setSteps,
|
||||
shouldConcatPromptsChanged,
|
||||
vaeSelected,
|
||||
widthChanged,
|
||||
} from 'features/controlLayers/store/paramsSlice';
|
||||
import { refImagesRecalled } from 'features/controlLayers/store/refImagesSlice';
|
||||
import type { CanvasMetadata, LoRA, RefImageState } from 'features/controlLayers/store/types';
|
||||
@@ -82,8 +84,9 @@ import {
|
||||
zParameterStrength,
|
||||
} from 'features/parameters/types/parameterSchemas';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { t } from 'i18next';
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { modelsApi } from 'services/api/endpoints/models';
|
||||
@@ -170,7 +173,8 @@ export type SingleMetadataHandler<T> = {
|
||||
type: string;
|
||||
parse: (metadata: unknown, store: AppStore) => Promise<T>;
|
||||
recall: (value: T, store: AppStore) => void;
|
||||
LabelComponent: ComponentType;
|
||||
i18nKey: string;
|
||||
LabelComponent: ComponentType<{ i18nKey: string }>;
|
||||
ValueComponent: ComponentType<SingleMetadataValueProps<T>>;
|
||||
};
|
||||
|
||||
@@ -184,7 +188,8 @@ export type CollectionMetadataHandler<T extends any[]> = {
|
||||
parse: (metadata: unknown, store: AppStore) => Promise<T>;
|
||||
recall: (values: T, store: AppStore) => void;
|
||||
recallOne: (value: T[number], store: AppStore) => void;
|
||||
LabelComponent: ComponentType;
|
||||
i18nKey: string;
|
||||
LabelComponent: ComponentType<{ i18nKey: string }>;
|
||||
ValueComponent: ComponentType<CollectionMetadataValueProps<T>>;
|
||||
};
|
||||
|
||||
@@ -196,7 +201,8 @@ export type UnrecallableMetadataHandler<T> = {
|
||||
[UnrecallableMetadataKey]: true;
|
||||
type: string;
|
||||
parse: (metadata: unknown, store: AppStore) => Promise<T>;
|
||||
LabelComponent: ComponentType;
|
||||
i18nKey: string;
|
||||
LabelComponent: ComponentType<{ i18nKey: string }>;
|
||||
ValueComponent: ComponentType<UnrecallableMetadataValueProps<T>>;
|
||||
};
|
||||
|
||||
@@ -221,7 +227,8 @@ const CreatedBy: UnrecallableMetadataHandler<string> = {
|
||||
const parsed = z.string().parse(raw);
|
||||
return Promise.resolve(parsed);
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.createdBy" />,
|
||||
i18nKey: 'metadata.createdBy',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: UnrecallableMetadataValueProps<string>) => <MetadataPrimitiveValue value={value} />,
|
||||
};
|
||||
//#endregion Created By
|
||||
@@ -235,7 +242,8 @@ const GenerationMode: UnrecallableMetadataHandler<string> = {
|
||||
const parsed = z.string().parse(raw);
|
||||
return Promise.resolve(parsed);
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.generationMode" />,
|
||||
i18nKey: 'metadata.generationMode',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: UnrecallableMetadataValueProps<string>) => <MetadataPrimitiveValue value={value} />,
|
||||
};
|
||||
//#endregion Generation Mode
|
||||
@@ -252,7 +260,8 @@ const PositivePrompt: SingleMetadataHandler<ParameterPositivePrompt> = {
|
||||
recall: (value, store) => {
|
||||
store.dispatch(positivePromptChanged(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.positivePrompt" />,
|
||||
i18nKey: 'metadata.positivePrompt',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterPositivePrompt>) => (
|
||||
<MetadataPrimitiveValue value={value} />
|
||||
),
|
||||
@@ -271,7 +280,8 @@ const NegativePrompt: SingleMetadataHandler<ParameterNegativePrompt> = {
|
||||
recall: (value, store) => {
|
||||
store.dispatch(negativePromptChanged(value || null));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.negativePrompt" />,
|
||||
i18nKey: 'metadata.negativePrompt',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterNegativePrompt>) => (
|
||||
<MetadataPrimitiveValue value={value} />
|
||||
),
|
||||
@@ -290,7 +300,8 @@ const PositiveStylePrompt: SingleMetadataHandler<ParameterPositiveStylePromptSDX
|
||||
recall: (value, store) => {
|
||||
store.dispatch(positivePrompt2Changed(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="sdxl.posStylePrompt" />,
|
||||
i18nKey: 'sdxl.posStylePrompt',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterPositiveStylePromptSDXL>) => (
|
||||
<MetadataPrimitiveValue value={value} />
|
||||
),
|
||||
@@ -309,7 +320,8 @@ const NegativeStylePrompt: SingleMetadataHandler<ParameterPositiveStylePromptSDX
|
||||
recall: (value, store) => {
|
||||
store.dispatch(negativePrompt2Changed(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="sdxl.negStylePrompt" />,
|
||||
i18nKey: 'sdxl.negStylePrompt',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterPositiveStylePromptSDXL>) => (
|
||||
<MetadataPrimitiveValue value={value} />
|
||||
),
|
||||
@@ -328,7 +340,8 @@ const CFGScale: SingleMetadataHandler<ParameterCFGScale> = {
|
||||
recall: (value, store) => {
|
||||
store.dispatch(setCfgScale(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.cfgScale" />,
|
||||
i18nKey: 'metadata.cfgScale',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterCFGScale>) => <MetadataPrimitiveValue value={value} />,
|
||||
};
|
||||
//#endregion CFG Scale
|
||||
@@ -345,7 +358,8 @@ const CFGRescaleMultiplier: SingleMetadataHandler<ParameterCFGRescaleMultiplier>
|
||||
recall: (value, store) => {
|
||||
store.dispatch(setCfgRescaleMultiplier(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.cfgRescaleMultiplier" />,
|
||||
i18nKey: 'metadata.cfgRescaleMultiplier',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterCFGRescaleMultiplier>) => (
|
||||
<MetadataPrimitiveValue value={value} />
|
||||
),
|
||||
@@ -364,7 +378,8 @@ const Guidance: SingleMetadataHandler<ParameterGuidance> = {
|
||||
recall: (value, store) => {
|
||||
store.dispatch(setGuidance(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.guidance" />,
|
||||
i18nKey: 'metadata.guidance',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterGuidance>) => <MetadataPrimitiveValue value={value} />,
|
||||
};
|
||||
//#endregion Guidance
|
||||
@@ -381,7 +396,8 @@ const Scheduler: SingleMetadataHandler<ParameterScheduler> = {
|
||||
recall: (value, store) => {
|
||||
store.dispatch(setScheduler(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.scheduler" />,
|
||||
i18nKey: 'metadata.scheduler',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterScheduler>) => <MetadataPrimitiveValue value={value} />,
|
||||
};
|
||||
//#endregion Scheduler
|
||||
@@ -396,9 +412,15 @@ const Width: SingleMetadataHandler<ParameterWidth> = {
|
||||
return Promise.resolve(parsed);
|
||||
},
|
||||
recall: (value, store) => {
|
||||
store.dispatch(bboxWidthChanged({ width: value, updateAspectRatio: true, clamp: true }));
|
||||
const activeTab = selectActiveTab(store.getState());
|
||||
if (activeTab === 'canvas') {
|
||||
store.dispatch(bboxWidthChanged({ width: value, updateAspectRatio: true, clamp: true }));
|
||||
} else if (activeTab === 'generate') {
|
||||
store.dispatch(widthChanged({ width: value, updateAspectRatio: true, clamp: true }));
|
||||
}
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.width" />,
|
||||
i18nKey: 'metadata.width',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterWidth>) => <MetadataPrimitiveValue value={value} />,
|
||||
};
|
||||
//#endregion Width
|
||||
@@ -413,9 +435,15 @@ const Height: SingleMetadataHandler<ParameterHeight> = {
|
||||
return Promise.resolve(parsed);
|
||||
},
|
||||
recall: (value, store) => {
|
||||
store.dispatch(bboxHeightChanged({ height: value, updateAspectRatio: true, clamp: true }));
|
||||
const activeTab = selectActiveTab(store.getState());
|
||||
if (activeTab === 'canvas') {
|
||||
store.dispatch(bboxHeightChanged({ height: value, updateAspectRatio: true, clamp: true }));
|
||||
} else if (activeTab === 'generate') {
|
||||
store.dispatch(heightChanged({ height: value, updateAspectRatio: true, clamp: true }));
|
||||
}
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.height" />,
|
||||
i18nKey: 'metadata.height',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterHeight>) => <MetadataPrimitiveValue value={value} />,
|
||||
};
|
||||
//#endregion Height
|
||||
@@ -432,7 +460,8 @@ const Seed: SingleMetadataHandler<ParameterSeed> = {
|
||||
recall: (value, store) => {
|
||||
store.dispatch(setSeed(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.seed" />,
|
||||
i18nKey: 'metadata.seed',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSeed>) => <MetadataPrimitiveValue value={value} />,
|
||||
};
|
||||
//#endregion Seed
|
||||
@@ -449,7 +478,8 @@ const Steps: SingleMetadataHandler<ParameterSteps> = {
|
||||
recall: (value, store) => {
|
||||
store.dispatch(setSteps(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.steps" />,
|
||||
i18nKey: 'metadata.steps',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSteps>) => <MetadataPrimitiveValue value={value} />,
|
||||
};
|
||||
//#endregion Steps
|
||||
@@ -466,7 +496,8 @@ const DenoisingStrength: SingleMetadataHandler<ParameterStrength> = {
|
||||
recall: (value, store) => {
|
||||
store.dispatch(setImg2imgStrength(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.strength" />,
|
||||
i18nKey: 'metadata.strength',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterStrength>) => <MetadataPrimitiveValue value={value} />,
|
||||
};
|
||||
//#endregion DenoisingStrength
|
||||
@@ -483,7 +514,8 @@ const SeamlessX: SingleMetadataHandler<ParameterSeamlessX> = {
|
||||
recall: (value, store) => {
|
||||
store.dispatch(setSeamlessXAxis(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.seamlessXAxis" />,
|
||||
i18nKey: 'metadata.seamlessXAxis',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSeamlessX>) => <MetadataPrimitiveValue value={value} />,
|
||||
};
|
||||
//#endregion SeamlessX
|
||||
@@ -500,7 +532,8 @@ const SeamlessY: SingleMetadataHandler<ParameterSeamlessY> = {
|
||||
recall: (value, store) => {
|
||||
store.dispatch(setSeamlessYAxis(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.seamlessYAxis" />,
|
||||
i18nKey: 'metadata.seamlessYAxis',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSeamlessY>) => <MetadataPrimitiveValue value={value} />,
|
||||
};
|
||||
//#endregion SeamlessY
|
||||
@@ -520,7 +553,8 @@ const RefinerModel: SingleMetadataHandler<ParameterSDXLRefinerModel> = {
|
||||
recall: (value, store) => {
|
||||
store.dispatch(refinerModelChanged(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="sdxl.refinermodel" />,
|
||||
i18nKey: 'sdxl.refinermodel',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSDXLRefinerModel>) => (
|
||||
<MetadataPrimitiveValue value={`${value.name} (${value.base.toUpperCase()})`} />
|
||||
),
|
||||
@@ -539,7 +573,8 @@ const RefinerSteps: SingleMetadataHandler<ParameterSteps> = {
|
||||
recall: (value, store) => {
|
||||
store.dispatch(setRefinerSteps(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="sdxl.refinerSteps" />,
|
||||
i18nKey: 'sdxl.refinerSteps',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSteps>) => <MetadataPrimitiveValue value={value} />,
|
||||
};
|
||||
//#endregion RefinerSteps
|
||||
@@ -556,7 +591,8 @@ const RefinerCFGScale: SingleMetadataHandler<ParameterCFGScale> = {
|
||||
recall: (value, store) => {
|
||||
store.dispatch(setRefinerCFGScale(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="sdxl.cfgScale" />,
|
||||
i18nKey: 'sdxl.cfgScale',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterCFGScale>) => <MetadataPrimitiveValue value={value} />,
|
||||
};
|
||||
//#endregion RefinerCFGScale
|
||||
@@ -573,7 +609,8 @@ const RefinerScheduler: SingleMetadataHandler<ParameterScheduler> = {
|
||||
recall: (value, store) => {
|
||||
store.dispatch(setRefinerScheduler(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="sdxl.scheduler" />,
|
||||
i18nKey: 'sdxl.scheduler',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterScheduler>) => <MetadataPrimitiveValue value={value} />,
|
||||
};
|
||||
//#endregion RefinerScheduler
|
||||
@@ -590,7 +627,8 @@ const RefinerPositiveAestheticScore: SingleMetadataHandler<ParameterSDXLRefinerP
|
||||
recall: (value, store) => {
|
||||
store.dispatch(setRefinerPositiveAestheticScore(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="sdxl.posAestheticScore" />,
|
||||
i18nKey: 'sdxl.posAestheticScore',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSDXLRefinerPositiveAestheticScore>) => (
|
||||
<MetadataPrimitiveValue value={value} />
|
||||
),
|
||||
@@ -609,7 +647,8 @@ const RefinerNegativeAestheticScore: SingleMetadataHandler<ParameterSDXLRefinerN
|
||||
recall: (value, store) => {
|
||||
store.dispatch(setRefinerNegativeAestheticScore(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="sdxl.negAestheticScore" />,
|
||||
i18nKey: 'sdxl.negAestheticScore',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSDXLRefinerNegativeAestheticScore>) => (
|
||||
<MetadataPrimitiveValue value={value} />
|
||||
),
|
||||
@@ -628,7 +667,8 @@ const RefinerDenoisingStart: SingleMetadataHandler<ParameterSDXLRefinerStart> =
|
||||
recall: (value, store) => {
|
||||
store.dispatch(setRefinerStart(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="sdxl.refinerStart" />,
|
||||
i18nKey: 'sdxl.refinerStart',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterSDXLRefinerStart>) => (
|
||||
<MetadataPrimitiveValue value={value} />
|
||||
),
|
||||
@@ -648,7 +688,8 @@ const MainModel: SingleMetadataHandler<ParameterModel> = {
|
||||
recall: (value, store) => {
|
||||
store.dispatch(modelSelected(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.model" />,
|
||||
i18nKey: 'metadata.model',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterModel>) => (
|
||||
<MetadataPrimitiveValue value={`${value.name} (${value.base.toUpperCase()})`} />
|
||||
),
|
||||
@@ -669,7 +710,8 @@ const VAEModel: SingleMetadataHandler<ParameterVAEModel> = {
|
||||
recall: (value, store) => {
|
||||
store.dispatch(vaeSelected(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.vae" />,
|
||||
i18nKey: 'metadata.vae',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<ParameterVAEModel>) => (
|
||||
<MetadataPrimitiveValue value={`${value.name} (${value.base.toUpperCase()})`} />
|
||||
),
|
||||
@@ -733,7 +775,8 @@ const LoRAs: CollectionMetadataHandler<LoRA[]> = {
|
||||
store.dispatch(loraRecalled({ lora }));
|
||||
}
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="models.lora" />,
|
||||
i18nKey: 'models.lora',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: CollectionMetadataValueProps<LoRA[]>) => (
|
||||
<MetadataPrimitiveValue value={`${value.model.name} (${value.model.base.toUpperCase()}) - ${value.weight}`} />
|
||||
),
|
||||
@@ -763,7 +806,8 @@ const CanvasLayers: SingleMetadataHandler<CanvasMetadata> = {
|
||||
}
|
||||
store.dispatch(canvasMetadataRecalled(value));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="metadata.canvasV2Metadata" />,
|
||||
i18nKey: 'metadata.canvasV2Metadata',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: SingleMetadataValueProps<CanvasMetadata>) => {
|
||||
const { t } = useTranslation();
|
||||
const count =
|
||||
@@ -810,7 +854,8 @@ const RefImages: CollectionMetadataHandler<RefImageState[]> = {
|
||||
const entities = [{ ...data, id: getPrefixedId('reference_image') }];
|
||||
store.dispatch(refImagesRecalled({ entities, replace: false }));
|
||||
},
|
||||
LabelComponent: () => <MetadataLabel i18nKey="controlLayers.referenceImage" />,
|
||||
i18nKey: 'controlLayers.referenceImage',
|
||||
LabelComponent: MetadataLabel,
|
||||
ValueComponent: ({ value }: CollectionMetadataValueProps<RefImageState[]>) => {
|
||||
if (value.config.model) {
|
||||
return <MetadataPrimitiveValue value={value.config.model.name} />;
|
||||
@@ -862,7 +907,7 @@ export const MetadataHandlers = {
|
||||
// ipAdapterToIPAdapterLayer: parseIPAdapterToIPAdapterLayer,
|
||||
} as const;
|
||||
|
||||
const successToast = (parameter: ReactNode) => {
|
||||
const successToast = (parameter: string) => {
|
||||
toast({
|
||||
id: 'PARAMETER_SET',
|
||||
title: t('toast.parameterSet'),
|
||||
@@ -871,7 +916,7 @@ const successToast = (parameter: ReactNode) => {
|
||||
});
|
||||
};
|
||||
|
||||
const failedToast = (parameter: ReactNode, message?: ReactNode) => {
|
||||
const failedToast = (parameter: string, message?: string) => {
|
||||
toast({
|
||||
id: 'PARAMETER_NOT_SET',
|
||||
title: t('toast.parameterNotSet'),
|
||||
@@ -902,9 +947,9 @@ const recallByHandler = async (arg: {
|
||||
|
||||
if (!silent) {
|
||||
if (didRecall) {
|
||||
successToast(<handler.LabelComponent />);
|
||||
successToast(t(handler.i18nKey));
|
||||
} else {
|
||||
failedToast(<handler.LabelComponent />);
|
||||
failedToast(t(handler.i18nKey));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1006,6 +1051,28 @@ const recallPrompts = async (metadata: unknown, store: AppStore) => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasMetadataByHandlers = async (arg: {
|
||||
metadata: unknown;
|
||||
handlers: (SingleMetadataHandler<any> | CollectionMetadataHandler<any[]>)[];
|
||||
store: AppStore;
|
||||
require: 'some' | 'all';
|
||||
}) => {
|
||||
const { metadata, handlers, store, require } = arg;
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
await handler.parse(metadata, store);
|
||||
if (require === 'some') {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
if (require === 'all') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const recallDimensions = async (metadata: unknown, store: AppStore) => {
|
||||
const recalled = await recallByHandlers({
|
||||
metadata,
|
||||
@@ -1035,6 +1102,7 @@ const recallAll = async (
|
||||
};
|
||||
|
||||
export const MetadataUtils = {
|
||||
hasMetadataByHandlers,
|
||||
recallByHandler,
|
||||
recallByHandlers,
|
||||
recallAll,
|
||||
|
||||
@@ -32,7 +32,7 @@ const CurrentImageNode = (props: NodeProps) => {
|
||||
if (imageDTO) {
|
||||
return (
|
||||
<Wrapper nodeProps={props}>
|
||||
<DndImage imageDTO={imageDTO} />
|
||||
<DndImage imageDTO={imageDTO} borderRadius="base" />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ const ImageGridItemContent = memo(
|
||||
return (
|
||||
<>
|
||||
<DndImage
|
||||
borderRadius="base"
|
||||
imageDTO={query.data}
|
||||
asThumbnail
|
||||
objectFit="contain"
|
||||
|
||||
@@ -76,7 +76,7 @@ const ImageFieldInputComponent = (props: FieldComponentProps<ImageFieldInputInst
|
||||
)}
|
||||
{imageDTO && (
|
||||
<>
|
||||
<Flex borderRadius="base" borderWidth={1} borderStyle="solid">
|
||||
<Flex borderRadius="base" borderWidth={1} borderStyle="solid" overflow="hidden">
|
||||
<DndImage imageDTO={imageDTO} asThumbnail />
|
||||
</Flex>
|
||||
<Text
|
||||
|
||||
@@ -14,7 +14,7 @@ const ImageOutputPreview = ({ output }: Props) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <DndImage imageDTO={imageDTO} />;
|
||||
return <DndImage imageDTO={imageDTO} borderRadius="base" />;
|
||||
};
|
||||
|
||||
export default memo(ImageOutputPreview);
|
||||
|
||||
@@ -30,11 +30,14 @@ export const addFLUXFill = async ({
|
||||
denoise.denoising_start = denoising_start;
|
||||
denoise.denoising_end = denoising_end;
|
||||
|
||||
const { originalSize, scaledSize, rect } = getOriginalAndScaledSizesForOtherModes(state);
|
||||
|
||||
denoise.width = scaledSize.width;
|
||||
denoise.height = scaledSize.height;
|
||||
|
||||
const params = selectParamsSlice(state);
|
||||
const canvasSettings = selectCanvasSettingsSlice(state);
|
||||
|
||||
const { originalSize, scaledSize, rect } = getOriginalAndScaledSizesForOtherModes(state);
|
||||
|
||||
const rasterAdapters = manager.compositor.getVisibleAdaptersOfType('raster_layer');
|
||||
const initialImage = await manager.compositor.getCompositeImageDTO(rasterAdapters, rect, {
|
||||
is_intermediate: true,
|
||||
|
||||
@@ -78,8 +78,6 @@ export const buildFLUXGraph = async (arg: GraphBuilderArg): Promise<GraphBuilder
|
||||
if (generationMode !== 'txt2img') {
|
||||
throw new UnsupportedGenerationModeError(t('toast.fluxKontextIncompatibleGenerationMode'));
|
||||
}
|
||||
|
||||
guidance = 30;
|
||||
}
|
||||
|
||||
const g = new Graph(getPrefixedId('flux_graph'));
|
||||
|
||||
@@ -26,7 +26,7 @@ export const buildFluxKontextGraph = (arg: GraphBuilderArg): GraphBuilderReturn
|
||||
assert(model.base === 'flux-kontext', 'Selected model is not a FLUX Kontext API model');
|
||||
|
||||
if (generationMode !== 'txt2img') {
|
||||
throw new UnsupportedGenerationModeError(t('toast.imagenIncompatibleGenerationMode', { model: 'FLUX Kontext' }));
|
||||
throw new UnsupportedGenerationModeError(t('toast.fluxKontextIncompatibleGenerationMode'));
|
||||
}
|
||||
|
||||
log.debug({ generationMode, manager: manager?.id }, 'Building FLUX Kontext graph');
|
||||
|
||||
@@ -18,7 +18,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { $onClickGoToModelManager } from 'app/store/nanostores/onClickGoToModelManager';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import type { Group, PickerContextState } from 'common/components/Picker/Picker';
|
||||
import { buildGroup, getRegex, isOption, Picker, usePickerContext } from 'common/components/Picker/Picker';
|
||||
import { buildGroup, getRegex, isGroup, Picker, usePickerContext } from 'common/components/Picker/Picker';
|
||||
import { useDisclosure } from 'common/hooks/useBoolean';
|
||||
import { typedMemo } from 'common/util/typedMemo';
|
||||
import { uniq } from 'es-toolkit/compat';
|
||||
@@ -277,8 +277,22 @@ export const ModelPicker = typedMemo(
|
||||
if (!selectedModelConfig) {
|
||||
return undefined;
|
||||
}
|
||||
let _selectedOption: WithStarred<T> | undefined = undefined;
|
||||
|
||||
return options.filter(isOption).find((o) => o.key === selectedModelConfig.key);
|
||||
for (const optionOrGroup of options) {
|
||||
if (isGroup(optionOrGroup)) {
|
||||
const result = optionOrGroup.options.find((o) => o.key === selectedModelConfig.key);
|
||||
if (result) {
|
||||
_selectedOption = result;
|
||||
break;
|
||||
}
|
||||
} else if (optionOrGroup.key === selectedModelConfig.key) {
|
||||
_selectedOption = optionOrGroup;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return _selectedOption;
|
||||
}, [options, selectedModelConfig]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
@@ -361,9 +375,19 @@ const optionSx: SystemStyleObject = {
|
||||
cursor: 'pointer',
|
||||
borderRadius: 'base',
|
||||
'&[data-selected="true"]': {
|
||||
bg: 'base.700',
|
||||
bg: 'invokeBlue.300',
|
||||
color: 'base.900',
|
||||
'.extra-info': {
|
||||
color: 'base.700',
|
||||
},
|
||||
'.picker-option': {
|
||||
fontWeight: 'bold',
|
||||
'&[data-is-compact="true"]': {
|
||||
fontWeight: 'semibold',
|
||||
},
|
||||
},
|
||||
'&[data-active="true"]': {
|
||||
bg: 'base.650',
|
||||
bg: 'invokeBlue.250',
|
||||
},
|
||||
},
|
||||
'&[data-active="true"]': {
|
||||
@@ -400,17 +424,31 @@ const PickerOptionComponent = typedMemo(
|
||||
<Flex flexDir="column" gap={1} flex={1}>
|
||||
<Flex gap={2} alignItems="center">
|
||||
{option.starred && <Icon as={PiLinkSimple} color="invokeYellow.500" boxSize={4} />}
|
||||
<Text sx={optionNameSx} data-is-compact={compactView}>
|
||||
<Text className="picker-option" sx={optionNameSx} data-is-compact={compactView}>
|
||||
{option.name}
|
||||
</Text>
|
||||
<Spacer />
|
||||
{option.file_size > 0 && (
|
||||
<Text variant="subtext" fontStyle="italic" noOfLines={1} flexShrink={0} overflow="visible">
|
||||
<Text
|
||||
className="extra-info"
|
||||
variant="subtext"
|
||||
fontStyle="italic"
|
||||
noOfLines={1}
|
||||
flexShrink={0}
|
||||
overflow="visible"
|
||||
>
|
||||
{filesize(option.file_size)}
|
||||
</Text>
|
||||
)}
|
||||
{option.usage_info && (
|
||||
<Text variant="subtext" fontStyle="italic" noOfLines={1} flexShrink={0} overflow="visible">
|
||||
<Text
|
||||
className="extra-info"
|
||||
variant="subtext"
|
||||
fontStyle="italic"
|
||||
noOfLines={1}
|
||||
flexShrink={0}
|
||||
overflow="visible"
|
||||
>
|
||||
{option.usage_info}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ButtonProps } from '@invoke-ai/ui-library';
|
||||
import { Button } from '@invoke-ai/ui-library';
|
||||
import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiXCircle } from 'react-icons/pi';
|
||||
|
||||
export const CancelAllExceptCurrentButton = memo((props: ButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const api = useCancelAllExceptCurrentQueueItemDialog();
|
||||
|
||||
return (
|
||||
<Button
|
||||
isDisabled={api.isDisabled}
|
||||
isLoading={api.isLoading}
|
||||
aria-label={t('queue.clear')}
|
||||
tooltip={t('queue.cancelAllExceptCurrentTooltip')}
|
||||
leftIcon={<PiXCircle />}
|
||||
colorScheme="error"
|
||||
onClick={api.openDialog}
|
||||
{...props}
|
||||
>
|
||||
{t('queue.clear')}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
CancelAllExceptCurrentButton.displayName = 'CancelAllExceptCurrentButton';
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { ButtonProps } from '@invoke-ai/ui-library';
|
||||
import { Button } from '@invoke-ai/ui-library';
|
||||
import { useDeleteAllExceptCurrentQueueItemDialog } from 'features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiXCircle } from 'react-icons/pi';
|
||||
|
||||
type Props = ButtonProps;
|
||||
|
||||
export const DeleteAllExceptCurrentButton = memo((props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const deleteAllExceptCurrent = useDeleteAllExceptCurrentQueueItemDialog();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={deleteAllExceptCurrent.openDialog}
|
||||
isLoading={deleteAllExceptCurrent.isLoading}
|
||||
isDisabled={deleteAllExceptCurrent.isDisabled}
|
||||
tooltip={t('queue.cancelAllExceptCurrentTooltip')}
|
||||
leftIcon={<PiXCircle />}
|
||||
colorScheme="error"
|
||||
data-testid={t('queue.clear')}
|
||||
{...props}
|
||||
>
|
||||
{t('queue.clear')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
DeleteAllExceptCurrentButton.displayName = 'DeleteAllExceptCurrentButton';
|
||||
@@ -3,7 +3,7 @@ import { Badge, ButtonGroup, Collapse, Flex, IconButton, Text } from '@invoke-ai
|
||||
import QueueStatusBadge from 'features/queue/components/common/QueueStatusBadge';
|
||||
import { useDestinationText } from 'features/queue/components/QueueList/useDestinationText';
|
||||
import { useOriginText } from 'features/queue/components/QueueList/useOriginText';
|
||||
import { useDeleteQueueItem } from 'features/queue/hooks/useDeleteQueueItem';
|
||||
import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem';
|
||||
import { useRetryQueueItem } from 'features/queue/hooks/useRetryQueueItem';
|
||||
import { getSecondsFromTimestamps } from 'features/queue/util/getSecondsFromTimestamps';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
@@ -38,13 +38,13 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => {
|
||||
const handleToggle = useCallback(() => {
|
||||
context.toggleQueueItem(item.item_id);
|
||||
}, [context, item.item_id]);
|
||||
const deleteQueueItem = useDeleteQueueItem();
|
||||
const onClickDeleteQueueItem = useCallback(
|
||||
const cancelQueueItem = useCancelQueueItem();
|
||||
const onClickCancelQueueItem = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
deleteQueueItem.trigger(item.item_id);
|
||||
cancelQueueItem.trigger(item.item_id);
|
||||
},
|
||||
[deleteQueueItem, item.item_id]
|
||||
[cancelQueueItem, item.item_id]
|
||||
);
|
||||
const retryQueueItem = useRetryQueueItem();
|
||||
const onClickRetryQueueItem = useCallback(
|
||||
@@ -135,9 +135,9 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => {
|
||||
<ButtonGroup size="xs" variant="ghost">
|
||||
{(!isFailed || !isRetryEnabled || isValidationRun) && (
|
||||
<IconButton
|
||||
onClick={onClickDeleteQueueItem}
|
||||
onClick={onClickCancelQueueItem}
|
||||
isDisabled={isCanceled}
|
||||
isLoading={deleteQueueItem.isLoading}
|
||||
isLoading={cancelQueueItem.isLoading}
|
||||
aria-label={t('queue.cancelItem')}
|
||||
icon={<PiXBold />}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useDestinationText } from 'features/queue/components/QueueList/useDesti
|
||||
import { useOriginText } from 'features/queue/components/QueueList/useOriginText';
|
||||
import { useBatchIsCanceled } from 'features/queue/hooks/useBatchIsCanceled';
|
||||
import { useCancelBatch } from 'features/queue/hooks/useCancelBatch';
|
||||
import { useDeleteQueueItem } from 'features/queue/hooks/useDeleteQueueItem';
|
||||
import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem';
|
||||
import { useRetryQueueItem } from 'features/queue/hooks/useRetryQueueItem';
|
||||
import { getSecondsFromTimestamps } from 'features/queue/util/getSecondsFromTimestamps';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
@@ -13,20 +13,22 @@ import type { ReactNode } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowCounterClockwiseBold, PiXBold } from 'react-icons/pi';
|
||||
import { useGetQueueItemQuery } from 'services/api/endpoints/queue';
|
||||
import type { S } from 'services/api/types';
|
||||
|
||||
type Props = {
|
||||
queueItem: S['SessionQueueItem'];
|
||||
};
|
||||
|
||||
const QueueItemComponent = ({ queueItem }: Props) => {
|
||||
const { session_id, batch_id, item_id, origin, destination } = queueItem;
|
||||
const QueueItemComponent = ({ queueItem: queueItemDTO }: Props) => {
|
||||
const { session_id, batch_id, item_id, origin, destination } = queueItemDTO;
|
||||
const { t } = useTranslation();
|
||||
const isRetryEnabled = useFeatureStatus('retryQueueItem');
|
||||
const isBatchCanceled = useBatchIsCanceled(batch_id);
|
||||
const cancelBatch = useCancelBatch();
|
||||
const deleteQueueItem = useDeleteQueueItem();
|
||||
const cancelQueueItem = useCancelQueueItem();
|
||||
const retryQueueItem = useRetryQueueItem();
|
||||
const { data: queueItem } = useGetQueueItemQuery(item_id);
|
||||
|
||||
const originText = useOriginText(origin);
|
||||
const destinationText = useDestinationText(destination);
|
||||
@@ -57,8 +59,8 @@ const QueueItemComponent = ({ queueItem }: Props) => {
|
||||
}, [cancelBatch, batch_id]);
|
||||
|
||||
const onCancelQueueItem = useCallback(() => {
|
||||
deleteQueueItem.trigger(item_id);
|
||||
}, [deleteQueueItem, item_id]);
|
||||
cancelQueueItem.trigger(item_id);
|
||||
}, [cancelQueueItem, item_id]);
|
||||
|
||||
const onRetryQueueItem = useCallback(() => {
|
||||
retryQueueItem.trigger(item_id);
|
||||
@@ -85,8 +87,8 @@ const QueueItemComponent = ({ queueItem }: Props) => {
|
||||
{(!isFailed || !isRetryEnabled) && (
|
||||
<Button
|
||||
onClick={onCancelQueueItem}
|
||||
isLoading={deleteQueueItem.isLoading}
|
||||
isDisabled={deleteQueueItem.isDisabled || queueItem ? isCanceled : true}
|
||||
isLoading={cancelQueueItem.isLoading}
|
||||
isDisabled={cancelQueueItem.isDisabled || queueItem ? isCanceled : true}
|
||||
aria-label={t('queue.cancelItem')}
|
||||
leftIcon={<PiXBold />}
|
||||
colorScheme="error"
|
||||
|
||||
@@ -13,7 +13,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Components, ItemContent } from 'react-virtuoso';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { useListQueueItemsQuery } from 'services/api/endpoints/queue';
|
||||
import { queueItemsAdapterSelectors, useListQueueItemsQuery } from 'services/api/endpoints/queue';
|
||||
import type { S } from 'services/api/types';
|
||||
|
||||
import QueueItemComponent from './QueueItemComponent';
|
||||
@@ -70,7 +70,7 @@ const QueueList = () => {
|
||||
if (!listQueueItemsData) {
|
||||
return [];
|
||||
}
|
||||
return listQueueItemsData.items;
|
||||
return queueItemsAdapterSelectors.selectAll(listQueueItemsData);
|
||||
}, [listQueueItemsData]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ButtonGroup, Flex } from '@invoke-ai/ui-library';
|
||||
import { DeleteAllExceptCurrentButton } from 'features/queue/components/DeleteAllExceptCurrentButton';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { CancelAllExceptCurrentButton } from './CancelAllExceptCurrentButton';
|
||||
import ClearModelCacheButton from './ClearModelCacheButton';
|
||||
import PauseProcessorButton from './PauseProcessorButton';
|
||||
import PruneQueueButton from './PruneQueueButton';
|
||||
@@ -23,7 +23,7 @@ const QueueTabQueueControls = () => {
|
||||
)}
|
||||
<ButtonGroup w={28} orientation="vertical" size="sm">
|
||||
<PruneQueueButton />
|
||||
<DeleteAllExceptCurrentButton />
|
||||
<CancelAllExceptCurrentButton />
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
<ClearModelCacheButton />
|
||||
|
||||
@@ -33,7 +33,7 @@ export const queueSlice = createSlice({
|
||||
},
|
||||
});
|
||||
|
||||
export const { listCursorChanged, listPriorityChanged } = queueSlice.actions;
|
||||
export const { listCursorChanged, listPriorityChanged, listParamsReset } = queueSlice.actions;
|
||||
|
||||
const selectQueueSlice = (state: RootState) => state.queue;
|
||||
const createQueueSelector = <T>(selector: Selector<QueueState, T>) => createSelector(selectQueueSlice, selector);
|
||||
|
||||
@@ -287,17 +287,19 @@ const getReasonsWhyCannotEnqueueGenerateTab = (arg: {
|
||||
reasons.push({ content: i18n.t('parameters.invoke.fluxKontextMultipleReferenceImages') });
|
||||
}
|
||||
|
||||
refImages.entities.forEach((entity, i) => {
|
||||
const layerNumber = i + 1;
|
||||
const refImageLiteral = i18n.t(LAYER_TYPE_TO_TKEY['reference_image']);
|
||||
const prefix = `${refImageLiteral} #${layerNumber}`;
|
||||
const problems = getGlobalReferenceImageWarnings(entity, model);
|
||||
refImages.entities
|
||||
.filter(({ isEnabled }) => isEnabled)
|
||||
.forEach((entity, i) => {
|
||||
const layerNumber = i + 1;
|
||||
const refImageLiteral = i18n.t(LAYER_TYPE_TO_TKEY['reference_image']);
|
||||
const prefix = `${refImageLiteral} #${layerNumber}`;
|
||||
const problems = getGlobalReferenceImageWarnings(entity, model);
|
||||
|
||||
if (problems.length) {
|
||||
const content = upperFirst(problems.map((p) => i18n.t(p)).join(', '));
|
||||
reasons.push({ prefix, content });
|
||||
}
|
||||
});
|
||||
if (problems.length) {
|
||||
const content = upperFirst(problems.map((p) => i18n.t(p)).join(', '));
|
||||
reasons.push({ prefix, content });
|
||||
}
|
||||
});
|
||||
|
||||
return reasons;
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ export const UpscaleInitialImage = () => {
|
||||
{!imageDTO && <UploadImageIconButton w="full" h="full" isError={!imageDTO} onUpload={onUpload} fontSize={36} />}
|
||||
{imageDTO && (
|
||||
<>
|
||||
<DndImage imageDTO={imageDTO} />
|
||||
<DndImage imageDTO={imageDTO} borderRadius="base" />
|
||||
<Flex position="absolute" flexDir="column" top={1} insetInlineEnd={1} gap={1}>
|
||||
<DndImageIcon
|
||||
onClick={onReset}
|
||||
|
||||
@@ -144,7 +144,6 @@ export const useHotkeyData = (): HotkeysData => {
|
||||
addHotkey('viewer', 'recallPrompts', ['p']);
|
||||
addHotkey('viewer', 'remix', ['r']);
|
||||
addHotkey('viewer', 'useSize', ['d']);
|
||||
addHotkey('viewer', 'runPostprocessing', ['shift+u']);
|
||||
addHotkey('viewer', 'toggleMetadata', ['i']);
|
||||
|
||||
// Gallery
|
||||
|
||||
@@ -26,14 +26,14 @@ const optionsObject: Record<Language, string> = {
|
||||
nl: 'Nederlands',
|
||||
pl: 'Polski',
|
||||
pt: 'Português',
|
||||
pt_BR: 'Português do Brasil',
|
||||
'pt-BR': 'Português do Brasil',
|
||||
ru: 'Русский',
|
||||
sv: 'Svenska',
|
||||
tr: 'Türkçe',
|
||||
ua: 'Украї́нська',
|
||||
vi: 'Tiếng Việt',
|
||||
zh_CN: '简体中文',
|
||||
zh_Hant: '漢語',
|
||||
'zh-CN': '简体中文',
|
||||
'zh-Hant': '漢語',
|
||||
};
|
||||
|
||||
const options = map(optionsObject, (label, value) => ({ label, value }));
|
||||
|
||||
@@ -9,7 +9,7 @@ import { uniq } from 'es-toolkit/compat';
|
||||
import type { Language, SystemState } from './types';
|
||||
|
||||
const initialSystemState: SystemState = {
|
||||
_version: 1,
|
||||
_version: 2,
|
||||
shouldConfirmOnDelete: true,
|
||||
shouldAntialiasProgressImage: false,
|
||||
shouldConfirmOnNewSession: true,
|
||||
@@ -96,6 +96,10 @@ const migrateSystemState = (state: any): any => {
|
||||
if (!('_version' in state)) {
|
||||
state._version = 1;
|
||||
}
|
||||
if (state._version === 1) {
|
||||
state.language = (state as SystemState).language.replace('_', '-');
|
||||
state._version = 2;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
|
||||
@@ -17,20 +17,20 @@ const zLanguage = z.enum([
|
||||
'nl',
|
||||
'pl',
|
||||
'pt',
|
||||
'pt_BR',
|
||||
'pt-BR',
|
||||
'ru',
|
||||
'sv',
|
||||
'tr',
|
||||
'ua',
|
||||
'vi',
|
||||
'zh_CN',
|
||||
'zh_Hant',
|
||||
'zh-CN',
|
||||
'zh-Hant',
|
||||
]);
|
||||
export type Language = z.infer<typeof zLanguage>;
|
||||
export const isLanguage = (v: unknown): v is Language => zLanguage.safeParse(v).success;
|
||||
|
||||
export interface SystemState {
|
||||
_version: 1;
|
||||
_version: 2;
|
||||
shouldConfirmOnDelete: boolean;
|
||||
shouldAntialiasProgressImage: boolean;
|
||||
shouldConfirmOnNewSession: boolean;
|
||||
|
||||
@@ -58,7 +58,7 @@ const TabContent = memo(() => {
|
||||
TabContent.displayName = 'TabContent';
|
||||
|
||||
const SwitchingTabsLoader = memo(() => {
|
||||
const isSwitchingTabs = useStore(navigationApi.$isSwitchingTabs);
|
||||
const isSwitchingTabs = useStore(navigationApi.$isLoading);
|
||||
|
||||
if (isSwitchingTabs) {
|
||||
return <Loading />;
|
||||
|
||||
@@ -13,8 +13,6 @@ import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD';
|
||||
import { InvokeCanvasComponent } from 'features/controlLayers/components/InvokeCanvasComponent';
|
||||
import { SelectObject } from 'features/controlLayers/components/SelectObject/SelectObject';
|
||||
import { CanvasSessionContextProvider } from 'features/controlLayers/components/SimpleSession/context';
|
||||
import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList';
|
||||
import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
|
||||
import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar';
|
||||
import { Transform } from 'features/controlLayers/components/Transform/Transform';
|
||||
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
@@ -23,6 +21,8 @@ import { selectCanvasSessionId } from 'features/controlLayers/store/canvasStagin
|
||||
import { memo, useCallback } from 'react';
|
||||
import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi';
|
||||
|
||||
import { StagingArea } from './StagingArea';
|
||||
|
||||
const MenuContent = memo(() => {
|
||||
return (
|
||||
<CanvasManagerProviderGate>
|
||||
@@ -106,23 +106,7 @@ export const CanvasWorkspacePanel = memo(() => {
|
||||
{canvasId !== null && (
|
||||
<CanvasManagerProviderGate>
|
||||
<CanvasSessionContextProvider type="advanced" id={canvasId}>
|
||||
<Flex
|
||||
position="absolute"
|
||||
flexDir="column"
|
||||
bottom={4}
|
||||
gap={2}
|
||||
align="center"
|
||||
justify="center"
|
||||
left={4}
|
||||
right={4}
|
||||
>
|
||||
<Flex position="relative" maxW="full" w="full" h={108}>
|
||||
<StagingAreaItemsList />
|
||||
</Flex>
|
||||
<Flex gap={2}>
|
||||
<StagingAreaToolbar />
|
||||
</Flex>
|
||||
</Flex>
|
||||
<StagingArea />
|
||||
</CanvasSessionContextProvider>
|
||||
</CanvasManagerProviderGate>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList';
|
||||
import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
|
||||
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const StagingArea = memo(() => {
|
||||
const isStaging = useAppSelector(selectIsStaging);
|
||||
|
||||
if (!isStaging) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex position="absolute" flexDir="column" bottom={2} gap={2} align="center" justify="center" left={2} right={2}>
|
||||
<StagingAreaItemsList />
|
||||
<StagingAreaToolbar />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
StagingArea.displayName = 'StagingArea';
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
PiTextAaBold,
|
||||
} from 'react-icons/pi';
|
||||
|
||||
import { useHackOutDvTabDraggable } from './use-hack-out-dv-tab-draggable';
|
||||
|
||||
const TAB_ICONS: Record<TabName, IconType> = {
|
||||
generate: PiTextAaBold,
|
||||
canvas: PiBoundingBoxBold,
|
||||
@@ -41,6 +43,8 @@ export const TabWithLaunchpadIcon = memo((props: IDockviewPanelHeaderProps) => {
|
||||
setFocusedRegion(props.params.focusRegion);
|
||||
}, [props.params.focusRegion]);
|
||||
|
||||
useHackOutDvTabDraggable(ref);
|
||||
|
||||
return (
|
||||
<Flex ref={ref} alignItems="center" h="full" px={4} gap={3} onPointerDown={onPointerDown}>
|
||||
<Icon as={TAB_ICONS[activeTab]} color="invokeYellow.300" boxSize={5} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { IDockviewPanelHeaderProps } from 'dockview';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
|
||||
import type { PanelParameters } from './auto-layout-context';
|
||||
import { useHackOutDvTabDraggable } from './use-hack-out-dv-tab-draggable';
|
||||
|
||||
export const TabWithoutCloseButton = memo((props: IDockviewPanelHeaderProps<PanelParameters>) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -20,6 +21,8 @@ export const TabWithoutCloseButton = memo((props: IDockviewPanelHeaderProps<Pane
|
||||
setFocusedRegion(props.params.focusRegion);
|
||||
}, [props.params.focusRegion]);
|
||||
|
||||
useHackOutDvTabDraggable(ref);
|
||||
|
||||
return (
|
||||
<Flex ref={ref} alignItems="center" h="full" onPointerDown={onPointerDown}>
|
||||
<Text userSelect="none" px={4}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { memo, useCallback, useRef } from 'react';
|
||||
import { useIsGenerationInProgress } from 'services/api/endpoints/queue';
|
||||
|
||||
import type { PanelParameters } from './auto-layout-context';
|
||||
import { useHackOutDvTabDraggable } from './use-hack-out-dv-tab-draggable';
|
||||
|
||||
export const TabWithoutCloseButtonAndWithProgressIndicator = memo(
|
||||
(props: IDockviewPanelHeaderProps<PanelParameters>) => {
|
||||
@@ -25,6 +26,8 @@ export const TabWithoutCloseButtonAndWithProgressIndicator = memo(
|
||||
setFocusedRegion(props.params.focusRegion);
|
||||
}, [props.params.focusRegion]);
|
||||
|
||||
useHackOutDvTabDraggable(ref);
|
||||
|
||||
return (
|
||||
<Flex ref={ref} position="relative" alignItems="center" h="full" onPointerDown={onPointerDown}>
|
||||
<Text userSelect="none" px={4}>
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import type {
|
||||
DockviewApi,
|
||||
GridviewApi,
|
||||
IDockviewPanel,
|
||||
IDockviewReactProps,
|
||||
IGridviewPanel,
|
||||
IGridviewReactProps,
|
||||
} from 'dockview';
|
||||
import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview';
|
||||
import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview';
|
||||
import { CanvasLayersPanel } from 'features/controlLayers/components/CanvasLayersPanelContent';
|
||||
import { CanvasLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel';
|
||||
@@ -71,54 +64,50 @@ const mainPanelComponents: AutoLayoutDockviewComponents = {
|
||||
};
|
||||
|
||||
const initializeCenterPanelLayout = (tab: TabName, api: DockviewApi) => {
|
||||
const launchpad = api.addPanel<PanelParameters>({
|
||||
id: LAUNCHPAD_PANEL_ID,
|
||||
component: LAUNCHPAD_PANEL_ID,
|
||||
title: 'Launchpad',
|
||||
tabComponent: TAB_WITH_LAUNCHPAD_ICON_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'launchpad',
|
||||
},
|
||||
navigationApi.registerContainer(tab, 'main', api, () => {
|
||||
const launchpad = api.addPanel<PanelParameters>({
|
||||
id: LAUNCHPAD_PANEL_ID,
|
||||
component: LAUNCHPAD_PANEL_ID,
|
||||
title: 'Launchpad',
|
||||
tabComponent: TAB_WITH_LAUNCHPAD_ICON_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'launchpad',
|
||||
},
|
||||
});
|
||||
|
||||
api.addPanel<PanelParameters>({
|
||||
id: WORKSPACE_PANEL_ID,
|
||||
component: WORKSPACE_PANEL_ID,
|
||||
title: 'Canvas',
|
||||
tabComponent: DEFAULT_TAB_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'canvas',
|
||||
},
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: launchpad.id,
|
||||
},
|
||||
});
|
||||
|
||||
api.addPanel<PanelParameters>({
|
||||
id: VIEWER_PANEL_ID,
|
||||
component: VIEWER_PANEL_ID,
|
||||
title: 'Image Viewer',
|
||||
tabComponent: DEFAULT_TAB_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'viewer',
|
||||
},
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: launchpad.id,
|
||||
},
|
||||
});
|
||||
|
||||
launchpad.api.setActive();
|
||||
});
|
||||
|
||||
const workspace = api.addPanel<PanelParameters>({
|
||||
id: WORKSPACE_PANEL_ID,
|
||||
component: WORKSPACE_PANEL_ID,
|
||||
title: 'Canvas',
|
||||
tabComponent: DEFAULT_TAB_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'canvas',
|
||||
},
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: launchpad.id,
|
||||
},
|
||||
});
|
||||
|
||||
const viewer = api.addPanel<PanelParameters>({
|
||||
id: VIEWER_PANEL_ID,
|
||||
component: VIEWER_PANEL_ID,
|
||||
title: 'Image Viewer',
|
||||
tabComponent: DEFAULT_TAB_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'viewer',
|
||||
},
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: launchpad.id,
|
||||
},
|
||||
});
|
||||
|
||||
launchpad.api.setActive();
|
||||
|
||||
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
|
||||
navigationApi.registerPanel(tab, WORKSPACE_PANEL_ID, workspace);
|
||||
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
|
||||
|
||||
return { launchpad, workspace, viewer } satisfies Record<string, IDockviewPanel>;
|
||||
};
|
||||
|
||||
const MainPanel = memo(() => {
|
||||
@@ -157,54 +146,49 @@ const rightPanelComponents: AutoLayoutGridviewComponents = {
|
||||
};
|
||||
|
||||
const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => {
|
||||
const gallery = api.addPanel<PanelParameters>({
|
||||
id: GALLERY_PANEL_ID,
|
||||
component: GALLERY_PANEL_ID,
|
||||
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
|
||||
minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'gallery',
|
||||
},
|
||||
navigationApi.registerContainer(tab, 'right', api, () => {
|
||||
const gallery = api.addPanel<PanelParameters>({
|
||||
id: GALLERY_PANEL_ID,
|
||||
component: GALLERY_PANEL_ID,
|
||||
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
|
||||
minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'gallery',
|
||||
},
|
||||
});
|
||||
|
||||
const boards = api.addPanel<PanelParameters>({
|
||||
id: BOARDS_PANEL_ID,
|
||||
component: BOARDS_PANEL_ID,
|
||||
minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'boards',
|
||||
},
|
||||
position: {
|
||||
direction: 'above',
|
||||
referencePanel: gallery.id,
|
||||
},
|
||||
});
|
||||
|
||||
api.addPanel<PanelParameters>({
|
||||
id: LAYERS_PANEL_ID,
|
||||
component: LAYERS_PANEL_ID,
|
||||
minimumHeight: LAYERS_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'layers',
|
||||
},
|
||||
position: {
|
||||
direction: 'below',
|
||||
referencePanel: gallery.id,
|
||||
},
|
||||
});
|
||||
|
||||
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX });
|
||||
boards.api.setSize({ height: CANVAS_BOARD_PANEL_DEFAULT_HEIGHT_PX });
|
||||
});
|
||||
|
||||
const layers = api.addPanel<PanelParameters>({
|
||||
id: LAYERS_PANEL_ID,
|
||||
component: LAYERS_PANEL_ID,
|
||||
minimumHeight: LAYERS_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'layers',
|
||||
},
|
||||
position: {
|
||||
direction: 'below',
|
||||
referencePanel: gallery.id,
|
||||
},
|
||||
});
|
||||
|
||||
const boards = api.addPanel<PanelParameters>({
|
||||
id: BOARDS_PANEL_ID,
|
||||
component: BOARDS_PANEL_ID,
|
||||
minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'boards',
|
||||
},
|
||||
position: {
|
||||
direction: 'above',
|
||||
referencePanel: gallery.id,
|
||||
},
|
||||
});
|
||||
|
||||
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
|
||||
boards.api.setSize({ height: CANVAS_BOARD_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
|
||||
|
||||
// Register panels with navigation API
|
||||
navigationApi.registerPanel(tab, GALLERY_PANEL_ID, gallery);
|
||||
navigationApi.registerPanel(tab, LAYERS_PANEL_ID, layers);
|
||||
navigationApi.registerPanel(tab, BOARDS_PANEL_ID, boards);
|
||||
|
||||
return { gallery, layers, boards } satisfies Record<string, IGridviewPanel>;
|
||||
};
|
||||
|
||||
const RightPanel = memo(() => {
|
||||
@@ -232,19 +216,16 @@ const leftPanelComponents: AutoLayoutGridviewComponents = {
|
||||
};
|
||||
|
||||
const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => {
|
||||
const settings = api.addPanel<PanelParameters>({
|
||||
id: SETTINGS_PANEL_ID,
|
||||
component: SETTINGS_PANEL_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'settings',
|
||||
},
|
||||
navigationApi.registerContainer(tab, 'left', api, () => {
|
||||
api.addPanel<PanelParameters>({
|
||||
id: SETTINGS_PANEL_ID,
|
||||
component: SETTINGS_PANEL_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'settings',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Register panel with navigation API
|
||||
navigationApi.registerPanel(tab, SETTINGS_PANEL_ID, settings);
|
||||
|
||||
return { settings } satisfies Record<string, IGridviewPanel>;
|
||||
};
|
||||
|
||||
const LeftPanel = memo(() => {
|
||||
@@ -273,47 +254,44 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
|
||||
[RIGHT_PANEL_ID]: RightPanel,
|
||||
};
|
||||
|
||||
const initializeRootPanelLayout = (api: GridviewApi) => {
|
||||
const main = api.addPanel({
|
||||
id: MAIN_PANEL_ID,
|
||||
component: MAIN_PANEL_ID,
|
||||
priority: LayoutPriority.High,
|
||||
const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => {
|
||||
navigationApi.registerContainer(tab, 'root', api, () => {
|
||||
const main = api.addPanel({
|
||||
id: MAIN_PANEL_ID,
|
||||
component: MAIN_PANEL_ID,
|
||||
priority: LayoutPriority.High,
|
||||
});
|
||||
|
||||
const left = api.addPanel({
|
||||
id: LEFT_PANEL_ID,
|
||||
component: LEFT_PANEL_ID,
|
||||
minimumWidth: LEFT_PANEL_MIN_SIZE_PX,
|
||||
priority: LayoutPriority.Low,
|
||||
position: {
|
||||
direction: 'left',
|
||||
referencePanel: main.id,
|
||||
},
|
||||
});
|
||||
|
||||
const right = api.addPanel({
|
||||
id: RIGHT_PANEL_ID,
|
||||
component: RIGHT_PANEL_ID,
|
||||
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
|
||||
priority: LayoutPriority.Low,
|
||||
position: {
|
||||
direction: 'right',
|
||||
referencePanel: main.id,
|
||||
},
|
||||
});
|
||||
|
||||
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
|
||||
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
|
||||
});
|
||||
|
||||
const left = api.addPanel({
|
||||
id: LEFT_PANEL_ID,
|
||||
component: LEFT_PANEL_ID,
|
||||
minimumWidth: LEFT_PANEL_MIN_SIZE_PX,
|
||||
position: {
|
||||
direction: 'left',
|
||||
referencePanel: main.id,
|
||||
},
|
||||
});
|
||||
|
||||
const right = api.addPanel({
|
||||
id: RIGHT_PANEL_ID,
|
||||
component: RIGHT_PANEL_ID,
|
||||
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
|
||||
position: {
|
||||
direction: 'right',
|
||||
referencePanel: main.id,
|
||||
},
|
||||
});
|
||||
|
||||
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
|
||||
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
|
||||
|
||||
navigationApi.registerPanel('canvas', LEFT_PANEL_ID, left);
|
||||
navigationApi.registerPanel('canvas', MAIN_PANEL_ID, main);
|
||||
navigationApi.registerPanel('canvas', RIGHT_PANEL_ID, right);
|
||||
|
||||
return { main, left, right } satisfies Record<string, IGridviewPanel>;
|
||||
};
|
||||
|
||||
export const CanvasTabAutoLayout = memo(() => {
|
||||
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
|
||||
initializeRootPanelLayout(api);
|
||||
navigationApi.onTabReady('canvas');
|
||||
initializeRootPanelLayout('canvas', api);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import type {
|
||||
DockviewApi,
|
||||
GridviewApi,
|
||||
IDockviewPanel,
|
||||
IDockviewReactProps,
|
||||
IGridviewPanel,
|
||||
IGridviewReactProps,
|
||||
} from 'dockview';
|
||||
import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview';
|
||||
import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview';
|
||||
import { GenerateLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel';
|
||||
import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent';
|
||||
@@ -65,38 +58,35 @@ const mainPanelComponents: AutoLayoutDockviewComponents = {
|
||||
};
|
||||
|
||||
const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => {
|
||||
const launchpad = api.addPanel<PanelParameters>({
|
||||
id: LAUNCHPAD_PANEL_ID,
|
||||
component: LAUNCHPAD_PANEL_ID,
|
||||
title: 'Launchpad',
|
||||
tabComponent: TAB_WITH_LAUNCHPAD_ICON_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'launchpad',
|
||||
},
|
||||
navigationApi.registerContainer(tab, 'main', api, () => {
|
||||
const launchpad = api.addPanel<PanelParameters>({
|
||||
id: LAUNCHPAD_PANEL_ID,
|
||||
component: LAUNCHPAD_PANEL_ID,
|
||||
title: 'Launchpad',
|
||||
tabComponent: TAB_WITH_LAUNCHPAD_ICON_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'launchpad',
|
||||
},
|
||||
});
|
||||
|
||||
api.addPanel<PanelParameters>({
|
||||
id: VIEWER_PANEL_ID,
|
||||
component: VIEWER_PANEL_ID,
|
||||
title: 'Image Viewer',
|
||||
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'viewer',
|
||||
},
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: launchpad.id,
|
||||
},
|
||||
});
|
||||
|
||||
launchpad.api.setActive();
|
||||
});
|
||||
|
||||
const viewer = api.addPanel<PanelParameters>({
|
||||
id: VIEWER_PANEL_ID,
|
||||
component: VIEWER_PANEL_ID,
|
||||
title: 'Image Viewer',
|
||||
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'viewer',
|
||||
},
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: launchpad.id,
|
||||
},
|
||||
});
|
||||
|
||||
launchpad.api.setActive();
|
||||
|
||||
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
|
||||
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
|
||||
|
||||
return { launchpad, viewer } satisfies Record<string, IDockviewPanel>;
|
||||
};
|
||||
|
||||
const MainPanel = memo(() => {
|
||||
@@ -134,39 +124,35 @@ const rightPanelComponents: AutoLayoutGridviewComponents = {
|
||||
};
|
||||
|
||||
const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => {
|
||||
const gallery = api.addPanel<PanelParameters>({
|
||||
id: GALLERY_PANEL_ID,
|
||||
component: GALLERY_PANEL_ID,
|
||||
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
|
||||
minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'gallery',
|
||||
},
|
||||
navigationApi.registerContainer(tab, 'right', api, () => {
|
||||
const gallery = api.addPanel<PanelParameters>({
|
||||
id: GALLERY_PANEL_ID,
|
||||
component: GALLERY_PANEL_ID,
|
||||
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
|
||||
minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'gallery',
|
||||
},
|
||||
});
|
||||
|
||||
const boards = api.addPanel<PanelParameters>({
|
||||
id: BOARDS_PANEL_ID,
|
||||
component: BOARDS_PANEL_ID,
|
||||
minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'boards',
|
||||
},
|
||||
position: {
|
||||
direction: 'above',
|
||||
referencePanel: gallery.id,
|
||||
},
|
||||
});
|
||||
|
||||
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX });
|
||||
boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX });
|
||||
});
|
||||
|
||||
const boards = api.addPanel<PanelParameters>({
|
||||
id: BOARDS_PANEL_ID,
|
||||
component: BOARDS_PANEL_ID,
|
||||
minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'boards',
|
||||
},
|
||||
position: {
|
||||
direction: 'above',
|
||||
referencePanel: gallery.id,
|
||||
},
|
||||
});
|
||||
|
||||
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
|
||||
boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
|
||||
|
||||
// Register panels with navigation API
|
||||
navigationApi.registerPanel(tab, GALLERY_PANEL_ID, gallery);
|
||||
navigationApi.registerPanel(tab, BOARDS_PANEL_ID, boards);
|
||||
|
||||
return { gallery, boards } satisfies Record<string, IGridviewPanel>;
|
||||
};
|
||||
|
||||
const RightPanel = memo(() => {
|
||||
@@ -194,19 +180,16 @@ const leftPanelComponents: AutoLayoutGridviewComponents = {
|
||||
};
|
||||
|
||||
const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => {
|
||||
const settings = api.addPanel<PanelParameters>({
|
||||
id: SETTINGS_PANEL_ID,
|
||||
component: SETTINGS_PANEL_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'settings',
|
||||
},
|
||||
navigationApi.registerContainer(tab, 'left', api, () => {
|
||||
api.addPanel<PanelParameters>({
|
||||
id: SETTINGS_PANEL_ID,
|
||||
component: SETTINGS_PANEL_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'settings',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Register panel with navigation API
|
||||
navigationApi.registerPanel(tab, SETTINGS_PANEL_ID, settings);
|
||||
|
||||
return { settings } satisfies Record<string, IGridviewPanel>;
|
||||
};
|
||||
|
||||
const LeftPanel = memo(() => {
|
||||
@@ -235,47 +218,42 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
|
||||
[RIGHT_PANEL_ID]: RightPanel,
|
||||
};
|
||||
|
||||
const initializeRootPanelLayout = (layoutApi: GridviewApi) => {
|
||||
const main = layoutApi.addPanel<PanelParameters>({
|
||||
id: MAIN_PANEL_ID,
|
||||
component: MAIN_PANEL_ID,
|
||||
priority: LayoutPriority.High,
|
||||
const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => {
|
||||
navigationApi.registerContainer(tab, 'root', api, () => {
|
||||
const main = api.addPanel<PanelParameters>({
|
||||
id: MAIN_PANEL_ID,
|
||||
component: MAIN_PANEL_ID,
|
||||
priority: LayoutPriority.High,
|
||||
});
|
||||
|
||||
const left = api.addPanel<PanelParameters>({
|
||||
id: LEFT_PANEL_ID,
|
||||
component: LEFT_PANEL_ID,
|
||||
minimumWidth: LEFT_PANEL_MIN_SIZE_PX,
|
||||
position: {
|
||||
direction: 'left',
|
||||
referencePanel: main.id,
|
||||
},
|
||||
});
|
||||
|
||||
const right = api.addPanel<PanelParameters>({
|
||||
id: RIGHT_PANEL_ID,
|
||||
component: RIGHT_PANEL_ID,
|
||||
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
|
||||
position: {
|
||||
direction: 'right',
|
||||
referencePanel: main.id,
|
||||
},
|
||||
});
|
||||
|
||||
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
|
||||
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
|
||||
});
|
||||
|
||||
const left = layoutApi.addPanel<PanelParameters>({
|
||||
id: LEFT_PANEL_ID,
|
||||
component: LEFT_PANEL_ID,
|
||||
minimumWidth: LEFT_PANEL_MIN_SIZE_PX,
|
||||
position: {
|
||||
direction: 'left',
|
||||
referencePanel: main.id,
|
||||
},
|
||||
});
|
||||
|
||||
const right = layoutApi.addPanel<PanelParameters>({
|
||||
id: RIGHT_PANEL_ID,
|
||||
component: RIGHT_PANEL_ID,
|
||||
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
|
||||
position: {
|
||||
direction: 'right',
|
||||
referencePanel: main.id,
|
||||
},
|
||||
});
|
||||
|
||||
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
|
||||
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
|
||||
|
||||
navigationApi.registerPanel('generate', LEFT_PANEL_ID, left);
|
||||
navigationApi.registerPanel('generate', MAIN_PANEL_ID, main);
|
||||
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, right);
|
||||
|
||||
return { main, left, right } satisfies Record<string, IGridviewPanel>;
|
||||
};
|
||||
|
||||
export const GenerateTabAutoLayout = memo(() => {
|
||||
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
|
||||
initializeRootPanelLayout(api);
|
||||
navigationApi.onTabReady('generate');
|
||||
initializeRootPanelLayout('generate', api);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { GridviewApi, IGridviewPanel, IGridviewReactProps } from 'dockview';
|
||||
import type { GridviewApi, IGridviewReactProps } from 'dockview';
|
||||
import { GridviewReact, LayoutPriority, Orientation } from 'dockview';
|
||||
import ModelManagerTab from 'features/ui/components/tabs/ModelManagerTab';
|
||||
import type { RootLayoutGridviewComponents } from 'features/ui/layouts/auto-layout-context';
|
||||
import { AutoLayoutProvider } from 'features/ui/layouts/auto-layout-context';
|
||||
import type { TabName } from 'features/ui/store/uiTypes';
|
||||
import { memo, useCallback, useEffect } from 'react';
|
||||
|
||||
import { navigationApi } from './navigation-api';
|
||||
@@ -12,22 +13,19 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
|
||||
[MODELS_PANEL_ID]: ModelManagerTab,
|
||||
};
|
||||
|
||||
const initializeRootPanelLayout = (layoutApi: GridviewApi) => {
|
||||
const models = layoutApi.addPanel({
|
||||
id: MODELS_PANEL_ID,
|
||||
component: MODELS_PANEL_ID,
|
||||
priority: LayoutPriority.High,
|
||||
const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => {
|
||||
navigationApi.registerContainer(tab, 'root', api, () => {
|
||||
api.addPanel({
|
||||
id: MODELS_PANEL_ID,
|
||||
component: MODELS_PANEL_ID,
|
||||
priority: LayoutPriority.High,
|
||||
});
|
||||
});
|
||||
|
||||
navigationApi.registerPanel('models', MODELS_PANEL_ID, models);
|
||||
|
||||
return { models } satisfies Record<string, IGridviewPanel>;
|
||||
};
|
||||
|
||||
export const ModelsTabAutoLayout = memo(() => {
|
||||
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
|
||||
initializeRootPanelLayout(api);
|
||||
navigationApi.onTabReady('models');
|
||||
initializeRootPanelLayout('models', api);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DockviewApi, GridviewApi } from 'dockview';
|
||||
import { DockviewPanel, GridviewPanel } from 'dockview';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
RIGHT_PANEL_ID,
|
||||
RIGHT_PANEL_MIN_SIZE_PX,
|
||||
SETTINGS_PANEL_ID,
|
||||
SWITCH_TABS_FAKE_DELAY_MS,
|
||||
WORKSPACE_PANEL_ID,
|
||||
} from './shared';
|
||||
|
||||
@@ -20,6 +22,7 @@ vi.mock('app/logging/logger', () => ({
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -28,8 +31,9 @@ vi.mock('dockview', async () => {
|
||||
|
||||
// Mock GridviewPanel class for instanceof checks
|
||||
class MockGridviewPanel {
|
||||
maximumWidth: number;
|
||||
minimumWidth: number;
|
||||
maximumWidth?: number;
|
||||
minimumWidth?: number;
|
||||
width?: number;
|
||||
api = {
|
||||
setActive: vi.fn(),
|
||||
setConstraints: vi.fn(),
|
||||
@@ -37,9 +41,10 @@ vi.mock('dockview', async () => {
|
||||
onDidDimensionsChange: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
};
|
||||
|
||||
constructor(config: { maximumWidth?: number; minimumWidth?: number } = {}) {
|
||||
this.maximumWidth = config.maximumWidth ?? Number.MAX_SAFE_INTEGER;
|
||||
this.minimumWidth = config.minimumWidth ?? 0;
|
||||
constructor(config: { maximumWidth?: number; minimumWidth?: number; width?: number } = {}) {
|
||||
this.maximumWidth = config.maximumWidth;
|
||||
this.minimumWidth = config.minimumWidth;
|
||||
this.width = config.width;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +66,7 @@ vi.mock('dockview', async () => {
|
||||
});
|
||||
|
||||
// Mock panel with setActive method
|
||||
const createMockPanel = (config: { maximumWidth?: number; minimumWidth?: number } = {}) => {
|
||||
const createMockPanel = (config: { maximumWidth?: number; minimumWidth?: number; width?: number } = {}) => {
|
||||
/* @ts-expect-error we are mocking GridviewPanel to be a concrete class */
|
||||
return new GridviewPanel(config);
|
||||
};
|
||||
@@ -75,27 +80,27 @@ describe('AppNavigationApi', () => {
|
||||
let navigationApi: NavigationApi;
|
||||
let mockSetAppTab: ReturnType<typeof vi.fn>;
|
||||
let mockGetAppTab: ReturnType<typeof vi.fn>;
|
||||
let mockSetPanelState: ReturnType<typeof vi.fn>;
|
||||
let mockGetPanelState: ReturnType<typeof vi.fn>;
|
||||
let mockDeletePanelState: ReturnType<typeof vi.fn>;
|
||||
let mockSetStorage: ReturnType<typeof vi.fn>;
|
||||
let mockGetStorage: ReturnType<typeof vi.fn>;
|
||||
let mockDeleteStorage: ReturnType<typeof vi.fn>;
|
||||
let mockAppApi: NavigationAppApi;
|
||||
|
||||
beforeEach(() => {
|
||||
navigationApi = new NavigationApi();
|
||||
mockSetAppTab = vi.fn();
|
||||
mockGetAppTab = vi.fn();
|
||||
mockSetPanelState = vi.fn();
|
||||
mockGetPanelState = vi.fn();
|
||||
mockDeletePanelState = vi.fn();
|
||||
mockSetStorage = vi.fn();
|
||||
mockGetStorage = vi.fn();
|
||||
mockDeleteStorage = vi.fn();
|
||||
mockAppApi = {
|
||||
activeTab: {
|
||||
set: mockSetAppTab,
|
||||
get: mockGetAppTab,
|
||||
},
|
||||
panelStorage: {
|
||||
set: mockSetPanelState,
|
||||
get: mockGetPanelState,
|
||||
delete: mockDeletePanelState,
|
||||
storage: {
|
||||
set: mockSetStorage,
|
||||
get: mockGetStorage,
|
||||
delete: mockDeleteStorage,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -115,9 +120,9 @@ describe('AppNavigationApi', () => {
|
||||
expect(navigationApi._app).not.toBeNull();
|
||||
expect(navigationApi._app?.activeTab.set).toBe(mockSetAppTab);
|
||||
expect(navigationApi._app?.activeTab.get).toBe(mockGetAppTab);
|
||||
expect(navigationApi._app?.panelStorage.set).toBe(mockSetPanelState);
|
||||
expect(navigationApi._app?.panelStorage.get).toBe(mockGetPanelState);
|
||||
expect(navigationApi._app?.panelStorage.delete).toBe(mockDeletePanelState);
|
||||
expect(navigationApi._app?.storage.set).toBe(mockSetStorage);
|
||||
expect(navigationApi._app?.storage.get).toBe(mockGetStorage);
|
||||
expect(navigationApi._app?.storage.delete).toBe(mockDeleteStorage);
|
||||
});
|
||||
|
||||
it('should disconnect from app', () => {
|
||||
@@ -128,10 +133,89 @@ describe('AppNavigationApi', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tab Switching', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should switch tabs', () => {
|
||||
navigationApi.connectToApp(mockAppApi);
|
||||
navigationApi.switchToTab('canvas');
|
||||
expect(mockSetAppTab).toHaveBeenCalledWith('canvas');
|
||||
});
|
||||
|
||||
it('should not set the tab if it is already on that tab', () => {
|
||||
navigationApi.connectToApp(mockAppApi);
|
||||
mockGetAppTab.mockReturnValue('canvas');
|
||||
|
||||
navigationApi.switchToTab('canvas');
|
||||
expect(mockSetAppTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set the $isLoading atom when switching', () => {
|
||||
navigationApi.connectToApp(mockAppApi);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
navigationApi.switchToTab('canvas');
|
||||
expect(navigationApi.$isLoading.get()).toBe(true);
|
||||
});
|
||||
|
||||
it('should unset the $isLoading atom after a fake delay', () => {
|
||||
navigationApi.connectToApp(mockAppApi);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
navigationApi.switchToTab('canvas');
|
||||
expect(navigationApi.$isLoading.get()).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(SWITCH_TABS_FAKE_DELAY_MS);
|
||||
expect(navigationApi.$isLoading.get()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle rapid tab changes', () => {
|
||||
navigationApi.connectToApp(mockAppApi);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
navigationApi.switchToTab('canvas');
|
||||
expect(navigationApi.$isLoading.get()).toBe(true);
|
||||
|
||||
navigationApi.switchToTab('generate');
|
||||
expect(navigationApi.$isLoading.get()).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(SWITCH_TABS_FAKE_DELAY_MS / 5);
|
||||
|
||||
navigationApi.switchToTab('canvas');
|
||||
expect(navigationApi.$isLoading.get()).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(SWITCH_TABS_FAKE_DELAY_MS / 5);
|
||||
|
||||
navigationApi.switchToTab('generate');
|
||||
expect(navigationApi.$isLoading.get()).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(SWITCH_TABS_FAKE_DELAY_MS / 5);
|
||||
|
||||
navigationApi.switchToTab('canvas');
|
||||
expect(navigationApi.$isLoading.get()).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(SWITCH_TABS_FAKE_DELAY_MS);
|
||||
|
||||
expect(navigationApi.$isLoading.get()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not switch tabs if the app is not connected', () => {
|
||||
navigationApi.switchToTab('canvas');
|
||||
expect(mockSetAppTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Panel Registration', () => {
|
||||
it('should register and unregister panels', () => {
|
||||
const mockPanel = createMockPanel();
|
||||
const unregister = navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
const unregister = navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
|
||||
expect(typeof unregister).toBe('function');
|
||||
expect(navigationApi.isPanelRegistered('generate', SETTINGS_PANEL_ID)).toBe(true);
|
||||
@@ -148,7 +232,7 @@ describe('AppNavigationApi', () => {
|
||||
const waitPromise = navigationApi.waitForPanel('generate', SETTINGS_PANEL_ID);
|
||||
|
||||
// Register the panel
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
|
||||
// Wait should resolve
|
||||
await expect(waitPromise).resolves.toBeUndefined();
|
||||
@@ -158,8 +242,8 @@ describe('AppNavigationApi', () => {
|
||||
const mockPanel1 = createMockPanel();
|
||||
const mockPanel2 = createMockDockPanel();
|
||||
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel1);
|
||||
navigationApi.registerPanel('generate', LAUNCHPAD_PANEL_ID, mockPanel2);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel1);
|
||||
navigationApi._registerPanel('generate', LAUNCHPAD_PANEL_ID, mockPanel2);
|
||||
|
||||
expect(navigationApi.isPanelRegistered('generate', SETTINGS_PANEL_ID)).toBe(true);
|
||||
expect(navigationApi.isPanelRegistered('generate', LAUNCHPAD_PANEL_ID)).toBe(true);
|
||||
@@ -173,8 +257,8 @@ describe('AppNavigationApi', () => {
|
||||
const mockPanel1 = createMockPanel();
|
||||
const mockPanel2 = createMockPanel();
|
||||
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel1);
|
||||
navigationApi.registerPanel('canvas', SETTINGS_PANEL_ID, mockPanel2);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel1);
|
||||
navigationApi._registerPanel('canvas', SETTINGS_PANEL_ID, mockPanel2);
|
||||
|
||||
expect(navigationApi.isPanelRegistered('generate', SETTINGS_PANEL_ID)).toBe(true);
|
||||
expect(navigationApi.isPanelRegistered('canvas', SETTINGS_PANEL_ID)).toBe(true);
|
||||
@@ -185,95 +269,6 @@ describe('AppNavigationApi', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Panel Storage', () => {
|
||||
beforeEach(() => {
|
||||
navigationApi.connectToApp(mockAppApi);
|
||||
});
|
||||
|
||||
it('stores initial gridview state when none exists', () => {
|
||||
const key = `generate:${LEFT_PANEL_ID}`;
|
||||
mockGetPanelState.mockReturnValue(undefined);
|
||||
|
||||
const panel = createMockPanel();
|
||||
// simulate real dimensions
|
||||
panel.api.height = 200;
|
||||
panel.api.width = 400;
|
||||
|
||||
navigationApi.registerPanel('generate', LEFT_PANEL_ID, panel);
|
||||
|
||||
expect(mockGetPanelState).toHaveBeenCalledWith(key);
|
||||
expect(mockSetPanelState).toHaveBeenCalledWith(key, {
|
||||
id: key,
|
||||
type: 'gridview-panel',
|
||||
dimensions: { height: 200, width: 400 },
|
||||
});
|
||||
});
|
||||
|
||||
it('restores gridview from stored state', () => {
|
||||
const key = `generate:${LEFT_PANEL_ID}`;
|
||||
const stored = { id: key, type: 'gridview-panel', dimensions: { height: 50, width: 75 } };
|
||||
mockGetPanelState.mockReturnValue(stored);
|
||||
|
||||
const panel = createMockPanel();
|
||||
navigationApi.registerPanel('generate', LEFT_PANEL_ID, panel);
|
||||
|
||||
expect(panel.api.setSize).toHaveBeenCalledWith({ height: 50, width: 75 });
|
||||
expect(mockDeletePanelState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('collapses gridview when stored dimensions are zero', () => {
|
||||
const key = `generate:${LEFT_PANEL_ID}`;
|
||||
const stored = { id: key, type: 'gridview-panel', dimensions: { height: 0, width: 0 } };
|
||||
mockGetPanelState.mockReturnValue(stored);
|
||||
|
||||
const panel = createMockPanel();
|
||||
navigationApi.registerPanel('generate', LEFT_PANEL_ID, panel);
|
||||
|
||||
expect(panel.api.setConstraints).toHaveBeenCalledWith({ minimumWidth: 0, maximumWidth: 0 });
|
||||
expect(panel.api.setConstraints).toHaveBeenCalledWith({ minimumHeight: 0, maximumHeight: 0 });
|
||||
expect(panel.api.setSize).toHaveBeenCalledWith({ height: 0, width: 0 });
|
||||
});
|
||||
|
||||
it('stores initial dockview state when none exists', () => {
|
||||
const key = `generate:${LAUNCHPAD_PANEL_ID}`;
|
||||
mockGetPanelState.mockReturnValue(undefined);
|
||||
|
||||
const panel = createMockDockPanel();
|
||||
Object.defineProperty(panel.api, 'isActive', { value: true });
|
||||
|
||||
navigationApi.registerPanel('generate', LAUNCHPAD_PANEL_ID, panel);
|
||||
|
||||
expect(mockGetPanelState).toHaveBeenCalledWith(key);
|
||||
expect(mockSetPanelState).toHaveBeenCalledWith(key, {
|
||||
id: key,
|
||||
type: 'dockview-panel',
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('restores dockview active state', () => {
|
||||
const key = `generate:${LAUNCHPAD_PANEL_ID}`;
|
||||
const stored = { id: key, type: 'dockview-panel', isActive: true };
|
||||
mockGetPanelState.mockReturnValue(stored);
|
||||
|
||||
const panel = createMockDockPanel();
|
||||
navigationApi.registerPanel('generate', LAUNCHPAD_PANEL_ID, panel);
|
||||
|
||||
expect(panel.api.setActive).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes mismatched dockview state', () => {
|
||||
const key = `generate:${LAUNCHPAD_PANEL_ID}`;
|
||||
const stored = { id: key, type: 'gridview-panel', dimensions: { height: 5, width: 5 } };
|
||||
mockGetPanelState.mockReturnValue(stored);
|
||||
|
||||
const panel = createMockDockPanel();
|
||||
navigationApi.registerPanel('generate', LAUNCHPAD_PANEL_ID, panel);
|
||||
|
||||
expect(mockDeletePanelState).toHaveBeenCalledWith(key);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Panel Focus', () => {
|
||||
beforeEach(() => {
|
||||
navigationApi.connectToApp(mockAppApi);
|
||||
@@ -281,7 +276,7 @@ describe('AppNavigationApi', () => {
|
||||
|
||||
it('should focus panel in already registered tab', async () => {
|
||||
const mockPanel = createMockPanel();
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = await navigationApi.focusPanel('generate', SETTINGS_PANEL_ID);
|
||||
@@ -293,7 +288,7 @@ describe('AppNavigationApi', () => {
|
||||
|
||||
it('should switch tab before focusing panel', async () => {
|
||||
const mockPanel = createMockPanel();
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
mockGetAppTab.mockReturnValue('canvas'); // Currently on different tab
|
||||
|
||||
const result = await navigationApi.focusPanel('generate', SETTINGS_PANEL_ID);
|
||||
@@ -312,7 +307,7 @@ describe('AppNavigationApi', () => {
|
||||
|
||||
// Register panel after a short delay
|
||||
setTimeout(() => {
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
}, 100);
|
||||
|
||||
const result = await focusPromise;
|
||||
@@ -325,8 +320,8 @@ describe('AppNavigationApi', () => {
|
||||
const mockGridPanel = createMockPanel();
|
||||
const mockDockPanel = createMockDockPanel();
|
||||
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockGridPanel);
|
||||
navigationApi.registerPanel('generate', LAUNCHPAD_PANEL_ID, mockDockPanel);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockGridPanel);
|
||||
navigationApi._registerPanel('generate', LAUNCHPAD_PANEL_ID, mockDockPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
// Test gridview panel
|
||||
@@ -357,7 +352,7 @@ describe('AppNavigationApi', () => {
|
||||
throw new Error('Mock error');
|
||||
});
|
||||
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = await navigationApi.focusPanel('generate', SETTINGS_PANEL_ID);
|
||||
@@ -367,7 +362,7 @@ describe('AppNavigationApi', () => {
|
||||
|
||||
it('should work without app connection', async () => {
|
||||
const mockPanel = createMockPanel();
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
|
||||
// Don't connect to app
|
||||
const result = await navigationApi.focusPanel('generate', SETTINGS_PANEL_ID);
|
||||
@@ -380,7 +375,7 @@ describe('AppNavigationApi', () => {
|
||||
describe('Panel Waiting', () => {
|
||||
it('should resolve immediately for already registered panels', async () => {
|
||||
const mockPanel = createMockPanel();
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
|
||||
const waitPromise = navigationApi.waitForPanel('generate', SETTINGS_PANEL_ID);
|
||||
|
||||
@@ -394,7 +389,7 @@ describe('AppNavigationApi', () => {
|
||||
const waitPromise2 = navigationApi.waitForPanel('generate', SETTINGS_PANEL_ID);
|
||||
|
||||
setTimeout(() => {
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
}, 50);
|
||||
|
||||
await expect(Promise.all([waitPromise1, waitPromise2])).resolves.toEqual([undefined, undefined]);
|
||||
@@ -403,14 +398,14 @@ describe('AppNavigationApi', () => {
|
||||
it('should timeout if panel is not registered', async () => {
|
||||
const waitPromise = navigationApi.waitForPanel('generate', SETTINGS_PANEL_ID, 100);
|
||||
|
||||
await expect(waitPromise).rejects.toThrow('Panel generate:settings registration timed out after 100ms');
|
||||
await expect(waitPromise).rejects.toThrow(/Panel .* registration timed out after 100ms/);
|
||||
});
|
||||
|
||||
it('should handle custom timeout', async () => {
|
||||
const start = Date.now();
|
||||
const waitPromise = navigationApi.waitForPanel('generate', SETTINGS_PANEL_ID, 200);
|
||||
|
||||
await expect(waitPromise).rejects.toThrow('Panel generate:settings registration timed out after 200ms');
|
||||
await expect(waitPromise).rejects.toThrow(/Panel .* registration timed out after 200ms/);
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
// TODO(psyche): Use vitest's fake timeres
|
||||
@@ -426,9 +421,9 @@ describe('AppNavigationApi', () => {
|
||||
const mockPanel2 = createMockPanel();
|
||||
const mockPanel3 = createMockPanel();
|
||||
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel1);
|
||||
navigationApi.registerPanel('generate', LAUNCHPAD_PANEL_ID, mockPanel2);
|
||||
navigationApi.registerPanel('canvas', SETTINGS_PANEL_ID, mockPanel3);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel1);
|
||||
navigationApi._registerPanel('generate', LAUNCHPAD_PANEL_ID, mockPanel2);
|
||||
navigationApi._registerPanel('canvas', SETTINGS_PANEL_ID, mockPanel3);
|
||||
|
||||
expect(navigationApi.getRegisteredPanels('generate')).toHaveLength(2);
|
||||
expect(navigationApi.getRegisteredPanels('canvas')).toHaveLength(1);
|
||||
@@ -458,7 +453,7 @@ describe('AppNavigationApi', () => {
|
||||
mockGetAppTab.mockReturnValue('canvas');
|
||||
|
||||
// Register panel
|
||||
const unregister = navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
const unregister = navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
|
||||
// Focus panel (should switch tab and focus)
|
||||
const result = await navigationApi.focusPanel('generate', SETTINGS_PANEL_ID);
|
||||
@@ -484,9 +479,9 @@ describe('AppNavigationApi', () => {
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
// Register panels
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel1);
|
||||
navigationApi.registerPanel('generate', LAUNCHPAD_PANEL_ID, mockPanel2);
|
||||
navigationApi.registerPanel('canvas', WORKSPACE_PANEL_ID, mockPanel3);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel1);
|
||||
navigationApi._registerPanel('generate', LAUNCHPAD_PANEL_ID, mockPanel2);
|
||||
navigationApi._registerPanel('canvas', WORKSPACE_PANEL_ID, mockPanel3);
|
||||
|
||||
// Focus panels
|
||||
await navigationApi.focusPanel('generate', SETTINGS_PANEL_ID);
|
||||
@@ -510,7 +505,7 @@ describe('AppNavigationApi', () => {
|
||||
|
||||
// Register after delay
|
||||
setTimeout(() => {
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
}, 50);
|
||||
|
||||
const result = await focusPromise;
|
||||
@@ -527,7 +522,7 @@ describe('AppNavigationApi', () => {
|
||||
|
||||
it('should focus panel in active tab', async () => {
|
||||
const mockPanel = createMockPanel();
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = await navigationApi.focusPanelInActiveTab(SETTINGS_PANEL_ID);
|
||||
@@ -583,7 +578,7 @@ describe('AppNavigationApi', () => {
|
||||
describe('getPanel', () => {
|
||||
it('should return registered panel', () => {
|
||||
const mockPanel = createMockPanel();
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPanel);
|
||||
|
||||
const result = navigationApi.getPanel('generate', SETTINGS_PANEL_ID);
|
||||
|
||||
@@ -609,8 +604,8 @@ describe('AppNavigationApi', () => {
|
||||
});
|
||||
|
||||
it('should expand collapsed left panel', () => {
|
||||
const mockPanel = createMockPanel({ maximumWidth: 0 });
|
||||
navigationApi.registerPanel('generate', LEFT_PANEL_ID, mockPanel);
|
||||
const mockPanel = createMockPanel({ width: 0 });
|
||||
navigationApi._registerPanel('generate', LEFT_PANEL_ID, mockPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = navigationApi.toggleLeftPanel();
|
||||
@@ -625,7 +620,7 @@ describe('AppNavigationApi', () => {
|
||||
|
||||
it('should collapse expanded left panel', () => {
|
||||
const mockPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER });
|
||||
navigationApi.registerPanel('generate', LEFT_PANEL_ID, mockPanel);
|
||||
navigationApi._registerPanel('generate', LEFT_PANEL_ID, mockPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = navigationApi.toggleLeftPanel();
|
||||
@@ -656,7 +651,7 @@ describe('AppNavigationApi', () => {
|
||||
|
||||
it('should return false when panel is not GridviewPanel', () => {
|
||||
const mockPanel = createMockDockPanel();
|
||||
navigationApi.registerPanel('generate', LEFT_PANEL_ID, mockPanel);
|
||||
navigationApi._registerPanel('generate', LEFT_PANEL_ID, mockPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = navigationApi.toggleLeftPanel();
|
||||
@@ -671,8 +666,8 @@ describe('AppNavigationApi', () => {
|
||||
});
|
||||
|
||||
it('should expand collapsed right panel', () => {
|
||||
const mockPanel = createMockPanel({ maximumWidth: 0 });
|
||||
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, mockPanel);
|
||||
const mockPanel = createMockPanel({ width: 0 });
|
||||
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, mockPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = navigationApi.toggleRightPanel();
|
||||
@@ -687,7 +682,7 @@ describe('AppNavigationApi', () => {
|
||||
|
||||
it('should collapse expanded right panel', () => {
|
||||
const mockPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER });
|
||||
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, mockPanel);
|
||||
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, mockPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = navigationApi.toggleRightPanel();
|
||||
@@ -718,7 +713,7 @@ describe('AppNavigationApi', () => {
|
||||
|
||||
it('should return false when panel is not GridviewPanel', () => {
|
||||
const mockPanel = createMockDockPanel();
|
||||
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, mockPanel);
|
||||
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, mockPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = navigationApi.toggleRightPanel();
|
||||
@@ -733,11 +728,11 @@ describe('AppNavigationApi', () => {
|
||||
});
|
||||
|
||||
it('should expand both panels when left is collapsed', () => {
|
||||
const leftPanel = createMockPanel({ maximumWidth: 0 });
|
||||
const leftPanel = createMockPanel({ width: 0 });
|
||||
const rightPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER });
|
||||
|
||||
navigationApi.registerPanel('generate', LEFT_PANEL_ID, leftPanel);
|
||||
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
|
||||
navigationApi._registerPanel('generate', LEFT_PANEL_ID, leftPanel);
|
||||
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = navigationApi.toggleLeftAndRightPanels();
|
||||
@@ -757,10 +752,10 @@ describe('AppNavigationApi', () => {
|
||||
|
||||
it('should expand both panels when right is collapsed', () => {
|
||||
const leftPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER });
|
||||
const rightPanel = createMockPanel({ maximumWidth: 0 });
|
||||
const rightPanel = createMockPanel({ width: 0 });
|
||||
|
||||
navigationApi.registerPanel('generate', LEFT_PANEL_ID, leftPanel);
|
||||
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
|
||||
navigationApi._registerPanel('generate', LEFT_PANEL_ID, leftPanel);
|
||||
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = navigationApi.toggleLeftAndRightPanels();
|
||||
@@ -782,8 +777,8 @@ describe('AppNavigationApi', () => {
|
||||
const leftPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER });
|
||||
const rightPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER });
|
||||
|
||||
navigationApi.registerPanel('generate', LEFT_PANEL_ID, leftPanel);
|
||||
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
|
||||
navigationApi._registerPanel('generate', LEFT_PANEL_ID, leftPanel);
|
||||
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = navigationApi.toggleLeftAndRightPanels();
|
||||
@@ -802,11 +797,11 @@ describe('AppNavigationApi', () => {
|
||||
});
|
||||
|
||||
it('should expand both panels when both are collapsed', () => {
|
||||
const leftPanel = createMockPanel({ maximumWidth: 0 });
|
||||
const rightPanel = createMockPanel({ maximumWidth: 0 });
|
||||
const leftPanel = createMockPanel({ width: 0 });
|
||||
const rightPanel = createMockPanel({ width: 0 });
|
||||
|
||||
navigationApi.registerPanel('generate', LEFT_PANEL_ID, leftPanel);
|
||||
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
|
||||
navigationApi._registerPanel('generate', LEFT_PANEL_ID, leftPanel);
|
||||
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = navigationApi.toggleLeftAndRightPanels();
|
||||
@@ -844,8 +839,8 @@ describe('AppNavigationApi', () => {
|
||||
const leftPanel = createMockDockPanel();
|
||||
const rightPanel = createMockDockPanel();
|
||||
|
||||
navigationApi.registerPanel('generate', LEFT_PANEL_ID, leftPanel);
|
||||
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
|
||||
navigationApi._registerPanel('generate', LEFT_PANEL_ID, leftPanel);
|
||||
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = navigationApi.toggleLeftAndRightPanels();
|
||||
@@ -860,11 +855,11 @@ describe('AppNavigationApi', () => {
|
||||
});
|
||||
|
||||
it('should reset both panels to expanded state', () => {
|
||||
const leftPanel = createMockPanel({ maximumWidth: 0 });
|
||||
const rightPanel = createMockPanel({ maximumWidth: 0 });
|
||||
const leftPanel = createMockPanel({ width: 0 });
|
||||
const rightPanel = createMockPanel({ width: 0 });
|
||||
|
||||
navigationApi.registerPanel('generate', LEFT_PANEL_ID, leftPanel);
|
||||
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
|
||||
navigationApi._registerPanel('generate', LEFT_PANEL_ID, leftPanel);
|
||||
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = navigationApi.resetLeftAndRightPanels();
|
||||
@@ -905,8 +900,8 @@ describe('AppNavigationApi', () => {
|
||||
const leftPanel = createMockDockPanel();
|
||||
const rightPanel = createMockDockPanel();
|
||||
|
||||
navigationApi.registerPanel('generate', LEFT_PANEL_ID, leftPanel);
|
||||
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
|
||||
navigationApi._registerPanel('generate', LEFT_PANEL_ID, leftPanel);
|
||||
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = navigationApi.resetLeftAndRightPanels();
|
||||
@@ -926,9 +921,9 @@ describe('AppNavigationApi', () => {
|
||||
const settingsPanel = createMockPanel();
|
||||
|
||||
// Register panels
|
||||
navigationApi.registerPanel('generate', LEFT_PANEL_ID, leftPanel);
|
||||
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
|
||||
navigationApi.registerPanel('generate', SETTINGS_PANEL_ID, settingsPanel);
|
||||
navigationApi._registerPanel('generate', LEFT_PANEL_ID, leftPanel);
|
||||
navigationApi._registerPanel('generate', RIGHT_PANEL_ID, rightPanel);
|
||||
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, settingsPanel);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
// Focus a panel in active tab
|
||||
@@ -955,10 +950,10 @@ describe('AppNavigationApi', () => {
|
||||
|
||||
it('should handle tab switching with panel operations', () => {
|
||||
const generateLeftPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER });
|
||||
const canvasLeftPanel = createMockPanel({ maximumWidth: 0 });
|
||||
const canvasLeftPanel = createMockPanel({ width: 0 });
|
||||
|
||||
navigationApi.registerPanel('generate', LEFT_PANEL_ID, generateLeftPanel);
|
||||
navigationApi.registerPanel('canvas', LEFT_PANEL_ID, canvasLeftPanel);
|
||||
navigationApi._registerPanel('generate', LEFT_PANEL_ID, generateLeftPanel);
|
||||
navigationApi._registerPanel('canvas', LEFT_PANEL_ID, canvasLeftPanel);
|
||||
|
||||
// Start on generate tab
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
@@ -992,4 +987,122 @@ describe('AppNavigationApi', () => {
|
||||
expect(focusResult).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerContainer', () => {
|
||||
const tab = 'generate';
|
||||
const viewId = 'myView';
|
||||
const key = `${tab}:container:${viewId}`;
|
||||
|
||||
beforeEach(() => {
|
||||
navigationApi = new NavigationApi();
|
||||
navigationApi.connectToApp(mockAppApi);
|
||||
});
|
||||
|
||||
it('initializes from scratch when no stored state', () => {
|
||||
mockGetStorage.mockReturnValue(undefined);
|
||||
const initialize = vi.fn();
|
||||
const panel1 = { id: 'p1' };
|
||||
const panel2 = { id: 'p2' };
|
||||
const mockApi = {
|
||||
panels: [panel1, panel2],
|
||||
toJSON: vi.fn(() => ({ foo: 'bar' })),
|
||||
onDidLayoutChange: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
} as unknown as DockviewApi | GridviewApi;
|
||||
navigationApi.registerContainer(tab, viewId, mockApi, initialize);
|
||||
|
||||
expect(initialize).toHaveBeenCalledOnce();
|
||||
expect(mockSetStorage).toHaveBeenCalledOnce();
|
||||
expect(mockSetStorage).toHaveBeenCalledWith(key, { foo: 'bar' });
|
||||
// panels registered
|
||||
expect(navigationApi.isPanelRegistered(tab, 'p1')).toBe(true);
|
||||
expect(navigationApi.isPanelRegistered(tab, 'p2')).toBe(true);
|
||||
});
|
||||
|
||||
it('restores from storage when fromJSON succeeds', () => {
|
||||
const stored = { saved: true };
|
||||
mockGetStorage.mockReturnValue(stored);
|
||||
const initialize = vi.fn();
|
||||
const panel = { id: 'p' };
|
||||
const mockApi = {
|
||||
panels: [panel],
|
||||
fromJSON: vi.fn(),
|
||||
toJSON: vi.fn(),
|
||||
onDidLayoutChange: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
} as unknown as DockviewApi | GridviewApi;
|
||||
navigationApi.registerContainer(tab, viewId, mockApi, initialize);
|
||||
|
||||
expect(mockApi.fromJSON).toHaveBeenCalledWith(stored);
|
||||
expect(initialize).not.toHaveBeenCalled();
|
||||
expect(mockSetStorage).not.toHaveBeenCalled(); // no initial persist
|
||||
expect(navigationApi.isPanelRegistered(tab, 'p')).toBe(true);
|
||||
});
|
||||
|
||||
it('re-initializes when fromJSON throws, deletes then sets', () => {
|
||||
const stored = { saved: true };
|
||||
mockGetStorage.mockReturnValue(stored);
|
||||
const initialize = vi.fn();
|
||||
const panel = { id: 'p' };
|
||||
const mockApi = {
|
||||
panels: [panel],
|
||||
fromJSON: vi.fn(() => {
|
||||
throw new Error('bad');
|
||||
}),
|
||||
toJSON: vi.fn(() => ({ new: 'state' })),
|
||||
onDidLayoutChange: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
} as unknown as DockviewApi | GridviewApi;
|
||||
navigationApi.registerContainer(tab, viewId, mockApi, initialize);
|
||||
|
||||
expect(mockApi.fromJSON).toHaveBeenCalledWith(stored);
|
||||
expect(mockDeleteStorage).toHaveBeenCalledOnce();
|
||||
expect(mockDeleteStorage).toHaveBeenCalledWith(key);
|
||||
expect(initialize).toHaveBeenCalledOnce();
|
||||
expect(mockSetStorage).toHaveBeenCalledOnce();
|
||||
expect(mockSetStorage).toHaveBeenCalledWith(key, { new: 'state' });
|
||||
expect(navigationApi.isPanelRegistered(tab, 'p')).toBe(true);
|
||||
});
|
||||
|
||||
it('persists on layout change after debounce', () => {
|
||||
vi.useFakeTimers();
|
||||
mockGetStorage.mockReturnValue(undefined);
|
||||
const initialize = vi.fn();
|
||||
const panel = { id: 'p' };
|
||||
let layoutCb: () => void = () => {};
|
||||
const mockApi = {
|
||||
panels: [panel],
|
||||
toJSON: vi.fn(() => ({ x: 1 })),
|
||||
onDidLayoutChange: vi.fn((cb) => {
|
||||
layoutCb = cb;
|
||||
return { dispose: vi.fn() };
|
||||
}),
|
||||
} as unknown as DockviewApi | GridviewApi;
|
||||
navigationApi.registerContainer(tab, viewId, mockApi, initialize);
|
||||
|
||||
// first set: initial persistence
|
||||
expect(mockSetStorage).toHaveBeenCalledWith(key, { x: 1 });
|
||||
|
||||
// simulate layout change
|
||||
layoutCb();
|
||||
// advance past debounce (300ms)
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(mockSetStorage).toHaveBeenCalledTimes(2);
|
||||
expect(mockSetStorage).toHaveBeenLastCalledWith(key, { x: 1 });
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does nothing if app not connected', () => {
|
||||
navigationApi.disconnectFromApp();
|
||||
const initialize = vi.fn();
|
||||
const mockApi = {
|
||||
panels: [],
|
||||
fromJSON: vi.fn(),
|
||||
toJSON: vi.fn(),
|
||||
onDidLayoutChange: vi.fn(),
|
||||
} as unknown as DockviewApi | GridviewApi;
|
||||
expect(() => navigationApi.registerContainer(tab, viewId, mockApi, initialize)).not.toThrow();
|
||||
expect(mockGetStorage).not.toHaveBeenCalled();
|
||||
expect(initialize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { createDeferredPromise, type Deferred } from 'common/util/createDeferredPromise';
|
||||
import { DockviewPanel, GridviewPanel, type IDockviewPanel, type IGridviewPanel } from 'dockview';
|
||||
import { parseify } from 'common/util/serialize';
|
||||
import type { DockviewApi, GridviewApi, IDockviewPanel, IGridviewPanel } from 'dockview';
|
||||
import { GridviewPanel } from 'dockview';
|
||||
import { debounce } from 'es-toolkit';
|
||||
import type { StoredDockviewPanelState, StoredGridviewPanelState, TabName } from 'features/ui/store/uiTypes';
|
||||
import type { Serializable, TabName } from 'features/ui/store/uiTypes';
|
||||
import type { Atom } from 'nanostores';
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
import {
|
||||
@@ -27,14 +30,23 @@ type Waiter = {
|
||||
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The API exposed by the application to manage navigation and panel states.
|
||||
*/
|
||||
export type NavigationAppApi = {
|
||||
/**
|
||||
* API to manage the currently active tab in the application.
|
||||
*/
|
||||
activeTab: {
|
||||
get: () => TabName;
|
||||
set: (tab: TabName) => void;
|
||||
};
|
||||
panelStorage: {
|
||||
get: (id: string) => StoredDockviewPanelState | StoredGridviewPanelState | undefined;
|
||||
set: (id: string, state: StoredDockviewPanelState | StoredGridviewPanelState) => void;
|
||||
/**
|
||||
* API to manage the storage of panel states.
|
||||
*/
|
||||
storage: {
|
||||
get: (id: string) => Serializable | undefined;
|
||||
set: (id: string, state: Serializable) => void;
|
||||
delete: (id: string) => void;
|
||||
};
|
||||
};
|
||||
@@ -54,20 +66,17 @@ export class NavigationApi {
|
||||
/**
|
||||
* A flag indicating if the application is currently switching tabs, which can take some time.
|
||||
*/
|
||||
$isSwitchingTabs = atom(false);
|
||||
/**
|
||||
* The timeout used to add a short additional delay when switching tabs.
|
||||
*
|
||||
* The time it takes to switch tabs varies depending on the tab, and sometimes it is very fast, resulting in a flicker
|
||||
* of the loading screen. This timeout is used to artificially extend the time the loading screen is shown.
|
||||
*/
|
||||
switchingTabsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private _$isLoading = atom(false);
|
||||
$isLoading: Atom<boolean> = this._$isLoading;
|
||||
|
||||
/**
|
||||
* Separator used to create unique keys for panels. Typo protection.
|
||||
*/
|
||||
KEY_SEPARATOR = ':';
|
||||
|
||||
/**
|
||||
* The application API that provides methods to set and get the current app tab and manage panel storage.
|
||||
*/
|
||||
_app: NavigationAppApi | null = null;
|
||||
|
||||
/**
|
||||
@@ -86,154 +95,46 @@ export class NavigationApi {
|
||||
this._app = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the flag indicating that the navigation is loading and schedules a debounced hide of the loading screen.
|
||||
*/
|
||||
_showFakeLoadingScreen = () => {
|
||||
log.trace('Showing fake loading screen for tab switch');
|
||||
this._$isLoading.set(true);
|
||||
this._hideLoadingScreenDebounced();
|
||||
};
|
||||
|
||||
/**
|
||||
* Debounced function to hide the loading screen after a delay.
|
||||
*/
|
||||
_hideLoadingScreenDebounced = debounce(() => {
|
||||
log.trace('Hiding fake loading screen for tab switch');
|
||||
this._$isLoading.set(false);
|
||||
}, SWITCH_TABS_FAKE_DELAY_MS);
|
||||
|
||||
/**
|
||||
* Switch to a specific app tab.
|
||||
*
|
||||
* The loading screen will be shown while the tab is switching.
|
||||
* The loading screen will be shown while the tab is switching (and for a little while longer to smooth out the UX).
|
||||
*
|
||||
* @param tab - The tab to switch to
|
||||
* @return True if the switch was successful, false otherwise
|
||||
*/
|
||||
switchToTab = (tab: TabName): boolean => {
|
||||
if (this.switchingTabsTimeout !== null) {
|
||||
clearTimeout(this.switchingTabsTimeout);
|
||||
this.switchingTabsTimeout = null;
|
||||
}
|
||||
if (tab === this._app?.activeTab.get?.()) {
|
||||
return true;
|
||||
}
|
||||
this.$isSwitchingTabs.set(true);
|
||||
log.debug(`Switching to tab: ${tab}`);
|
||||
if (this._app) {
|
||||
this._app.activeTab.set(tab);
|
||||
return true;
|
||||
} else {
|
||||
log.error('No setAppTab function available to switch tabs');
|
||||
if (!this._app) {
|
||||
log.error('No app connected to switch tabs');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback for when a tab is ready after switching.
|
||||
*
|
||||
* Hides the loading screen after a short delay.
|
||||
*/
|
||||
onTabReady = (tab: TabName): void => {
|
||||
this.switchingTabsTimeout = setTimeout(() => {
|
||||
this.$isSwitchingTabs.set(false);
|
||||
log.debug(`Tab ${tab} ready`);
|
||||
}, SWITCH_TABS_FAKE_DELAY_MS);
|
||||
};
|
||||
|
||||
_initGridviewPanelStorage = (key: string, panel: IGridviewPanel) => {
|
||||
if (!this._app) {
|
||||
log.error('App not connected');
|
||||
return;
|
||||
}
|
||||
const storedState = this._app.panelStorage.get(key);
|
||||
if (!storedState) {
|
||||
log.debug('No stored state for panel, setting initial state');
|
||||
const { height, width } = panel.api;
|
||||
this._app.panelStorage.set(key, {
|
||||
id: key,
|
||||
type: 'gridview-panel',
|
||||
dimensions: { height, width },
|
||||
});
|
||||
} else {
|
||||
if (storedState.type !== 'gridview-panel') {
|
||||
log.error(`Panel ${key} type mismatch: expected gridview-panel, got ${storedState.type}`);
|
||||
this._app.panelStorage.delete(key);
|
||||
return;
|
||||
}
|
||||
log.debug({ storedState }, 'Found stored state for panel, restoring');
|
||||
|
||||
// If the panel's dimensions are 0, we assume it was collapsed by the user. But when panels are initialzed,
|
||||
// by default they may have a minimize dimension greater than 0. If we attempt to set a size of 0, it will
|
||||
// not work - dockview will instead set the size to the minimum size.
|
||||
//
|
||||
// The user-facing issue is that the panel will not remember if it was collapsed or not, and will always
|
||||
// be expanded when navigating to the tab.
|
||||
//
|
||||
// To fix this, if we find a stored state with dimensions of 0, we set the constraints to 0 before setting the
|
||||
// size.
|
||||
if (storedState.dimensions.width === 0) {
|
||||
panel.api.setConstraints({ minimumWidth: 0, maximumWidth: 0 });
|
||||
}
|
||||
|
||||
if (storedState.dimensions.height === 0) {
|
||||
panel.api.setConstraints({ minimumHeight: 0, maximumHeight: 0 });
|
||||
}
|
||||
|
||||
panel.api.setSize(storedState.dimensions);
|
||||
}
|
||||
const { dispose } = panel.api.onDidDimensionsChange(
|
||||
debounce(({ width, height }) => {
|
||||
log.debug({ key, width, height }, 'Panel dimensions changed');
|
||||
if (!this._app) {
|
||||
log.error('App not connected');
|
||||
return;
|
||||
}
|
||||
this._app.panelStorage.set(key, {
|
||||
id: key,
|
||||
type: 'gridview-panel',
|
||||
dimensions: { width, height },
|
||||
});
|
||||
}, 1000)
|
||||
);
|
||||
|
||||
return dispose;
|
||||
};
|
||||
|
||||
_initDockviewPanelStorage = (key: string, panel: IDockviewPanel) => {
|
||||
if (!this._app) {
|
||||
log.error('App not connected');
|
||||
return;
|
||||
}
|
||||
const storedState = this._app.panelStorage.get(key);
|
||||
if (!storedState) {
|
||||
const { isActive } = panel.api;
|
||||
this._app.panelStorage.set(key, {
|
||||
id: key,
|
||||
type: 'dockview-panel',
|
||||
isActive,
|
||||
});
|
||||
} else {
|
||||
if (storedState.type !== 'dockview-panel') {
|
||||
log.error(`Panel ${key} type mismatch: expected dockview-panel, got ${storedState.type}`);
|
||||
this._app.panelStorage.delete(key);
|
||||
return;
|
||||
}
|
||||
if (storedState.isActive) {
|
||||
panel.api.setActive();
|
||||
}
|
||||
if (tab === this._app.activeTab.get()) {
|
||||
log.trace(`Already on tab: ${tab}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const { dispose } = panel.api.onDidActiveChange(
|
||||
debounce(({ isActive }) => {
|
||||
if (!this._app) {
|
||||
log.error('App not connected');
|
||||
return;
|
||||
}
|
||||
this._app.panelStorage.set(key, {
|
||||
id: key,
|
||||
type: 'dockview-panel',
|
||||
isActive,
|
||||
});
|
||||
}, 1000)
|
||||
);
|
||||
|
||||
return dispose;
|
||||
};
|
||||
|
||||
_initPanelStorage = (key: string, panel: PanelType) => {
|
||||
if (panel instanceof GridviewPanel) {
|
||||
return this._initGridviewPanelStorage(key, panel);
|
||||
} else if (panel instanceof DockviewPanel) {
|
||||
return this._initDockviewPanelStorage(key, panel);
|
||||
} else {
|
||||
log.error(`Unsupported panel type: ${panel.constructor.name}`);
|
||||
return;
|
||||
}
|
||||
log.trace(`Switching to tab: ${tab}`);
|
||||
this._showFakeLoadingScreen();
|
||||
this._app.activeTab.set(tab);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -244,13 +145,11 @@ export class NavigationApi {
|
||||
* @param panel - The panel instance
|
||||
* @returns Cleanup function to unregister the panel
|
||||
*/
|
||||
registerPanel = (tab: TabName, panelId: string, panel: PanelType): (() => void) => {
|
||||
_registerPanel = <T extends PanelType>(tab: TabName, panelId: string, panel: T): (() => void) => {
|
||||
const key = this._getPanelKey(tab, panelId);
|
||||
|
||||
this.panels.set(key, panel);
|
||||
|
||||
const cleanupPanelStorage = this._initPanelStorage(key, panel);
|
||||
|
||||
// Resolve any pending waiters for this panel, notifying them that the panel is now registered.
|
||||
const waiter = this.waiters.get(key);
|
||||
if (waiter) {
|
||||
@@ -261,15 +160,64 @@ export class NavigationApi {
|
||||
this.waiters.delete(key);
|
||||
}
|
||||
|
||||
log.debug(`Registered panel ${key}`);
|
||||
log.trace(`Registered panel ${key}`);
|
||||
|
||||
return () => {
|
||||
cleanupPanelStorage?.();
|
||||
this.panels.delete(key);
|
||||
log.debug(`Unregistered panel ${key}`);
|
||||
log.trace(`Unregistered panel ${key}`);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers a container (Dockview or Gridview) with the navigation API.
|
||||
*
|
||||
* This method initializes the container from storage if available, or calls the provided initialize function
|
||||
* to set it up from scratch.
|
||||
*
|
||||
* @param tab - The tab this container belongs to
|
||||
* @param id - Unique identifier for the container
|
||||
* @param api - The DockviewApi or GridviewApi instance
|
||||
* @param initialize - Function to call if the container needs to be initialized from scratch
|
||||
*/
|
||||
registerContainer = (tab: TabName, id: string, api: DockviewApi | GridviewApi, initialize: () => void) => {
|
||||
if (!this._app) {
|
||||
log.error('App not connected to register view');
|
||||
return;
|
||||
}
|
||||
|
||||
const key = this._getContainerKey(tab, id);
|
||||
|
||||
const stored = this._app.storage.get(key);
|
||||
if (stored) {
|
||||
try {
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
api.fromJSON(stored as any);
|
||||
log.trace({ stored: parseify(stored) }, `Restored view ${key} from storage`);
|
||||
} catch (error) {
|
||||
log.error({ error: parseify(error) }, `Failed to restore view ${key} from storage`);
|
||||
this._app.storage.delete(key);
|
||||
initialize();
|
||||
this._app.storage.set(key, api.toJSON());
|
||||
}
|
||||
} else {
|
||||
initialize();
|
||||
log.trace(`Initialized ${key} from scratch`);
|
||||
this._app.storage.set(key, api.toJSON());
|
||||
}
|
||||
|
||||
for (const panel of api.panels) {
|
||||
this._registerPanel(tab, panel.id, panel);
|
||||
}
|
||||
|
||||
api.onDidLayoutChange(
|
||||
debounce(() => {
|
||||
this._app?.storage.set(key, api.toJSON());
|
||||
}, 300)
|
||||
);
|
||||
|
||||
log.trace(`Registered view ${key}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Waits for a panel to be ready.
|
||||
*
|
||||
@@ -319,17 +267,38 @@ export class NavigationApi {
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the prefix for a tab to create unique keys for panels.
|
||||
* Get the prefix for a tab to create unique keys for panels/containers.
|
||||
*/
|
||||
_getTabPrefix = (tab: TabName): string => {
|
||||
return `${tab}${this.KEY_SEPARATOR}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a prefix for a panel based on its tab.
|
||||
*/
|
||||
_getPanelPrefix = (tab: TabName): string => {
|
||||
return `${this._getTabPrefix(tab)}panel${this.KEY_SEPARATOR}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the unique key for a panel based on its tab and ID.
|
||||
*/
|
||||
_getPanelKey = (tab: TabName, panelId: string): string => {
|
||||
return `${this._getTabPrefix(tab)}${panelId}`;
|
||||
return `${this._getPanelPrefix(tab)}${panelId}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a prefix for a container based on its tab.
|
||||
*/
|
||||
_getContainerPrefix = (tab: TabName): string => {
|
||||
return `${this._getTabPrefix(tab)}container${this.KEY_SEPARATOR}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the unique key for a container based on its tab and ID.
|
||||
*/
|
||||
_getContainerKey = (tab: TabName, viewId: string): string => {
|
||||
return `${this._getContainerPrefix(tab)}${viewId}`;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -368,7 +337,7 @@ export class NavigationApi {
|
||||
|
||||
// Dockview uses the term "active", but we use "focused" for consistency.
|
||||
panel.api.setActive();
|
||||
log.debug(`Focused panel ${key}`);
|
||||
log.trace(`Focused panel ${key}`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -458,7 +427,7 @@ export class NavigationApi {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isCollapsed = leftPanel.maximumWidth === 0;
|
||||
const isCollapsed = leftPanel.width === 0;
|
||||
if (isCollapsed) {
|
||||
this._expandPanel(leftPanel, LEFT_PANEL_MIN_SIZE_PX);
|
||||
} else {
|
||||
@@ -491,7 +460,7 @@ export class NavigationApi {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isCollapsed = rightPanel.maximumWidth === 0;
|
||||
const isCollapsed = rightPanel.width === 0;
|
||||
if (isCollapsed) {
|
||||
this._expandPanel(rightPanel, RIGHT_PANEL_MIN_SIZE_PX);
|
||||
} else {
|
||||
@@ -527,8 +496,8 @@ export class NavigationApi {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isLeftCollapsed = leftPanel.maximumWidth === 0;
|
||||
const isRightCollapsed = rightPanel.maximumWidth === 0;
|
||||
const isLeftCollapsed = leftPanel.width === 0;
|
||||
const isRightCollapsed = rightPanel.width === 0;
|
||||
|
||||
if (isLeftCollapsed || isRightCollapsed) {
|
||||
this._expandPanel(leftPanel, LEFT_PANEL_MIN_SIZE_PX);
|
||||
@@ -593,7 +562,7 @@ export class NavigationApi {
|
||||
* @returns Array of panel IDs
|
||||
*/
|
||||
getRegisteredPanels = (tab: TabName): string[] => {
|
||||
const prefix = this._getTabPrefix(tab);
|
||||
const prefix = this._getPanelPrefix(tab);
|
||||
return Array.from(this.panels.keys())
|
||||
.filter((key) => key.startsWith(prefix))
|
||||
.map((key) => key.substring(prefix.length));
|
||||
@@ -604,7 +573,7 @@ export class NavigationApi {
|
||||
* @param tab - The tab to unregister panels for
|
||||
*/
|
||||
unregisterTab = (tab: TabName): void => {
|
||||
const prefix = this._getTabPrefix(tab);
|
||||
const prefix = this._getPanelPrefix(tab);
|
||||
const keysToDelete = Array.from(this.panels.keys()).filter((key) => key.startsWith(prefix));
|
||||
|
||||
for (const key of keysToDelete) {
|
||||
@@ -624,7 +593,7 @@ export class NavigationApi {
|
||||
this.waiters.delete(key);
|
||||
}
|
||||
|
||||
log.debug(`Unregistered all panels for tab ${tab}`);
|
||||
log.trace(`Unregistered all panels for tab ${tab}`);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { GridviewApi, IGridviewPanel, IGridviewReactProps } from 'dockview';
|
||||
import type { GridviewApi, IGridviewReactProps } from 'dockview';
|
||||
import { GridviewReact, LayoutPriority, Orientation } from 'dockview';
|
||||
import QueueTab from 'features/ui/components/tabs/QueueTab';
|
||||
import type { RootLayoutGridviewComponents } from 'features/ui/layouts/auto-layout-context';
|
||||
import { AutoLayoutProvider } from 'features/ui/layouts/auto-layout-context';
|
||||
import type { TabName } from 'features/ui/store/uiTypes';
|
||||
import { memo, useCallback, useEffect } from 'react';
|
||||
|
||||
import { navigationApi } from './navigation-api';
|
||||
@@ -12,22 +13,19 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
|
||||
[QUEUE_PANEL_ID]: QueueTab,
|
||||
};
|
||||
|
||||
const initializeRootPanelLayout = (layoutApi: GridviewApi) => {
|
||||
const queue = layoutApi.addPanel({
|
||||
id: QUEUE_PANEL_ID,
|
||||
component: QUEUE_PANEL_ID,
|
||||
priority: LayoutPriority.High,
|
||||
const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => {
|
||||
navigationApi.registerContainer(tab, 'root', api, () => {
|
||||
api.addPanel({
|
||||
id: QUEUE_PANEL_ID,
|
||||
component: QUEUE_PANEL_ID,
|
||||
priority: LayoutPriority.High,
|
||||
});
|
||||
});
|
||||
|
||||
navigationApi.registerPanel('queue', QUEUE_PANEL_ID, queue);
|
||||
|
||||
return { queue } satisfies Record<string, IGridviewPanel>;
|
||||
};
|
||||
|
||||
export const QueueTabAutoLayout = memo(() => {
|
||||
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
|
||||
initializeRootPanelLayout(api);
|
||||
navigationApi.onTabReady('queue');
|
||||
initializeRootPanelLayout('queue', api);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import type {
|
||||
DockviewApi,
|
||||
GridviewApi,
|
||||
IDockviewPanel,
|
||||
IDockviewReactProps,
|
||||
IGridviewPanel,
|
||||
IGridviewReactProps,
|
||||
} from 'dockview';
|
||||
import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview';
|
||||
import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview';
|
||||
import { UpscalingLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/UpscalingLaunchpadPanel';
|
||||
import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent';
|
||||
@@ -64,46 +57,42 @@ const mainPanelComponents: AutoLayoutDockviewComponents = {
|
||||
[PROGRESS_PANEL_ID]: withPanelContainer(GenerationProgressPanel),
|
||||
};
|
||||
|
||||
const initializeCenterPanelLayout = (tab: TabName, api: DockviewApi) => {
|
||||
const launchpad = api.addPanel<PanelParameters>({
|
||||
id: LAUNCHPAD_PANEL_ID,
|
||||
component: LAUNCHPAD_PANEL_ID,
|
||||
title: 'Launchpad',
|
||||
tabComponent: TAB_WITH_LAUNCHPAD_ICON_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'launchpad',
|
||||
},
|
||||
const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => {
|
||||
navigationApi.registerContainer(tab, 'main', api, () => {
|
||||
const launchpad = api.addPanel<PanelParameters>({
|
||||
id: LAUNCHPAD_PANEL_ID,
|
||||
component: LAUNCHPAD_PANEL_ID,
|
||||
title: 'Launchpad',
|
||||
tabComponent: TAB_WITH_LAUNCHPAD_ICON_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'launchpad',
|
||||
},
|
||||
});
|
||||
|
||||
api.addPanel<PanelParameters>({
|
||||
id: VIEWER_PANEL_ID,
|
||||
component: VIEWER_PANEL_ID,
|
||||
title: 'Image Viewer',
|
||||
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'viewer',
|
||||
},
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: launchpad.id,
|
||||
},
|
||||
});
|
||||
launchpad.api.setActive();
|
||||
});
|
||||
|
||||
const viewer = api.addPanel<PanelParameters>({
|
||||
id: VIEWER_PANEL_ID,
|
||||
component: VIEWER_PANEL_ID,
|
||||
title: 'Image Viewer',
|
||||
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'viewer',
|
||||
},
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: launchpad.id,
|
||||
},
|
||||
});
|
||||
|
||||
launchpad.api.setActive();
|
||||
|
||||
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
|
||||
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
|
||||
|
||||
return { launchpad, viewer } satisfies Record<string, IDockviewPanel>;
|
||||
};
|
||||
|
||||
const MainPanel = memo(() => {
|
||||
const { tab } = useAutoLayoutContext();
|
||||
const onReady = useCallback<IDockviewReactProps['onReady']>(
|
||||
({ api }) => {
|
||||
initializeCenterPanelLayout(tab, api);
|
||||
initializeMainPanelLayout(tab, api);
|
||||
},
|
||||
[tab]
|
||||
);
|
||||
@@ -133,39 +122,35 @@ const rightPanelComponents: AutoLayoutGridviewComponents = {
|
||||
};
|
||||
|
||||
const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => {
|
||||
const gallery = api.addPanel<PanelParameters>({
|
||||
id: GALLERY_PANEL_ID,
|
||||
component: GALLERY_PANEL_ID,
|
||||
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
|
||||
minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'gallery',
|
||||
},
|
||||
navigationApi.registerContainer(tab, 'right', api, () => {
|
||||
const gallery = api.addPanel<PanelParameters>({
|
||||
id: GALLERY_PANEL_ID,
|
||||
component: GALLERY_PANEL_ID,
|
||||
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
|
||||
minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'gallery',
|
||||
},
|
||||
});
|
||||
|
||||
const boards = api.addPanel<PanelParameters>({
|
||||
id: BOARDS_PANEL_ID,
|
||||
component: BOARDS_PANEL_ID,
|
||||
minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'boards',
|
||||
},
|
||||
position: {
|
||||
direction: 'above',
|
||||
referencePanel: gallery.id,
|
||||
},
|
||||
});
|
||||
|
||||
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX });
|
||||
boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX });
|
||||
});
|
||||
|
||||
const boards = api.addPanel<PanelParameters>({
|
||||
id: BOARDS_PANEL_ID,
|
||||
component: BOARDS_PANEL_ID,
|
||||
minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'boards',
|
||||
},
|
||||
position: {
|
||||
direction: 'above',
|
||||
referencePanel: gallery.id,
|
||||
},
|
||||
});
|
||||
|
||||
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
|
||||
boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
|
||||
|
||||
// Register panels with navigation API
|
||||
navigationApi.registerPanel(tab, GALLERY_PANEL_ID, gallery);
|
||||
navigationApi.registerPanel(tab, BOARDS_PANEL_ID, boards);
|
||||
|
||||
return { gallery, boards } satisfies Record<string, IGridviewPanel>;
|
||||
};
|
||||
|
||||
const RightPanel = memo(() => {
|
||||
@@ -193,19 +178,16 @@ const leftPanelComponents: AutoLayoutGridviewComponents = {
|
||||
};
|
||||
|
||||
const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => {
|
||||
const settings = api.addPanel<PanelParameters>({
|
||||
id: SETTINGS_PANEL_ID,
|
||||
component: SETTINGS_PANEL_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'settings',
|
||||
},
|
||||
navigationApi.registerContainer(tab, 'left', api, () => {
|
||||
api.addPanel<PanelParameters>({
|
||||
id: SETTINGS_PANEL_ID,
|
||||
component: SETTINGS_PANEL_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'settings',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Register panel with navigation API
|
||||
navigationApi.registerPanel(tab, SETTINGS_PANEL_ID, settings);
|
||||
|
||||
return { settings } satisfies Record<string, IGridviewPanel>;
|
||||
};
|
||||
|
||||
const LeftPanel = memo(() => {
|
||||
@@ -235,47 +217,42 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
|
||||
[RIGHT_PANEL_ID]: RightPanel,
|
||||
};
|
||||
|
||||
const initializeRootPanelLayout = (layoutApi: GridviewApi) => {
|
||||
const main = layoutApi.addPanel({
|
||||
id: MAIN_PANEL_ID,
|
||||
component: MAIN_PANEL_ID,
|
||||
priority: LayoutPriority.High,
|
||||
const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => {
|
||||
navigationApi.registerContainer(tab, 'root', api, () => {
|
||||
const main = api.addPanel({
|
||||
id: MAIN_PANEL_ID,
|
||||
component: MAIN_PANEL_ID,
|
||||
priority: LayoutPriority.High,
|
||||
});
|
||||
|
||||
const left = api.addPanel({
|
||||
id: LEFT_PANEL_ID,
|
||||
component: LEFT_PANEL_ID,
|
||||
minimumWidth: LEFT_PANEL_MIN_SIZE_PX,
|
||||
position: {
|
||||
direction: 'left',
|
||||
referencePanel: main.id,
|
||||
},
|
||||
});
|
||||
|
||||
const right = api.addPanel({
|
||||
id: RIGHT_PANEL_ID,
|
||||
component: RIGHT_PANEL_ID,
|
||||
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
|
||||
position: {
|
||||
direction: 'right',
|
||||
referencePanel: main.id,
|
||||
},
|
||||
});
|
||||
|
||||
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
|
||||
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
|
||||
});
|
||||
|
||||
const left = layoutApi.addPanel({
|
||||
id: LEFT_PANEL_ID,
|
||||
component: LEFT_PANEL_ID,
|
||||
minimumWidth: LEFT_PANEL_MIN_SIZE_PX,
|
||||
position: {
|
||||
direction: 'left',
|
||||
referencePanel: main.id,
|
||||
},
|
||||
});
|
||||
|
||||
const right = layoutApi.addPanel({
|
||||
id: RIGHT_PANEL_ID,
|
||||
component: RIGHT_PANEL_ID,
|
||||
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
|
||||
position: {
|
||||
direction: 'right',
|
||||
referencePanel: main.id,
|
||||
},
|
||||
});
|
||||
|
||||
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
|
||||
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
|
||||
|
||||
navigationApi.registerPanel('upscaling', LEFT_PANEL_ID, left);
|
||||
navigationApi.registerPanel('upscaling', MAIN_PANEL_ID, main);
|
||||
navigationApi.registerPanel('upscaling', RIGHT_PANEL_ID, right);
|
||||
|
||||
return { main, left, right } satisfies Record<string, IGridviewPanel>;
|
||||
};
|
||||
|
||||
export const UpscalingTabAutoLayout = memo(() => {
|
||||
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
|
||||
initializeRootPanelLayout(api);
|
||||
navigationApi.onTabReady('upscaling');
|
||||
initializeRootPanelLayout('upscaling', api);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
|
||||
@@ -11,9 +11,9 @@ const getIsCollapsed = (
|
||||
collapsedSize?: number
|
||||
) => {
|
||||
if (orientation === 'vertical') {
|
||||
return panel.height <= (collapsedSize ?? panel.minimumHeight);
|
||||
return panel.height <= (collapsedSize ?? panel.minimumHeight ?? 0);
|
||||
}
|
||||
return panel.width <= (collapsedSize ?? panel.minimumWidth);
|
||||
return panel.width <= (collapsedSize ?? panel.minimumWidth ?? 0);
|
||||
};
|
||||
|
||||
export const useCollapsibleGridviewPanel = (
|
||||
@@ -36,9 +36,9 @@ export const useCollapsibleGridviewPanel = (
|
||||
lastExpandedSizeRef.current = orientation === 'vertical' ? panel.height : panel.width;
|
||||
|
||||
if (orientation === 'vertical') {
|
||||
panel.api.setSize({ height: collapsedSize ?? panel.minimumHeight });
|
||||
panel.api.setSize({ height: collapsedSize ?? panel.minimumHeight ?? 0 });
|
||||
} else {
|
||||
panel.api.setSize({ width: collapsedSize ?? panel.minimumWidth });
|
||||
panel.api.setSize({ width: collapsedSize ?? panel.minimumWidth ?? 0 });
|
||||
}
|
||||
}, [collapsedSize, orientation, panelId, tab]);
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Prevent undesired dnd behavior in Dockview tabs.
|
||||
*
|
||||
* Dockview always sets the draggable flag on its tab elements, even when dnd is disabled. This hook traverses
|
||||
* up from the provided ref to find the closest tab element and sets its `draggable` attribute to `false`.
|
||||
*/
|
||||
export const useHackOutDvTabDraggable = (ref: RefObject<HTMLElement>) => {
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const parentTab = el.closest('.dv-tab');
|
||||
if (!parentTab) {
|
||||
return;
|
||||
}
|
||||
parentTab.setAttribute('draggable', 'false');
|
||||
}, [ref]);
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { panelStateChanged, setActiveTab } from 'features/ui/store/uiSlice';
|
||||
import type { StoredDockviewPanelState, StoredGridviewPanelState, TabName } from 'features/ui/store/uiTypes';
|
||||
import { dockviewStorageKeyChanged, setActiveTab } from 'features/ui/store/uiSlice';
|
||||
import type { TabName } from 'features/ui/store/uiTypes';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import type { JsonObject } from 'type-fest';
|
||||
|
||||
import { navigationApi } from './navigation-api';
|
||||
|
||||
@@ -25,15 +26,15 @@ export const useNavigationApi = () => {
|
||||
store.dispatch(setActiveTab(tab));
|
||||
},
|
||||
},
|
||||
panelStorage: {
|
||||
storage: {
|
||||
get: (id: string) => {
|
||||
return store.getState().ui.panels[id];
|
||||
},
|
||||
set: (id: string, state: StoredDockviewPanelState | StoredGridviewPanelState) => {
|
||||
store.dispatch(panelStateChanged({ id, state }));
|
||||
set: (id: string, state: JsonObject) => {
|
||||
store.dispatch(dockviewStorageKeyChanged({ id, state }));
|
||||
},
|
||||
delete: (id: string) => {
|
||||
store.dispatch(panelStateChanged({ id, state: undefined }));
|
||||
store.dispatch(dockviewStorageKeyChanged({ id, state: undefined }));
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import type {
|
||||
DockviewApi,
|
||||
GridviewApi,
|
||||
IDockviewPanel,
|
||||
IDockviewReactProps,
|
||||
IGridviewPanel,
|
||||
IGridviewReactProps,
|
||||
} from 'dockview';
|
||||
import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview';
|
||||
import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview';
|
||||
import { WorkflowsLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/WorkflowsLaunchpadPanel';
|
||||
import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent';
|
||||
@@ -68,54 +61,50 @@ const mainPanelComponents: AutoLayoutDockviewComponents = {
|
||||
};
|
||||
|
||||
const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => {
|
||||
const launchpad = api.addPanel<PanelParameters>({
|
||||
id: LAUNCHPAD_PANEL_ID,
|
||||
component: LAUNCHPAD_PANEL_ID,
|
||||
title: 'Launchpad',
|
||||
tabComponent: TAB_WITH_LAUNCHPAD_ICON_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'launchpad',
|
||||
},
|
||||
navigationApi.registerContainer(tab, 'main', api, () => {
|
||||
const launchpad = api.addPanel<PanelParameters>({
|
||||
id: LAUNCHPAD_PANEL_ID,
|
||||
component: LAUNCHPAD_PANEL_ID,
|
||||
title: 'Launchpad',
|
||||
tabComponent: TAB_WITH_LAUNCHPAD_ICON_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'launchpad',
|
||||
},
|
||||
});
|
||||
|
||||
api.addPanel<PanelParameters>({
|
||||
id: WORKSPACE_PANEL_ID,
|
||||
component: WORKSPACE_PANEL_ID,
|
||||
title: 'Workflow Editor',
|
||||
tabComponent: DEFAULT_TAB_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'workflows',
|
||||
},
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: launchpad.id,
|
||||
},
|
||||
});
|
||||
|
||||
api.addPanel<PanelParameters>({
|
||||
id: VIEWER_PANEL_ID,
|
||||
component: VIEWER_PANEL_ID,
|
||||
title: 'Image Viewer',
|
||||
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'viewer',
|
||||
},
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: launchpad.id,
|
||||
},
|
||||
});
|
||||
|
||||
launchpad.api.setActive();
|
||||
});
|
||||
|
||||
const workspace = api.addPanel<PanelParameters>({
|
||||
id: WORKSPACE_PANEL_ID,
|
||||
component: WORKSPACE_PANEL_ID,
|
||||
title: 'Workflow Editor',
|
||||
tabComponent: DEFAULT_TAB_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'workflows',
|
||||
},
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: launchpad.id,
|
||||
},
|
||||
});
|
||||
|
||||
const viewer = api.addPanel<PanelParameters>({
|
||||
id: VIEWER_PANEL_ID,
|
||||
component: VIEWER_PANEL_ID,
|
||||
title: 'Image Viewer',
|
||||
tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'viewer',
|
||||
},
|
||||
position: {
|
||||
direction: 'within',
|
||||
referencePanel: launchpad.id,
|
||||
},
|
||||
});
|
||||
|
||||
launchpad.api.setActive();
|
||||
|
||||
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
|
||||
navigationApi.registerPanel(tab, WORKSPACE_PANEL_ID, workspace);
|
||||
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
|
||||
|
||||
return { launchpad, workspace, viewer } satisfies Record<string, IDockviewPanel>;
|
||||
};
|
||||
|
||||
const MainPanel = memo(() => {
|
||||
@@ -153,39 +142,35 @@ const rightPanelComponents: AutoLayoutGridviewComponents = {
|
||||
};
|
||||
|
||||
const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => {
|
||||
const gallery = api.addPanel<PanelParameters>({
|
||||
id: GALLERY_PANEL_ID,
|
||||
component: GALLERY_PANEL_ID,
|
||||
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
|
||||
minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'gallery',
|
||||
},
|
||||
navigationApi.registerContainer(tab, 'right', api, () => {
|
||||
const gallery = api.addPanel<PanelParameters>({
|
||||
id: GALLERY_PANEL_ID,
|
||||
component: GALLERY_PANEL_ID,
|
||||
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
|
||||
minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'gallery',
|
||||
},
|
||||
});
|
||||
|
||||
const boards = api.addPanel<PanelParameters>({
|
||||
id: BOARDS_PANEL_ID,
|
||||
component: BOARDS_PANEL_ID,
|
||||
minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'boards',
|
||||
},
|
||||
position: {
|
||||
direction: 'above',
|
||||
referencePanel: gallery.id,
|
||||
},
|
||||
});
|
||||
|
||||
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX });
|
||||
boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX });
|
||||
});
|
||||
|
||||
const boards = api.addPanel<PanelParameters>({
|
||||
id: BOARDS_PANEL_ID,
|
||||
component: BOARDS_PANEL_ID,
|
||||
minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'boards',
|
||||
},
|
||||
position: {
|
||||
direction: 'above',
|
||||
referencePanel: gallery.id,
|
||||
},
|
||||
});
|
||||
|
||||
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
|
||||
boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
|
||||
|
||||
// Register panels with navigation API
|
||||
navigationApi.registerPanel(tab, GALLERY_PANEL_ID, gallery);
|
||||
navigationApi.registerPanel(tab, BOARDS_PANEL_ID, boards);
|
||||
|
||||
return { gallery, boards } satisfies Record<string, IGridviewPanel>;
|
||||
};
|
||||
|
||||
const RightPanel = memo(() => {
|
||||
@@ -213,19 +198,16 @@ const leftPanelComponents: AutoLayoutGridviewComponents = {
|
||||
};
|
||||
|
||||
const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => {
|
||||
const settings = api.addPanel<PanelParameters>({
|
||||
id: SETTINGS_PANEL_ID,
|
||||
component: SETTINGS_PANEL_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'settings',
|
||||
},
|
||||
navigationApi.registerContainer(tab, 'left', api, () => {
|
||||
api.addPanel<PanelParameters>({
|
||||
id: SETTINGS_PANEL_ID,
|
||||
component: SETTINGS_PANEL_ID,
|
||||
params: {
|
||||
tab,
|
||||
focusRegion: 'settings',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Register panel with navigation API
|
||||
navigationApi.registerPanel(tab, SETTINGS_PANEL_ID, settings);
|
||||
|
||||
return { settings } satisfies Record<string, IGridviewPanel>;
|
||||
};
|
||||
|
||||
const LeftPanel = memo(() => {
|
||||
@@ -254,45 +236,42 @@ const rootPanelComponents: RootLayoutGridviewComponents = {
|
||||
[RIGHT_PANEL_ID]: RightPanel,
|
||||
};
|
||||
|
||||
const initializeRootPanelLayout = (api: GridviewApi) => {
|
||||
const main = api.addPanel({
|
||||
id: MAIN_PANEL_ID,
|
||||
component: MAIN_PANEL_ID,
|
||||
priority: LayoutPriority.High,
|
||||
});
|
||||
const left = api.addPanel({
|
||||
id: LEFT_PANEL_ID,
|
||||
component: LEFT_PANEL_ID,
|
||||
minimumWidth: LEFT_PANEL_MIN_SIZE_PX,
|
||||
position: {
|
||||
direction: 'left',
|
||||
referencePanel: MAIN_PANEL_ID,
|
||||
},
|
||||
});
|
||||
const right = api.addPanel({
|
||||
id: RIGHT_PANEL_ID,
|
||||
component: RIGHT_PANEL_ID,
|
||||
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
|
||||
position: {
|
||||
direction: 'right',
|
||||
referencePanel: MAIN_PANEL_ID,
|
||||
},
|
||||
});
|
||||
const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => {
|
||||
navigationApi.registerContainer(tab, 'root', api, () => {
|
||||
const main = api.addPanel({
|
||||
id: MAIN_PANEL_ID,
|
||||
component: MAIN_PANEL_ID,
|
||||
priority: LayoutPriority.High,
|
||||
});
|
||||
|
||||
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
|
||||
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
|
||||
const left = api.addPanel({
|
||||
id: LEFT_PANEL_ID,
|
||||
component: LEFT_PANEL_ID,
|
||||
minimumWidth: LEFT_PANEL_MIN_SIZE_PX,
|
||||
position: {
|
||||
direction: 'left',
|
||||
referencePanel: main.id,
|
||||
},
|
||||
});
|
||||
|
||||
navigationApi.registerPanel('workflows', LEFT_PANEL_ID, left);
|
||||
navigationApi.registerPanel('workflows', MAIN_PANEL_ID, main);
|
||||
navigationApi.registerPanel('workflows', RIGHT_PANEL_ID, right);
|
||||
const right = api.addPanel({
|
||||
id: RIGHT_PANEL_ID,
|
||||
component: RIGHT_PANEL_ID,
|
||||
minimumWidth: RIGHT_PANEL_MIN_SIZE_PX,
|
||||
position: {
|
||||
direction: 'right',
|
||||
referencePanel: main.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { main, left, right } satisfies Record<string, IGridviewPanel>;
|
||||
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
|
||||
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
|
||||
});
|
||||
};
|
||||
|
||||
export const WorkflowsTabAutoLayout = memo(() => {
|
||||
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
|
||||
initializeRootPanelLayout(api);
|
||||
navigationApi.onTabReady('workflows');
|
||||
initializeRootPanelLayout('workflows', api);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
|
||||
@@ -51,7 +51,7 @@ export const uiSlice = createSlice({
|
||||
const { id, size } = action.payload;
|
||||
state.textAreaSizes[id] = size;
|
||||
},
|
||||
panelStateChanged: (
|
||||
dockviewStorageKeyChanged: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
id: keyof UIState['panels'];
|
||||
@@ -80,7 +80,7 @@ export const {
|
||||
expanderStateChanged,
|
||||
shouldShowNotificationChanged,
|
||||
textAreaSizesStateChanged,
|
||||
panelStateChanged,
|
||||
dockviewStorageKeyChanged,
|
||||
} = uiSlice.actions;
|
||||
|
||||
export const selectUiSlice = (state: RootState) => state.ui;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import { isPlainObject } from 'es-toolkit';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
const zTabName = z.enum(['generate', 'canvas', 'upscaling', 'workflows', 'models', 'queue']);
|
||||
@@ -10,35 +11,19 @@ const zPartialDimensions = z.object({
|
||||
height: z.number().optional(),
|
||||
});
|
||||
|
||||
const zDimensions = z.object({
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
});
|
||||
|
||||
const zDockviewPanelState = z.object({
|
||||
id: z.string(),
|
||||
type: z.literal('dockview-panel'),
|
||||
isActive: z.boolean(),
|
||||
});
|
||||
export type StoredDockviewPanelState = z.infer<typeof zDockviewPanelState>;
|
||||
|
||||
const zGridviewPanelState = z.object({
|
||||
id: z.string(),
|
||||
type: z.literal('gridview-panel'),
|
||||
dimensions: zDimensions,
|
||||
});
|
||||
export type StoredGridviewPanelState = z.infer<typeof zGridviewPanelState>;
|
||||
const zSerializable = z.any().refine(isPlainObject);
|
||||
export type Serializable = z.infer<typeof zSerializable>;
|
||||
|
||||
const zUIState = z.object({
|
||||
_version: z.literal(3).default(3),
|
||||
activeTab: zTabName.default('canvas'),
|
||||
activeTab: zTabName.default('generate'),
|
||||
activeTabCanvasRightPanel: zCanvasRightPanelTabName.default('gallery'),
|
||||
shouldShowImageDetails: z.boolean().default(false),
|
||||
shouldShowProgressInViewer: z.boolean().default(true),
|
||||
accordions: z.record(z.string(), z.boolean()).default(() => ({})),
|
||||
expanders: z.record(z.string(), z.boolean()).default(() => ({})),
|
||||
textAreaSizes: z.record(z.string(), zPartialDimensions).default({}),
|
||||
panels: z.record(z.string(), z.discriminatedUnion('type', [zDockviewPanelState, zGridviewPanelState])).default({}),
|
||||
panels: z.record(z.string(), zSerializable).default({}),
|
||||
shouldShowNotificationV2: z.boolean().default(true),
|
||||
});
|
||||
const INITIAL_STATE = zUIState.parse({});
|
||||
|
||||
@@ -263,7 +263,6 @@ export const imagesApi = api.injectEndpoints({
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
invalidatesTags: (result) => {
|
||||
if (!result || result.is_intermediate) {
|
||||
// Don't add it to anything
|
||||
@@ -276,6 +275,7 @@ export const imagesApi = api.injectEndpoints({
|
||||
...getTagsToInvalidateForBoardAffectingMutation([boardId]),
|
||||
'ImageCollectionCounts',
|
||||
{ type: 'ImageCollection', id: LIST_TAG },
|
||||
'ImageNameList',
|
||||
];
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { EntityState, ThunkDispatch, UnknownAction } from '@reduxjs/toolkit';
|
||||
import { createEntityAdapter } from '@reduxjs/toolkit';
|
||||
import { getSelectorsOptions } from 'app/store/createMemoizedSelector';
|
||||
import { $queueId } from 'app/store/nanostores/queueId';
|
||||
import { listParamsReset } from 'features/queue/store/queueSlice';
|
||||
import queryString from 'query-string';
|
||||
import type { components, paths } from 'services/api/schema';
|
||||
|
||||
@@ -31,6 +35,30 @@ export type SessionQueueItemStatus = NonNullable<
|
||||
NonNullable<paths['/api/v1/queue/{queue_id}/list']['get']['parameters']['query']>['status']
|
||||
>;
|
||||
|
||||
export const queueItemsAdapter = createEntityAdapter<components['schemas']['SessionQueueItem'], string>({
|
||||
selectId: (queueItem) => String(queueItem.item_id),
|
||||
sortComparer: (a, b) => {
|
||||
// Sort by priority in descending order
|
||||
if (a.priority > b.priority) {
|
||||
return -1;
|
||||
}
|
||||
if (a.priority < b.priority) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If priority is the same, sort by id in ascending order
|
||||
if (a.item_id < b.item_id) {
|
||||
return -1;
|
||||
}
|
||||
if (a.item_id > b.item_id) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
export const queueItemsAdapterSelectors = queueItemsAdapter.getSelectors(undefined, getSelectorsOptions);
|
||||
|
||||
export const queueApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
enqueueBatch: build.mutation<
|
||||
@@ -50,6 +78,57 @@ export const queueApi = api.injectEndpoints({
|
||||
{ type: 'SessionQueueItem', id: LIST_TAG },
|
||||
{ type: 'SessionQueueItem', id: LIST_ALL_TAG },
|
||||
],
|
||||
onQueryStarted: async (arg, api) => {
|
||||
const { dispatch, queryFulfilled } = api;
|
||||
try {
|
||||
const { data } = await queryFulfilled;
|
||||
resetListQueryData(dispatch);
|
||||
/**
|
||||
* When a batch is enqueued, we need to update the queue status. While it might be templting to invalidate the
|
||||
* `SessionQueueStatus` tag here, this can introduce a race condition when the queue item executes quickly:
|
||||
*
|
||||
* - Enqueue via this query
|
||||
* - On success, we invalidate `SessionQueueStatus` tag - network request sent to server
|
||||
* - The server gets the queue status request and responds, but this takes some time... in the meantime:
|
||||
* - The new queue item starts executing, and we receive a socket queue item status changed event
|
||||
* - We optimistically update the queue status in the queue item status changed socket handler
|
||||
* - At this point, the queue status is correct
|
||||
* - Finally, we get the queue status from the tag invalidation request - but it's reporting the queue status
|
||||
* from _before_ the last queue event
|
||||
* - The queue status is now incorrect!
|
||||
*
|
||||
* Ok, what if we just never did optimistic updates and invalidated the tag in the queue event handlers instead?
|
||||
* It's much simpler that way, but it causes a lot of network requests - 3 per queue item, as it moves from
|
||||
* pending -> in_progress -> completed/failed/canceled.
|
||||
*
|
||||
* We can do a bit of extra work here, incrementing the pending and total counts in the queue status, and do
|
||||
* similar optimistic updates in the socket handler. Because this optimistic update runs immediately after the
|
||||
* enqueue network request, it should always occur _before_ the next queue event, so no race condition:
|
||||
*
|
||||
* - Enqueue batch via this query
|
||||
* - On success, optimistically update - this happens immediately on the HTTP OK - before the next queue event
|
||||
* - At this point, the queue status is correct
|
||||
* - A queue item status changes and we receive a socket event w/ updated status
|
||||
* - Update status optimistically in socket handler
|
||||
* - Queue status is still correct
|
||||
*
|
||||
* This problem occurs most commonly with canvas filters like Canny edge detection, which are single-node
|
||||
* graphs that execute very quickly. Image generation graphs take long enough to not trigger this race
|
||||
* condition - even when all nodes are cached on the server.
|
||||
*/
|
||||
dispatch(
|
||||
queueApi.util.updateQueryData('getQueueStatus', undefined, (draft) => {
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
draft.queue.pending += data.enqueued;
|
||||
draft.queue.total += data.enqueued;
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
},
|
||||
}),
|
||||
resumeProcessor: build.mutation<
|
||||
paths['/api/v1/queue/{queue_id}/processor/resume']['put']['responses']['200']['content']['application/json'],
|
||||
@@ -85,6 +164,15 @@ export const queueApi = api.injectEndpoints({
|
||||
{ type: 'SessionQueueItem', id: LIST_TAG },
|
||||
{ type: 'SessionQueueItem', id: LIST_ALL_TAG },
|
||||
],
|
||||
onQueryStarted: async (arg, api) => {
|
||||
const { dispatch, queryFulfilled } = api;
|
||||
try {
|
||||
await queryFulfilled;
|
||||
resetListQueryData(dispatch);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
},
|
||||
}),
|
||||
clearQueue: build.mutation<
|
||||
paths['/api/v1/queue/{queue_id}/clear']['put']['responses']['200']['content']['application/json'],
|
||||
@@ -104,6 +192,15 @@ export const queueApi = api.injectEndpoints({
|
||||
{ type: 'SessionQueueItem', id: LIST_TAG },
|
||||
{ type: 'SessionQueueItem', id: LIST_ALL_TAG },
|
||||
],
|
||||
onQueryStarted: async (arg, api) => {
|
||||
const { dispatch, queryFulfilled } = api;
|
||||
try {
|
||||
await queryFulfilled;
|
||||
resetListQueryData(dispatch);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
},
|
||||
}),
|
||||
getCurrentQueueItem: build.query<
|
||||
paths['/api/v1/queue/{queue_id}/current']['get']['responses']['200']['content']['application/json'],
|
||||
@@ -187,6 +284,25 @@ export const queueApi = api.injectEndpoints({
|
||||
url: buildQueueUrl(`i/${item_id}/cancel`),
|
||||
method: 'PUT',
|
||||
}),
|
||||
onQueryStarted: async (item_id, { dispatch, queryFulfilled }) => {
|
||||
try {
|
||||
const { data } = await queryFulfilled;
|
||||
dispatch(
|
||||
queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => {
|
||||
queueItemsAdapter.updateOne(draft, {
|
||||
id: String(item_id),
|
||||
changes: {
|
||||
status: data.status,
|
||||
completed_at: data.completed_at,
|
||||
updated_at: data.updated_at,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
},
|
||||
invalidatesTags: (result) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
@@ -210,6 +326,15 @@ export const queueApi = api.injectEndpoints({
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
onQueryStarted: async (arg, api) => {
|
||||
const { dispatch, queryFulfilled } = api;
|
||||
try {
|
||||
await queryFulfilled;
|
||||
resetListQueryData(dispatch);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
},
|
||||
invalidatesTags: (result, error, { batch_ids }) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
@@ -256,16 +381,6 @@ export const queueApi = api.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: ['SessionQueueStatus', 'BatchStatus', 'QueueCountsByDestination', 'SessionQueueItem'],
|
||||
}),
|
||||
deleteAllExceptCurrent: build.mutation<
|
||||
paths['/api/v1/queue/{queue_id}/delete_all_except_current']['put']['responses']['200']['content']['application/json'],
|
||||
void
|
||||
>({
|
||||
query: () => ({
|
||||
url: buildQueueUrl('delete_all_except_current'),
|
||||
method: 'PUT',
|
||||
}),
|
||||
invalidatesTags: ['SessionQueueStatus', 'BatchStatus', 'QueueCountsByDestination', 'SessionQueueItem'],
|
||||
}),
|
||||
retryItemsById: build.mutation<
|
||||
paths['/api/v1/queue/{queue_id}/retry_items_by_id']['put']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/queue/{queue_id}/retry_items_by_id']['put']['requestBody']['content']['application/json']
|
||||
@@ -275,6 +390,15 @@ export const queueApi = api.injectEndpoints({
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
onQueryStarted: async (arg, api) => {
|
||||
const { dispatch, queryFulfilled } = api;
|
||||
try {
|
||||
await queryFulfilled;
|
||||
resetListQueryData(dispatch);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
},
|
||||
invalidatesTags: (result, error, item_ids) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
@@ -290,24 +414,31 @@ export const queueApi = api.injectEndpoints({
|
||||
},
|
||||
}),
|
||||
listQueueItems: build.query<
|
||||
components['schemas']['CursorPaginatedResults_SessionQueueItem_'],
|
||||
{ cursor?: number; priority?: number; destination?: string } | undefined
|
||||
EntityState<components['schemas']['SessionQueueItem'], string> & {
|
||||
has_more: boolean;
|
||||
},
|
||||
{ cursor?: number; priority?: number } | undefined
|
||||
>({
|
||||
query: (queryArgs) => ({
|
||||
url: getListQueueItemsUrl(queryArgs),
|
||||
method: 'GET',
|
||||
}),
|
||||
keepUnusedDataFor: 60 * 5, // 5 minutes
|
||||
providesTags: (result, _error, _args) => {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
'FetchOnReconnect',
|
||||
{ type: 'SessionQueueItem', id: LIST_TAG },
|
||||
...result.items.map(({ item_id }) => ({ type: 'SessionQueueItem', id: item_id }) satisfies ApiTagDescription),
|
||||
];
|
||||
serializeQueryArgs: () => {
|
||||
return buildQueueUrl('list');
|
||||
},
|
||||
transformResponse: (response: components['schemas']['CursorPaginatedResults_SessionQueueItem_']) =>
|
||||
queueItemsAdapter.addMany(
|
||||
queueItemsAdapter.getInitialState({
|
||||
has_more: response.has_more,
|
||||
}),
|
||||
response.items
|
||||
),
|
||||
merge: (cache, response) => {
|
||||
queueItemsAdapter.addMany(cache, queueItemsAdapterSelectors.selectAll(response));
|
||||
cache.has_more = response.has_more;
|
||||
},
|
||||
forceRefetch: ({ currentArg, previousArg }) => currentArg !== previousArg,
|
||||
keepUnusedDataFor: 60 * 5, // 5 minutes
|
||||
}),
|
||||
listAllQueueItems: build.query<
|
||||
paths['/api/v1/queue/{queue_id}/list_all']['get']['responses']['200']['content']['application/json'],
|
||||
@@ -356,6 +487,16 @@ export const queueApi = api.injectEndpoints({
|
||||
{ type: 'SessionQueueItem', id: LIST_ALL_TAG },
|
||||
],
|
||||
}),
|
||||
deleteAllExceptCurrent: build.mutation<
|
||||
paths['/api/v1/queue/{queue_id}/delete_all_except_current']['put']['responses']['200']['content']['application/json'],
|
||||
void
|
||||
>({
|
||||
query: () => ({
|
||||
url: buildQueueUrl('delete_all_except_current'),
|
||||
method: 'PUT',
|
||||
}),
|
||||
invalidatesTags: ['SessionQueueStatus', 'BatchStatus', 'QueueCountsByDestination', 'SessionQueueItem'],
|
||||
}),
|
||||
getQueueCountsByDestination: build.query<
|
||||
paths['/api/v1/queue/{queue_id}/counts_by_destination']['get']['responses']['200']['content']['application/json'],
|
||||
paths['/api/v1/queue/{queue_id}/counts_by_destination']['get']['parameters']['query']
|
||||
@@ -378,6 +519,7 @@ export const {
|
||||
useClearQueueMutation,
|
||||
usePruneQueueMutation,
|
||||
useGetQueueStatusQuery,
|
||||
useGetQueueItemQuery,
|
||||
useListQueueItemsQuery,
|
||||
useCancelQueueItemMutation,
|
||||
useCancelQueueItemsByDestinationMutation,
|
||||
@@ -392,6 +534,24 @@ export const {
|
||||
|
||||
export const selectQueueStatus = queueApi.endpoints.getQueueStatus.select();
|
||||
|
||||
const resetListQueryData = (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dispatch: ThunkDispatch<any, any, UnknownAction>
|
||||
) => {
|
||||
dispatch(
|
||||
queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => {
|
||||
// remove all items from the list
|
||||
queueItemsAdapter.removeAll(draft);
|
||||
// reset the has_more flag
|
||||
draft.has_more = false;
|
||||
})
|
||||
);
|
||||
// set the list cursor and priority to undefined
|
||||
dispatch(listParamsReset());
|
||||
// we have to manually kick off another query to get the first page and re-initialize the list
|
||||
dispatch(queueApi.endpoints.listQueueItems.initiate(undefined));
|
||||
};
|
||||
|
||||
export const enqueueMutationFixedCacheKeyOptions = {
|
||||
fixedCacheKey: 'enqueueBatch',
|
||||
} as const;
|
||||
|
||||
@@ -129,6 +129,17 @@ export const selectIPAdapterModels = buildModelsSelector(isIPAdapterModelConfig)
|
||||
// export const selectEmbeddingModels = buildModelsSelector(isTIModelConfig);
|
||||
// export const selectVAEModels = buildModelsSelector(isVAEModelConfig);
|
||||
// export const selectFluxVAEModels = buildModelsSelector(isFluxVAEModelConfig);
|
||||
export const selectGlobalRefImageModels = buildModelsSelector(
|
||||
(config) =>
|
||||
isIPAdapterModelConfig(config) ||
|
||||
isFluxReduxModelConfig(config) ||
|
||||
isChatGPT4oModelConfig(config) ||
|
||||
isFluxKontextApiModelConfig(config) ||
|
||||
isFluxKontextModelConfig(config)
|
||||
);
|
||||
export const selectRegionalRefImageModels = buildModelsSelector(
|
||||
(config) => isIPAdapterModelConfig(config) || isFluxReduxModelConfig(config)
|
||||
);
|
||||
|
||||
export const buildSelectModelConfig = <T extends AnyModelConfig>(
|
||||
key: string,
|
||||
|
||||
@@ -22266,16 +22266,6 @@ export interface operations {
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
/** @example {
|
||||
* "path": "/path/to/model",
|
||||
* "name": "model_name",
|
||||
* "base": "sd-1",
|
||||
* "type": "main",
|
||||
* "format": "checkpoint",
|
||||
* "config_path": "configs/stable-diffusion/v1-inference.yaml",
|
||||
* "description": "Model description",
|
||||
* "variant": "normal"
|
||||
* } */
|
||||
"application/json": components["schemas"]["ModelRecordChanges"];
|
||||
};
|
||||
};
|
||||
@@ -22579,10 +22569,6 @@ export interface operations {
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
/** @example {
|
||||
* "name": "string",
|
||||
* "description": "string"
|
||||
* } */
|
||||
"application/json": components["schemas"]["ModelRecordChanges"];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ import { t } from 'i18next';
|
||||
import type { ApiTagDescription } from 'services/api';
|
||||
import { api, LIST_ALL_TAG, LIST_TAG } from 'services/api';
|
||||
import { modelsApi } from 'services/api/endpoints/models';
|
||||
import { queueApi } from 'services/api/endpoints/queue';
|
||||
import { queueApi, queueItemsAdapter } from 'services/api/endpoints/queue';
|
||||
import { workflowsApi } from 'services/api/endpoints/workflows';
|
||||
import { buildOnInvocationComplete } from 'services/events/onInvocationComplete';
|
||||
import { buildOnModelInstallError } from 'services/events/onModelInstallError';
|
||||
@@ -343,10 +343,42 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
|
||||
|
||||
socket.on('queue_item_status_changed', (data) => {
|
||||
// we've got new status for the queue item, batch and queue
|
||||
const { item_id, session_id, status, batch_status, error_type, error_message, destination } = data;
|
||||
const {
|
||||
item_id,
|
||||
session_id,
|
||||
status,
|
||||
batch_status,
|
||||
error_type,
|
||||
error_message,
|
||||
destination,
|
||||
started_at,
|
||||
updated_at,
|
||||
completed_at,
|
||||
error_traceback,
|
||||
credits,
|
||||
} = data;
|
||||
|
||||
log.debug({ data }, `Queue item ${item_id} status updated: ${status}`);
|
||||
|
||||
// // Update this specific queue item in the list of queue items
|
||||
dispatch(
|
||||
queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => {
|
||||
queueItemsAdapter.updateOne(draft, {
|
||||
id: String(item_id),
|
||||
changes: {
|
||||
status,
|
||||
started_at,
|
||||
updated_at: updated_at ?? undefined,
|
||||
completed_at: completed_at ?? undefined,
|
||||
error_type,
|
||||
error_message,
|
||||
error_traceback,
|
||||
credits,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Invalidate caches for things we cannot easily update
|
||||
const tagsToInvalidate: ApiTagDescription[] = [
|
||||
'SessionQueueStatus',
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "6.0.0rc4"
|
||||
__version__ = "6.1.0rc1"
|
||||
|
||||
Reference in New Issue
Block a user