refactor(ui): simplify canvas session logic

This commit is contained in:
psychedelicious
2025-07-14 16:31:35 +10:00
parent 7561b73e8f
commit dd03e3ddcd
31 changed files with 536 additions and 740 deletions

View File

@@ -2087,9 +2087,9 @@
"resetCanvasLayers": "Reset Canvas Layers",
"resetGenerationSettings": "Reset Generation Settings",
"replaceCurrent": "Replace Current",
"controlLayerEmptyState": "<UploadButton>Upload an image</UploadButton>, drag an image from the <GalleryButton>gallery</GalleryButton> onto this layer, <PullBboxButton>pull the bounding box into this layer</PullBboxButton>, or draw on the canvas to get started.",
"referenceImageEmptyStateWithCanvasOptions": "<UploadButton>Upload an image</UploadButton>, drag an image from the <GalleryButton>gallery</GalleryButton> onto this Reference Image or <PullBboxButton>pull the bounding box into this Reference Image</PullBboxButton> to get started.",
"referenceImageEmptyState": "<UploadButton>Upload an image</UploadButton> or drag an image from the <GalleryButton>gallery</GalleryButton> onto this Reference Image to get started.",
"controlLayerEmptyState": "<UploadButton>Upload an image</UploadButton>, drag an image from the gallery onto this layer, <PullBboxButton>pull the bounding box into this layer</PullBboxButton>, or draw on the canvas to get started.",
"referenceImageEmptyStateWithCanvasOptions": "<UploadButton>Upload an image</UploadButton>, drag an image from the gallery onto this Reference Image or <PullBboxButton>pull the bounding box into this Reference Image</PullBboxButton> to get started.",
"referenceImageEmptyState": "<UploadButton>Upload an image</UploadButton> or drag an image from the gallery onto this Reference Image to get started.",
"uploadOrDragAnImage": "Drag an image from the gallery or <UploadButton>upload an image</UploadButton>.",
"imageNoise": "Image Noise",
"denoiseLimit": "Denoise Limit",

View File

