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',