feat(ui): improved automatic tab/panel switching on user actions

This commit is contained in:
psychedelicious
2025-07-04 19:14:39 +10:00
parent f2426c3ab2
commit e73150c3e6
9 changed files with 87 additions and 30 deletions

View File

@@ -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",

View File

@@ -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}
/>
</GridItem>
<GridItem position="relative">
<DndDropTarget
dndTarget={newCanvasEntityFromImageDndTarget}
@@ -54,6 +57,14 @@ export const CanvasDropArea = memo(() => {
isDisabled={isBusy}
/>
</GridItem>
<GridItem position="relative">
<DndDropTarget
dndTarget={newCanvasEntityFromImageDndTarget}
dndTargetData={addResizedControlLayerFromImageDndTargetData}
label={t('controlLayers.canvasContextMenu.newResizedControlLayer')}
isDisabled={isBusy}
/>
</GridItem>
</Grid>
</>
);

View File

@@ -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')}

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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<Pick<CanvasEntityState, 'isEnabled' | 'isLocked' | 'name' | 'position'>>;
}) => {
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<Equals<typeof type, never>>(false);
}
// Switch to the Canvas panel when creating a new canvas from image
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
};
export const replaceCanvasEntityObjectsWithImage = (arg: {

View File

@@ -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);
}

View File

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

View File

@@ -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<WorkflowV3 | null> => {
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',