From 3aa0c500ec8dccc953adc9f7331eb3cc1e55c06c Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 18 Jul 2025 18:41:43 +1000 Subject: [PATCH 01/16] chore(ui): bump version to v6.1.0rc2 --- invokeai/version/invokeai_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/version/invokeai_version.py b/invokeai/version/invokeai_version.py index 80ad21a52c1..09b7a103a42 100644 --- a/invokeai/version/invokeai_version.py +++ b/invokeai/version/invokeai_version.py @@ -1 +1 @@ -__version__ = "6.1.0rc1" +__version__ = "6.1.0rc2" From 4e5eacedce4b8062be5c00cbde259b191eaac06a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:41:38 +1000 Subject: [PATCH 02/16] chore(ui): update dockview to latest Remove extraneous fix now that the disableDnd issue is resolved upstream --- invokeai/frontend/web/package.json | 2 +- invokeai/frontend/web/pnpm-lock.yaml | 18 +++++----- .../src/features/ui/layouts/DockviewTab.tsx | 3 -- .../ui/layouts/DockviewTabCanvasViewer.tsx | 3 -- .../ui/layouts/DockviewTabCanvasWorkspace.tsx | 3 -- .../ui/layouts/DockviewTabLaunchpad.tsx | 4 --- .../ui/layouts/DockviewTabProgress.tsx | 3 -- .../layouts/use-hack-out-dv-tab-draggable.ts | 34 ------------------- 8 files changed, 10 insertions(+), 60 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/ui/layouts/use-hack-out-dv-tab-draggable.ts diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 452c666cd93..152540d582a 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -56,7 +56,7 @@ "chakra-react-select": "^4.9.2", "cmdk": "^1.1.1", "compare-versions": "^6.1.1", - "dockview": "^4.4.0", + "dockview": "^4.4.1", "es-toolkit": "^1.39.7", "filesize": "^10.1.6", "fracturedjsonjs": "^4.1.0", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index 6561702558f..bbd109e09a9 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -60,8 +60,8 @@ importers: specifier: ^6.1.1 version: 6.1.1 dockview: - specifier: ^4.4.0 - version: 4.4.0(react@18.3.1) + specifier: ^4.4.1 + version: 4.4.1(react@18.3.1) es-toolkit: specifier: ^1.39.7 version: 1.39.7 @@ -2247,11 +2247,11 @@ packages: discontinuous-range@1.0.0: resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} - dockview-core@4.4.0: - resolution: {integrity: sha512-UsBJwS3lfZXM+gaTA+bJs8rAxLd7ZEmNcUf5CbKKhiPeKIPJrNCxXxTLcnQb3IXMJUGkE0aX1ZJ4BDaZGMtzlA==} + dockview-core@4.4.1: + resolution: {integrity: sha512-pDQPlVfDYDuN3zSebVUMVn2x21bpYPGD1ybGYrKJMI1KDkSQSqy57FJRJXi7yEnkcrmBUF0xEEo4d0Yi3j2vGA==} - dockview@4.4.0: - resolution: {integrity: sha512-cWi5R40R5kDky69vAqsKGznRx5tA0gk3Mdqe5aS2r4ollK951mWNJ/EeMmac+UP/juw4cbl0/APhXTV+EMnAbg==} + dockview@4.4.1: + resolution: {integrity: sha512-XEAwl+VYVZGkBd3hprF6kRLspWSF/hydbRHuV3KEg3BHev1i5xc+H+Kjp+u5DHTQ97klFAATl5MntNoVXQeg0w==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -6601,11 +6601,11 @@ snapshots: discontinuous-range@1.0.0: {} - dockview-core@4.4.0: {} + dockview-core@4.4.1: {} - dockview@4.4.0(react@18.3.1): + dockview@4.4.1(react@18.3.1): dependencies: - dockview-core: 4.4.0 + dockview-core: 4.4.1 react: 18.3.1 doctrine@2.1.0: diff --git a/invokeai/frontend/web/src/features/ui/layouts/DockviewTab.tsx b/invokeai/frontend/web/src/features/ui/layouts/DockviewTab.tsx index 61afb0fbd92..44427fed8cb 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/DockviewTab.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/DockviewTab.tsx @@ -5,7 +5,6 @@ import type { IDockviewPanelHeaderProps } from 'dockview'; import { memo, useCallback, useRef } from 'react'; import type { PanelParameters } from './auto-layout-context'; -import { useHackOutDvTabDraggable } from './use-hack-out-dv-tab-draggable'; export const DockviewTab = memo((props: IDockviewPanelHeaderProps) => { const ref = useRef(null); @@ -21,8 +20,6 @@ export const DockviewTab = memo((props: IDockviewPanelHeaderProps diff --git a/invokeai/frontend/web/src/features/ui/layouts/DockviewTabCanvasViewer.tsx b/invokeai/frontend/web/src/features/ui/layouts/DockviewTabCanvasViewer.tsx index 490c012edde..1fa3506bc66 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/DockviewTabCanvasViewer.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/DockviewTabCanvasViewer.tsx @@ -8,7 +8,6 @@ import { memo, useCallback, useRef } from 'react'; import { useIsGenerationInProgress } from 'services/api/endpoints/queue'; import type { PanelParameters } from './auto-layout-context'; -import { useHackOutDvTabDraggable } from './use-hack-out-dv-tab-draggable'; export const DockviewTabCanvasViewer = memo((props: IDockviewPanelHeaderProps) => { const isGenerationInProgress = useIsGenerationInProgress(); @@ -27,8 +26,6 @@ export const DockviewTabCanvasViewer = memo((props: IDockviewPanelHeaderProps diff --git a/invokeai/frontend/web/src/features/ui/layouts/DockviewTabCanvasWorkspace.tsx b/invokeai/frontend/web/src/features/ui/layouts/DockviewTabCanvasWorkspace.tsx index 3856f54c8a7..ad6155a252c 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/DockviewTabCanvasWorkspace.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/DockviewTabCanvasWorkspace.tsx @@ -10,7 +10,6 @@ import { memo, useCallback, useRef } from 'react'; import { useIsGenerationInProgress } from 'services/api/endpoints/queue'; import type { PanelParameters } from './auto-layout-context'; -import { useHackOutDvTabDraggable } from './use-hack-out-dv-tab-draggable'; export const DockviewTabCanvasWorkspace = memo((props: IDockviewPanelHeaderProps) => { const isGenerationInProgress = useIsGenerationInProgress(); @@ -30,8 +29,6 @@ export const DockviewTabCanvasWorkspace = memo((props: IDockviewPanelHeaderProps setFocusedRegion(props.params.focusRegion); }, [props.params.focusRegion]); - useHackOutDvTabDraggable(ref); - return ( diff --git a/invokeai/frontend/web/src/features/ui/layouts/DockviewTabLaunchpad.tsx b/invokeai/frontend/web/src/features/ui/layouts/DockviewTabLaunchpad.tsx index cb2a68d344f..43236ef7349 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/DockviewTabLaunchpad.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/DockviewTabLaunchpad.tsx @@ -16,8 +16,6 @@ import { PiTextAaBold, } from 'react-icons/pi'; -import { useHackOutDvTabDraggable } from './use-hack-out-dv-tab-draggable'; - const TAB_ICONS: Record = { generate: PiTextAaBold, canvas: PiBoundingBoxBold, @@ -43,8 +41,6 @@ export const DockviewTabLaunchpad = memo((props: IDockviewPanelHeaderProps) => { setFocusedRegion(props.params.focusRegion); }, [props.params.focusRegion]); - useHackOutDvTabDraggable(ref); - return ( diff --git a/invokeai/frontend/web/src/features/ui/layouts/DockviewTabProgress.tsx b/invokeai/frontend/web/src/features/ui/layouts/DockviewTabProgress.tsx index b681365a515..7b685ed37ac 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/DockviewTabProgress.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/DockviewTabProgress.tsx @@ -7,7 +7,6 @@ import { memo, useCallback, useRef } from 'react'; import { useIsGenerationInProgress } from 'services/api/endpoints/queue'; import type { PanelParameters } from './auto-layout-context'; -import { useHackOutDvTabDraggable } from './use-hack-out-dv-tab-draggable'; export const DockviewTabProgress = memo((props: IDockviewPanelHeaderProps) => { const isGenerationInProgress = useIsGenerationInProgress(); @@ -25,8 +24,6 @@ export const DockviewTabProgress = memo((props: IDockviewPanelHeaderProps diff --git a/invokeai/frontend/web/src/features/ui/layouts/use-hack-out-dv-tab-draggable.ts b/invokeai/frontend/web/src/features/ui/layouts/use-hack-out-dv-tab-draggable.ts deleted file mode 100644 index ce78faa6ad6..00000000000 --- a/invokeai/frontend/web/src/features/ui/layouts/use-hack-out-dv-tab-draggable.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { RefObject } from 'react'; -import { useEffect } from 'react'; - -/** - * Prevent undesired dnd behavior in Dockview tabs. - * - * Dockview always sets the draggable flag on its tab elements, even when dnd is disabled. This hook traverses - * up from the provided ref to find the closest tab element and sets its `draggable` attribute to `false`. - * - * TODO: Remove this when https://github.com/mathuo/dockview/pull/961 is shipped. - */ -export const useHackOutDvTabDraggable = (ref: RefObject) => { - useEffect(() => { - const el = ref.current; - if (!el) { - return; - } - const parentTab = el.closest('.dv-tab'); - if (!parentTab) { - return; - } - parentTab.setAttribute('draggable', 'false'); - - const tabContainer = parentTab.closest('.dv-tabs-and-actions-container'); - if (!tabContainer) { - return; - } - const voidContainer = tabContainer.querySelector('.dv-void-container'); - if (!voidContainer) { - return; - } - voidContainer.setAttribute('draggable', 'false'); - }, [ref]); -}; From 4ea3ddaf16c6e97bd74e48820271a9d28f0594eb Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:50:14 +1000 Subject: [PATCH 03/16] fix(ui): use invocation context provider in inspector panel --- .../sidePanel/inspector/InspectorDetailsTab.tsx | 15 +++++++++------ .../sidePanel/inspector/InspectorOutputsTab.tsx | 15 +++++++++------ .../sidePanel/inspector/InspectorTemplateTab.tsx | 15 +++++++++------ 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx index 53a581ca011..30b2de00ad1 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx @@ -2,6 +2,7 @@ import { Box, Flex, FormControl, FormLabel, HStack, Text } from '@invoke-ai/ui-l import { useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; +import { InvocationNodeContextProvider } from 'features/nodes/components/flow/nodes/Invocation/context'; import { InvocationNodeNotesTextarea } from 'features/nodes/components/flow/nodes/Invocation/InvocationNodeNotesTextarea'; import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate'; import { useNodeNeedsUpdate } from 'features/nodes/hooks/useNodeNeedsUpdate'; @@ -22,12 +23,14 @@ const InspectorDetailsTab = () => { } return ( - } - > - - + + } + > + + + ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorOutputsTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorOutputsTab.tsx index 64d706bfd54..f1880c5e48f 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorOutputsTab.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorOutputsTab.tsx @@ -3,6 +3,7 @@ import { useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer'; +import { InvocationNodeContextProvider } from 'features/nodes/components/flow/nodes/Invocation/context'; import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate'; import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow'; @@ -22,12 +23,14 @@ const InspectorOutputsTab = () => { } return ( - } - > - - + + } + > + + + ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTemplateTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTemplateTab.tsx index 0c22b6bc63d..c2a33059790 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTemplateTab.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTemplateTab.tsx @@ -1,6 +1,7 @@ import { useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer'; +import { InvocationNodeContextProvider } from 'features/nodes/components/flow/nodes/Invocation/context'; import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate'; import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow'; import { selectLastSelectedNodeId } from 'features/nodes/store/selectors'; @@ -16,12 +17,14 @@ const NodeTemplateInspector = () => { } return ( - } - > - - + + } + > + + + ); }; From b1d181b74f08f8cc71bbfbd9a435f3b2f79ca4d7 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:08:44 +1000 Subject: [PATCH 04/16] fix(ui): unstyled error boundary --- .../frontend/web/src/app/components/App.tsx | 8 +-- .../src/app/components/GlobalHookIsolator.tsx | 2 + .../app/components/ThemeLocaleProvider.tsx | 56 +++++++++---------- .../web/src/app/hooks/useSyncLangDirection.ts | 36 ++++++++++++ 4 files changed, 68 insertions(+), 34 deletions(-) create mode 100644 invokeai/frontend/web/src/app/hooks/useSyncLangDirection.ts diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index 1ad174863cb..755fc5e1be4 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -30,16 +30,16 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => { }, [clearStorage]); return ( - - + + {!didStudioInit && } - - + + ); }; diff --git a/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx index 9aa70a55122..0a21348e984 100644 --- a/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx @@ -2,6 +2,7 @@ import { useGlobalModifiersInit } from '@invoke-ai/ui-library'; import { setupListeners } from '@reduxjs/toolkit/query'; import type { StudioInitAction } from 'app/hooks/useStudioInitAction'; import { useStudioInitAction } from 'app/hooks/useStudioInitAction'; +import { useSyncLangDirection } from 'app/hooks/useSyncLangDirection'; import { useSyncQueueStatus } from 'app/hooks/useSyncQueueStatus'; import { useLogger } from 'app/logging/useLogger'; import { useSyncLoggingConfig } from 'app/logging/useSyncLoggingConfig'; @@ -50,6 +51,7 @@ export const GlobalHookIsolator = memo( useNavigationApi(); useDndMonitor(); useSyncNodeErrors(); + useSyncLangDirection(); // Persistent subscription to the queue counts query - canvas relies on this to know if there are pending // and/or in progress canvas sessions. diff --git a/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx b/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx index 25ee01903bc..437cecd4925 100644 --- a/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx +++ b/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx @@ -3,43 +3,39 @@ import 'overlayscrollbars/overlayscrollbars.css'; import '@xyflow/react/dist/base.css'; import 'common/components/OverlayScrollbars/overlayscrollbars.css'; -import { ChakraProvider, DarkMode, extendTheme, theme as _theme, TOAST_OPTIONS } from '@invoke-ai/ui-library'; +import { ChakraProvider, DarkMode, extendTheme, theme as baseTheme, TOAST_OPTIONS } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { $direction } from 'app/hooks/useSyncLangDirection'; import type { ReactNode } from 'react'; -import { memo, useEffect, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; +import { memo, useMemo } from 'react'; type ThemeLocaleProviderProps = { children: ReactNode; }; -function ThemeLocaleProvider({ children }: ThemeLocaleProviderProps) { - const { i18n } = useTranslation(); - - const direction = i18n.dir(); - - const theme = useMemo(() => { - return extendTheme({ - ..._theme, - direction, - shadows: { - ..._theme.shadows, - selected: - 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)', - hoverSelected: - 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)', - hoverUnselected: - 'inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-800)', - selectedForCompare: - 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)', - hoverSelectedForCompare: - 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)', - }, - }); - }, [direction]); +const buildTheme = (direction: 'ltr' | 'rtl') => { + return extendTheme({ + ...baseTheme, + direction, + shadows: { + ...baseTheme.shadows, + selected: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)', + hoverSelected: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)', + hoverUnselected: + 'inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-800)', + selectedForCompare: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)', + hoverSelectedForCompare: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)', + }, + }); +}; - useEffect(() => { - document.body.dir = direction; - }, [direction]); +function ThemeLocaleProvider({ children }: ThemeLocaleProviderProps) { + const direction = useStore($direction); + const theme = useMemo(() => buildTheme(direction), [direction]); return ( diff --git a/invokeai/frontend/web/src/app/hooks/useSyncLangDirection.ts b/invokeai/frontend/web/src/app/hooks/useSyncLangDirection.ts new file mode 100644 index 00000000000..da1e0dbbcb3 --- /dev/null +++ b/invokeai/frontend/web/src/app/hooks/useSyncLangDirection.ts @@ -0,0 +1,36 @@ +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { atom } from 'nanostores'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +/** + * Global atom storing the language direction, to be consumed by the Chakra theme. + * + * Why do we need this? We have a kind of catch-22: + * - The Chakra theme needs to know the language direction to apply the correct styles. + * - The language direction is determined by i18n and the language selection. + * - We want our error boundary to be themed. + * - It's possible that i18n can throw if the language selection is invalid or not supported. + * + * Previously, we had the logic in this file in the theme provider, which wrapped the error boundary. The error + * was properly themed. But then, if i18n threw in the theme provider, the error boundary does not catch the + * error. The app would crash to a white screen. + * + * We tried swapping the component hierarchy so that the error boundary wraps the theme provider, but then the + * error boundary isn't themed! + * + * The solution is to move this i18n direction logic out of the theme provider and into a hook that we can use + * within the error boundary. The error boundary will be themed, _and_ catch any i18n errors. + */ +export const $direction = atom<'ltr' | 'rtl'>('ltr'); + +export const useSyncLangDirection = () => { + useAssertSingleton('useSyncLangDirection'); + const { i18n, t } = useTranslation(); + + useEffect(() => { + const direction = i18n.dir(); + $direction.set(direction); + document.body.dir = direction; + }, [i18n, t]); +}; From c310ccdbae721d6f70bfc8665ca7787d573dab14 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:45:19 +1000 Subject: [PATCH 05/16] wip --- .../components/SimpleSession/context.tsx | 146 ++++++++++++------ 1 file changed, 101 insertions(+), 45 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx index f38c37bb701..afeef069084 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -1,5 +1,6 @@ import { useStore } from '@nanostores/react'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; +import { clamp } from 'es-toolkit/compat'; import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared'; import { loadImage } from 'features/controlLayers/konva/util'; import { selectStagingAreaAutoSwitch } from 'features/controlLayers/store/canvasSettingsSlice'; @@ -11,7 +12,7 @@ import { } from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { ProgressImage } from 'features/nodes/types/common'; import type { Atom, MapStore, StoreValue, WritableAtom } from 'nanostores'; -import { atom, computed, effect, map, subscribeKeys } from 'nanostores'; +import { atom, computed, map, subscribeKeys } from 'nanostores'; import type { PropsWithChildren } from 'react'; import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { getImageDTOSafe } from 'services/api/endpoints/images'; @@ -122,6 +123,11 @@ export const CanvasSessionContextProvider = memo(({ children }: PropsWithChildre const socket = useStore($socket); + /** + * Track the last started item. Used to implement autoswitch. + */ + const $lastStartedItemId = useState(() => atom(null))[0]; + /** * Track the last completed item. Used to implement autoswitch. */ @@ -186,7 +192,13 @@ export const CanvasSessionContextProvider = memo(({ children }: PropsWithChildre if (selectedItemId === null) { return null; } - return items.findIndex(({ item_id }) => item_id === selectedItemId) ?? null; + const selectedItemIndex = items.findIndex(({ item_id }) => item_id === selectedItemId); + + if (selectedItemIndex === -1) { + return null; + } + + return selectedItemIndex; }) )[0]; @@ -212,17 +224,6 @@ export const CanvasSessionContextProvider = memo(({ children }: PropsWithChildre */ 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) { @@ -271,6 +272,28 @@ export const CanvasSessionContextProvider = memo(({ children }: PropsWithChildre $selectedItemId.set(last.item_id); }, [$items, $selectedItemId]); + const discard = useCallback( + (itemId: number) => { + const selectedItemId = $selectedItemId.get(); + const items = $items.get(); + const currentIndex = items.findIndex((item) => item.item_id === selectedItemId); + const nextIndex = clamp(currentIndex + 1, 0, items.length - 1); + const nextItem = items[nextIndex]; + if (nextItem) { + $selectedItemId.set(nextItem.item_id); + } else { + $selectedItemId.set(null); + } + store.dispatch(canvasQueueItemDiscarded({ itemId })); + }, + [$items, $selectedItemId, store] + ); + + const discardAll = useCallback(() => { + store.dispatch(canvasSessionReset()); + $selectedItemId.set(null); + }, [$selectedItemId, store]); + // Set up socket listeners useEffect(() => { if (!socket) { @@ -301,7 +324,7 @@ export const CanvasSessionContextProvider = memo(({ children }: PropsWithChildre $lastCompletedItemId.set(data.item_id); } if (data.status === 'in_progress' && selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_start') { - $selectedItemId.set(data.item_id); + $lastStartedItemId.set(data.item_id); } }; @@ -312,7 +335,7 @@ export const CanvasSessionContextProvider = memo(({ children }: PropsWithChildre socket.off('invocation_progress', onProgress); socket.off('queue_item_status_changed', onQueueItemStatusChanged); }; - }, [$progressData, $selectedItemId, sessionId, socket, $lastCompletedItemId, store]); + }, [$progressData, $selectedItemId, sessionId, socket, $lastCompletedItemId, store, $lastStartedItemId]); // Set up state subscriptions and effects useEffect(() => { @@ -322,38 +345,54 @@ export const CanvasSessionContextProvider = memo(({ children }: PropsWithChildre // 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); + const oldItems = $items.get(); + const newItems = selectQueueItems(store.getState()); + if (newItems !== oldItems) { + // _prevItems = prevItems; + // const selectedItemId = $selectedItemId.get(); + if (newItems.length === 0) { + // If there are no items, cannot have a selected item. + $selectedItemId.set(null); + } else if ($selectedItemId.get() === null && newItems.length > 0) { + // If there is no selected item but there are items, select the first one. + $selectedItemId.set(newItems[0]?.item_id ?? null); + return; + } + // } 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); + // prevIndex = clamp(prevIndex + 1, 0, items.length - 1); + // const nextItem = items[prevIndex]; + // $selectedItemId.set(nextItem?.item_id ?? null); + // } + $items.set(newItems); } }); // Handle cases that could result in a nonexistent queue item being selected. - const unsubEnsureSelectedItemIdExists = effect([$items, $selectedItemId], (items, selectedItemId) => { - 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 (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; - } - }); + // const unsubEnsureSelectedItemIdExists = effect([$items, $selectedItemId], (items, selectedItemId) => { + // 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 (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); + // prevIndex = clamp(prevIndex + 1, 0, items.length - 1); + // // console.log('first', prevIndex); + // // if (prevIndex !== -1) { + // // console.log('inner', prevIndex); + // // } + // const nextItem = items[prevIndex]; + // console.log('final', prevIndex, nextItem); + // $selectedItemId.set(nextItem?.item_id ?? null); + // } + // }); // Sync progress data - remove canceled/failed items, update progress data with new images, and load images const unsubSyncProgressData = $items.subscribe(async (items) => { @@ -383,6 +422,14 @@ export const CanvasSessionContextProvider = memo(({ children }: PropsWithChildre for (const item of items) { const datum = progressData[item.item_id]; + if ( + $lastStartedItemId.get() === item.item_id && + selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_start' + ) { + $selectedItemId.set(item.item_id); + $lastStartedItemId.set(null); + } + if (datum?.imageDTO) { continue; } @@ -432,13 +479,22 @@ export const CanvasSessionContextProvider = memo(({ children }: PropsWithChildre return () => { unsubQueueItemsQuery(); unsubReduxSyncToItemsAtom(); - unsubEnsureSelectedItemIdExists(); + // unsubEnsureSelectedItemIdExists(); unsubSyncProgressData(); $items.set([]); $progressData.set({}); $selectedItemId.set(null); }; - }, [$items, $progressData, $selectedItemId, selectQueueItems, sessionId, store, $lastCompletedItemId]); + }, [ + $items, + $progressData, + $selectedItemId, + selectQueueItems, + sessionId, + store, + $lastCompletedItemId, + $lastStartedItemId, + ]); const value = useMemo( () => ({ From 019a7ebc6697c807e6a5da76b47794354aa1e8a3 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 18 Jul 2025 15:58:47 +1000 Subject: [PATCH 06/16] refactor(ui): move staging area logic out side react Was running into difficultlies reasoning about the logic and couldn't write tests because it was all in react. Moved logic outside react, updated context, make it testable. --- .../QueueItemCircularProgress.tsx | 6 +- .../SimpleSession/QueueItemPreviewMini.tsx | 26 +- .../SimpleSession/QueueItemProgressImage.tsx | 6 +- .../SimpleSession/QueueItemStatusLabel.tsx | 8 +- .../SimpleSession/StagingAreaItemsList.tsx | 66 ++- .../components/SimpleSession/context2.tsx | 119 ++++++ .../components/SimpleSession/state.ts | 399 ++++++++++++++++++ .../StagingAreaAutoSwitchButtons.tsx | 8 + .../StagingArea/StagingAreaToolbar.tsx | 17 +- .../StagingAreaToolbarAcceptButton.tsx | 51 +-- .../StagingAreaToolbarDiscardAllButton.tsx | 25 +- ...tagingAreaToolbarDiscardSelectedButton.tsx | 26 +- .../StagingAreaToolbarImageCountButton.tsx | 18 +- .../StagingArea/StagingAreaToolbarMenu.tsx | 13 +- ...tagingAreaToolbarMenuNewLayerFromImage.tsx | 38 +- .../StagingAreaToolbarNextButton.tsx | 16 +- .../StagingAreaToolbarPrevButton.tsx | 15 +- ...AreaToolbarSaveSelectedToGalleryButton.tsx | 14 +- .../konva/CanvasStagingAreaModule.ts | 29 +- .../store/canvasSettingsSlice.ts | 1 + .../ui/layouts/CanvasWorkspacePanel.tsx | 123 +++--- 21 files changed, 760 insertions(+), 264 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context2.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/state.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx index 80da7d250d4..7732f29559d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx @@ -1,10 +1,11 @@ import type { CircularProgressProps, SystemStyleObject } from '@invoke-ai/ui-library'; import { CircularProgress, Tooltip } from '@invoke-ai/ui-library'; -import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context'; import { getProgressMessage } from 'features/controlLayers/components/SimpleSession/shared'; import { memo } from 'react'; import type { S } from 'services/api/types'; +import { useProgressDatum } from './context2'; + const circleStyles: SystemStyleObject = { circle: { transitionProperty: 'none', @@ -18,8 +19,7 @@ const circleStyles: SystemStyleObject = { type Props = { itemId: number; status: S['SessionQueueItem']['status'] } & CircularProgressProps; export const QueueItemCircularProgress = memo(({ itemId, status, ...rest }: Props) => { - const { $progressData } = useCanvasSessionContext(); - const { progressEvent } = useProgressData($progressData, itemId); + const { progressEvent } = useProgressDatum(itemId); if (status !== 'in_progress') { return null; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx index ee31573d32c..2dd5df9088e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx @@ -1,11 +1,7 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { - useCanvasSessionContext, - useOutputImageDTO, - useProgressData, -} from 'features/controlLayers/components/SimpleSession/context'; import { QueueItemCircularProgress } from 'features/controlLayers/components/SimpleSession/QueueItemCircularProgress'; import { QueueItemNumber } from 'features/controlLayers/components/SimpleSession/QueueItemNumber'; import { QueueItemProgressImage } from 'features/controlLayers/components/SimpleSession/QueueItemProgressImage'; @@ -17,9 +13,11 @@ import { } from 'features/controlLayers/store/canvasSettingsSlice'; import { DndImage } from 'features/dnd/DndImage'; import { toast } from 'features/toast/toast'; -import { memo, useCallback } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import type { S } from 'services/api/types'; +import { useOutputImageDTO, useStagingAreaContext } from './context2'; + const sx = { cursor: 'pointer', userSelect: 'none', @@ -41,19 +39,19 @@ const sx = { type Props = { item: S['SessionQueueItem']; index: number; - isSelected: boolean; }; -export const QueueItemPreviewMini = memo(({ item, isSelected, index }: Props) => { +export const QueueItemPreviewMini = memo(({ item, index }: Props) => { + const ctx = useStagingAreaContext(); const dispatch = useAppDispatch(); - const ctx = useCanvasSessionContext(); - const { imageLoaded } = useProgressData(ctx.$progressData, item.item_id); - const imageDTO = useOutputImageDTO(item); + const $isSelected = useMemo(() => ctx.buildIsSelectedComputed(item.item_id), [ctx, item.item_id]); + const isSelected = useStore($isSelected); + const imageDTO = useOutputImageDTO(item.item_id); const autoSwitch = useAppSelector(selectStagingAreaAutoSwitch); const onClick = useCallback(() => { - ctx.$selectedItemId.set(item.item_id); - }, [ctx.$selectedItemId, item.item_id]); + ctx.select(item.item_id); + }, [ctx, item.item_id]); const onDoubleClick = useCallback(() => { if (autoSwitch !== 'off') { @@ -74,7 +72,7 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, index }: Props) => > {imageDTO && } - {!imageLoaded && } + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx index 2ea3dd827eb..f28df9d870b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx @@ -1,13 +1,13 @@ import type { ImageProps } from '@invoke-ai/ui-library'; import { Image } from '@invoke-ai/ui-library'; -import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context'; import { memo } from 'react'; +import { useProgressDatum } from './context2'; + type Props = { itemId: number } & ImageProps; export const QueueItemProgressImage = memo(({ itemId, ...rest }: Props) => { - const ctx = useCanvasSessionContext(); - const { progressImage } = useProgressData(ctx.$progressData, itemId); + const { progressImage } = useProgressDatum(itemId); if (!progressImage) { return null; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx index 35fb76b28d3..907e4641951 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx @@ -1,16 +1,16 @@ import type { TextProps } from '@invoke-ai/ui-library'; import { Text } from '@invoke-ai/ui-library'; -import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context'; import { memo } from 'react'; import type { S } from 'services/api/types'; +import { useProgressDatum } from './context2'; + type Props = { item: S['SessionQueueItem'] } & TextProps; export const QueueItemStatusLabel = memo(({ item, ...rest }: Props) => { - const ctx = useCanvasSessionContext(); - const { progressImage, imageLoaded } = useProgressData(ctx.$progressData, item.item_id); + const { progressImage } = useProgressDatum(item.item_id); - if (progressImage || imageLoaded) { + if (progressImage) { return null; } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx index aed3f9c8750..a73d52cb7cf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx @@ -1,16 +1,16 @@ import { Box, Flex, forwardRef } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { logger } from 'app/logging/logger'; -import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { QueueItemPreviewMini } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewMini'; -import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; import type { CSSProperties, RefObject } from 'react'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { Components, ItemContent, ListRange, VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import type { Components, ComputeItemKey, ItemContent, ListRange, VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso'; import type { S } from 'services/api/types'; +import { useStagingAreaContext } from './context2'; import { getQueueItemElementId } from './shared'; const log = logger('system'); @@ -20,8 +20,6 @@ const virtuosoStyles = { height: '72px', } satisfies CSSProperties; -type VirtuosoContext = { selectedItemId: number | null }; - /** * Scroll the item at the given index into view if it is not currently visible. */ @@ -132,41 +130,35 @@ const useScrollableStagingArea = (rootRef: RefObject) => { }; export const StagingAreaItemsList = memo(() => { - const canvasManager = useCanvasManagerSafe(); - const ctx = useCanvasSessionContext(); + const canvasManager = useCanvasManager(); + + const ctx = useStagingAreaContext(); const virtuosoRef = useRef(null); const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); const rootRef = useRef(null); const items = useStore(ctx.$items); - const selectedItemId = useStore(ctx.$selectedItemId); - const context = useMemo(() => ({ selectedItemId }), [selectedItemId]); const scrollerRef = useScrollableStagingArea(rootRef); useEffect(() => { - if (!canvasManager) { - return; - } - - return canvasManager.stagingArea.connectToSession(ctx.$items, ctx.$selectedItemId, ctx.$progressData); - }, [canvasManager, ctx.$progressData, ctx.$selectedItemId, ctx.$items]); + return canvasManager.stagingArea.connectToSession(ctx.$items, ctx.$selectedItem); + }, [canvasManager, ctx.$progressData, ctx.$items, ctx.$selectedItem]); useEffect(() => { - return ctx.$selectedItemIndex.listen((index) => { - if (!virtuosoRef.current) { + return ctx.$selectedItemIndex.listen((selectedItemIndex) => { + if (selectedItemIndex === null) { return; } - - if (!rootRef.current) { + if (!virtuosoRef.current) { return; } - if (index === null) { + if (!rootRef.current) { return; } - scrollIntoView(index, rootRef.current, virtuosoRef.current, rangeRef.current); + scrollIntoView(selectedItemIndex, rootRef.current, virtuosoRef.current, rangeRef.current); }); }, [ctx.$selectedItemIndex]); @@ -176,40 +168,46 @@ export const StagingAreaItemsList = memo(() => { return ( - + ref={virtuosoRef} - context={context} data={items} horizontalDirection style={virtuosoStyles} + computeItemKey={computeItemKey} + increaseViewportBy={2048} itemContent={itemContent} components={components} rangeChanged={onRangeChanged} // Virtuoso expects the ref to be of HTMLElement | null | Window, but overlayscrollbars doesn't allow Window - scrollerRef={scrollerRef as VirtuosoProps['scrollerRef']} + scrollerRef={scrollerRef as VirtuosoProps['scrollerRef']} /> ); }); StagingAreaItemsList.displayName = 'StagingAreaItemsList'; -const itemContent: ItemContent = (index, item, { selectedItemId }) => ( - +const computeItemKey: ComputeItemKey = (_, item: S['SessionQueueItem']) => { + return item.item_id; +}; + +const itemContent: ItemContent = (index, item) => ( + ); const listSx = { '& > * + *': { pl: 2, }, + '&[data-disabled="true"]': { + filter: 'grayscale(1) opacity(0.5)', + }, }; -const components: Components = { +const components: Components = { List: forwardRef(({ context: _, ...rest }, ref) => { - return ; + const canvasManager = useCanvasManager(); + const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); + + return ; }), }; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context2.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context2.tsx new file mode 100644 index 00000000000..b48f69f3269 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context2.tsx @@ -0,0 +1,119 @@ +import { useStore } from '@nanostores/react'; +import { useAppStore } from 'app/store/storeHooks'; +import { loadImage } from 'features/controlLayers/konva/util'; +import { + selectStagingAreaAutoSwitch, + settingsStagingAreaAutoSwitchChanged, +} from 'features/controlLayers/store/canvasSettingsSlice'; +import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice'; +import { + buildSelectCanvasQueueItems, + canvasQueueItemDiscarded, + canvasSessionReset, +} 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'; +import type { PropsWithChildren } from 'react'; +import { createContext, memo, useContext, useMemo } from 'react'; +import { getImageDTOSafe } from 'services/api/endpoints/images'; +import { queueApi } from 'services/api/endpoints/queue'; +import type { S } from 'services/api/types'; +import { $socket } from 'services/events/stores'; +import { assert } from 'tsafe'; + +import type { ProgressData, StagingAreaAppApi } from './state'; +import { getInitialProgressData, StagingAreaApi } from './state'; + +const StagingAreaContext = createContext(null); + +export const StagingAreaContextProvider = memo(({ children, sessionId }: PropsWithChildren<{ sessionId: string }>) => { + const store = useAppStore(); + const socket = useStore($socket); + const stagingAreaAppApi = useMemo(() => { + const selectQueueItems = buildSelectCanvasQueueItems(sessionId); + + const _stagingAreaAppApi: StagingAreaAppApi = { + getAutoSwitch: () => selectStagingAreaAutoSwitch(store.getState()), + getImageDTO: (imageName: string) => getImageDTOSafe(imageName), + loadImage: (imageUrl: string) => loadImage(imageUrl, true), + onInvocationProgress: (handler) => { + socket?.on('invocation_progress', handler); + return () => { + socket?.off('invocation_progress', handler); + }; + }, + onQueueItemStatusChanged: (handler) => { + socket?.on('queue_item_status_changed', handler); + return () => { + socket?.off('queue_item_status_changed', handler); + }; + }, + onItemsChanged: (handler) => { + let prev: S['SessionQueueItem'][] = []; + return store.subscribe(() => { + const next = selectQueueItems(store.getState()); + if (prev !== next) { + prev = next; + handler(next); + } + }); + }, + onDiscard: ({ item_id, status }) => { + store.dispatch(canvasQueueItemDiscarded({ itemId: item_id })); + if (status === 'in_progress' || status === 'pending') { + store.dispatch(queueApi.endpoints.cancelQueueItem.initiate({ item_id }, { track: false })); + } + }, + onDiscardAll: () => { + store.dispatch(canvasSessionReset()); + store.dispatch( + queueApi.endpoints.cancelQueueItemsByDestination.initiate({ destination: sessionId }, { track: false }) + ); + }, + onAccept: (item, imageDTO) => { + const bboxRect = selectBboxRect(store.getState()); + const { x, y, width, height } = bboxRect; + const imageObject = imageNameToImageObject(imageDTO.image_name, { width, height }); + const selectedEntityIdentifier = selectSelectedEntityIdentifier(store.getState()); + const overrides: Partial = { + position: { x, y }, + objects: [imageObject], + }; + + store.dispatch(rasterLayerAdded({ overrides, isSelected: selectedEntityIdentifier?.type === 'raster_layer' })); + store.dispatch(canvasSessionReset()); + store.dispatch( + queueApi.endpoints.cancelQueueItemsByDestination.initiate({ destination: sessionId }, { track: false }) + ); + }, + onAutoSwitchChange: (mode) => { + store.dispatch(settingsStagingAreaAutoSwitchChanged(mode)); + }, + }; + + return _stagingAreaAppApi; + }, [sessionId, socket, store]); + const value = useMemo(() => new StagingAreaApi(sessionId, stagingAreaAppApi), [sessionId, stagingAreaAppApi]); + + return {children}; +}); +StagingAreaContextProvider.displayName = 'StagingAreaContextProvider'; + +export const useStagingAreaContext = () => { + const ctx = useContext(StagingAreaContext); + assert(ctx !== null, "'useStagingAreaContext' must be used within a StagingAreaContextProvider"); + return ctx; +}; + +export const useOutputImageDTO = (itemId: number) => { + const ctx = useStagingAreaContext(); + const allProgressData = useStore(ctx.$progressData, { keys: [itemId] }); + return allProgressData[itemId]?.imageDTO ?? null; +}; + +export const useProgressDatum = (itemId: number): ProgressData => { + const ctx = useStagingAreaContext(); + const allProgressData = useStore(ctx.$progressData, { keys: [itemId] }); + return allProgressData[itemId] ?? getInitialProgressData(itemId); +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/state.ts b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/state.ts new file mode 100644 index 00000000000..095c903bf6b --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/state.ts @@ -0,0 +1,399 @@ +import { clamp } from 'es-toolkit'; +import type { AutoSwitchMode } from 'features/controlLayers/store/canvasSettingsSlice'; +import type { ProgressImage } from 'features/nodes/types/common'; +import type { MapStore } from 'nanostores'; +import { atom, computed, map } from 'nanostores'; +import type { ImageDTO, S } from 'services/api/types'; +import { objectEntries } from 'tsafe'; + +import { getOutputImageName } from './shared'; + +export type StagingAreaAppApi = { + onDiscard?: (item: S['SessionQueueItem']) => void; + onDiscardAll?: () => void; + onAccept?: (item: S['SessionQueueItem'], imageDTO: ImageDTO) => void; + onSelect?: (itemId: number) => void; + onSelectPrev?: () => void; + onSelectNext?: () => void; + onSelectFirst?: () => void; + onSelectLast?: () => void; + getAutoSwitch: () => AutoSwitchMode; + onAutoSwitchChange?: (mode: AutoSwitchMode) => void; + getImageDTO: (imageName: string) => Promise; + loadImage: (imageName: string) => Promise; + onItemsChanged: (handler: (data: S['SessionQueueItem'][]) => Promise | void) => () => void; + onQueueItemStatusChanged: (handler: (data: S['QueueItemStatusChangedEvent']) => Promise | void) => () => void; + onInvocationProgress: (handler: (data: S['InvocationProgressEvent']) => Promise | void) => () => void; +}; + +export type ProgressData = { + itemId: number; + progressEvent: S['InvocationProgressEvent'] | null; + progressImage: ProgressImage | null; + imageDTO: ImageDTO | null; +}; + +export type SelectedItemData = { + item: S['SessionQueueItem']; + index: number; + progressData: ProgressData; +}; + +export const getInitialProgressData = (itemId: number): ProgressData => ({ + itemId, + progressEvent: null, + progressImage: null, + imageDTO: null, +}); +export type ProgressDataMap = Record; + +export class StagingAreaApi { + sessionId: string; + _app: StagingAreaAppApi; + _subscriptions = new Set<() => void>(); + + constructor(sessionId: string, app: StagingAreaAppApi) { + this.sessionId = sessionId; + this._app = app; + + this._subscriptions.add(this._app.onItemsChanged(this.onItemsChangedEvent)); + this._subscriptions.add(this._app.onQueueItemStatusChanged(this.onQueueItemStatusChangedEvent)); + this._subscriptions.add(this._app.onInvocationProgress(this.onInvocationProgressEvent)); + } + + /** + * Track the last started item. Used to implement autoswitch. + */ + $lastStartedItemId = atom(null); + + /** + * Track the last completed item. Used to implement autoswitch. + */ + $lastCompletedItemId = atom(null); + + /** + * 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. + */ + $items = atom([]); + + /** + * An ephemeral store of progress events and images for all items in the current session. + */ + $progressData = map({}); + + /** + * The currently selected queue item's ID, or null if one is not selected. + */ + $selectedItemId = atom(null); + + /** + * The number of items. Computed from the queue items array. + */ + $itemCount = computed([this.$items], (items) => items.length); + + /** + * Whether there are any items. Computed from the queue items array. + */ + $hasItems = computed([this.$items], (items) => items.length > 0); + + /** + * Whether there are any pending or in-progress items. Computed from the queue items array. + */ + $isPending = computed([this.$items], (items) => + items.some((item) => item.status === 'pending' || item.status === 'in_progress') + ); + + /** + * The currently selected queue item, its index and progress data - or null, if one is not selected. + */ + $selectedItem = computed( + [this.$items, this.$selectedItemId, this.$progressData], + (items, selectedItemId, progressData) => { + if (items.length === 0) { + return null; + } + if (selectedItemId === null) { + return null; + } + const item = items.find(({ item_id }) => item_id === selectedItemId); + if (!item) { + return null; + } + + return { + item, + index: items.findIndex(({ item_id }) => item_id === selectedItemId), + progressData: progressData[selectedItemId] || getInitialProgressData(selectedItemId), + }; + } + ); + + $selectedItemImageDTO = computed([this.$selectedItem], (selectedItem) => { + return selectedItem?.progressData.imageDTO ?? null; + }); + + $selectedItemIndex = computed([this.$selectedItem], (selectedItem) => { + return selectedItem?.index ?? null; + }); + + select = (itemId: number) => { + this.$selectedItemId.set(itemId); + this._app.onSelect?.(itemId); + }; + + selectNext = () => { + const selectedItem = this.$selectedItem.get(); + if (selectedItem === null) { + return; + } + const items = this.$items.get(); + const nextIndex = (selectedItem.index + 1) % items.length; + const nextItem = items[nextIndex]; + if (!nextItem) { + return; + } + this.$selectedItemId.set(nextItem.item_id); + this._app.onSelectNext?.(); + }; + + selectPrev = () => { + const selectedItem = this.$selectedItem.get(); + if (selectedItem === null) { + return; + } + const items = this.$items.get(); + const prevIndex = (selectedItem.index - 1 + items.length) % items.length; + const prevItem = items[prevIndex]; + if (!prevItem) { + return; + } + this.$selectedItemId.set(prevItem.item_id); + this._app.onSelectPrev?.(); + }; + + selectFirst = () => { + const items = this.$items.get(); + const first = items.at(0); + if (!first) { + return; + } + this.$selectedItemId.set(first.item_id); + this._app.onSelectFirst?.(); + }; + + selectLast = () => { + const items = this.$items.get(); + const last = items.at(-1); + if (!last) { + return; + } + this.$selectedItemId.set(last.item_id); + this._app.onSelectLast?.(); + }; + + discardSelected = () => { + const selectedItem = this.$selectedItem.get(); + if (selectedItem === null) { + return; + } + const items = this.$items.get(); + const nextIndex = clamp(selectedItem.index + 1, 0, items.length - 1); + const nextItem = items[nextIndex]; + if (nextItem) { + this.$selectedItemId.set(nextItem.item_id); + } else { + this.$selectedItemId.set(null); + } + this._app.onDiscard?.(selectedItem.item); + }; + $discardSelectedIsEnabled = computed([this.$selectedItem], (selectedItem) => { + if (selectedItem === null) { + return false; + } + return true; + }); + + discardAll = () => { + this.$selectedItemId.set(null); + this._app.onDiscardAll?.(); + }; + + acceptSelected = () => { + const selectedItem = this.$selectedItem.get(); + if (selectedItem === null) { + return; + } + const progressData = this.$progressData.get(); + const datum = progressData[selectedItem.item.item_id]; + if (!datum || !datum.imageDTO) { + return; + } + this._app.onAccept?.(selectedItem.item, datum.imageDTO); + }; + $acceptSelectedIsEnabled = computed([this.$selectedItem, this.$progressData], (selectedItem, progressData) => { + if (selectedItem === null) { + return false; + } + const datum = progressData[selectedItem.item.item_id]; + return !!datum && !!datum.imageDTO; + }); + + setAutoSwitch = (mode: AutoSwitchMode) => { + this._app.onAutoSwitchChange?.(mode); + }; + + onInvocationProgressEvent = (data: S['InvocationProgressEvent']) => { + if (data.destination !== this.sessionId) { + return; + } + setProgress(this.$progressData, data); + }; + + onQueueItemStatusChangedEvent = (data: S['QueueItemStatusChangedEvent']) => { + if (data.destination !== this.sessionId) { + return; + } + if (data.status === 'completed') { + /** + * There is an unpleasant bit of indirection here. When an item is completed, and auto-switch is set to + * switch_on_finish, we want to load the image and switch to it. In this socket handler, we don't have + * access to the full queue item, which we need to get the output image and load it. We get the full + * queue items as part of the list query, so it's rather inefficient to fetch it again here. + * + * To reduce the number of extra network requests, we instead store this item as the last completed item. + * Then in the progress data sync effect, we process the queue item load its image. + */ + this.$lastCompletedItemId.set(data.item_id); + } + if (data.status === 'in_progress' && this._app.getAutoSwitch() === 'switch_on_start') { + this.$lastStartedItemId.set(data.item_id); + } + }; + + onItemsChangedEvent = async (items: S['SessionQueueItem'][]) => { + const oldItems = this.$items.get(); + + if (items === oldItems) { + return; + } + + if (items.length === 0) { + // If there are no items, cannot have a selected item. + this.$selectedItemId.set(null); + } else if (this.$selectedItemId.get() === null && items.length > 0) { + // If there is no selected item but there are items, select the first one. + this.$selectedItemId.set(items[0]?.item_id ?? null); + } + + const progressData = this.$progressData.get(); + + const toDelete: number[] = []; + const toUpdate: ProgressData[] = []; + + 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, + }); + } + } + + for (const item of items) { + const datum = progressData[item.item_id]; + + if (this.$lastStartedItemId.get() === item.item_id && this._app.getAutoSwitch() === 'switch_on_start') { + this.$selectedItemId.set(item.item_id); + this.$lastStartedItemId.set(null); + } + + if (datum?.imageDTO) { + continue; + } + const outputImageName = getOutputImageName(item); + if (!outputImageName) { + continue; + } + const imageDTO = await this._app.getImageDTO(outputImageName); + if (!imageDTO) { + continue; + } + + // This is the load logic mentioned in the comment in the QueueItemStatusChangedEvent handler above. + if (this.$lastCompletedItemId.get() === item.item_id && this._app.getAutoSwitch() === 'switch_on_finish') { + this._app.loadImage(imageDTO.image_url).then(() => { + this.$selectedItemId.set(item.item_id); + this.$lastCompletedItemId.set(null); + }); + } + + toUpdate.push({ + ...getInitialProgressData(item.item_id), + ...datum, + imageDTO, + }); + } + + for (const itemId of toDelete) { + this.$progressData.setKey(itemId, undefined); + } + + for (const datum of toUpdate) { + this.$progressData.setKey(datum.itemId, datum); + } + + this.$items.set(items); + }; + + buildIsSelectedComputed = (itemId: number) => { + return computed([this.$selectedItemId], (selectedItemId) => { + return selectedItemId === itemId; + }); + }; + + cleanup = () => { + this.$lastStartedItemId.set(null); + this.$lastCompletedItemId.set(null); + this.$items.set([]); + this.$progressData.set({}); + this.$selectedItemId.set(null); + this._subscriptions.forEach((unsubscribe) => unsubscribe()); + this._subscriptions.clear(); + }; +} + +const setProgress = ($progressData: MapStore, data: S['InvocationProgressEvent']) => { + const progressData = $progressData.get(); + const current = progressData[data.item_id]; + if (current) { + const next = { ...current }; + next.progressEvent = data; + if (data.image) { + next.progressImage = data.image; + } + $progressData.set({ + ...progressData, + [data.item_id]: next, + }); + } else { + $progressData.set({ + ...progressData, + [data.item_id]: { + itemId: data.item_id, + progressEvent: data, + progressImage: data.image ?? null, + imageDTO: null, + }, + }); + } +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaAutoSwitchButtons.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaAutoSwitchButtons.tsx index 8d98eec2914..f9fc483eea5 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaAutoSwitchButtons.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaAutoSwitchButtons.tsx @@ -1,5 +1,7 @@ import { IconButton } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectStagingAreaAutoSwitch, settingsStagingAreaAutoSwitchChanged, @@ -8,6 +10,9 @@ import { memo, useCallback } from 'react'; import { PiCaretLineRightBold, PiCaretRightBold, PiMoonBold } from 'react-icons/pi'; export const StagingAreaAutoSwitchButtons = memo(() => { + const canvasManager = useCanvasManager(); + const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); + const autoSwitch = useAppSelector(selectStagingAreaAutoSwitch); const dispatch = useAppDispatch(); @@ -29,6 +34,7 @@ export const StagingAreaAutoSwitchButtons = memo(() => { icon={} colorScheme={autoSwitch === 'off' ? 'invokeBlue' : 'base'} onClick={onClickOff} + isDisabled={!shouldShowStagedImage} /> { icon={} colorScheme={autoSwitch === 'switch_on_start' ? 'invokeBlue' : 'base'} onClick={onClickSwitchOnStart} + isDisabled={!shouldShowStagedImage} /> { icon={} colorScheme={autoSwitch === 'switch_on_finish' ? 'invokeBlue' : 'base'} onClick={onClickSwitchOnFinished} + isDisabled={!shouldShowStagedImage} /> ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx index 64df68cf267..1942d0bb869 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx @@ -1,6 +1,5 @@ import { ButtonGroup, Flex } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; -import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; import { StagingAreaToolbarAcceptButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton'; import { StagingAreaToolbarDiscardAllButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton'; import { StagingAreaToolbarDiscardSelectedButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton'; @@ -10,17 +9,13 @@ import { StagingAreaToolbarNextButton } from 'features/controlLayers/components/ import { StagingAreaToolbarPrevButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton'; import { StagingAreaToolbarSaveSelectedToGalleryButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton'; import { StagingAreaToolbarToggleShowResultsButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarToggleShowResultsButton'; -import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { memo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { StagingAreaAutoSwitchButtons } from './StagingAreaAutoSwitchButtons'; export const StagingAreaToolbar = memo(() => { - const canvasManager = useCanvasManager(); - const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); - - const ctx = useCanvasSessionContext(); + const ctx = useStagingAreaContext(); useHotkeys('meta+left', ctx.selectFirst, { preventDefault: true }); useHotkeys('meta+right', ctx.selectLast, { preventDefault: true }); @@ -28,22 +23,22 @@ export const StagingAreaToolbar = memo(() => { return ( - + - + - + - + ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx index 2161b3f1389..08af9233a1c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx @@ -1,65 +1,32 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useIsRegionFocused } from 'common/hooks/focus'; -import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice'; -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'; import { useCancelQueueItemsByDestination } from 'features/queue/hooks/useCancelQueueItemsByDestination'; -import { memo, useCallback } from 'react'; +import { memo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiCheckBold } from 'react-icons/pi'; export const StagingAreaToolbarAcceptButton = memo(() => { - const ctx = useCanvasSessionContext(); - const dispatch = useAppDispatch(); + const ctx = useStagingAreaContext(); const canvasManager = useCanvasManager(); - const canvasSessionId = useAppSelector(selectCanvasSessionId); - const bboxRect = useAppSelector(selectBboxRect); - const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const isCanvasFocused = useIsRegionFocused('canvas'); - const selectedItemImageDTO = useStore(ctx.$selectedItemOutputImageDTO); const cancelQueueItemsByDestination = useCancelQueueItemsByDestination(); + const acceptSelectedIsEnabled = useStore(ctx.$acceptSelectedIsEnabled); const { t } = useTranslation(); - const acceptSelected = useCallback(() => { - if (!selectedItemImageDTO) { - return; - } - const { x, y, width, height } = bboxRect; - const imageObject = imageNameToImageObject(selectedItemImageDTO.image_name, { width, height }); - const overrides: Partial = { - position: { x, y }, - objects: [imageObject], - }; - - dispatch(rasterLayerAdded({ overrides, isSelected: selectedEntityIdentifier?.type === 'raster_layer' })); - dispatch(canvasSessionReset()); - cancelQueueItemsByDestination.trigger(canvasSessionId, { withToast: false }); - }, [ - selectedItemImageDTO, - bboxRect, - dispatch, - selectedEntityIdentifier?.type, - cancelQueueItemsByDestination, - canvasSessionId, - ]); - useHotkeys( ['enter'], - acceptSelected, + ctx.acceptSelected, { preventDefault: true, - enabled: isCanvasFocused && shouldShowStagedImage && selectedItemImageDTO !== null, + enabled: isCanvasFocused && shouldShowStagedImage && acceptSelectedIsEnabled, }, - [isCanvasFocused, shouldShowStagedImage, selectedItemImageDTO] + [ctx.acceptSelected, isCanvasFocused, shouldShowStagedImage, acceptSelectedIsEnabled] ); return ( @@ -67,9 +34,9 @@ export const StagingAreaToolbarAcceptButton = memo(() => { tooltip={`${t('common.accept')} (Enter)`} aria-label={`${t('common.accept')} (Enter)`} icon={} - onClick={acceptSelected} + onClick={ctx.acceptSelected} colorScheme="invokeBlue" - isDisabled={!selectedItemImageDTO || !shouldShowStagedImage || cancelQueueItemsByDestination.isDisabled} + isDisabled={!acceptSelectedIsEnabled || !shouldShowStagedImage || cancelQueueItemsByDestination.isDisabled} isLoading={cancelQueueItemsByDestination.isLoading} /> ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx index 0c5f94206e2..54d48e9f60c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx @@ -1,31 +1,28 @@ 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 { useStore } from '@nanostores/react'; +import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useCancelQueueItemsByDestination } from 'features/queue/hooks/useCancelQueueItemsByDestination'; -import { memo, useCallback } from 'react'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiTrashSimpleBold } from 'react-icons/pi'; -export const StagingAreaToolbarDiscardAllButton = memo(({ isDisabled }: { isDisabled?: boolean }) => { - const ctx = useCanvasSessionContext(); +export const StagingAreaToolbarDiscardAllButton = memo(() => { + const canvasManager = useCanvasManager(); + const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); + + const ctx = useStagingAreaContext(); const { t } = useTranslation(); const cancelQueueItemsByDestination = useCancelQueueItemsByDestination(); - const canvasSessionId = useAppSelector(selectCanvasSessionId); - - const discardAll = useCallback(() => { - ctx.discardAll(); - cancelQueueItemsByDestination.trigger(canvasSessionId, { withToast: false }); - }, [cancelQueueItemsByDestination, ctx, canvasSessionId]); return ( } - onClick={discardAll} + onClick={ctx.discardAll} colorScheme="error" - isDisabled={isDisabled || cancelQueueItemsByDestination.isDisabled} + isDisabled={cancelQueueItemsByDestination.isDisabled || !shouldShowStagedImage} isLoading={cancelQueueItemsByDestination.isLoading} /> ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx index 78cace1cb00..ddef449f794 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx @@ -1,34 +1,30 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem'; -import { memo, useCallback } from 'react'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiXBold } from 'react-icons/pi'; -export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { isDisabled?: boolean }) => { - const ctx = useCanvasSessionContext(); +export const StagingAreaToolbarDiscardSelectedButton = memo(() => { + const canvasManager = useCanvasManager(); + const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); + + const ctx = useStagingAreaContext(); const cancelQueueItem = useCancelQueueItem(); - const selectedItemId = useStore(ctx.$selectedItemId); + const discardSelectedIsEnabled = useStore(ctx.$discardSelectedIsEnabled); const { t } = useTranslation(); - const discardSelected = useCallback(async () => { - if (selectedItemId === null) { - return; - } - ctx.discard(selectedItemId); - await cancelQueueItem.trigger(selectedItemId, { withToast: false }); - }, [selectedItemId, ctx, cancelQueueItem]); - return ( } - onClick={discardSelected} + onClick={ctx.discardSelected} colorScheme="invokeBlue" - isDisabled={selectedItemId === null || cancelQueueItem.isDisabled || isDisabled} + isDisabled={!discardSelectedIsEnabled || cancelQueueItem.isDisabled || !shouldShowStagedImage} isLoading={cancelQueueItem.isLoading} /> ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton.tsx index 446dea8836b..c159503bbda 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton.tsx @@ -1,23 +1,27 @@ import { Button } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { memo, useMemo } from 'react'; export const StagingAreaToolbarImageCountButton = memo(() => { - const ctx = useCanvasSessionContext(); - const selectItemIndex = useStore(ctx.$selectedItemIndex); + const canvasManager = useCanvasManager(); + const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); + + const ctx = useStagingAreaContext(); + const selectedItem = useStore(ctx.$selectedItem); const itemCount = useStore(ctx.$itemCount); const counterText = useMemo(() => { - if (itemCount > 0 && selectItemIndex !== null) { - return `${selectItemIndex + 1} of ${itemCount}`; + if (itemCount > 0 && selectedItem !== null) { + return `${selectedItem.index + 1} of ${itemCount}`; } else { return `0 of 0`; } - }, [itemCount, selectItemIndex]); + }, [itemCount, selectedItem]); return ( - ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenu.tsx index 2886f7f2c69..ce32c823aa5 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenu.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenu.tsx @@ -1,12 +1,23 @@ import { IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; import { StagingAreaToolbarNewLayerFromImageMenuItems } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { memo } from 'react'; import { PiDotsThreeVerticalBold } from 'react-icons/pi'; export const StagingAreaToolbarMenu = memo(() => { + const canvasManager = useCanvasManager(); + const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); + return ( - } colorScheme="invokeBlue" /> + } + colorScheme="invokeBlue" + isDisabled={!shouldShowStagedImage} + /> diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx index 1f73b8de73d..97817d3458c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx @@ -2,7 +2,7 @@ import { MenuGroup, MenuItem } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppStore } from 'app/store/storeHooks'; import { NewLayerIcon } from 'features/controlLayers/components/common/icons'; -import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { createNewCanvasEntityFromImage } from 'features/imageActions/actions'; import { toast } from 'features/toast/toast'; @@ -15,8 +15,8 @@ const uploadImageArg = { image_category: 'general', is_intermediate: true, silen export const StagingAreaToolbarNewLayerFromImageMenuItems = memo(() => { const canvasManager = useCanvasManager(); const { t } = useTranslation(); - const ctx = useCanvasSessionContext(); - const selectedItemOutputImageDTO = useStore(ctx.$selectedItemOutputImageDTO); + const ctx = useStagingAreaContext(); + const selectedItemImageDTO = useStore(ctx.$selectedItemImageDTO); const store = useAppStore(); const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); @@ -29,11 +29,11 @@ export const StagingAreaToolbarNewLayerFromImageMenuItems = memo(() => { }, [t]); const onClickNewRasterLayerFromImage = useCallback(async () => { - if (!selectedItemOutputImageDTO) { + if (!selectedItemImageDTO) { return; } const { dispatch, getState } = store; - const imageDTO = await copyImage(selectedItemOutputImageDTO.image_name, uploadImageArg); + const imageDTO = await copyImage(selectedItemImageDTO.image_name, uploadImageArg); createNewCanvasEntityFromImage({ imageDTO, type: 'raster_layer', @@ -42,14 +42,14 @@ export const StagingAreaToolbarNewLayerFromImageMenuItems = memo(() => { overrides: { isEnabled: false }, // We are adding the layer while staging, it should be disabled by default }); toastSentToCanvas(); - }, [selectedItemOutputImageDTO, store, toastSentToCanvas]); + }, [selectedItemImageDTO, store, toastSentToCanvas]); const onClickNewControlLayerFromImage = useCallback(async () => { - if (!selectedItemOutputImageDTO) { + if (!selectedItemImageDTO) { return; } const { dispatch, getState } = store; - const imageDTO = await copyImage(selectedItemOutputImageDTO.image_name, uploadImageArg); + const imageDTO = await copyImage(selectedItemImageDTO.image_name, uploadImageArg); createNewCanvasEntityFromImage({ imageDTO, @@ -59,14 +59,14 @@ export const StagingAreaToolbarNewLayerFromImageMenuItems = memo(() => { overrides: { isEnabled: false }, // We are adding the layer while staging, it should be disabled by default }); toastSentToCanvas(); - }, [selectedItemOutputImageDTO, store, toastSentToCanvas]); + }, [selectedItemImageDTO, store, toastSentToCanvas]); const onClickNewInpaintMaskFromImage = useCallback(async () => { - if (!selectedItemOutputImageDTO) { + if (!selectedItemImageDTO) { return; } const { dispatch, getState } = store; - const imageDTO = await copyImage(selectedItemOutputImageDTO.image_name, uploadImageArg); + const imageDTO = await copyImage(selectedItemImageDTO.image_name, uploadImageArg); createNewCanvasEntityFromImage({ imageDTO, @@ -76,14 +76,14 @@ export const StagingAreaToolbarNewLayerFromImageMenuItems = memo(() => { overrides: { isEnabled: false }, // We are adding the layer while staging, it should be disabled by default }); toastSentToCanvas(); - }, [selectedItemOutputImageDTO, store, toastSentToCanvas]); + }, [selectedItemImageDTO, store, toastSentToCanvas]); const onClickNewRegionalGuidanceFromImage = useCallback(async () => { - if (!selectedItemOutputImageDTO) { + if (!selectedItemImageDTO) { return; } const { dispatch, getState } = store; - const imageDTO = await copyImage(selectedItemOutputImageDTO.image_name, uploadImageArg); + const imageDTO = await copyImage(selectedItemImageDTO.image_name, uploadImageArg); createNewCanvasEntityFromImage({ imageDTO, @@ -93,35 +93,35 @@ export const StagingAreaToolbarNewLayerFromImageMenuItems = memo(() => { overrides: { isEnabled: false }, // We are adding the layer while staging, it should be disabled by default }); toastSentToCanvas(); - }, [selectedItemOutputImageDTO, store, toastSentToCanvas]); + }, [selectedItemImageDTO, store, toastSentToCanvas]); return ( } onClickCapture={onClickNewInpaintMaskFromImage} - isDisabled={!selectedItemOutputImageDTO || !shouldShowStagedImage} + isDisabled={!selectedItemImageDTO || !shouldShowStagedImage} > {t('controlLayers.inpaintMask')} } onClickCapture={onClickNewRegionalGuidanceFromImage} - isDisabled={!selectedItemOutputImageDTO || !shouldShowStagedImage} + isDisabled={!selectedItemImageDTO || !shouldShowStagedImage} > {t('controlLayers.regionalGuidance')} } onClickCapture={onClickNewControlLayerFromImage} - isDisabled={!selectedItemOutputImageDTO || !shouldShowStagedImage} + isDisabled={!selectedItemImageDTO || !shouldShowStagedImage} > {t('controlLayers.controlLayer')} } onClickCapture={onClickNewRasterLayerFromImage} - isDisabled={!selectedItemOutputImageDTO || !shouldShowStagedImage} + isDisabled={!selectedItemImageDTO || !shouldShowStagedImage} > {t('controlLayers.rasterLayer')} diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx index 91eec2f6def..650015f0aee 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx @@ -1,14 +1,18 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useIsRegionFocused } from 'common/hooks/focus'; -import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { memo, useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiArrowRightBold } from 'react-icons/pi'; -export const StagingAreaToolbarNextButton = memo(({ isDisabled }: { isDisabled?: boolean }) => { - const ctx = useCanvasSessionContext(); +export const StagingAreaToolbarNextButton = memo(() => { + const canvasManager = useCanvasManager(); + const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); + + const ctx = useStagingAreaContext(); const itemCount = useStore(ctx.$itemCount); const isCanvasFocused = useIsRegionFocused('canvas'); @@ -23,9 +27,9 @@ export const StagingAreaToolbarNextButton = memo(({ isDisabled }: { isDisabled?: ctx.selectNext, { preventDefault: true, - enabled: isCanvasFocused && !isDisabled && itemCount > 1, + enabled: isCanvasFocused && !shouldShowStagedImage && itemCount > 1, }, - [isCanvasFocused, isDisabled, itemCount, ctx.selectNext] + [isCanvasFocused, shouldShowStagedImage, itemCount, ctx.selectNext] ); return ( @@ -35,7 +39,7 @@ export const StagingAreaToolbarNextButton = memo(({ isDisabled }: { isDisabled?: icon={} onClick={selectNext} colorScheme="invokeBlue" - isDisabled={itemCount <= 1 || isDisabled} + isDisabled={itemCount <= 1 || !shouldShowStagedImage} /> ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx index cbed5ab675e..af0e625e08a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx @@ -1,14 +1,17 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useIsRegionFocused } from 'common/hooks/focus'; -import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { memo, useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiArrowLeftBold } from 'react-icons/pi'; -export const StagingAreaToolbarPrevButton = memo(({ isDisabled }: { isDisabled?: boolean }) => { - const ctx = useCanvasSessionContext(); +export const StagingAreaToolbarPrevButton = memo(() => { + const canvasManager = useCanvasManager(); + const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); + const ctx = useStagingAreaContext(); const itemCount = useStore(ctx.$itemCount); const isCanvasFocused = useIsRegionFocused('canvas'); @@ -23,9 +26,9 @@ export const StagingAreaToolbarPrevButton = memo(({ isDisabled }: { isDisabled?: ctx.selectPrev, { preventDefault: true, - enabled: isCanvasFocused && !isDisabled && itemCount > 1, + enabled: isCanvasFocused && !shouldShowStagedImage && itemCount > 1, }, - [isCanvasFocused, isDisabled, itemCount, ctx.selectPrev] + [isCanvasFocused, shouldShowStagedImage, itemCount, ctx.selectPrev] ); return ( @@ -35,7 +38,7 @@ export const StagingAreaToolbarPrevButton = memo(({ isDisabled }: { isDisabled?: icon={} onClick={selectPrev} colorScheme="invokeBlue" - isDisabled={itemCount <= 1 || isDisabled} + isDisabled={itemCount <= 1 || !shouldShowStagedImage} /> ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx index 82476c1e4de..3ce7fad3196 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx @@ -2,7 +2,7 @@ import { IconButton } from '@invoke-ai/ui-library'; 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 { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; import { toast } from 'features/toast/toast'; @@ -16,14 +16,14 @@ const TOAST_ID = 'SAVE_STAGING_AREA_IMAGE_TO_GALLERY'; export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => { const canvasManager = useCanvasManager(); const autoAddBoardId = useAppSelector(selectAutoAddBoardId); - const ctx = useCanvasSessionContext(); - const selectedItemOutputImageDTO = useStore(ctx.$selectedItemOutputImageDTO); + const ctx = useStagingAreaContext(); + const selectedItemImageDTO = useStore(ctx.$selectedItemImageDTO); const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const { t } = useTranslation(); const saveSelectedImageToGallery = useCallback(async () => { - if (!selectedItemOutputImageDTO) { + if (!selectedItemImageDTO) { return; } @@ -31,7 +31,7 @@ export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => { // the gallery without borking the canvas, which may need this image to exist. const result = await withResultAsync(async () => { // Create a new file with the same name, which we will upload - await copyImage(selectedItemOutputImageDTO.image_name, { + await copyImage(selectedItemImageDTO.image_name, { // Image should show up in the Images tab image_category: 'general', is_intermediate: false, @@ -55,7 +55,7 @@ export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => { status: 'error', }); } - }, [autoAddBoardId, selectedItemOutputImageDTO, t]); + }, [autoAddBoardId, selectedItemImageDTO, t]); return ( { icon={} onClick={saveSelectedImageToGallery} colorScheme="invokeBlue" - isDisabled={!selectedItemOutputImageDTO || !shouldShowStagedImage} + isDisabled={!selectedItemImageDTO || !shouldShowStagedImage} /> ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts index 156d4eccb60..26e4fb8e37c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts @@ -1,5 +1,5 @@ import { Mutex } from 'async-mutex'; -import type { ProgressData, ProgressDataMap } from 'features/controlLayers/components/SimpleSession/context'; +import type { SelectedItemData } from 'features/controlLayers/components/SimpleSession/state'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage'; @@ -149,33 +149,24 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { this.render(); }; - connectToSession = ( - $items: Atom, - $selectedItemId: Atom, - $progressData: ProgressDataMap - ) => { - const imageSrcListener = ( - selectedItemId: number | null, - progressData: Record - ) => { - if (!selectedItemId) { + connectToSession = ($items: Atom, $selectedItem: Atom) => { + const imageSrcListener = (selectedItem: SelectedItemData | null) => { + if (!selectedItem) { this.$imageSrc.set(null); return; } - const datum = progressData[selectedItemId]; - - if (datum?.imageDTO) { - this.$imageSrc.set({ type: 'imageName', data: datum.imageDTO.image_name }); + if (selectedItem.progressData.imageDTO) { + this.$imageSrc.set({ type: 'imageName', data: selectedItem.progressData.imageDTO.image_name }); return; - } else if (datum?.progressImage) { - this.$imageSrc.set({ type: 'dataURL', data: datum.progressImage.dataURL }); + } else if (selectedItem.progressData?.progressImage) { + this.$imageSrc.set({ type: 'dataURL', data: selectedItem.progressData.progressImage.dataURL }); return; } else { this.$imageSrc.set(null); } }; - const unsubImageSrc = effect([$selectedItemId, $progressData], imageSrcListener); + const unsubImageSrc = effect([$selectedItem], imageSrcListener); const isPendingListener = (items: S['SessionQueueItem'][]) => { this.$isPending.set(items.some((item) => item.status === 'pending' || item.status === 'in_progress')); @@ -190,7 +181,7 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { // Run the effects & forcibly render once to initialize isStagingListener($items.get()); isPendingListener($items.get()); - imageSrcListener($selectedItemId.get(), $progressData.get()); + imageSrcListener($selectedItem.get()); this.render(); return () => { diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts index ed0768da588..5830fe1f12c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts @@ -5,6 +5,7 @@ import { zRgbaColor } from 'features/controlLayers/store/types'; import { z } from 'zod'; const zAutoSwitchMode = z.enum(['off', 'switch_on_start', 'switch_on_finish']); +export type AutoSwitchMode = z.infer; const zCanvasSettingsState = z.object({ /** diff --git a/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx index de1993f053d..d7dbc9a4cc6 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx @@ -13,10 +13,12 @@ import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD'; import { InvokeCanvasComponent } from 'features/controlLayers/components/InvokeCanvasComponent'; import { SelectObject } from 'features/controlLayers/components/SelectObject/SelectObject'; import { CanvasSessionContextProvider } from 'features/controlLayers/components/SimpleSession/context'; +import { StagingAreaContextProvider } from 'features/controlLayers/components/SimpleSession/context2'; import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; 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'; @@ -49,74 +51,77 @@ const canvasBgSx = { export const CanvasWorkspacePanel = memo(() => { const dynamicGrid = useAppSelector(selectDynamicGrid); const showHUD = useAppSelector(selectShowHUD); + const sessionId = useAppSelector(selectCanvasSessionId); const renderMenu = useCallback(() => { return ; }, []); return ( - - - - - - renderMenu={renderMenu} withLongPress={false}> - {(ref) => ( - - - - - {showHUD && } - - - - - - - - } colorScheme="base" /> - - - - - - - )} - - - - - - - + + - - - + + + + renderMenu={renderMenu} withLongPress={false}> + {(ref) => ( + + + + + {showHUD && } + + + + + + + + } colorScheme="base" /> + + + + + + + )} + + + + + + + + + + + + + + + - - - - + ); }); CanvasWorkspacePanel.displayName = 'CanvasWorkspacePanel'; From 1e16b92cf6373cc20e47eebe6b6863228e2fd56b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 18 Jul 2025 16:00:36 +1000 Subject: [PATCH 07/16] chore(ui): lint --- .../components/SimpleSession/context.tsx | 554 ------------------ .../components/SimpleSession/state.ts | 2 +- .../ui/layouts/CanvasWorkspacePanel.tsx | 5 +- 3 files changed, 2 insertions(+), 559 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx deleted file mode 100644 index afeef069084..00000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ /dev/null @@ -1,554 +0,0 @@ -import { useStore } from '@nanostores/react'; -import { useAppSelector, useAppStore } from 'app/store/storeHooks'; -import { clamp } from 'es-toolkit/compat'; -import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared'; -import { loadImage } from 'features/controlLayers/konva/util'; -import { selectStagingAreaAutoSwitch } from 'features/controlLayers/store/canvasSettingsSlice'; -import { - 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'; -import { atom, computed, map, subscribeKeys } from 'nanostores'; -import type { PropsWithChildren } from 'react'; -import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { getImageDTOSafe } from 'services/api/endpoints/images'; -import { queueApi } from 'services/api/endpoints/queue'; -import type { ImageDTO, S } from 'services/api/types'; -import { $socket } from 'services/events/stores'; -import { assert, objectEntries } from 'tsafe'; - -export type ProgressData = { - itemId: number; - progressEvent: S['InvocationProgressEvent'] | null; - progressImage: ProgressImage | null; - imageDTO: ImageDTO | null; - imageLoaded: boolean; -}; - -const getInitialProgressData = (itemId: number): ProgressData => ({ - itemId, - progressEvent: null, - progressImage: null, - imageDTO: null, - imageLoaded: false, -}); - -export const useProgressData = ($progressData: ProgressDataMap, itemId: number): ProgressData => { - const getInitialValue = useCallback( - () => $progressData.get()[itemId] ?? getInitialProgressData(itemId), - [$progressData, itemId] - ); - const [value, setValue] = useState(getInitialValue); - useEffect(() => { - const unsub = subscribeKeys($progressData, [itemId], (data) => { - const progressData = data[itemId]; - if (!progressData) { - return; - } - setValue(progressData); - }); - return () => { - unsub(); - }; - }, [$progressData, itemId]); - - return value; -}; - -const setProgress = ($progressData: ProgressDataMap, data: S['InvocationProgressEvent']) => { - const progressData = $progressData.get(); - const current = progressData[data.item_id]; - if (current) { - const next = { ...current }; - next.progressEvent = data; - if (data.image) { - next.progressImage = data.image; - } - $progressData.set({ - ...progressData, - [data.item_id]: next, - }); - } else { - $progressData.set({ - ...progressData, - [data.item_id]: { - itemId: data.item_id, - progressEvent: data, - progressImage: data.image ?? null, - imageDTO: null, - imageLoaded: false, - }, - }); - } -}; - -export type ProgressDataMap = MapStore>; - -type CanvasSessionContextValue = { - $items: Atom; - $itemCount: Atom; - $hasItems: Atom; - $progressData: ProgressDataMap; - $selectedItemId: WritableAtom; - $selectedItem: Atom; - $selectedItemIndex: Atom; - $selectedItemOutputImageDTO: Atom; - selectNext: () => void; - selectPrev: () => void; - selectFirst: () => void; - selectLast: () => void; - discard: (itemId: number) => void; - discardAll: () => void; -}; - -const CanvasSessionContext = createContext(null); - -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(); - - const sessionId = useAppSelector(selectCanvasSessionId); - - const socket = useStore($socket); - - /** - * Track the last started item. Used to implement autoswitch. - */ - const $lastStartedItemId = useState(() => atom(null))[0]; - - /** - * Track the last completed item. Used to implement autoswitch. - */ - const $lastCompletedItemId = useState(() => atom(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([]))[0]; - - /** - * An ephemeral store of progress events and images for all items in the current session. - */ - const $progressData = useState(() => map>({}))[0]; - - /** - * The currently selected queue item's ID, or null if one is not selected. - */ - const $selectedItemId = useState(() => atom(null))[0]; - - /** - * The number of items. Computed from the queue items array. - */ - const $itemCount = useState(() => computed([$items], (items) => items.length))[0]; - - /** - * Whether there are any items. Computed from the queue items array. - */ - const $hasItems = useState(() => computed([$items], (items) => items.length > 0))[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]; - - /** - * 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; - } - const selectedItemIndex = items.findIndex(({ item_id }) => item_id === selectedItemId); - - if (selectedItemIndex === -1) { - return null; - } - - return selectedItemIndex; - }) - )[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 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 discard = useCallback( - (itemId: number) => { - const selectedItemId = $selectedItemId.get(); - const items = $items.get(); - const currentIndex = items.findIndex((item) => item.item_id === selectedItemId); - const nextIndex = clamp(currentIndex + 1, 0, items.length - 1); - const nextItem = items[nextIndex]; - if (nextItem) { - $selectedItemId.set(nextItem.item_id); - } else { - $selectedItemId.set(null); - } - store.dispatch(canvasQueueItemDiscarded({ itemId })); - }, - [$items, $selectedItemId, store] - ); - - const discardAll = useCallback(() => { - store.dispatch(canvasSessionReset()); - $selectedItemId.set(null); - }, [$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') { - /** - * There is an unpleasant bit of indirection here. When an item is completed, and auto-switch is set to - * switch_on_finish, we want to load the image and switch to it. In this socket handler, we don't have - * access to the full queue item, which we need to get the output image and load it. We get the full - * queue items as part of the list query, so it's rather inefficient to fetch it again here. - * - * To reduce the number of extra network requests, we instead store this item as the last completed item. - * Then in the progress data sync effect, we process the queue item load its image. - */ - $lastCompletedItemId.set(data.item_id); - } - if (data.status === 'in_progress' && selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_start') { - $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); - }; - }, [$progressData, $selectedItemId, sessionId, socket, $lastCompletedItemId, store, $lastStartedItemId]); - - // 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 oldItems = $items.get(); - const newItems = selectQueueItems(store.getState()); - if (newItems !== oldItems) { - // _prevItems = prevItems; - // const selectedItemId = $selectedItemId.get(); - if (newItems.length === 0) { - // If there are no items, cannot have a selected item. - $selectedItemId.set(null); - } else if ($selectedItemId.get() === null && newItems.length > 0) { - // If there is no selected item but there are items, select the first one. - $selectedItemId.set(newItems[0]?.item_id ?? null); - return; - } - // } 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); - // prevIndex = clamp(prevIndex + 1, 0, items.length - 1); - // const nextItem = items[prevIndex]; - // $selectedItemId.set(nextItem?.item_id ?? null); - // } - $items.set(newItems); - } - }); - - // Handle cases that could result in a nonexistent queue item being selected. - // const unsubEnsureSelectedItemIdExists = effect([$items, $selectedItemId], (items, selectedItemId) => { - // 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 (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); - // prevIndex = clamp(prevIndex + 1, 0, items.length - 1); - // // console.log('first', prevIndex); - // // if (prevIndex !== -1) { - // // console.log('inner', prevIndex); - // // } - // const nextItem = items[prevIndex]; - // console.log('final', prevIndex, nextItem); - // $selectedItemId.set(nextItem?.item_id ?? null); - // } - // }); - - // Sync progress data - remove canceled/failed items, update progress data with new images, and load images - const unsubSyncProgressData = $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); - 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, - }); - } - } - - for (const item of items) { - const datum = progressData[item.item_id]; - - if ( - $lastStartedItemId.get() === item.item_id && - selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_start' - ) { - $selectedItemId.set(item.item_id); - $lastStartedItemId.set(null); - } - - if (datum?.imageDTO) { - continue; - } - const outputImageName = getOutputImageName(item); - if (!outputImageName) { - continue; - } - const imageDTO = await getImageDTOSafe(outputImageName); - if (!imageDTO) { - continue; - } - - // This is the load logic mentioned in the comment in the QueueItemStatusChangedEvent handler above. - if ( - $lastCompletedItemId.get() === item.item_id && - selectStagingAreaAutoSwitch(store.getState()) === 'switch_on_finish' - ) { - loadImage(imageDTO.image_url, true).then(() => { - $selectedItemId.set(item.item_id); - $lastCompletedItemId.set(null); - }); - } - - toUpdate.push({ - ...getInitialProgressData(item.item_id), - ...datum, - imageDTO, - }); - } - - for (const itemId of toDelete) { - $progressData.setKey(itemId, undefined); - } - - for (const datum of toUpdate) { - $progressData.setKey(datum.itemId, datum); - } - }); - - // 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 }) - ); - - // Clean up all subscriptions and top-level (i.e. non-computed/derived state) - return () => { - unsubQueueItemsQuery(); - unsubReduxSyncToItemsAtom(); - // unsubEnsureSelectedItemIdExists(); - unsubSyncProgressData(); - $items.set([]); - $progressData.set({}); - $selectedItemId.set(null); - }; - }, [ - $items, - $progressData, - $selectedItemId, - selectQueueItems, - sessionId, - store, - $lastCompletedItemId, - $lastStartedItemId, - ]); - - const value = useMemo( - () => ({ - $items, - $hasItems, - $isPending, - $progressData, - $selectedItemId, - $selectedItem, - $selectedItemIndex, - $selectedItemOutputImageDTO, - $itemCount, - selectNext, - selectPrev, - selectFirst, - selectLast, - discard, - discardAll, - }), - [ - $items, - $hasItems, - $isPending, - $progressData, - $selectedItem, - $selectedItemId, - $selectedItemIndex, - $selectedItemOutputImageDTO, - $itemCount, - selectNext, - selectPrev, - selectFirst, - selectLast, - discard, - discardAll, - ] - ); - - return {children}; -}); -CanvasSessionContextProvider.displayName = 'CanvasSessionContextProvider'; - -export const useCanvasSessionContext = () => { - const ctx = useContext(CanvasSessionContext); - assert(ctx !== null, "'useCanvasSessionContext' must be used within a CanvasSessionContextProvider"); - return ctx; -}; - -export const useOutputImageDTO = (item: S['SessionQueueItem']) => { - const ctx = useCanvasSessionContext(); - const $imageDTO = useState(() => - computed([ctx.$progressData], (progressData) => progressData[item.item_id]?.imageDTO ?? null) - )[0]; - const imageDTO = useStore($imageDTO); - - return imageDTO; -}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/state.ts b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/state.ts index 095c903bf6b..cf27bfdebfc 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/state.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/state.ts @@ -45,7 +45,7 @@ export const getInitialProgressData = (itemId: number): ProgressData => ({ progressImage: null, imageDTO: null, }); -export type ProgressDataMap = Record; +type ProgressDataMap = Record; export class StagingAreaApi { sessionId: string; diff --git a/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx index d7dbc9a4cc6..b5aded3578d 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx @@ -12,7 +12,6 @@ import { Filter } from 'features/controlLayers/components/Filters/Filter'; import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD'; import { InvokeCanvasComponent } from 'features/controlLayers/components/InvokeCanvasComponent'; import { SelectObject } from 'features/controlLayers/components/SelectObject/SelectObject'; -import { CanvasSessionContextProvider } from 'features/controlLayers/components/SimpleSession/context'; import { StagingAreaContextProvider } from 'features/controlLayers/components/SimpleSession/context2'; import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; import { Transform } from 'features/controlLayers/components/Transform/Transform'; @@ -106,9 +105,7 @@ export const CanvasWorkspacePanel = memo(() => { )} - - - + From b297892734733c3fde793c821628b9b36bed1c1e Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 18 Jul 2025 16:02:16 +1000 Subject: [PATCH 08/16] chore(ui): rename `context2.tsx` -> `context.tsx` --- .../components/SimpleSession/QueueItemCircularProgress.tsx | 2 +- .../components/SimpleSession/QueueItemPreviewMini.tsx | 2 +- .../components/SimpleSession/QueueItemProgressImage.tsx | 2 +- .../components/SimpleSession/QueueItemStatusLabel.tsx | 2 +- .../components/SimpleSession/StagingAreaItemsList.tsx | 2 +- .../components/SimpleSession/{context2.tsx => context.tsx} | 0 .../controlLayers/components/StagingArea/StagingAreaToolbar.tsx | 2 +- .../components/StagingArea/StagingAreaToolbarAcceptButton.tsx | 2 +- .../StagingArea/StagingAreaToolbarDiscardAllButton.tsx | 2 +- .../StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx | 2 +- .../StagingArea/StagingAreaToolbarImageCountButton.tsx | 2 +- .../StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx | 2 +- .../components/StagingArea/StagingAreaToolbarNextButton.tsx | 2 +- .../components/StagingArea/StagingAreaToolbarPrevButton.tsx | 2 +- .../StagingAreaToolbarSaveSelectedToGalleryButton.tsx | 2 +- .../web/src/features/ui/layouts/CanvasWorkspacePanel.tsx | 2 +- 16 files changed, 15 insertions(+), 15 deletions(-) rename invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/{context2.tsx => context.tsx} (100%) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx index 7732f29559d..b47c112bf51 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx @@ -4,7 +4,7 @@ import { getProgressMessage } from 'features/controlLayers/components/SimpleSess import { memo } from 'react'; import type { S } from 'services/api/types'; -import { useProgressDatum } from './context2'; +import { useProgressDatum } from './context'; const circleStyles: SystemStyleObject = { circle: { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx index 2dd5df9088e..b3b246392bd 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx @@ -16,7 +16,7 @@ import { toast } from 'features/toast/toast'; import { memo, useCallback, useMemo } from 'react'; import type { S } from 'services/api/types'; -import { useOutputImageDTO, useStagingAreaContext } from './context2'; +import { useOutputImageDTO, useStagingAreaContext } from './context'; const sx = { cursor: 'pointer', diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx index f28df9d870b..20eb6c1ae4d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx @@ -2,7 +2,7 @@ import type { ImageProps } from '@invoke-ai/ui-library'; import { Image } from '@invoke-ai/ui-library'; import { memo } from 'react'; -import { useProgressDatum } from './context2'; +import { useProgressDatum } from './context'; type Props = { itemId: number } & ImageProps; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx index 907e4641951..d1acbd9487a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx @@ -3,7 +3,7 @@ import { Text } from '@invoke-ai/ui-library'; import { memo } from 'react'; import type { S } from 'services/api/types'; -import { useProgressDatum } from './context2'; +import { useProgressDatum } from './context'; type Props = { item: S['SessionQueueItem'] } & TextProps; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx index a73d52cb7cf..d237665eab6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx @@ -10,7 +10,7 @@ import type { Components, ComputeItemKey, ItemContent, ListRange, VirtuosoHandle import { Virtuoso } from 'react-virtuoso'; import type { S } from 'services/api/types'; -import { useStagingAreaContext } from './context2'; +import { useStagingAreaContext } from './context'; import { getQueueItemElementId } from './shared'; const log = logger('system'); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context2.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context2.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx index 1942d0bb869..f7494924593 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx @@ -1,5 +1,5 @@ import { ButtonGroup, Flex } from '@invoke-ai/ui-library'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; +import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context'; import { StagingAreaToolbarAcceptButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton'; import { StagingAreaToolbarDiscardAllButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton'; import { StagingAreaToolbarDiscardSelectedButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx index 08af9233a1c..ff70a61101e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx @@ -1,7 +1,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useIsRegionFocused } from 'common/hooks/focus'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; +import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useCancelQueueItemsByDestination } from 'features/queue/hooks/useCancelQueueItemsByDestination'; import { memo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx index 54d48e9f60c..22da6ec204a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx @@ -1,6 +1,6 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; +import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useCancelQueueItemsByDestination } from 'features/queue/hooks/useCancelQueueItemsByDestination'; import { memo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx index ddef449f794..ccd3d795f58 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx @@ -1,6 +1,6 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; +import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem'; import { memo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton.tsx index c159503bbda..8350a658c10 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton.tsx @@ -1,6 +1,6 @@ import { Button } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; +import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { memo, useMemo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx index 97817d3458c..2986a2ff33b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx @@ -2,7 +2,7 @@ import { MenuGroup, MenuItem } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppStore } from 'app/store/storeHooks'; import { NewLayerIcon } from 'features/controlLayers/components/common/icons'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; +import { useStagingAreaContext } 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'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx index 650015f0aee..ad2ad5c0bc8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx @@ -1,7 +1,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useIsRegionFocused } from 'common/hooks/focus'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; +import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { memo, useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx index af0e625e08a..a2f0029b49e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx @@ -1,7 +1,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useIsRegionFocused } from 'common/hooks/focus'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; +import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { memo, useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx index 3ce7fad3196..cfdec688d44 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx @@ -2,7 +2,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppSelector } from 'app/store/storeHooks'; import { withResultAsync } from 'common/util/result'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context2'; +import { useStagingAreaContext } 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'; diff --git a/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx index b5aded3578d..0d9f55f5cdd 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx @@ -12,7 +12,7 @@ import { Filter } from 'features/controlLayers/components/Filters/Filter'; import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD'; import { InvokeCanvasComponent } from 'features/controlLayers/components/InvokeCanvasComponent'; import { SelectObject } from 'features/controlLayers/components/SelectObject/SelectObject'; -import { StagingAreaContextProvider } from 'features/controlLayers/components/SimpleSession/context2'; +import { StagingAreaContextProvider } from 'features/controlLayers/components/SimpleSession/context'; import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; import { Transform } from 'features/controlLayers/components/Transform/Transform'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; From 546cb23071e2ecd29d8831622a193301487725a3 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 18 Jul 2025 16:06:55 +1000 Subject: [PATCH 09/16] tidy(ui): move launchpad components to ui dir --- .../nodes/components/sidePanel/viewMode/EmptyState.tsx | 2 +- .../SimpleSession => ui/layouts}/CanvasLaunchpadPanel.tsx | 0 .../SimpleSession => ui/layouts}/GenerateLaunchpadPanel.tsx | 4 ++-- .../layouts}/InitialStateMainModelPicker.tsx | 0 .../layouts}/LaunchpadAddStyleReference.tsx | 2 +- .../SimpleSession => ui/layouts}/LaunchpadButton.tsx | 0 .../SimpleSession => ui/layouts}/LaunchpadContainer.tsx | 0 .../SimpleSession => ui/layouts}/LaunchpadEditImageButton.tsx | 2 +- .../layouts}/LaunchpadGenerateFromTextButton.tsx | 2 +- .../layouts}/LaunchpadUseALayoutImageButton.tsx | 0 .../SimpleSession => ui/layouts}/UpscalingLaunchpadPanel.tsx | 0 .../SimpleSession => ui/layouts}/WorkflowsLaunchpadPanel.tsx | 0 .../web/src/features/ui/layouts/canvas-tab-auto-layout.tsx | 2 +- .../web/src/features/ui/layouts/generate-tab-auto-layout.tsx | 2 +- .../web/src/features/ui/layouts/upscaling-tab-auto-layout.tsx | 2 +- .../web/src/features/ui/layouts/workflows-tab-auto-layout.tsx | 2 +- 16 files changed, 10 insertions(+), 10 deletions(-) rename invokeai/frontend/web/src/features/{controlLayers/components/SimpleSession => ui/layouts}/CanvasLaunchpadPanel.tsx (100%) rename invokeai/frontend/web/src/features/{controlLayers/components/SimpleSession => ui/layouts}/GenerateLaunchpadPanel.tsx (87%) rename invokeai/frontend/web/src/features/{controlLayers/components/SimpleSession => ui/layouts}/InitialStateMainModelPicker.tsx (100%) rename invokeai/frontend/web/src/features/{controlLayers/components/SimpleSession => ui/layouts}/LaunchpadAddStyleReference.tsx (95%) rename invokeai/frontend/web/src/features/{controlLayers/components/SimpleSession => ui/layouts}/LaunchpadButton.tsx (100%) rename invokeai/frontend/web/src/features/{controlLayers/components/SimpleSession => ui/layouts}/LaunchpadContainer.tsx (100%) rename invokeai/frontend/web/src/features/{controlLayers/components/SimpleSession => ui/layouts}/LaunchpadEditImageButton.tsx (94%) rename invokeai/frontend/web/src/features/{controlLayers/components/SimpleSession => ui/layouts}/LaunchpadGenerateFromTextButton.tsx (92%) rename invokeai/frontend/web/src/features/{controlLayers/components/SimpleSession => ui/layouts}/LaunchpadUseALayoutImageButton.tsx (100%) rename invokeai/frontend/web/src/features/{controlLayers/components/SimpleSession => ui/layouts}/UpscalingLaunchpadPanel.tsx (100%) rename invokeai/frontend/web/src/features/{controlLayers/components/SimpleSession => ui/layouts}/WorkflowsLaunchpadPanel.tsx (100%) diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/EmptyState.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/EmptyState.tsx index 5fb3a37d15f..410633acf87 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/EmptyState.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/EmptyState.tsx @@ -1,9 +1,9 @@ import { Flex, Heading, Icon, Link, Text } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; -import { LaunchpadButton } from 'features/controlLayers/components/SimpleSession/LaunchpadButton'; import { useIsWorkflowUntouched } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher'; import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal'; import { workflowModeChanged } from 'features/nodes/store/workflowLibrarySlice'; +import { LaunchpadButton } from 'features/ui/layouts/LaunchpadButton'; import { useCallback } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { PiFolderOpenBold, PiPlusBold } from 'react-icons/pi'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/CanvasLaunchpadPanel.tsx similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx rename to invokeai/frontend/web/src/features/ui/layouts/CanvasLaunchpadPanel.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/GenerateLaunchpadPanel.tsx similarity index 87% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel.tsx rename to invokeai/frontend/web/src/features/ui/layouts/GenerateLaunchpadPanel.tsx index dd7160cb434..2db7cdef4ec 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/GenerateLaunchpadPanel.tsx @@ -1,9 +1,9 @@ import { Alert, Button, Flex, Grid, Text } from '@invoke-ai/ui-library'; -import { InitialStateMainModelPicker } from 'features/controlLayers/components/SimpleSession/InitialStateMainModelPicker'; -import { LaunchpadAddStyleReference } from 'features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference'; import { navigationApi } from 'features/ui/layouts/navigation-api'; import { memo, useCallback } from 'react'; +import { InitialStateMainModelPicker } from './InitialStateMainModelPicker'; +import { LaunchpadAddStyleReference } from './LaunchpadAddStyleReference'; import { LaunchpadContainer } from './LaunchpadContainer'; import { LaunchpadGenerateFromTextButton } from './LaunchpadGenerateFromTextButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateMainModelPicker.tsx b/invokeai/frontend/web/src/features/ui/layouts/InitialStateMainModelPicker.tsx similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateMainModelPicker.tsx rename to invokeai/frontend/web/src/features/ui/layouts/InitialStateMainModelPicker.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference.tsx b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadAddStyleReference.tsx similarity index 95% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference.tsx rename to invokeai/frontend/web/src/features/ui/layouts/LaunchpadAddStyleReference.tsx index 4f68f9eaf6b..e95bdee2508 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadAddStyleReference.tsx @@ -1,12 +1,12 @@ import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/storeHooks'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; -import { LaunchpadButton } from 'features/controlLayers/components/SimpleSession/LaunchpadButton'; import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks'; import { refImageAdded } from 'features/controlLayers/store/refImagesSlice'; import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; import { addGlobalReferenceImageDndTarget, newCanvasFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { LaunchpadButton } from 'features/ui/layouts/LaunchpadButton'; import { memo, useMemo } from 'react'; import { PiUploadBold, PiUserCircleGearBold } from 'react-icons/pi'; import type { ImageDTO } from 'services/api/types'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadButton.tsx b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadButton.tsx similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadButton.tsx rename to invokeai/frontend/web/src/features/ui/layouts/LaunchpadButton.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadContainer.tsx b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadContainer.tsx similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadContainer.tsx rename to invokeai/frontend/web/src/features/ui/layouts/LaunchpadContainer.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadEditImageButton.tsx b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadEditImageButton.tsx similarity index 94% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadEditImageButton.tsx rename to invokeai/frontend/web/src/features/ui/layouts/LaunchpadEditImageButton.tsx index 7899b5f6462..c9919e652f8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadEditImageButton.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadEditImageButton.tsx @@ -1,10 +1,10 @@ import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/storeHooks'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; -import { LaunchpadButton } from 'features/controlLayers/components/SimpleSession/LaunchpadButton'; import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { newCanvasFromImage } from 'features/imageActions/actions'; +import { LaunchpadButton } from 'features/ui/layouts/LaunchpadButton'; import { memo, useCallback } from 'react'; import { PiPencilBold, PiUploadBold } from 'react-icons/pi'; import type { ImageDTO } from 'services/api/types'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadGenerateFromTextButton.tsx b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadGenerateFromTextButton.tsx similarity index 92% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadGenerateFromTextButton.tsx rename to invokeai/frontend/web/src/features/ui/layouts/LaunchpadGenerateFromTextButton.tsx index e0bb929a874..a5d4c0c2692 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadGenerateFromTextButton.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadGenerateFromTextButton.tsx @@ -1,5 +1,5 @@ import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; -import { LaunchpadButton } from 'features/controlLayers/components/SimpleSession/LaunchpadButton'; +import { LaunchpadButton } from 'features/ui/layouts/LaunchpadButton'; import { memo, useCallback } from 'react'; import { PiCursorTextBold, PiTextAaBold } from 'react-icons/pi'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadUseALayoutImageButton.tsx b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadUseALayoutImageButton.tsx similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadUseALayoutImageButton.tsx rename to invokeai/frontend/web/src/features/ui/layouts/LaunchpadUseALayoutImageButton.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/UpscalingLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/UpscalingLaunchpadPanel.tsx similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/UpscalingLaunchpadPanel.tsx rename to invokeai/frontend/web/src/features/ui/layouts/UpscalingLaunchpadPanel.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/WorkflowsLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/WorkflowsLaunchpadPanel.tsx similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/WorkflowsLaunchpadPanel.tsx rename to invokeai/frontend/web/src/features/ui/layouts/WorkflowsLaunchpadPanel.tsx diff --git a/invokeai/frontend/web/src/features/ui/layouts/canvas-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/canvas-tab-auto-layout.tsx index ff76901061c..cc4b8c4b7bc 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/canvas-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/canvas-tab-auto-layout.tsx @@ -1,7 +1,6 @@ import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview'; import { CanvasLayersPanel } from 'features/controlLayers/components/CanvasLayersPanelContent'; -import { CanvasLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel'; import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent'; import { GalleryPanel } from 'features/gallery/components/Gallery'; import { GenerationProgressPanel } from 'features/gallery/components/ImageViewer/GenerationProgressPanel'; @@ -15,6 +14,7 @@ import type { RootLayoutGridviewComponents, } from 'features/ui/layouts/auto-layout-context'; import { AutoLayoutProvider, useAutoLayoutContext, withPanelContainer } from 'features/ui/layouts/auto-layout-context'; +import { CanvasLaunchpadPanel } from 'features/ui/layouts/CanvasLaunchpadPanel'; import type { TabName } from 'features/ui/store/uiTypes'; import { dockviewTheme } from 'features/ui/styles/theme'; import { memo, useCallback, useEffect } from 'react'; diff --git a/invokeai/frontend/web/src/features/ui/layouts/generate-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/generate-tab-auto-layout.tsx index 3c63ea64948..5e433038a6c 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/generate-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/generate-tab-auto-layout.tsx @@ -1,6 +1,5 @@ import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview'; -import { GenerateLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel'; import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent'; import { GalleryPanel } from 'features/gallery/components/Gallery'; import { GenerationProgressPanel } from 'features/gallery/components/ImageViewer/GenerationProgressPanel'; @@ -21,6 +20,7 @@ import { memo, useCallback, useEffect } from 'react'; import { DockviewTab } from './DockviewTab'; import { DockviewTabLaunchpad } from './DockviewTabLaunchpad'; import { DockviewTabProgress } from './DockviewTabProgress'; +import { GenerateLaunchpadPanel } from './GenerateLaunchpadPanel'; import { GenerateTabLeftPanel } from './GenerateTabLeftPanel'; import { navigationApi } from './navigation-api'; import { PanelHotkeysLogical } from './PanelHotkeysLogical'; diff --git a/invokeai/frontend/web/src/features/ui/layouts/upscaling-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/upscaling-tab-auto-layout.tsx index 5dffd5172dc..005abdea30a 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/upscaling-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/upscaling-tab-auto-layout.tsx @@ -1,6 +1,5 @@ import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview'; -import { UpscalingLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/UpscalingLaunchpadPanel'; import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent'; import { GalleryPanel } from 'features/gallery/components/Gallery'; import { GenerationProgressPanel } from 'features/gallery/components/ImageViewer/GenerationProgressPanel'; @@ -43,6 +42,7 @@ import { SETTINGS_PANEL_ID, VIEWER_PANEL_ID, } from './shared'; +import { UpscalingLaunchpadPanel } from './UpscalingLaunchpadPanel'; import { UpscalingTabLeftPanel } from './UpscalingTabLeftPanel'; const tabComponents = { diff --git a/invokeai/frontend/web/src/features/ui/layouts/workflows-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/workflows-tab-auto-layout.tsx index 7785ae836ff..808b01fee2e 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/workflows-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/workflows-tab-auto-layout.tsx @@ -1,6 +1,5 @@ import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview'; -import { WorkflowsLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/WorkflowsLaunchpadPanel'; import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent'; import { GalleryPanel } from 'features/gallery/components/Gallery'; import { GenerationProgressPanel } from 'features/gallery/components/ImageViewer/GenerationProgressPanel'; @@ -46,6 +45,7 @@ import { VIEWER_PANEL_ID, WORKSPACE_PANEL_ID, } from './shared'; +import { WorkflowsLaunchpadPanel } from './WorkflowsLaunchpadPanel'; const tabComponents = { [DOCKVIEW_TAB_ID]: DockviewTab, From a5218d1a7415d20370784c86857d19c1bee8fb40 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 18 Jul 2025 16:09:02 +1000 Subject: [PATCH 10/16] tidy(ui): move staging area components to correct dir --- .../QueueItemCircularProgress.tsx | 2 +- .../{SimpleSession => StagingArea}/QueueItemNumber.tsx | 3 ++- .../QueueItemPreviewMini.tsx | 10 +++++----- .../QueueItemProgressImage.tsx | 0 .../QueueItemStatusLabel.tsx | 0 .../StagingAreaItemsList.tsx | 2 +- .../components/StagingArea/StagingAreaToolbar.tsx | 2 +- .../StagingArea/StagingAreaToolbarAcceptButton.tsx | 2 +- .../StagingArea/StagingAreaToolbarDiscardAllButton.tsx | 2 +- .../StagingAreaToolbarDiscardSelectedButton.tsx | 2 +- .../StagingArea/StagingAreaToolbarImageCountButton.tsx | 2 +- .../StagingAreaToolbarMenuNewLayerFromImage.tsx | 2 +- .../StagingArea/StagingAreaToolbarNextButton.tsx | 2 +- .../StagingArea/StagingAreaToolbarPrevButton.tsx | 2 +- .../StagingAreaToolbarSaveSelectedToGalleryButton.tsx | 2 +- .../{SimpleSession => StagingArea}/context.tsx | 0 .../{SimpleSession => StagingArea}/shared.ts | 0 .../components/{SimpleSession => StagingArea}/state.ts | 0 .../controlLayers/konva/CanvasStagingAreaModule.ts | 2 +- .../src/features/ui/layouts/CanvasWorkspacePanel.tsx | 2 +- .../web/src/features/ui/layouts/StagingArea.tsx | 2 +- 21 files changed, 21 insertions(+), 20 deletions(-) rename invokeai/frontend/web/src/features/controlLayers/components/{SimpleSession => StagingArea}/QueueItemCircularProgress.tsx (97%) rename invokeai/frontend/web/src/features/controlLayers/components/{SimpleSession => StagingArea}/QueueItemNumber.tsx (81%) rename invokeai/frontend/web/src/features/controlLayers/components/{SimpleSession => StagingArea}/QueueItemPreviewMini.tsx (90%) rename invokeai/frontend/web/src/features/controlLayers/components/{SimpleSession => StagingArea}/QueueItemProgressImage.tsx (100%) rename invokeai/frontend/web/src/features/controlLayers/components/{SimpleSession => StagingArea}/QueueItemStatusLabel.tsx (100%) rename invokeai/frontend/web/src/features/controlLayers/components/{SimpleSession => StagingArea}/StagingAreaItemsList.tsx (99%) rename invokeai/frontend/web/src/features/controlLayers/components/{SimpleSession => StagingArea}/context.tsx (100%) rename invokeai/frontend/web/src/features/controlLayers/components/{SimpleSession => StagingArea}/shared.ts (100%) rename invokeai/frontend/web/src/features/controlLayers/components/{SimpleSession => StagingArea}/state.ts (100%) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/QueueItemCircularProgress.tsx similarity index 97% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/StagingArea/QueueItemCircularProgress.tsx index b47c112bf51..0a92106a6e6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/QueueItemCircularProgress.tsx @@ -1,6 +1,6 @@ import type { CircularProgressProps, SystemStyleObject } from '@invoke-ai/ui-library'; import { CircularProgress, Tooltip } from '@invoke-ai/ui-library'; -import { getProgressMessage } from 'features/controlLayers/components/SimpleSession/shared'; +import { getProgressMessage } from 'features/controlLayers/components/StagingArea/shared'; import { memo } from 'react'; import type { S } from 'services/api/types'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemNumber.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/QueueItemNumber.tsx similarity index 81% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemNumber.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/StagingArea/QueueItemNumber.tsx index 33686a5c836..60734b2fd7e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemNumber.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/QueueItemNumber.tsx @@ -1,8 +1,9 @@ import type { TextProps } from '@invoke-ai/ui-library'; import { Text } from '@invoke-ai/ui-library'; -import { DROP_SHADOW } from 'features/controlLayers/components/SimpleSession/shared'; import { memo } from 'react'; +import { DROP_SHADOW } from './shared'; + export const QueueItemNumber = memo(({ number, ...rest }: { number: number } & TextProps) => { return {`#${number}`}; }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/QueueItemPreviewMini.tsx similarity index 90% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/StagingArea/QueueItemPreviewMini.tsx index b3b246392bd..b84dff0ed30 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/QueueItemPreviewMini.tsx @@ -2,11 +2,10 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { QueueItemCircularProgress } from 'features/controlLayers/components/SimpleSession/QueueItemCircularProgress'; -import { QueueItemNumber } from 'features/controlLayers/components/SimpleSession/QueueItemNumber'; -import { QueueItemProgressImage } from 'features/controlLayers/components/SimpleSession/QueueItemProgressImage'; -import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel'; -import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared'; +import { QueueItemCircularProgress } from 'features/controlLayers/components/StagingArea/QueueItemCircularProgress'; +import { QueueItemProgressImage } from 'features/controlLayers/components/StagingArea/QueueItemProgressImage'; +import { QueueItemStatusLabel } from 'features/controlLayers/components/StagingArea/QueueItemStatusLabel'; +import { getQueueItemElementId } from 'features/controlLayers/components/StagingArea/shared'; import { selectStagingAreaAutoSwitch, settingsStagingAreaAutoSwitchChanged, @@ -17,6 +16,7 @@ import { memo, useCallback, useMemo } from 'react'; import type { S } from 'services/api/types'; import { useOutputImageDTO, useStagingAreaContext } from './context'; +import { QueueItemNumber } from './QueueItemNumber'; const sx = { cursor: 'pointer', diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/QueueItemProgressImage.tsx similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/StagingArea/QueueItemProgressImage.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/QueueItemStatusLabel.tsx similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/StagingArea/QueueItemStatusLabel.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaItemsList.tsx similarity index 99% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaItemsList.tsx index d237665eab6..962ad027ccf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaItemsList.tsx @@ -1,7 +1,7 @@ import { Box, Flex, forwardRef } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { logger } from 'app/logging/logger'; -import { QueueItemPreviewMini } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewMini'; +import { QueueItemPreviewMini } from 'features/controlLayers/components/StagingArea/QueueItemPreviewMini'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; import type { CSSProperties, RefObject } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx index f7494924593..ff73a37fc94 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx @@ -1,5 +1,5 @@ import { ButtonGroup, Flex } from '@invoke-ai/ui-library'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context'; import { StagingAreaToolbarAcceptButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton'; import { StagingAreaToolbarDiscardAllButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton'; import { StagingAreaToolbarDiscardSelectedButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx index ff70a61101e..c18ca5cac19 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx @@ -1,7 +1,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useIsRegionFocused } from 'common/hooks/focus'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useCancelQueueItemsByDestination } from 'features/queue/hooks/useCancelQueueItemsByDestination'; import { memo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx index 22da6ec204a..467c80d6ff7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx @@ -1,6 +1,6 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useCancelQueueItemsByDestination } from 'features/queue/hooks/useCancelQueueItemsByDestination'; import { memo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx index ccd3d795f58..8f912ca52e2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx @@ -1,6 +1,6 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem'; import { memo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton.tsx index 8350a658c10..a47a84cd8fe 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton.tsx @@ -1,6 +1,6 @@ import { Button } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { memo, useMemo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx index 2986a2ff33b..6b1b4a11150 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx @@ -2,7 +2,7 @@ import { MenuGroup, MenuItem } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppStore } from 'app/store/storeHooks'; import { NewLayerIcon } from 'features/controlLayers/components/common/icons'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { createNewCanvasEntityFromImage } from 'features/imageActions/actions'; import { toast } from 'features/toast/toast'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx index ad2ad5c0bc8..95e4182e657 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx @@ -1,7 +1,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useIsRegionFocused } from 'common/hooks/focus'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { memo, useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx index a2f0029b49e..d4288fdfecd 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx @@ -1,7 +1,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useIsRegionFocused } from 'common/hooks/focus'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { memo, useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx index cfdec688d44..e2ef7e0ccba 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx @@ -2,7 +2,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppSelector } from 'app/store/storeHooks'; import { withResultAsync } from 'common/util/result'; -import { useStagingAreaContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useStagingAreaContext } from 'features/controlLayers/components/StagingArea/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; import { toast } from 'features/toast/toast'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.ts similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts rename to invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/state.ts b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/state.ts rename to invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts index 26e4fb8e37c..b3e5c305f76 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts @@ -1,5 +1,5 @@ import { Mutex } from 'async-mutex'; -import type { SelectedItemData } from 'features/controlLayers/components/SimpleSession/state'; +import type { SelectedItemData } from 'features/controlLayers/components/StagingArea/state'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage'; diff --git a/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx index 0d9f55f5cdd..dc6672437f4 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx @@ -12,7 +12,7 @@ import { Filter } from 'features/controlLayers/components/Filters/Filter'; import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD'; import { InvokeCanvasComponent } from 'features/controlLayers/components/InvokeCanvasComponent'; import { SelectObject } from 'features/controlLayers/components/SelectObject/SelectObject'; -import { StagingAreaContextProvider } from 'features/controlLayers/components/SimpleSession/context'; +import { StagingAreaContextProvider } from 'features/controlLayers/components/StagingArea/context'; import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; import { Transform } from 'features/controlLayers/components/Transform/Transform'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; diff --git a/invokeai/frontend/web/src/features/ui/layouts/StagingArea.tsx b/invokeai/frontend/web/src/features/ui/layouts/StagingArea.tsx index 6b24dfef44d..f262a25daa8 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/StagingArea.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/StagingArea.tsx @@ -1,5 +1,5 @@ import { Flex } from '@invoke-ai/ui-library'; -import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList'; +import { StagingAreaItemsList } from 'features/controlLayers/components/StagingArea/StagingAreaItemsList'; import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar'; import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { memo } from 'react'; From de231d4e0f80646d3342d67cc0528787ccae8540 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 18 Jul 2025 17:38:29 +1000 Subject: [PATCH 11/16] tests(ui): add test suite for StagingAreaApi --- .../__mocks__/mockStagingAreaApp.ts | 193 +++++ .../components/StagingArea/shared.test.ts | 205 +++++ .../components/StagingArea/state.test.ts | 781 ++++++++++++++++++ 3 files changed, 1179 insertions(+) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/StagingArea/__mocks__/mockStagingAreaApp.ts create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.test.ts create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.test.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/__mocks__/mockStagingAreaApp.ts b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/__mocks__/mockStagingAreaApp.ts new file mode 100644 index 00000000000..3e502f1e6e9 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/__mocks__/mockStagingAreaApp.ts @@ -0,0 +1,193 @@ +import { merge } from 'es-toolkit'; +import type { StagingAreaAppApi } from 'features/controlLayers/components/StagingArea/state'; +import type { AutoSwitchMode } from 'features/controlLayers/store/canvasSettingsSlice'; +import type { ImageDTO, S } from 'services/api/types'; +import type { PartialDeep } from 'type-fest'; +import { vi } from 'vitest'; + +export const createMockStagingAreaApp = (): StagingAreaAppApi & { + // Additional methods for testing + _triggerItemsChanged: (items: S['SessionQueueItem'][]) => void; + _triggerQueueItemStatusChanged: (data: S['QueueItemStatusChangedEvent']) => void; + _triggerInvocationProgress: (data: S['InvocationProgressEvent']) => void; + _setAutoSwitchMode: (mode: AutoSwitchMode) => void; + _setImageDTO: (imageName: string, imageDTO: ImageDTO | null) => void; + _setLoadImageDelay: (delay: number) => void; +} => { + const itemsChangedHandlers = new Set<(items: S['SessionQueueItem'][]) => void>(); + const queueItemStatusChangedHandlers = new Set<(data: S['QueueItemStatusChangedEvent']) => void>(); + const invocationProgressHandlers = new Set<(data: S['InvocationProgressEvent']) => void>(); + + let autoSwitchMode: AutoSwitchMode = 'switch_on_start'; + const imageDTOs = new Map(); + let loadImageDelay = 0; + + return { + onDiscard: vi.fn(), + onDiscardAll: vi.fn(), + onAccept: vi.fn(), + onSelect: vi.fn(), + onSelectPrev: vi.fn(), + onSelectNext: vi.fn(), + onSelectFirst: vi.fn(), + onSelectLast: vi.fn(), + getAutoSwitch: vi.fn(() => autoSwitchMode), + onAutoSwitchChange: vi.fn(), + getImageDTO: vi.fn((imageName: string) => { + return Promise.resolve(imageDTOs.get(imageName) || null); + }), + loadImage: vi.fn(async (imageName: string) => { + if (loadImageDelay > 0) { + await new Promise((resolve) => { + setTimeout(resolve, loadImageDelay); + }); + } + // Mock HTMLImageElement for testing environment + const mockImage = { + src: imageName, + width: 512, + height: 512, + onload: null, + onerror: null, + } as HTMLImageElement; + return mockImage; + }), + onItemsChanged: vi.fn((handler) => { + itemsChangedHandlers.add(handler); + return () => itemsChangedHandlers.delete(handler); + }), + onQueueItemStatusChanged: vi.fn((handler) => { + queueItemStatusChangedHandlers.add(handler); + return () => queueItemStatusChangedHandlers.delete(handler); + }), + onInvocationProgress: vi.fn((handler) => { + invocationProgressHandlers.add(handler); + return () => invocationProgressHandlers.delete(handler); + }), + + // Testing helper methods + _triggerItemsChanged: (items: S['SessionQueueItem'][]) => { + itemsChangedHandlers.forEach((handler) => handler(items)); + }, + _triggerQueueItemStatusChanged: (data: S['QueueItemStatusChangedEvent']) => { + queueItemStatusChangedHandlers.forEach((handler) => handler(data)); + }, + _triggerInvocationProgress: (data: S['InvocationProgressEvent']) => { + invocationProgressHandlers.forEach((handler) => handler(data)); + }, + _setAutoSwitchMode: (mode: AutoSwitchMode) => { + autoSwitchMode = mode; + }, + _setImageDTO: (imageName: string, imageDTO: ImageDTO | null) => { + imageDTOs.set(imageName, imageDTO); + }, + _setLoadImageDelay: (delay: number) => { + loadImageDelay = delay; + }, + }; +}; + +export const createMockQueueItem = (overrides: PartialDeep = {}): S['SessionQueueItem'] => + merge( + { + item_id: 1, + batch_id: 'test-batch-id', + session_id: 'test-session', + queue_id: 'test-queue-id', + status: 'pending', + priority: 0, + origin: null, + destination: 'test-session', + error_type: null, + error_message: null, + error_traceback: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + started_at: null, + completed_at: null, + field_values: null, + retried_from_item_id: null, + is_api_validation_run: false, + published_workflow_id: null, + session: { + id: 'test-session', + graph: {}, + execution_graph: {}, + executed: [], + executed_history: [], + results: { + 'test-node-id': { + image: { + image_name: 'test-image.png', + }, + }, + }, + errors: {}, + prepared_source_mapping: {}, + source_prepared_mapping: { + canvas_output: ['test-node-id'], + }, + }, + workflow: null, + }, + overrides + ) as S['SessionQueueItem']; + +export const createMockImageDTO = (overrides: Partial = {}): ImageDTO => ({ + image_name: 'test-image.png', + image_url: 'http://test.com/test-image.png', + thumbnail_url: 'http://test.com/test-image-thumb.png', + image_origin: 'internal', + image_category: 'general', + width: 512, + height: 512, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + deleted_at: null, + is_intermediate: false, + starred: false, + has_workflow: false, + session_id: 'test-session', + node_id: 'test-node-id', + board_id: null, + ...overrides, +}); + +export const createMockProgressEvent = ( + overrides: PartialDeep = {} +): S['InvocationProgressEvent'] => + merge( + { + timestamp: Date.now(), + queue_id: 'test-queue-id', + item_id: 1, + batch_id: 'test-batch-id', + session_id: 'test-session', + origin: null, + destination: 'test-session', + invocation: {}, + invocation_source_id: 'test-invocation-source-id', + message: 'Processing...', + percentage: 50, + image: null, + } as S['InvocationProgressEvent'], + overrides + ); + +export const createMockQueueItemStatusChangedEvent = ( + overrides: PartialDeep = {} +): S['QueueItemStatusChangedEvent'] => + merge( + { + timestamp: Date.now(), + queue_id: 'test-queue-id', + item_id: 1, + batch_id: 'test-batch-id', + origin: null, + destination: 'test-session', + status: 'completed', + error_type: null, + error_message: null, + } as S['QueueItemStatusChangedEvent'], + overrides + ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.test.ts b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.test.ts new file mode 100644 index 00000000000..f16b9023164 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/shared.test.ts @@ -0,0 +1,205 @@ +import type { S } from 'services/api/types'; +import { describe, expect, it } from 'vitest'; + +import { getOutputImageName, getProgressMessage, getQueueItemElementId } from './shared'; + +describe('StagingAreaApi Utility Functions', () => { + describe('getProgressMessage', () => { + it('should return default message when no data provided', () => { + expect(getProgressMessage()).toBe('Generating'); + expect(getProgressMessage(null)).toBe('Generating'); + }); + + it('should format progress message when data is provided', () => { + const progressEvent: S['InvocationProgressEvent'] = { + item_id: 1, + destination: 'test-session', + node_id: 'test-node', + source_node_id: 'test-source-node', + progress: 0.5, + message: 'Processing image...', + image: null, + } as unknown as S['InvocationProgressEvent']; + + const result = getProgressMessage(progressEvent); + expect(result).toBe('Processing image...'); + }); + }); + + describe('getQueueItemElementId', () => { + it('should generate correct element ID for queue item', () => { + expect(getQueueItemElementId(0)).toBe('queue-item-preview-0'); + expect(getQueueItemElementId(5)).toBe('queue-item-preview-5'); + expect(getQueueItemElementId(99)).toBe('queue-item-preview-99'); + }); + }); + + describe('getOutputImageName', () => { + it('should extract image name from completed queue item', () => { + const queueItem: S['SessionQueueItem'] = { + item_id: 1, + status: 'completed', + priority: 0, + destination: 'test-session', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + started_at: '2024-01-01T00:00:01Z', + completed_at: '2024-01-01T00:01:00Z', + error: null, + session: { + id: 'test-session', + source_prepared_mapping: { + canvas_output: ['output-node-id'], + }, + results: { + 'output-node-id': { + image: { + image_name: 'test-output.png', + }, + }, + }, + }, + } as unknown as S['SessionQueueItem']; + + expect(getOutputImageName(queueItem)).toBe('test-output.png'); + }); + + it('should return null when no canvas output node found', () => { + const queueItem = { + item_id: 1, + status: 'completed', + priority: 0, + destination: 'test-session', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + started_at: '2024-01-01T00:00:01Z', + completed_at: '2024-01-01T00:01:00Z', + error: null, + session: { + id: 'test-session', + source_prepared_mapping: { + some_other_node: ['other-node-id'], + }, + results: { + 'other-node-id': { + type: 'image_output', + image: { + image_name: 'test-output.png', + }, + width: 512, + height: 512, + }, + }, + }, + } as unknown as S['SessionQueueItem']; + + expect(getOutputImageName(queueItem)).toBe(null); + }); + + it('should return null when output node has no results', () => { + const queueItem: S['SessionQueueItem'] = { + item_id: 1, + status: 'completed', + priority: 0, + destination: 'test-session', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + started_at: '2024-01-01T00:00:01Z', + completed_at: '2024-01-01T00:01:00Z', + error: null, + session: { + id: 'test-session', + source_prepared_mapping: { + canvas_output: ['output-node-id'], + }, + results: {}, + }, + } as unknown as S['SessionQueueItem']; + + expect(getOutputImageName(queueItem)).toBe(null); + }); + + it('should return null when results contain no image fields', () => { + const queueItem: S['SessionQueueItem'] = { + item_id: 1, + status: 'completed', + priority: 0, + destination: 'test-session', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + started_at: '2024-01-01T00:00:01Z', + completed_at: '2024-01-01T00:01:00Z', + error: null, + session: { + id: 'test-session', + source_prepared_mapping: { + canvas_output: ['output-node-id'], + }, + results: { + 'output-node-id': { + text: 'some text output', + number: 42, + }, + }, + }, + } as unknown as S['SessionQueueItem']; + + expect(getOutputImageName(queueItem)).toBe(null); + }); + + it('should handle multiple outputs and return first image', () => { + const queueItem: S['SessionQueueItem'] = { + item_id: 1, + status: 'completed', + priority: 0, + destination: 'test-session', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + started_at: '2024-01-01T00:00:01Z', + completed_at: '2024-01-01T00:01:00Z', + error: null, + session: { + id: 'test-session', + source_prepared_mapping: { + canvas_output: ['output-node-id'], + }, + results: { + 'output-node-id': { + text: 'some text', + first_image: { + image_name: 'first-image.png', + }, + second_image: { + image_name: 'second-image.png', + }, + }, + }, + }, + } as unknown as S['SessionQueueItem']; + + const result = getOutputImageName(queueItem); + expect(result).toBe('first-image.png'); + }); + + it('should handle empty session mapping', () => { + const queueItem: S['SessionQueueItem'] = { + item_id: 1, + status: 'completed', + priority: 0, + destination: 'test-session', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + started_at: '2024-01-01T00:00:01Z', + completed_at: '2024-01-01T00:01:00Z', + error: null, + session: { + id: 'test-session', + source_prepared_mapping: {}, + results: {}, + }, + } as unknown as S['SessionQueueItem']; + + expect(getOutputImageName(queueItem)).toBe(null); + }); + }); +}); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.test.ts b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.test.ts new file mode 100644 index 00000000000..74e53a53acf --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.test.ts @@ -0,0 +1,781 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createMockImageDTO, + createMockProgressEvent, + createMockQueueItem, + createMockQueueItemStatusChangedEvent, + createMockStagingAreaApp, +} from './__mocks__/mockStagingAreaApp'; +import { StagingAreaApi } from './state'; + +describe('StagingAreaApi', () => { + let api: StagingAreaApi; + let mockApp: ReturnType; + const sessionId = 'test-session'; + + beforeEach(() => { + mockApp = createMockStagingAreaApp(); + api = new StagingAreaApi(sessionId, mockApp); + }); + + afterEach(() => { + api.cleanup(); + }); + + describe('Constructor and Setup', () => { + it('should initialize with correct session ID', () => { + expect(api.sessionId).toBe(sessionId); + }); + + it('should set up event subscriptions', () => { + expect(mockApp.onItemsChanged).toHaveBeenCalledWith(expect.any(Function)); + expect(mockApp.onQueueItemStatusChanged).toHaveBeenCalledWith(expect.any(Function)); + expect(mockApp.onInvocationProgress).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should initialize atoms with default values', () => { + expect(api.$lastStartedItemId.get()).toBe(null); + expect(api.$lastCompletedItemId.get()).toBe(null); + expect(api.$items.get()).toEqual([]); + expect(api.$progressData.get()).toEqual({}); + expect(api.$selectedItemId.get()).toBe(null); + }); + }); + + describe('Computed Values', () => { + it('should compute item count correctly', () => { + expect(api.$itemCount.get()).toBe(0); + + const items = [createMockQueueItem({ item_id: 1 })]; + api.$items.set(items); + expect(api.$itemCount.get()).toBe(1); + }); + + it('should compute hasItems correctly', () => { + expect(api.$hasItems.get()).toBe(false); + + const items = [createMockQueueItem({ item_id: 1 })]; + api.$items.set(items); + expect(api.$hasItems.get()).toBe(true); + }); + + it('should compute isPending correctly', () => { + expect(api.$isPending.get()).toBe(false); + + const items = [ + createMockQueueItem({ item_id: 1, status: 'pending' }), + createMockQueueItem({ item_id: 2, status: 'completed' }), + ]; + api.$items.set(items); + expect(api.$isPending.get()).toBe(true); + }); + + it('should compute selectedItem correctly', () => { + expect(api.$selectedItem.get()).toBe(null); + + const items = [createMockQueueItem({ item_id: 1 }), createMockQueueItem({ item_id: 2 })]; + api.$items.set(items); + api.$selectedItemId.set(1); + + const selectedItem = api.$selectedItem.get(); + expect(selectedItem).not.toBe(null); + expect(selectedItem?.item.item_id).toBe(1); + expect(selectedItem?.index).toBe(0); + }); + + it('should compute selectedItemImageDTO correctly', () => { + const items = [createMockQueueItem({ item_id: 1 })]; + const imageDTO = createMockImageDTO(); + + api.$items.set(items); + api.$selectedItemId.set(1); + api.$progressData.setKey(1, { + itemId: 1, + progressEvent: null, + progressImage: null, + imageDTO, + }); + + expect(api.$selectedItemImageDTO.get()).toBe(imageDTO); + }); + + it('should compute selectedItemIndex correctly', () => { + const items = [createMockQueueItem({ item_id: 1 }), createMockQueueItem({ item_id: 2 })]; + api.$items.set(items); + api.$selectedItemId.set(2); + + expect(api.$selectedItemIndex.get()).toBe(1); + }); + }); + + describe('Selection Methods', () => { + beforeEach(() => { + const items = [ + createMockQueueItem({ item_id: 1 }), + createMockQueueItem({ item_id: 2 }), + createMockQueueItem({ item_id: 3 }), + ]; + api.$items.set(items); + }); + + it('should select item by ID', () => { + api.select(2); + expect(api.$selectedItemId.get()).toBe(2); + expect(mockApp.onSelect).toHaveBeenCalledWith(2); + }); + + it('should select next item', () => { + api.$selectedItemId.set(1); + api.selectNext(); + expect(api.$selectedItemId.get()).toBe(2); + expect(mockApp.onSelectNext).toHaveBeenCalled(); + }); + + it('should wrap to first item when selecting next from last', () => { + api.$selectedItemId.set(3); + api.selectNext(); + expect(api.$selectedItemId.get()).toBe(1); + }); + + it('should select previous item', () => { + api.$selectedItemId.set(2); + api.selectPrev(); + expect(api.$selectedItemId.get()).toBe(1); + expect(mockApp.onSelectPrev).toHaveBeenCalled(); + }); + + it('should wrap to last item when selecting previous from first', () => { + api.$selectedItemId.set(1); + api.selectPrev(); + expect(api.$selectedItemId.get()).toBe(3); + }); + + it('should select first item', () => { + api.selectFirst(); + expect(api.$selectedItemId.get()).toBe(1); + expect(mockApp.onSelectFirst).toHaveBeenCalled(); + }); + + it('should select last item', () => { + api.selectLast(); + expect(api.$selectedItemId.get()).toBe(3); + expect(mockApp.onSelectLast).toHaveBeenCalled(); + }); + + it('should do nothing when no items exist', () => { + api.$items.set([]); + api.selectNext(); + api.selectPrev(); + api.selectFirst(); + api.selectLast(); + + expect(api.$selectedItemId.get()).toBe(null); + }); + + it('should do nothing when no item is selected', () => { + api.$selectedItemId.set(null); + api.selectNext(); + api.selectPrev(); + + expect(api.$selectedItemId.get()).toBe(null); + }); + }); + + describe('Discard Methods', () => { + beforeEach(() => { + const items = [ + createMockQueueItem({ item_id: 1 }), + createMockQueueItem({ item_id: 2 }), + createMockQueueItem({ item_id: 3 }), + ]; + api.$items.set(items); + }); + + it('should discard selected item and select next', () => { + api.$selectedItemId.set(2); + const selectedItem = api.$selectedItem.get(); + + api.discardSelected(); + + expect(api.$selectedItemId.get()).toBe(3); + expect(mockApp.onDiscard).toHaveBeenCalledWith(selectedItem?.item); + }); + + it('should discard selected item and clamp to last valid index', () => { + api.$selectedItemId.set(3); + const selectedItem = api.$selectedItem.get(); + + api.discardSelected(); + + // The logic clamps to the next index, so when discarding last item (index 2), + // it tries to select index 3 which clamps to index 2 (item 3) + expect(api.$selectedItemId.get()).toBe(3); + expect(mockApp.onDiscard).toHaveBeenCalledWith(selectedItem?.item); + }); + + it('should set selectedItemId to null when discarding last item', () => { + api.$items.set([createMockQueueItem({ item_id: 1 })]); + api.$selectedItemId.set(1); + + api.discardSelected(); + + // When there's only one item, after clamping we get the same item, so it stays selected + expect(api.$selectedItemId.get()).toBe(1); + }); + + it('should do nothing when no item is selected', () => { + api.$selectedItemId.set(null); + api.discardSelected(); + + expect(mockApp.onDiscard).not.toHaveBeenCalled(); + }); + + it('should discard all items', () => { + api.$selectedItemId.set(2); + api.discardAll(); + + expect(api.$selectedItemId.get()).toBe(null); + expect(mockApp.onDiscardAll).toHaveBeenCalled(); + }); + + it('should compute discardSelectedIsEnabled correctly', () => { + expect(api.$discardSelectedIsEnabled.get()).toBe(false); + + api.$selectedItemId.set(1); + expect(api.$discardSelectedIsEnabled.get()).toBe(true); + }); + }); + + describe('Accept Methods', () => { + beforeEach(() => { + const items = [createMockQueueItem({ item_id: 1 })]; + api.$items.set(items); + api.$selectedItemId.set(1); + }); + + it('should accept selected item when image is available', () => { + const imageDTO = createMockImageDTO(); + api.$progressData.setKey(1, { + itemId: 1, + progressEvent: null, + progressImage: null, + imageDTO, + }); + + const selectedItem = api.$selectedItem.get(); + api.acceptSelected(); + + expect(mockApp.onAccept).toHaveBeenCalledWith(selectedItem?.item, imageDTO); + }); + + it('should do nothing when no image is available', () => { + api.$progressData.setKey(1, { + itemId: 1, + progressEvent: null, + progressImage: null, + imageDTO: null, + }); + + api.acceptSelected(); + + expect(mockApp.onAccept).not.toHaveBeenCalled(); + }); + + it('should do nothing when no item is selected', () => { + api.$selectedItemId.set(null); + api.acceptSelected(); + + expect(mockApp.onAccept).not.toHaveBeenCalled(); + }); + + it('should compute acceptSelectedIsEnabled correctly', () => { + expect(api.$acceptSelectedIsEnabled.get()).toBe(false); + + const imageDTO = createMockImageDTO(); + api.$progressData.setKey(1, { + itemId: 1, + progressEvent: null, + progressImage: null, + imageDTO, + }); + + expect(api.$acceptSelectedIsEnabled.get()).toBe(true); + }); + }); + + describe('Progress Event Handling', () => { + it('should handle invocation progress events', () => { + const progressEvent = createMockProgressEvent({ + item_id: 1, + destination: sessionId, + }); + + api.onInvocationProgressEvent(progressEvent); + + const progressData = api.$progressData.get(); + expect(progressData[1]?.progressEvent).toBe(progressEvent); + }); + + it('should ignore events for different sessions', () => { + const progressEvent = createMockProgressEvent({ + item_id: 1, + destination: 'different-session', + }); + + api.onInvocationProgressEvent(progressEvent); + + const progressData = api.$progressData.get(); + expect(progressData[1]).toBeUndefined(); + }); + + it('should update existing progress data', () => { + api.$progressData.setKey(1, { + itemId: 1, + progressEvent: null, + progressImage: null, + imageDTO: createMockImageDTO(), + }); + + const progressEvent = createMockProgressEvent({ + item_id: 1, + destination: sessionId, + }); + + api.onInvocationProgressEvent(progressEvent); + + const progressData = api.$progressData.get(); + expect(progressData[1]?.progressEvent).toBe(progressEvent); + expect(progressData[1]?.imageDTO).toBeTruthy(); + }); + }); + + describe('Queue Item Status Change Handling', () => { + it('should handle completed status and set last completed item', () => { + const statusEvent = createMockQueueItemStatusChangedEvent({ + item_id: 1, + destination: sessionId, + status: 'completed', + }); + + api.onQueueItemStatusChangedEvent(statusEvent); + + expect(api.$lastCompletedItemId.get()).toBe(1); + }); + + it('should handle in_progress status with switch_on_start', () => { + mockApp._setAutoSwitchMode('switch_on_start'); + + const statusEvent = createMockQueueItemStatusChangedEvent({ + item_id: 1, + destination: sessionId, + status: 'in_progress', + }); + + api.onQueueItemStatusChangedEvent(statusEvent); + + expect(api.$lastStartedItemId.get()).toBe(1); + }); + + it('should ignore events for different sessions', () => { + const statusEvent = createMockQueueItemStatusChangedEvent({ + item_id: 1, + destination: 'different-session', + status: 'completed', + }); + + api.onQueueItemStatusChangedEvent(statusEvent); + + expect(api.$lastCompletedItemId.get()).toBe(null); + }); + }); + + describe('Items Changed Event Handling', () => { + it('should update items and auto-select first item', async () => { + const items = [createMockQueueItem({ item_id: 1 }), createMockQueueItem({ item_id: 2 })]; + + await api.onItemsChangedEvent(items); + + expect(api.$items.get()).toBe(items); + expect(api.$selectedItemId.get()).toBe(1); + }); + + it('should clear selection when no items', async () => { + api.$selectedItemId.set(1); + + await api.onItemsChangedEvent([]); + + expect(api.$selectedItemId.get()).toBe(null); + }); + + it('should not change selection if item already selected', async () => { + api.$selectedItemId.set(2); + + const items = [createMockQueueItem({ item_id: 1 }), createMockQueueItem({ item_id: 2 })]; + + await api.onItemsChangedEvent(items); + + expect(api.$selectedItemId.get()).toBe(2); + }); + + it('should load images for completed items', async () => { + const imageDTO = createMockImageDTO({ image_name: 'test-image.png' }); + mockApp._setImageDTO('test-image.png', imageDTO); + + const items = [ + createMockQueueItem({ + item_id: 1, + status: 'completed', + }), + ]; + + await api.onItemsChangedEvent(items); + + const progressData = api.$progressData.get(); + expect(progressData[1]?.imageDTO).toBe(imageDTO); + }); + + it('should handle auto-switch on completion', async () => { + mockApp._setAutoSwitchMode('switch_on_finish'); + api.$lastCompletedItemId.set(1); + + const imageDTO = createMockImageDTO({ image_name: 'test-image.png' }); + mockApp._setImageDTO('test-image.png', imageDTO); + + const items = [ + createMockQueueItem({ + item_id: 1, + status: 'completed', + }), + ]; + + await api.onItemsChangedEvent(items); + + // Wait for async image loading - the loadImage promise needs to complete + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + expect(api.$selectedItemId.get()).toBe(1); + // The lastCompletedItemId should be reset after the loadImage promise resolves + expect(api.$lastCompletedItemId.get()).toBe(null); + }); + + it('should clean up progress data for removed items', async () => { + api.$progressData.setKey(999, { + itemId: 999, + progressEvent: null, + progressImage: null, + imageDTO: null, + }); + + const items = [createMockQueueItem({ item_id: 1 })]; + + await api.onItemsChangedEvent(items); + + const progressData = api.$progressData.get(); + expect(progressData[999]).toBeUndefined(); + }); + + it('should clear progress data for canceled/failed items', async () => { + api.$progressData.setKey(1, { + itemId: 1, + progressEvent: createMockProgressEvent({ item_id: 1 }), + progressImage: null, + imageDTO: createMockImageDTO(), + }); + + const items = [createMockQueueItem({ item_id: 1, status: 'canceled' })]; + + await api.onItemsChangedEvent(items); + + const progressData = api.$progressData.get(); + expect(progressData[1]?.progressEvent).toBe(null); + expect(progressData[1]?.progressImage).toBe(null); + expect(progressData[1]?.imageDTO).toBe(null); + }); + }); + + describe('Auto Switch', () => { + it('should set auto switch mode', () => { + api.setAutoSwitch('switch_on_finish'); + expect(mockApp.onAutoSwitchChange).toHaveBeenCalledWith('switch_on_finish'); + }); + }); + + describe('Utility Methods', () => { + it('should build isSelected computed correctly', () => { + const isSelected = api.buildIsSelectedComputed(1); + expect(isSelected.get()).toBe(false); + + api.$selectedItemId.set(1); + expect(isSelected.get()).toBe(true); + }); + }); + + describe('Cleanup', () => { + it('should reset all state on cleanup', () => { + api.$selectedItemId.set(1); + api.$items.set([createMockQueueItem({ item_id: 1 })]); + api.$lastStartedItemId.set(1); + api.$lastCompletedItemId.set(1); + api.$progressData.setKey(1, { + itemId: 1, + progressEvent: null, + progressImage: null, + imageDTO: null, + }); + + api.cleanup(); + + expect(api.$selectedItemId.get()).toBe(null); + expect(api.$items.get()).toEqual([]); + expect(api.$lastStartedItemId.get()).toBe(null); + expect(api.$lastCompletedItemId.get()).toBe(null); + expect(api.$progressData.get()).toEqual({}); + }); + }); + + describe('Edge Cases and Error Handling', () => { + describe('Selection with Empty or Single Item Lists', () => { + it('should handle selection operations with single item', () => { + const items = [createMockQueueItem({ item_id: 1 })]; + api.$items.set(items); + api.$selectedItemId.set(1); + + // Navigation should wrap around to the same item + api.selectNext(); + expect(api.$selectedItemId.get()).toBe(1); + + api.selectPrev(); + expect(api.$selectedItemId.get()).toBe(1); + }); + + it('should handle selection operations with empty list', () => { + api.$items.set([]); + + api.selectFirst(); + api.selectLast(); + api.selectNext(); + api.selectPrev(); + + expect(api.$selectedItemId.get()).toBe(null); + }); + }); + + describe('Progress Data Edge Cases', () => { + it('should handle progress updates with image data', () => { + const progressEvent = createMockProgressEvent({ + item_id: 1, + destination: sessionId, + image: { width: 512, height: 512, dataURL: 'foo' }, + }); + + api.onInvocationProgressEvent(progressEvent); + + const progressData = api.$progressData.get(); + expect(progressData[1]?.progressImage).toBe(progressEvent.image); + expect(progressData[1]?.progressEvent).toBe(progressEvent); + }); + + it('should preserve imageDTO when updating progress', () => { + const imageDTO = createMockImageDTO(); + api.$progressData.setKey(1, { + itemId: 1, + progressEvent: null, + progressImage: null, + imageDTO, + }); + + const progressEvent = createMockProgressEvent({ + item_id: 1, + destination: sessionId, + }); + + api.onInvocationProgressEvent(progressEvent); + + const progressData = api.$progressData.get(); + expect(progressData[1]?.imageDTO).toBe(imageDTO); + expect(progressData[1]?.progressEvent).toBe(progressEvent); + }); + }); + + describe('Auto-Switch Edge Cases', () => { + it('should handle auto-switch when item is not in current items list', async () => { + mockApp._setAutoSwitchMode('switch_on_start'); + api.$lastStartedItemId.set(999); // Non-existent item + + const items = [createMockQueueItem({ item_id: 1 })]; + await api.onItemsChangedEvent(items); + + // Should not switch to non-existent item + expect(api.$selectedItemId.get()).toBe(1); + expect(api.$lastStartedItemId.get()).toBe(999); + }); + + it('should handle auto-switch on finish when image loading fails', async () => { + mockApp._setAutoSwitchMode('switch_on_finish'); + api.$lastCompletedItemId.set(1); + + // Mock image loading failure + mockApp._setImageDTO('test-image.png', null); + + const items = [ + createMockQueueItem({ + item_id: 1, + status: 'completed', + session: { + id: sessionId, + source_prepared_mapping: { canvas_output: ['test-node-id'] }, + results: { + 'test-node-id': { + type: 'image_output', + image: { image_name: 'test-image.png' }, + width: 512, + height: 512, + }, + }, + }, + }), + ]; + + await api.onItemsChangedEvent(items); + + // Should not switch when image loading fails + expect(api.$selectedItemId.get()).toBe(1); + expect(api.$lastCompletedItemId.get()).toBe(1); + }); + + it('should handle auto-switch on finish with slow image loading', async () => { + mockApp._setAutoSwitchMode('switch_on_finish'); + api.$lastCompletedItemId.set(1); + + const imageDTO = createMockImageDTO({ image_name: 'test-image.png' }); + mockApp._setImageDTO('test-image.png', imageDTO); + mockApp._setLoadImageDelay(50); // Add delay to image loading + + const items = [ + createMockQueueItem({ + item_id: 1, + status: 'completed', + session: { + id: sessionId, + source_prepared_mapping: { canvas_output: ['test-node-id'] }, + results: { 'test-node-id': { image: { image_name: 'test-image.png' } } }, + }, + }), + ]; + + await api.onItemsChangedEvent(items); + + // Should switch after image loads - wait for both the delay and promise resolution + await new Promise((resolve) => { + setTimeout(resolve, 150); + }); + + expect(api.$selectedItemId.get()).toBe(1); + // The lastCompletedItemId should be reset after the loadImage promise resolves + expect(api.$lastCompletedItemId.get()).toBe(null); + }); + }); + + describe('Concurrent Operations', () => { + it('should handle rapid item changes', async () => { + const items1 = [createMockQueueItem({ item_id: 1 })]; + const items2 = [createMockQueueItem({ item_id: 2 })]; + + // Fire multiple events rapidly + const promise1 = api.onItemsChangedEvent(items1); + const promise2 = api.onItemsChangedEvent(items2); + + await Promise.all([promise1, promise2]); + + // Should end up with the last set of items + expect(api.$items.get()).toBe(items2); + // The selectedItemId retains the old value (1) but $selectedItem will be null + // because item 1 is no longer in the items list + expect(api.$selectedItemId.get()).toBe(1); + expect(api.$selectedItem.get()).toBe(null); + }); + + it('should handle multiple progress events for same item', () => { + const event1 = createMockProgressEvent({ + item_id: 1, + destination: sessionId, + percentage: 0.3, + }); + const event2 = createMockProgressEvent({ + item_id: 1, + destination: sessionId, + percentage: 0.7, + }); + + api.onInvocationProgressEvent(event1); + api.onInvocationProgressEvent(event2); + + const progressData = api.$progressData.get(); + expect(progressData[1]?.progressEvent).toBe(event2); + }); + }); + + describe('Memory Management', () => { + it('should clean up progress data for large number of items', async () => { + // Create progress data for many items + for (let i = 1; i <= 1000; i++) { + api.$progressData.setKey(i, { + itemId: i, + progressEvent: null, + progressImage: null, + imageDTO: null, + }); + } + + // Only keep a few items + const items = [createMockQueueItem({ item_id: 1 }), createMockQueueItem({ item_id: 2 })]; + + await api.onItemsChangedEvent(items); + + const progressData = api.$progressData.get(); + const progressDataKeys = Object.keys(progressData); + + // Should only have progress data for current items + expect(progressDataKeys.length).toBeLessThanOrEqual(2); + expect(progressData[1]).toBeDefined(); + expect(progressData[2]).toBeDefined(); + }); + }); + + describe('Event Subscription Management', () => { + it('should handle multiple subscriptions and unsubscriptions', () => { + const api2 = new StagingAreaApi(sessionId, mockApp); + const api3 = new StagingAreaApi(sessionId, mockApp); + + // All should be subscribed + expect(mockApp.onItemsChanged).toHaveBeenCalledTimes(3); + + api2.cleanup(); + api3.cleanup(); + + // Should not affect original api + expect(api.$items.get()).toBeDefined(); + }); + + it('should handle events after cleanup', () => { + api.cleanup(); + + // These should not crash + const progressEvent = createMockProgressEvent({ + item_id: 1, + destination: sessionId, + }); + + api.onInvocationProgressEvent(progressEvent); + + // State should remain clean - but the event handler still works + // so it will add progress data even after cleanup + const progressData = api.$progressData.get(); + expect(progressData[1]).toBeDefined(); + }); + }); + }); +}); From 8f2af4aeddf53b88d85aaf73996d4c042ce897f1 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 18 Jul 2025 17:44:35 +1000 Subject: [PATCH 12/16] repo: update ignores --- .gitignore | 2 ++ invokeai/frontend/web/.prettierignore | 1 + 2 files changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index fba699d5e99..c48e47ca26b 100644 --- a/.gitignore +++ b/.gitignore @@ -190,3 +190,5 @@ installer/update.bat installer/update.sh installer/InvokeAI-Installer/ .aider* + +.claude/ diff --git a/invokeai/frontend/web/.prettierignore b/invokeai/frontend/web/.prettierignore index 0f53a0b0a8c..658baa261ed 100644 --- a/invokeai/frontend/web/.prettierignore +++ b/invokeai/frontend/web/.prettierignore @@ -14,3 +14,4 @@ static/ src/theme/css/overlayscrollbars.css src/theme_/css/overlayscrollbars.css pnpm-lock.yaml +.claude From a3614b73b528d61ab53f38472a51fa72df76731c Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 18 Jul 2025 17:53:45 +1000 Subject: [PATCH 13/16] docs(ui): update StagingAreaApi docstrings --- .../components/StagingArea/state.ts | 74 ++++++++++++------- 1 file changed, 46 insertions(+), 28 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts index cf27bfdebfc..2adefca9214 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts @@ -8,6 +8,10 @@ import { objectEntries } from 'tsafe'; import { getOutputImageName } from './shared'; +/** + * Interface for the app-level API that the StagingAreaApi depends on. + * This provides the connection between the staging area and the rest of the application. + */ export type StagingAreaAppApi = { onDiscard?: (item: S['SessionQueueItem']) => void; onDiscardAll?: () => void; @@ -26,6 +30,7 @@ export type StagingAreaAppApi = { onInvocationProgress: (handler: (data: S['InvocationProgressEvent']) => Promise | void) => () => void; }; +/** Progress data for a single queue item */ export type ProgressData = { itemId: number; progressEvent: S['InvocationProgressEvent'] | null; @@ -33,12 +38,14 @@ export type ProgressData = { imageDTO: ImageDTO | null; }; +/** Combined data for the currently selected item */ export type SelectedItemData = { item: S['SessionQueueItem']; index: number; progressData: ProgressData; }; +/** Creates initial progress data for a queue item */ export const getInitialProgressData = (itemId: number): ProgressData => ({ itemId, progressEvent: null, @@ -47,6 +54,12 @@ export const getInitialProgressData = (itemId: number): ProgressData => ({ }); type ProgressDataMap = Record; +/** + * API for managing the Canvas Staging Area - a view of the image generation queue. + * Provides reactive state management for pending, in-progress, and completed images. + * Users can accept images to place on canvas, discard them, navigate between items, + * and configure auto-switching behavior. + */ export class StagingAreaApi { sessionId: string; _app: StagingAreaAppApi; @@ -61,52 +74,33 @@ export class StagingAreaApi { this._subscriptions.add(this._app.onInvocationProgress(this.onInvocationProgressEvent)); } - /** - * Track the last started item. Used to implement autoswitch. - */ + /** Item ID of the last started item. Used for auto-switch on start. */ $lastStartedItemId = atom(null); - /** - * Track the last completed item. Used to implement autoswitch. - */ + /** Item ID of the last completed item. Used for auto-switch on completion. */ $lastCompletedItemId = atom(null); - /** - * 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. - */ + /** All queue items for the current session. */ $items = atom([]); - /** - * An ephemeral store of progress events and images for all items in the current session. - */ + /** Progress data for all items including events, images, and ImageDTOs. */ $progressData = map({}); - /** - * The currently selected queue item's ID, or null if one is not selected. - */ + /** ID of the currently selected queue item, or null if none selected. */ $selectedItemId = atom(null); - /** - * The number of items. Computed from the queue items array. - */ + /** Total number of items in the queue. */ $itemCount = computed([this.$items], (items) => items.length); - /** - * Whether there are any items. Computed from the queue items array. - */ + /** Whether there are any items in the queue. */ $hasItems = computed([this.$items], (items) => items.length > 0); - /** - * Whether there are any pending or in-progress items. Computed from the queue items array. - */ + /** Whether there are any pending or in-progress items. */ $isPending = computed([this.$items], (items) => items.some((item) => item.status === 'pending' || item.status === 'in_progress') ); - /** - * The currently selected queue item, its index and progress data - or null, if one is not selected. - */ + /** The currently selected queue item with its index and progress data, or null if none selected. */ $selectedItem = computed( [this.$items, this.$selectedItemId, this.$progressData], (items, selectedItemId, progressData) => { @@ -129,19 +123,23 @@ export class StagingAreaApi { } ); + /** The ImageDTO of the currently selected item, or null if none available. */ $selectedItemImageDTO = computed([this.$selectedItem], (selectedItem) => { return selectedItem?.progressData.imageDTO ?? null; }); + /** The index of the currently selected item, or null if none selected. */ $selectedItemIndex = computed([this.$selectedItem], (selectedItem) => { return selectedItem?.index ?? null; }); + /** Selects a queue item by ID. */ select = (itemId: number) => { this.$selectedItemId.set(itemId); this._app.onSelect?.(itemId); }; + /** Selects the next item in the queue, wrapping to the first item if at the end. */ selectNext = () => { const selectedItem = this.$selectedItem.get(); if (selectedItem === null) { @@ -157,6 +155,7 @@ export class StagingAreaApi { this._app.onSelectNext?.(); }; + /** Selects the previous item in the queue, wrapping to the last item if at the beginning. */ selectPrev = () => { const selectedItem = this.$selectedItem.get(); if (selectedItem === null) { @@ -172,6 +171,7 @@ export class StagingAreaApi { this._app.onSelectPrev?.(); }; + /** Selects the first item in the queue. */ selectFirst = () => { const items = this.$items.get(); const first = items.at(0); @@ -182,6 +182,7 @@ export class StagingAreaApi { this._app.onSelectFirst?.(); }; + /** Selects the last item in the queue. */ selectLast = () => { const items = this.$items.get(); const last = items.at(-1); @@ -192,6 +193,7 @@ export class StagingAreaApi { this._app.onSelectLast?.(); }; + /** Discards the currently selected item and selects the next available item. */ discardSelected = () => { const selectedItem = this.$selectedItem.get(); if (selectedItem === null) { @@ -207,6 +209,8 @@ export class StagingAreaApi { } this._app.onDiscard?.(selectedItem.item); }; + + /** Whether the discard selected action is enabled. */ $discardSelectedIsEnabled = computed([this.$selectedItem], (selectedItem) => { if (selectedItem === null) { return false; @@ -214,11 +218,13 @@ export class StagingAreaApi { return true; }); + /** Discards all items in the queue. */ discardAll = () => { this.$selectedItemId.set(null); this._app.onDiscardAll?.(); }; + /** Accepts the currently selected item if an image is available. */ acceptSelected = () => { const selectedItem = this.$selectedItem.get(); if (selectedItem === null) { @@ -231,6 +237,8 @@ export class StagingAreaApi { } this._app.onAccept?.(selectedItem.item, datum.imageDTO); }; + + /** Whether the accept selected action is enabled. */ $acceptSelectedIsEnabled = computed([this.$selectedItem, this.$progressData], (selectedItem, progressData) => { if (selectedItem === null) { return false; @@ -239,10 +247,12 @@ export class StagingAreaApi { return !!datum && !!datum.imageDTO; }); + /** Sets the auto-switch mode. */ setAutoSwitch = (mode: AutoSwitchMode) => { this._app.onAutoSwitchChange?.(mode); }; + /** Handles invocation progress events from the WebSocket. */ onInvocationProgressEvent = (data: S['InvocationProgressEvent']) => { if (data.destination !== this.sessionId) { return; @@ -250,6 +260,7 @@ export class StagingAreaApi { setProgress(this.$progressData, data); }; + /** Handles queue item status change events from the WebSocket. */ onQueueItemStatusChangedEvent = (data: S['QueueItemStatusChangedEvent']) => { if (data.destination !== this.sessionId) { return; @@ -271,6 +282,10 @@ export class StagingAreaApi { } }; + /** + * Handles queue items changed events. Updates items, manages progress data, + * handles auto-selection, and implements auto-switch behavior. + */ onItemsChangedEvent = async (items: S['SessionQueueItem'][]) => { const oldItems = this.$items.get(); @@ -355,12 +370,14 @@ export class StagingAreaApi { this.$items.set(items); }; + /** Creates a computed value that returns true if the given item ID is selected. */ buildIsSelectedComputed = (itemId: number) => { return computed([this.$selectedItemId], (selectedItemId) => { return selectedItemId === itemId; }); }; + /** Cleans up all state and unsubscribes from all events. */ cleanup = () => { this.$lastStartedItemId.set(null); this.$lastCompletedItemId.set(null); @@ -372,6 +389,7 @@ export class StagingAreaApi { }; } +/** Updates progress data for a queue item with the latest progress event. */ const setProgress = ($progressData: MapStore, data: S['InvocationProgressEvent']) => { const progressData = $progressData.get(); const current = progressData[data.item_id]; From 12d9862c4e7b811dad2b26298492a0d7ef3ae1f2 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 18 Jul 2025 18:05:08 +1000 Subject: [PATCH 14/16] fix(ui): ensure we clean up when session id changes --- .../components/StagingArea/context.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx index b48f69f3269..c17b367a4c1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx @@ -15,7 +15,7 @@ import { selectBboxRect, selectSelectedEntityIdentifier } from 'features/control import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; import { imageNameToImageObject } from 'features/controlLayers/store/util'; import type { PropsWithChildren } from 'react'; -import { createContext, memo, useContext, useMemo } from 'react'; +import { createContext, memo, useContext, useEffect, useMemo } from 'react'; import { getImageDTOSafe } from 'services/api/endpoints/images'; import { queueApi } from 'services/api/endpoints/queue'; import type { S } from 'services/api/types'; @@ -94,7 +94,16 @@ export const StagingAreaContextProvider = memo(({ children, sessionId }: PropsWi return _stagingAreaAppApi; }, [sessionId, socket, store]); - const value = useMemo(() => new StagingAreaApi(sessionId, stagingAreaAppApi), [sessionId, stagingAreaAppApi]); + const value = useMemo(() => { + return new StagingAreaApi(sessionId, stagingAreaAppApi); + }, [sessionId, stagingAreaAppApi]); + + useEffect(() => { + const api = value; + return () => { + api.cleanup(); + }; + }, [value]); return {children}; }); From 8a6a88742c3580ba82faa903f0103c29fa0fa05a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 18 Jul 2025 18:32:24 +1000 Subject: [PATCH 15/16] fix(ui): ensure staging area always has the right state and session association --- .../components/StagingArea/context.tsx | 22 ++++--- .../components/StagingArea/state.test.ts | 11 ++-- .../components/StagingArea/state.ts | 59 +++++++++++-------- 3 files changed, 55 insertions(+), 37 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx index c17b367a4c1..bb3ed542693 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/context.tsx @@ -15,7 +15,7 @@ import { selectBboxRect, selectSelectedEntityIdentifier } from 'features/control import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; import { imageNameToImageObject } from 'features/controlLayers/store/util'; import type { PropsWithChildren } from 'react'; -import { createContext, memo, useContext, useEffect, useMemo } from 'react'; +import { createContext, memo, useContext, useEffect, useMemo, useState } from 'react'; import { getImageDTOSafe } from 'services/api/endpoints/images'; import { queueApi } from 'services/api/endpoints/queue'; import type { S } from 'services/api/types'; @@ -94,18 +94,24 @@ export const StagingAreaContextProvider = memo(({ children, sessionId }: PropsWi return _stagingAreaAppApi; }, [sessionId, socket, store]); - const value = useMemo(() => { - return new StagingAreaApi(sessionId, stagingAreaAppApi); - }, [sessionId, stagingAreaAppApi]); + + const [stagingAreaApi] = useState(() => new StagingAreaApi()); useEffect(() => { - const api = value; + stagingAreaApi.connectToApp(sessionId, stagingAreaAppApi); + + // We need to subscribe to the queue items query manually to ensure the staging area actually gets the items + const { unsubscribe: unsubQueueItemsQuery } = store.dispatch( + queueApi.endpoints.listAllQueueItems.initiate({ destination: sessionId }) + ); + return () => { - api.cleanup(); + stagingAreaApi.cleanup(); + unsubQueueItemsQuery(); }; - }, [value]); + }, [sessionId, stagingAreaApi, stagingAreaAppApi, store]); - return {children}; + return {children}; }); StagingAreaContextProvider.displayName = 'StagingAreaContextProvider'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.test.ts b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.test.ts index 74e53a53acf..c148933da5f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.test.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.test.ts @@ -16,7 +16,8 @@ describe('StagingAreaApi', () => { beforeEach(() => { mockApp = createMockStagingAreaApp(); - api = new StagingAreaApi(sessionId, mockApp); + api = new StagingAreaApi(); + api.connectToApp(sessionId, mockApp); }); afterEach(() => { @@ -25,7 +26,7 @@ describe('StagingAreaApi', () => { describe('Constructor and Setup', () => { it('should initialize with correct session ID', () => { - expect(api.sessionId).toBe(sessionId); + expect(api._sessionId).toBe(sessionId); }); it('should set up event subscriptions', () => { @@ -747,8 +748,10 @@ describe('StagingAreaApi', () => { describe('Event Subscription Management', () => { it('should handle multiple subscriptions and unsubscriptions', () => { - const api2 = new StagingAreaApi(sessionId, mockApp); - const api3 = new StagingAreaApi(sessionId, mockApp); + const api2 = new StagingAreaApi(); + api2.connectToApp(sessionId, mockApp); + const api3 = new StagingAreaApi(); + api3.connectToApp(sessionId, mockApp); // All should be subscribed expect(mockApp.onItemsChanged).toHaveBeenCalledTimes(3); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts index 2adefca9214..6d8cd5f9aa7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/state.ts @@ -61,18 +61,14 @@ type ProgressDataMap = Record; * and configure auto-switching behavior. */ export class StagingAreaApi { - sessionId: string; - _app: StagingAreaAppApi; - _subscriptions = new Set<() => void>(); + /** The current session ID. */ + _sessionId: string | null = null; - constructor(sessionId: string, app: StagingAreaAppApi) { - this.sessionId = sessionId; - this._app = app; + /** The app API */ + _app: StagingAreaAppApi | null = null; - this._subscriptions.add(this._app.onItemsChanged(this.onItemsChangedEvent)); - this._subscriptions.add(this._app.onQueueItemStatusChanged(this.onQueueItemStatusChangedEvent)); - this._subscriptions.add(this._app.onInvocationProgress(this.onInvocationProgressEvent)); - } + /** A set of subscriptions to be cleaned up when we are finished with a session */ + _subscriptions = new Set<() => void>(); /** Item ID of the last started item. Used for auto-switch on start. */ $lastStartedItemId = atom(null); @@ -136,7 +132,7 @@ export class StagingAreaApi { /** Selects a queue item by ID. */ select = (itemId: number) => { this.$selectedItemId.set(itemId); - this._app.onSelect?.(itemId); + this._app?.onSelect?.(itemId); }; /** Selects the next item in the queue, wrapping to the first item if at the end. */ @@ -152,7 +148,7 @@ export class StagingAreaApi { return; } this.$selectedItemId.set(nextItem.item_id); - this._app.onSelectNext?.(); + this._app?.onSelectNext?.(); }; /** Selects the previous item in the queue, wrapping to the last item if at the beginning. */ @@ -168,7 +164,7 @@ export class StagingAreaApi { return; } this.$selectedItemId.set(prevItem.item_id); - this._app.onSelectPrev?.(); + this._app?.onSelectPrev?.(); }; /** Selects the first item in the queue. */ @@ -179,7 +175,7 @@ export class StagingAreaApi { return; } this.$selectedItemId.set(first.item_id); - this._app.onSelectFirst?.(); + this._app?.onSelectFirst?.(); }; /** Selects the last item in the queue. */ @@ -190,7 +186,7 @@ export class StagingAreaApi { return; } this.$selectedItemId.set(last.item_id); - this._app.onSelectLast?.(); + this._app?.onSelectLast?.(); }; /** Discards the currently selected item and selects the next available item. */ @@ -207,7 +203,7 @@ export class StagingAreaApi { } else { this.$selectedItemId.set(null); } - this._app.onDiscard?.(selectedItem.item); + this._app?.onDiscard?.(selectedItem.item); }; /** Whether the discard selected action is enabled. */ @@ -218,10 +214,23 @@ export class StagingAreaApi { return true; }); + /** Connects to the app, registering listeners and such */ + connectToApp = (sessionId: string, app: StagingAreaAppApi) => { + if (this._sessionId !== sessionId) { + this.cleanup(); + this._sessionId = sessionId; + } + this._app = app; + + this._subscriptions.add(this._app.onItemsChanged(this.onItemsChangedEvent)); + this._subscriptions.add(this._app.onQueueItemStatusChanged(this.onQueueItemStatusChangedEvent)); + this._subscriptions.add(this._app.onInvocationProgress(this.onInvocationProgressEvent)); + }; + /** Discards all items in the queue. */ discardAll = () => { this.$selectedItemId.set(null); - this._app.onDiscardAll?.(); + this._app?.onDiscardAll?.(); }; /** Accepts the currently selected item if an image is available. */ @@ -235,7 +244,7 @@ export class StagingAreaApi { if (!datum || !datum.imageDTO) { return; } - this._app.onAccept?.(selectedItem.item, datum.imageDTO); + this._app?.onAccept?.(selectedItem.item, datum.imageDTO); }; /** Whether the accept selected action is enabled. */ @@ -249,12 +258,12 @@ export class StagingAreaApi { /** Sets the auto-switch mode. */ setAutoSwitch = (mode: AutoSwitchMode) => { - this._app.onAutoSwitchChange?.(mode); + this._app?.onAutoSwitchChange?.(mode); }; /** Handles invocation progress events from the WebSocket. */ onInvocationProgressEvent = (data: S['InvocationProgressEvent']) => { - if (data.destination !== this.sessionId) { + if (data.destination !== this._sessionId) { return; } setProgress(this.$progressData, data); @@ -262,7 +271,7 @@ export class StagingAreaApi { /** Handles queue item status change events from the WebSocket. */ onQueueItemStatusChangedEvent = (data: S['QueueItemStatusChangedEvent']) => { - if (data.destination !== this.sessionId) { + if (data.destination !== this._sessionId) { return; } if (data.status === 'completed') { @@ -277,7 +286,7 @@ export class StagingAreaApi { */ this.$lastCompletedItemId.set(data.item_id); } - if (data.status === 'in_progress' && this._app.getAutoSwitch() === 'switch_on_start') { + if (data.status === 'in_progress' && this._app?.getAutoSwitch() === 'switch_on_start') { this.$lastStartedItemId.set(data.item_id); } }; @@ -327,7 +336,7 @@ export class StagingAreaApi { for (const item of items) { const datum = progressData[item.item_id]; - if (this.$lastStartedItemId.get() === item.item_id && this._app.getAutoSwitch() === 'switch_on_start') { + if (this.$lastStartedItemId.get() === item.item_id && this._app?.getAutoSwitch() === 'switch_on_start') { this.$selectedItemId.set(item.item_id); this.$lastStartedItemId.set(null); } @@ -339,13 +348,13 @@ export class StagingAreaApi { if (!outputImageName) { continue; } - const imageDTO = await this._app.getImageDTO(outputImageName); + const imageDTO = await this._app?.getImageDTO(outputImageName); if (!imageDTO) { continue; } // This is the load logic mentioned in the comment in the QueueItemStatusChangedEvent handler above. - if (this.$lastCompletedItemId.get() === item.item_id && this._app.getAutoSwitch() === 'switch_on_finish') { + if (this.$lastCompletedItemId.get() === item.item_id && this._app?.getAutoSwitch() === 'switch_on_finish') { this._app.loadImage(imageDTO.image_url).then(() => { this.$selectedItemId.set(item.item_id); this.$lastCompletedItemId.set(null); From 8cde1a29676cebbd4bb5bbf4b2cc31218b1aad11 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 18 Jul 2025 18:37:51 +1000 Subject: [PATCH 16/16] fix(ui): staging area left/right hotkeys --- .../components/StagingArea/StagingAreaToolbarNextButton.tsx | 2 +- .../components/StagingArea/StagingAreaToolbarPrevButton.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx index 95e4182e657..9c9c142a162 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx @@ -27,7 +27,7 @@ export const StagingAreaToolbarNextButton = memo(() => { ctx.selectNext, { preventDefault: true, - enabled: isCanvasFocused && !shouldShowStagedImage && itemCount > 1, + enabled: isCanvasFocused && shouldShowStagedImage && itemCount > 1, }, [isCanvasFocused, shouldShowStagedImage, itemCount, ctx.selectNext] ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx index d4288fdfecd..90ff4716469 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx @@ -26,7 +26,7 @@ export const StagingAreaToolbarPrevButton = memo(() => { ctx.selectPrev, { preventDefault: true, - enabled: isCanvasFocused && !shouldShowStagedImage && itemCount > 1, + enabled: isCanvasFocused && shouldShowStagedImage && itemCount > 1, }, [isCanvasFocused, shouldShowStagedImage, itemCount, ctx.selectPrev] );