From e73150c3e6ea703a9ac962626bcd8fc001878d88 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 4 Jul 2025 19:14:39 +1000
Subject: [PATCH] feat(ui): improved automatic tab/panel switching on user
actions
---
invokeai/frontend/web/public/locales/en.json | 1 +
.../components/CanvasDropArea.tsx | 13 ++++-
.../SimpleSession/CanvasLaunchpadPanel.tsx | 2 +
.../SimpleSession/GenerateLaunchpadPanel.tsx | 2 +
invokeai/frontend/web/src/features/dnd/dnd.ts | 11 ++--
.../web/src/features/imageActions/actions.ts | 52 +++++++++++--------
.../web/src/features/queue/hooks/useInvoke.ts | 16 +++++-
.../NewWorkflowConfirmationAlertDialog.tsx | 4 ++
.../hooks/useValidateAndLoadWorkflow.ts | 16 +++++-
9 files changed, 87 insertions(+), 30 deletions(-)
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 253e351817..dfbff9c511 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -2358,6 +2358,7 @@
"newGlobalReferenceImage": "New Global Reference Image",
"newRegionalReferenceImage": "New Regional Reference Image",
"newControlLayer": "New Control Layer",
+ "newResizedControlLayer": "New Resized Control Layer",
"newRasterLayer": "New Raster Layer",
"newInpaintMask": "New Inpaint Mask",
"newRegionalGuidance": "New Regional Guidance",
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx
index 4bde508d2b..ebb8e41404 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx
@@ -12,6 +12,10 @@ const addControlLayerFromImageDndTargetData = newCanvasEntityFromImageDndTarget.
const addRegionalGuidanceReferenceImageFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({
type: 'regional_guidance_with_reference_image',
});
+const addResizedControlLayerFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({
+ type: 'control_layer',
+ withResize: true,
+});
export const CanvasDropArea = memo(() => {
const { t } = useTranslation();
@@ -45,7 +49,6 @@ export const CanvasDropArea = memo(() => {
isDisabled={isBusy}
/>
-
{
isDisabled={isBusy}
/>
+
+
+
>
);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx
index 0371c39ef6..367c71cc4a 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx
@@ -29,6 +29,8 @@ export const CanvasLaunchpadPanel = memo(() => {
as="a"
variant="link"
href="https://support.invoke.ai/support/solutions/articles/151000216086-model-guide"
+ target="_blank"
+ rel="noopener noreferrer"
size="sm"
>
{t('ui.launchpad.modelGuideLink')}
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel.tsx
index d9147814e8..bbd2f4b07b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel.tsx
@@ -25,6 +25,8 @@ export const GenerateLaunchpadPanel = memo(() => {
as="a"
variant="link"
href="https://support.invoke.ai/support/solutions/articles/151000216086-model-guide"
+ target="_blank"
+ rel="noopener noreferrer"
size="sm"
>
Check out our Model Guide.
diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts
index 8667027770..30b99e41fb 100644
--- a/invokeai/frontend/web/src/features/dnd/dnd.ts
+++ b/invokeai/frontend/web/src/features/dnd/dnd.ts
@@ -352,7 +352,10 @@ const _newCanvasEntity = buildTypeAndKey('new-canvas-entity-from-image');
type NewCanvasEntityFromImageDndTargetData = DndData<
typeof _newCanvasEntity.type,
typeof _newCanvasEntity.key,
- { type: CanvasEntityType | 'regional_guidance_with_reference_image' }
+ {
+ type: CanvasEntityType | 'regional_guidance_with_reference_image';
+ withResize?: boolean;
+ }
>;
export const newCanvasEntityFromImageDndTarget: DndTarget<
NewCanvasEntityFromImageDndTargetData,
@@ -368,9 +371,9 @@ export const newCanvasEntityFromImageDndTarget: DndTarget<
return true;
},
handler: ({ sourceData, targetData, dispatch, getState }) => {
- const { type } = targetData.payload;
+ const { type, withResize } = targetData.payload;
const { imageDTO } = sourceData.payload;
- createNewCanvasEntityFromImage({ type, imageDTO, dispatch, getState });
+ createNewCanvasEntityFromImage({ type, imageDTO, withResize, dispatch, getState });
},
};
//#endregion
@@ -381,7 +384,7 @@ type NewCanvasFromImageDndTargetData = DndData<
typeof _newCanvas.type,
typeof _newCanvas.key,
{
- type: CanvasEntityType | 'regional_guidance_with_reference_image' | 'reference_image';
+ type: CanvasEntityType | 'regional_guidance_with_reference_image';
withResize?: boolean;
withInpaintMask?: boolean;
}
diff --git a/invokeai/frontend/web/src/features/imageActions/actions.ts b/invokeai/frontend/web/src/features/imageActions/actions.ts
index 296c9a639d..ac8319a3a0 100644
--- a/invokeai/frontend/web/src/features/imageActions/actions.ts
+++ b/invokeai/frontend/web/src/features/imageActions/actions.ts
@@ -1,9 +1,6 @@
import type { AppDispatch, AppGetState } from 'app/store/store';
import { deepClone } from 'common/util/deepClone';
-import {
- getDefaultRefImageConfig,
- getDefaultRegionalGuidanceRefImageConfig,
-} from 'features/controlLayers/hooks/addLayerHooks';
+import { getDefaultRegionalGuidanceRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks';
import { CanvasEntityTransformer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityTransformer';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { canvasReset } from 'features/controlLayers/store/actions';
@@ -17,7 +14,7 @@ import {
rgAdded,
rgRefImageImageChanged,
} from 'features/controlLayers/store/canvasSlice';
-import { refImageAdded, refImageImageChanged } from 'features/controlLayers/store/refImagesSlice';
+import { refImageImageChanged } from 'features/controlLayers/store/refImagesSlice';
import { selectBboxModelBase, selectBboxRect } from 'features/controlLayers/store/selectors';
import type {
CanvasControlLayerState,
@@ -37,6 +34,8 @@ import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import type { FieldIdentifier } from 'features/nodes/types/field';
import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice';
import { getOptimalDimension } from 'features/parameters/util/optimalDimension';
+import { navigationApi } from 'features/ui/layouts/navigation-api';
+import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
import { imageDTOToFile, imagesApi, uploadImage } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import type { Equals } from 'tsafe';
@@ -76,22 +75,39 @@ export const setComparisonImage = (arg: { image_name: string; dispatch: AppDispa
dispatch(imageToCompareChanged(image_name));
};
-export const createNewCanvasEntityFromImage = (arg: {
+export const createNewCanvasEntityFromImage = async (arg: {
imageDTO: ImageDTO;
type: CanvasEntityType | 'regional_guidance_with_reference_image';
+ withResize?: boolean;
dispatch: AppDispatch;
getState: AppGetState;
overrides?: Partial>;
}) => {
- const { type, imageDTO, dispatch, getState, overrides: _overrides } = arg;
+ const { type, imageDTO, dispatch, getState, withResize, overrides: _overrides } = arg;
const state = getState();
- const imageObject = imageDTOToImageObject(imageDTO);
- const { x, y } = selectBboxRect(state);
+ const { x, y, width, height } = selectBboxRect(state);
+
+ let imageObject: CanvasImageState;
+
+ if (withResize && (width !== imageDTO.width || height !== imageDTO.height)) {
+ const resizedImageDTO = await uploadImage({
+ file: await imageDTOToFile(imageDTO),
+ image_category: 'general',
+ is_intermediate: true,
+ silent: true,
+ resize_to: { width, height },
+ });
+ imageObject = imageDTOToImageObject(resizedImageDTO);
+ } else {
+ imageObject = imageDTOToImageObject(imageDTO);
+ }
+
const overrides = {
objects: [imageObject],
position: { x, y },
..._overrides,
};
+
switch (type) {
case 'raster_layer': {
dispatch(rasterLayerAdded({ overrides, isSelected: true }));
@@ -122,6 +138,8 @@ export const createNewCanvasEntityFromImage = (arg: {
break;
}
}
+
+ navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
};
/**
@@ -137,7 +155,7 @@ export const createNewCanvasEntityFromImage = (arg: {
*/
export const newCanvasFromImage = async (arg: {
imageDTO: ImageDTO;
- type: CanvasEntityType | 'regional_guidance_with_reference_image' | 'reference_image';
+ type: CanvasEntityType | 'regional_guidance_with_reference_image';
withResize?: boolean;
withInpaintMask?: boolean;
dispatch: AppDispatch;
@@ -244,17 +262,6 @@ export const newCanvasFromImage = async (arg: {
dispatch(canvasClearHistory());
break;
}
- case 'reference_image': {
- const config = deepClone(getDefaultRefImageConfig(getState));
- config.image = imageDTOToImageWithDims(imageDTO);
- dispatch(canvasReset());
- dispatch(refImageAdded({ overrides: { config } }));
- if (withInpaintMask) {
- dispatch(inpaintMaskAdded({ isSelected: true, isBookmarked: true }));
- }
- dispatch(canvasClearHistory());
- break;
- }
case 'regional_guidance_with_reference_image': {
const config = getDefaultRegionalGuidanceRefImageConfig(getState);
config.image = imageDTOToImageWithDims(imageDTO);
@@ -270,6 +277,9 @@ export const newCanvasFromImage = async (arg: {
default:
assert>(false);
}
+
+ // Switch to the Canvas panel when creating a new canvas from image
+ navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
};
export const replaceCanvasEntityObjectsWithImage = (arg: {
diff --git a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts
index ba32c41e3d..668b9d4440 100644
--- a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts
+++ b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts
@@ -62,8 +62,14 @@ export const useInvoke = () => {
const enqueueBack = useCallback(() => {
enqueue(false, false);
- if (tabName === 'generate' || tabName === 'workflows' || tabName === 'upscaling') {
+ if (tabName === 'generate' || tabName === 'upscaling') {
navigationApi.focusPanel(tabName, VIEWER_PANEL_ID);
+ } else if (tabName === 'workflows') {
+ // Only switch to viewer if the workflow editor is not currently active
+ const workspace = navigationApi.getPanel('workflows', WORKSPACE_PANEL_ID);
+ if (!workspace?.api.isActive) {
+ navigationApi.focusPanel(tabName, VIEWER_PANEL_ID);
+ }
} else if (tabName === 'canvas') {
navigationApi.focusPanel(tabName, WORKSPACE_PANEL_ID);
}
@@ -71,8 +77,14 @@ export const useInvoke = () => {
const enqueueFront = useCallback(() => {
enqueue(true, false);
- if (tabName === 'generate' || tabName === 'workflows' || tabName === 'upscaling') {
+ if (tabName === 'generate' || tabName === 'upscaling') {
navigationApi.focusPanel(tabName, VIEWER_PANEL_ID);
+ } else if (tabName === 'workflows') {
+ // Only switch to viewer if the workflow editor is not currently active
+ const workspace = navigationApi.getPanel('workflows', WORKSPACE_PANEL_ID);
+ if (!workspace?.api.isActive) {
+ navigationApi.focusPanel(tabName, VIEWER_PANEL_ID);
+ }
} else if (tabName === 'canvas') {
navigationApi.focusPanel(tabName, WORKSPACE_PANEL_ID);
}
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog.tsx
index 4461c52a29..d61814acaa 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog.tsx
@@ -7,6 +7,8 @@ import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal';
import { workflowModeChanged } from 'features/nodes/store/workflowLibrarySlice';
import { toast } from 'features/toast/toast';
+import { navigationApi } from 'features/ui/layouts/navigation-api';
+import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -24,6 +26,8 @@ export const useNewWorkflow = () => {
dispatch(workflowModeChanged('edit'));
workflowLibraryModal.close();
+ navigationApi.focusPanel('workflows', WORKSPACE_PANEL_ID);
+
toast({
id: 'NEW_WORKFLOW_CREATED',
title: t('workflows.newWorkflowCreated'),
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useValidateAndLoadWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useValidateAndLoadWorkflow.ts
index ae1c0cfb12..575ef29089 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useValidateAndLoadWorkflow.ts
+++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useValidateAndLoadWorkflow.ts
@@ -1,14 +1,16 @@
import { logger } from 'app/logging/logger';
import { useAppDispatch } from 'app/store/storeHooks';
+import { getIsFormEmpty } from 'features/nodes/components/sidePanel/builder/form-manipulation';
import { $nodeExecutionStates } from 'features/nodes/hooks/useNodeExecutionState';
import { $templates, workflowLoaded } from 'features/nodes/store/nodesSlice';
import { $needsFit } from 'features/nodes/store/reactFlowInstance';
+import { workflowModeChanged } from 'features/nodes/store/workflowLibrarySlice';
import { WorkflowMigrationError, WorkflowVersionError } from 'features/nodes/types/error';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow';
import { toast } from 'features/toast/toast';
import { navigationApi } from 'features/ui/layouts/navigation-api';
-import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
+import { VIEWER_PANEL_ID, WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { serializeError } from 'serialize-error';
@@ -49,7 +51,6 @@ export const useValidateAndLoadWorkflow = () => {
origin: 'file' | 'image' | 'object' | 'library'
): Promise => {
try {
- await navigationApi.focusPanel('workflows', WORKSPACE_PANEL_ID);
const templates = $templates.get();
const { workflow, warnings } = await validateWorkflow({
workflow: unvalidatedWorkflow,
@@ -68,6 +69,17 @@ export const useValidateAndLoadWorkflow = () => {
$nodeExecutionStates.set({});
dispatch(workflowLoaded(workflow));
+
+ // If the form is empty, assume the user is editing a new workflow.
+ if (getIsFormEmpty(workflow.form)) {
+ dispatch(workflowModeChanged('edit'));
+ navigationApi.focusPanel('workflows', WORKSPACE_PANEL_ID);
+ } else {
+ // Else assume they want to use the linear view of the workflow.
+ dispatch(workflowModeChanged('view'));
+ navigationApi.focusPanel('workflows', VIEWER_PANEL_ID);
+ }
+
if (!warnings.length) {
toast({
id: 'WORKFLOW_LOADED',