@@ -1,10 +1,6 @@
import { GlobalImageHotkeys } from 'app/components/GlobalImageHotkeys';
import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal';
import { CanvasPasteModal } from 'features/controlLayers/components/CanvasPasteModal';
import {
NewCanvasSessionDialog,
NewGallerySessionDialog,
} from 'features/controlLayers/components/NewSessionConfirmationAlertDialog';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { DeleteImageModal } from 'features/deleteImageModal/components/DeleteImageModal';
import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone';
@@ -50,8 +46,6 @@ export const GlobalModalIsolator = memo(() => {
<RefreshAfterResetModal />
<DeleteBoardModal />
<GlobalImageHotkeys />
<NewGallerySessionDialog />
<NewCanvasSessionDialog />
<ImageContextMenu />
<FullscreenDropzone />
<VideosModal />

View File

@@ -21,7 +21,6 @@ import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/st
import { toast } from 'features/toast/toast';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { LAUNCHPAD_PANEL_ID, WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import { atom } from 'nanostores';
import { useCallback, useEffect } from 'react';
@@ -165,7 +164,6 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
// Go to the generate tab, open the launchpad
await navigationApi.focusPanel('generate', LAUNCHPAD_PANEL_ID);
store.dispatch(paramsReset());
store.dispatch(activeTabCanvasRightPanelChanged('gallery'));
break;
case 'canvas':
// Go to the canvas tab, open the launchpad

View File

@@ -1,7 +1,7 @@
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { bboxSyncedToOptimalDimension, rgRefImageModelChanged } from 'features/controlLayers/store/canvasSlice';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { buildSelectIsStaging, selectCanvasSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { loraDeleted } from 'features/controlLayers/store/lorasSlice';
import { modelChanged, syncedToOptimalDimension, vaeSelected } from 'features/controlLayers/store/paramsSlice';
import { refImageModelChanged, selectReferenceImageEntities } from 'features/controlLayers/store/refImagesSlice';
@@ -152,7 +152,8 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
if (modelBase !== state.params.model?.base) {
// Sync generate tab settings whenever the model base changes
dispatch(syncedToOptimalDimension());
if (!selectIsStaging(state)) {
const isStaging = buildSelectIsStaging(selectCanvasSessionId(state))(state);
if (!isStaging) {
// Canvas tab only syncs if not staging
dispatch(bboxSyncedToOptimalDimension());
}

View File

@@ -1,7 +1,7 @@
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { isNil } from 'es-toolkit';
import { bboxHeightChanged, bboxWidthChanged } from 'features/controlLayers/store/canvasSlice';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { buildSelectIsStaging, selectCanvasSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice';
import {
heightChanged,
setCfgRescaleMultiplier,
@@ -115,7 +115,8 @@ export const addSetDefaultSettingsListener = (startAppListening: AppStartListeni
}
const setSizeOptions = { updateAspectRatio: true, clamp: true };
const isStaging = selectIsStaging(getState());
const isStaging = buildSelectIsStaging(selectCanvasSessionId(state))(state);
const activeTab = selectActiveTab(getState());
if (activeTab === 'generate') {
if (isParameterWidth(width)) {

View File

@@ -5,7 +5,6 @@ import { useEntityIdentifierContext } from 'features/controlLayers/contexts/Enti
import { usePullBboxIntoLayer } from 'features/controlLayers/hooks/saveCanvasHooks';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { replaceCanvasEntityObjectsWithImage } from 'features/imageActions/actions';
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
import { memo, useCallback, useMemo } from 'react';
import { Trans } from 'react-i18next';
import type { ImageDTO } from 'services/api/types';
@@ -21,9 +20,6 @@ export const ControlLayerSettingsEmptyState = memo(() => {
[dispatch, entityIdentifier, getState]
);
const uploadApi = useImageUploadButton({ onUpload, allowMultiple: false });
const onClickGalleryButton = useCallback(() => {
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}, [dispatch]);
const pullBboxIntoLayer = usePullBboxIntoLayer(entityIdentifier);
const components = useMemo(
@@ -31,14 +27,11 @@ export const ControlLayerSettingsEmptyState = memo(() => {
UploadButton: (
<Button isDisabled={isBusy} size="sm" variant="link" color="base.300" {...uploadApi.getUploadButtonProps()} />
),
GalleryButton: (
<Button onClick={onClickGalleryButton} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
),
PullBboxButton: (
<Button onClick={pullBboxIntoLayer} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
),
}),
[isBusy, onClickGalleryButton, pullBboxIntoLayer, uploadApi]
[isBusy, pullBboxIntoLayer, uploadApi]
);
return (

View File

@@ -1,131 +0,0 @@
import { Checkbox, ConfirmationAlertDialog, Flex, FormControl, FormLabel, Text } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { buildUseBoolean } from 'common/hooks/useBoolean';
import { canvasSessionReset, generateSessionReset } from 'features/controlLayers/store/canvasStagingAreaSlice';
import {
selectSystemShouldConfirmOnNewSession,
shouldConfirmOnNewSessionToggled,
} from 'features/system/store/systemSlice';
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
const [useNewGallerySessionDialog] = buildUseBoolean(false);
const [useNewCanvasSessionDialog] = buildUseBoolean(false);
const useNewGallerySession = () => {
const dispatch = useAppDispatch();
const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession);
const newSessionDialog = useNewGallerySessionDialog();
const newGallerySessionImmediate = useCallback(() => {
dispatch(generateSessionReset());
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}, [dispatch]);
const newGallerySessionWithDialog = useCallback(() => {
if (shouldConfirmOnNewSession) {
newSessionDialog.setTrue();
return;
}
newGallerySessionImmediate();
}, [newGallerySessionImmediate, newSessionDialog, shouldConfirmOnNewSession]);
return { newGallerySessionImmediate, newGallerySessionWithDialog };
};
const useNewCanvasSession = () => {
const dispatch = useAppDispatch();
const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession);
const newSessionDialog = useNewCanvasSessionDialog();
const newCanvasSessionImmediate = useCallback(() => {
dispatch(canvasSessionReset());
dispatch(activeTabCanvasRightPanelChanged('layers'));
}, [dispatch]);
const newCanvasSessionWithDialog = useCallback(() => {
if (shouldConfirmOnNewSession) {
newSessionDialog.setTrue();
return;
}
newCanvasSessionImmediate();
}, [newCanvasSessionImmediate, newSessionDialog, shouldConfirmOnNewSession]);
return { newCanvasSessionImmediate, newCanvasSessionWithDialog };
};
export const NewGallerySessionDialog = memo(() => {
useAssertSingleton('NewGallerySessionDialog');
const { t } = useTranslation();
const dispatch = useAppDispatch();
const dialog = useNewGallerySessionDialog();
const { newGallerySessionImmediate } = useNewGallerySession();
const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession);
const onToggleConfirm = useCallback(() => {
dispatch(shouldConfirmOnNewSessionToggled());
}, [dispatch]);
return (
<ConfirmationAlertDialog
isOpen={dialog.isTrue}
onClose={dialog.setFalse}
title={t('controlLayers.newGallerySession')}
acceptCallback={newGallerySessionImmediate}
acceptButtonText={t('common.ok')}
useInert={false}
>
<Flex direction="column" gap={3}>
<Text>{t('controlLayers.newGallerySessionDesc')}</Text>
<Text>{t('common.areYouSure')}</Text>
<FormControl>
<FormLabel>{t('common.dontAskMeAgain')}</FormLabel>
<Checkbox isChecked={!shouldConfirmOnNewSession} onChange={onToggleConfirm} />
</FormControl>
</Flex>
</ConfirmationAlertDialog>
);
});
NewGallerySessionDialog.displayName = 'NewGallerySessionDialog';
export const NewCanvasSessionDialog = memo(() => {
useAssertSingleton('NewCanvasSessionDialog');
const { t } = useTranslation();
const dispatch = useAppDispatch();
const dialog = useNewCanvasSessionDialog();
const { newCanvasSessionImmediate } = useNewCanvasSession();
const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession);
const onToggleConfirm = useCallback(() => {
dispatch(shouldConfirmOnNewSessionToggled());
}, [dispatch]);
return (
<ConfirmationAlertDialog
isOpen={dialog.isTrue}
onClose={dialog.setFalse}
title={t('controlLayers.newCanvasSession')}
acceptCallback={newCanvasSessionImmediate}
acceptButtonText={t('common.ok')}
useInert={false}
>
<Flex direction="column" gap={3}>
<Text>{t('controlLayers.newCanvasSessionDesc')}</Text>
<Text>{t('common.areYouSure')}</Text>
<FormControl>
<FormLabel>{t('common.dontAskMeAgain')}</FormLabel>
<Checkbox isChecked={!shouldConfirmOnNewSession} onChange={onToggleConfirm} />
</FormControl>
</Flex>
</ConfirmationAlertDialog>
);
});
NewCanvasSessionDialog.displayName = 'NewCanvasSessionDialog';

View File

@@ -6,7 +6,6 @@ import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd';
import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { setGlobalReferenceImage } from 'features/imageActions/actions';
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
import { memo, useCallback, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import type { ImageDTO } from 'services/api/types';
@@ -22,9 +21,6 @@ export const RefImageNoImageState = memo(() => {
[dispatch, id]
);
const uploadApi = useImageUploadButton({ onUpload, allowMultiple: false });
const onClickGalleryButton = useCallback(() => {
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}, [dispatch]);
const dndTargetData = useMemo<SetGlobalReferenceImageDndTargetData>(
() => setGlobalReferenceImageDndTarget.getData({ id }),
@@ -34,9 +30,8 @@ export const RefImageNoImageState = memo(() => {
const components = useMemo(
() => ({
UploadButton: <Button size="sm" variant="link" color="base.300" {...uploadApi.getUploadButtonProps()} />,
GalleryButton: <Button onClick={onClickGalleryButton} size="sm" variant="link" color="base.300" />,
}),
[onClickGalleryButton, uploadApi]
[uploadApi]
);
return (

View File

@@ -8,7 +8,6 @@ import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd';
import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { setGlobalReferenceImage } from 'features/imageActions/actions';
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
import { memo, useCallback, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import type { ImageDTO } from 'services/api/types';
@@ -25,9 +24,6 @@ export const RefImageNoImageStateWithCanvasOptions = memo(() => {
[dispatch, id]
);
const uploadApi = useImageUploadButton({ onUpload, allowMultiple: false });
const onClickGalleryButton = useCallback(() => {
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}, [dispatch]);
const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(id);
const dndTargetData = useMemo<SetGlobalReferenceImageDndTargetData>(
@@ -40,14 +36,11 @@ export const RefImageNoImageStateWithCanvasOptions = memo(() => {
UploadButton: (
<Button isDisabled={isBusy} size="sm" variant="link" color="base.300" {...uploadApi.getUploadButtonProps()} />
),
GalleryButton: (
<Button onClick={onClickGalleryButton} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
),
PullBboxButton: (
<Button onClick={pullBboxIntoIPAdapter} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
),
}),
[isBusy, onClickGalleryButton, pullBboxIntoIPAdapter, uploadApi]
[isBusy, pullBboxIntoIPAdapter, uploadApi]
);
return (

View File

@@ -9,7 +9,6 @@ import type { SetRegionalGuidanceReferenceImageDndTargetData } from 'features/dn
import { setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { setRegionalGuidanceReferenceImage } from 'features/imageActions/actions';
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
import { memo, useCallback, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
@@ -31,9 +30,6 @@ export const RegionalGuidanceIPAdapterSettingsEmptyState = memo(({ referenceImag
[dispatch, entityIdentifier, referenceImageId]
);
const uploadApi = useImageUploadButton({ onUpload, allowMultiple: false });
const onClickGalleryButton = useCallback(() => {
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}, [dispatch]);
const onDeleteIPAdapter = useCallback(() => {
dispatch(rgRefImageDeleted({ entityIdentifier, referenceImageId }));
}, [dispatch, entityIdentifier, referenceImageId]);
@@ -53,14 +49,11 @@ export const RegionalGuidanceIPAdapterSettingsEmptyState = memo(({ referenceImag
UploadButton: (
<Button isDisabled={isBusy} size="sm" variant="link" color="base.300" {...uploadApi.getUploadButtonProps()} />
),
GalleryButton: (
<Button onClick={onClickGalleryButton} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
),
PullBboxButton: (
<Button onClick={pullBboxIntoIPAdapter} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
),
}),
[isBusy, onClickGalleryButton, pullBboxIntoIPAdapter, uploadApi]
[isBusy, pullBboxIntoIPAdapter, uploadApi]
);
return (

View File

@@ -149,8 +149,8 @@ export const StagingAreaItemsList = memo(() => {
return;
}
return canvasManager.stagingArea.connectToSession(ctx.$selectedItemId, ctx.$progressData, ctx.$isPending);
}, [canvasManager, ctx.$progressData, ctx.$selectedItemId, ctx.$isPending]);
return canvasManager.stagingArea.connectToSession(ctx.$items, ctx.$selectedItemId, ctx.$progressData);
}, [canvasManager, ctx.$progressData, ctx.$selectedItemId, ctx.$items]);
useEffect(() => {
return ctx.$selectedItemIndex.listen((index) => {

View File

@@ -1,11 +1,12 @@
import { useStore } from '@nanostores/react';
import { useAppStore } from 'app/store/storeHooks';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared';
import { selectStagingAreaAutoSwitch } from 'features/controlLayers/store/canvasSettingsSlice';
import {
buildSelectSessionQueueItems,
buildSelectCanvasQueueItems,
canvasQueueItemDiscarded,
canvasSessionReset,
selectCanvasSessionId,
} from 'features/controlLayers/store/canvasStagingAreaSlice';
import type { ProgressImage } from 'features/nodes/types/common';
import type { Atom, MapStore, StoreValue, WritableAtom } from 'nanostores';
@@ -86,11 +87,9 @@ const setProgress = ($progressData: ProgressDataMap, data: S['InvocationProgress
export type ProgressDataMap = MapStore<Record<number, ProgressData | undefined>>;
type CanvasSessionContextValue = {
session: { id: string; type: 'simple' | 'advanced' };
$items: Atom<S['SessionQueueItem'][]>;
$itemCount: Atom<number>;
$hasItems: Atom<boolean>;
$isPending: Atom<boolean>;
$progressData: ProgressDataMap;
$selectedItemId: WritableAtom<number | null>;
$selectedItem: Atom<S['SessionQueueItem'] | null>;
@@ -107,465 +106,446 @@ type CanvasSessionContextValue = {
const CanvasSessionContext = createContext<CanvasSessionContextValue | null>(null);
export const CanvasSessionContextProvider = memo(
({ id, type, children }: PropsWithChildren<{ id: string; type: 'simple' | 'advanced' }>) => {
/**
* For best performance and interop with the Canvas, which is outside react but needs to interact with the react
* app, all canvas session state is packaged as nanostores atoms. The trickiest part is syncing the queue items
* with a nanostores atom.
*/
const session = useMemo(() => ({ type, id }), [type, id]);
export const CanvasSessionContextProvider = memo(({ children }: PropsWithChildren) => {
/**
* For best performance and interop with the Canvas, which is outside react but needs to interact with the react
* app, all canvas session state is packaged as nanostores atoms. The trickiest part is syncing the queue items
* with a nanostores atom.
*/
/**
* App store
*/
const store = useAppStore();
/**
* App store
*/
const store = useAppStore();
const socket = useStore($socket);
const sessionId = useAppSelector(selectCanvasSessionId);
/**
* Track the last completed item. Used to implement autoswitch.
*/
const $lastCompletedItemId = useState(() => atom<number | null>(null))[0];
const socket = useStore($socket);
/**
* Track the last started item. Used to implement autoswitch.
*/
const $lastStartedItemId = useState(() => atom<number | null>(null))[0];
/**
* Track the last completed item. Used to implement autoswitch.
*/
const $lastCompletedItemId = useState(() => atom<number | null>(null))[0];
/**
* Manually-synced atom containing queue items for the current session. This is populated from the RTK Query cache
* and kept in sync with it via a redux subscription.
*/
const $items = useState(() => atom<S['SessionQueueItem'][]>([]))[0];
/**
* Track the last started item. Used to implement autoswitch.
*/
const $lastStartedItemId = useState(() => atom<number | null>(null))[0];
/**
* An internal flag used to work around race conditions with auto-switch switching to queue items before their
* output images have fully loaded.
*/
const $lastLoadedItemId = useState(() => atom<number | null>(null))[0];
/**
* Manually-synced atom containing queue items for the current session. This is populated from the RTK Query cache
* and kept in sync with it via a redux subscription.
*/
const $items = useState(() => atom<S['SessionQueueItem'][]>([]))[0];
/**
* An ephemeral store of progress events and images for all items in the current session.
*/
const $progressData = useState(() => map<StoreValue<ProgressDataMap>>({}))[0];
/**
* An internal flag used to work around race conditions with auto-switch switching to queue items before their
* output images have fully loaded.
*/
const $lastLoadedItemId = useState(() => atom<number | null>(null))[0];
/**
* The currently selected queue item's ID, or null if one is not selected.
*/
const $selectedItemId = useState(() => atom<number | null>(null))[0];
/**
* An ephemeral store of progress events and images for all items in the current session.
*/
const $progressData = useState(() => map<StoreValue<ProgressDataMap>>({}))[0];
/**
* The number of items. Computed from the queue items array.
*/
const $itemCount = useState(() => computed([$items], (items) => items.length))[0];
/**
* The currently selected queue item's ID, or null if one is not selected.
*/
const $selectedItemId = useState(() => atom<number | null>(null))[0];
/**
* Whether there are any items. Computed from the queue items array.
*/
const $hasItems = useState(() => computed([$items], (items) => items.length > 0))[0];
/**
* The number of items. Computed from the queue items array.
*/
const $itemCount = useState(() => computed([$items], (items) => items.length))[0];
/**
* Whether there are any pending or in-progress items. Computed from the queue items array.
*/
const $isPending = useState(() =>
computed([$items], (items) => items.some((item) => item.status === 'pending' || item.status === 'in_progress'))
)[0];
/**
* Whether there are any items. Computed from the queue items array.
*/
const $hasItems = useState(() => computed([$items], (items) => items.length > 0))[0];
/**
* The currently selected queue item, or null if one is not selected.
*/
const $selectedItem = useState(() =>
computed([$items, $selectedItemId], (items, selectedItemId) => {
/**
* Whether there are any pending or in-progress items. Computed from the queue items array.
*/
const $isPending = useState(() =>
computed([$items], (items) => items.some((item) => item.status === 'pending' || item.status === 'in_progress'))
)[0];
/**
* The currently selected queue item, or null if one is not selected.
*/
const $selectedItem = useState(() =>
computed([$items, $selectedItemId], (items, selectedItemId) => {
if (items.length === 0) {
return null;
}
if (selectedItemId === null) {
return null;
}
return items.find(({ item_id }) => item_id === selectedItemId) ?? null;
})
)[0];
/**
* The currently selected queue item's index in the list of items, or null if one is not selected.
*/
const $selectedItemIndex = useState(() =>
computed([$items, $selectedItemId], (items, selectedItemId) => {
if (items.length === 0) {
return null;
}
if (selectedItemId === null) {
return null;
}
return items.findIndex(({ item_id }) => item_id === selectedItemId) ?? null;
})
)[0];
/**
* The currently selected queue item's output image name, or null if one is not selected or there is no output
* image recorded.
*/
const $selectedItemOutputImageDTO = useState(() =>
computed([$selectedItemId, $progressData], (selectedItemId, progressData) => {
if (selectedItemId === null) {
return null;
}
const datum = progressData[selectedItemId];
if (!datum) {
return null;
}
return datum.imageDTO;
})
)[0];
/**
* A redux selector to select all queue items from the RTK Query cache.
*/
const selectQueueItems = useMemo(() => buildSelectCanvasQueueItems(sessionId), [sessionId]);
const discard = useCallback(
(itemId: number) => {
store.dispatch(canvasQueueItemDiscarded({ itemId }));
},
[store]
);
const discardAll = useCallback(() => {
store.dispatch(canvasSessionReset());
}, [store]);
const selectNext = useCallback(() => {
const selectedItemId = $selectedItemId.get();
if (selectedItemId === null) {
return;
}
const items = $items.get();
const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
const nextIndex = (currentIndex + 1) % items.length;
const nextItem = items[nextIndex];
if (!nextItem) {
return;
}
$selectedItemId.set(nextItem.item_id);
}, [$items, $selectedItemId]);
const selectPrev = useCallback(() => {
const selectedItemId = $selectedItemId.get();
if (selectedItemId === null) {
return;
}
const items = $items.get();
const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
const prevIndex = (currentIndex - 1 + items.length) % items.length;
const prevItem = items[prevIndex];
if (!prevItem) {
return;
}
$selectedItemId.set(prevItem.item_id);
}, [$items, $selectedItemId]);
const selectFirst = useCallback(() => {
const items = $items.get();
const first = items.at(0);
if (!first) {
return;
}
$selectedItemId.set(first.item_id);
}, [$items, $selectedItemId]);
const selectLast = useCallback(() => {
const items = $items.get();
const last = items.at(-1);
if (!last) {
return;
}
$selectedItemId.set(last.item_id);
}, [$items, $selectedItemId]);
const onImageLoad = useCallback(
(itemId: number) => {
const progressData = $progressData.get();
const current = progressData[itemId];
if (current) {
const next = { ...current, imageLoaded: true };
$progressData.setKey(itemId, next);
} else {
$progressData.setKey(itemId, {
...getInitialProgressData(itemId),
imageLoaded: true,
});
}
if (
$lastCompletedItemId.get() === itemId &&
selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_finish'
) {
$selectedItemId.set(itemId);
$lastCompletedItemId.set(null);
}
},
[$lastCompletedItemId, $progressData, $selectedItemId, store]
);
// Set up socket listeners
useEffect(() => {
if (!socket) {
return;
}
const onProgress = (data: S['InvocationProgressEvent']) => {
if (data.destination !== sessionId) {
return;
}
setProgress($progressData, data);
};
const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => {
if (data.destination !== sessionId) {
return;
}
if (data.status === 'completed') {
$lastCompletedItemId.set(data.item_id);
}
if (data.status === 'in_progress') {
$lastStartedItemId.set(data.item_id);
}
};
socket.on('invocation_progress', onProgress);
socket.on('queue_item_status_changed', onQueueItemStatusChanged);
return () => {
socket.off('invocation_progress', onProgress);
socket.off('queue_item_status_changed', onQueueItemStatusChanged);
};
}, [$lastCompletedItemId, $lastStartedItemId, $progressData, $selectedItemId, sessionId, socket]);
// Set up state subscriptions and effects
useEffect(() => {
let _prevItems: readonly S['SessionQueueItem'][] = [];
// Seed the $items atom with the initial query cache state
$items.set(selectQueueItems(store.getState()));
// Manually keep the $items atom in sync as the query cache is updated
const unsubReduxSyncToItemsAtom = store.subscribe(() => {
const prevItems = $items.get();
const items = selectQueueItems(store.getState());
if (items !== prevItems) {
_prevItems = prevItems;
$items.set(items);
}
});
// Handle cases that could result in a nonexistent queue item being selected.
const unsubEnsureSelectedItemIdExists = effect(
[$items, $selectedItemId, $lastStartedItemId],
(items, selectedItemId, lastStartedItemId) => {
if (items.length === 0) {
return null;
// If there are no items, cannot have a selected item.
$selectedItemId.set(null);
} else if (selectedItemId === null && items.length > 0) {
// If there is no selected item but there are items, select the first one.
$selectedItemId.set(items[0]?.item_id ?? null);
return;
} else if (
selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_start' &&
items.findIndex(({ item_id }) => item_id === lastStartedItemId) !== -1
) {
$selectedItemId.set(lastStartedItemId);
$lastStartedItemId.set(null);
} else if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) {
// If an item is selected and it is not in the list of items, un-set it. This effect will run again and we'll
// the above case, selecting the first item if there are any.
let prevIndex = _prevItems.findIndex(({ item_id }) => item_id === selectedItemId);
if (prevIndex >= items.length) {
prevIndex = items.length - 1;
}
const nextItem = items[prevIndex];
$selectedItemId.set(nextItem?.item_id ?? null);
}
if (selectedItemId === null) {
return null;
}
return items.find(({ item_id }) => item_id === selectedItemId) ?? null;
})
)[0];
/**
* The currently selected queue item's index in the list of items, or null if one is not selected.
*/
const $selectedItemIndex = useState(() =>
computed([$items, $selectedItemId], (items, selectedItemId) => {
if (items.length === 0) {
return null;
if (items !== _prevItems) {
_prevItems = items;
}
if (selectedItemId === null) {
return null;
}
return items.findIndex(({ item_id }) => item_id === selectedItemId) ?? null;
})
)[0];
/**
* The currently selected queue item's output image name, or null if one is not selected or there is no output
* image recorded.
*/
const $selectedItemOutputImageDTO = useState(() =>
computed([$selectedItemId, $progressData], (selectedItemId, progressData) => {
if (selectedItemId === null) {
return null;
}
const datum = progressData[selectedItemId];
if (!datum) {
return null;
}
return datum.imageDTO;
})
)[0];
/**
* A redux selector to select all queue items from the RTK Query cache.
*/
const selectQueueItems = useMemo(() => buildSelectSessionQueueItems(session.id), [session.id]);
const discard = useCallback(
(itemId: number) => {
store.dispatch(canvasQueueItemDiscarded({ itemId }));
},
[store]
}
);
const discardAll = useCallback(() => {
store.dispatch(canvasSessionReset());
}, [store]);
// Clean up the progress data when a queue item is discarded.
const unsubCleanUpProgressData = $items.subscribe(async (items) => {
const progressData = $progressData.get();
const selectNext = useCallback(() => {
const selectedItemId = $selectedItemId.get();
if (selectedItemId === null) {
return;
}
const items = $items.get();
const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
const nextIndex = (currentIndex + 1) % items.length;
const nextItem = items[nextIndex];
if (!nextItem) {
return;
}
$selectedItemId.set(nextItem.item_id);
}, [$items, $selectedItemId]);
const toDelete: number[] = [];
const toUpdate: ProgressData[] = [];
const selectPrev = useCallback(() => {
const selectedItemId = $selectedItemId.get();
if (selectedItemId === null) {
return;
}
const items = $items.get();
const currentIndex = items.findIndex((item) => item.item_id === selectedItemId);
const prevIndex = (currentIndex - 1 + items.length) % items.length;
const prevItem = items[prevIndex];
if (!prevItem) {
return;
}
$selectedItemId.set(prevItem.item_id);
}, [$items, $selectedItemId]);
const selectFirst = useCallback(() => {
const items = $items.get();
const first = items.at(0);
if (!first) {
return;
}
$selectedItemId.set(first.item_id);
}, [$items, $selectedItemId]);
const selectLast = useCallback(() => {
const items = $items.get();
const last = items.at(-1);
if (!last) {
return;
}
$selectedItemId.set(last.item_id);
}, [$items, $selectedItemId]);
const onImageLoad = useCallback(
(itemId: number) => {
const progressData = $progressData.get();
const current = progressData[itemId];
if (current) {
const next = { ...current, imageLoaded: true };
$progressData.setKey(itemId, next);
} else {
$progressData.setKey(itemId, {
...getInitialProgressData(itemId),
imageLoaded: true,
for (const [id, datum] of objectEntries(progressData)) {
if (!datum) {
toDelete.push(id);
continue;
}
const item = items.find(({ item_id }) => item_id === datum.itemId);
if (!item) {
toDelete.push(datum.itemId);
} else if (item.status === 'canceled' || item.status === 'failed') {
toUpdate.push({
...datum,
progressEvent: null,
progressImage: null,
imageDTO: null,
});
}
if (
$lastCompletedItemId.get() === itemId &&
selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_finish'
) {
$selectedItemId.set(itemId);
$lastCompletedItemId.set(null);
}
},
[$lastCompletedItemId, $progressData, $selectedItemId, store]
);
// Set up socket listeners
useEffect(() => {
if (!socket) {
return;
}
const onProgress = (data: S['InvocationProgressEvent']) => {
if (data.destination !== session.id) {
return;
}
setProgress($progressData, data);
};
for (const item of items) {
const datum = progressData[item.item_id];
const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => {
if (data.destination !== session.id) {
return;
}
if (data.status === 'completed') {
$lastCompletedItemId.set(data.item_id);
}
if (data.status === 'in_progress') {
$lastStartedItemId.set(data.item_id);
}
};
socket.on('invocation_progress', onProgress);
socket.on('queue_item_status_changed', onQueueItemStatusChanged);
return () => {
socket.off('invocation_progress', onProgress);
socket.off('queue_item_status_changed', onQueueItemStatusChanged);
};
}, [$lastCompletedItemId, $lastStartedItemId, $progressData, $selectedItemId, session.id, socket]);
// Set up state subscriptions and effects
useEffect(() => {
let _prevItems: readonly S['SessionQueueItem'][] = [];
// Seed the $items atom with the initial query cache state
$items.set(selectQueueItems(store.getState()));
// Manually keep the $items atom in sync as the query cache is updated
const unsubReduxSyncToItemsAtom = store.subscribe(() => {
const prevItems = $items.get();
const items = selectQueueItems(store.getState());
if (items !== prevItems) {
_prevItems = prevItems;
$items.set(items);
}
});
// Handle cases that could result in a nonexistent queue item being selected.
const unsubEnsureSelectedItemIdExists = effect(
[$items, $selectedItemId, $lastStartedItemId],
(items, selectedItemId, lastStartedItemId) => {
if (items.length === 0) {
// If there are no items, cannot have a selected item.
$selectedItemId.set(null);
} else if (selectedItemId === null && items.length > 0) {
// If there is no selected item but there are items, select the first one.
$selectedItemId.set(items[0]?.item_id ?? null);
return;
} else if (
selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_start' &&
items.findIndex(({ item_id }) => item_id === lastStartedItemId) !== -1
) {
$selectedItemId.set(lastStartedItemId);
$lastStartedItemId.set(null);
} else if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) {
// If an item is selected and it is not in the list of items, un-set it. This effect will run again and we'll
// the above case, selecting the first item if there are any.
let prevIndex = _prevItems.findIndex(({ item_id }) => item_id === selectedItemId);
if (prevIndex >= items.length) {
prevIndex = items.length - 1;
}
const nextItem = items[prevIndex];
$selectedItemId.set(nextItem?.item_id ?? null);
}
if (items !== _prevItems) {
_prevItems = items;
}
}
);
// Clean up the progress data when a queue item is discarded.
const unsubCleanUpProgressData = $items.subscribe(async (items) => {
const progressData = $progressData.get();
const toDelete: number[] = [];
const toUpdate: ProgressData[] = [];
for (const [id, datum] of objectEntries(progressData)) {
if (!datum) {
toDelete.push(id);
if (datum) {
if (datum.imageDTO) {
continue;
}
const item = items.find(({ item_id }) => item_id === datum.itemId);
if (!item) {
toDelete.push(datum.itemId);
} else if (item.status === 'canceled' || item.status === 'failed') {
toUpdate.push({
...datum,
progressEvent: null,
progressImage: null,
imageDTO: null,
});
const outputImageName = getOutputImageName(item);
if (!outputImageName) {
continue;
}
}
for (const item of items) {
const datum = progressData[item.item_id];
if (datum) {
if (datum.imageDTO) {
continue;
}
const outputImageName = getOutputImageName(item);
if (!outputImageName) {
continue;
}
const imageDTO = await getImageDTOSafe(outputImageName);
if (!imageDTO) {
continue;
}
toUpdate.push({
...datum,
imageDTO,
});
} else {
const outputImageName = getOutputImageName(item);
if (!outputImageName) {
continue;
}
const imageDTO = await getImageDTOSafe(outputImageName);
if (!imageDTO) {
continue;
}
toUpdate.push({
...getInitialProgressData(item.item_id),
imageDTO,
});
const imageDTO = await getImageDTOSafe(outputImageName);
if (!imageDTO) {
continue;
}
toUpdate.push({
...datum,
imageDTO,
});
} else {
const outputImageName = getOutputImageName(item);
if (!outputImageName) {
continue;
}
const imageDTO = await getImageDTOSafe(outputImageName);
if (!imageDTO) {
continue;
}
toUpdate.push({
...getInitialProgressData(item.item_id),
imageDTO,
});
}
}
for (const itemId of toDelete) {
$progressData.setKey(itemId, undefined);
}
for (const itemId of toDelete) {
$progressData.setKey(itemId, undefined);
}
for (const datum of toUpdate) {
$progressData.setKey(datum.itemId, datum);
}
});
for (const datum of toUpdate) {
$progressData.setKey(datum.itemId, datum);
}
});
// We only want to auto-switch to completed queue items once their images have fully loaded to prevent flashes
// of fallback content and/or progress images. The only surefire way to determine when images have fully loaded
// is via the image elements' `onLoad` callback. Images set `$lastLoadedItemId` to their queue item ID in their
// `onLoad` handler, and we listen for that here. If auto-switch is enabled, we then switch the to the item.
//
// TODO: This isn't perfect... we set $lastLoadedItemId in the mini preview component, but the full view
// component still needs to retrieve the image from the browser cache... can result in a flash of the progress
// image...
const unsubHandleAutoSwitch = $lastLoadedItemId.listen((lastLoadedItemId) => {
if (lastLoadedItemId === null) {
return;
}
if (selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_finish') {
$selectedItemId.set(lastLoadedItemId);
}
$lastLoadedItemId.set(null);
});
// We only want to auto-switch to completed queue items once their images have fully loaded to prevent flashes
// of fallback content and/or progress images. The only surefire way to determine when images have fully loaded
// is via the image elements' `onLoad` callback. Images set `$lastLoadedItemId` to their queue item ID in their
// `onLoad` handler, and we listen for that here. If auto-switch is enabled, we then switch the to the item.
//
// TODO: This isn't perfect... we set $lastLoadedItemId in the mini preview component, but the full view
// component still needs to retrieve the image from the browser cache... can result in a flash of the progress
// image...
const unsubHandleAutoSwitch = $lastLoadedItemId.listen((lastLoadedItemId) => {
if (lastLoadedItemId === null) {
return;
}
if (selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_finish') {
$selectedItemId.set(lastLoadedItemId);
}
$lastLoadedItemId.set(null);
});
// Create an RTK Query subscription. Without this, the query cache selector will never return anything bc RTK
// doesn't know we care about it.
const { unsubscribe: unsubQueueItemsQuery } = store.dispatch(
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();
unsubQueueItemsQuery();
unsubReduxSyncToItemsAtom();
unsubEnsureSelectedItemIdExists();
unsubCleanUpProgressData();
$items.set([]);
$progressData.set({});
$selectedItemId.set(null);
};
}, [
$items,
$lastLoadedItemId,
$lastStartedItemId,
$progressData,
$selectedItemId,
selectQueueItems,
session.id,
store,
]);
const value = useMemo<CanvasSessionContextValue>(
() => ({
session,
$items,
$hasItems,
$isPending,
$progressData,
$selectedItemId,
$selectedItem,
$selectedItemIndex,
$selectedItemOutputImageDTO,
$itemCount,
selectNext,
selectPrev,
selectFirst,
selectLast,
onImageLoad,
discard,
discardAll,
}),
[
$items,
$hasItems,
$isPending,
$progressData,
$selectedItem,
$selectedItemId,
$selectedItemIndex,
session,
$selectedItemOutputImageDTO,
$itemCount,
selectNext,
selectPrev,
selectFirst,
selectLast,
onImageLoad,
discard,
discardAll,
]
// Create an RTK Query subscription. Without this, the query cache selector will never return anything bc RTK
// doesn't know we care about it.
const { unsubscribe: unsubQueueItemsQuery } = store.dispatch(
queueApi.endpoints.listAllQueueItems.initiate({ destination: sessionId })
);
return <CanvasSessionContext.Provider value={value}>{children}</CanvasSessionContext.Provider>;
}
);
// Clean up all subscriptions and top-level (i.e. non-computed/derived state)
return () => {
unsubHandleAutoSwitch();
unsubQueueItemsQuery();
unsubReduxSyncToItemsAtom();
unsubEnsureSelectedItemIdExists();
unsubCleanUpProgressData();
$items.set([]);
$progressData.set({});
$selectedItemId.set(null);
};
}, [
$items,
$lastLoadedItemId,
$lastStartedItemId,
$progressData,
$selectedItemId,
selectQueueItems,
sessionId,
store,
]);
const value = useMemo<CanvasSessionContextValue>(
() => ({
$items,
$hasItems,
$isPending,
$progressData,
$selectedItemId,
$selectedItem,
$selectedItemIndex,
$selectedItemOutputImageDTO,
$itemCount,
selectNext,
selectPrev,
selectFirst,
selectLast,
onImageLoad,
discard,
discardAll,
}),
[
$items,
$hasItems,
$isPending,
$progressData,
$selectedItem,
$selectedItemId,
$selectedItemIndex,
$selectedItemOutputImageDTO,
$itemCount,
selectNext,
selectPrev,
selectFirst,
selectLast,
onImageLoad,
discard,
discardAll,
]
);
return <CanvasSessionContext.Provider value={value}>{children}</CanvasSessionContext.Provider>;
});
CanvasSessionContextProvider.displayName = 'CanvasSessionContextProvider';
export const useCanvasSessionContext = () => {

View File

@@ -5,7 +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 { canvasSessionReset } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { canvasSessionReset, selectCanvasSessionId } 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';
@@ -19,6 +19,7 @@ export const StagingAreaToolbarAcceptButton = memo(() => {
const ctx = useCanvasSessionContext();
const dispatch = useAppDispatch();
const canvasManager = useCanvasManager();
const canvasSessionId = useAppSelector(selectCanvasSessionId);
const bboxRect = useAppSelector(selectBboxRect);
const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
@@ -41,14 +42,14 @@ export const StagingAreaToolbarAcceptButton = memo(() => {
dispatch(rasterLayerAdded({ overrides, isSelected: selectedEntityIdentifier?.type === 'raster_layer' }));
dispatch(canvasSessionReset());
cancelQueueItemsByDestination.trigger(ctx.session.id, { withToast: false });
cancelQueueItemsByDestination.trigger(canvasSessionId, { withToast: false });
}, [
selectedItemImageDTO,
bboxRect,
dispatch,
selectedEntityIdentifier?.type,
cancelQueueItemsByDestination,
ctx.session.id,
canvasSessionId,
]);
useHotkeys(

View File

@@ -1,5 +1,7 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { selectCanvasSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useCancelQueueItemsByDestination } from 'features/queue/hooks/useCancelQueueItemsByDestination';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -9,11 +11,12 @@ export const StagingAreaToolbarDiscardAllButton = memo(({ isDisabled }: { isDisa
const ctx = useCanvasSessionContext();
const { t } = useTranslation();
const cancelQueueItemsByDestination = useCancelQueueItemsByDestination();
const canvasSessionId = useAppSelector(selectCanvasSessionId);
const discardAll = useCallback(() => {
ctx.discardAll();
cancelQueueItemsByDestination.trigger(ctx.session.id, { withToast: false });
}, [cancelQueueItemsByDestination, ctx]);
cancelQueueItemsByDestination.trigger(canvasSessionId, { withToast: false });
}, [cancelQueueItemsByDestination, ctx, canvasSessionId]);
return (
<IconButton

View File

@@ -1,10 +1,10 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { getFocusedRegion } from 'common/hooks/focus';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { entityDeleted } from 'features/controlLayers/store/canvasSlice';
import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors';
import { useCallback } from 'react';
export function useCanvasDeleteLayerHotkey() {
@@ -12,14 +12,13 @@ export function useCanvasDeleteLayerHotkey() {
const dispatch = useAppDispatch();
const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
const isBusy = useCanvasIsBusy();
const canvasRightPanelTab = useAppSelector(selectActiveTabCanvasRightPanel);
const deleteSelectedLayer = useCallback(() => {
if (selectedEntityIdentifier === null || isBusy || canvasRightPanelTab !== 'layers') {
if (selectedEntityIdentifier === null || isBusy || getFocusedRegion() !== 'layers') {
return;
}
dispatch(entityDeleted({ entityIdentifier: selectedEntityIdentifier }));
}, [canvasRightPanelTab, dispatch, isBusy, selectedEntityIdentifier]);
}, [dispatch, isBusy, selectedEntityIdentifier]);
useRegisteredHotkeys({
id: 'deleteSelected',

View File

@@ -16,7 +16,6 @@ import {
selectIsolatedLayerPreview,
selectIsolatedStagingPreview,
} from 'features/controlLayers/store/canvasSettingsSlice';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import {
buildSelectIsSelected,
getSelectIsTypeHidden,
@@ -283,7 +282,7 @@ export abstract class CanvasEntityAdapterBase<T extends CanvasEntityState, U ext
this.subscriptions.add(
this.manager.stateApi.createStoreSubscription(selectIsolatedStagingPreview, this.syncVisibility)
);
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectIsStaging, this.syncVisibility));
this.subscriptions.add(this.manager.stagingArea.$isStaging.listen(this.syncVisibility));
this.subscriptions.add(this.manager.stateApi.$filteringAdapter.listen(this.syncVisibility));
this.subscriptions.add(this.manager.stateApi.$transformingAdapter.listen(this.syncVisibility));
this.subscriptions.add(this.manager.stateApi.$segmentingAdapter.listen(this.syncVisibility));
@@ -462,7 +461,7 @@ export abstract class CanvasEntityAdapterBase<T extends CanvasEntityState, U ext
* This allows the user to easily see how the new generation fits in with the rest of the canvas without the
* other layer types getting in the way.
*/
const isStaging = this.manager.stateApi.runSelector(selectIsStaging);
const isStaging = this.manager.stagingArea.$isStaging.get();
const isRasterLayer = isRasterLayerEntityIdentifier(this.entityIdentifier);
if (isStaging && !isRasterLayer) {
this.setVisibility(false);

View File

@@ -4,12 +4,12 @@ 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 type { Atom } from 'nanostores';
import { atom, effect } from 'nanostores';
import type { Logger } from 'roarr';
import type { S } from 'services/api/types';
// To get pixel sizes corresponding to our theme tokens, first find the theme token CSS var in browser dev tools.
// For example `var(--invoke-space-8)` is equivalent to using `8` as a space prop in a component.
@@ -121,14 +121,12 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
this.image = null;
/**
* 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.
* Rerender when the anything important changes.
*/
this.subscriptions.add(this.$imageSrc.listen(this.render));
this.subscriptions.add(this.$shouldShowStagedImage.listen(this.render));
this.subscriptions.add(this.$isPending.listen(this.render));
this.subscriptions.add(this.$isStaging.listen(this.render));
/**
* Sync the $isStaging flag with the redux state. $isStaging is used by the manager to determine the global busy
@@ -138,8 +136,7 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
* even if the user disabled this in the last staging session.
*/
this.subscriptions.add(
this.manager.stateApi.createStoreSubscription(selectIsStaging, (isStaging, oldIsStaging) => {
this.$isStaging.set(isStaging);
this.$isStaging.listen((isStaging, oldIsStaging) => {
if (isStaging && !oldIsStaging) {
this.$shouldShowStagedImage.set(true);
}
@@ -150,15 +147,17 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
initialize = () => {
this.log.debug('Initializing module');
this.render();
this.$isStaging.set(this.manager.stateApi.runSelector(selectIsStaging));
};
connectToSession = (
$items: Atom<S['SessionQueueItem'][]>,
$selectedItemId: Atom<number | null>,
$progressData: ProgressDataMap,
$isPending: Atom<boolean>
$progressData: ProgressDataMap
) => {
const cb = (selectedItemId: number | null, progressData: Record<number, ProgressData | undefined>) => {
const imageSrcListener = (
selectedItemId: number | null,
progressData: Record<number, ProgressData | undefined>
) => {
if (!selectedItemId) {
this.$imageSrc.set(null);
return;
@@ -176,20 +175,30 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
this.$imageSrc.set(null);
}
};
const unsubImageSrc = effect([$selectedItemId, $progressData], imageSrcListener);
// Run the effect & forcibly render once to initialize
cb($selectedItemId.get(), $progressData.get());
const isPendingListener = (items: S['SessionQueueItem'][]) => {
this.$isPending.set(items.some((item) => item.status === 'pending' || item.status === 'in_progress'));
};
const unsubIsPending = effect([$items], isPendingListener);
const isStagingListener = (items: S['SessionQueueItem'][]) => {
this.$isStaging.set(items.length > 0);
};
const unsubIsStaging = effect([$items], isStagingListener);
// Run the effects & forcibly render once to initialize
isStagingListener($items.get());
isPendingListener($items.get());
imageSrcListener($selectedItemId.get(), $progressData.get());
this.render();
// Sync the $isPending flag with the computed
const unsubIsPending = effect([$isPending], (isPending) => {
this.$isPending.set(isPending);
});
const unsubImageSrc = effect([$selectedItemId, $progressData], cb);
return () => {
this.$isStaging.set(false);
unsubIsStaging();
this.$isPending.set(false);
unsubIsPending();
this.$imageSrc.set(null);
unsubImageSrc();
};
};
@@ -247,6 +256,7 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
}
this.konva.group.visible(shouldShowStagedImage && this.$isStaging.get());
console.log({ isPending, isStaging: this.$isStaging.get(), shouldShowStagedImage, imageSrc });
} finally {
release();
}

View File

@@ -1,19 +1,21 @@
import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { EMPTY_ARRAY } from 'app/store/constants';
import type { PersistConfig, RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { deepClone } from 'common/util/deepClone';
import { canvasReset } from 'features/controlLayers/store/actions';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { useMemo } from 'react';
import { queueApi } from 'services/api/endpoints/queue';
type CanvasStagingAreaState = {
generateSessionId: string | null;
canvasSessionId: string | null;
_version: 1;
canvasSessionId: string;
canvasDiscardedQueueItems: number[];
};
const INITIAL_STATE: CanvasStagingAreaState = {
generateSessionId: null,
canvasSessionId: null,
_version: 1,
canvasSessionId: getPrefixedId('canvas'),
canvasDiscardedQueueItems: [],
};
@@ -23,46 +25,38 @@ export const canvasSessionSlice = createSlice({
name: 'canvasSession',
initialState: getInitialState(),
reducers: {
generateSessionIdChanged: (state, action: PayloadAction<{ id: string }>) => {
const { id } = action.payload;
state.generateSessionId = id;
},
generateSessionReset: (state) => {
state.generateSessionId = null;
},
canvasQueueItemDiscarded: (state, action: PayloadAction<{ itemId: number }>) => {
const { itemId } = action.payload;
if (!state.canvasDiscardedQueueItems.includes(itemId)) {
state.canvasDiscardedQueueItems.push(itemId);
}
},
canvasSessionIdChanged: (state, action: PayloadAction<{ id: string }>) => {
const { id } = action.payload;
state.canvasSessionId = id;
state.canvasDiscardedQueueItems = [];
canvasSessionReset: {
reducer: (state, action: PayloadAction<{ canvasSessionId: string }>) => {
const { canvasSessionId } = action.payload;
state.canvasSessionId = canvasSessionId;
state.canvasDiscardedQueueItems = [];
},
prepare: () => {
return {
payload: {
canvasSessionId: getPrefixedId('canvas'),
},
};
},
},
canvasSessionReset: (state) => {
state.canvasSessionId = null;
state.canvasDiscardedQueueItems = [];
},
},
extraReducers(builder) {
builder.addCase(canvasReset, (state) => {
state.canvasSessionId = null;
});
},
});
export const {
generateSessionIdChanged,
generateSessionReset,
canvasSessionIdChanged,
canvasSessionReset,
canvasQueueItemDiscarded,
} = canvasSessionSlice.actions;
export const { canvasSessionReset, canvasQueueItemDiscarded } = canvasSessionSlice.actions;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const migrate = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
state.canvasSessionId = state.canvasSessionId ?? getPrefixedId('canvas');
}
return state;
};
@@ -74,13 +68,14 @@ export const canvasStagingAreaPersistConfig: PersistConfig<CanvasStagingAreaStat
};
export const selectCanvasSessionSlice = (s: RootState) => s[canvasSessionSlice.name];
export const selectCanvasSessionId = createSelector(selectCanvasSessionSlice, ({ canvasSessionId }) => canvasSessionId);
export const selectGenerateSessionId = createSelector(
const selectDiscardedItems = createSelector(
selectCanvasSessionSlice,
({ generateSessionId }) => generateSessionId
({ canvasDiscardedQueueItems }) => canvasDiscardedQueueItems
);
export const buildSelectSessionQueueItems = (sessionId: string) =>
export const buildSelectCanvasQueueItems = (sessionId: string) =>
createSelector(
[queueApi.endpoints.listAllQueueItems.select({ destination: sessionId }), selectDiscardedItems],
({ data }, discardedItems) => {
@@ -92,22 +87,18 @@ export const buildSelectSessionQueueItems = (sessionId: string) =>
);
}
);
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)
);
export const useCanvasQueueItems = () => {
const sessionId = useAppSelector(selectCanvasSessionId);
const selector = useMemo(() => buildSelectCanvasQueueItems(sessionId), [sessionId]);
return useAppSelector(selector);
};
export const buildSelectIsStaging = (sessionId: string) =>
createSelector([buildSelectCanvasQueueItems(sessionId)], (queueItems) => {
return queueItems.length > 0;
});
export const useCanvasIsStaging = () => {
const sessionId = useAppSelector(selectCanvasSessionId);
const selector = useMemo(() => buildSelectIsStaging(sessionId), [sessionId]);
return useAppSelector(selector);
};
const selectDiscardedItems = createSelector(
selectCanvasSessionSlice,
({ canvasDiscardedQueueItems }) => canvasDiscardedQueueItems
);

View File

@@ -1,5 +1,5 @@
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { MetadataHandlers, MetadataUtils } from 'features/metadata/parsing';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { useCallback, useMemo } from 'react';
@@ -12,7 +12,7 @@ export const useRecallAll = (imageDTO: ImageDTO) => {
const store = useAppStore();
const tab = useAppSelector(selectActiveTab);
const { metadata, isLoading } = useDebouncedMetadata(imageDTO.image_name);
const isStaging = useAppSelector(selectIsStaging);
const isStaging = useCanvasIsStaging();
const clearStylePreset = useClearStylePresetWithToast();
const isEnabled = useMemo(() => {

View File

@@ -1,5 +1,5 @@
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { MetadataUtils } from 'features/metadata/parsing';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { useCallback, useMemo } from 'react';
@@ -8,7 +8,7 @@ import type { ImageDTO } from 'services/api/types';
export const useRecallDimensions = (imageDTO: ImageDTO) => {
const store = useAppStore();
const tab = useAppSelector(selectActiveTab);
const isStaging = useAppSelector(selectIsStaging);
const isStaging = useCanvasIsStaging();
const isEnabled = useMemo(() => {
if (tab !== 'canvas' && tab !== 'generate') {

View File

@@ -1,5 +1,5 @@
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { MetadataHandlers, MetadataUtils } from 'features/metadata/parsing';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { useCallback, useMemo } from 'react';
@@ -11,7 +11,7 @@ import { useClearStylePresetWithToast } from './useClearStylePresetWithToast';
export const useRecallRemix = (imageDTO: ImageDTO) => {
const store = useAppStore();
const tab = useAppSelector(selectActiveTab);
const isStaging = useAppSelector(selectIsStaging);
const isStaging = useCanvasIsStaging();
const clearStylePreset = useClearStylePresetWithToast();
const { metadata, isLoading } = useDebouncedMetadata(imageDTO.image_name);

View File

@@ -2,7 +2,7 @@ import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import { bboxAspectRatioIdChanged } from 'features/controlLayers/store/canvasSlice';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import {
selectIsChatGPT4o,
selectIsFluxKontext,
@@ -26,7 +26,7 @@ export const BboxAspectRatioSelect = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const id = useAppSelector(selectAspectRatioID);
const isStaging = useAppSelector(selectIsStaging);
const isStaging = useCanvasIsStaging();
const isImagen3 = useAppSelector(selectIsImagen3);
const isChatGPT4o = useAppSelector(selectIsChatGPT4o);
const isImagen4 = useAppSelector(selectIsImagen4);

View File

@@ -1,7 +1,7 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppDispatch } from 'app/store/storeHooks';
import { bboxDimensionsSwapped } from 'features/controlLayers/store/canvasSlice';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowsDownUpBold } from 'react-icons/pi';
@@ -9,7 +9,7 @@ import { PiArrowsDownUpBold } from 'react-icons/pi';
export const BboxSwapDimensionsButton = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isStaging = useAppSelector(selectIsStaging);
const isStaging = useCanvasIsStaging();
const onClick = useCallback(() => {
dispatch(bboxDimensionsSwapped());
}, [dispatch]);

View File

@@ -1,9 +1,8 @@
import { useAppSelector } from 'app/store/storeHooks';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { useIsApiModel } from 'features/parameters/hooks/useIsApiModel';
export const useIsBboxSizeLocked = () => {
const isStaging = useAppSelector(selectIsStaging);
const isStaging = useCanvasIsStaging();
const isApiModel = useIsApiModel();
return isApiModel || isStaging;

View File

@@ -7,8 +7,7 @@ import { extractMessageFromAssertionError } from 'common/util/extractMessageFrom
import { withResult, withResultAsync } from 'common/util/result';
import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { canvasSessionIdChanged, selectCanvasSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { selectCanvasSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
import { buildChatGPT4oGraph } from 'features/nodes/util/graph/generation/buildChatGPT4oGraph';
import { buildCogView4Graph } from 'features/nodes/util/graph/generation/buildCogView4Graph';
@@ -37,11 +36,7 @@ const enqueueCanvas = async (store: AppStore, canvasManager: CanvasManager, prep
const state = getState();
let destination = selectCanvasSessionId(state);
if (destination === null) {
destination = getPrefixedId('canvas');
dispatch(canvasSessionIdChanged({ id: destination }));
}
const destination = selectCanvasSessionId(state);
const buildGraphResult = await withResultAsync(async () => {
const model = state.params.model;

View File

@@ -5,8 +5,6 @@ import type { AppStore } from 'app/store/store';
import { useAppStore } from 'app/store/storeHooks';
import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError';
import { withResult, withResultAsync } from 'common/util/result';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { generateSessionIdChanged, selectGenerateSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
import { buildChatGPT4oGraph } from 'features/nodes/util/graph/generation/buildChatGPT4oGraph';
import { buildCogView4Graph } from 'features/nodes/util/graph/generation/buildCogView4Graph';
@@ -36,11 +34,7 @@ const enqueueGenerate = async (store: AppStore, prepend: boolean) => {
const state = getState();
let destination = selectGenerateSessionId(state);
if (destination === null) {
destination = getPrefixedId('generate');
dispatch(generateSessionIdChanged({ id: destination }));
}
const destination = 'generate';
const buildGraphResult = await withResultAsync(async () => {
const model = state.params.model;

View File

@@ -17,7 +17,6 @@ import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasT
import { Transform } from 'features/controlLayers/components/Transform/Transform';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice';
import { selectCanvasSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo, useCallback } from 'react';
import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi';
@@ -50,7 +49,6 @@ const canvasBgSx = {
export const CanvasWorkspacePanel = memo(() => {
const dynamicGrid = useAppSelector(selectDynamicGrid);
const showHUD = useAppSelector(selectShowHUD);
const canvasId = useAppSelector(selectCanvasSessionId);
const renderMenu = useCallback(() => {
return <MenuContent />;
@@ -103,13 +101,11 @@ export const CanvasWorkspacePanel = memo(() => {
</Flex>
)}
</ContextMenu>
{canvasId !== null && (
<CanvasManagerProviderGate>
<CanvasSessionContextProvider type="advanced" id={canvasId}>
<StagingArea />
</CanvasSessionContextProvider>
</CanvasManagerProviderGate>
)}
<CanvasManagerProviderGate>
<CanvasSessionContextProvider>
<StagingArea />
</CanvasSessionContextProvider>
</CanvasManagerProviderGate>
<Flex position="absolute" bottom={4}>
<CanvasManagerProviderGate>
<Filter />

View File

@@ -1,12 +1,11 @@
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 { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { memo } from 'react';
export const StagingArea = memo(() => {
const isStaging = useAppSelector(selectIsStaging);
const isStaging = useCanvasIsStaging();
if (!isStaging) {
return null;

View File

@@ -4,4 +4,3 @@ import { selectUiSlice } from 'features/ui/store/uiSlice';
export const selectActiveTab = createSelector(selectUiSlice, (ui) => ui.activeTab);
export const selectShouldShowImageDetails = createSelector(selectUiSlice, (ui) => ui.shouldShowImageDetails);
export const selectShouldShowProgressInViewer = createSelector(selectUiSlice, (ui) => ui.shouldShowProgressInViewer);
export const selectActiveTabCanvasRightPanel = createSelector(selectUiSlice, (ui) => ui.activeTabCanvasRightPanel);

View File

@@ -12,9 +12,6 @@ export const uiSlice = createSlice({
setActiveTab: (state, action: PayloadAction<UIState['activeTab']>) => {
state.activeTab = action.payload;
},
activeTabCanvasRightPanelChanged: (state, action: PayloadAction<UIState['activeTabCanvasRightPanel']>) => {
state.activeTabCanvasRightPanel = action.payload;
},
setShouldShowImageDetails: (state, action: PayloadAction<UIState['shouldShowImageDetails']>) => {
state.shouldShowImageDetails = action.payload;
},
@@ -73,7 +70,6 @@ export const uiSlice = createSlice({
export const {
setActiveTab,
activeTabCanvasRightPanelChanged,
setShouldShowImageDetails,
setShouldShowProgressInViewer,
accordionStateChanged,

View File

@@ -4,7 +4,6 @@ import { z } from 'zod/v4';
const zTabName = z.enum(['generate', 'canvas', 'upscaling', 'workflows', 'models', 'queue']);
export type TabName = z.infer<typeof zTabName>;
const zCanvasRightPanelTabName = z.enum(['layers', 'gallery']);
const zPartialDimensions = z.object({
width: z.number().optional(),
@@ -17,7 +16,6 @@ export type Serializable = z.infer<typeof zSerializable>;
const zUIState = z.object({
_version: z.literal(3).default(3),
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(() => ({})),