feat(ui): more staging fixes

This commit is contained in:
psychedelicious
2025-06-05 19:15:04 +10:00
parent 3a08ea799a
commit 6570c0c3b9
10 changed files with 91 additions and 27 deletions

View File

@@ -18,7 +18,7 @@ const sx = {
h: 108,
w: 108,
flexShrink: 0,
borderWidth: 1,
borderWidth: 2,
borderRadius: 'base',
'&[data-selected="true"]': {
borderColor: 'invokeBlue.300',
@@ -46,8 +46,10 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) =
const onLoad = useCallback(() => {
setImageLoaded(true);
ctx.$lastLoadedItemId.set(item.item_id);
}, [ctx.$lastLoadedItemId, item.item_id]);
if (ctx.$progressData.get()[item.item_id]) {
ctx.$lastLoadedItemId.set(item.item_id);
}
}, [ctx.$lastLoadedItemId, ctx.$progressData, item.item_id]);
return (
<Flex

View File

@@ -22,25 +22,25 @@ export const StagingAreaItemsList = memo(() => {
return effect([ctx.$selectedItem, ctx.$progressData], (selectedItem, progressData) => {
if (!selectedItem) {
canvasManager.stagingArea.render();
canvasManager.stagingArea.$imageSrc.set(null);
return;
}
const outputImageName = getOutputImageName(selectedItem);
if (outputImageName) {
canvasManager.stagingArea.render({ type: 'imageName', data: outputImageName });
canvasManager.stagingArea.$imageSrc.set({ type: 'imageName', data: outputImageName });
return;
}
const data = progressData[selectedItem.item_id];
if (data?.progressImage) {
canvasManager.stagingArea.render({ type: 'dataURL', data: data.progressImage.dataURL });
canvasManager.stagingArea.$imageSrc.set({ type: 'dataURL', data: data.progressImage.dataURL });
return;
}
canvasManager.stagingArea.render();
canvasManager.stagingArea.$imageSrc.set(null);
});
}, [canvasManager, ctx.$progressData, ctx.$selectedItem]);

View File

@@ -67,7 +67,7 @@ const setProgress = ($progressData: WritableAtom<Record<number, ProgressData>>,
}
};
export const clearProgressEvent = ($progressData: WritableAtom<Record<number, ProgressData>>, itemId: number) => {
const clearProgressEvent = ($progressData: WritableAtom<Record<number, ProgressData>>, itemId: number) => {
const progressData = $progressData.get();
const current = progressData[itemId];
if (!current) {
@@ -81,7 +81,7 @@ export const clearProgressEvent = ($progressData: WritableAtom<Record<number, Pr
});
};
export const clearProgressImage = ($progressData: WritableAtom<Record<number, ProgressData>>, itemId: number) => {
const clearProgressImage = ($progressData: WritableAtom<Record<number, ProgressData>>, itemId: number) => {
const progressData = $progressData.get();
const current = progressData[itemId];
if (!current) {

View File

@@ -5,6 +5,7 @@ import { useIsRegionFocused } from 'common/hooks/focus';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice';
import { canvasSessionGenerationFinished } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { selectBboxRect, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import type { CanvasRasterLayerState } from 'features/controlLayers/store/types';
import { imageNameToImageObject } from 'features/controlLayers/store/util';
@@ -12,6 +13,7 @@ import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiCheckBold } from 'react-icons/pi';
import { useDeleteQueueItemsByDestinationMutation } from 'services/api/endpoints/queue';
export const StagingAreaToolbarAcceptButton = memo(() => {
const ctx = useCanvasSessionContext();
@@ -22,6 +24,7 @@ export const StagingAreaToolbarAcceptButton = memo(() => {
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
const isCanvasFocused = useIsRegionFocused('canvas');
const selectedItemImageName = useStore(ctx.$selectedItemOutputImageName);
const [deleteByDestination] = useDeleteQueueItemsByDestinationMutation();
const { t } = useTranslation();
@@ -37,7 +40,9 @@ export const StagingAreaToolbarAcceptButton = memo(() => {
};
dispatch(rasterLayerAdded({ overrides, isSelected: selectedEntityIdentifier?.type === 'raster_layer' }));
}, [bboxRect, selectedItemImageName, dispatch, selectedEntityIdentifier?.type]);
dispatch(canvasSessionGenerationFinished());
deleteByDestination({ destination: ctx.session.id });
}, [selectedItemImageName, bboxRect, dispatch, selectedEntityIdentifier?.type, deleteByDestination, ctx.session.id]);
useHotkeys(
['enter'],
@@ -56,7 +61,7 @@ export const StagingAreaToolbarAcceptButton = memo(() => {
icon={<PiCheckBold />}
onClick={acceptSelected}
colorScheme="invokeBlue"
isDisabled={!selectedItemImageName}
isDisabled={!selectedItemImageName || !shouldShowStagedImage}
/>
);
});

View File

@@ -1,18 +1,26 @@
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 { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { canvasSessionGenerationFinished } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold } from 'react-icons/pi';
import { useDeleteQueueItemsByDestinationMutation } from 'services/api/endpoints/queue';
export const StagingAreaToolbarDiscardAllButton = memo(() => {
const canvasManager = useCanvasManager();
const ctx = useCanvasSessionContext();
const dispatch = useAppDispatch();
const { t } = useTranslation();
const [deleteByDestination] = useDeleteQueueItemsByDestinationMutation();
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
const discardAll = useCallback(() => {
deleteByDestination({ destination: ctx.session.id });
}, [deleteByDestination, ctx.session.id]);
dispatch(canvasSessionGenerationFinished());
}, [deleteByDestination, ctx.session.id, dispatch]);
return (
<IconButton
@@ -22,6 +30,7 @@ export const StagingAreaToolbarDiscardAllButton = memo(() => {
onClick={discardAll}
colorScheme="error"
fontSize={16}
isDisabled={!shouldShowStagedImage}
/>
);
});

View File

@@ -1,15 +1,18 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
import { useDeleteQueueItemMutation } from 'services/api/endpoints/queue';
export const StagingAreaToolbarDiscardSelectedButton = memo(() => {
const canvasManager = useCanvasManager();
const ctx = useCanvasSessionContext();
const [deleteQueueItem] = useDeleteQueueItemMutation();
const selectedItemId = useStore(ctx.$selectedItemId);
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
const { t } = useTranslation();
@@ -28,7 +31,7 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(() => {
onClick={discardSelected}
colorScheme="invokeBlue"
fontSize={16}
isDisabled={selectedItemId === null}
isDisabled={selectedItemId === null || !shouldShowStagedImage}
/>
);
});

View File

@@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react';
import { useAppStore } from 'app/store/nanostores/store';
import { NewLayerIcon } from 'features/controlLayers/components/common/icons';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { createNewCanvasEntityFromImage } from 'features/imageActions/actions';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
@@ -13,10 +14,12 @@ import { copyImage } from 'services/api/endpoints/images';
const uploadImageArg = { image_category: 'general', is_intermediate: true, silent: true } as const;
export const StagingAreaToolbarSaveAsMenu = memo(() => {
const canvasManager = useCanvasManager();
const { t } = useTranslation();
const ctx = useCanvasSessionContext();
const imageName = useStore(ctx.$selectedItemOutputImageName);
const store = useAppStore();
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
const toastSentToCanvas = useCallback(() => {
toast({
@@ -101,19 +104,35 @@ export const StagingAreaToolbarSaveAsMenu = memo(() => {
tooltip={t('controlLayers.newLayerFromImage')}
icon={<PiDotsThreeBold />}
colorScheme="invokeBlue"
isDisabled={!imageName}
isDisabled={!imageName || !shouldShowStagedImage}
/>
<MenuList>
<MenuItem icon={<NewLayerIcon />} onClickCapture={onClickNewInpaintMaskFromImage} isDisabled={!imageName}>
<MenuItem
icon={<NewLayerIcon />}
onClickCapture={onClickNewInpaintMaskFromImage}
isDisabled={!imageName || !shouldShowStagedImage}
>
{t('controlLayers.inpaintMask')}
</MenuItem>
<MenuItem icon={<NewLayerIcon />} onClickCapture={onClickNewRegionalGuidanceFromImage} isDisabled={!imageName}>
<MenuItem
icon={<NewLayerIcon />}
onClickCapture={onClickNewRegionalGuidanceFromImage}
isDisabled={!imageName || !shouldShowStagedImage}
>
{t('controlLayers.regionalGuidance')}
</MenuItem>
<MenuItem icon={<NewLayerIcon />} onClickCapture={onClickNewControlLayerFromImage} isDisabled={!imageName}>
<MenuItem
icon={<NewLayerIcon />}
onClickCapture={onClickNewControlLayerFromImage}
isDisabled={!imageName || !shouldShowStagedImage}
>
{t('controlLayers.controlLayer')}
</MenuItem>
<MenuItem icon={<NewLayerIcon />} onClickCapture={onClickNewRasterLayerFromImage} isDisabled={!imageName}>
<MenuItem
icon={<NewLayerIcon />}
onClickCapture={onClickNewRasterLayerFromImage}
isDisabled={!imageName || !shouldShowStagedImage}
>
{t('controlLayers.rasterLayer')}
</MenuItem>
</MenuList>

View File

@@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { withResultAsync } from 'common/util/result';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
@@ -13,9 +14,11 @@ import { copyImage } from 'services/api/endpoints/images';
const TOAST_ID = 'SAVE_STAGING_AREA_IMAGE_TO_GALLERY';
export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => {
const canvasManager = useCanvasManager();
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const ctx = useCanvasSessionContext();
const imageName = useStore(ctx.$selectedItemOutputImageName);
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
const { t } = useTranslation();
@@ -61,7 +64,7 @@ export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => {
icon={<PiFloppyDiskBold />}
onClick={saveSelectedImageToGallery}
colorScheme="invokeBlue"
isDisabled={!imageName}
isDisabled={!imageName || !shouldShowStagedImage}
/>
);
});

View File

@@ -12,8 +12,8 @@ export const StagingAreaToolbarToggleShowResultsButton = memo(() => {
const { t } = useTranslation();
const toggleShowResults = useCallback(() => {
canvasManager.stagingArea.$shouldShowStagedImage.set(!shouldShowStagedImage);
}, [canvasManager.stagingArea.$shouldShowStagedImage, shouldShowStagedImage]);
canvasManager.stagingArea.$shouldShowStagedImage.set(!canvasManager.stagingArea.$shouldShowStagedImage.get());
}, [canvasManager.stagingArea.$shouldShowStagedImage]);
return (
<IconButton

View File

@@ -3,6 +3,7 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import type { CanvasImageState } from 'features/controlLayers/store/types';
import Konva from 'konva';
import { atom } from 'nanostores';
@@ -21,8 +22,10 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
image: CanvasObjectImage | null;
mutex = new Mutex();
$imageSrc = atom<{ type: 'imageName'; data: string } | { type: 'dataURL'; data: string } | null>(null);
$shouldShowStagedImage = atom<boolean>(true);
$isStaging = atom(true); //TODO: wire up to queue?
$isStaging = atom<boolean>(false);
constructor(manager: CanvasManager) {
super();
@@ -40,9 +43,26 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
/**
* When we change this flag, we need to re-render the staging area, which hides or shows the staged image.
*/
this.subscriptions.add(this.$shouldShowStagedImage.listen(this.render));
/**
* Rerender when the image source changes.
*/
this.subscriptions.add(this.$imageSrc.listen(this.render));
/**
* Sync the $isStaging flag with the redux state. $isStaging is used by the manager to determine the global busy
* state of the canvas.
*
* We also set the $shouldShowStagedImage flag when we enter staging mode, so that the staged images are shown,
* even if the user disabled this in the last staging session.
*/
this.subscriptions.add(
this.$shouldShowStagedImage.listen(() => {
this.render();
this.manager.stateApi.createStoreSubscription(selectIsStaging, (isStaging, oldIsStaging) => {
this.$isStaging.set(isStaging);
if (isStaging && !oldIsStaging) {
this.$shouldShowStagedImage.set(true);
}
})
);
}
@@ -50,6 +70,7 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
initialize = () => {
this.log.debug('Initializing module');
this.render();
this.$isStaging.set(this.manager.stateApi.runSelector(selectIsStaging));
};
getImageFromSrc = (
@@ -72,7 +93,7 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
}
};
render = async (imageSrc?: { type: 'imageName'; data: string } | { type: 'dataURL'; data: string }) => {
render = async () => {
const release = await this.mutex.acquire();
try {
this.log.trace('Rendering staging area');
@@ -82,6 +103,8 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
this.konva.group.position({ x, y });
const imageSrc = this.$imageSrc.get();
if (imageSrc) {
const image = this.getImageFromSrc(imageSrc, width, height);
if (!this.image) {
@@ -91,13 +114,13 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
} else if (this.image.isLoading || this.image.isError) {
// noop
} else {
await this.image.update({ ...this.image.state, image }, true);
await this.image.update({ ...this.image.state, image });
}
this.image.konva.group.visible(shouldShowStagedImage);
} else {
this.image?.destroy();
this.image = null;
}
this.konva.group.visible(shouldShowStagedImage);
} finally {
release();
}